Skip to content

Commit 68fc1bf

Browse files
committed
feat: Document the API in OpenAPI, unify problem responses
The hand-written firmware-api-spec.md is gone; the generated Scalar document is now the contract. Controllers carry XML docs and ProducesResponseType attributes for every status they can return, with the error codes behind each one noted inline. Authorization failures on the versioned API surface now answer with a problem body naming the unmet requirements instead of a bare 403. Browser requests to the admin UI still fall through to the redirect. JSON settings live in one place, so problem bodies written from middleware are shaped like the ones written from controllers. Errors and JSON serialization each move to their own namespace.
1 parent 3b8665e commit 68fc1bf

35 files changed

Lines changed: 332 additions & 1321 deletions

‎README.md‎

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,9 @@ secrets.
2929

3030
### API Documentation
3131

32-
The Scalar API reference is served at `/scalar` on any running instance.
33-
34-
`firmware-api-spec.md` in this repository is the canonical contract for the firmware endpoints:
35-
response shapes, caching rules, error codes and the release workflow. Consumers should conform to it.
32+
The Scalar API reference is served at `/scalar` on any running instance. It is generated from the
33+
controllers, one document per API version, and covers response shapes, caching rules and the problem
34+
details returned on error.
3635

3736
# Configuration
3837

@@ -239,8 +238,6 @@ the build fails. With `?nofail` the release is created in `editing` status inste
239238
published until a maintainer fixes the notes through the admin endpoint. Releases that outlive their
240239
TTL are aborted by a background job that runs every five minutes.
241240
242-
See sections 5.2 and 5.3 of `firmware-api-spec.md` for request bodies and the changelog format.
243-
244241
# Deployment / Self Hosting
245242
246243
The stack is small:

‎RepositoryServer/ApiSurface.cs‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
namespace OpenShock.RepositoryServer;
2+
3+
/// <summary>
4+
/// Tells the versioned API surface apart from the browser-facing admin UI.
5+
/// </summary>
6+
/// <remarks>
7+
/// The two want different answers to the same authentication or authorization failure: an API caller
8+
/// wants a status code and a problem body, a browser wants to be sent to the login page. Deciding on
9+
/// path rather than sniffing Accept headers keeps the behaviour identical for curl, fetch and the
10+
/// admin UI.
11+
/// </remarks>
12+
public static class ApiSurface
13+
{
14+
/// <summary>
15+
/// True for anything under the versioned API prefixes; everything else is assumed to be a browser
16+
/// that can usefully follow a redirect.
17+
/// </summary>
18+
public static bool IsApiPath(PathString path) =>
19+
path.StartsWithSegments("/1") || path.StartsWithSegments("/2");
20+
}

‎RepositoryServer/AuthenticationHandlers/GitHubAuthentication.cs‎

Lines changed: 3 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,15 @@
11
using System.Net;
22
using System.Net.Http.Headers;
3-
using System.Net.Mime;
43
using System.Security.Claims;
54
using System.Text.Json;
65
using AspNet.Security.OAuth.GitHub;
76
using Microsoft.AspNetCore.Authentication;
87
using Microsoft.AspNetCore.Authentication.Cookies;
98
using Microsoft.AspNetCore.Authentication.OAuth;
10-
using Microsoft.Extensions.Options;
119
using OpenShock.Internal.Common.Problems;
1210
using OpenShock.RepositoryServer.Config;
1311
using OpenShock.RepositoryServer.Errors;
14-
using JsonOptions = Microsoft.AspNetCore.Http.Json.JsonOptions;
12+
using OpenShock.RepositoryServer.JsonSerialization;
1513

1614
namespace OpenShock.RepositoryServer.AuthenticationHandlers;
1715

@@ -205,7 +203,7 @@ private static Task WriteProblemOrRedirect<TOptions>(
205203
RedirectContext<TOptions> context, OpenShockProblem problem)
206204
where TOptions : AuthenticationSchemeOptions
207205
{
208-
if (IsApiPath(context.Request.Path))
206+
if (ApiSurface.IsApiPath(context.Request.Path))
209207
{
210208
return WriteProblemAsync(context.HttpContext, problem);
211209
}
@@ -214,25 +212,10 @@ private static Task WriteProblemOrRedirect<TOptions>(
214212
return Task.CompletedTask;
215213
}
216214

217-
/// <summary>
218-
/// Versioned API surface. Everything under these prefixes answers with a status code; anything
219-
/// else is assumed to be a browser that can usefully follow a redirect.
220-
/// </summary>
221-
private static bool IsApiPath(PathString path) =>
222-
path.StartsWithSegments("/1") || path.StartsWithSegments("/2");
223-
224215
private static Task WriteProblemAsync(HttpContext httpContext, OpenShockProblem problem)
225216
{
226217
if (httpContext.Response.HasStarted) return Task.CompletedTask;
227218

228-
problem.RequestId = httpContext.TraceIdentifier;
229-
httpContext.Response.StatusCode = problem.Status!.Value;
230-
231-
var serializerOptions = httpContext.RequestServices
232-
.GetRequiredService<IOptions<JsonOptions>>()
233-
.Value.SerializerOptions;
234-
235-
return httpContext.Response.WriteAsJsonAsync(problem, serializerOptions,
236-
contentType: MediaTypeNames.Application.ProblemJson);
219+
return problem.WriteAsJsonAsync(httpContext, JsonOptions.Default, httpContext.RequestAborted);
237220
}
238221
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
using Microsoft.AspNetCore.Authorization;
2+
using Microsoft.AspNetCore.Authorization.Policy;
3+
using OpenShock.RepositoryServer.Errors;
4+
using OpenShock.RepositoryServer.JsonSerialization;
5+
6+
namespace OpenShock.RepositoryServer.AuthenticationHandlers;
7+
8+
/// <summary>
9+
/// Answers a failed authorization with a problem body naming the requirements that were not met,
10+
/// instead of the framework default of a bare 403.
11+
/// </summary>
12+
/// <remarks>
13+
/// Only the versioned API surface is handled here. A browser hitting an admin page is left to the
14+
/// default handler, which calls Forbid on the cookie scheme and lands in
15+
/// <see cref="GitHubAuthentication.ConfigureCookie"/>'s access-denied event - the redirect a page
16+
/// request wants. Challenges (401) always fall through for the same reason: they are what start a
17+
/// login.
18+
/// </remarks>
19+
public sealed class OpenShockAuthorizationMiddlewareResultHandler : IAuthorizationMiddlewareResultHandler
20+
{
21+
private readonly AuthorizationMiddlewareResultHandler _defaultHandler = new();
22+
23+
public Task HandleAsync(RequestDelegate next, HttpContext context, AuthorizationPolicy policy,
24+
PolicyAuthorizationResult authorizeResult)
25+
{
26+
if (authorizeResult.Forbidden && ApiSurface.IsApiPath(context.Request.Path))
27+
{
28+
var failedRequirements = authorizeResult.AuthorizationFailure?.FailedRequirements
29+
.Select(x => x.ToString() ?? "error") ?? [];
30+
31+
return AuthorizationError.PolicyNotMet(failedRequirements)
32+
.WriteAsJsonAsync(context, JsonOptions.Default, context.RequestAborted);
33+
}
34+
35+
return _defaultHandler.HandleAsync(next, context, policy, authorizeResult);
36+
}
37+
}

‎RepositoryServer/Components/_Imports.razor‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
@using OpenShock.RepositoryServer.Components.Layout
1616
@using OpenShock.RepositoryServer.Components.Pages
1717
@using OpenShock.RepositoryServer.Enums
18-
@using OpenShock.RepositoryServer.Problems
18+
@using OpenShock.RepositoryServer.Errors
1919
@using OpenShock.RepositoryServer.RepoServerDb
2020
@using OpenShock.RepositoryServer.Services.Admin
2121
@using OpenShock.RepositoryServer.Components.Shared

‎RepositoryServer/Controllers/AuthController.cs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using Microsoft.AspNetCore.Authorization;
33
using Microsoft.AspNetCore.Mvc;
44
using OpenShock.Internal.Common;
5+
using OpenShock.Internal.Common.Problems;
56
using OpenShock.RepositoryServer.AuthenticationHandlers;
67
using OpenShock.RepositoryServer.Errors;
78
using System.Net.Mime;
@@ -32,8 +33,12 @@ public AuthController(AdminAuthMode authMode)
3233
/// <summary>
3334
/// Starts the GitHub login. Returns a redirect to GitHub's authorization page.
3435
/// </summary>
36+
/// <response code="302">Redirect to GitHub, or straight to <paramref name="returnUrl"/> under the development bypass.</response>
37+
/// <response code="400">The return url is not a relative path on this server.</response>
3538
[HttpGet("login")]
3639
[AllowAnonymous]
40+
[ProducesResponseType(StatusCodes.Status302Found)]
41+
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status400BadRequest, MediaTypeNames.Application.ProblemJson)] // InvalidReturnUrl
3742
public IActionResult Login([FromQuery] string? returnUrl)
3843
{
3944
// An open redirect here would be worth something: the victim arrives from a real login on the

‎RepositoryServer/Controllers/V1/CiCdController.cs‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33
using Microsoft.AspNetCore.Mvc;
44
using Microsoft.EntityFrameworkCore;
55
using OpenShock.Internal.Common;
6+
using OpenShock.Internal.Common.Problems;
67
using OpenShock.RepositoryServer.Config;
7-
using OpenShock.RepositoryServer.Problems;
8+
using OpenShock.RepositoryServer.Errors;
89
using OpenShock.RepositoryServer.RepoServerDb;
910
using OpenShock.RepositoryServer.Services;
1011
using Semver;
@@ -41,9 +42,19 @@ public CiCdController(RepoServerContext db, IStorageService storage, ApiConfig a
4142
/// Publishes a desktop module version by uploading a zip file.
4243
/// The file is hashed server-side, uploaded to storage, and recorded in the database.
4344
/// </summary>
45+
/// <response code="201">The version was published.</response>
46+
/// <response code="400">The version is not valid semver, or the uploaded zip is missing or rejected.</response>
47+
/// <response code="403">The calling repository does not own this module.</response>
48+
/// <response code="404">The module does not exist.</response>
49+
/// <response code="409">This version has already been published.</response>
4450
[HttpPut("modules/{moduleId}/versions/{moduleVersion}")]
4551
[Consumes("multipart/form-data")]
4652
[RequestSizeLimit(256 * 1024 * 1024)] // 256 MB
53+
[ProducesResponseType(StatusCodes.Status201Created)]
54+
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status400BadRequest, MediaTypeNames.Application.ProblemJson)] // VersionInvalidSemver, ZipMissing, ZipInvalid, ZipEmpty, ZipPathTraversal, ZipDisallowedDirectory, ZipDisallowedRootFile
55+
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] // ModuleNotOwned
56+
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // ModuleNotFound
57+
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status409Conflict, MediaTypeNames.Application.ProblemJson)] // VersionAlreadyExists
4758
public async Task<IActionResult> PublishVersion(
4859
[FromRoute] string moduleId,
4960
[FromRoute] string moduleVersion,
@@ -86,7 +97,7 @@ public async Task<IActionResult> PublishVersion(
8697
var file = Request.Form.Files.GetFile("zip");
8798
if (file == null)
8899
{
89-
return BadRequest(new { error = "No file uploaded. Expected a file field named 'zip'." });
100+
return Problem(ModuleError.ZipMissing);
90101
}
91102

92103
// Read file into memory for validation + hashing + storage upload

‎RepositoryServer/Controllers/V1/RepoController.cs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,12 @@ public RepoController(RepoServerContext db, ApiConfig apiConfig)
2929
_apiConfig = apiConfig;
3030
}
3131

32+
/// <summary>
33+
/// Gets the desktop module repository index: every module and its published versions.
34+
/// </summary>
35+
/// <response code="200">The repository index.</response>
3236
[HttpGet]
37+
[ProducesResponseType<Repository>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)]
3338
public async Task<IActionResult> GetRepo()
3439
{
3540
var moduleRaw = await _db.Modules.Include(x => x.Versions).ToArrayAsync();

‎RepositoryServer/Controllers/V2/Firmware/BoardsController.cs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,16 @@ public BoardsController(RepoServerContext db)
2323
_db = db;
2424
}
2525

26+
/// <summary>
27+
/// Lists the known boards.
28+
/// </summary>
29+
/// <param name="chip">Optional chip name filter, matched case-insensitively.</param>
30+
/// <param name="includeDiscontinued">Whether to include boards that are no longer produced.</param>
31+
/// <param name="ct">Cancellation token.</param>
32+
/// <response code="200">The matching boards, ordered by name.</response>
2633
[HttpGet]
2734
[CacheControl(300)]
35+
[ProducesResponseType<List<FirmwareBoardDto>>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)]
2836
public async Task<IActionResult> ListBoards(
2937
[FromQuery] string? chip,
3038
[FromQuery] bool includeDiscontinued = true,

‎RepositoryServer/Controllers/V2/Firmware/ChipsController.cs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,14 @@ public ChipsController(RepoServerContext db)
2222
_db = db;
2323
}
2424

25+
/// <summary>
26+
/// Lists the known chips.
27+
/// </summary>
28+
/// <param name="ct">Cancellation token.</param>
29+
/// <response code="200">The chips, ordered by name.</response>
2530
[HttpGet]
2631
[CacheControl(300)]
32+
[ProducesResponseType<List<FirmwareChipDto>>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)]
2733
public async Task<IActionResult> ListChips(CancellationToken ct)
2834
{
2935
var rows = await _db.FirmwareChips

0 commit comments

Comments
 (0)