@zenstackhq/server 3.5.0-beta.3 → 3.5.0-beta.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/api.js CHANGED
@@ -2,7 +2,7 @@ var __defProp = Object.defineProperty;
2
2
  var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
3
3
 
4
4
  // src/api/rest/index.ts
5
- import { clone, enumerate, lowerCaseFirst, paramCase } from "@zenstackhq/common-helpers";
5
+ import { clone, enumerate, lowerCaseFirst as lowerCaseFirst3, paramCase, safeJSONStringify } from "@zenstackhq/common-helpers";
6
6
  import { ORMError as ORMError2, ORMErrorReason } from "@zenstackhq/orm";
7
7
  import { Decimal as Decimal2 } from "decimal.js";
8
8
  import SuperJSON3 from "superjson";
@@ -92,6 +92,26 @@ var loggerSchema = z.union([
92
92
  ]).array(),
93
93
  z.function()
94
94
  ]);
95
+ var fieldSlicingSchema = z.looseObject({
96
+ includedFilterKinds: z.string().array().optional(),
97
+ excludedFilterKinds: z.string().array().optional()
98
+ });
99
+ var modelSlicingSchema = z.looseObject({
100
+ includedOperations: z.array(z.string()).optional(),
101
+ excludedOperations: z.array(z.string()).optional(),
102
+ fields: z.record(z.string(), fieldSlicingSchema).optional()
103
+ });
104
+ var slicingSchema = z.looseObject({
105
+ includedModels: z.array(z.string()).optional(),
106
+ excludedModels: z.array(z.string()).optional(),
107
+ models: z.record(z.string(), modelSlicingSchema).optional(),
108
+ includedProcedures: z.array(z.string()).optional(),
109
+ excludedProcedures: z.array(z.string()).optional()
110
+ });
111
+ var queryOptionsSchema = z.looseObject({
112
+ omit: z.record(z.string(), z.record(z.string(), z.boolean())).optional(),
113
+ slicing: slicingSchema.optional()
114
+ });
95
115
 
96
116
  // src/api/common/utils.ts
97
117
  import SuperJSON from "superjson";
@@ -194,6 +214,1401 @@ function getZodErrorMessage(error) {
194
214
  }
195
215
  __name(getZodErrorMessage, "getZodErrorMessage");
196
216
 
217
+ // src/api/rest/openapi.ts
218
+ import { lowerCaseFirst as lowerCaseFirst2 } from "@zenstackhq/common-helpers";
219
+
220
+ // src/api/common/spec-utils.ts
221
+ import { lowerCaseFirst } from "@zenstackhq/common-helpers";
222
+ import { ExpressionUtils } from "@zenstackhq/orm/schema";
223
+ function isModelIncluded(modelName, queryOptions) {
224
+ const slicing = queryOptions?.slicing;
225
+ if (!slicing) return true;
226
+ const excluded = slicing.excludedModels;
227
+ if (excluded?.includes(modelName)) return false;
228
+ const included = slicing.includedModels;
229
+ if (included && !included.includes(modelName)) return false;
230
+ return true;
231
+ }
232
+ __name(isModelIncluded, "isModelIncluded");
233
+ function isOperationIncluded(modelName, op, queryOptions) {
234
+ const slicing = queryOptions?.slicing;
235
+ if (!slicing?.models) return true;
236
+ const modelKey = lowerCaseFirst(modelName);
237
+ const modelSlicing = slicing.models[modelKey] ?? slicing.models.$all;
238
+ if (!modelSlicing) return true;
239
+ const excluded = modelSlicing.excludedOperations;
240
+ if (excluded?.includes(op)) return false;
241
+ const included = modelSlicing.includedOperations;
242
+ if (included && !included.includes(op)) return false;
243
+ return true;
244
+ }
245
+ __name(isOperationIncluded, "isOperationIncluded");
246
+ function isProcedureIncluded(procName, queryOptions) {
247
+ const slicing = queryOptions?.slicing;
248
+ if (!slicing) return true;
249
+ const excluded = slicing.excludedProcedures;
250
+ if (excluded?.includes(procName)) return false;
251
+ const included = slicing.includedProcedures;
252
+ if (included && !included.includes(procName)) return false;
253
+ return true;
254
+ }
255
+ __name(isProcedureIncluded, "isProcedureIncluded");
256
+ function isFieldOmitted(modelName, fieldName, queryOptions) {
257
+ const omit = queryOptions?.omit;
258
+ return omit?.[modelName]?.[fieldName] === true;
259
+ }
260
+ __name(isFieldOmitted, "isFieldOmitted");
261
+ function getIncludedModels(schema, queryOptions) {
262
+ return Object.keys(schema.models).filter((name) => isModelIncluded(name, queryOptions));
263
+ }
264
+ __name(getIncludedModels, "getIncludedModels");
265
+ function isFilterKindIncluded(modelName, fieldName, filterKind, queryOptions) {
266
+ const slicing = queryOptions?.slicing;
267
+ if (!slicing?.models) return true;
268
+ const modelKey = lowerCaseFirst(modelName);
269
+ const modelSlicing = slicing.models[modelKey] ?? slicing.models.$all;
270
+ if (!modelSlicing?.fields) return true;
271
+ const fieldSlicing = modelSlicing.fields[fieldName] ?? modelSlicing.fields.$all;
272
+ if (!fieldSlicing) return true;
273
+ const excluded = fieldSlicing.excludedFilterKinds;
274
+ if (excluded?.includes(filterKind)) return false;
275
+ const included = fieldSlicing.includedFilterKinds;
276
+ if (included && !included.includes(filterKind)) return false;
277
+ return true;
278
+ }
279
+ __name(isFilterKindIncluded, "isFilterKindIncluded");
280
+ function getMetaDescription(attributes) {
281
+ if (!attributes) return void 0;
282
+ for (const attr of attributes) {
283
+ if (attr.name !== "@meta" && attr.name !== "@@meta") continue;
284
+ const nameArg = attr.args?.find((a) => a.name === "name");
285
+ if (!nameArg || ExpressionUtils.getLiteralValue(nameArg.value) !== "description") continue;
286
+ const valueArg = attr.args?.find((a) => a.name === "value");
287
+ if (valueArg) {
288
+ return ExpressionUtils.getLiteralValue(valueArg.value);
289
+ }
290
+ }
291
+ return void 0;
292
+ }
293
+ __name(getMetaDescription, "getMetaDescription");
294
+
295
+ // src/api/rest/openapi.ts
296
+ function errorResponse(description) {
297
+ return {
298
+ description,
299
+ content: {
300
+ "application/vnd.api+json": {
301
+ schema: {
302
+ $ref: "#/components/schemas/_errorResponse"
303
+ }
304
+ }
305
+ }
306
+ };
307
+ }
308
+ __name(errorResponse, "errorResponse");
309
+ var ERROR_400 = errorResponse("Error occurred while processing the request");
310
+ var ERROR_403 = errorResponse("Forbidden: insufficient permissions to perform this operation");
311
+ var ERROR_404 = errorResponse("Resource not found");
312
+ var ERROR_422 = errorResponse("Operation is unprocessable due to validation errors");
313
+ var SCALAR_STRING_OPS = [
314
+ "$contains",
315
+ "$icontains",
316
+ "$search",
317
+ "$startsWith",
318
+ "$endsWith"
319
+ ];
320
+ var SCALAR_COMPARABLE_OPS = [
321
+ "$lt",
322
+ "$lte",
323
+ "$gt",
324
+ "$gte"
325
+ ];
326
+ var SCALAR_ARRAY_OPS = [
327
+ "$has",
328
+ "$hasEvery",
329
+ "$hasSome",
330
+ "$isEmpty"
331
+ ];
332
+ var RestApiSpecGenerator = class {
333
+ static {
334
+ __name(this, "RestApiSpecGenerator");
335
+ }
336
+ handlerOptions;
337
+ specOptions;
338
+ constructor(handlerOptions) {
339
+ this.handlerOptions = handlerOptions;
340
+ }
341
+ get schema() {
342
+ return this.handlerOptions.schema;
343
+ }
344
+ get modelNameMapping() {
345
+ const mapping = {};
346
+ if (this.handlerOptions.modelNameMapping) {
347
+ for (const [k, v] of Object.entries(this.handlerOptions.modelNameMapping)) {
348
+ mapping[lowerCaseFirst2(k)] = v;
349
+ }
350
+ }
351
+ return mapping;
352
+ }
353
+ get queryOptions() {
354
+ return this.handlerOptions?.queryOptions;
355
+ }
356
+ generateSpec(options) {
357
+ this.specOptions = options;
358
+ return {
359
+ openapi: "3.1.0",
360
+ info: {
361
+ title: options?.title ?? "ZenStack Generated API",
362
+ version: options?.version ?? "1.0.0",
363
+ ...options?.description && {
364
+ description: options.description
365
+ },
366
+ ...options?.summary && {
367
+ summary: options.summary
368
+ }
369
+ },
370
+ tags: this.generateTags(),
371
+ paths: this.generatePaths(),
372
+ components: {
373
+ schemas: this.generateSchemas(),
374
+ parameters: this.generateSharedParams()
375
+ }
376
+ };
377
+ }
378
+ generateTags() {
379
+ return getIncludedModels(this.schema, this.queryOptions).map((modelName) => ({
380
+ name: lowerCaseFirst2(modelName),
381
+ description: `${modelName} operations`
382
+ }));
383
+ }
384
+ getModelPath(modelName) {
385
+ const lower = lowerCaseFirst2(modelName);
386
+ return this.modelNameMapping[lower] ?? lower;
387
+ }
388
+ generatePaths() {
389
+ const paths = {};
390
+ for (const modelName of getIncludedModels(this.schema, this.queryOptions)) {
391
+ const modelDef = this.schema.models[modelName];
392
+ const idFields = this.getIdFields(modelDef);
393
+ if (idFields.length === 0) continue;
394
+ const modelPath = this.getModelPath(modelName);
395
+ const tag = lowerCaseFirst2(modelName);
396
+ const collectionPath = this.buildCollectionPath(modelName, modelDef, tag);
397
+ if (Object.keys(collectionPath).length > 0) {
398
+ paths[`/${modelPath}`] = collectionPath;
399
+ }
400
+ const singlePath = this.buildSinglePath(modelDef, tag);
401
+ if (Object.keys(singlePath).length > 0) {
402
+ paths[`/${modelPath}/{id}`] = singlePath;
403
+ }
404
+ for (const [fieldName, fieldDef] of Object.entries(modelDef.fields)) {
405
+ if (!fieldDef.relation) continue;
406
+ if (!isModelIncluded(fieldDef.type, this.queryOptions)) continue;
407
+ const relModelDef = this.schema.models[fieldDef.type];
408
+ if (!relModelDef) continue;
409
+ const relIdFields = this.getIdFields(relModelDef);
410
+ if (relIdFields.length === 0) continue;
411
+ paths[`/${modelPath}/{id}/${fieldName}`] = this.buildFetchRelatedPath(modelName, fieldName, fieldDef, tag);
412
+ paths[`/${modelPath}/{id}/relationships/${fieldName}`] = this.buildRelationshipPath(modelDef, fieldName, fieldDef, tag);
413
+ }
414
+ }
415
+ if (this.schema.procedures) {
416
+ for (const [procName, procDef] of Object.entries(this.schema.procedures)) {
417
+ if (!isProcedureIncluded(procName, this.queryOptions)) continue;
418
+ const isMutation = !!procDef.mutation;
419
+ if (isMutation) {
420
+ paths[`/${PROCEDURE_ROUTE_PREFIXES}/${procName}`] = {
421
+ post: this.buildProcedureOperation(procName, "post")
422
+ };
423
+ } else {
424
+ paths[`/${PROCEDURE_ROUTE_PREFIXES}/${procName}`] = {
425
+ get: this.buildProcedureOperation(procName, "get")
426
+ };
427
+ }
428
+ }
429
+ }
430
+ return paths;
431
+ }
432
+ buildCollectionPath(modelName, modelDef, tag) {
433
+ const filterParams = this.buildFilterParams(modelName, modelDef);
434
+ const listOp = {
435
+ tags: [
436
+ tag
437
+ ],
438
+ summary: `List ${modelName} resources`,
439
+ operationId: `list${modelName}`,
440
+ parameters: [
441
+ {
442
+ $ref: "#/components/parameters/include"
443
+ },
444
+ {
445
+ $ref: "#/components/parameters/sort"
446
+ },
447
+ {
448
+ $ref: "#/components/parameters/pageOffset"
449
+ },
450
+ {
451
+ $ref: "#/components/parameters/pageLimit"
452
+ },
453
+ ...filterParams
454
+ ],
455
+ responses: {
456
+ "200": {
457
+ description: `List of ${modelName} resources`,
458
+ content: {
459
+ "application/vnd.api+json": {
460
+ schema: {
461
+ $ref: `#/components/schemas/${modelName}ListResponse`
462
+ }
463
+ }
464
+ }
465
+ },
466
+ "400": ERROR_400
467
+ }
468
+ };
469
+ const createOp = {
470
+ tags: [
471
+ tag
472
+ ],
473
+ summary: `Create a ${modelName} resource`,
474
+ operationId: `create${modelName}`,
475
+ requestBody: {
476
+ required: true,
477
+ content: {
478
+ "application/vnd.api+json": {
479
+ schema: {
480
+ $ref: `#/components/schemas/${modelName}CreateRequest`
481
+ }
482
+ }
483
+ }
484
+ },
485
+ responses: {
486
+ "201": {
487
+ description: `Created ${modelName} resource`,
488
+ content: {
489
+ "application/vnd.api+json": {
490
+ schema: {
491
+ $ref: `#/components/schemas/${modelName}Response`
492
+ }
493
+ }
494
+ }
495
+ },
496
+ "400": ERROR_400,
497
+ ...this.mayDenyAccess(modelDef, "create") && {
498
+ "403": ERROR_403
499
+ },
500
+ "422": ERROR_422
501
+ }
502
+ };
503
+ const result = {};
504
+ if (isOperationIncluded(modelName, "findMany", this.queryOptions)) {
505
+ result["get"] = listOp;
506
+ }
507
+ if (isOperationIncluded(modelName, "create", this.queryOptions)) {
508
+ result["post"] = createOp;
509
+ }
510
+ return result;
511
+ }
512
+ buildSinglePath(modelDef, tag) {
513
+ const modelName = modelDef.name;
514
+ const idParam = {
515
+ $ref: "#/components/parameters/id"
516
+ };
517
+ const result = {};
518
+ if (isOperationIncluded(modelName, "findUnique", this.queryOptions)) {
519
+ result["get"] = {
520
+ tags: [
521
+ tag
522
+ ],
523
+ summary: `Get a ${modelName} resource by ID`,
524
+ operationId: `get${modelName}`,
525
+ parameters: [
526
+ idParam,
527
+ {
528
+ $ref: "#/components/parameters/include"
529
+ }
530
+ ],
531
+ responses: {
532
+ "200": {
533
+ description: `${modelName} resource`,
534
+ content: {
535
+ "application/vnd.api+json": {
536
+ schema: {
537
+ $ref: `#/components/schemas/${modelName}Response`
538
+ }
539
+ }
540
+ }
541
+ },
542
+ "404": ERROR_404
543
+ }
544
+ };
545
+ }
546
+ if (isOperationIncluded(modelName, "update", this.queryOptions)) {
547
+ result["patch"] = {
548
+ tags: [
549
+ tag
550
+ ],
551
+ summary: `Update a ${modelName} resource`,
552
+ operationId: `update${modelName}`,
553
+ parameters: [
554
+ idParam
555
+ ],
556
+ requestBody: {
557
+ required: true,
558
+ content: {
559
+ "application/vnd.api+json": {
560
+ schema: {
561
+ $ref: `#/components/schemas/${modelName}UpdateRequest`
562
+ }
563
+ }
564
+ }
565
+ },
566
+ responses: {
567
+ "200": {
568
+ description: `Updated ${modelName} resource`,
569
+ content: {
570
+ "application/vnd.api+json": {
571
+ schema: {
572
+ $ref: `#/components/schemas/${modelName}Response`
573
+ }
574
+ }
575
+ }
576
+ },
577
+ "400": ERROR_400,
578
+ ...this.mayDenyAccess(modelDef, "update") && {
579
+ "403": ERROR_403
580
+ },
581
+ "404": ERROR_404,
582
+ "422": ERROR_422
583
+ }
584
+ };
585
+ }
586
+ if (isOperationIncluded(modelName, "delete", this.queryOptions)) {
587
+ result["delete"] = {
588
+ tags: [
589
+ tag
590
+ ],
591
+ summary: `Delete a ${modelName} resource`,
592
+ operationId: `delete${modelName}`,
593
+ parameters: [
594
+ idParam
595
+ ],
596
+ responses: {
597
+ "200": {
598
+ description: "Deleted successfully"
599
+ },
600
+ ...this.mayDenyAccess(modelDef, "delete") && {
601
+ "403": ERROR_403
602
+ },
603
+ "404": ERROR_404
604
+ }
605
+ };
606
+ }
607
+ return result;
608
+ }
609
+ buildFetchRelatedPath(modelName, fieldName, fieldDef, tag) {
610
+ const isCollection = !!fieldDef.array;
611
+ const params = [
612
+ {
613
+ $ref: "#/components/parameters/id"
614
+ },
615
+ {
616
+ $ref: "#/components/parameters/include"
617
+ }
618
+ ];
619
+ if (isCollection && this.schema.models[fieldDef.type]) {
620
+ const relModelDef = this.schema.models[fieldDef.type];
621
+ params.push({
622
+ $ref: "#/components/parameters/sort"
623
+ }, {
624
+ $ref: "#/components/parameters/pageOffset"
625
+ }, {
626
+ $ref: "#/components/parameters/pageLimit"
627
+ }, ...this.buildFilterParams(fieldDef.type, relModelDef));
628
+ }
629
+ return {
630
+ get: {
631
+ tags: [
632
+ tag
633
+ ],
634
+ summary: `Fetch related ${fieldDef.type} for ${modelName}`,
635
+ operationId: `get${modelName}_${fieldName}`,
636
+ parameters: params,
637
+ responses: {
638
+ "200": {
639
+ description: `Related ${fieldDef.type} resource(s)`,
640
+ content: {
641
+ "application/vnd.api+json": {
642
+ schema: isCollection ? {
643
+ $ref: `#/components/schemas/${fieldDef.type}ListResponse`
644
+ } : {
645
+ $ref: `#/components/schemas/${fieldDef.type}Response`
646
+ }
647
+ }
648
+ }
649
+ },
650
+ "404": ERROR_404
651
+ }
652
+ }
653
+ };
654
+ }
655
+ buildRelationshipPath(modelDef, fieldName, fieldDef, tag) {
656
+ const modelName = modelDef.name;
657
+ const isCollection = !!fieldDef.array;
658
+ const idParam = {
659
+ $ref: "#/components/parameters/id"
660
+ };
661
+ const relSchemaRef = isCollection ? {
662
+ $ref: "#/components/schemas/_toManyRelationshipWithLinks"
663
+ } : {
664
+ $ref: "#/components/schemas/_toOneRelationshipWithLinks"
665
+ };
666
+ const relRequestRef = isCollection ? {
667
+ $ref: "#/components/schemas/_toManyRelationshipRequest"
668
+ } : {
669
+ $ref: "#/components/schemas/_toOneRelationshipRequest"
670
+ };
671
+ const mayDeny = this.mayDenyAccess(modelDef, "update");
672
+ const pathItem = {
673
+ get: {
674
+ tags: [
675
+ tag
676
+ ],
677
+ summary: `Fetch ${fieldName} relationship`,
678
+ operationId: `get${modelName}_relationships_${fieldName}`,
679
+ parameters: [
680
+ idParam
681
+ ],
682
+ responses: {
683
+ "200": {
684
+ description: `${fieldName} relationship`,
685
+ content: {
686
+ "application/vnd.api+json": {
687
+ schema: relSchemaRef
688
+ }
689
+ }
690
+ },
691
+ "404": ERROR_404
692
+ }
693
+ },
694
+ put: {
695
+ tags: [
696
+ tag
697
+ ],
698
+ summary: `Replace ${fieldName} relationship`,
699
+ operationId: `put${modelName}_relationships_${fieldName}`,
700
+ parameters: [
701
+ idParam
702
+ ],
703
+ requestBody: {
704
+ required: true,
705
+ content: {
706
+ "application/vnd.api+json": {
707
+ schema: relRequestRef
708
+ }
709
+ }
710
+ },
711
+ responses: {
712
+ "200": {
713
+ description: "Relationship updated"
714
+ },
715
+ "400": ERROR_400,
716
+ ...mayDeny && {
717
+ "403": ERROR_403
718
+ }
719
+ }
720
+ },
721
+ patch: {
722
+ tags: [
723
+ tag
724
+ ],
725
+ summary: `Update ${fieldName} relationship`,
726
+ operationId: `patch${modelName}_relationships_${fieldName}`,
727
+ parameters: [
728
+ idParam
729
+ ],
730
+ requestBody: {
731
+ required: true,
732
+ content: {
733
+ "application/vnd.api+json": {
734
+ schema: relRequestRef
735
+ }
736
+ }
737
+ },
738
+ responses: {
739
+ "200": {
740
+ description: "Relationship updated"
741
+ },
742
+ "400": ERROR_400,
743
+ ...mayDeny && {
744
+ "403": ERROR_403
745
+ }
746
+ }
747
+ }
748
+ };
749
+ if (isCollection) {
750
+ pathItem["post"] = {
751
+ tags: [
752
+ tag
753
+ ],
754
+ summary: `Add to ${fieldName} collection relationship`,
755
+ operationId: `post${modelName}_relationships_${fieldName}`,
756
+ parameters: [
757
+ idParam
758
+ ],
759
+ requestBody: {
760
+ required: true,
761
+ content: {
762
+ "application/vnd.api+json": {
763
+ schema: {
764
+ $ref: "#/components/schemas/_toManyRelationshipRequest"
765
+ }
766
+ }
767
+ }
768
+ },
769
+ responses: {
770
+ "200": {
771
+ description: "Added to relationship collection"
772
+ },
773
+ "400": ERROR_400,
774
+ ...mayDeny && {
775
+ "403": ERROR_403
776
+ }
777
+ }
778
+ };
779
+ }
780
+ return pathItem;
781
+ }
782
+ buildProcedureOperation(procName, method) {
783
+ const op = {
784
+ tags: [
785
+ "$procs"
786
+ ],
787
+ summary: `Execute procedure ${procName}`,
788
+ operationId: `proc_${procName}`,
789
+ responses: {
790
+ "200": {
791
+ description: `Result of ${procName}`
792
+ },
793
+ "400": ERROR_400
794
+ }
795
+ };
796
+ if (method === "get") {
797
+ op["parameters"] = [
798
+ {
799
+ name: "q",
800
+ in: "query",
801
+ description: "Procedure arguments as JSON",
802
+ schema: {
803
+ type: "string"
804
+ }
805
+ }
806
+ ];
807
+ } else {
808
+ op["requestBody"] = {
809
+ content: {
810
+ "application/json": {
811
+ schema: {
812
+ type: "object"
813
+ }
814
+ }
815
+ }
816
+ };
817
+ }
818
+ return op;
819
+ }
820
+ buildFilterParams(modelName, modelDef) {
821
+ const params = [];
822
+ const idFieldNames = new Set(modelDef.idFields);
823
+ if (isFilterKindIncluded(modelName, "id", "Equality", this.queryOptions)) {
824
+ params.push({
825
+ name: "filter[id]",
826
+ in: "query",
827
+ schema: {
828
+ type: "string"
829
+ },
830
+ description: `Filter by ${modelName} ID`
831
+ });
832
+ }
833
+ for (const [fieldName, fieldDef] of Object.entries(modelDef.fields)) {
834
+ if (fieldDef.relation) continue;
835
+ if (idFieldNames.has(fieldName)) continue;
836
+ const type = fieldDef.type;
837
+ if (isFilterKindIncluded(modelName, fieldName, "Equality", this.queryOptions)) {
838
+ params.push({
839
+ name: `filter[${fieldName}]`,
840
+ in: "query",
841
+ schema: {
842
+ type: "string"
843
+ },
844
+ description: `Filter by ${fieldName}`
845
+ });
846
+ }
847
+ if (type === "String" && isFilterKindIncluded(modelName, fieldName, "Like", this.queryOptions)) {
848
+ for (const op of SCALAR_STRING_OPS) {
849
+ params.push({
850
+ name: `filter[${fieldName}][${op}]`,
851
+ in: "query",
852
+ schema: {
853
+ type: "string"
854
+ }
855
+ });
856
+ }
857
+ } else if ((type === "Int" || type === "Float" || type === "BigInt" || type === "Decimal" || type === "DateTime") && isFilterKindIncluded(modelName, fieldName, "Range", this.queryOptions)) {
858
+ for (const op of SCALAR_COMPARABLE_OPS) {
859
+ params.push({
860
+ name: `filter[${fieldName}][${op}]`,
861
+ in: "query",
862
+ schema: {
863
+ type: "string"
864
+ }
865
+ });
866
+ }
867
+ }
868
+ if (fieldDef.array && isFilterKindIncluded(modelName, fieldName, "List", this.queryOptions)) {
869
+ for (const op of SCALAR_ARRAY_OPS) {
870
+ params.push({
871
+ name: `filter[${fieldName}][${op}]`,
872
+ in: "query",
873
+ schema: {
874
+ type: "string"
875
+ }
876
+ });
877
+ }
878
+ }
879
+ }
880
+ return params;
881
+ }
882
+ generateSchemas() {
883
+ const schemas = {};
884
+ Object.assign(schemas, this.buildSharedSchemas());
885
+ if (this.schema.enums) {
886
+ for (const [_enumName, enumDef] of Object.entries(this.schema.enums)) {
887
+ schemas[_enumName] = this.buildEnumSchema(enumDef);
888
+ }
889
+ }
890
+ if (this.schema.typeDefs) {
891
+ for (const [typeName, typeDef] of Object.entries(this.schema.typeDefs)) {
892
+ schemas[typeName] = this.buildTypeDefSchema(typeDef);
893
+ }
894
+ }
895
+ for (const modelName of getIncludedModels(this.schema, this.queryOptions)) {
896
+ const modelDef = this.schema.models[modelName];
897
+ const idFields = this.getIdFields(modelDef);
898
+ if (idFields.length === 0) continue;
899
+ schemas[modelName] = this.buildModelReadSchema(modelName, modelDef);
900
+ schemas[`${modelName}CreateRequest`] = this.buildCreateRequestSchema(modelName, modelDef);
901
+ schemas[`${modelName}UpdateRequest`] = this.buildUpdateRequestSchema(modelDef);
902
+ schemas[`${modelName}Response`] = this.buildModelResponseSchema(modelName);
903
+ schemas[`${modelName}ListResponse`] = this.buildModelListResponseSchema(modelName);
904
+ }
905
+ return schemas;
906
+ }
907
+ buildSharedSchemas() {
908
+ const nullableString = {
909
+ oneOf: [
910
+ {
911
+ type: "string"
912
+ },
913
+ {
914
+ type: "null"
915
+ }
916
+ ]
917
+ };
918
+ return {
919
+ _jsonapi: {
920
+ type: "object",
921
+ properties: {
922
+ version: {
923
+ type: "string"
924
+ },
925
+ meta: {
926
+ type: "object"
927
+ }
928
+ }
929
+ },
930
+ _meta: {
931
+ type: "object",
932
+ additionalProperties: true
933
+ },
934
+ _links: {
935
+ type: "object",
936
+ properties: {
937
+ self: {
938
+ type: "string"
939
+ },
940
+ related: {
941
+ type: "string"
942
+ }
943
+ }
944
+ },
945
+ _pagination: {
946
+ type: "object",
947
+ properties: {
948
+ first: nullableString,
949
+ last: nullableString,
950
+ prev: nullableString,
951
+ next: nullableString
952
+ }
953
+ },
954
+ _errors: {
955
+ type: "array",
956
+ items: {
957
+ type: "object",
958
+ properties: {
959
+ status: {
960
+ type: "integer"
961
+ },
962
+ code: {
963
+ type: "string"
964
+ },
965
+ title: {
966
+ type: "string"
967
+ },
968
+ detail: {
969
+ type: "string"
970
+ }
971
+ },
972
+ required: [
973
+ "status",
974
+ "title"
975
+ ]
976
+ }
977
+ },
978
+ _errorResponse: {
979
+ type: "object",
980
+ properties: {
981
+ errors: {
982
+ $ref: "#/components/schemas/_errors"
983
+ }
984
+ },
985
+ required: [
986
+ "errors"
987
+ ]
988
+ },
989
+ _resourceIdentifier: {
990
+ type: "object",
991
+ properties: {
992
+ type: {
993
+ type: "string"
994
+ },
995
+ id: {
996
+ type: "string"
997
+ }
998
+ },
999
+ required: [
1000
+ "type",
1001
+ "id"
1002
+ ]
1003
+ },
1004
+ _resource: {
1005
+ type: "object",
1006
+ properties: {
1007
+ type: {
1008
+ type: "string"
1009
+ },
1010
+ id: {
1011
+ type: "string"
1012
+ },
1013
+ attributes: {
1014
+ type: "object"
1015
+ },
1016
+ relationships: {
1017
+ type: "object"
1018
+ },
1019
+ links: {
1020
+ $ref: "#/components/schemas/_links"
1021
+ },
1022
+ meta: {
1023
+ $ref: "#/components/schemas/_meta"
1024
+ }
1025
+ },
1026
+ required: [
1027
+ "type",
1028
+ "id"
1029
+ ]
1030
+ },
1031
+ _relationLinks: {
1032
+ type: "object",
1033
+ properties: {
1034
+ self: {
1035
+ type: "string"
1036
+ },
1037
+ related: {
1038
+ type: "string"
1039
+ }
1040
+ }
1041
+ },
1042
+ _pagedRelationLinks: {
1043
+ type: "object",
1044
+ allOf: [
1045
+ {
1046
+ $ref: "#/components/schemas/_relationLinks"
1047
+ },
1048
+ {
1049
+ $ref: "#/components/schemas/_pagination"
1050
+ }
1051
+ ]
1052
+ },
1053
+ _toOneRelationship: {
1054
+ type: "object",
1055
+ properties: {
1056
+ data: {
1057
+ oneOf: [
1058
+ {
1059
+ $ref: "#/components/schemas/_resourceIdentifier"
1060
+ },
1061
+ {
1062
+ type: "null"
1063
+ }
1064
+ ]
1065
+ }
1066
+ }
1067
+ },
1068
+ _toManyRelationship: {
1069
+ type: "object",
1070
+ properties: {
1071
+ data: {
1072
+ type: "array",
1073
+ items: {
1074
+ $ref: "#/components/schemas/_resourceIdentifier"
1075
+ }
1076
+ }
1077
+ }
1078
+ },
1079
+ _toOneRelationshipWithLinks: {
1080
+ type: "object",
1081
+ allOf: [
1082
+ {
1083
+ $ref: "#/components/schemas/_toOneRelationship"
1084
+ },
1085
+ {
1086
+ properties: {
1087
+ links: {
1088
+ $ref: "#/components/schemas/_relationLinks"
1089
+ }
1090
+ }
1091
+ }
1092
+ ]
1093
+ },
1094
+ _toManyRelationshipWithLinks: {
1095
+ type: "object",
1096
+ allOf: [
1097
+ {
1098
+ $ref: "#/components/schemas/_toManyRelationship"
1099
+ },
1100
+ {
1101
+ properties: {
1102
+ links: {
1103
+ $ref: "#/components/schemas/_pagedRelationLinks"
1104
+ }
1105
+ }
1106
+ }
1107
+ ]
1108
+ },
1109
+ _toManyRelationshipRequest: {
1110
+ type: "object",
1111
+ properties: {
1112
+ data: {
1113
+ type: "array",
1114
+ items: {
1115
+ $ref: "#/components/schemas/_resourceIdentifier"
1116
+ }
1117
+ }
1118
+ },
1119
+ required: [
1120
+ "data"
1121
+ ]
1122
+ },
1123
+ _toOneRelationshipRequest: {
1124
+ type: "object",
1125
+ properties: {
1126
+ data: {
1127
+ oneOf: [
1128
+ {
1129
+ $ref: "#/components/schemas/_resourceIdentifier"
1130
+ },
1131
+ {
1132
+ type: "null"
1133
+ }
1134
+ ]
1135
+ }
1136
+ },
1137
+ required: [
1138
+ "data"
1139
+ ]
1140
+ },
1141
+ _toManyRelationshipResponse: {
1142
+ type: "object",
1143
+ properties: {
1144
+ data: {
1145
+ type: "array",
1146
+ items: {
1147
+ $ref: "#/components/schemas/_resourceIdentifier"
1148
+ }
1149
+ },
1150
+ links: {
1151
+ $ref: "#/components/schemas/_pagedRelationLinks"
1152
+ },
1153
+ meta: {
1154
+ $ref: "#/components/schemas/_meta"
1155
+ }
1156
+ }
1157
+ },
1158
+ _toOneRelationshipResponse: {
1159
+ type: "object",
1160
+ properties: {
1161
+ data: {
1162
+ oneOf: [
1163
+ {
1164
+ $ref: "#/components/schemas/_resourceIdentifier"
1165
+ },
1166
+ {
1167
+ type: "null"
1168
+ }
1169
+ ]
1170
+ },
1171
+ links: {
1172
+ $ref: "#/components/schemas/_relationLinks"
1173
+ },
1174
+ meta: {
1175
+ $ref: "#/components/schemas/_meta"
1176
+ }
1177
+ }
1178
+ }
1179
+ };
1180
+ }
1181
+ buildEnumSchema(enumDef) {
1182
+ return {
1183
+ type: "string",
1184
+ enum: Object.values(enumDef.values)
1185
+ };
1186
+ }
1187
+ buildTypeDefSchema(typeDef) {
1188
+ const properties = {};
1189
+ const required = [];
1190
+ for (const [fieldName, fieldDef] of Object.entries(typeDef.fields)) {
1191
+ properties[fieldName] = this.fieldToSchema(fieldDef);
1192
+ if (!fieldDef.optional && !fieldDef.array) {
1193
+ required.push(fieldName);
1194
+ }
1195
+ }
1196
+ const result = {
1197
+ type: "object",
1198
+ properties
1199
+ };
1200
+ if (required.length > 0) {
1201
+ result.required = required;
1202
+ }
1203
+ return result;
1204
+ }
1205
+ buildModelReadSchema(modelName, modelDef) {
1206
+ const attrProperties = {};
1207
+ const attrRequired = [];
1208
+ const relProperties = {};
1209
+ for (const [fieldName, fieldDef] of Object.entries(modelDef.fields)) {
1210
+ if (fieldDef.omit) continue;
1211
+ if (isFieldOmitted(modelName, fieldName, this.queryOptions)) continue;
1212
+ if (fieldDef.relation && !isModelIncluded(fieldDef.type, this.queryOptions)) continue;
1213
+ if (fieldDef.relation) {
1214
+ const relRef = fieldDef.array ? {
1215
+ $ref: "#/components/schemas/_toManyRelationshipWithLinks"
1216
+ } : {
1217
+ $ref: "#/components/schemas/_toOneRelationshipWithLinks"
1218
+ };
1219
+ relProperties[fieldName] = fieldDef.optional ? {
1220
+ oneOf: [
1221
+ {
1222
+ type: "null"
1223
+ },
1224
+ relRef
1225
+ ]
1226
+ } : relRef;
1227
+ } else {
1228
+ const schema = this.fieldToSchema(fieldDef);
1229
+ const fieldDescription = getMetaDescription(fieldDef.attributes);
1230
+ if (fieldDescription && !("$ref" in schema)) {
1231
+ schema.description = fieldDescription;
1232
+ }
1233
+ attrProperties[fieldName] = schema;
1234
+ if (!fieldDef.optional && !fieldDef.array) {
1235
+ attrRequired.push(fieldName);
1236
+ }
1237
+ }
1238
+ }
1239
+ const properties = {};
1240
+ if (Object.keys(attrProperties).length > 0) {
1241
+ const attrSchema = {
1242
+ type: "object",
1243
+ properties: attrProperties
1244
+ };
1245
+ if (attrRequired.length > 0) attrSchema.required = attrRequired;
1246
+ properties["attributes"] = attrSchema;
1247
+ }
1248
+ if (Object.keys(relProperties).length > 0) {
1249
+ properties["relationships"] = {
1250
+ type: "object",
1251
+ properties: relProperties
1252
+ };
1253
+ }
1254
+ const result = {
1255
+ type: "object",
1256
+ properties
1257
+ };
1258
+ const description = getMetaDescription(modelDef.attributes);
1259
+ if (description) {
1260
+ result.description = description;
1261
+ }
1262
+ return result;
1263
+ }
1264
+ buildCreateRequestSchema(_modelName, modelDef) {
1265
+ const idFieldNames = new Set(modelDef.idFields);
1266
+ const attributes = {};
1267
+ const attrRequired = [];
1268
+ const relationships = {};
1269
+ for (const [fieldName, fieldDef] of Object.entries(modelDef.fields)) {
1270
+ if (fieldDef.updatedAt) continue;
1271
+ if (fieldDef.foreignKeyFor) continue;
1272
+ if (idFieldNames.has(fieldName) && fieldDef.default !== void 0) continue;
1273
+ if (fieldDef.relation && !isModelIncluded(fieldDef.type, this.queryOptions)) continue;
1274
+ if (fieldDef.relation) {
1275
+ relationships[fieldName] = fieldDef.array ? {
1276
+ type: "object",
1277
+ properties: {
1278
+ data: {
1279
+ type: "array",
1280
+ items: {
1281
+ $ref: "#/components/schemas/_resourceIdentifier"
1282
+ }
1283
+ }
1284
+ }
1285
+ } : {
1286
+ type: "object",
1287
+ properties: {
1288
+ data: {
1289
+ $ref: "#/components/schemas/_resourceIdentifier"
1290
+ }
1291
+ }
1292
+ };
1293
+ } else {
1294
+ attributes[fieldName] = this.fieldToSchema(fieldDef);
1295
+ if (!fieldDef.optional && fieldDef.default === void 0 && !fieldDef.array) {
1296
+ attrRequired.push(fieldName);
1297
+ }
1298
+ }
1299
+ }
1300
+ const dataProperties = {
1301
+ type: {
1302
+ type: "string"
1303
+ }
1304
+ };
1305
+ if (Object.keys(attributes).length > 0) {
1306
+ const attrSchema = {
1307
+ type: "object",
1308
+ properties: attributes
1309
+ };
1310
+ if (attrRequired.length > 0) attrSchema.required = attrRequired;
1311
+ dataProperties["attributes"] = attrSchema;
1312
+ }
1313
+ if (Object.keys(relationships).length > 0) {
1314
+ dataProperties["relationships"] = {
1315
+ type: "object",
1316
+ properties: relationships
1317
+ };
1318
+ }
1319
+ return {
1320
+ type: "object",
1321
+ properties: {
1322
+ data: {
1323
+ type: "object",
1324
+ properties: dataProperties,
1325
+ required: [
1326
+ "type"
1327
+ ]
1328
+ }
1329
+ },
1330
+ required: [
1331
+ "data"
1332
+ ]
1333
+ };
1334
+ }
1335
+ buildUpdateRequestSchema(modelDef) {
1336
+ const attributes = {};
1337
+ const relationships = {};
1338
+ for (const [fieldName, fieldDef] of Object.entries(modelDef.fields)) {
1339
+ if (fieldDef.updatedAt) continue;
1340
+ if (fieldDef.foreignKeyFor) continue;
1341
+ if (fieldDef.relation && !isModelIncluded(fieldDef.type, this.queryOptions)) continue;
1342
+ if (fieldDef.relation) {
1343
+ relationships[fieldName] = fieldDef.array ? {
1344
+ type: "object",
1345
+ properties: {
1346
+ data: {
1347
+ type: "array",
1348
+ items: {
1349
+ $ref: "#/components/schemas/_resourceIdentifier"
1350
+ }
1351
+ }
1352
+ }
1353
+ } : {
1354
+ type: "object",
1355
+ properties: {
1356
+ data: {
1357
+ $ref: "#/components/schemas/_resourceIdentifier"
1358
+ }
1359
+ }
1360
+ };
1361
+ } else {
1362
+ attributes[fieldName] = this.fieldToSchema(fieldDef);
1363
+ }
1364
+ }
1365
+ const dataProperties = {
1366
+ type: {
1367
+ type: "string"
1368
+ },
1369
+ id: {
1370
+ type: "string"
1371
+ }
1372
+ };
1373
+ if (Object.keys(attributes).length > 0) {
1374
+ dataProperties["attributes"] = {
1375
+ type: "object",
1376
+ properties: attributes
1377
+ };
1378
+ }
1379
+ if (Object.keys(relationships).length > 0) {
1380
+ dataProperties["relationships"] = {
1381
+ type: "object",
1382
+ properties: relationships
1383
+ };
1384
+ }
1385
+ return {
1386
+ type: "object",
1387
+ properties: {
1388
+ data: {
1389
+ type: "object",
1390
+ properties: dataProperties,
1391
+ required: [
1392
+ "type",
1393
+ "id"
1394
+ ]
1395
+ }
1396
+ },
1397
+ required: [
1398
+ "data"
1399
+ ]
1400
+ };
1401
+ }
1402
+ buildModelResponseSchema(modelName) {
1403
+ return {
1404
+ type: "object",
1405
+ properties: {
1406
+ jsonapi: {
1407
+ $ref: "#/components/schemas/_jsonapi"
1408
+ },
1409
+ data: {
1410
+ allOf: [
1411
+ {
1412
+ $ref: `#/components/schemas/${modelName}`
1413
+ },
1414
+ {
1415
+ $ref: "#/components/schemas/_resource"
1416
+ }
1417
+ ]
1418
+ },
1419
+ meta: {
1420
+ $ref: "#/components/schemas/_meta"
1421
+ }
1422
+ }
1423
+ };
1424
+ }
1425
+ buildModelListResponseSchema(modelName) {
1426
+ return {
1427
+ type: "object",
1428
+ properties: {
1429
+ jsonapi: {
1430
+ $ref: "#/components/schemas/_jsonapi"
1431
+ },
1432
+ data: {
1433
+ type: "array",
1434
+ items: {
1435
+ allOf: [
1436
+ {
1437
+ $ref: `#/components/schemas/${modelName}`
1438
+ },
1439
+ {
1440
+ $ref: "#/components/schemas/_resource"
1441
+ }
1442
+ ]
1443
+ }
1444
+ },
1445
+ links: {
1446
+ allOf: [
1447
+ {
1448
+ $ref: "#/components/schemas/_pagination"
1449
+ },
1450
+ {
1451
+ $ref: "#/components/schemas/_links"
1452
+ }
1453
+ ]
1454
+ },
1455
+ meta: {
1456
+ $ref: "#/components/schemas/_meta"
1457
+ }
1458
+ }
1459
+ };
1460
+ }
1461
+ generateSharedParams() {
1462
+ return {
1463
+ id: {
1464
+ name: "id",
1465
+ in: "path",
1466
+ required: true,
1467
+ schema: {
1468
+ type: "string"
1469
+ },
1470
+ description: "Resource ID"
1471
+ },
1472
+ include: {
1473
+ name: "include",
1474
+ in: "query",
1475
+ schema: {
1476
+ type: "string"
1477
+ },
1478
+ description: "Comma-separated list of relationships to include"
1479
+ },
1480
+ sort: {
1481
+ name: "sort",
1482
+ in: "query",
1483
+ schema: {
1484
+ type: "string"
1485
+ },
1486
+ description: "Comma-separated list of fields to sort by. Prefix with - for descending"
1487
+ },
1488
+ pageOffset: {
1489
+ name: "page[offset]",
1490
+ in: "query",
1491
+ schema: {
1492
+ type: "integer",
1493
+ minimum: 0
1494
+ },
1495
+ description: "Page offset"
1496
+ },
1497
+ pageLimit: {
1498
+ name: "page[limit]",
1499
+ in: "query",
1500
+ schema: {
1501
+ type: "integer",
1502
+ minimum: 1
1503
+ },
1504
+ description: "Page limit"
1505
+ }
1506
+ };
1507
+ }
1508
+ fieldToSchema(fieldDef) {
1509
+ const baseSchema = this.typeToSchema(fieldDef.type);
1510
+ if (fieldDef.array) {
1511
+ return {
1512
+ type: "array",
1513
+ items: baseSchema
1514
+ };
1515
+ }
1516
+ if (fieldDef.optional) {
1517
+ return {
1518
+ oneOf: [
1519
+ baseSchema,
1520
+ {
1521
+ type: "null"
1522
+ }
1523
+ ]
1524
+ };
1525
+ }
1526
+ return baseSchema;
1527
+ }
1528
+ typeToSchema(type) {
1529
+ switch (type) {
1530
+ case "String":
1531
+ return {
1532
+ type: "string"
1533
+ };
1534
+ case "Int":
1535
+ case "BigInt":
1536
+ return {
1537
+ type: "integer"
1538
+ };
1539
+ case "Float":
1540
+ return {
1541
+ type: "number"
1542
+ };
1543
+ case "Decimal":
1544
+ return {
1545
+ oneOf: [
1546
+ {
1547
+ type: "number"
1548
+ },
1549
+ {
1550
+ type: "string"
1551
+ }
1552
+ ]
1553
+ };
1554
+ case "Boolean":
1555
+ return {
1556
+ type: "boolean"
1557
+ };
1558
+ case "DateTime":
1559
+ return {
1560
+ type: "string",
1561
+ format: "date-time"
1562
+ };
1563
+ case "Bytes":
1564
+ return {
1565
+ type: "string",
1566
+ format: "byte"
1567
+ };
1568
+ case "Json":
1569
+ case "Unsupported":
1570
+ return {};
1571
+ default:
1572
+ return {
1573
+ $ref: `#/components/schemas/${type}`
1574
+ };
1575
+ }
1576
+ }
1577
+ getIdFields(modelDef) {
1578
+ return modelDef.idFields.map((name) => modelDef.fields[name]).filter((f) => f !== void 0);
1579
+ }
1580
+ /**
1581
+ * Checks if an operation on a model may be denied by access policies.
1582
+ * Returns true when `respectAccessPolicies` is enabled and the model's
1583
+ * policies for the given operation are NOT a constant allow (i.e., not
1584
+ * simply `@@allow('...', true)` with no `@@deny` rules).
1585
+ */
1586
+ mayDenyAccess(modelDef, operation) {
1587
+ if (!this.specOptions?.respectAccessPolicies) return false;
1588
+ const policyAttrs = (modelDef.attributes ?? []).filter((attr) => attr.name === "@@allow" || attr.name === "@@deny");
1589
+ if (policyAttrs.length === 0) return true;
1590
+ const getArgByName = /* @__PURE__ */ __name((args, name) => args?.find((a) => a.name === name)?.value, "getArgByName");
1591
+ const matchesOperation = /* @__PURE__ */ __name((args) => {
1592
+ const val = getArgByName(args, "operation");
1593
+ if (!val || val.kind !== "literal" || typeof val.value !== "string") return false;
1594
+ const ops = val.value.split(",").map((s) => s.trim());
1595
+ return ops.includes(operation) || ops.includes("all");
1596
+ }, "matchesOperation");
1597
+ const hasEffectiveDeny = policyAttrs.some((attr) => {
1598
+ if (attr.name !== "@@deny" || !matchesOperation(attr.args)) return false;
1599
+ const condition = getArgByName(attr.args, "condition");
1600
+ return !(condition?.kind === "literal" && condition.value === false);
1601
+ });
1602
+ if (hasEffectiveDeny) return true;
1603
+ const relevantAllow = policyAttrs.filter((attr) => attr.name === "@@allow" && matchesOperation(attr.args));
1604
+ const hasConstantAllow = relevantAllow.some((attr) => {
1605
+ const condition = getArgByName(attr.args, "condition");
1606
+ return condition?.kind === "literal" && condition.value === true;
1607
+ });
1608
+ return !hasConstantAllow;
1609
+ }
1610
+ };
1611
+
197
1612
  // src/api/rest/index.ts
198
1613
  var InvalidValueError = class InvalidValueError2 extends Error {
199
1614
  static {
@@ -369,6 +1784,7 @@ var RestApiHandler = class {
369
1784
  modelNameMapping;
370
1785
  reverseModelNameMapping;
371
1786
  externalIdMapping;
1787
+ nestedRoutes;
372
1788
  constructor(options) {
373
1789
  this.options = options;
374
1790
  this.validateOptions(options);
@@ -376,7 +1792,7 @@ var RestApiHandler = class {
376
1792
  const segmentCharset = options.urlSegmentCharset ?? "a-zA-Z0-9-_~ %";
377
1793
  this.modelNameMapping = options.modelNameMapping ?? {};
378
1794
  this.modelNameMapping = Object.fromEntries(Object.entries(this.modelNameMapping).map(([k, v]) => [
379
- lowerCaseFirst(k),
1795
+ lowerCaseFirst3(k),
380
1796
  v
381
1797
  ]));
382
1798
  this.reverseModelNameMapping = Object.fromEntries(Object.entries(this.modelNameMapping).map(([k, v]) => [
@@ -385,9 +1801,10 @@ var RestApiHandler = class {
385
1801
  ]));
386
1802
  this.externalIdMapping = options.externalIdMapping ?? {};
387
1803
  this.externalIdMapping = Object.fromEntries(Object.entries(this.externalIdMapping).map(([k, v]) => [
388
- lowerCaseFirst(k),
1804
+ lowerCaseFirst3(k),
389
1805
  v
390
1806
  ]));
1807
+ this.nestedRoutes = options.nestedRoutes ?? false;
391
1808
  this.urlPatternMap = this.buildUrlPatternMap(segmentCharset);
392
1809
  this.buildTypeMap();
393
1810
  this.buildSerializers();
@@ -404,7 +1821,9 @@ var RestApiHandler = class {
404
1821
  idDivider: z2.string().min(1).optional(),
405
1822
  urlSegmentCharset: z2.string().min(1).optional(),
406
1823
  modelNameMapping: z2.record(z2.string(), z2.string()).optional(),
407
- externalIdMapping: z2.record(z2.string(), z2.string()).optional()
1824
+ externalIdMapping: z2.record(z2.string(), z2.string()).optional(),
1825
+ queryOptions: queryOptionsSchema.optional(),
1826
+ nestedRoutes: z2.boolean().optional()
408
1827
  });
409
1828
  const parseResult = schema.safeParse(options);
410
1829
  if (!parseResult.success) {
@@ -429,6 +1848,12 @@ var RestApiHandler = class {
429
1848
  ":type",
430
1849
  ":id"
431
1850
  ]), options),
1851
+ ["nestedSingle"]: new UrlPattern(buildPath([
1852
+ ":type",
1853
+ ":id",
1854
+ ":relationship",
1855
+ ":childId"
1856
+ ]), options),
432
1857
  ["fetchRelationship"]: new UrlPattern(buildPath([
433
1858
  ":type",
434
1859
  ":id",
@@ -448,6 +1873,81 @@ var RestApiHandler = class {
448
1873
  mapModelName(modelName) {
449
1874
  return this.modelNameMapping[modelName] ?? modelName;
450
1875
  }
1876
+ /**
1877
+ * Resolves child model type and reverse relation from a parent relation name.
1878
+ * e.g. given parentType='user', parentRelation='posts', returns { childType:'post', reverseRelation:'author' }
1879
+ */
1880
+ resolveNestedRelation(parentType, parentRelation) {
1881
+ const parentInfo = this.getModelInfo(parentType);
1882
+ if (!parentInfo) return void 0;
1883
+ const field = this.schema.models[parentInfo.name]?.fields[parentRelation];
1884
+ if (!field?.relation) return void 0;
1885
+ const reverseRelation = field.relation.opposite;
1886
+ if (!reverseRelation) return void 0;
1887
+ return {
1888
+ childType: lowerCaseFirst3(field.type),
1889
+ reverseRelation,
1890
+ isCollection: !!field.array
1891
+ };
1892
+ }
1893
+ mergeFilters(left, right) {
1894
+ if (!left) {
1895
+ return right;
1896
+ }
1897
+ if (!right) {
1898
+ return left;
1899
+ }
1900
+ return {
1901
+ AND: [
1902
+ left,
1903
+ right
1904
+ ]
1905
+ };
1906
+ }
1907
+ /**
1908
+ * Builds a WHERE filter for the child model that constrains results to those belonging to the given parent.
1909
+ * @param parentType lowercased parent model name
1910
+ * @param parentId parent resource ID string
1911
+ * @param parentRelation relation field name on the parent model (e.g. 'posts')
1912
+ */
1913
+ buildNestedParentFilter(parentType, parentId, parentRelation) {
1914
+ const parentInfo = this.getModelInfo(parentType);
1915
+ if (!parentInfo) {
1916
+ return {
1917
+ filter: void 0,
1918
+ error: this.makeUnsupportedModelError(parentType)
1919
+ };
1920
+ }
1921
+ const resolved = this.resolveNestedRelation(parentType, parentRelation);
1922
+ if (!resolved) {
1923
+ return {
1924
+ filter: void 0,
1925
+ error: this.makeError("invalidPath", `invalid nested route: cannot resolve relation "${parentType}.${parentRelation}"`)
1926
+ };
1927
+ }
1928
+ const { reverseRelation } = resolved;
1929
+ const childInfo = this.getModelInfo(resolved.childType);
1930
+ if (!childInfo) {
1931
+ return {
1932
+ filter: void 0,
1933
+ error: this.makeUnsupportedModelError(resolved.childType)
1934
+ };
1935
+ }
1936
+ const reverseRelInfo = childInfo.relationships[reverseRelation];
1937
+ const relationFilter = reverseRelInfo?.isCollection ? {
1938
+ [reverseRelation]: {
1939
+ some: this.makeIdFilter(parentInfo.idFields, parentId, false)
1940
+ }
1941
+ } : {
1942
+ [reverseRelation]: {
1943
+ is: this.makeIdFilter(parentInfo.idFields, parentId, false)
1944
+ }
1945
+ };
1946
+ return {
1947
+ filter: relationFilter,
1948
+ error: void 0
1949
+ };
1950
+ }
451
1951
  matchUrlPattern(path, routeType) {
452
1952
  const pattern = this.urlPatternMap[routeType];
453
1953
  if (!pattern) {
@@ -495,6 +1995,10 @@ var RestApiHandler = class {
495
1995
  if (match4) {
496
1996
  return await this.processReadRelationship(client, match4.type, match4.id, match4.relationship, query);
497
1997
  }
1998
+ match4 = this.matchUrlPattern(path, "nestedSingle");
1999
+ if (match4 && this.nestedRoutes && this.resolveNestedRelation(match4.type, match4.relationship)?.isCollection) {
2000
+ return await this.processNestedSingleRead(client, match4.type, match4.id, match4.relationship, match4.childId, query);
2001
+ }
498
2002
  match4 = this.matchUrlPattern(path, "collection");
499
2003
  if (match4) {
500
2004
  return await this.processCollectionRead(client, match4.type, query);
@@ -505,6 +2009,10 @@ var RestApiHandler = class {
505
2009
  if (!requestBody) {
506
2010
  return this.makeError("invalidPayload");
507
2011
  }
2012
+ const nestedMatch = this.matchUrlPattern(path, "fetchRelationship");
2013
+ if (nestedMatch && this.nestedRoutes && this.resolveNestedRelation(nestedMatch.type, nestedMatch.relationship)?.isCollection) {
2014
+ return await this.processNestedCreate(client, nestedMatch.type, nestedMatch.id, nestedMatch.relationship, query, requestBody);
2015
+ }
508
2016
  let match4 = this.matchUrlPattern(path, "collection");
509
2017
  if (match4) {
510
2018
  const body = requestBody;
@@ -527,24 +2035,36 @@ var RestApiHandler = class {
527
2035
  if (!requestBody) {
528
2036
  return this.makeError("invalidPayload");
529
2037
  }
530
- let match4 = this.matchUrlPattern(path, "single");
2038
+ let match4 = this.matchUrlPattern(path, "relationship");
531
2039
  if (match4) {
532
- return await this.processUpdate(client, match4.type, match4.id, query, requestBody);
2040
+ return await this.processRelationshipCRUD(client, "update", match4.type, match4.id, match4.relationship, query, requestBody);
533
2041
  }
534
- match4 = this.matchUrlPattern(path, "relationship");
2042
+ const nestedToOnePatchMatch = this.matchUrlPattern(path, "fetchRelationship");
2043
+ if (nestedToOnePatchMatch && this.nestedRoutes && this.resolveNestedRelation(nestedToOnePatchMatch.type, nestedToOnePatchMatch.relationship) && !this.resolveNestedRelation(nestedToOnePatchMatch.type, nestedToOnePatchMatch.relationship)?.isCollection) {
2044
+ return await this.processNestedUpdate(client, nestedToOnePatchMatch.type, nestedToOnePatchMatch.id, nestedToOnePatchMatch.relationship, void 0, query, requestBody);
2045
+ }
2046
+ const nestedPatchMatch = this.matchUrlPattern(path, "nestedSingle");
2047
+ if (nestedPatchMatch && this.nestedRoutes && this.resolveNestedRelation(nestedPatchMatch.type, nestedPatchMatch.relationship)?.isCollection) {
2048
+ return await this.processNestedUpdate(client, nestedPatchMatch.type, nestedPatchMatch.id, nestedPatchMatch.relationship, nestedPatchMatch.childId, query, requestBody);
2049
+ }
2050
+ match4 = this.matchUrlPattern(path, "single");
535
2051
  if (match4) {
536
- return await this.processRelationshipCRUD(client, "update", match4.type, match4.id, match4.relationship, query, requestBody);
2052
+ return await this.processUpdate(client, match4.type, match4.id, query, requestBody);
537
2053
  }
538
2054
  return this.makeError("invalidPath");
539
2055
  }
540
2056
  case "DELETE": {
541
- let match4 = this.matchUrlPattern(path, "single");
2057
+ let match4 = this.matchUrlPattern(path, "relationship");
542
2058
  if (match4) {
543
- return await this.processDelete(client, match4.type, match4.id);
2059
+ return await this.processRelationshipCRUD(client, "delete", match4.type, match4.id, match4.relationship, query, requestBody);
544
2060
  }
545
- match4 = this.matchUrlPattern(path, "relationship");
2061
+ const nestedDeleteMatch = this.matchUrlPattern(path, "nestedSingle");
2062
+ if (nestedDeleteMatch && this.nestedRoutes && this.resolveNestedRelation(nestedDeleteMatch.type, nestedDeleteMatch.relationship)?.isCollection) {
2063
+ return await this.processNestedDelete(client, nestedDeleteMatch.type, nestedDeleteMatch.id, nestedDeleteMatch.relationship, nestedDeleteMatch.childId);
2064
+ }
2065
+ match4 = this.matchUrlPattern(path, "single");
546
2066
  if (match4) {
547
- return await this.processRelationshipCRUD(client, "delete", match4.type, match4.id, match4.relationship, query, requestBody);
2067
+ return await this.processDelete(client, match4.type, match4.id);
548
2068
  }
549
2069
  return this.makeError("invalidPath");
550
2070
  }
@@ -562,8 +2082,9 @@ var RestApiHandler = class {
562
2082
  }
563
2083
  }
564
2084
  handleGenericError(err) {
565
- return this.makeError("unknownError", err instanceof Error ? `${err.message}
566
- ${err.stack}` : "Unknown error");
2085
+ const resp = this.makeError("unknownError", err instanceof Error ? `${err.message}` : "Unknown error");
2086
+ log(this.options.log, "debug", () => `sending error response: ${safeJSONStringify(resp)}${err instanceof Error ? "\n" + err.stack : ""}`);
2087
+ return resp;
567
2088
  }
568
2089
  async processProcedureRequest({ client, method, proc, query, requestBody }) {
569
2090
  if (!proc) {
@@ -621,29 +2142,32 @@ ${err.stack}` : "Unknown error");
621
2142
  }
622
2143
  makeProcBadInputErrorResponse(message) {
623
2144
  const resp = this.makeError("invalidPayload", message, 400);
624
- log(this.log, "debug", () => `sending error response: ${JSON.stringify(resp)}`);
2145
+ log(this.log, "debug", () => `sending error response: ${safeJSONStringify(resp)}`);
625
2146
  return resp;
626
2147
  }
627
2148
  makeProcGenericErrorResponse(err) {
628
2149
  const message = err instanceof Error ? err.message : "unknown error";
629
2150
  const resp = this.makeError("unknownError", message, 500);
630
- log(this.log, "debug", () => `sending error response: ${JSON.stringify(resp)}`);
2151
+ log(this.log, "debug", () => `sending error response: ${safeJSONStringify(resp)}${err instanceof Error ? "\n" + err.stack : ""}`);
631
2152
  return resp;
632
2153
  }
633
- async processSingleRead(client, type, resourceId, query) {
634
- const typeInfo = this.getModelInfo(type);
635
- if (!typeInfo) {
636
- return this.makeUnsupportedModelError(type);
637
- }
638
- const args = {
639
- where: this.makeIdFilter(typeInfo.idFields, resourceId)
640
- };
2154
+ /**
2155
+ * Builds the ORM `args` object (include, select) shared by single-read operations.
2156
+ * Returns the args to pass to findUnique/findFirst and the resolved `include` list for serialization,
2157
+ * or an error response if query params are invalid.
2158
+ */
2159
+ buildSingleReadArgs(type, query) {
2160
+ const args = {};
641
2161
  this.includeRelationshipIds(type, args, "include");
642
2162
  let include;
643
2163
  if (query?.["include"]) {
644
2164
  const { select: select2, error: error2, allIncludes } = this.buildRelationSelect(type, query["include"], query);
645
2165
  if (error2) {
646
- return error2;
2166
+ return {
2167
+ args,
2168
+ include,
2169
+ error: error2
2170
+ };
647
2171
  }
648
2172
  if (select2) {
649
2173
  args.include = {
@@ -654,7 +2178,11 @@ ${err.stack}` : "Unknown error");
654
2178
  include = allIncludes;
655
2179
  }
656
2180
  const { select, error } = this.buildPartialSelect(type, query);
657
- if (error) return error;
2181
+ if (error) return {
2182
+ args,
2183
+ include,
2184
+ error
2185
+ };
658
2186
  if (select) {
659
2187
  args.select = {
660
2188
  ...select,
@@ -668,6 +2196,19 @@ ${err.stack}` : "Unknown error");
668
2196
  args.include = void 0;
669
2197
  }
670
2198
  }
2199
+ return {
2200
+ args,
2201
+ include
2202
+ };
2203
+ }
2204
+ async processSingleRead(client, type, resourceId, query) {
2205
+ const typeInfo = this.getModelInfo(type);
2206
+ if (!typeInfo) {
2207
+ return this.makeUnsupportedModelError(type);
2208
+ }
2209
+ const { args, include, error } = this.buildSingleReadArgs(type, query);
2210
+ if (error) return error;
2211
+ args.where = this.makeIdFilter(typeInfo.idFields, resourceId);
671
2212
  const entity = await client[type].findUnique(args);
672
2213
  if (entity) {
673
2214
  return {
@@ -700,7 +2241,7 @@ ${err.stack}` : "Unknown error");
700
2241
  select = relationSelect;
701
2242
  }
702
2243
  if (!select) {
703
- const { select: partialFields, error } = this.buildPartialSelect(lowerCaseFirst(relationInfo.type), query);
2244
+ const { select: partialFields, error } = this.buildPartialSelect(lowerCaseFirst3(relationInfo.type), query);
704
2245
  if (error) return error;
705
2246
  select = partialFields ? {
706
2247
  [relationship]: {
@@ -852,8 +2393,12 @@ ${err.stack}` : "Unknown error");
852
2393
  }
853
2394
  if (limit === Infinity) {
854
2395
  const entities = await client[type].findMany(args);
2396
+ const mappedType = this.mapModelName(type);
855
2397
  const body = await this.serializeItems(type, entities, {
856
- include
2398
+ include,
2399
+ linkers: {
2400
+ document: new tsjapi.Linker(() => this.makeLinkUrl(`/${mappedType}`))
2401
+ }
857
2402
  });
858
2403
  const total = entities.length;
859
2404
  body.meta = this.addTotalCountToMeta(body.meta, total);
@@ -875,6 +2420,7 @@ ${err.stack}` : "Unknown error");
875
2420
  const options = {
876
2421
  include,
877
2422
  linkers: {
2423
+ document: new tsjapi.Linker(() => this.makeLinkUrl(`/${mappedType}`)),
878
2424
  paginator: this.makePaginator(url, offset, limit, total)
879
2425
  }
880
2426
  };
@@ -886,6 +2432,278 @@ ${err.stack}` : "Unknown error");
886
2432
  };
887
2433
  }
888
2434
  }
2435
+ /**
2436
+ * Builds link URL for a nested resource using parent type, parent ID, relation name, and optional child ID.
2437
+ * Uses the parent model name mapping for the parent segment; the relation name is used as-is.
2438
+ */
2439
+ makeNestedLinkUrl(parentType, parentId, parentRelation, childId) {
2440
+ const mappedParentType = this.mapModelName(parentType);
2441
+ const base = `/${mappedParentType}/${parentId}/${parentRelation}`;
2442
+ return childId ? `${base}/${childId}` : base;
2443
+ }
2444
+ async processNestedSingleRead(client, parentType, parentId, parentRelation, childId, query) {
2445
+ const resolved = this.resolveNestedRelation(parentType, parentRelation);
2446
+ if (!resolved) {
2447
+ return this.makeError("invalidPath");
2448
+ }
2449
+ const { filter: nestedFilter, error: nestedError } = this.buildNestedParentFilter(parentType, parentId, parentRelation);
2450
+ if (nestedError) return nestedError;
2451
+ const childType = resolved.childType;
2452
+ const typeInfo = this.getModelInfo(childType);
2453
+ const { args, include, error } = this.buildSingleReadArgs(childType, query);
2454
+ if (error) return error;
2455
+ args.where = this.mergeFilters(this.makeIdFilter(typeInfo.idFields, childId), nestedFilter);
2456
+ const entity = await client[childType].findFirst(args);
2457
+ if (!entity) return this.makeError("notFound");
2458
+ const linkUrl = this.makeLinkUrl(this.makeNestedLinkUrl(parentType, parentId, parentRelation, childId));
2459
+ const nestedLinker = new tsjapi.Linker(() => linkUrl);
2460
+ return {
2461
+ status: 200,
2462
+ body: await this.serializeItems(childType, entity, {
2463
+ include,
2464
+ linkers: {
2465
+ document: nestedLinker,
2466
+ resource: nestedLinker
2467
+ }
2468
+ })
2469
+ };
2470
+ }
2471
+ async processNestedCreate(client, parentType, parentId, parentRelation, _query, requestBody) {
2472
+ const resolved = this.resolveNestedRelation(parentType, parentRelation);
2473
+ if (!resolved) {
2474
+ return this.makeError("invalidPath");
2475
+ }
2476
+ const parentInfo = this.getModelInfo(parentType);
2477
+ const childType = resolved.childType;
2478
+ const childInfo = this.getModelInfo(childType);
2479
+ const { attributes, relationships, error } = this.processRequestBody(requestBody);
2480
+ if (error) return error;
2481
+ const createData = {
2482
+ ...attributes
2483
+ };
2484
+ if (relationships) {
2485
+ for (const [key, data] of Object.entries(relationships)) {
2486
+ if (!data?.data) {
2487
+ return this.makeError("invalidRelationData");
2488
+ }
2489
+ if (key === resolved.reverseRelation) {
2490
+ return this.makeError("invalidPayload", `Relation "${key}" is controlled by the parent route and cannot be set in the request payload`);
2491
+ }
2492
+ const relationInfo = childInfo.relationships[key];
2493
+ if (!relationInfo) {
2494
+ return this.makeUnsupportedRelationshipError(childType, key, 400);
2495
+ }
2496
+ if (relationInfo.isCollection) {
2497
+ createData[key] = {
2498
+ connect: enumerate(data.data).map((item) => this.makeIdConnect(relationInfo.idFields, item.id))
2499
+ };
2500
+ } else {
2501
+ if (typeof data.data !== "object") {
2502
+ return this.makeError("invalidRelationData");
2503
+ }
2504
+ createData[key] = {
2505
+ connect: this.makeIdConnect(relationInfo.idFields, data.data.id)
2506
+ };
2507
+ }
2508
+ }
2509
+ }
2510
+ const parentFkFields = Object.values(childInfo.fields).filter((f) => f.foreignKeyFor?.includes(resolved.reverseRelation));
2511
+ if (parentFkFields.some((f) => Object.prototype.hasOwnProperty.call(createData, f.name))) {
2512
+ return this.makeError("invalidPayload", `Relation "${resolved.reverseRelation}" is controlled by the parent route and cannot be set in the request payload`);
2513
+ }
2514
+ await client[parentType].update({
2515
+ where: this.makeIdFilter(parentInfo.idFields, parentId),
2516
+ data: {
2517
+ [parentRelation]: {
2518
+ create: createData
2519
+ }
2520
+ }
2521
+ });
2522
+ const { filter: nestedFilter, error: filterError } = this.buildNestedParentFilter(parentType, parentId, parentRelation);
2523
+ if (filterError) return filterError;
2524
+ const fetchArgs = {
2525
+ where: nestedFilter
2526
+ };
2527
+ this.includeRelationshipIds(childType, fetchArgs, "include");
2528
+ if (childInfo.idFields[0]) {
2529
+ fetchArgs.orderBy = {
2530
+ [childInfo.idFields[0].name]: "desc"
2531
+ };
2532
+ }
2533
+ const entity = await client[childType].findFirst(fetchArgs);
2534
+ if (!entity) return this.makeError("notFound");
2535
+ const collectionPath = this.makeNestedLinkUrl(parentType, parentId, parentRelation);
2536
+ const resourceLinker = new tsjapi.Linker((item) => this.makeLinkUrl(`${collectionPath}/${this.getId(childInfo.name, item)}`));
2537
+ return {
2538
+ status: 201,
2539
+ body: await this.serializeItems(childType, entity, {
2540
+ linkers: {
2541
+ document: resourceLinker,
2542
+ resource: resourceLinker
2543
+ }
2544
+ })
2545
+ };
2546
+ }
2547
+ /**
2548
+ * Builds the ORM `data` payload for a nested update, shared by both to-many (childId present)
2549
+ * and to-one (childId absent) variants. Returns either `{ updateData }` or `{ error }`.
2550
+ */
2551
+ buildNestedUpdatePayload(childType, typeInfo, rev, requestBody) {
2552
+ const { attributes, relationships, error } = this.processRequestBody(requestBody);
2553
+ if (error) return {
2554
+ error
2555
+ };
2556
+ const updateData = {
2557
+ ...attributes
2558
+ };
2559
+ if (relationships && Object.prototype.hasOwnProperty.call(relationships, rev)) {
2560
+ return {
2561
+ error: this.makeError("invalidPayload", `Relation "${rev}" cannot be changed via a nested route`)
2562
+ };
2563
+ }
2564
+ const fkFields = Object.values(typeInfo.fields).filter((f) => f.foreignKeyFor?.includes(rev));
2565
+ if (fkFields.some((f) => Object.prototype.hasOwnProperty.call(updateData, f.name))) {
2566
+ return {
2567
+ error: this.makeError("invalidPayload", `Relation "${rev}" cannot be changed via a nested route`)
2568
+ };
2569
+ }
2570
+ if (relationships) {
2571
+ for (const [key, data] of Object.entries(relationships)) {
2572
+ if (!data?.data) {
2573
+ return {
2574
+ error: this.makeError("invalidRelationData")
2575
+ };
2576
+ }
2577
+ const relationInfo = typeInfo.relationships[key];
2578
+ if (!relationInfo) {
2579
+ return {
2580
+ error: this.makeUnsupportedRelationshipError(childType, key, 400)
2581
+ };
2582
+ }
2583
+ if (relationInfo.isCollection) {
2584
+ updateData[key] = {
2585
+ set: enumerate(data.data).map((item) => ({
2586
+ [this.makeDefaultIdKey(relationInfo.idFields)]: item.id
2587
+ }))
2588
+ };
2589
+ } else {
2590
+ if (typeof data.data !== "object") {
2591
+ return {
2592
+ error: this.makeError("invalidRelationData")
2593
+ };
2594
+ }
2595
+ updateData[key] = {
2596
+ connect: {
2597
+ [this.makeDefaultIdKey(relationInfo.idFields)]: data.data.id
2598
+ }
2599
+ };
2600
+ }
2601
+ }
2602
+ }
2603
+ return {
2604
+ updateData
2605
+ };
2606
+ }
2607
+ /**
2608
+ * Handles PATCH /:type/:id/:relationship/:childId (to-many) and
2609
+ * PATCH /:type/:id/:relationship (to-one, childId undefined).
2610
+ */
2611
+ async processNestedUpdate(client, parentType, parentId, parentRelation, childId, _query, requestBody) {
2612
+ const resolved = this.resolveNestedRelation(parentType, parentRelation);
2613
+ if (!resolved) {
2614
+ return this.makeError("invalidPath");
2615
+ }
2616
+ const parentInfo = this.getModelInfo(parentType);
2617
+ const childType = resolved.childType;
2618
+ const typeInfo = this.getModelInfo(childType);
2619
+ const { updateData, error } = this.buildNestedUpdatePayload(childType, typeInfo, resolved.reverseRelation, requestBody);
2620
+ if (error) return error;
2621
+ if (childId) {
2622
+ await client[parentType].update({
2623
+ where: this.makeIdFilter(parentInfo.idFields, parentId),
2624
+ data: {
2625
+ [parentRelation]: {
2626
+ update: {
2627
+ where: this.makeIdFilter(typeInfo.idFields, childId),
2628
+ data: updateData
2629
+ }
2630
+ }
2631
+ }
2632
+ });
2633
+ const fetchArgs = {
2634
+ where: this.makeIdFilter(typeInfo.idFields, childId)
2635
+ };
2636
+ this.includeRelationshipIds(childType, fetchArgs, "include");
2637
+ const entity = await client[childType].findUnique(fetchArgs);
2638
+ if (!entity) return this.makeError("notFound");
2639
+ const linkUrl = this.makeLinkUrl(this.makeNestedLinkUrl(parentType, parentId, parentRelation, childId));
2640
+ const nestedLinker = new tsjapi.Linker(() => linkUrl);
2641
+ return {
2642
+ status: 200,
2643
+ body: await this.serializeItems(childType, entity, {
2644
+ linkers: {
2645
+ document: nestedLinker,
2646
+ resource: nestedLinker
2647
+ }
2648
+ })
2649
+ };
2650
+ } else {
2651
+ await client[parentType].update({
2652
+ where: this.makeIdFilter(parentInfo.idFields, parentId),
2653
+ data: {
2654
+ [parentRelation]: {
2655
+ update: updateData
2656
+ }
2657
+ }
2658
+ });
2659
+ const childIncludeArgs = {};
2660
+ this.includeRelationshipIds(childType, childIncludeArgs, "include");
2661
+ const fetchArgs = {
2662
+ where: this.makeIdFilter(parentInfo.idFields, parentId),
2663
+ select: {
2664
+ [parentRelation]: childIncludeArgs.include ? {
2665
+ include: childIncludeArgs.include
2666
+ } : true
2667
+ }
2668
+ };
2669
+ const parent = await client[parentType].findUnique(fetchArgs);
2670
+ const entity = parent?.[parentRelation];
2671
+ if (!entity) return this.makeError("notFound");
2672
+ const linkUrl = this.makeLinkUrl(this.makeNestedLinkUrl(parentType, parentId, parentRelation));
2673
+ const nestedLinker = new tsjapi.Linker(() => linkUrl);
2674
+ return {
2675
+ status: 200,
2676
+ body: await this.serializeItems(childType, entity, {
2677
+ linkers: {
2678
+ document: nestedLinker,
2679
+ resource: nestedLinker
2680
+ }
2681
+ })
2682
+ };
2683
+ }
2684
+ }
2685
+ async processNestedDelete(client, parentType, parentId, parentRelation, childId) {
2686
+ const resolved = this.resolveNestedRelation(parentType, parentRelation);
2687
+ if (!resolved) {
2688
+ return this.makeError("invalidPath");
2689
+ }
2690
+ const parentInfo = this.getModelInfo(parentType);
2691
+ const typeInfo = this.getModelInfo(resolved.childType);
2692
+ await client[parentType].update({
2693
+ where: this.makeIdFilter(parentInfo.idFields, parentId),
2694
+ data: {
2695
+ [parentRelation]: {
2696
+ delete: this.makeIdFilter(typeInfo.idFields, childId)
2697
+ }
2698
+ }
2699
+ });
2700
+ return {
2701
+ status: 200,
2702
+ body: {
2703
+ meta: {}
2704
+ }
2705
+ };
2706
+ }
889
2707
  buildPartialSelect(type, query) {
890
2708
  const selectFieldsQuery = query?.[`fields[${type}]`];
891
2709
  if (!selectFieldsQuery) {
@@ -1244,7 +3062,7 @@ ${err.stack}` : "Unknown error");
1244
3062
  }
1245
3063
  getIdFields(model) {
1246
3064
  const modelDef = this.requireModel(model);
1247
- const modelLower = lowerCaseFirst(model);
3065
+ const modelLower = lowerCaseFirst3(model);
1248
3066
  if (!(modelLower in this.externalIdMapping)) {
1249
3067
  return Object.values(modelDef.fields).filter((f) => modelDef.idFields.includes(f.name));
1250
3068
  }
@@ -1278,7 +3096,7 @@ ${err.stack}` : "Unknown error");
1278
3096
  log(this.options.log, "warn", `Not including model ${model} in the API because it has no ID field`);
1279
3097
  continue;
1280
3098
  }
1281
- const modelInfo = this.typeMap[lowerCaseFirst(model)] = {
3099
+ const modelInfo = this.typeMap[lowerCaseFirst3(model)] = {
1282
3100
  name: model,
1283
3101
  idFields,
1284
3102
  relationships: {},
@@ -1303,7 +3121,7 @@ ${err.stack}` : "Unknown error");
1303
3121
  }
1304
3122
  }
1305
3123
  getModelInfo(model) {
1306
- return this.typeMap[lowerCaseFirst(model)];
3124
+ return this.typeMap[lowerCaseFirst3(model)];
1307
3125
  }
1308
3126
  makeLinkUrl(path) {
1309
3127
  return `${this.options.endpoint}${path}`;
@@ -1312,7 +3130,7 @@ ${err.stack}` : "Unknown error");
1312
3130
  const linkers = {};
1313
3131
  for (const model of Object.keys(this.schema.models)) {
1314
3132
  const ids = this.getIdFields(model);
1315
- const modelLower = lowerCaseFirst(model);
3133
+ const modelLower = lowerCaseFirst3(model);
1316
3134
  const mappedModel = this.mapModelName(modelLower);
1317
3135
  if (ids.length < 1) {
1318
3136
  continue;
@@ -1341,7 +3159,7 @@ ${err.stack}` : "Unknown error");
1341
3159
  this.serializers.set(modelLower, serializer);
1342
3160
  }
1343
3161
  for (const model of Object.keys(this.schema.models)) {
1344
- const modelLower = lowerCaseFirst(model);
3162
+ const modelLower = lowerCaseFirst3(model);
1345
3163
  const serializer = this.serializers.get(modelLower);
1346
3164
  if (!serializer) {
1347
3165
  continue;
@@ -1352,7 +3170,7 @@ ${err.stack}` : "Unknown error");
1352
3170
  if (!fieldDef.relation) {
1353
3171
  continue;
1354
3172
  }
1355
- const fieldSerializer = this.serializers.get(lowerCaseFirst(fieldDef.type));
3173
+ const fieldSerializer = this.serializers.get(lowerCaseFirst3(fieldDef.type));
1356
3174
  if (!fieldSerializer) {
1357
3175
  continue;
1358
3176
  }
@@ -1386,7 +3204,7 @@ ${err.stack}` : "Unknown error");
1386
3204
  }
1387
3205
  }
1388
3206
  async serializeItems(model, items, options) {
1389
- model = lowerCaseFirst(model);
3207
+ model = lowerCaseFirst3(model);
1390
3208
  const serializer = this.serializers.get(model);
1391
3209
  if (!serializer) {
1392
3210
  throw new Error(`serializer not found for model ${model}`);
@@ -1828,7 +3646,7 @@ ${err.stack}` : "Unknown error");
1828
3646
  error: this.makeUnsupportedModelError(relationInfo.type)
1829
3647
  };
1830
3648
  }
1831
- const { select, error } = this.buildPartialSelect(lowerCaseFirst(relationInfo.type), query);
3649
+ const { select, error } = this.buildPartialSelect(lowerCaseFirst3(relationInfo.type), query);
1832
3650
  if (error) return {
1833
3651
  select: void 0,
1834
3652
  error
@@ -2028,10 +3846,15 @@ ${err.stack}` : "Unknown error");
2028
3846
  makeUnsupportedRelationshipError(model, relationship, status) {
2029
3847
  return this.makeError("unsupportedRelationship", `Relationship ${model}.${relationship} doesn't exist`, status);
2030
3848
  }
3849
+ //#endregion
3850
+ async generateSpec(options) {
3851
+ const generator = new RestApiSpecGenerator(this.options);
3852
+ return generator.generateSpec(options);
3853
+ }
2031
3854
  };
2032
3855
 
2033
3856
  // src/api/rpc/index.ts
2034
- import { lowerCaseFirst as lowerCaseFirst2, safeJSONStringify } from "@zenstackhq/common-helpers";
3857
+ import { lowerCaseFirst as lowerCaseFirst4, safeJSONStringify as safeJSONStringify2 } from "@zenstackhq/common-helpers";
2035
3858
  import { CoreCrudOperations, ORMError as ORMError3, ORMErrorReason as ORMErrorReason2 } from "@zenstackhq/orm";
2036
3859
  import SuperJSON4 from "superjson";
2037
3860
  import { match as match3 } from "ts-pattern";
@@ -2052,7 +3875,8 @@ var RPCApiHandler = class {
2052
3875
  validateOptions(options) {
2053
3876
  const schema = z3.strictObject({
2054
3877
  schema: z3.object(),
2055
- log: loggerSchema.optional()
3878
+ log: loggerSchema.optional(),
3879
+ queryOptions: queryOptionsSchema.optional()
2056
3880
  });
2057
3881
  const parseResult = schema.safeParse(options);
2058
3882
  if (!parseResult.success) {
@@ -2089,7 +3913,7 @@ var RPCApiHandler = class {
2089
3913
  requestBody
2090
3914
  });
2091
3915
  }
2092
- model = lowerCaseFirst2(model);
3916
+ model = lowerCaseFirst4(model);
2093
3917
  method = method.toUpperCase();
2094
3918
  let args;
2095
3919
  let resCode = 200;
@@ -2156,7 +3980,7 @@ var RPCApiHandler = class {
2156
3980
  if (!this.isValidModel(client, model)) {
2157
3981
  return this.makeBadInputErrorResponse(`unknown model name: ${model}`);
2158
3982
  }
2159
- log(this.options.log, "debug", () => `handling "${model}.${op}" request with args: ${safeJSONStringify(processedArgs)}`);
3983
+ log(this.options.log, "debug", () => `handling "${model}.${op}" request with args: ${safeJSONStringify2(processedArgs)}`);
2160
3984
  const clientResult = await client[model][op](processedArgs);
2161
3985
  let responseBody = {
2162
3986
  data: clientResult
@@ -2176,7 +4000,7 @@ var RPCApiHandler = class {
2176
4000
  status: resCode,
2177
4001
  body: responseBody
2178
4002
  };
2179
- log(this.options.log, "debug", () => `sending response for "${model}.${op}" request: ${safeJSONStringify(response)}`);
4003
+ log(this.options.log, "debug", () => `sending response for "${model}.${op}" request: ${safeJSONStringify2(response)}`);
2180
4004
  return response;
2181
4005
  } catch (err) {
2182
4006
  log(this.options.log, "error", `error occurred when handling "${model}.${op}" request`, err);
@@ -2213,7 +4037,7 @@ var RPCApiHandler = class {
2213
4037
  if (!VALID_OPS.has(itemOp)) {
2214
4038
  return this.makeBadInputErrorResponse(`operation at index ${i} has invalid op: ${itemOp}`);
2215
4039
  }
2216
- if (!this.isValidModel(client, lowerCaseFirst2(itemModel))) {
4040
+ if (!this.isValidModel(client, lowerCaseFirst4(itemModel))) {
2217
4041
  return this.makeBadInputErrorResponse(`operation at index ${i} has unknown model: ${itemModel}`);
2218
4042
  }
2219
4043
  if (itemArgs !== void 0 && itemArgs !== null && (typeof itemArgs !== "object" || Array.isArray(itemArgs))) {
@@ -2224,7 +4048,7 @@ var RPCApiHandler = class {
2224
4048
  return this.makeBadInputErrorResponse(`operation at index ${i}: ${argsError}`);
2225
4049
  }
2226
4050
  processedOps.push({
2227
- model: lowerCaseFirst2(itemModel),
4051
+ model: lowerCaseFirst4(itemModel),
2228
4052
  op: itemOp,
2229
4053
  args: processedArgs
2230
4054
  });
@@ -2248,7 +4072,7 @@ var RPCApiHandler = class {
2248
4072
  status: 200,
2249
4073
  body: responseBody
2250
4074
  };
2251
- log(this.options.log, "debug", () => `sending response for "$transaction" request: ${safeJSONStringify(response)}`);
4075
+ log(this.options.log, "debug", () => `sending response for "$transaction" request: ${safeJSONStringify2(response)}`);
2252
4076
  return response;
2253
4077
  } catch (err) {
2254
4078
  log(this.options.log, "error", `error occurred when handling "$transaction" request`, err);
@@ -2310,7 +4134,7 @@ var RPCApiHandler = class {
2310
4134
  status: 200,
2311
4135
  body: responseBody
2312
4136
  };
2313
- log(this.options.log, "debug", () => `sending response for "$procs.${proc}" request: ${safeJSONStringify(response)}`);
4137
+ log(this.options.log, "debug", () => `sending response for "$procs.${proc}" request: ${safeJSONStringify2(response)}`);
2314
4138
  return response;
2315
4139
  } catch (err) {
2316
4140
  log(this.options.log, "error", `error occurred when handling "$procs.${proc}" request`, err);
@@ -2321,7 +4145,7 @@ var RPCApiHandler = class {
2321
4145
  }
2322
4146
  }
2323
4147
  isValidModel(client, model) {
2324
- return Object.keys(client.$schema.models).some((m) => lowerCaseFirst2(m) === lowerCaseFirst2(model));
4148
+ return Object.keys(client.$schema.models).some((m) => lowerCaseFirst4(m) === lowerCaseFirst4(model));
2325
4149
  }
2326
4150
  makeBadInputErrorResponse(message) {
2327
4151
  const resp = {
@@ -2332,7 +4156,7 @@ var RPCApiHandler = class {
2332
4156
  }
2333
4157
  }
2334
4158
  };
2335
- log(this.options.log, "debug", () => `sending error response: ${safeJSONStringify(resp)}`);
4159
+ log(this.options.log, "debug", () => `sending error response: ${safeJSONStringify2(resp)}`);
2336
4160
  return resp;
2337
4161
  }
2338
4162
  makeGenericErrorResponse(err) {
@@ -2344,7 +4168,7 @@ var RPCApiHandler = class {
2344
4168
  }
2345
4169
  }
2346
4170
  };
2347
- log(this.options.log, "debug", () => `sending error response: ${safeJSONStringify(resp)}${err instanceof Error ? "\n" + err.stack : ""}`);
4171
+ log(this.options.log, "debug", () => `sending error response: ${safeJSONStringify2(resp)}${err instanceof Error ? "\n" + err.stack : ""}`);
2348
4172
  return resp;
2349
4173
  }
2350
4174
  makeORMErrorResponse(err) {
@@ -2376,7 +4200,7 @@ var RPCApiHandler = class {
2376
4200
  error
2377
4201
  }
2378
4202
  };
2379
- log(this.options.log, "debug", () => `sending error response: ${safeJSONStringify(resp)}`);
4203
+ log(this.options.log, "debug", () => `sending error response: ${safeJSONStringify2(resp)}`);
2380
4204
  return resp;
2381
4205
  }
2382
4206
  async processRequestPayload(args) {