Skip to content

Commit e356d4f

Browse files
committed
Remove POST endpoint from spec, add renderability test
1 parent 4302428 commit e356d4f

File tree

4 files changed

+127
-101
lines changed

4 files changed

+127
-101
lines changed

internal/apidoc/handler_test.go

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,6 @@ func TestHandleDocJSON(t *testing.T) {
7070
"/api/operating-system-architectures",
7171
"/api/operating-system-architectures/{name}",
7272
"/api/operating-system-architectures/{name}/series",
73-
"/api/submit",
7473
}
7574

7675
for _, p := range expectedPaths {
@@ -116,7 +115,6 @@ func TestHandleDocJSONProduction(t *testing.T) {
116115
"/api/system-architectures",
117116
"/api/operating-systems",
118117
"/api/operating-system-architectures",
119-
"/api/submit",
120118
}
121119
for _, p := range internalPaths {
122120
if _, found := paths[p]; found {

internal/apidoc/spec.go

Lines changed: 1 addition & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -24,16 +24,14 @@ type SpecTag struct {
2424
}
2525

2626
type PathItem struct {
27-
Get *Operation `json:"get,omitempty"`
28-
Post *Operation `json:"post,omitempty"`
27+
Get *Operation `json:"get,omitempty"`
2928
}
3029

3130
type Operation struct {
3231
Tags []string `json:"tags,omitempty"`
3332
Summary string `json:"summary,omitempty"`
3433
OperationID string `json:"operationId,omitempty"`
3534
Parameters []Parameter `json:"parameters,omitempty"`
36-
RequestBody *RequestBody `json:"requestBody,omitempty"`
3735
Responses map[string]Response `json:"responses"`
3836
}
3937

@@ -45,11 +43,6 @@ type Parameter struct {
4543
Schema *Schema `json:"schema,omitempty"`
4644
}
4745

48-
type RequestBody struct {
49-
Required bool `json:"required,omitempty"`
50-
Content map[string]MediaType `json:"content"`
51-
}
52-
5346
type MediaType struct {
5447
Schema *Schema `json:"schema,omitempty"`
5548
}
@@ -298,63 +291,5 @@ func BuildSpec(includeInternal bool) *OpenAPISpec {
298291
}
299292
}
300293

301-
if includeInternal {
302-
spec.Tags = append(spec.Tags, SpecTag{Name: "submit"})
303-
spec.Paths["/api/submit"] = PathItem{
304-
Post: &Operation{
305-
Tags: []string{"submit"},
306-
Summary: "Submit package statistics",
307-
OperationID: "submit",
308-
RequestBody: &RequestBody{
309-
Required: true,
310-
Content: map[string]MediaType{
311-
"application/json": {Schema: &Schema{Ref: "#/components/schemas/SubmitRequest"}},
312-
},
313-
},
314-
Responses: map[string]Response{
315-
"204": {Description: "Statistics accepted"},
316-
"400": {Description: "Invalid request"},
317-
"429": {Description: "Rate limit exceeded"},
318-
"500": {Description: "Internal server error"},
319-
},
320-
},
321-
}
322-
spec.Components.Schemas["SubmitRequest"] = &Schema{
323-
Type: "object",
324-
Required: []string{"version", "system", "os", "pacman"},
325-
Properties: map[string]*Schema{
326-
"version": {Type: "string", Enum: []string{"3"}},
327-
"system": {
328-
Type: "object",
329-
Required: []string{"architecture"},
330-
Properties: map[string]*Schema{
331-
"architecture": {Type: "string"},
332-
},
333-
},
334-
"os": {
335-
Type: "object",
336-
Required: []string{"architecture"},
337-
Properties: map[string]*Schema{
338-
"architecture": {Type: "string"},
339-
"id": {Type: "string", Pattern: `^[0-9a-z._-]{1,50}$`},
340-
},
341-
},
342-
"pacman": {
343-
Type: "object",
344-
Required: []string{"packages"},
345-
Properties: map[string]*Schema{
346-
"mirror": {Type: "string"},
347-
"packages": {
348-
Type: "array",
349-
Items: &Schema{Type: "string"},
350-
MinItems: new(1),
351-
MaxItems: new(submit.MaxPackages),
352-
},
353-
},
354-
},
355-
},
356-
}
357-
}
358-
359294
return spec
360295
}
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
package apidoc
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"pkgstatsd/internal/apidoc"
8+
)
9+
10+
var allowedPropertyTypes = map[string]bool{
11+
"string": true,
12+
"integer": true,
13+
"number": true,
14+
"array": true,
15+
}
16+
17+
var allowedTopLevelSchemaTypes = map[string]bool{
18+
"object": true,
19+
}
20+
21+
var allowedParameterLocations = map[string]bool{
22+
"query": true,
23+
"path": true,
24+
}
25+
26+
func TestSpecIsRenderable(t *testing.T) {
27+
for _, includeInternal := range []bool{false, true} {
28+
spec := apidoc.BuildSpec(includeInternal)
29+
30+
if len(spec.Paths) == 0 {
31+
t.Fatal("spec has no paths")
32+
}
33+
34+
for path, item := range spec.Paths {
35+
if item.Get == nil {
36+
t.Errorf("path %q: missing GET operation", path)
37+
continue
38+
}
39+
assertOperationRenderable(t, path, item.Get, spec.Components)
40+
}
41+
42+
for name, s := range spec.Components.Schemas {
43+
assertTopLevelSchemaRenderable(t, "schema "+name, s, spec.Components)
44+
}
45+
}
46+
}
47+
48+
func assertOperationRenderable(t *testing.T, path string, op *apidoc.Operation, components apidoc.SpecComponents) {
49+
t.Helper()
50+
51+
for _, p := range op.Parameters {
52+
if !allowedParameterLocations[p.In] {
53+
t.Errorf("path %q param %q: unsupported location %q", path, p.Name, p.In)
54+
}
55+
if p.Schema != nil {
56+
assertPropertySchemaRenderable(t, path+" param "+p.Name, p.Schema, components)
57+
}
58+
}
59+
60+
for code, resp := range op.Responses {
61+
for _, media := range resp.Content {
62+
if media.Schema == nil {
63+
continue
64+
}
65+
if media.Schema.Ref == "" {
66+
t.Errorf("path %q response %s: expected $ref to component schema, got inline schema", path, code)
67+
continue
68+
}
69+
name := strings.TrimPrefix(media.Schema.Ref, "#/components/schemas/")
70+
if _, ok := components.Schemas[name]; !ok {
71+
t.Errorf("path %q response %s: unresolved $ref %q", path, code, media.Schema.Ref)
72+
}
73+
}
74+
}
75+
}
76+
77+
func assertTopLevelSchemaRenderable(t *testing.T, context string, s *apidoc.Schema, components apidoc.SpecComponents) {
78+
t.Helper()
79+
80+
if !allowedTopLevelSchemaTypes[s.Type] {
81+
t.Errorf("%s: unsupported top-level schema type %q", context, s.Type)
82+
}
83+
84+
for propName, prop := range s.Properties {
85+
assertPropertySchemaRenderable(t, context+"."+propName, prop, components)
86+
}
87+
}
88+
89+
func assertPropertySchemaRenderable(t *testing.T, context string, s *apidoc.Schema, components apidoc.SpecComponents) {
90+
t.Helper()
91+
92+
if s.Ref != "" {
93+
name := strings.TrimPrefix(s.Ref, "#/components/schemas/")
94+
if _, ok := components.Schemas[name]; !ok {
95+
t.Errorf("%s: unresolved $ref %q", context, s.Ref)
96+
}
97+
return
98+
}
99+
100+
if !allowedPropertyTypes[s.Type] {
101+
t.Errorf("%s: unsupported property type %q", context, s.Type)
102+
}
103+
104+
if s.Type == "array" && s.Items != nil {
105+
if s.Items.Ref != "" {
106+
name := strings.TrimPrefix(s.Items.Ref, "#/components/schemas/")
107+
if _, ok := components.Schemas[name]; !ok {
108+
t.Errorf("%s: unresolved array item $ref %q", context, s.Items.Ref)
109+
}
110+
} else if !allowedPropertyTypes[s.Items.Type] {
111+
t.Errorf("%s: unsupported array item type %q", context, s.Items.Type)
112+
}
113+
}
114+
115+
if s.Properties != nil {
116+
t.Errorf("%s: nested object properties not supported by renderer", context)
117+
}
118+
}

internal/ui/apidoc/templates.templ

Lines changed: 8 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,8 @@ func sortedResponseCodes(responses map[string]apidoc.Response) []string {
4141
return keys
4242
}
4343

44-
func accordionID(method, path string) string {
45-
s := method + "-" + path
46-
s = strings.ReplaceAll(s, "/", "-")
44+
func accordionID(path string) string {
45+
s := strings.ReplaceAll(path, "/", "-")
4746
s = strings.ReplaceAll(s, "{", "")
4847
s = strings.ReplaceAll(s, "}", "")
4948
return strings.Trim(s, "-")
@@ -122,65 +121,41 @@ func sortedProperties(properties map[string]*apidoc.Schema) []string {
122121

123122
templ renderPath(path string, item apidoc.PathItem, components apidoc.SpecComponents) {
124123
if item.Get != nil {
125-
@renderOperation("GET", path, item.Get, components)
126-
}
127-
if item.Post != nil {
128-
@renderOperation("POST", path, item.Post, components)
124+
@renderOperation(path, item.Get, components)
129125
}
130126
}
131127

132-
templ renderOperation(method, path string, op *apidoc.Operation, components apidoc.SpecComponents) {
128+
templ renderOperation(path string, op *apidoc.Operation, components apidoc.SpecComponents) {
133129
<div class="accordion-item">
134130
<h2 class="accordion-header">
135131
<button
136132
class="accordion-button collapsed"
137133
type="button"
138134
data-bs-toggle="collapse"
139-
data-bs-target={ "#" + accordionID(method, path) }
135+
data-bs-target={ "#" + accordionID(path) }
140136
aria-expanded="false"
141-
aria-controls={ accordionID(method, path) }
137+
aria-controls={ accordionID(path) }
142138
>
143-
@methodBadge(method)
139+
<span class="badge text-bg-primary">GET</span>
144140
<code class="ms-2">{ path }</code>
145141
if op.Summary != "" {
146142
<span class="text-body-secondary ms-3">{ op.Summary }</span>
147143
}
148144
</button>
149145
</h2>
150-
<div id={ accordionID(method, path) } class="accordion-collapse collapse">
146+
<div id={ accordionID(path) } class="accordion-collapse collapse">
151147
<div class="accordion-body">
152148
if len(op.Parameters) > 0 {
153149
<h3 class="h6">Parameters</h3>
154150
@renderParameters(op.Parameters)
155151
}
156-
if op.RequestBody != nil {
157-
<h3 class="h6 mt-3">Request body</h3>
158-
for contentType, media := range op.RequestBody.Content {
159-
<p class="text-body-secondary mb-2">Content type: <code>{ contentType }</code></p>
160-
@renderSchemaDetail(media.Schema, components)
161-
}
162-
}
163152
<h3 class="h6 mt-3">Responses</h3>
164153
@renderResponses(op.Responses, components)
165154
</div>
166155
</div>
167156
</div>
168157
}
169158

170-
templ methodBadge(method string) {
171-
if method == "GET" {
172-
<span class="badge text-bg-primary">GET</span>
173-
} else if method == "POST" {
174-
<span class="badge text-bg-success">POST</span>
175-
} else if method == "PUT" {
176-
<span class="badge text-bg-warning">PUT</span>
177-
} else if method == "DELETE" {
178-
<span class="badge text-bg-danger">DELETE</span>
179-
} else {
180-
<span class="badge text-bg-secondary">{ method }</span>
181-
}
182-
}
183-
184159
templ renderParameters(params []apidoc.Parameter) {
185160
<div class="table-responsive">
186161
<table class="table table-sm">

0 commit comments

Comments
 (0)