From 88125587284af2962c0863e81bcebb2fd74643e0 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Wed, 29 Apr 2026 02:42:15 +0000 Subject: [PATCH] Wire upload endpoint to accept reference files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The service layer, database schema, blob storage, and RFC index already supported multi-file skills — only the HTTP endpoint was missing the wiring. Accept optional `references` form fields in POST /skills, validate paths via ResourcePath, and route through the existing UploadSkillWithResourcesAsync method. Backward compatible — uploads without references continue to work unchanged. --- src/SkillServer/Endpoints.cs | 44 ++++++- .../SkillServerIntegrationTests.cs | 121 ++++++++++++++++++ 2 files changed, 163 insertions(+), 2 deletions(-) diff --git a/src/SkillServer/Endpoints.cs b/src/SkillServer/Endpoints.cs index b7f5955..7c40021 100644 --- a/src/SkillServer/Endpoints.cs +++ b/src/SkillServer/Endpoints.cs @@ -196,6 +196,7 @@ private static async Task UploadSkill( [FromForm] string name, [FromForm] string version, [FromForm] string? category, + HttpRequest request, SkillUploadService uploadService, IConfiguration configuration, CancellationToken ct) @@ -227,9 +228,48 @@ private static async Task UploadSkill( }); } - await using var stream = file.OpenReadStream(); - var result = await uploadService.UploadSkillMdAsync(skillName.Value, skillVersion.Value, stream, category, ct); + var referenceFiles = request.Form.Files.GetFiles("references"); + var hasReferences = referenceFiles.Count > 0; + if (hasReferences) + { + var resources = new List<(ResourcePath Path, Stream Content)>(); + foreach (var refFile in referenceFiles) + { + var relativePath = $"references/{refFile.FileName}"; + if (!ResourcePath.TryCreate(relativePath, out var resourcePath)) + { + return Results.BadRequest(new ErrorResponse + { + Error = "invalid_resource_path", + Message = $"Invalid resource path: '{relativePath}'. Must be in references/, scripts/, or assets/ directories." + }); + } + + resources.Add((resourcePath.Value, refFile.OpenReadStream())); + } + + await using var stream = file.OpenReadStream(); + var result = await uploadService.UploadSkillWithResourcesAsync( + skillName.Value, skillVersion.Value, stream, resources, category, ct); + + foreach (var (_, content) in resources) + await content.DisposeAsync(); + + return HandleUploadResult(result, configuration); + } + else + { + await using var stream = file.OpenReadStream(); + var result = await uploadService.UploadSkillMdAsync( + skillName.Value, skillVersion.Value, stream, category, ct); + + return HandleUploadResult(result, configuration); + } + } + + private static IResult HandleUploadResult(SkillUploadResult result, IConfiguration configuration) + { if (!result.Success) { if (result.IsDuplicateVersion) diff --git a/tests/SkillServer.Integration.Tests/SkillServerIntegrationTests.cs b/tests/SkillServer.Integration.Tests/SkillServerIntegrationTests.cs index 6524e2b..5a0cd78 100644 --- a/tests/SkillServer.Integration.Tests/SkillServerIntegrationTests.cs +++ b/tests/SkillServer.Integration.Tests/SkillServerIntegrationTests.cs @@ -606,4 +606,125 @@ public async Task SearchSkills_WithPorterStemming_MatchesStemmedTerms() results = await _fixture.Client.SearchSkillsAsync("closing", ct: ct); Assert.Contains(results, s => s.Name == skillName); } + + [Fact] + public async Task UploadSkillWithReferences_EndToEnd() + { + var ct = TestContext.Current.CancellationToken; + var skillName = $"ref-test-{Guid.NewGuid():N}"[..20]; + + var skillContent = $""" + --- + name: {skillName} + description: Testing reference file uploads + --- + + # Reference Upload Test + + See references/guide.md for details. + """; + + var referenceContent = """ + # Guide + + This is a reference document with detailed examples. + + ## Section One + First section content. + + ## Section Two + Second section content. + """; + + var referenceContent2 = """ + # Patterns + + Common usage patterns for this skill. + """; + + // Upload skill with two reference files + using var content = new MultipartFormDataContent(); + content.Add(new StringContent(skillName), "name"); + content.Add(new StringContent("1.0.0"), "version"); + + var fileContent = new ByteArrayContent(Encoding.UTF8.GetBytes(skillContent)); + fileContent.Headers.ContentType = new MediaTypeHeaderValue("text/markdown"); + content.Add(fileContent, "file", "SKILL.md"); + + var refFileContent = new ByteArrayContent(Encoding.UTF8.GetBytes(referenceContent)); + refFileContent.Headers.ContentType = new MediaTypeHeaderValue("text/markdown"); + content.Add(refFileContent, "references", "guide.md"); + + var refFileContent2 = new ByteArrayContent(Encoding.UTF8.GetBytes(referenceContent2)); + refFileContent2.Headers.ContentType = new MediaTypeHeaderValue("text/markdown"); + content.Add(refFileContent2, "references", "patterns.md"); + + var uploadResponse = await _fixture.AuthenticatedHttpClient.PostAsync("/skills", content, ct); + Assert.Equal(HttpStatusCode.Created, uploadResponse.StatusCode); + + // Verify the version shows file count (SKILL.md + 2 references) + var version = await _fixture.Client.GetVersionAsync(skillName, "1.0.0", ct); + Assert.NotNull(version); + Assert.Equal(2, version.FileCount); + + // Download SKILL.md + var downloadedSkill = await _fixture.Client.GetSkillFileAsStringAsync(skillName, "1.0.0", ct: ct); + Assert.Contains("# Reference Upload Test", downloadedSkill); + + // Download reference files via resource endpoint + var refResponse = await _fixture.HttpClient.GetAsync( + $"/skills/{skillName}/1.0.0/references/guide.md", ct); + Assert.Equal(HttpStatusCode.OK, refResponse.StatusCode); + var refBody = await refResponse.Content.ReadAsStringAsync(ct); + Assert.Contains("# Guide", refBody); + Assert.Contains("Section One", refBody); + + var refResponse2 = await _fixture.HttpClient.GetAsync( + $"/skills/{skillName}/1.0.0/references/patterns.md", ct); + Assert.Equal(HttpStatusCode.OK, refResponse2.StatusCode); + var refBody2 = await refResponse2.Content.ReadAsStringAsync(ct); + Assert.Contains("# Patterns", refBody2); + + // Verify resources appear in RFC index + var index = await _fixture.Client.GetRfcIndexAsync(ct); + Assert.NotNull(index); + var indexSkill = index.Skills.FirstOrDefault(s => s.Name == skillName); + Assert.NotNull(indexSkill); + var resources = indexSkill!.Resources; + Assert.NotNull(resources); + Assert.Equal(2, resources!.Count); + Assert.Contains(resources, r => r.Path == "references/guide.md"); + Assert.Contains(resources, r => r.Path == "references/patterns.md"); + } + + [Fact] + public async Task UploadSkillWithReferences_WithoutReferences_StillWorks() + { + var ct = TestContext.Current.CancellationToken; + var skillName = $"noref-{Guid.NewGuid():N}"[..20]; + + var skillContent = $""" + --- + name: {skillName} + description: Testing upload without references still works + --- + + # No References Test + """; + + using var content = new MultipartFormDataContent(); + content.Add(new StringContent(skillName), "name"); + content.Add(new StringContent("1.0.0"), "version"); + + var fileContent = new ByteArrayContent(Encoding.UTF8.GetBytes(skillContent)); + fileContent.Headers.ContentType = new MediaTypeHeaderValue("text/markdown"); + content.Add(fileContent, "file", "SKILL.md"); + + var uploadResponse = await _fixture.AuthenticatedHttpClient.PostAsync("/skills", content, ct); + Assert.Equal(HttpStatusCode.Created, uploadResponse.StatusCode); + + var version = await _fixture.Client.GetVersionAsync(skillName, "1.0.0", ct); + Assert.NotNull(version); + Assert.Equal(0, version.FileCount); + } }