-
-
Notifications
You must be signed in to change notification settings - Fork 0
Server: Add GET /p2/{vendor}/{file} route
#55
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| package server | ||
|
|
||
| import ( | ||
| "net/http" | ||
| "strings" | ||
| ) | ||
|
|
||
| func handleP2(store AdvisoriesMarshaler) http.HandlerFunc { | ||
| return func(w http.ResponseWriter, r *http.Request) { | ||
| vendor := r.PathValue("vendor") | ||
| vendor = strings.ToLower(vendor) | ||
|
|
||
| file := r.PathValue("file") | ||
| file = strings.ToLower(file) | ||
| if !strings.HasSuffix(file, ".json") { | ||
| http.NotFound(w, r) | ||
| return | ||
| } | ||
| if strings.HasSuffix(file, "~dev.json") { | ||
| http.NotFound(w, r) | ||
| return | ||
| } | ||
|
|
||
| slug := strings.TrimSuffix(file, ".json") | ||
|
|
||
| advisories, err := store.MarshalAdvisoriesFor(vendor, slug) | ||
| if err != nil { | ||
| http.NotFound(w, r) | ||
| return | ||
| } | ||
|
|
||
| w.Header().Set("Content-Type", "application/json") | ||
| w.WriteHeader(http.StatusOK) | ||
|
|
||
| w.Write([]byte(`{"packages":[],"security-advisories":`)) | ||
| //gosec:disable G705 -- Advisories bytes originate from trusted embedded asset files | ||
| w.Write(advisories) | ||
| w.Write([]byte(`}`)) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,7 +1,6 @@ | ||
| package server | ||
|
|
||
| import ( | ||
| _ "embed" | ||
| "net/http" | ||
| "net/http/httptest" | ||
| "testing" | ||
|
|
@@ -10,38 +9,78 @@ import ( | |
| func TestP2(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| methods := []string{http.MethodGet, http.MethodPost} | ||
|
|
||
| tests := []struct { | ||
| name string | ||
| path string | ||
| name string | ||
| data map[string][]byte | ||
| path string | ||
| wantCode int | ||
| wantCT string | ||
| wantBody string | ||
| }{ | ||
| {"stable", "/p2/foo/bar.json"}, | ||
| {"dev", "/p2/foo/bar~dev.json"}, | ||
| { | ||
| name: "known/stable", | ||
| data: map[string][]byte{ | ||
| "foo/bar": []byte(`[{"advisoryId":"WPSECADV/1","affectedVersions":">=1.0.0"}]`), | ||
| }, | ||
| path: "/p2/foo/bar.json", | ||
| wantCode: http.StatusOK, | ||
| wantCT: "application/json", | ||
| wantBody: `{"packages":[],"security-advisories":[{"advisoryId":"WPSECADV/1","affectedVersions":">=1.0.0"}]}`, | ||
| }, | ||
| { | ||
| name: "known/dev", | ||
| data: map[string][]byte{ | ||
| "foo/bar": []byte(`[{"advisoryId":"WPSECADV/1","affectedVersions":">=1.0.0"}]`), | ||
| }, | ||
| path: "/p2/foo/bar~dev.json", | ||
| wantCode: http.StatusNotFound, | ||
| }, | ||
| { | ||
| name: "unknown/stable", | ||
| data: map[string][]byte{ | ||
| "foo/bar": []byte(`[{"advisoryId":"WPSECADV/1","affectedVersions":">=1.0.0"}]`), | ||
| }, | ||
| path: "/p2/baz/qux.json", | ||
| wantCode: http.StatusNotFound, | ||
| }, | ||
| { | ||
| name: "unknown/dev", | ||
| path: "/p2/foo/bar~dev.json", | ||
| wantCode: http.StatusNotFound, | ||
| }, | ||
| {name: "no_vendor/stable", path: "/p2/bar.json", wantCode: http.StatusNotFound}, | ||
| {name: "no_vendor/dev", path: "/p2/bar~dev.json", wantCode: http.StatusNotFound}, | ||
| {name: "no_file", path: "/p2/foo/", wantCode: http.StatusNotFound}, | ||
| {name: "no_slug/stable", path: "/p2/foo/.json", wantCode: http.StatusNotFound}, | ||
| {name: "no_slug/dev", path: "/p2/foo/~dev.json", wantCode: http.StatusNotFound}, | ||
| {name: "no_extension/stable", path: "/p2/foo/bar", wantCode: http.StatusNotFound}, | ||
| {name: "no_extension/dev", path: "/p2/foo/bar~dev", wantCode: http.StatusNotFound}, | ||
| {name: "not_json/stable", path: "/p2/foo/bar.txt", wantCode: http.StatusNotFound}, | ||
| {name: "not_json/dev", path: "/p2/foo/bar~dev.txt", wantCode: http.StatusNotFound}, | ||
| } | ||
|
|
||
| for _, method := range methods { | ||
| t.Run(method, func(t *testing.T) { | ||
| for _, tt := range tests { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| t.Parallel() | ||
| for _, tt := range tests { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| srv := newTestServerWithData(tt.data) | ||
| req := httptest.NewRequest(http.MethodGet, tt.path, http.NoBody) | ||
| rec := httptest.NewRecorder() | ||
|
|
||
| srv := newTestServer() | ||
| req := httptest.NewRequest(method, tt.path, http.NoBody) | ||
| rec := httptest.NewRecorder() | ||
| srv.ServeHTTP(rec, req) | ||
|
|
||
| srv.ServeHTTP(rec, req) | ||
| if rec.Code != tt.wantCode { | ||
| t.Errorf("status = %d, want %d", rec.Code, tt.wantCode) | ||
| } | ||
|
|
||
| if rec.Code != http.StatusNotFound { | ||
| t.Errorf("status code = %d, want %d", rec.Code, http.StatusNotFound) | ||
| } | ||
| gotCT := rec.Header().Get("Content-Type") | ||
| if tt.wantCT != "" && gotCT != tt.wantCT { | ||
| t.Errorf("Content-Type = %q, want %q", gotCT, tt.wantCT) | ||
| } | ||
|
Comment on lines
+70
to
+79
|
||
|
|
||
|
Comment on lines
+70
to
80
|
||
| gotCC := rec.Header().Get("Cache-Control") | ||
| wantCC := "max-age=86400" | ||
| if gotCC != wantCC { | ||
| t.Errorf("Cache-Control header = %q, want %q", gotCC, wantCC) | ||
| } | ||
| }) | ||
| gotBody := rec.Body.String() | ||
| if tt.wantBody != "" && gotBody != tt.wantBody { | ||
| t.Errorf("body = %q, want %q", gotBody, tt.wantBody) | ||
| } | ||
|
Comment on lines
+70
to
84
|
||
| }) | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -17,6 +17,8 @@ func addRoutes(mux *http.ServeMux, store AdvisoriesMarshaler, modTime time.Time) | |||||
| mux.HandleFunc("GET /api/security-advisories/{$}", withCacheControl("max-age=3600")(m(hAdvs))) | ||||||
| mux.HandleFunc("POST /api/security-advisories/{$}", withCacheControl("max-age=3600")(hAdvs)) | ||||||
|
|
||||||
| mux.HandleFunc("GET /p2/{vendor}/{file}", handleP2(store)) | ||||||
|
||||||
| mux.HandleFunc("GET /p2/{vendor}/{file}", handleP2(store)) | |
| mux.HandleFunc("GET /p2/{vendor}/{file}", m(handleP2(store))) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| schema: spec-driven | ||
| created: 2026-03-31 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,81 @@ | ||
| ## Context | ||
|
|
||
| The server uses stdlib `net/http.ServeMux` with Go 1.22+ method+path patterns. | ||
| All response JSON is produced as pre-serialized `[]byte` — handlers write raw | ||
| bytes directly rather than marshalling structs at request time. Advisory data | ||
| is embedded in the binary at compile time as per-slug JSON files. | ||
|
|
||
| The `AdvisoriesMarshaler` interface is the seam between the HTTP layer and the | ||
| data layer. The `data.Store` already knows how to map `(vendor, slug)` to a | ||
| JSON array of full `Advisory` objects. | ||
|
|
||
| Currently both `/p2/{vendor}/{slug}.json` and `/p2/{vendor}/{slug}~dev.json` | ||
| fall through to the default 404, which is wrapped by the global | ||
| `withCacheControl("max-age=86400")` middleware. | ||
|
|
||
| ## Goals / Non-Goals | ||
|
|
||
| **Goals:** | ||
| - Serve `{ "packages": [], "security-advisories": [...] }` at the stable p2 path | ||
| - Return 404 for the dev p2 path | ||
| - Enable `"metadata": true` in `packages.json` | ||
|
|
||
| **Non-Goals:** | ||
| - Serving actual package version metadata (`packages` stays always-empty) | ||
| - Supporting `~dev.json` advisory lookups | ||
| - Changing how the existing `/api/security-advisories/` endpoint works | ||
|
|
||
| ## Decisions | ||
|
|
||
| ### Route registration: single handler, not two routes | ||
|
|
||
| Go 1.22 ServeMux wildcards match whole path segments. The last segment of | ||
| `/p2/foo/bar.json` is `bar.json` and of `/p2/foo/bar~dev.json` is | ||
| `bar~dev.json` — there is no way to write two patterns with a per-segment | ||
| wildcard that distinguish these at the routing layer alone. | ||
|
|
||
| **Decision:** Register one route — `GET /p2/{vendor}/{file}` — and dispatch | ||
| inside the handler on whether `{file}` ends in `~dev.json` (→ 404), ends in | ||
| `.json` (→ serve advisories, slug = file without `.json` suffix), or anything | ||
| else (→ 404). | ||
|
|
||
| Considered: registering `GET /p2/{vendor}/{slug}~dev.json` as a literal-suffix | ||
| pattern. Rejected because Go ServeMux does not support literal suffixes within | ||
| a wildcard segment. | ||
|
|
||
| ### Reuse `AdvisoriesMarshaler` | ||
|
|
||
| The p2 handler accepts the existing `AdvisoriesMarshaler` interface. | ||
| `MarshalAdvisoriesFor(vendor, slug)` returns a JSON array of full `Advisory` | ||
| objects; that array is used verbatim as the `security-advisories` value in the | ||
| p2 response — no transformation needed. | ||
|
|
||
| **Decision:** No new interface. The p2 handler takes `AdvisoriesMarshaler` | ||
| directly and writes the returned bytes straight into the response body. | ||
|
|
||
| Considered: a separate `P2AdvisoriesMarshaler` interface returning only | ||
| `advisoryId` + `affectedVersions`. Rejected: unnecessary complexity; Composer | ||
| ignores unknown fields, so serving the full advisory objects is harmless and | ||
| keeps the data layer unchanged. | ||
|
|
||
| ### Response assembly: inline byte writes | ||
|
|
||
| Consistent with `handleAdvisories`, the p2 handler assembles the response with | ||
| direct `w.Write` calls using pre-built byte literals and the `[]byte` from | ||
| `MarshalAdvisoriesFor`, avoiding any per-request struct marshalling. | ||
|
|
||
| ``` | ||
| {"packages":[],"security-advisories": <bytes from store> } | ||
| ``` | ||
|
|
||
| ### Cache-Control | ||
|
|
||
| The global `withCacheControl("max-age=86400")` wrapper already covers all | ||
| responses including 404s, matching the existing test expectation. No per-route | ||
| override needed. | ||
|
|
||
| ## Risks / Trade-offs | ||
|
|
||
| - **Test breakage** → `handle_p2_test.go` currently asserts `StatusNotFound` | ||
| for the stable path. That assertion must change to `StatusOK`. Mitigation: | ||
| the test file is small and the change is mechanical. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| ## Why | ||
|
|
||
| Composer clients can discover security advisories directly from Packagist v2 | ||
| `/p2/{vendor}/{slug}.json` endpoints when a repository sets `"metadata": true`. | ||
| This change implements those endpoints and enables the flag, giving Composer a | ||
| standards-compliant path to per-package advisory data. | ||
|
|
||
| ## What Changes | ||
|
|
||
| - Add `GET /p2/{vendor}/{slug}.json` — responds with Packagist v2 metadata: | ||
| `packages: []` (always empty; no version metadata served) and a | ||
| `security-advisories` array of `{ advisoryId, affectedVersions }` objects | ||
| drawn from `AdvisoriesMarshaler.MarshalAdvisoriesFor(vendor, slug)` | ||
| - Add `GET /p2/{vendor}/{slug}~dev.json` — always returns 404 (dev/branch | ||
| aliases carry no advisory metadata) | ||
| - Update `internal/server/static/packages.json` — flip `"metadata": false` | ||
| to `"metadata": true` so Composer clients use the new endpoints | ||
|
|
||
| ## Capabilities | ||
|
|
||
| ### New Capabilities | ||
| - `p2-package-metadata`: Packagist v2 per-package endpoint that surfaces | ||
| security advisories in the format Composer expects at `/p2/{vendor}/{slug}.json` | ||
|
|
||
| ### Modified Capabilities | ||
|
|
||
| (none) | ||
|
|
||
| ## Impact | ||
|
|
||
| - `internal/server/routes.go` — new route registrations for `/p2/` paths | ||
| - `internal/server/handle_p2.go` — new handler file | ||
| - `internal/server/static/packages.json` — `metadata` flag update | ||
| - `internal/server/handle_p2_test.go` — existing tests assert 404 for both | ||
| paths; stable path assertion changes to 200 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| ## ADDED Requirements | ||
|
|
||
| ### Requirement: Stable p2 path returns security advisories | ||
| The server SHALL respond to `GET /p2/{vendor}/{slug}.json` with HTTP 200 and a | ||
| JSON body containing `"packages": []` and a `"security-advisories"` array | ||
| populated from `AdvisoriesMarshaler.MarshalAdvisoriesFor(vendor, slug)`. | ||
|
|
||
| #### Scenario: Known package with advisories | ||
| - **WHEN** a GET request is made to `/p2/{vendor}/{slug}.json` for a vendor/slug that has advisories | ||
| - **THEN** the response status SHALL be 200 | ||
| - **THEN** the response body SHALL be `{"packages":[],"security-advisories":[...]}` where the array contains the advisory objects for that package | ||
|
|
||
| #### Scenario: Unknown package | ||
| - **WHEN** a GET request is made to `/p2/{vendor}/{slug}.json` for a vendor/slug with no known advisories | ||
| - **THEN** the response status SHALL be 404 | ||
|
|
||
| #### Scenario: Cache-Control header on stable path | ||
| - **WHEN** a GET request is made to `/p2/{vendor}/{slug}.json` | ||
| - **THEN** the response SHALL include a `Cache-Control: max-age=86400` header | ||
|
|
||
| ### Requirement: Dev p2 path always returns 404 | ||
| The server SHALL respond to `GET /p2/{vendor}/{slug}~dev.json` with HTTP 404 | ||
| regardless of vendor or slug. | ||
|
|
||
| #### Scenario: Dev path returns 404 | ||
| - **WHEN** a GET request is made to `/p2/{vendor}/{slug}~dev.json` | ||
| - **THEN** the response status SHALL be 404 | ||
|
|
||
| #### Scenario: Cache-Control header on dev path | ||
| - **WHEN** a GET request is made to `/p2/{vendor}/{slug}~dev.json` | ||
| - **THEN** the response SHALL include a `Cache-Control: max-age=86400` header | ||
|
|
||
| ### Requirement: Composer repository metadata enables p2 advisory discovery | ||
| The `packages.json` repository metadata SHALL set `"metadata": true` in the | ||
| `security-advisories` block so that Composer clients look up advisories via | ||
| the `/p2/` endpoints. | ||
|
|
||
| #### Scenario: packages.json reports metadata enabled | ||
| - **WHEN** a client fetches `/packages.json` | ||
| - **THEN** the response body SHALL contain `"metadata": true` inside the `security-advisories` object |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| ## 1. Configuration | ||
|
|
||
| - [x] 1.1 Update `internal/server/static/packages.json`: change `"metadata": false` to `"metadata": true` | ||
|
|
||
| ## 2. Handler | ||
|
|
||
| - [x] 2.1 Create `internal/server/handle_p2.go` with a `handleP2(store AdvisoriesMarshaler) http.HandlerFunc` | ||
| - [x] 2.2 Dispatch on the `{file}` path value: suffix `~dev.json` → 404; suffix `.json` → extract slug and continue; anything else → 404 | ||
| - [x] 2.3 Call `store.MarshalAdvisoriesFor(vendor, slug)`; on error return 404 | ||
| - [x] 2.4 On success write `{"packages":[],"security-advisories":<bytes>}` with status 200 and `Content-Type: application/json` | ||
|
|
||
| ## 3. Routing | ||
|
|
||
| - [x] 3.1 Register `GET /p2/{vendor}/{file}` in `internal/server/routes.go` wired to `handleP2(store)` | ||
|
|
||
| ## 4. Tests | ||
|
|
||
| - [x] 4.1 Update `handle_p2_test.go`: known-package stable path now expects 200 with correct JSON body | ||
| - [x] 4.2 Add test case: unknown-package stable path expects 404 | ||
| - [x] 4.3 Confirm dev path case still expects 404 (no change needed, verify only) | ||
| - [x] 4.4 Update `stubStore` in `server_test.go` if needed to support p2 test data | ||
|
|
||
| ## 5. Verification | ||
|
|
||
| - [x] 5.1 Run `mise run test:unit` — all tests pass | ||
| - [x] 5.2 Run `mise run lint` — no lint errors |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| schema: spec-driven | ||
|
|
||
| # Project context (optional) | ||
| # This is shown to AI when creating artifacts. | ||
| # Add your tech stack, conventions, style guides, domain knowledge, etc. | ||
| # Example: | ||
| # context: | | ||
| # Tech stack: TypeScript, React, Node.js | ||
| # We use conventional commits | ||
| # Domain: e-commerce platform | ||
|
|
||
| # Per-artifact rules (optional) | ||
| # Add custom rules for specific artifacts. | ||
| # Example: | ||
| # rules: | ||
| # proposal: | ||
| # - Keep proposals under 500 words | ||
| # - Always include a "Non-goals" section | ||
| # tasks: | ||
| # - Break tasks into chunks of max 2 hours |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The p2 tests no longer assert the
Cache-Control: max-age=86400header, even though the spec requires it for both stable and dev paths and other server tests verify Cache-Control behavior. Consider re-adding the Cache-Control assertion here to prevent regressions in caching semantics.