Skip to content

Commit bc07aa9

Browse files
committed
Generate OpenAPI spec programmatically instead of maintaining a static file
Replace the hand-written spec.json with Go code in internal/apidoc/spec.go. The spec is built at startup from an entitySpec table and buildSpec(), covering all popularity endpoints and the submit endpoint. Internal endpoints (countries, mirrors, system/OS architectures, submit) are excluded from the production spec and only included in development/test environments. Add Config.IsDevelopment() to centralise the environment string comparisons that were previously scattered across main.go and the apidoc package.
1 parent c29b8f9 commit bc07aa9

7 files changed

Lines changed: 472 additions & 304 deletions

File tree

internal/apidoc/handler.go

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,29 @@
11
package apidoc
22

33
import (
4-
_ "embed"
4+
"encoding/json"
55
"net/http"
66
)
77

8-
//go:embed spec.json
9-
var specJSON []byte
10-
118
// Handler handles HTTP requests for the OpenAPI documentation.
12-
type Handler struct{}
9+
type Handler struct {
10+
specJSON []byte
11+
}
1312

14-
// NewHandler creates a new Handler.
15-
func NewHandler() *Handler {
16-
return &Handler{}
13+
// NewHandler creates a new Handler and builds the OpenAPI spec.
14+
// Internal endpoints are included only when includeInternal is true.
15+
func NewHandler(includeInternal bool) *Handler {
16+
data, err := json.MarshalIndent(buildSpec(includeInternal), "", " ")
17+
if err != nil {
18+
panic("apidoc: failed to marshal OpenAPI spec: " + err.Error())
19+
}
20+
return &Handler{specJSON: data}
1721
}
1822

1923
// HandleDocJSON handles GET /api/doc.json
2024
func (h *Handler) HandleDocJSON(w http.ResponseWriter, _ *http.Request) {
2125
w.Header().Set("Content-Type", "application/json")
22-
_, _ = w.Write(specJSON)
26+
_, _ = w.Write(h.specJSON)
2327
}
2428

2529
// RegisterRoutes registers the API documentation routes on the given mux.

internal/apidoc/handler_test.go

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import (
88
)
99

1010
func TestHandleDocJSON(t *testing.T) {
11-
handler := NewHandler()
11+
handler := NewHandler(true)
1212
mux := http.NewServeMux()
1313
handler.RegisterRoutes(mux)
1414

@@ -41,4 +41,82 @@ func TestHandleDocJSON(t *testing.T) {
4141
if info["title"] != "pkgstats API documentation" {
4242
t.Errorf("expected title 'pkgstats API documentation', got %v", info["title"])
4343
}
44+
45+
paths, ok := doc["paths"].(map[string]any)
46+
if !ok {
47+
t.Fatal("expected paths to be an object")
48+
}
49+
50+
expectedPaths := []string{
51+
"/api/packages",
52+
"/api/packages/{name}",
53+
"/api/packages/{name}/series",
54+
"/api/countries",
55+
"/api/countries/{code}",
56+
"/api/countries/{code}/series",
57+
"/api/mirrors",
58+
"/api/mirrors/{url}",
59+
"/api/mirrors/{url}/series",
60+
"/api/system-architectures",
61+
"/api/system-architectures/{name}",
62+
"/api/system-architectures/{name}/series",
63+
"/api/operating-systems",
64+
"/api/operating-systems/{id}",
65+
"/api/operating-systems/{id}/series",
66+
"/api/operating-system-architectures",
67+
"/api/operating-system-architectures/{name}",
68+
"/api/operating-system-architectures/{name}/series",
69+
"/api/submit",
70+
}
71+
72+
for _, p := range expectedPaths {
73+
if _, found := paths[p]; !found {
74+
t.Errorf("expected path %q in development spec", p)
75+
}
76+
}
77+
}
78+
79+
func TestHandleDocJSONProduction(t *testing.T) {
80+
handler := NewHandler(false)
81+
mux := http.NewServeMux()
82+
handler.RegisterRoutes(mux)
83+
84+
req := httptest.NewRequest(http.MethodGet, "/api/doc.json", nil)
85+
rr := httptest.NewRecorder()
86+
mux.ServeHTTP(rr, req)
87+
88+
var doc map[string]any
89+
if err := json.Unmarshal(rr.Body.Bytes(), &doc); err != nil {
90+
t.Fatalf("response is not valid JSON: %v", err)
91+
}
92+
93+
paths, ok := doc["paths"].(map[string]any)
94+
if !ok {
95+
t.Fatal("expected paths to be an object")
96+
}
97+
98+
publicPaths := []string{
99+
"/api/packages",
100+
"/api/packages/{name}",
101+
"/api/packages/{name}/series",
102+
}
103+
for _, p := range publicPaths {
104+
if _, found := paths[p]; !found {
105+
t.Errorf("expected public path %q in production spec", p)
106+
}
107+
}
108+
109+
internalPaths := []string{
110+
"/api/countries",
111+
"/api/mirrors",
112+
"/api/system-architectures",
113+
"/api/operating-systems",
114+
"/api/operating-system-architectures",
115+
"/api/submit",
116+
}
117+
for _, p := range internalPaths {
118+
if _, found := paths[p]; found {
119+
t.Errorf("internal path %q should not appear in production spec", p)
120+
}
121+
}
44122
}

0 commit comments

Comments
 (0)