@@ -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,28 @@ 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 nestedSinglePath = this . buildNestedSinglePath (
142+ modelName ,
143+ fieldName ,
144+ fieldDef ,
145+ relModelDef ,
146+ tag ,
147+ ) ;
148+ if ( Object . keys ( nestedSinglePath ) . length > 0 ) {
149+ paths [ `/${ modelPath } /{id}/${ fieldName } /{childId}` ] = nestedSinglePath ;
150+ }
151+ }
152+
135153 // Relationship management path
136154 paths [ `/${ modelPath } /{id}/relationships/${ fieldName } ` ] = this . buildRelationshipPath (
137155 modelDef ,
@@ -306,10 +324,10 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
306324 tag : string ,
307325 ) : Record < string , any > {
308326 const isCollection = ! ! fieldDef . array ;
327+ const relModelDef = this . schema . models [ fieldDef . type ] ;
309328 const params : any [ ] = [ { $ref : '#/components/parameters/id' } , { $ref : '#/components/parameters/include' } ] ;
310329
311- if ( isCollection && this . schema . models [ fieldDef . type ] ) {
312- const relModelDef = this . schema . models [ fieldDef . type ] ! ;
330+ if ( isCollection && relModelDef ) {
313331 params . push (
314332 { $ref : '#/components/parameters/sort' } ,
315333 { $ref : '#/components/parameters/pageOffset' } ,
@@ -318,7 +336,7 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
318336 ) ;
319337 }
320338
321- return {
339+ const pathItem : Record < string , any > = {
322340 get : {
323341 tags : [ tag ] ,
324342 summary : `Fetch related ${ fieldDef . type } for ${ modelName } ` ,
@@ -339,6 +357,153 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
339357 } ,
340358 } ,
341359 } ;
360+
361+ if ( this . nestedRoutes && relModelDef ) {
362+ const mayDeny = this . mayDenyAccess ( relModelDef , isCollection ? 'create' : 'update' ) ;
363+ if ( isCollection && isOperationIncluded ( fieldDef . type , 'create' , this . queryOptions ) ) {
364+ // POST /{model}/{id}/{field} — nested create
365+ pathItem [ 'post' ] = {
366+ tags : [ tag ] ,
367+ summary : `Create a nested ${ fieldDef . type } under ${ modelName } ` ,
368+ operationId : `create${ modelName } _${ fieldName } ` ,
369+ parameters : [ { $ref : '#/components/parameters/id' } ] ,
370+ requestBody : {
371+ required : true ,
372+ content : {
373+ 'application/vnd.api+json' : {
374+ schema : { $ref : `#/components/schemas/${ fieldDef . type } CreateRequest` } ,
375+ } ,
376+ } ,
377+ } ,
378+ responses : {
379+ '201' : {
380+ description : `Created ${ fieldDef . type } resource` ,
381+ content : {
382+ 'application/vnd.api+json' : {
383+ schema : { $ref : `#/components/schemas/${ fieldDef . type } Response` } ,
384+ } ,
385+ } ,
386+ } ,
387+ '400' : ERROR_400 ,
388+ ...( mayDeny && { '403' : ERROR_403 } ) ,
389+ '422' : ERROR_422 ,
390+ } ,
391+ } ;
392+ } else if ( isOperationIncluded ( fieldDef . type , 'update' , this . queryOptions ) ) {
393+ // PATCH /{model}/{id}/{field} — nested to-one update
394+ pathItem [ 'patch' ] = {
395+ tags : [ tag ] ,
396+ summary : `Update nested ${ fieldDef . type } under ${ modelName } ` ,
397+ operationId : `update${ modelName } _${ fieldName } ` ,
398+ parameters : [ { $ref : '#/components/parameters/id' } ] ,
399+ requestBody : {
400+ required : true ,
401+ content : {
402+ 'application/vnd.api+json' : {
403+ schema : { $ref : `#/components/schemas/${ fieldDef . type } UpdateRequest` } ,
404+ } ,
405+ } ,
406+ } ,
407+ responses : {
408+ '200' : {
409+ description : `Updated ${ fieldDef . type } resource` ,
410+ content : {
411+ 'application/vnd.api+json' : {
412+ schema : { $ref : `#/components/schemas/${ fieldDef . type } Response` } ,
413+ } ,
414+ } ,
415+ } ,
416+ '400' : ERROR_400 ,
417+ ...( mayDeny && { '403' : ERROR_403 } ) ,
418+ '404' : ERROR_404 ,
419+ '422' : ERROR_422 ,
420+ } ,
421+ } ;
422+ }
423+ }
424+
425+ return pathItem ;
426+ }
427+
428+ private buildNestedSinglePath (
429+ modelName : string ,
430+ fieldName : string ,
431+ fieldDef : FieldDef ,
432+ relModelDef : ModelDef ,
433+ tag : string ,
434+ ) : Record < string , any > {
435+ const childIdParam = { name : 'childId' , in : 'path' , required : true , schema : { type : 'string' } } ;
436+ const idParam = { $ref : '#/components/parameters/id' } ;
437+ const mayDenyUpdate = this . mayDenyAccess ( relModelDef , 'update' ) ;
438+ const mayDenyDelete = this . mayDenyAccess ( relModelDef , 'delete' ) ;
439+ const result : Record < string , any > = { } ;
440+
441+ if ( isOperationIncluded ( fieldDef . type , 'findUnique' , this . queryOptions ) ) {
442+ result [ 'get' ] = {
443+ tags : [ tag ] ,
444+ summary : `Get a nested ${ fieldDef . type } by ID under ${ modelName } ` ,
445+ operationId : `get${ modelName } _${ fieldName } _single` ,
446+ parameters : [ idParam , childIdParam , { $ref : '#/components/parameters/include' } ] ,
447+ responses : {
448+ '200' : {
449+ description : `${ fieldDef . type } resource` ,
450+ content : {
451+ 'application/vnd.api+json' : {
452+ schema : { $ref : `#/components/schemas/${ fieldDef . type } Response` } ,
453+ } ,
454+ } ,
455+ } ,
456+ '404' : ERROR_404 ,
457+ } ,
458+ } ;
459+ }
460+
461+ if ( isOperationIncluded ( fieldDef . type , 'update' , this . queryOptions ) ) {
462+ result [ 'patch' ] = {
463+ tags : [ tag ] ,
464+ summary : `Update a nested ${ fieldDef . type } by ID under ${ modelName } ` ,
465+ operationId : `update${ modelName } _${ fieldName } _single` ,
466+ parameters : [ idParam , childIdParam ] ,
467+ requestBody : {
468+ required : true ,
469+ content : {
470+ 'application/vnd.api+json' : {
471+ schema : { $ref : `#/components/schemas/${ fieldDef . type } UpdateRequest` } ,
472+ } ,
473+ } ,
474+ } ,
475+ responses : {
476+ '200' : {
477+ description : `Updated ${ fieldDef . type } resource` ,
478+ content : {
479+ 'application/vnd.api+json' : {
480+ schema : { $ref : `#/components/schemas/${ fieldDef . type } Response` } ,
481+ } ,
482+ } ,
483+ } ,
484+ '400' : ERROR_400 ,
485+ ...( mayDenyUpdate && { '403' : ERROR_403 } ) ,
486+ '404' : ERROR_404 ,
487+ '422' : ERROR_422 ,
488+ } ,
489+ } ;
490+ }
491+
492+ if ( isOperationIncluded ( fieldDef . type , 'delete' , this . queryOptions ) ) {
493+ result [ 'delete' ] = {
494+ tags : [ tag ] ,
495+ summary : `Delete a nested ${ fieldDef . type } by ID under ${ modelName } ` ,
496+ operationId : `delete${ modelName } _${ fieldName } _single` ,
497+ parameters : [ idParam , childIdParam ] ,
498+ responses : {
499+ '200' : { description : 'Deleted successfully' } ,
500+ ...( mayDenyDelete && { '403' : ERROR_403 } ) ,
501+ '404' : ERROR_404 ,
502+ } ,
503+ } ;
504+ }
505+
506+ return result ;
342507 }
343508
344509 private buildRelationshipPath (
@@ -1073,9 +1238,7 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
10731238 } ) ;
10741239 if ( hasEffectiveDeny ) return true ;
10751240
1076- const relevantAllow = policyAttrs . filter (
1077- ( attr ) => attr . name === '@@allow' && matchesOperation ( attr . args ) ,
1078- ) ;
1241+ const relevantAllow = policyAttrs . filter ( ( attr ) => attr . name === '@@allow' && matchesOperation ( attr . args ) ) ;
10791242
10801243 // If any allow rule has a constant `true` condition (and no deny), access is unconditional
10811244 const hasConstantAllow = relevantAllow . some ( ( attr ) => {
0 commit comments