@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/README.md +40 -0
- package/dist/api.cjs +1915 -91
- package/dist/api.cjs.map +1 -1
- package/dist/api.d.cts +83 -5
- package/dist/api.d.ts +83 -5
- package/dist/api.js +1873 -49
- package/dist/api.js.map +1 -1
- package/package.json +10 -7
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
|
-
|
|
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
|
-
|
|
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, "
|
|
2038
|
+
let match4 = this.matchUrlPattern(path, "relationship");
|
|
531
2039
|
if (match4) {
|
|
532
|
-
return await this.
|
|
2040
|
+
return await this.processRelationshipCRUD(client, "update", match4.type, match4.id, match4.relationship, query, requestBody);
|
|
533
2041
|
}
|
|
534
|
-
|
|
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.
|
|
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, "
|
|
2057
|
+
let match4 = this.matchUrlPattern(path, "relationship");
|
|
542
2058
|
if (match4) {
|
|
543
|
-
return await this.
|
|
2059
|
+
return await this.processRelationshipCRUD(client, "delete", match4.type, match4.id, match4.relationship, query, requestBody);
|
|
544
2060
|
}
|
|
545
|
-
|
|
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.
|
|
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
|
-
|
|
566
|
-
${err.stack
|
|
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: ${
|
|
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: ${
|
|
2151
|
+
log(this.log, "debug", () => `sending error response: ${safeJSONStringify(resp)}${err instanceof Error ? "\n" + err.stack : ""}`);
|
|
631
2152
|
return resp;
|
|
632
2153
|
}
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
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
|
|
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
|
|
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(
|
|
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 =
|
|
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[
|
|
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[
|
|
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 =
|
|
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 =
|
|
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(
|
|
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 =
|
|
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(
|
|
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
|
|
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 =
|
|
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: ${
|
|
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: ${
|
|
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,
|
|
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:
|
|
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: ${
|
|
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: ${
|
|
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) =>
|
|
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: ${
|
|
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: ${
|
|
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: ${
|
|
4203
|
+
log(this.options.log, "debug", () => `sending error response: ${safeJSONStringify2(resp)}`);
|
|
2380
4204
|
return resp;
|
|
2381
4205
|
}
|
|
2382
4206
|
async processRequestPayload(args) {
|