@@ -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.
8297func 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
140192type 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 {}},
0 commit comments