feat(managementapi): repository list and detail handlers (S17 Phase 1 Step 8)
Implements S17 Phase 1 Step 8 of the repository CRUD plan: the management API read handlers, replacing the Step 2 placeholders for GET /api/v1/{slug}/repositories (list) and GET .../{repository_name} (detail).
- List:
format/kindfilters, the five indexed sorts in both directions,limitdefault 20 / clamp 100, keyset pagination via an opaque cursor and RFC 8288Linkheaders (rel="next"/rel="prev", backward paging included), validation before any store access. - Detail: name lookup scoped to the resolved namespace, existence-hiding
404. - Responses validated against
api/openapi/v1.yamlwithkin-openapicontract tests; the cursor decoder is fuzzed. docs/dev/api-style.mdgains the pagination convention and the now-live contract-test gate, paired with the code that introduces them.
Files beyond the plan's Step 8 entry
cmd/artifact-registry/wire_management.go+ test: consequence of wideningNewHandler(finder)toNewHandler(deps Deps)so Steps 9/10 can add write seams without breaking construction sites; the wire site injects the productionRepositoryStore.internal/managementapi/resolve.go: unexportedrequestNamespaceID/logAndWriteInternalErrorhelpers shared by both new handlers.docs/specs/S17-rest-management-api.md: two companion-table rows aligned with the authoritative OpenAPI document (idis a UUID string, and the invalid-cursor400the plan pins is now in the Error Cases table).go.mod/go.sum:github.com/getkin/kin-openapi, plan-sanctioned ("whichever of Step 1/8 lands first").
The pagination fake in fake_repository_reader_test.go mirrors the store's keyset contract; real-store keyset correctness (index backing, gap-free boundaries) is pinned by the Step 5 integration tests (!698 (merged)), and the handler-store composition (the walk through the real keyset SQL) is pinned by the stacked follow-up !791 (merged), which auto-retargets to main when this merges and closes the fake-drift caveat.
Spec coverage
Spec: docs/specs/S17-rest-management-api.md
Acceptance criteria
| # | Criterion | Tests |
|---|---|---|
| AC-1 | OpenAPI document defines endpoints/resource/enums/envelope, CI-validated | Step 1 (merged, !637 (merged)); the CI job owns validation. TestReadHandlers_ResponsesMatchOpenAPIContract re-validates the document on load. |
| AC-2 | POST creates each format atomically, 201 + Location | Step 9 MR (create handler). Not this MR. |
| AC-3 | POST duplicate name returns 409 | Step 9 MR. Not this MR. |
| AC-4 | POST validation 400 / non-hosted 422 / default visibility | Step 9 MR. Not this MR. |
| AC-5 | POST per-format cap 422 | Step 9 MR. Not this MR. |
| AC-6 | POST format=maven works | Step 9 MR. Not this MR. |
| AC-7 | GET detail 200 existing / 404 missing, no settings key | TestDetailHandler_ExistingRepository_Returns200, TestDetailHandler_MissingRepository_Returns404, TestReadHandlers_ResponsesMatchOpenAPIContract |
| AC-8 | GET list filters format/kind, sorts each column both directions, 400 on unrecognized params | TestListHandler_ParamMapping (every wire enum to its store param), TestListHandler_InvalidParams_Return400; the executed ordering per column/direction is store-owned, pinned by Step 5's repositories_integration_test.go |
| AC-9 | Keyset pagination: next link, no dups/gaps, final page omits next, limit capped | TestListHandler_KeysetWalk, TestListHandler_KeysetWalk_PreservesFilter, TestListHandler_PrevLink, TestListHandler_BackwardWalk_Links (backward Link emission, asc and desc, keyset symmetry), TestListHandler_CursorPageAfterDeletion, TestListHandler_LastUpdatedAtSort_CursorCoalescesToCreatedAt, TestListHandler_LimitBounds, TestReadHandlers_PaginatedResponseMatchesContract |
| AC-10 | PATCH updates, 422 on immutable field | Step 10 MR. Not this MR. |
| AC-11 | DELETE hard-deletes / 409 with artifacts / 404 missing | Step 10 MR. Not this MR. |
| AC-12 | Cross-slug: absent from other slug's list, 404 on detail/update/delete | List: TestListHandler_TenantScoping; detail: TestDetailHandler_CrossSlug_Returns404. Update/delete halves: Step 10 MR. |
| AC-13 | Every error path returns the S01 envelope with request_id | assertErrorEnvelope in every error-path test (400/404/500), TestReadHandlers_ResponsesMatchOpenAPIContract error rows; slug-404/finder-500 rows pre-exist in handler_test.go |
Error cases
| # | Condition | Tests |
|---|---|---|
| E-1 | Slug resolves to no namespace: 404 not_found | TestHandler_UnknownSlug_Returns404WithEnvelope (pre-existing, Step 2), TestReadHandlers_ResponsesMatchOpenAPIContract/detail_404_unknown_slug |
| E-2 | Malformed JSON body: 400 | Steps 9/10 MRs (the read endpoints take no body). Not this MR. |
| E-3 | Auth missing/invalid: 401 (S08) | S08 stub middleware-owned, outside this handler. Not tested in this MR. |
| E-4 | Authenticated but not permitted: 403 (S09) | S09 stub middleware-owned, outside this handler. Not tested in this MR. |
| E-5 | List: invalid format/kind/sort/order/limit: 400 | TestListHandler_InvalidParams_Return400; cursor rejections additionally pinned by TestDecodeListCursor_Rejects and FuzzDecodeListCursor |
| E-6 | Create: name conflict 409 | Step 9 MR. Not this MR. |
| E-7 | Create: invalid fields 400 | Step 9 MR. Not this MR. |
| E-8 | Create: non-hosted kind 422 | Step 9 MR. Not this MR. |
| E-9 | Create: cap reached 422 | Step 9 MR. Not this MR. |
| E-10 | Detail/Update/Delete: repository missing 404 | Detail: TestDetailHandler_MissingRepository_Returns404, TestDetailHandler_CrossSlug_Returns404. Update/delete: Step 10 MR. |
| E-11 | Delete: repository still has artifacts 409 | Step 10 MR. Not this MR. |
| E-12 | Update: immutable field 422 | Step 10 MR. Not this MR. |
| E-13 | Update: invalid field value 400 | Step 10 MR. Not this MR. |
| E-14 | Unexpected server failure 500 | TestListHandler_StoreFailure_Returns500, TestDetailHandler_StoreFailure_Returns500, TestDetailHandler_UnknownEnumRow_Returns500; finder-500 rows pre-exist in handler_test.go |
Security considerations
| # | Concern | Tests |
|---|---|---|
| S-1 | Authentication and authorization (S08/S09 stubs) | Middleware-owned (wired outside this handler). Not tested in this MR. |
| S-2 | Existence hiding: invisible resources return 404 | TestDetailHandler_MissingRepository_Returns404, TestDetailHandler_CrossSlug_Returns404, TestHandler_UnknownSlug_Returns404WithEnvelope (pre-existing) |
| S-3 | Input validation before any database access | TestListHandler_InvalidParams_Return400 (asserts zero store calls on 400). Write-body validation: Steps 9/10 MRs. |
| S-4 | Injection: Jet builder with bound parameters | Datastore-owned; Step 5's repositories_integration_test.go exercises the built queries. Not a handler concern. |
| S-5 | Name immutability | Steps 9/10 MRs (write surface). Not this MR. |
| S-6 | Tenant isolation: every query scoped by namespace_id | TestListHandler_TenantScoping, TestDetailHandler_CrossSlug_Returns404, plus the recorded-params namespace assertions in TestListHandler_Defaults and TestDetailHandler_ExistingRepository_Returns200 |
Size
+2,628/−132 across 17 files, over the 500 LOC guideline, so per that rule here is the split and the justification.
| Category | Added | Deleted | Share of adds |
|---|---|---|---|
| Production code | 678 | 47 | 26% |
| Tests | 1,895 | 79 | 72% |
| Docs (dev guide, spec, plan row) | 35 | 6 | 1% |
Dependencies (go.mod/go.sum) |
20 | 0 | <1% |
The reviewable production surface is smaller than the raw count suggests: the two new handlers hold ~325 executable lines (list.go: 285 code / 113 comment / 79 blank; detail.go: 40 code), with the remainder being doc comments per house style and the NewHandler(deps) wiring change. The bulk of the diff is tests: a store-shaped pagination fake (363), the one-time kin-openapi contract-test gate that Steps 9/10 extend rather than rebuild (229), and table-driven pagination walks.
Why not split: Step 8 is the plan's approved MR unit, and list + detail share the serializer, slug resolution, and error conventions. Splitting detail out would produce a ~100-line second MR that re-reviews the same seams. The irreducible review core is the keyset pagination logic in list.go; everything around it exists to pin that logic down.
Related to #171 (closed)