Skip to content

Commit 4c57dea

Browse files
committed
Serve API paths without the "v" prefix, matching develop and the spec
The shipped desktop app hardcodes https://repo.openshock.org/1 as a built-in module repository (Desktop/Constants.cs, BuiltInModuleRepositories), and develop's controllers route on "/{version:apiVersion}/". This branch had changed them to "/v{version:apiVersion}/", so deploying it would have returned 404 to every installed desktop client for the module manifest and for module version downloads. firmware-api-spec.md documents the unprefixed form throughout ("/2/firmware/..."), so the implementation disagreed with both the deployed clients and its own contract. Routes now match: /1/ for the desktop endpoints and /2/firmware/... for the firmware API. Updates the two consumers written against the prefixed form in this branch — the frontend client and the firmware OTA client, neither of which has shipped — along with the CI ingestion action, the seeding script and the tests.
1 parent b6545af commit 4c57dea

33 files changed

Lines changed: 118 additions & 118 deletions

‎CLAUDE.md‎

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ Unified repository server for distributing OpenShock desktop app modules and fir
88
- PostgreSQL via Npgsql + EF Core (with DbContext pooling)
99
- Serilog (console + Grafana Loki)
1010
- OpenTelemetry (Prometheus exporter)
11-
- API Versioning (Asp.Versioning) — URL-based: `/v1/`, `/v2/firmware/`
11+
- API Versioning (Asp.Versioning) — URL-based: `/1/`, `/2/firmware/`
1212
- FlexLabs.EntityFrameworkCore.Upsert for upserts
1313
- MiniValidation for config validation
1414
- Semver NuGet package for semver parsing
@@ -29,25 +29,25 @@ RepositoryServer/ # Main web API project
2929
Controllers/
3030
OpenShockControllerBase.cs # Shared base controller
3131
V1/ # Desktop module endpoints (V1)
32-
RepoController.cs # GET /v1/ — desktop module manifest
33-
AdminController.cs # /v1/admin/... — desktop module CRUD
34-
CiCdController.cs # PUT /v1/cicd/modules/{id}/versions/{v} — desktop zip upload
32+
RepoController.cs # GET /1/ — desktop module manifest
33+
AdminController.cs # /1/admin/... — desktop module CRUD
34+
CiCdController.cs # PUT /1/cicd/modules/{id}/versions/{v} — desktop zip upload
3535
V2/Firmware/ # Firmware V2 endpoints
36-
ManifestController.cs # GET /v2/firmware/manifest (bootstrap payload)
37-
LatestController.cs # GET /v2/firmware/latest/{channel}[/{board}?version=]
38-
VersionsController.cs # GET /v2/firmware/versions[/{version}[/{board}]]
39-
BoardsController.cs # GET /v2/firmware/boards
40-
ChipsController.cs # GET /v2/firmware/chips
41-
ReleasesController.cs # POST/PUT/DELETE /v2/firmware/releases/... (CI/CD ingestion)
36+
ManifestController.cs # GET /2/firmware/manifest (bootstrap payload)
37+
LatestController.cs # GET /2/firmware/latest/{channel}[/{board}?version=]
38+
VersionsController.cs # GET /2/firmware/versions[/{version}[/{board}]]
39+
BoardsController.cs # GET /2/firmware/boards
40+
ChipsController.cs # GET /2/firmware/chips
41+
ReleasesController.cs # POST/PUT/DELETE /2/firmware/releases/... (CI/CD ingestion)
4242
Admin/ # AdminToken-protected CRUD (split by concern)
43-
RepositoriesController.cs # GET, DELETE /v2/firmware/admin/repositories
44-
VersionsAdminController.cs # DELETE /v2/firmware/admin/versions/{v}
45-
BoardsAdminController.cs # PUT, PATCH, DELETE /v2/firmware/admin/boards/{id}
43+
RepositoriesController.cs # GET, DELETE /2/firmware/admin/repositories
44+
VersionsAdminController.cs # DELETE /2/firmware/admin/versions/{v}
45+
BoardsAdminController.cs # PUT, PATCH, DELETE /2/firmware/admin/boards/{id}
4646
# + USB attach/detach
47-
ChipsAdminController.cs # PUT, DELETE /v2/firmware/admin/chips/{id} + USB attach/detach
48-
ReleasesAdminController.cs # PUT /v2/firmware/admin/releases/{id}/changelog (fix)
49-
UsbDevicesController.cs # PUT/GET/DELETE /v2/firmware/admin/usb-devices
50-
UsbSerialFiltersController.cs # PUT/GET/DELETE /v2/firmware/admin/usb-serial-filters
47+
ChipsAdminController.cs # PUT, DELETE /2/firmware/admin/chips/{id} + USB attach/detach
48+
ReleasesAdminController.cs # PUT /2/firmware/admin/releases/{id}/changelog (fix)
49+
UsbDevicesController.cs # PUT/GET/DELETE /2/firmware/admin/usb-devices
50+
UsbSerialFiltersController.cs # PUT/GET/DELETE /2/firmware/admin/usb-serial-filters
5151
Enums/ # ReleaseChannel, FirmwareArtifactType, FirmwareChipArchitecture,
5252
# ReleaseNoteSectionType, ReleaseStatus, RepositoryProvider
5353
Errors/ # AuthResultError, ExceptionError
@@ -97,7 +97,7 @@ docker/
9797
validates a GitHub Actions OIDC JWT, then looks up a matching row in the `repositories`
9898
table and attaches `AuthSchemas.CiCdClaims` to the principal. **Unregistered repositories are
9999
rejected** — the table is the publish allowlist, and a valid OIDC token alone says nothing about
100-
which repo is calling. Onboarding is `PUT /v2/firmware/admin/repositories` (admin token).
100+
which repo is calling. Onboarding is `PUT /2/firmware/admin/repositories` (admin token).
101101
Authorization does not stop there: release upload/publish/abort each re-check that the release
102102
belongs to the calling repository, and desktop modules carry an owning `repository_id`.
103103
- **Error Handling**: `OpenShockProblem` base class → `ToObjectResult()` for RFC 7807
@@ -113,7 +113,7 @@ docker/
113113
- **Metrics**: Prometheus at `/metrics`, restricted to private networks
114114
- **Notifications**: Fire-and-forget Discord webhooks via `IDiscordNotificationService`.
115115
Targets live in the `discord_webhooks` table, each subscribing to specific events, managed via
116-
`/v2/admin/discord-webhooks`. The URL is a credential so it is write-only — responses return a
116+
`/2/admin/discord-webhooks`. The URL is a credential so it is write-only — responses return a
117117
masked form. The service is a **singleton** that resolves its own `HttpClient` and `DbContext`:
118118
notifications outlive the request that triggers them, so capturing request-scoped ones dropped
119119
them non-deterministically. No subscribed webhooks → no-op.

‎RepositoryServer.Tests/Integration/Tests/AdvisoriesAdminControllerTests.cs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ namespace OpenShock.RepositoryServer.Tests.Integration.Tests;
1010
[NotInParallel("repo-server-integration")]
1111
public class AdvisoriesAdminControllerTests
1212
{
13-
private const string BasePath = "/v2/firmware/admin/advisories";
13+
private const string BasePath = "/2/firmware/admin/advisories";
1414

1515
[ClassDataSource<WebApplicationFactory>(Shared = SharedType.PerTestSession)]
1616
public required WebApplicationFactory Factory { get; init; }
@@ -172,7 +172,7 @@ public async Task Post_Advisory_AppearsOnPublicManifest()
172172
await Assert.That(createResponse.StatusCode).IsEqualTo(HttpStatusCode.Created);
173173

174174
using var publicClient = Factory.CreateClient();
175-
var manifest = await publicClient.GetFromJsonAsync<JsonElement>("/v2/firmware/manifest");
175+
var manifest = await publicClient.GetFromJsonAsync<JsonElement>("/2/firmware/manifest");
176176
var advisories = manifest.GetProperty("advisories").EnumerateArray().ToList();
177177

178178
await Assert.That(advisories).Count().IsEqualTo(1);

‎RepositoryServer.Tests/Integration/Tests/BoardsAdminControllerTests.cs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ namespace OpenShock.RepositoryServer.Tests.Integration.Tests;
1111
[NotInParallel("repo-server-integration")]
1212
public class BoardsAdminControllerTests
1313
{
14-
private const string BasePath = "/v2/firmware/admin/boards";
15-
private const string ChipsPath = "/v2/firmware/admin/chips";
14+
private const string BasePath = "/2/firmware/admin/boards";
15+
private const string ChipsPath = "/2/firmware/admin/chips";
1616

1717
[ClassDataSource<WebApplicationFactory>(Shared = SharedType.PerTestSession)]
1818
public required WebApplicationFactory Factory { get; init; }

‎RepositoryServer.Tests/Integration/Tests/ChipsAdminControllerTests.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ namespace OpenShock.RepositoryServer.Tests.Integration.Tests;
1111
[NotInParallel("repo-server-integration")]
1212
public class ChipsAdminControllerTests
1313
{
14-
private const string BasePath = "/v2/firmware/admin/chips";
14+
private const string BasePath = "/2/firmware/admin/chips";
1515

1616
[ClassDataSource<WebApplicationFactory>(Shared = SharedType.PerTestSession)]
1717
public required WebApplicationFactory Factory { get; init; }

‎RepositoryServer.Tests/Integration/Tests/DesktopCiCdControllerTests.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ private static async Task<HttpResponseMessage> PublishAsync(HttpClient client, s
129129
var content = new MultipartFormDataContent();
130130
content.Add(new ByteArrayContent(BuildModuleZip()), "zip", "module.zip");
131131

132-
return await client.PutAsync($"/v1/cicd/modules/{ModuleId}/versions/{version}", content);
132+
return await client.PutAsync($"/1/cicd/modules/{ModuleId}/versions/{version}", content);
133133
}
134134

135135
/// <summary>Minimal zip passing the controller's root-entry validation (.dll/.pdb/.json only).</summary>

‎RepositoryServer.Tests/Integration/Tests/DiscordWebhooksControllerTests.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ namespace OpenShock.RepositoryServer.Tests.Integration.Tests;
1111
[NotInParallel("repo-server-integration")]
1212
public class DiscordWebhooksControllerTests
1313
{
14-
private const string BasePath = "/v2/admin/discord-webhooks";
14+
private const string BasePath = "/2/admin/discord-webhooks";
1515
private const string WebhookUrl = "https://discord.com/api/webhooks/123456789/s3cr3t-token-value";
1616

1717
[ClassDataSource<WebApplicationFactory>(Shared = SharedType.PerTestSession)]

‎RepositoryServer.Tests/Integration/Tests/LatestControllerTests.cs‎

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,15 @@ public class LatestControllerTests
2323
public async Task GetLatest_InvalidChannel_Returns400()
2424
{
2525
using var client = Factory.CreateClient();
26-
var response = await client.GetAsync("/v2/firmware/latest/nightly");
26+
var response = await client.GetAsync("/2/firmware/latest/nightly");
2727
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
2828
}
2929

3030
[Test]
3131
public async Task GetLatest_ChannelWithNoReleases_Returns404()
3232
{
3333
using var client = Factory.CreateClient();
34-
var response = await client.GetAsync("/v2/firmware/latest/stable");
34+
var response = await client.GetAsync("/2/firmware/latest/stable");
3535
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound);
3636
}
3737

@@ -41,7 +41,7 @@ public async Task GetLatest_HappyPath_ReturnsReleaseWithSourceAndBoards()
4141
var (boardId, _, _) = await SeedReleaseAsync("1.5.1", ReleaseChannel.Stable);
4242

4343
using var client = Factory.CreateClient();
44-
var response = await client.GetAsync("/v2/firmware/latest/stable");
44+
var response = await client.GetAsync("/2/firmware/latest/stable");
4545
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
4646

4747
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
@@ -63,15 +63,15 @@ await Assert.That(source.GetProperty("commitUrl").GetString())
6363
public async Task GetLatestForBoard_UnknownChannel_Returns400()
6464
{
6565
using var client = Factory.CreateClient();
66-
var response = await client.GetAsync($"/v2/firmware/latest/nightly/{Guid.NewGuid()}");
66+
var response = await client.GetAsync($"/2/firmware/latest/nightly/{Guid.NewGuid()}");
6767
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
6868
}
6969

7070
[Test]
7171
public async Task GetLatestForBoard_NoReleases_Returns404()
7272
{
7373
using var client = Factory.CreateClient();
74-
var response = await client.GetAsync($"/v2/firmware/latest/stable/{Guid.NewGuid()}");
74+
var response = await client.GetAsync($"/2/firmware/latest/stable/{Guid.NewGuid()}");
7575
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound);
7676
}
7777

@@ -81,7 +81,7 @@ public async Task GetLatestForBoard_MatchingVersion_Returns204()
8181
var (boardId, _, _) = await SeedReleaseAsync("1.5.1", ReleaseChannel.Stable);
8282

8383
using var client = Factory.CreateClient();
84-
var response = await client.GetAsync($"/v2/firmware/latest/stable/{boardId}?version=1.5.1");
84+
var response = await client.GetAsync($"/2/firmware/latest/stable/{boardId}?version=1.5.1");
8585
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NoContent);
8686
}
8787

@@ -91,7 +91,7 @@ public async Task GetLatestForBoard_DifferentVersion_Returns200WithArtifacts()
9191
var (boardId, _, _) = await SeedReleaseAsync("1.5.1", ReleaseChannel.Stable);
9292

9393
using var client = Factory.CreateClient();
94-
var response = await client.GetAsync($"/v2/firmware/latest/stable/{boardId}?version=1.4.0");
94+
var response = await client.GetAsync($"/2/firmware/latest/stable/{boardId}?version=1.4.0");
9595
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
9696

9797
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
@@ -106,7 +106,7 @@ public async Task GetLatestForBoard_NoVersionParam_Returns200()
106106
var (boardId, _, _) = await SeedReleaseAsync("1.5.1", ReleaseChannel.Stable);
107107

108108
using var client = Factory.CreateClient();
109-
var response = await client.GetAsync($"/v2/firmware/latest/stable/{boardId}");
109+
var response = await client.GetAsync($"/2/firmware/latest/stable/{boardId}");
110110
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
111111
}
112112

@@ -116,7 +116,7 @@ public async Task GetLatestForBoard_ByName_Returns200WithNameEchoed()
116116
await SeedReleaseAsync("1.5.1", ReleaseChannel.Stable);
117117

118118
using var client = Factory.CreateClient();
119-
var response = await client.GetAsync($"/v2/firmware/latest/stable/{BoardName}?version=1.4.0");
119+
var response = await client.GetAsync($"/2/firmware/latest/stable/{BoardName}?version=1.4.0");
120120
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
121121

122122
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
@@ -130,7 +130,7 @@ public async Task GetLatestForBoard_ByNameDifferentCase_Returns200WithCanonicalN
130130

131131
using var client = Factory.CreateClient();
132132
var response = await client.GetAsync(
133-
$"/v2/firmware/latest/stable/{BoardName.ToLowerInvariant()}?version=1.4.0");
133+
$"/2/firmware/latest/stable/{BoardName.ToLowerInvariant()}?version=1.4.0");
134134
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
135135

136136
// The canonical stored spelling comes back, not what the caller sent.
@@ -144,7 +144,7 @@ public async Task GetLatestForBoard_ByName_MatchingVersion_Returns204()
144144
await SeedReleaseAsync("1.5.1", ReleaseChannel.Stable);
145145

146146
using var client = Factory.CreateClient();
147-
var response = await client.GetAsync($"/v2/firmware/latest/stable/{BoardName}?version=1.5.1");
147+
var response = await client.GetAsync($"/2/firmware/latest/stable/{BoardName}?version=1.5.1");
148148
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NoContent);
149149
}
150150

@@ -155,7 +155,7 @@ public async Task GetLatestForBoard_ArtifactUrlUsesImmutableBoardId()
155155

156156
using var client = Factory.CreateClient();
157157
var body = await client.GetFromJsonAsync<JsonElement>(
158-
$"/v2/firmware/latest/stable/{BoardName}");
158+
$"/2/firmware/latest/stable/{BoardName}");
159159

160160
// Storage paths are keyed by the board id so a rename cannot strand published artifacts,
161161
// while the response still labels the board by name.
@@ -173,7 +173,7 @@ public async Task GetLatestForBoard_UnknownName_Returns404NotNoContent()
173173
// An unknown board must not be masked as "already up to date" just because the
174174
// version query happens to match the latest release.
175175
using var client = Factory.CreateClient();
176-
var response = await client.GetAsync("/v2/firmware/latest/stable/No-Such-Board?version=1.5.1");
176+
var response = await client.GetAsync("/2/firmware/latest/stable/No-Such-Board?version=1.5.1");
177177
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound);
178178
}
179179

@@ -183,7 +183,7 @@ public async Task GetLatestForBoard_UnknownBoard_Returns404()
183183
await SeedReleaseAsync("1.5.1", ReleaseChannel.Stable);
184184

185185
using var client = Factory.CreateClient();
186-
var response = await client.GetAsync($"/v2/firmware/latest/stable/{Guid.NewGuid()}");
186+
var response = await client.GetAsync($"/2/firmware/latest/stable/{Guid.NewGuid()}");
187187
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound);
188188
}
189189

@@ -197,7 +197,7 @@ public async Task GetLatest_BetaChannel_IncludesNewerStableRelease()
197197
await AddVersionAsync("1.5.0", ReleaseChannel.Stable, DateTimeOffset.UtcNow, repoId);
198198

199199
using var client = Factory.CreateClient();
200-
var body = await client.GetFromJsonAsync<JsonElement>("/v2/firmware/latest/beta");
200+
var body = await client.GetFromJsonAsync<JsonElement>("/2/firmware/latest/beta");
201201
await Assert.That(body.GetProperty("version").GetString()).IsEqualTo("1.5.0");
202202
}
203203

@@ -209,7 +209,7 @@ public async Task GetLatest_StableChannel_ExcludesBetaReleases()
209209
await AddVersionAsync("1.6.0-beta.1", ReleaseChannel.Beta, DateTimeOffset.UtcNow.AddDays(1), repoId);
210210

211211
using var client = Factory.CreateClient();
212-
var body = await client.GetFromJsonAsync<JsonElement>("/v2/firmware/latest/stable");
212+
var body = await client.GetFromJsonAsync<JsonElement>("/2/firmware/latest/stable");
213213
await Assert.That(body.GetProperty("version").GetString()).IsEqualTo("1.5.0");
214214
}
215215

@@ -225,19 +225,19 @@ public async Task GetLatest_TiedReleaseDates_ResolvesDeterministicallyAndAgreesW
225225

226226
using var client = Factory.CreateClient();
227227

228-
var first = await client.GetFromJsonAsync<JsonElement>("/v2/firmware/latest/stable");
228+
var first = await client.GetFromJsonAsync<JsonElement>("/2/firmware/latest/stable");
229229
var picked = first.GetProperty("version").GetString();
230230
await Assert.That(picked).IsEqualTo("1.5.2");
231231

232232
// Stable across repeated calls...
233233
for (var i = 0; i < 3; i++)
234234
{
235-
var again = await client.GetFromJsonAsync<JsonElement>("/v2/firmware/latest/stable");
235+
var again = await client.GetFromJsonAsync<JsonElement>("/2/firmware/latest/stable");
236236
await Assert.That(again.GetProperty("version").GetString()).IsEqualTo(picked);
237237
}
238238

239239
// ...and consistent with the manifest, which computes latest independently.
240-
var manifest = await client.GetFromJsonAsync<JsonElement>("/v2/firmware/manifest");
240+
var manifest = await client.GetFromJsonAsync<JsonElement>("/2/firmware/manifest");
241241
await Assert.That(manifest.GetProperty("latest").GetProperty("stable").GetString())
242242
.IsEqualTo(picked);
243243
}

‎RepositoryServer.Tests/Integration/Tests/ManifestControllerTests.cs‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ public async Task GetManifest_EmptyDatabase_ReturnsShellWithFixedChannels()
2121
{
2222
using var client = Factory.CreateClient();
2323

24-
var response = await client.GetAsync("/v2/firmware/manifest");
24+
var response = await client.GetAsync("/2/firmware/manifest");
2525
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
2626

2727
var manifest = await response.Content.ReadFromJsonAsync<JsonElement>();
@@ -111,7 +111,7 @@ public async Task GetManifest_WithSeededData_ReturnsFullShape()
111111
}
112112

113113
using var client = Factory.CreateClient();
114-
var response = await client.GetAsync("/v2/firmware/manifest");
114+
var response = await client.GetAsync("/2/firmware/manifest");
115115
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
116116

117117
var manifest = await response.Content.ReadFromJsonAsync<JsonElement>();
@@ -156,7 +156,7 @@ await Assert.That(Guid.Parse(devices[0].GetProperty("id").GetString()!))
156156
public async Task GetManifest_SendsCacheControlHeader()
157157
{
158158
using var client = Factory.CreateClient();
159-
var response = await client.GetAsync("/v2/firmware/manifest");
159+
var response = await client.GetAsync("/2/firmware/manifest");
160160

161161
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
162162
await Assert.That(response.Headers.CacheControl).IsNotNull();

0 commit comments

Comments
 (0)