Skip to content

Commit 7d2e35e

Browse files
committed
fix(api): address review — required bodies, non-null arrays, strict schema names
Resolve the four review comments on #59: - ProjectOrDegraded.MarshalJSON now errors when neither Project nor Degraded is set instead of silently emitting `{"project": null}`, which would breach the required oneOf[Project, Degraded] contract. - requestBody.required: true is now set for addProject and updateProjectConfig via WithCustomize — swaggest left it absent (== optional) before. - schemaName replaces the TrimPrefix catch-all with an exhaustive default→clean map; an unrecognised type is returned verbatim so it surfaces in the diff rather than silently colliding with an existing schema. - nonNullableSlices strips the spurious "null" swaggest unions into every Go slice, so `projects` is `Summary[]` not `Summary[] | null`; the list handler normalises a nil slice to [] so the wire matches the non-nullable schema. Regenerated openapi.yaml + frontend schema.d.ts. Refs #59.
1 parent 465fec9 commit 7d2e35e

5 files changed

Lines changed: 111 additions & 42 deletions

File tree

backend/internal/httpd/apispec/openapi.yaml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ paths:
3434
application/json:
3535
schema:
3636
$ref: '#/components/schemas/AddInput'
37+
required: true
3738
responses:
3839
"201":
3940
content:
@@ -148,6 +149,7 @@ paths:
148149
application/json:
149150
schema:
150151
$ref: '#/components/schemas/UpdateConfigInput'
152+
required: true
151153
responses:
152154
"200":
153155
content:
@@ -296,9 +298,7 @@ components:
296298
projects:
297299
items:
298300
$ref: '#/components/schemas/Summary'
299-
type:
300-
- array
301-
- "null"
301+
type: array
302302
required:
303303
- projects
304304
type: object

backend/internal/httpd/apispec/specgen/build.go

Lines changed: 88 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ import (
1111
"reflect"
1212
"strings"
1313

14-
"github.com/swaggest/jsonschema-go"
15-
"github.com/swaggest/openapi-go"
14+
jsonschema "github.com/swaggest/jsonschema-go"
15+
openapi "github.com/swaggest/openapi-go"
1616
"github.com/swaggest/openapi-go/openapi31"
1717

1818
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/controllers"
@@ -37,8 +37,12 @@ func Build() ([]byte, error) {
3737
// Derive `required` from the idiomatic Go convention: a JSON field without
3838
// `omitempty` is required. swaggest does not infer this on its own, so the
3939
// structs stay clean (only description/enum tags) and this hook adds the
40-
// required array.
41-
r.DefaultOptions = append(r.DefaultOptions, jsonschema.InterceptProp(requiredFromJSONTag))
40+
// required array. nonNullableSlices drops the spurious "null" type swaggest
41+
// stamps on every Go slice.
42+
r.DefaultOptions = append(r.DefaultOptions,
43+
jsonschema.InterceptProp(requiredFromJSONTag),
44+
jsonschema.InterceptNullability(nonNullableSlices),
45+
)
4246
// Clean component schema names (which become the generated TS type names):
4347
// swaggest defaults to PackageType, e.g. "ProjectProject", "EnvelopeAPIError".
4448
r.InterceptDefName(schemaName)
@@ -63,8 +67,14 @@ func Build() ([]byte, error) {
6367
oc.SetID(op.id)
6468
oc.SetSummary(op.summary)
6569
oc.SetTags("projects")
66-
for _, req := range op.reqs {
67-
oc.AddReqStructure(req)
70+
for _, param := range op.pathParams {
71+
oc.AddReqStructure(param)
72+
}
73+
if op.reqBody != nil {
74+
// AddReqStructure leaves requestBody.required absent, which
75+
// OpenAPI reads as optional. These bodies are mandatory, so force
76+
// it — otherwise validators/generators treat the body as skippable.
77+
oc.AddReqStructure(op.reqBody, openapi.WithCustomize(markRequestBodyRequired))
6878
}
6979
for _, resp := range op.resps {
7080
oc.AddRespStructure(resp.body, openapi.WithHTTPStatus(resp.status))
@@ -77,26 +87,68 @@ func Build() ([]byte, error) {
7787
return r.Spec.MarshalYAML()
7888
}
7989

80-
// schemaName maps swaggest's default PackageType component names to clean,
81-
// stable schema names (these become the generated TypeScript type names).
90+
// schemaName maps swaggest's default PackageType component names (e.g.
91+
// "ProjectProject", "EnvelopeAPIError") to the clean, stable schema names that
92+
// become the generated TypeScript type names. Every reflected type is listed
93+
// explicitly: an unrecognised default name is returned verbatim, so a new type
94+
// surfaces as a visibly-wrong "PackageType" name in the diff (and the drift
95+
// test) rather than silently colliding with an existing schema via a
96+
// TrimPrefix catch-all.
8297
func schemaName(_ reflect.Type, defaultName string) string {
83-
switch defaultName {
84-
case "EnvelopeAPIError":
85-
return "APIError"
86-
case "DomainProjectID":
87-
return "ProjectID"
88-
case "ControllersListProjectsResponse":
89-
return "ListProjectsResponse"
90-
case "ControllersProjectResponse":
91-
return "ProjectResponse"
92-
case "ControllersGetProjectResponse":
93-
return "ProjectGetResponse"
94-
case "ControllersProjectOrDegraded":
95-
return "ProjectOrDegraded"
98+
if clean, ok := schemaNames[defaultName]; ok {
99+
return clean
100+
}
101+
return defaultName
102+
}
103+
104+
// schemaNames is the exhaustive default→clean mapping for every type reflected
105+
// by projectOperations(). Add an entry when a new contract type is introduced;
106+
// the drift test fails until the spec is regenerated, which flags the gap.
107+
var schemaNames = map[string]string{
108+
// httpd/envelope
109+
"EnvelopeAPIError": "APIError",
110+
// domain
111+
"DomainProjectID": "ProjectID",
112+
// httpd/controllers (wire envelopes)
113+
"ControllersListProjectsResponse": "ListProjectsResponse",
114+
"ControllersProjectResponse": "ProjectResponse",
115+
"ControllersGetProjectResponse": "ProjectGetResponse",
116+
"ControllersProjectOrDegraded": "ProjectOrDegraded",
117+
// project (entities + DTOs)
118+
"ProjectProject": "Project",
119+
"ProjectSummary": "Summary",
120+
"ProjectDegraded": "Degraded",
121+
"ProjectAddInput": "AddInput",
122+
"ProjectUpdateConfigInput": "UpdateConfigInput",
123+
"ProjectRemoveResult": "RemoveResult",
124+
"ProjectReloadResult": "ReloadResult",
125+
"ProjectTrackerConfig": "TrackerConfig",
126+
"ProjectSCMConfig": "SCMConfig",
127+
"ProjectSCMWebhookConfig": "SCMWebhookConfig",
128+
"ProjectReactionConfig": "ReactionConfig",
129+
}
130+
131+
// markRequestBodyRequired sets requestBody.required: true on the operation's
132+
// JSON body. swaggest leaves it absent (== optional) for AddReqStructure bodies.
133+
func markRequestBodyRequired(cor openapi.ContentOrReference) {
134+
if rb, ok := cor.(*openapi31.RequestBodyOrReference); ok && rb.RequestBody != nil {
135+
rb.RequestBody.WithRequired(true)
136+
}
137+
}
138+
139+
// nonNullableSlices drops the "null" that swaggest unions into every Go slice
140+
// type (a nil slice marshals as JSON null). A required array field should be
141+
// `T[]`, not `T[] | null`; the handlers normalise nil to an empty slice, so
142+
// null never reaches the wire. Byte slices (base64 strings) are left alone.
143+
func nonNullableSlices(p jsonschema.InterceptNullabilityParams) {
144+
if !p.NullAdded || p.Type == nil || p.Type.Kind() != reflect.Slice {
145+
return
146+
}
147+
if p.Type.Elem().Kind() == reflect.Uint8 {
148+
return
96149
}
97-
// project.* types: "ProjectProject" -> "Project", "ProjectSummary" -> "Summary",
98-
// "ProjectAddInput" -> "AddInput", "ProjectTrackerConfig" -> "TrackerConfig", etc.
99-
return strings.TrimPrefix(defaultName, "Project")
150+
p.Schema.TypeEns().WithSimpleTypes(jsonschema.Array)
151+
p.Schema.Type.SliceOfSimpleTypeValues = nil
100152
}
101153

102154
// requiredFromJSONTag marks a property required when its json tag lacks
@@ -139,7 +191,8 @@ type respUnit struct {
139191

140192
type operation struct {
141193
method, path, id, summary string
142-
reqs []any
194+
pathParams []any // path/query param containers (e.g. ProjectIDParam)
195+
reqBody any // JSON request body struct, nil when the op takes none
143196
resps []respUnit
144197
}
145198

@@ -159,7 +212,7 @@ func projectOperations() []operation {
159212
{
160213
method: http.MethodPost, path: "/api/v1/projects", id: "addProject",
161214
summary: "Register a new project from a git repository path",
162-
reqs: []any{project.AddInput{}},
215+
reqBody: project.AddInput{},
163216
resps: []respUnit{
164217
{http.StatusCreated, controllers.ProjectResponse{}},
165218
{http.StatusBadRequest, envelope.APIError{}},
@@ -177,8 +230,8 @@ func projectOperations() []operation {
177230
},
178231
{
179232
method: http.MethodGet, path: "/api/v1/projects/{id}", id: "getProject",
180-
summary: "Fetch one project; discriminates ok vs degraded",
181-
reqs: []any{controllers.ProjectIDParam{}},
233+
summary: "Fetch one project; discriminates ok vs degraded",
234+
pathParams: []any{controllers.ProjectIDParam{}},
182235
resps: []respUnit{
183236
{http.StatusOK, controllers.GetProjectResponse{}},
184237
{http.StatusNotFound, envelope.APIError{}},
@@ -187,8 +240,9 @@ func projectOperations() []operation {
187240
},
188241
{
189242
method: http.MethodPatch, path: "/api/v1/projects/{id}", id: "updateProjectConfig",
190-
summary: "Patch behaviour-only fields (identity is frozen)",
191-
reqs: []any{controllers.ProjectIDParam{}, project.UpdateConfigInput{}},
243+
summary: "Patch behaviour-only fields (identity is frozen)",
244+
pathParams: []any{controllers.ProjectIDParam{}},
245+
reqBody: project.UpdateConfigInput{},
192246
resps: []respUnit{
193247
{http.StatusOK, controllers.ProjectResponse{}},
194248
{http.StatusBadRequest, envelope.APIError{}},
@@ -199,8 +253,8 @@ func projectOperations() []operation {
199253
},
200254
{
201255
method: http.MethodDelete, path: "/api/v1/projects/{id}", id: "removeProject",
202-
summary: "Remove a project; stops sessions, cleans workspaces, unregisters",
203-
reqs: []any{controllers.ProjectIDParam{}},
256+
summary: "Remove a project; stops sessions, cleans workspaces, unregisters",
257+
pathParams: []any{controllers.ProjectIDParam{}},
204258
resps: []respUnit{
205259
{http.StatusOK, project.RemoveResult{}},
206260
{http.StatusBadRequest, envelope.APIError{}},
@@ -210,8 +264,8 @@ func projectOperations() []operation {
210264
},
211265
{
212266
method: http.MethodPost, path: "/api/v1/projects/{id}/repair", id: "repairProject",
213-
summary: "Recover a degraded project where automatic repair is available",
214-
reqs: []any{controllers.ProjectIDParam{}},
267+
summary: "Recover a degraded project where automatic repair is available",
268+
pathParams: []any{controllers.ProjectIDParam{}},
215269
resps: []respUnit{
216270
{http.StatusOK, controllers.ProjectResponse{}},
217271
{http.StatusNotFound, envelope.APIError{}},

backend/internal/httpd/controllers/dto.go

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package controllers
22

33
import (
44
"encoding/json"
5+
"errors"
56

67
"github.com/aoagents/agent-orchestrator/backend/internal/project"
78
)
@@ -49,10 +50,19 @@ type ProjectOrDegraded struct {
4950
}
5051

5152
func (p ProjectOrDegraded) MarshalJSON() ([]byte, error) {
52-
if p.Degraded != nil {
53+
switch {
54+
case p.Degraded != nil:
5355
return json.Marshal(p.Degraded)
56+
case p.Project != nil:
57+
return json.Marshal(p.Project)
58+
default:
59+
// Neither variant set — the spec declares `project` as a required
60+
// oneOf[Project, Degraded], so emitting `null` would silently breach
61+
// the contract. Fail loudly instead: a GetResult with both pointers
62+
// nil is a Manager bug, and the encode error surfaces it rather than
63+
// shipping an invalid body.
64+
return nil, errors.New("controllers: ProjectOrDegraded has neither Project nor Degraded set")
5465
}
55-
return json.Marshal(p.Project)
5666
}
5767

5868
// JSONSchemaOneOf is read by swaggest's reflector (apispec.Build) to emit the

backend/internal/httpd/controllers/projects.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@ func (c *ProjectsController) list(w http.ResponseWriter, r *http.Request) {
4848
writeProjectError(w, r, err)
4949
return
5050
}
51+
// The spec types `projects` as a non-nullable array; a nil slice would
52+
// marshal as JSON null and breach that. Normalise so the wire is always [].
53+
if projects == nil {
54+
projects = []project.Summary{}
55+
}
5156
envelope.WriteJSON(w, http.StatusOK, ListProjectsResponse{Projects: projects})
5257
}
5358

frontend/src/api/schema.d.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ export interface components {
100100
resolveError: string;
101101
};
102102
ListProjectsResponse: {
103-
projects: components["schemas"]["Summary"][] | null;
103+
projects: components["schemas"]["Summary"][];
104104
};
105105
Project: {
106106
agent?: string;
@@ -224,7 +224,7 @@ export interface operations {
224224
path?: never;
225225
cookie?: never;
226226
};
227-
requestBody?: {
227+
requestBody: {
228228
content: {
229229
"application/json": components["schemas"]["AddInput"];
230230
};
@@ -369,7 +369,7 @@ export interface operations {
369369
};
370370
cookie?: never;
371371
};
372-
requestBody?: {
372+
requestBody: {
373373
content: {
374374
"application/json": components["schemas"]["UpdateConfigInput"];
375375
};

0 commit comments

Comments
 (0)