Skip to content

Commit d0db53e

Browse files
Wire upload endpoint to accept reference files (#52)
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.
1 parent 029cad0 commit d0db53e

2 files changed

Lines changed: 163 additions & 2 deletions

File tree

src/SkillServer/Endpoints.cs

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@ private static async Task<IResult> UploadSkill(
196196
[FromForm] string name,
197197
[FromForm] string version,
198198
[FromForm] string? category,
199+
HttpRequest request,
199200
SkillUploadService uploadService,
200201
IConfiguration configuration,
201202
CancellationToken ct)
@@ -227,9 +228,48 @@ private static async Task<IResult> UploadSkill(
227228
});
228229
}
229230

230-
await using var stream = file.OpenReadStream();
231-
var result = await uploadService.UploadSkillMdAsync(skillName.Value, skillVersion.Value, stream, category, ct);
231+
var referenceFiles = request.Form.Files.GetFiles("references");
232+
var hasReferences = referenceFiles.Count > 0;
232233

234+
if (hasReferences)
235+
{
236+
var resources = new List<(ResourcePath Path, Stream Content)>();
237+
foreach (var refFile in referenceFiles)
238+
{
239+
var relativePath = $"references/{refFile.FileName}";
240+
if (!ResourcePath.TryCreate(relativePath, out var resourcePath))
241+
{
242+
return Results.BadRequest(new ErrorResponse
243+
{
244+
Error = "invalid_resource_path",
245+
Message = $"Invalid resource path: '{relativePath}'. Must be in references/, scripts/, or assets/ directories."
246+
});
247+
}
248+
249+
resources.Add((resourcePath.Value, refFile.OpenReadStream()));
250+
}
251+
252+
await using var stream = file.OpenReadStream();
253+
var result = await uploadService.UploadSkillWithResourcesAsync(
254+
skillName.Value, skillVersion.Value, stream, resources, category, ct);
255+
256+
foreach (var (_, content) in resources)
257+
await content.DisposeAsync();
258+
259+
return HandleUploadResult(result, configuration);
260+
}
261+
else
262+
{
263+
await using var stream = file.OpenReadStream();
264+
var result = await uploadService.UploadSkillMdAsync(
265+
skillName.Value, skillVersion.Value, stream, category, ct);
266+
267+
return HandleUploadResult(result, configuration);
268+
}
269+
}
270+
271+
private static IResult HandleUploadResult(SkillUploadResult result, IConfiguration configuration)
272+
{
233273
if (!result.Success)
234274
{
235275
if (result.IsDuplicateVersion)

tests/SkillServer.Integration.Tests/SkillServerIntegrationTests.cs

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -606,4 +606,125 @@ public async Task SearchSkills_WithPorterStemming_MatchesStemmedTerms()
606606
results = await _fixture.Client.SearchSkillsAsync("closing", ct: ct);
607607
Assert.Contains(results, s => s.Name == skillName);
608608
}
609+
610+
[Fact]
611+
public async Task UploadSkillWithReferences_EndToEnd()
612+
{
613+
var ct = TestContext.Current.CancellationToken;
614+
var skillName = $"ref-test-{Guid.NewGuid():N}"[..20];
615+
616+
var skillContent = $"""
617+
---
618+
name: {skillName}
619+
description: Testing reference file uploads
620+
---
621+
622+
# Reference Upload Test
623+
624+
See references/guide.md for details.
625+
""";
626+
627+
var referenceContent = """
628+
# Guide
629+
630+
This is a reference document with detailed examples.
631+
632+
## Section One
633+
First section content.
634+
635+
## Section Two
636+
Second section content.
637+
""";
638+
639+
var referenceContent2 = """
640+
# Patterns
641+
642+
Common usage patterns for this skill.
643+
""";
644+
645+
// Upload skill with two reference files
646+
using var content = new MultipartFormDataContent();
647+
content.Add(new StringContent(skillName), "name");
648+
content.Add(new StringContent("1.0.0"), "version");
649+
650+
var fileContent = new ByteArrayContent(Encoding.UTF8.GetBytes(skillContent));
651+
fileContent.Headers.ContentType = new MediaTypeHeaderValue("text/markdown");
652+
content.Add(fileContent, "file", "SKILL.md");
653+
654+
var refFileContent = new ByteArrayContent(Encoding.UTF8.GetBytes(referenceContent));
655+
refFileContent.Headers.ContentType = new MediaTypeHeaderValue("text/markdown");
656+
content.Add(refFileContent, "references", "guide.md");
657+
658+
var refFileContent2 = new ByteArrayContent(Encoding.UTF8.GetBytes(referenceContent2));
659+
refFileContent2.Headers.ContentType = new MediaTypeHeaderValue("text/markdown");
660+
content.Add(refFileContent2, "references", "patterns.md");
661+
662+
var uploadResponse = await _fixture.AuthenticatedHttpClient.PostAsync("/skills", content, ct);
663+
Assert.Equal(HttpStatusCode.Created, uploadResponse.StatusCode);
664+
665+
// Verify the version shows file count (SKILL.md + 2 references)
666+
var version = await _fixture.Client.GetVersionAsync(skillName, "1.0.0", ct);
667+
Assert.NotNull(version);
668+
Assert.Equal(2, version.FileCount);
669+
670+
// Download SKILL.md
671+
var downloadedSkill = await _fixture.Client.GetSkillFileAsStringAsync(skillName, "1.0.0", ct: ct);
672+
Assert.Contains("# Reference Upload Test", downloadedSkill);
673+
674+
// Download reference files via resource endpoint
675+
var refResponse = await _fixture.HttpClient.GetAsync(
676+
$"/skills/{skillName}/1.0.0/references/guide.md", ct);
677+
Assert.Equal(HttpStatusCode.OK, refResponse.StatusCode);
678+
var refBody = await refResponse.Content.ReadAsStringAsync(ct);
679+
Assert.Contains("# Guide", refBody);
680+
Assert.Contains("Section One", refBody);
681+
682+
var refResponse2 = await _fixture.HttpClient.GetAsync(
683+
$"/skills/{skillName}/1.0.0/references/patterns.md", ct);
684+
Assert.Equal(HttpStatusCode.OK, refResponse2.StatusCode);
685+
var refBody2 = await refResponse2.Content.ReadAsStringAsync(ct);
686+
Assert.Contains("# Patterns", refBody2);
687+
688+
// Verify resources appear in RFC index
689+
var index = await _fixture.Client.GetRfcIndexAsync(ct);
690+
Assert.NotNull(index);
691+
var indexSkill = index.Skills.FirstOrDefault(s => s.Name == skillName);
692+
Assert.NotNull(indexSkill);
693+
var resources = indexSkill!.Resources;
694+
Assert.NotNull(resources);
695+
Assert.Equal(2, resources!.Count);
696+
Assert.Contains(resources, r => r.Path == "references/guide.md");
697+
Assert.Contains(resources, r => r.Path == "references/patterns.md");
698+
}
699+
700+
[Fact]
701+
public async Task UploadSkillWithReferences_WithoutReferences_StillWorks()
702+
{
703+
var ct = TestContext.Current.CancellationToken;
704+
var skillName = $"noref-{Guid.NewGuid():N}"[..20];
705+
706+
var skillContent = $"""
707+
---
708+
name: {skillName}
709+
description: Testing upload without references still works
710+
---
711+
712+
# No References Test
713+
""";
714+
715+
using var content = new MultipartFormDataContent();
716+
content.Add(new StringContent(skillName), "name");
717+
content.Add(new StringContent("1.0.0"), "version");
718+
719+
var fileContent = new ByteArrayContent(Encoding.UTF8.GetBytes(skillContent));
720+
fileContent.Headers.ContentType = new MediaTypeHeaderValue("text/markdown");
721+
content.Add(fileContent, "file", "SKILL.md");
722+
723+
var uploadResponse = await _fixture.AuthenticatedHttpClient.PostAsync("/skills", content, ct);
724+
Assert.Equal(HttpStatusCode.Created, uploadResponse.StatusCode);
725+
726+
var version = await _fixture.Client.GetVersionAsync(skillName, "1.0.0", ct);
727+
Assert.NotNull(version);
728+
Assert.Equal(0, version.FileCount);
729+
}
609730
}

0 commit comments

Comments
 (0)