@@ -61,6 +61,10 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
6161 return this . handlerOptions ?. queryOptions ;
6262 }
6363
64+ private get nestedRoutes ( ) : boolean {
65+ return this . handlerOptions . nestedRoutes ?? false ;
66+ }
67+
6468 generateSpec ( options ?: OpenApiSpecOptions ) : OpenAPIV3_1 . Document {
6569 this . specOptions = options ;
6670 return {
@@ -124,14 +128,26 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
124128 const relIdFields = this . getIdFields ( relModelDef ) ;
125129 if ( relIdFields . length === 0 ) continue ;
126130
127- // GET /{model}/{id}/{field} — fetch related
131+ // GET /{model}/{id}/{field} — fetch related (+ nested create/update when nestedRoutes enabled)
128132 paths [ `/${ modelPath } /{id}/${ fieldName } ` ] = this . buildFetchRelatedPath (
129133 modelName ,
130134 fieldName ,
131135 fieldDef ,
132136 tag ,
133137 ) ;
134138
139+ // Nested single resource path: /{model}/{id}/{field}/{childId}
140+ if ( this . nestedRoutes && fieldDef . array ) {
141+ const relModelDef = this . schema . models [ fieldDef . type ] ! ;
142+ paths [ `/${ modelPath } /{id}/${ fieldName } /{childId}` ] = this . buildNestedSinglePath (
143+ modelName ,
144+ fieldName ,
145+ fieldDef ,
146+ relModelDef ,
147+ tag ,
148+ ) ;
149+ }
150+
135151 // Relationship management path
136152 paths [ `/${ modelPath } /{id}/relationships/${ fieldName } ` ] = this . buildRelationshipPath (
137153 modelDef ,
@@ -306,10 +322,10 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
306322 tag : string ,
307323 ) : Record < string , any > {
308324 const isCollection = ! ! fieldDef . array ;
325+ const relModelDef = this . schema . models [ fieldDef . type ] ;
309326 const params : any [ ] = [ { $ref : '#/components/parameters/id' } , { $ref : '#/components/parameters/include' } ] ;
310327
311- if ( isCollection && this . schema . models [ fieldDef . type ] ) {
312- const relModelDef = this . schema . models [ fieldDef . type ] ! ;
328+ if ( isCollection && relModelDef ) {
313329 params . push (
314330 { $ref : '#/components/parameters/sort' } ,
315331 { $ref : '#/components/parameters/pageOffset' } ,
@@ -318,7 +334,7 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
318334 ) ;
319335 }
320336
321- return {
337+ const pathItem : Record < string , any > = {
322338 get : {
323339 tags : [ tag ] ,
324340 summary : `Fetch related ${ fieldDef . type } for ${ modelName } ` ,
@@ -339,6 +355,144 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
339355 } ,
340356 } ,
341357 } ;
358+
359+ if ( this . nestedRoutes && relModelDef ) {
360+ const mayDeny = this . mayDenyAccess ( relModelDef , isCollection ? 'create' : 'update' ) ;
361+ if ( isCollection ) {
362+ // POST /{model}/{id}/{field} — nested create
363+ pathItem [ 'post' ] = {
364+ tags : [ tag ] ,
365+ summary : `Create a nested ${ fieldDef . type } under ${ modelName } ` ,
366+ operationId : `create${ modelName } _${ fieldName } ` ,
367+ parameters : [ { $ref : '#/components/parameters/id' } ] ,
368+ requestBody : {
369+ required : true ,
370+ content : {
371+ 'application/vnd.api+json' : {
372+ schema : { $ref : `#/components/schemas/${ fieldDef . type } CreateRequest` } ,
373+ } ,
374+ } ,
375+ } ,
376+ responses : {
377+ '201' : {
378+ description : `Created ${ fieldDef . type } resource` ,
379+ content : {
380+ 'application/vnd.api+json' : {
381+ schema : { $ref : `#/components/schemas/${ fieldDef . type } Response` } ,
382+ } ,
383+ } ,
384+ } ,
385+ '400' : ERROR_400 ,
386+ ...( mayDeny && { '403' : ERROR_403 } ) ,
387+ '422' : ERROR_422 ,
388+ } ,
389+ } ;
390+ } else {
391+ // PATCH /{model}/{id}/{field} — nested to-one update
392+ pathItem [ 'patch' ] = {
393+ tags : [ tag ] ,
394+ summary : `Update nested ${ fieldDef . type } under ${ modelName } ` ,
395+ operationId : `update${ modelName } _${ fieldName } ` ,
396+ parameters : [ { $ref : '#/components/parameters/id' } ] ,
397+ requestBody : {
398+ required : true ,
399+ content : {
400+ 'application/vnd.api+json' : {
401+ schema : { $ref : `#/components/schemas/${ fieldDef . type } UpdateRequest` } ,
402+ } ,
403+ } ,
404+ } ,
405+ responses : {
406+ '200' : {
407+ description : `Updated ${ fieldDef . type } resource` ,
408+ content : {
409+ 'application/vnd.api+json' : {
410+ schema : { $ref : `#/components/schemas/${ fieldDef . type } Response` } ,
411+ } ,
412+ } ,
413+ } ,
414+ '400' : ERROR_400 ,
415+ ...( mayDeny && { '403' : ERROR_403 } ) ,
416+ '404' : ERROR_404 ,
417+ '422' : ERROR_422 ,
418+ } ,
419+ } ;
420+ }
421+ }
422+
423+ return pathItem ;
424+ }
425+
426+ private buildNestedSinglePath (
427+ modelName : string ,
428+ fieldName : string ,
429+ fieldDef : FieldDef ,
430+ relModelDef : ModelDef ,
431+ tag : string ,
432+ ) : Record < string , any > {
433+ const childIdParam = { name : 'childId' , in : 'path' , required : true , schema : { type : 'string' } } ;
434+ const idParam = { $ref : '#/components/parameters/id' } ;
435+ const mayDenyUpdate = this . mayDenyAccess ( relModelDef , 'update' ) ;
436+ const mayDenyDelete = this . mayDenyAccess ( relModelDef , 'delete' ) ;
437+
438+ return {
439+ get : {
440+ tags : [ tag ] ,
441+ summary : `Get a nested ${ fieldDef . type } by ID under ${ modelName } ` ,
442+ operationId : `get${ modelName } _${ fieldName } _single` ,
443+ parameters : [ idParam , childIdParam , { $ref : '#/components/parameters/include' } ] ,
444+ responses : {
445+ '200' : {
446+ description : `${ fieldDef . type } resource` ,
447+ content : {
448+ 'application/vnd.api+json' : {
449+ schema : { $ref : `#/components/schemas/${ fieldDef . type } Response` } ,
450+ } ,
451+ } ,
452+ } ,
453+ '404' : ERROR_404 ,
454+ } ,
455+ } ,
456+ patch : {
457+ tags : [ tag ] ,
458+ summary : `Update a nested ${ fieldDef . type } by ID under ${ modelName } ` ,
459+ operationId : `update${ modelName } _${ fieldName } _single` ,
460+ parameters : [ idParam , childIdParam ] ,
461+ requestBody : {
462+ required : true ,
463+ content : {
464+ 'application/vnd.api+json' : {
465+ schema : { $ref : `#/components/schemas/${ fieldDef . type } UpdateRequest` } ,
466+ } ,
467+ } ,
468+ } ,
469+ responses : {
470+ '200' : {
471+ description : `Updated ${ fieldDef . type } resource` ,
472+ content : {
473+ 'application/vnd.api+json' : {
474+ schema : { $ref : `#/components/schemas/${ fieldDef . type } Response` } ,
475+ } ,
476+ } ,
477+ } ,
478+ '400' : ERROR_400 ,
479+ ...( mayDenyUpdate && { '403' : ERROR_403 } ) ,
480+ '404' : ERROR_404 ,
481+ '422' : ERROR_422 ,
482+ } ,
483+ } ,
484+ delete : {
485+ tags : [ tag ] ,
486+ summary : `Delete a nested ${ fieldDef . type } by ID under ${ modelName } ` ,
487+ operationId : `delete${ modelName } _${ fieldName } _single` ,
488+ parameters : [ idParam , childIdParam ] ,
489+ responses : {
490+ '200' : { description : 'Deleted successfully' } ,
491+ ...( mayDenyDelete && { '403' : ERROR_403 } ) ,
492+ '404' : ERROR_404 ,
493+ } ,
494+ } ,
495+ } ;
342496 }
343497
344498 private buildRelationshipPath (
0 commit comments