@vertz/server 0.2.17 → 0.2.19
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/index.d.ts +254 -133
- package/dist/index.js +874 -293
- package/package.json +5 -5
package/dist/index.js
CHANGED
|
@@ -6236,6 +6236,260 @@ import {
|
|
|
6236
6236
|
err as err4,
|
|
6237
6237
|
ok as ok4
|
|
6238
6238
|
} from "@vertz/errors";
|
|
6239
|
+
|
|
6240
|
+
// src/entity/vertzql-parser.ts
|
|
6241
|
+
function extractAllowKeys(allow) {
|
|
6242
|
+
if (!allow)
|
|
6243
|
+
return [];
|
|
6244
|
+
if (Array.isArray(allow))
|
|
6245
|
+
return allow;
|
|
6246
|
+
return Object.keys(allow);
|
|
6247
|
+
}
|
|
6248
|
+
var MAX_CURSOR_LENGTH = 512;
|
|
6249
|
+
var MAX_LIMIT = 1000;
|
|
6250
|
+
var MAX_Q_BASE64_LENGTH = 10240;
|
|
6251
|
+
var ALLOWED_Q_KEYS = new Set(["select", "include", "where", "orderBy", "limit"]);
|
|
6252
|
+
function parseVertzQL(query) {
|
|
6253
|
+
const result = {};
|
|
6254
|
+
for (const [key, value] of Object.entries(query)) {
|
|
6255
|
+
const whereMatch = key.match(/^where\[([^\]]+)\](?:\[([^\]]+)\])?$/);
|
|
6256
|
+
if (whereMatch) {
|
|
6257
|
+
if (!result.where)
|
|
6258
|
+
result.where = {};
|
|
6259
|
+
const field = whereMatch[1];
|
|
6260
|
+
const op = whereMatch[2];
|
|
6261
|
+
const existing = result.where[field];
|
|
6262
|
+
if (op) {
|
|
6263
|
+
const base = existing && typeof existing === "object" ? existing : existing !== undefined ? { eq: existing } : {};
|
|
6264
|
+
result.where[field] = { ...base, [op]: value };
|
|
6265
|
+
} else {
|
|
6266
|
+
if (existing && typeof existing === "object") {
|
|
6267
|
+
result.where[field] = { ...existing, eq: value };
|
|
6268
|
+
} else {
|
|
6269
|
+
result.where[field] = value;
|
|
6270
|
+
}
|
|
6271
|
+
}
|
|
6272
|
+
continue;
|
|
6273
|
+
}
|
|
6274
|
+
if (key === "limit") {
|
|
6275
|
+
const parsed = Number.parseInt(value, 10);
|
|
6276
|
+
if (!Number.isNaN(parsed)) {
|
|
6277
|
+
result.limit = Math.max(0, Math.min(parsed, MAX_LIMIT));
|
|
6278
|
+
}
|
|
6279
|
+
continue;
|
|
6280
|
+
}
|
|
6281
|
+
if (key === "after") {
|
|
6282
|
+
if (value && value.length <= MAX_CURSOR_LENGTH) {
|
|
6283
|
+
result.after = value;
|
|
6284
|
+
}
|
|
6285
|
+
continue;
|
|
6286
|
+
}
|
|
6287
|
+
if (key === "orderBy") {
|
|
6288
|
+
const [field, dir] = value.split(":");
|
|
6289
|
+
if (field) {
|
|
6290
|
+
if (!result.orderBy)
|
|
6291
|
+
result.orderBy = {};
|
|
6292
|
+
result.orderBy[field] = dir === "desc" ? "desc" : "asc";
|
|
6293
|
+
}
|
|
6294
|
+
continue;
|
|
6295
|
+
}
|
|
6296
|
+
if (key === "q") {
|
|
6297
|
+
try {
|
|
6298
|
+
const urlDecoded = decodeURIComponent(value);
|
|
6299
|
+
if (urlDecoded.length > MAX_Q_BASE64_LENGTH) {
|
|
6300
|
+
result._qError = "q= parameter exceeds maximum allowed size";
|
|
6301
|
+
continue;
|
|
6302
|
+
}
|
|
6303
|
+
const b64 = urlDecoded.replace(/-/g, "+").replace(/_/g, "/");
|
|
6304
|
+
const padded = b64 + "=".repeat((4 - b64.length % 4) % 4);
|
|
6305
|
+
const decoded = JSON.parse(atob(padded));
|
|
6306
|
+
for (const k of Object.keys(decoded)) {
|
|
6307
|
+
if (!ALLOWED_Q_KEYS.has(k)) {
|
|
6308
|
+
delete decoded[k];
|
|
6309
|
+
}
|
|
6310
|
+
}
|
|
6311
|
+
if (decoded.select && typeof decoded.select === "object") {
|
|
6312
|
+
result.select = decoded.select;
|
|
6313
|
+
}
|
|
6314
|
+
if (decoded.include && typeof decoded.include === "object") {
|
|
6315
|
+
result.include = decoded.include;
|
|
6316
|
+
}
|
|
6317
|
+
if (decoded.where && typeof decoded.where === "object" && !Array.isArray(decoded.where)) {
|
|
6318
|
+
result.where = { ...result.where, ...decoded.where };
|
|
6319
|
+
}
|
|
6320
|
+
if (decoded.orderBy && typeof decoded.orderBy === "object" && !Array.isArray(decoded.orderBy)) {
|
|
6321
|
+
const orderByObj = decoded.orderBy;
|
|
6322
|
+
const sanitized = {};
|
|
6323
|
+
for (const [field, dir] of Object.entries(orderByObj)) {
|
|
6324
|
+
sanitized[field] = dir === "desc" ? "desc" : "asc";
|
|
6325
|
+
}
|
|
6326
|
+
result.orderBy = { ...result.orderBy, ...sanitized };
|
|
6327
|
+
}
|
|
6328
|
+
if (decoded.limit !== undefined) {
|
|
6329
|
+
const parsed = typeof decoded.limit === "number" ? decoded.limit : Number(decoded.limit);
|
|
6330
|
+
if (!Number.isNaN(parsed)) {
|
|
6331
|
+
result.limit = Math.max(0, Math.min(parsed, MAX_LIMIT));
|
|
6332
|
+
}
|
|
6333
|
+
}
|
|
6334
|
+
} catch {
|
|
6335
|
+
result._qError = "Invalid q= parameter: not valid base64 or JSON";
|
|
6336
|
+
}
|
|
6337
|
+
}
|
|
6338
|
+
}
|
|
6339
|
+
return result;
|
|
6340
|
+
}
|
|
6341
|
+
function getHiddenColumns(table) {
|
|
6342
|
+
const hidden = new Set;
|
|
6343
|
+
for (const key of Object.keys(table._columns)) {
|
|
6344
|
+
const col = table._columns[key];
|
|
6345
|
+
if (col?._meta._annotations.hidden) {
|
|
6346
|
+
hidden.add(key);
|
|
6347
|
+
}
|
|
6348
|
+
}
|
|
6349
|
+
return hidden;
|
|
6350
|
+
}
|
|
6351
|
+
function validateVertzQL(options, table, relationsConfig, exposeConfig, evaluatedExpose) {
|
|
6352
|
+
if (options._qError) {
|
|
6353
|
+
return { ok: false, error: options._qError };
|
|
6354
|
+
}
|
|
6355
|
+
const hiddenColumns = getHiddenColumns(table);
|
|
6356
|
+
if (options.where) {
|
|
6357
|
+
const allowWhereSet = evaluatedExpose ? evaluatedExpose.allowedWhereFields : null;
|
|
6358
|
+
const allowWhereKeys = !evaluatedExpose && exposeConfig ? extractAllowKeys(exposeConfig.allowWhere) : null;
|
|
6359
|
+
for (const field of Object.keys(options.where)) {
|
|
6360
|
+
if (hiddenColumns.has(field)) {
|
|
6361
|
+
return { ok: false, error: `Field "${field}" is not filterable` };
|
|
6362
|
+
}
|
|
6363
|
+
if (allowWhereSet !== null && !allowWhereSet.has(field)) {
|
|
6364
|
+
return { ok: false, error: `Field "${field}" is not filterable` };
|
|
6365
|
+
}
|
|
6366
|
+
if (allowWhereKeys !== null && !allowWhereKeys.includes(field)) {
|
|
6367
|
+
return { ok: false, error: `Field "${field}" is not filterable` };
|
|
6368
|
+
}
|
|
6369
|
+
}
|
|
6370
|
+
}
|
|
6371
|
+
if (options.orderBy) {
|
|
6372
|
+
const allowOrderBySet = evaluatedExpose ? evaluatedExpose.allowedOrderByFields : null;
|
|
6373
|
+
const allowOrderByKeys = !evaluatedExpose && exposeConfig ? extractAllowKeys(exposeConfig.allowOrderBy) : null;
|
|
6374
|
+
for (const field of Object.keys(options.orderBy)) {
|
|
6375
|
+
if (hiddenColumns.has(field)) {
|
|
6376
|
+
return { ok: false, error: `Field "${field}" is not sortable` };
|
|
6377
|
+
}
|
|
6378
|
+
if (allowOrderBySet !== null && !allowOrderBySet.has(field)) {
|
|
6379
|
+
return { ok: false, error: `Field "${field}" is not sortable` };
|
|
6380
|
+
}
|
|
6381
|
+
if (allowOrderByKeys !== null && !allowOrderByKeys.includes(field)) {
|
|
6382
|
+
return { ok: false, error: `Field "${field}" is not sortable` };
|
|
6383
|
+
}
|
|
6384
|
+
}
|
|
6385
|
+
}
|
|
6386
|
+
if (options.select) {
|
|
6387
|
+
const exposeSelectKeys = exposeConfig ? extractAllowKeys(exposeConfig.select) : null;
|
|
6388
|
+
for (const field of Object.keys(options.select)) {
|
|
6389
|
+
if (hiddenColumns.has(field)) {
|
|
6390
|
+
return { ok: false, error: `Field "${field}" is not selectable` };
|
|
6391
|
+
}
|
|
6392
|
+
if (exposeSelectKeys !== null && exposeSelectKeys.length > 0 && !exposeSelectKeys.includes(field)) {
|
|
6393
|
+
return { ok: false, error: `Field "${field}" is not selectable` };
|
|
6394
|
+
}
|
|
6395
|
+
}
|
|
6396
|
+
}
|
|
6397
|
+
if (options.include && relationsConfig) {
|
|
6398
|
+
const includeResult = validateInclude(options.include, relationsConfig, "");
|
|
6399
|
+
if (!includeResult.ok)
|
|
6400
|
+
return includeResult;
|
|
6401
|
+
}
|
|
6402
|
+
return { ok: true };
|
|
6403
|
+
}
|
|
6404
|
+
function validateInclude(include, relationsConfig, pathPrefix) {
|
|
6405
|
+
for (const [relation, requested] of Object.entries(include)) {
|
|
6406
|
+
const entityConfig = relationsConfig[relation];
|
|
6407
|
+
const relationPath = pathPrefix ? `${pathPrefix}.${relation}` : relation;
|
|
6408
|
+
if (entityConfig === undefined || entityConfig === false) {
|
|
6409
|
+
return { ok: false, error: `Relation "${relationPath}" is not exposed` };
|
|
6410
|
+
}
|
|
6411
|
+
if (requested === true)
|
|
6412
|
+
continue;
|
|
6413
|
+
const configObj = typeof entityConfig === "object" ? entityConfig : undefined;
|
|
6414
|
+
if (requested.where) {
|
|
6415
|
+
const allowWhereKeys = extractAllowKeys(configObj?.allowWhere);
|
|
6416
|
+
if (!configObj || allowWhereKeys.length === 0) {
|
|
6417
|
+
return {
|
|
6418
|
+
ok: false,
|
|
6419
|
+
error: `Filtering is not enabled on relation '${relationPath}'. ` + "Add 'allowWhere' to the entity relations config."
|
|
6420
|
+
};
|
|
6421
|
+
}
|
|
6422
|
+
const allowedSet = new Set(allowWhereKeys);
|
|
6423
|
+
for (const field of Object.keys(requested.where)) {
|
|
6424
|
+
if (!allowedSet.has(field)) {
|
|
6425
|
+
return {
|
|
6426
|
+
ok: false,
|
|
6427
|
+
error: `Field '${field}' is not filterable on relation '${relationPath}'. ` + `Allowed: ${allowWhereKeys.join(", ")}`
|
|
6428
|
+
};
|
|
6429
|
+
}
|
|
6430
|
+
}
|
|
6431
|
+
}
|
|
6432
|
+
if (requested.orderBy) {
|
|
6433
|
+
const allowOrderByKeys = extractAllowKeys(configObj?.allowOrderBy);
|
|
6434
|
+
if (!configObj || allowOrderByKeys.length === 0) {
|
|
6435
|
+
return {
|
|
6436
|
+
ok: false,
|
|
6437
|
+
error: `Sorting is not enabled on relation '${relationPath}'. ` + "Add 'allowOrderBy' to the entity relations config."
|
|
6438
|
+
};
|
|
6439
|
+
}
|
|
6440
|
+
const allowedSet = new Set(allowOrderByKeys);
|
|
6441
|
+
for (const [field, dir] of Object.entries(requested.orderBy)) {
|
|
6442
|
+
if (!allowedSet.has(field)) {
|
|
6443
|
+
return {
|
|
6444
|
+
ok: false,
|
|
6445
|
+
error: `Field '${field}' is not sortable on relation '${relationPath}'. ` + `Allowed: ${allowOrderByKeys.join(", ")}`
|
|
6446
|
+
};
|
|
6447
|
+
}
|
|
6448
|
+
if (dir !== "asc" && dir !== "desc") {
|
|
6449
|
+
return {
|
|
6450
|
+
ok: false,
|
|
6451
|
+
error: `Invalid orderBy direction '${String(dir)}' for field '${field}' on relation '${relationPath}'. Must be 'asc' or 'desc'.`
|
|
6452
|
+
};
|
|
6453
|
+
}
|
|
6454
|
+
}
|
|
6455
|
+
}
|
|
6456
|
+
if (requested.limit !== undefined) {
|
|
6457
|
+
if (typeof requested.limit !== "number" || !Number.isFinite(requested.limit)) {
|
|
6458
|
+
return {
|
|
6459
|
+
ok: false,
|
|
6460
|
+
error: `Invalid limit on relation '${relationPath}': must be a finite number`
|
|
6461
|
+
};
|
|
6462
|
+
}
|
|
6463
|
+
if (requested.limit < 0) {
|
|
6464
|
+
requested.limit = 0;
|
|
6465
|
+
}
|
|
6466
|
+
if (configObj?.maxLimit !== undefined && requested.limit > configObj.maxLimit) {
|
|
6467
|
+
requested.limit = configObj.maxLimit;
|
|
6468
|
+
}
|
|
6469
|
+
}
|
|
6470
|
+
if (requested.select && configObj?.select) {
|
|
6471
|
+
for (const field of Object.keys(requested.select)) {
|
|
6472
|
+
if (!(field in configObj.select)) {
|
|
6473
|
+
return {
|
|
6474
|
+
ok: false,
|
|
6475
|
+
error: `Field "${field}" is not exposed on relation "${relationPath}"`
|
|
6476
|
+
};
|
|
6477
|
+
}
|
|
6478
|
+
}
|
|
6479
|
+
}
|
|
6480
|
+
if (requested.include) {
|
|
6481
|
+
if (entityConfig === true) {
|
|
6482
|
+
return {
|
|
6483
|
+
ok: false,
|
|
6484
|
+
error: `Nested includes are not supported on relation '${relationPath}' ` + "without a structured relations config."
|
|
6485
|
+
};
|
|
6486
|
+
}
|
|
6487
|
+
}
|
|
6488
|
+
}
|
|
6489
|
+
return { ok: true };
|
|
6490
|
+
}
|
|
6491
|
+
|
|
6492
|
+
// src/entity/crud-pipeline.ts
|
|
6239
6493
|
function resolvePrimaryKeyColumn(table) {
|
|
6240
6494
|
for (const key of Object.keys(table._columns)) {
|
|
6241
6495
|
const col = table._columns[key];
|
|
@@ -6320,7 +6574,7 @@ function createCrudHandlers(def, db, options) {
|
|
|
6320
6574
|
const indirectWhere = await resolveIndirectTenantWhere(ctx);
|
|
6321
6575
|
const where = indirectWhere ? { ...directWhere, ...indirectWhere } : directWhere;
|
|
6322
6576
|
const limit = Math.max(0, options2?.limit ?? 20);
|
|
6323
|
-
const after = options2?.after && options2.after.length <=
|
|
6577
|
+
const after = options2?.after && options2.after.length <= MAX_CURSOR_LENGTH ? options2.after : undefined;
|
|
6324
6578
|
const orderBy = options2?.orderBy;
|
|
6325
6579
|
const include = options2?.include;
|
|
6326
6580
|
const { data: rows, total } = await db.list({ where, orderBy, limit, after, include });
|
|
@@ -6521,310 +6775,76 @@ async function evaluateExposeRule(rule, ctx, options) {
|
|
|
6521
6775
|
return options.can(rule.entitlement);
|
|
6522
6776
|
}
|
|
6523
6777
|
case "where":
|
|
6524
|
-
return false;
|
|
6525
|
-
case "all": {
|
|
6526
|
-
for (const sub of rule.rules) {
|
|
6527
|
-
if (!await evaluateExposeRule(sub, ctx, options))
|
|
6528
|
-
return false;
|
|
6529
|
-
}
|
|
6530
|
-
return true;
|
|
6531
|
-
}
|
|
6532
|
-
case "any": {
|
|
6533
|
-
for (const sub of rule.rules) {
|
|
6534
|
-
if (await evaluateExposeRule(sub, ctx, options))
|
|
6535
|
-
return true;
|
|
6536
|
-
}
|
|
6537
|
-
return false;
|
|
6538
|
-
}
|
|
6539
|
-
case "fva": {
|
|
6540
|
-
if (options.fvaAge === undefined)
|
|
6541
|
-
return false;
|
|
6542
|
-
return options.fvaAge <= rule.maxAge;
|
|
6543
|
-
}
|
|
6544
|
-
}
|
|
6545
|
-
}
|
|
6546
|
-
async function evaluateExposeDescriptors(expose, ctx, options = {}) {
|
|
6547
|
-
const allowedSelectFields = new Set;
|
|
6548
|
-
const nulledFields = new Set;
|
|
6549
|
-
const allowedWhereFields = new Set;
|
|
6550
|
-
const allowedOrderByFields = new Set;
|
|
6551
|
-
for (const [field, value] of Object.entries(expose.select)) {
|
|
6552
|
-
if (value === true) {
|
|
6553
|
-
allowedSelectFields.add(field);
|
|
6554
|
-
} else {
|
|
6555
|
-
const passed = await evaluateExposeRule(value, ctx, options);
|
|
6556
|
-
if (passed) {
|
|
6557
|
-
allowedSelectFields.add(field);
|
|
6558
|
-
} else {
|
|
6559
|
-
allowedSelectFields.add(field);
|
|
6560
|
-
nulledFields.add(field);
|
|
6561
|
-
}
|
|
6562
|
-
}
|
|
6563
|
-
}
|
|
6564
|
-
if (expose.allowWhere) {
|
|
6565
|
-
for (const [field, value] of Object.entries(expose.allowWhere)) {
|
|
6566
|
-
if (value === true) {
|
|
6567
|
-
allowedWhereFields.add(field);
|
|
6568
|
-
} else {
|
|
6569
|
-
const passed = await evaluateExposeRule(value, ctx, options);
|
|
6570
|
-
if (passed) {
|
|
6571
|
-
allowedWhereFields.add(field);
|
|
6572
|
-
}
|
|
6573
|
-
}
|
|
6574
|
-
}
|
|
6575
|
-
}
|
|
6576
|
-
if (expose.allowOrderBy) {
|
|
6577
|
-
for (const [field, value] of Object.entries(expose.allowOrderBy)) {
|
|
6578
|
-
if (value === true) {
|
|
6579
|
-
allowedOrderByFields.add(field);
|
|
6580
|
-
} else {
|
|
6581
|
-
const passed = await evaluateExposeRule(value, ctx, options);
|
|
6582
|
-
if (passed) {
|
|
6583
|
-
allowedOrderByFields.add(field);
|
|
6584
|
-
}
|
|
6585
|
-
}
|
|
6586
|
-
}
|
|
6587
|
-
}
|
|
6588
|
-
return {
|
|
6589
|
-
allowedSelectFields,
|
|
6590
|
-
nulledFields,
|
|
6591
|
-
allowedWhereFields,
|
|
6592
|
-
allowedOrderByFields
|
|
6593
|
-
};
|
|
6594
|
-
}
|
|
6595
|
-
|
|
6596
|
-
// src/entity/vertzql-parser.ts
|
|
6597
|
-
function extractAllowKeys(allow) {
|
|
6598
|
-
if (!allow)
|
|
6599
|
-
return [];
|
|
6600
|
-
if (Array.isArray(allow))
|
|
6601
|
-
return allow;
|
|
6602
|
-
return Object.keys(allow);
|
|
6603
|
-
}
|
|
6604
|
-
var MAX_LIMIT = 1000;
|
|
6605
|
-
var MAX_Q_BASE64_LENGTH = 10240;
|
|
6606
|
-
var ALLOWED_Q_KEYS = new Set(["select", "include", "where", "orderBy", "limit", "offset"]);
|
|
6607
|
-
function parseVertzQL(query) {
|
|
6608
|
-
const result = {};
|
|
6609
|
-
for (const [key, value] of Object.entries(query)) {
|
|
6610
|
-
const whereMatch = key.match(/^where\[([^\]]+)\](?:\[([^\]]+)\])?$/);
|
|
6611
|
-
if (whereMatch) {
|
|
6612
|
-
if (!result.where)
|
|
6613
|
-
result.where = {};
|
|
6614
|
-
const field = whereMatch[1];
|
|
6615
|
-
const op = whereMatch[2];
|
|
6616
|
-
const existing = result.where[field];
|
|
6617
|
-
if (op) {
|
|
6618
|
-
const base = existing && typeof existing === "object" ? existing : existing !== undefined ? { eq: existing } : {};
|
|
6619
|
-
result.where[field] = { ...base, [op]: value };
|
|
6620
|
-
} else {
|
|
6621
|
-
if (existing && typeof existing === "object") {
|
|
6622
|
-
result.where[field] = { ...existing, eq: value };
|
|
6623
|
-
} else {
|
|
6624
|
-
result.where[field] = value;
|
|
6625
|
-
}
|
|
6626
|
-
}
|
|
6627
|
-
continue;
|
|
6628
|
-
}
|
|
6629
|
-
if (key === "limit") {
|
|
6630
|
-
const parsed = Number.parseInt(value, 10);
|
|
6631
|
-
if (!Number.isNaN(parsed)) {
|
|
6632
|
-
result.limit = Math.max(0, Math.min(parsed, MAX_LIMIT));
|
|
6633
|
-
}
|
|
6634
|
-
continue;
|
|
6635
|
-
}
|
|
6636
|
-
if (key === "after") {
|
|
6637
|
-
if (value) {
|
|
6638
|
-
result.after = value;
|
|
6639
|
-
}
|
|
6640
|
-
continue;
|
|
6641
|
-
}
|
|
6642
|
-
if (key === "orderBy") {
|
|
6643
|
-
const [field, dir] = value.split(":");
|
|
6644
|
-
if (field) {
|
|
6645
|
-
if (!result.orderBy)
|
|
6646
|
-
result.orderBy = {};
|
|
6647
|
-
result.orderBy[field] = dir === "desc" ? "desc" : "asc";
|
|
6648
|
-
}
|
|
6649
|
-
continue;
|
|
6650
|
-
}
|
|
6651
|
-
if (key === "q") {
|
|
6652
|
-
try {
|
|
6653
|
-
const urlDecoded = decodeURIComponent(value);
|
|
6654
|
-
if (urlDecoded.length > MAX_Q_BASE64_LENGTH) {
|
|
6655
|
-
result._qError = "q= parameter exceeds maximum allowed size";
|
|
6656
|
-
continue;
|
|
6657
|
-
}
|
|
6658
|
-
const b64 = urlDecoded.replace(/-/g, "+").replace(/_/g, "/");
|
|
6659
|
-
const padded = b64 + "=".repeat((4 - b64.length % 4) % 4);
|
|
6660
|
-
const decoded = JSON.parse(atob(padded));
|
|
6661
|
-
for (const k of Object.keys(decoded)) {
|
|
6662
|
-
if (!ALLOWED_Q_KEYS.has(k)) {
|
|
6663
|
-
delete decoded[k];
|
|
6664
|
-
}
|
|
6665
|
-
}
|
|
6666
|
-
if (decoded.select && typeof decoded.select === "object") {
|
|
6667
|
-
result.select = decoded.select;
|
|
6668
|
-
}
|
|
6669
|
-
if (decoded.include && typeof decoded.include === "object") {
|
|
6670
|
-
result.include = decoded.include;
|
|
6671
|
-
}
|
|
6672
|
-
} catch {
|
|
6673
|
-
result._qError = "Invalid q= parameter: not valid base64 or JSON";
|
|
6674
|
-
}
|
|
6675
|
-
}
|
|
6676
|
-
}
|
|
6677
|
-
return result;
|
|
6678
|
-
}
|
|
6679
|
-
function getHiddenColumns(table) {
|
|
6680
|
-
const hidden = new Set;
|
|
6681
|
-
for (const key of Object.keys(table._columns)) {
|
|
6682
|
-
const col = table._columns[key];
|
|
6683
|
-
if (col?._meta._annotations.hidden) {
|
|
6684
|
-
hidden.add(key);
|
|
6685
|
-
}
|
|
6686
|
-
}
|
|
6687
|
-
return hidden;
|
|
6688
|
-
}
|
|
6689
|
-
function validateVertzQL(options, table, relationsConfig, exposeConfig, evaluatedExpose) {
|
|
6690
|
-
if (options._qError) {
|
|
6691
|
-
return { ok: false, error: options._qError };
|
|
6692
|
-
}
|
|
6693
|
-
const hiddenColumns = getHiddenColumns(table);
|
|
6694
|
-
if (options.where) {
|
|
6695
|
-
const allowWhereSet = evaluatedExpose ? evaluatedExpose.allowedWhereFields : null;
|
|
6696
|
-
const allowWhereKeys = !evaluatedExpose && exposeConfig ? extractAllowKeys(exposeConfig.allowWhere) : null;
|
|
6697
|
-
for (const field of Object.keys(options.where)) {
|
|
6698
|
-
if (hiddenColumns.has(field)) {
|
|
6699
|
-
return { ok: false, error: `Field "${field}" is not filterable` };
|
|
6700
|
-
}
|
|
6701
|
-
if (allowWhereSet !== null && !allowWhereSet.has(field)) {
|
|
6702
|
-
return { ok: false, error: `Field "${field}" is not filterable` };
|
|
6703
|
-
}
|
|
6704
|
-
if (allowWhereKeys !== null && !allowWhereKeys.includes(field)) {
|
|
6705
|
-
return { ok: false, error: `Field "${field}" is not filterable` };
|
|
6706
|
-
}
|
|
6707
|
-
}
|
|
6708
|
-
}
|
|
6709
|
-
if (options.orderBy) {
|
|
6710
|
-
const allowOrderBySet = evaluatedExpose ? evaluatedExpose.allowedOrderByFields : null;
|
|
6711
|
-
const allowOrderByKeys = !evaluatedExpose && exposeConfig ? extractAllowKeys(exposeConfig.allowOrderBy) : null;
|
|
6712
|
-
for (const field of Object.keys(options.orderBy)) {
|
|
6713
|
-
if (hiddenColumns.has(field)) {
|
|
6714
|
-
return { ok: false, error: `Field "${field}" is not sortable` };
|
|
6715
|
-
}
|
|
6716
|
-
if (allowOrderBySet !== null && !allowOrderBySet.has(field)) {
|
|
6717
|
-
return { ok: false, error: `Field "${field}" is not sortable` };
|
|
6718
|
-
}
|
|
6719
|
-
if (allowOrderByKeys !== null && !allowOrderByKeys.includes(field)) {
|
|
6720
|
-
return { ok: false, error: `Field "${field}" is not sortable` };
|
|
6721
|
-
}
|
|
6722
|
-
}
|
|
6723
|
-
}
|
|
6724
|
-
if (options.select) {
|
|
6725
|
-
const exposeSelectKeys = exposeConfig ? extractAllowKeys(exposeConfig.select) : null;
|
|
6726
|
-
for (const field of Object.keys(options.select)) {
|
|
6727
|
-
if (hiddenColumns.has(field)) {
|
|
6728
|
-
return { ok: false, error: `Field "${field}" is not selectable` };
|
|
6729
|
-
}
|
|
6730
|
-
if (exposeSelectKeys !== null && exposeSelectKeys.length > 0 && !exposeSelectKeys.includes(field)) {
|
|
6731
|
-
return { ok: false, error: `Field "${field}" is not selectable` };
|
|
6732
|
-
}
|
|
6733
|
-
}
|
|
6734
|
-
}
|
|
6735
|
-
if (options.include && relationsConfig) {
|
|
6736
|
-
const includeResult = validateInclude(options.include, relationsConfig, "");
|
|
6737
|
-
if (!includeResult.ok)
|
|
6738
|
-
return includeResult;
|
|
6739
|
-
}
|
|
6740
|
-
return { ok: true };
|
|
6741
|
-
}
|
|
6742
|
-
function validateInclude(include, relationsConfig, pathPrefix) {
|
|
6743
|
-
for (const [relation, requested] of Object.entries(include)) {
|
|
6744
|
-
const entityConfig = relationsConfig[relation];
|
|
6745
|
-
const relationPath = pathPrefix ? `${pathPrefix}.${relation}` : relation;
|
|
6746
|
-
if (entityConfig === undefined || entityConfig === false) {
|
|
6747
|
-
return { ok: false, error: `Relation "${relationPath}" is not exposed` };
|
|
6748
|
-
}
|
|
6749
|
-
if (requested === true)
|
|
6750
|
-
continue;
|
|
6751
|
-
const configObj = typeof entityConfig === "object" ? entityConfig : undefined;
|
|
6752
|
-
if (requested.where) {
|
|
6753
|
-
const allowWhereKeys = extractAllowKeys(configObj?.allowWhere);
|
|
6754
|
-
if (!configObj || allowWhereKeys.length === 0) {
|
|
6755
|
-
return {
|
|
6756
|
-
ok: false,
|
|
6757
|
-
error: `Filtering is not enabled on relation '${relationPath}'. ` + "Add 'allowWhere' to the entity relations config."
|
|
6758
|
-
};
|
|
6759
|
-
}
|
|
6760
|
-
const allowedSet = new Set(allowWhereKeys);
|
|
6761
|
-
for (const field of Object.keys(requested.where)) {
|
|
6762
|
-
if (!allowedSet.has(field)) {
|
|
6763
|
-
return {
|
|
6764
|
-
ok: false,
|
|
6765
|
-
error: `Field '${field}' is not filterable on relation '${relationPath}'. ` + `Allowed: ${allowWhereKeys.join(", ")}`
|
|
6766
|
-
};
|
|
6767
|
-
}
|
|
6778
|
+
return false;
|
|
6779
|
+
case "all": {
|
|
6780
|
+
for (const sub of rule.rules) {
|
|
6781
|
+
if (!await evaluateExposeRule(sub, ctx, options))
|
|
6782
|
+
return false;
|
|
6768
6783
|
}
|
|
6784
|
+
return true;
|
|
6769
6785
|
}
|
|
6770
|
-
|
|
6771
|
-
const
|
|
6772
|
-
|
|
6773
|
-
|
|
6774
|
-
ok: false,
|
|
6775
|
-
error: `Sorting is not enabled on relation '${relationPath}'. ` + "Add 'allowOrderBy' to the entity relations config."
|
|
6776
|
-
};
|
|
6777
|
-
}
|
|
6778
|
-
const allowedSet = new Set(allowOrderByKeys);
|
|
6779
|
-
for (const [field, dir] of Object.entries(requested.orderBy)) {
|
|
6780
|
-
if (!allowedSet.has(field)) {
|
|
6781
|
-
return {
|
|
6782
|
-
ok: false,
|
|
6783
|
-
error: `Field '${field}' is not sortable on relation '${relationPath}'. ` + `Allowed: ${allowOrderByKeys.join(", ")}`
|
|
6784
|
-
};
|
|
6785
|
-
}
|
|
6786
|
-
if (dir !== "asc" && dir !== "desc") {
|
|
6787
|
-
return {
|
|
6788
|
-
ok: false,
|
|
6789
|
-
error: `Invalid orderBy direction '${String(dir)}' for field '${field}' on relation '${relationPath}'. Must be 'asc' or 'desc'.`
|
|
6790
|
-
};
|
|
6791
|
-
}
|
|
6786
|
+
case "any": {
|
|
6787
|
+
for (const sub of rule.rules) {
|
|
6788
|
+
if (await evaluateExposeRule(sub, ctx, options))
|
|
6789
|
+
return true;
|
|
6792
6790
|
}
|
|
6791
|
+
return false;
|
|
6793
6792
|
}
|
|
6794
|
-
|
|
6795
|
-
if (
|
|
6796
|
-
return
|
|
6797
|
-
|
|
6798
|
-
|
|
6799
|
-
|
|
6800
|
-
|
|
6801
|
-
|
|
6802
|
-
|
|
6803
|
-
|
|
6804
|
-
|
|
6805
|
-
|
|
6793
|
+
case "fva": {
|
|
6794
|
+
if (options.fvaAge === undefined)
|
|
6795
|
+
return false;
|
|
6796
|
+
return options.fvaAge <= rule.maxAge;
|
|
6797
|
+
}
|
|
6798
|
+
}
|
|
6799
|
+
}
|
|
6800
|
+
async function evaluateExposeDescriptors(expose, ctx, options = {}) {
|
|
6801
|
+
const allowedSelectFields = new Set;
|
|
6802
|
+
const nulledFields = new Set;
|
|
6803
|
+
const allowedWhereFields = new Set;
|
|
6804
|
+
const allowedOrderByFields = new Set;
|
|
6805
|
+
for (const [field, value] of Object.entries(expose.select)) {
|
|
6806
|
+
if (value === true) {
|
|
6807
|
+
allowedSelectFields.add(field);
|
|
6808
|
+
} else {
|
|
6809
|
+
const passed = await evaluateExposeRule(value, ctx, options);
|
|
6810
|
+
if (passed) {
|
|
6811
|
+
allowedSelectFields.add(field);
|
|
6812
|
+
} else {
|
|
6813
|
+
allowedSelectFields.add(field);
|
|
6814
|
+
nulledFields.add(field);
|
|
6806
6815
|
}
|
|
6807
6816
|
}
|
|
6808
|
-
|
|
6809
|
-
|
|
6810
|
-
|
|
6811
|
-
|
|
6812
|
-
|
|
6813
|
-
|
|
6814
|
-
|
|
6817
|
+
}
|
|
6818
|
+
if (expose.allowWhere) {
|
|
6819
|
+
for (const [field, value] of Object.entries(expose.allowWhere)) {
|
|
6820
|
+
if (value === true) {
|
|
6821
|
+
allowedWhereFields.add(field);
|
|
6822
|
+
} else {
|
|
6823
|
+
const passed = await evaluateExposeRule(value, ctx, options);
|
|
6824
|
+
if (passed) {
|
|
6825
|
+
allowedWhereFields.add(field);
|
|
6815
6826
|
}
|
|
6816
6827
|
}
|
|
6817
6828
|
}
|
|
6818
|
-
|
|
6819
|
-
|
|
6820
|
-
|
|
6821
|
-
|
|
6822
|
-
|
|
6823
|
-
|
|
6829
|
+
}
|
|
6830
|
+
if (expose.allowOrderBy) {
|
|
6831
|
+
for (const [field, value] of Object.entries(expose.allowOrderBy)) {
|
|
6832
|
+
if (value === true) {
|
|
6833
|
+
allowedOrderByFields.add(field);
|
|
6834
|
+
} else {
|
|
6835
|
+
const passed = await evaluateExposeRule(value, ctx, options);
|
|
6836
|
+
if (passed) {
|
|
6837
|
+
allowedOrderByFields.add(field);
|
|
6838
|
+
}
|
|
6824
6839
|
}
|
|
6825
6840
|
}
|
|
6826
6841
|
}
|
|
6827
|
-
return {
|
|
6842
|
+
return {
|
|
6843
|
+
allowedSelectFields,
|
|
6844
|
+
nulledFields,
|
|
6845
|
+
allowedWhereFields,
|
|
6846
|
+
allowedOrderByFields
|
|
6847
|
+
};
|
|
6828
6848
|
}
|
|
6829
6849
|
|
|
6830
6850
|
// src/entity/route-generator.ts
|
|
@@ -6940,6 +6960,19 @@ function generateEntityRoutes(def, registry, db, options) {
|
|
|
6940
6960
|
try {
|
|
6941
6961
|
const entityCtx = makeEntityCtx(ctx);
|
|
6942
6962
|
const body = ctx.body ?? {};
|
|
6963
|
+
if (body.after !== undefined) {
|
|
6964
|
+
if (typeof body.after !== "string") {
|
|
6965
|
+
return jsonResponse({ error: { code: "BadRequest", message: "cursor must be a string" } }, 400);
|
|
6966
|
+
}
|
|
6967
|
+
if (body.after.length > MAX_CURSOR_LENGTH) {
|
|
6968
|
+
return jsonResponse({
|
|
6969
|
+
error: {
|
|
6970
|
+
code: "BadRequest",
|
|
6971
|
+
message: `cursor exceeds maximum length of ${MAX_CURSOR_LENGTH}`
|
|
6972
|
+
}
|
|
6973
|
+
}, 400);
|
|
6974
|
+
}
|
|
6975
|
+
}
|
|
6943
6976
|
const parsed = {
|
|
6944
6977
|
where: body.where,
|
|
6945
6978
|
orderBy: body.orderBy,
|
|
@@ -7627,6 +7660,549 @@ function entity(name, config) {
|
|
|
7627
7660
|
};
|
|
7628
7661
|
return deepFreeze(def);
|
|
7629
7662
|
}
|
|
7663
|
+
// src/entity/openapi-generator.ts
|
|
7664
|
+
function columnToJsonSchema(column) {
|
|
7665
|
+
const meta = column._meta;
|
|
7666
|
+
const schema = sqlTypeToJsonSchema(meta);
|
|
7667
|
+
if (meta.nullable) {
|
|
7668
|
+
const baseType = schema.type;
|
|
7669
|
+
if (baseType) {
|
|
7670
|
+
schema.type = [baseType, "null"];
|
|
7671
|
+
} else {
|
|
7672
|
+
return { oneOf: [schema, { type: "null" }] };
|
|
7673
|
+
}
|
|
7674
|
+
}
|
|
7675
|
+
return schema;
|
|
7676
|
+
}
|
|
7677
|
+
function sqlTypeToJsonSchema(meta) {
|
|
7678
|
+
if (meta.format === "email") {
|
|
7679
|
+
return { type: "string", format: "email" };
|
|
7680
|
+
}
|
|
7681
|
+
switch (meta.sqlType) {
|
|
7682
|
+
case "uuid":
|
|
7683
|
+
return { type: "string", format: "uuid" };
|
|
7684
|
+
case "text":
|
|
7685
|
+
return { type: "string" };
|
|
7686
|
+
case "varchar": {
|
|
7687
|
+
const schema = { type: "string" };
|
|
7688
|
+
if (meta.length !== undefined)
|
|
7689
|
+
schema.maxLength = meta.length;
|
|
7690
|
+
return schema;
|
|
7691
|
+
}
|
|
7692
|
+
case "boolean":
|
|
7693
|
+
return { type: "boolean" };
|
|
7694
|
+
case "integer":
|
|
7695
|
+
case "serial":
|
|
7696
|
+
return { type: "integer" };
|
|
7697
|
+
case "bigint":
|
|
7698
|
+
case "decimal":
|
|
7699
|
+
return { type: "string" };
|
|
7700
|
+
case "real":
|
|
7701
|
+
return { type: "number" };
|
|
7702
|
+
case "double precision":
|
|
7703
|
+
return { type: "number", format: "double" };
|
|
7704
|
+
case "timestamp with time zone":
|
|
7705
|
+
return { type: "string", format: "date-time" };
|
|
7706
|
+
case "date":
|
|
7707
|
+
return { type: "string", format: "date" };
|
|
7708
|
+
case "time":
|
|
7709
|
+
return { type: "string", format: "time" };
|
|
7710
|
+
case "jsonb":
|
|
7711
|
+
return {};
|
|
7712
|
+
case "text[]":
|
|
7713
|
+
return { type: "array", items: { type: "string" } };
|
|
7714
|
+
case "integer[]":
|
|
7715
|
+
return { type: "array", items: { type: "integer" } };
|
|
7716
|
+
case "enum":
|
|
7717
|
+
return { type: "string", enum: meta.enumValues };
|
|
7718
|
+
default:
|
|
7719
|
+
return {};
|
|
7720
|
+
}
|
|
7721
|
+
}
|
|
7722
|
+
function toPascalCase(name) {
|
|
7723
|
+
return name.split("-").map((s2) => s2.charAt(0).toUpperCase() + s2.slice(1)).join("");
|
|
7724
|
+
}
|
|
7725
|
+
function buildColumnsSchema(columns, selectFilter) {
|
|
7726
|
+
const properties = {};
|
|
7727
|
+
const required = [];
|
|
7728
|
+
for (const [name, col] of Object.entries(columns)) {
|
|
7729
|
+
const meta = col._meta;
|
|
7730
|
+
if (meta._annotations.hidden)
|
|
7731
|
+
continue;
|
|
7732
|
+
if (selectFilter && !(name in selectFilter))
|
|
7733
|
+
continue;
|
|
7734
|
+
const isDescriptorGuarded = selectFilter && selectFilter[name] !== true;
|
|
7735
|
+
let schema = columnToJsonSchema(col);
|
|
7736
|
+
if (isDescriptorGuarded) {
|
|
7737
|
+
const baseType = schema.type;
|
|
7738
|
+
if (baseType) {
|
|
7739
|
+
const typeArray = Array.isArray(baseType) ? baseType : [baseType];
|
|
7740
|
+
if (!typeArray.includes("null")) {
|
|
7741
|
+
schema = { ...schema, type: [...typeArray, "null"] };
|
|
7742
|
+
}
|
|
7743
|
+
}
|
|
7744
|
+
const descriptor = selectFilter?.[name];
|
|
7745
|
+
const entitlementName = descriptor.entitlement ?? descriptor.type ?? "access rule";
|
|
7746
|
+
schema.description = `Requires entitlement '${entitlementName}'. Returns null when the caller lacks the entitlement.`;
|
|
7747
|
+
}
|
|
7748
|
+
properties[name] = schema;
|
|
7749
|
+
if (!meta.nullable && !isDescriptorGuarded) {
|
|
7750
|
+
required.push(name);
|
|
7751
|
+
}
|
|
7752
|
+
}
|
|
7753
|
+
const result = { type: "object", properties };
|
|
7754
|
+
if (required.length > 0)
|
|
7755
|
+
result.required = required;
|
|
7756
|
+
return result;
|
|
7757
|
+
}
|
|
7758
|
+
function entityResponseSchema(def, relationSchemas) {
|
|
7759
|
+
const table = def.model.table;
|
|
7760
|
+
const columns = table._columns;
|
|
7761
|
+
const exposeSelect = def.expose?.select;
|
|
7762
|
+
const schema = buildColumnsSchema(columns, exposeSelect);
|
|
7763
|
+
if (def.expose?.include && relationSchemas) {
|
|
7764
|
+
const relations = def.model.relations;
|
|
7765
|
+
const includeConfig = def.expose.include;
|
|
7766
|
+
const entityPrefix = toPascalCase(def.name);
|
|
7767
|
+
for (const [relationName, config] of Object.entries(includeConfig)) {
|
|
7768
|
+
if (config === false)
|
|
7769
|
+
continue;
|
|
7770
|
+
const relation = relations[relationName];
|
|
7771
|
+
if (!relation)
|
|
7772
|
+
continue;
|
|
7773
|
+
const targetTable = relation._target();
|
|
7774
|
+
const targetColumns = targetTable._columns;
|
|
7775
|
+
const relationSchemaName = `${entityPrefix}${toPascalCase(relationName)}Response`;
|
|
7776
|
+
if (config === true) {
|
|
7777
|
+
relationSchemas[relationSchemaName] = buildColumnsSchema(targetColumns);
|
|
7778
|
+
} else {
|
|
7779
|
+
const relSelect = config.select;
|
|
7780
|
+
relationSchemas[relationSchemaName] = buildColumnsSchema(targetColumns, relSelect);
|
|
7781
|
+
}
|
|
7782
|
+
if (schema.properties) {
|
|
7783
|
+
const isMany = relation._type === "many";
|
|
7784
|
+
if (isMany) {
|
|
7785
|
+
schema.properties[relationName] = {
|
|
7786
|
+
type: "array",
|
|
7787
|
+
items: { $ref: `#/components/schemas/${relationSchemaName}` }
|
|
7788
|
+
};
|
|
7789
|
+
} else {
|
|
7790
|
+
schema.properties[relationName] = {
|
|
7791
|
+
$ref: `#/components/schemas/${relationSchemaName}`
|
|
7792
|
+
};
|
|
7793
|
+
}
|
|
7794
|
+
}
|
|
7795
|
+
}
|
|
7796
|
+
}
|
|
7797
|
+
return schema;
|
|
7798
|
+
}
|
|
7799
|
+
function buildInputColumnsSchema(columns, allOptional) {
|
|
7800
|
+
const properties = {};
|
|
7801
|
+
const required = [];
|
|
7802
|
+
for (const [name, col] of Object.entries(columns)) {
|
|
7803
|
+
const meta = col._meta;
|
|
7804
|
+
if (meta.primary)
|
|
7805
|
+
continue;
|
|
7806
|
+
if (meta.isReadOnly)
|
|
7807
|
+
continue;
|
|
7808
|
+
if (meta.isAutoUpdate)
|
|
7809
|
+
continue;
|
|
7810
|
+
if (meta._annotations.hidden)
|
|
7811
|
+
continue;
|
|
7812
|
+
properties[name] = columnToJsonSchema(col);
|
|
7813
|
+
if (!allOptional && !meta.nullable && !meta.hasDefault) {
|
|
7814
|
+
required.push(name);
|
|
7815
|
+
}
|
|
7816
|
+
}
|
|
7817
|
+
const result = { type: "object", properties };
|
|
7818
|
+
if (required.length > 0)
|
|
7819
|
+
result.required = required;
|
|
7820
|
+
return result;
|
|
7821
|
+
}
|
|
7822
|
+
function entityCreateInputSchema(def) {
|
|
7823
|
+
const table = def.model.table;
|
|
7824
|
+
const columns = table._columns;
|
|
7825
|
+
return buildInputColumnsSchema(columns, false);
|
|
7826
|
+
}
|
|
7827
|
+
function entityUpdateInputSchema(def) {
|
|
7828
|
+
const table = def.model.table;
|
|
7829
|
+
const columns = table._columns;
|
|
7830
|
+
return buildInputColumnsSchema(columns, true);
|
|
7831
|
+
}
|
|
7832
|
+
var ERROR_RESPONSE_SCHEMA = {
|
|
7833
|
+
type: "object",
|
|
7834
|
+
required: ["error"],
|
|
7835
|
+
properties: {
|
|
7836
|
+
error: {
|
|
7837
|
+
type: "object",
|
|
7838
|
+
required: ["code", "message"],
|
|
7839
|
+
properties: {
|
|
7840
|
+
code: { type: "string" },
|
|
7841
|
+
message: { type: "string" }
|
|
7842
|
+
}
|
|
7843
|
+
}
|
|
7844
|
+
}
|
|
7845
|
+
};
|
|
7846
|
+
var STANDARD_RESPONSES = {
|
|
7847
|
+
BadRequest: {
|
|
7848
|
+
description: "Bad Request",
|
|
7849
|
+
content: {
|
|
7850
|
+
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } }
|
|
7851
|
+
}
|
|
7852
|
+
},
|
|
7853
|
+
Unauthorized: {
|
|
7854
|
+
description: "Unauthorized",
|
|
7855
|
+
content: {
|
|
7856
|
+
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } }
|
|
7857
|
+
}
|
|
7858
|
+
},
|
|
7859
|
+
NotFound: {
|
|
7860
|
+
description: "Not Found",
|
|
7861
|
+
content: {
|
|
7862
|
+
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } }
|
|
7863
|
+
}
|
|
7864
|
+
}
|
|
7865
|
+
};
|
|
7866
|
+
function errorRefs(...codes) {
|
|
7867
|
+
const result = {};
|
|
7868
|
+
for (const code of codes) {
|
|
7869
|
+
const refName = code === "400" ? "BadRequest" : code === "401" ? "Unauthorized" : "NotFound";
|
|
7870
|
+
result[code] = { $ref: `#/components/responses/${refName}` };
|
|
7871
|
+
}
|
|
7872
|
+
return result;
|
|
7873
|
+
}
|
|
7874
|
+
function extractJsonSchema(schema, entityName, actionName, field) {
|
|
7875
|
+
if (schema && typeof schema === "object" && "toJSONSchema" in schema && typeof schema.toJSONSchema === "function") {
|
|
7876
|
+
return schema.toJSONSchema();
|
|
7877
|
+
}
|
|
7878
|
+
console.warn(`[vertz] Warning: Action "${entityName}.${actionName}" ${field} schema does not expose JSON schema — using "any" in OpenAPI spec.`);
|
|
7879
|
+
return { description: "Schema not available for automated extraction." };
|
|
7880
|
+
}
|
|
7881
|
+
function generateOpenAPISpec(entities, options) {
|
|
7882
|
+
const apiPrefix = options.apiPrefix ?? "/api";
|
|
7883
|
+
const paths = {};
|
|
7884
|
+
const schemas = {
|
|
7885
|
+
ErrorResponse: ERROR_RESPONSE_SCHEMA
|
|
7886
|
+
};
|
|
7887
|
+
const tags = [];
|
|
7888
|
+
for (const def of entities) {
|
|
7889
|
+
const prefix = toPascalCase(def.name);
|
|
7890
|
+
const basePath = `${apiPrefix}/${def.name}`;
|
|
7891
|
+
const tag = def.name;
|
|
7892
|
+
tags.push({ name: tag });
|
|
7893
|
+
const relationSchemas = {};
|
|
7894
|
+
schemas[`${prefix}Response`] = entityResponseSchema(def, relationSchemas);
|
|
7895
|
+
Object.assign(schemas, relationSchemas);
|
|
7896
|
+
if (def.access.create !== undefined) {
|
|
7897
|
+
schemas[`${prefix}CreateInput`] = entityCreateInputSchema(def);
|
|
7898
|
+
}
|
|
7899
|
+
if (def.access.update !== undefined) {
|
|
7900
|
+
schemas[`${prefix}UpdateInput`] = entityUpdateInputSchema(def);
|
|
7901
|
+
}
|
|
7902
|
+
const collectionPath = {};
|
|
7903
|
+
const itemPath = {};
|
|
7904
|
+
if (def.access.list !== undefined && def.access.list !== false) {
|
|
7905
|
+
collectionPath.get = buildListOperation(def, prefix, tag);
|
|
7906
|
+
}
|
|
7907
|
+
if (def.access.create !== undefined && def.access.create !== false) {
|
|
7908
|
+
collectionPath.post = buildCreateOperation(prefix, tag);
|
|
7909
|
+
}
|
|
7910
|
+
if (def.access.get !== undefined && def.access.get !== false) {
|
|
7911
|
+
itemPath.get = buildGetOperation(prefix, tag);
|
|
7912
|
+
}
|
|
7913
|
+
if (def.access.update !== undefined && def.access.update !== false) {
|
|
7914
|
+
itemPath.patch = buildUpdateOperation(prefix, tag);
|
|
7915
|
+
}
|
|
7916
|
+
if (def.access.delete === false) {
|
|
7917
|
+
itemPath.delete = buildDisabledOperation(def.name, "delete", tag);
|
|
7918
|
+
} else if (def.access.delete !== undefined) {
|
|
7919
|
+
itemPath.delete = buildDeleteOperation(def.name, tag);
|
|
7920
|
+
}
|
|
7921
|
+
if (Object.keys(collectionPath).length > 0) {
|
|
7922
|
+
paths[basePath] = collectionPath;
|
|
7923
|
+
}
|
|
7924
|
+
if (Object.keys(itemPath).length > 0) {
|
|
7925
|
+
paths[`${basePath}/{id}`] = itemPath;
|
|
7926
|
+
}
|
|
7927
|
+
if (def.access.list !== undefined && def.access.list !== false) {
|
|
7928
|
+
paths[`${basePath}/query`] = {
|
|
7929
|
+
post: buildQueryOperation(def.name, prefix, tag)
|
|
7930
|
+
};
|
|
7931
|
+
}
|
|
7932
|
+
if (def.actions) {
|
|
7933
|
+
for (const [actionName, actionDef] of Object.entries(def.actions)) {
|
|
7934
|
+
const method = (actionDef.method ?? "POST").toUpperCase();
|
|
7935
|
+
const actionPath = actionDef.path ?? actionName;
|
|
7936
|
+
const fullPath = `${basePath}/{id}/${actionPath}`;
|
|
7937
|
+
const operation = buildActionOperation(def.name, actionName, actionDef, tag);
|
|
7938
|
+
const pathItem = {};
|
|
7939
|
+
if (method === "POST") {
|
|
7940
|
+
pathItem.post = operation;
|
|
7941
|
+
} else if (method === "PATCH") {
|
|
7942
|
+
pathItem.patch = operation;
|
|
7943
|
+
} else if (method === "GET") {
|
|
7944
|
+
pathItem.get = operation;
|
|
7945
|
+
} else if (method === "DELETE") {
|
|
7946
|
+
pathItem.delete = operation;
|
|
7947
|
+
}
|
|
7948
|
+
paths[fullPath] = pathItem;
|
|
7949
|
+
}
|
|
7950
|
+
}
|
|
7951
|
+
}
|
|
7952
|
+
const spec = {
|
|
7953
|
+
openapi: "3.1.0",
|
|
7954
|
+
info: options.info,
|
|
7955
|
+
paths,
|
|
7956
|
+
components: {
|
|
7957
|
+
schemas,
|
|
7958
|
+
responses: STANDARD_RESPONSES
|
|
7959
|
+
},
|
|
7960
|
+
tags
|
|
7961
|
+
};
|
|
7962
|
+
if (options.servers) {
|
|
7963
|
+
spec.servers = options.servers;
|
|
7964
|
+
}
|
|
7965
|
+
return spec;
|
|
7966
|
+
}
|
|
7967
|
+
function buildListOperation(def, prefix, tag) {
|
|
7968
|
+
const parameters = [];
|
|
7969
|
+
const table = def.model.table;
|
|
7970
|
+
const columns = table._columns;
|
|
7971
|
+
const expose = def.expose;
|
|
7972
|
+
if (expose?.allowWhere) {
|
|
7973
|
+
const allowWhere = expose.allowWhere;
|
|
7974
|
+
for (const field of Object.keys(allowWhere)) {
|
|
7975
|
+
const col = columns[field];
|
|
7976
|
+
if (!col)
|
|
7977
|
+
continue;
|
|
7978
|
+
const schema = columnToJsonSchema(col);
|
|
7979
|
+
parameters.push({
|
|
7980
|
+
name: `where[${field}]`,
|
|
7981
|
+
in: "query",
|
|
7982
|
+
required: false,
|
|
7983
|
+
schema
|
|
7984
|
+
});
|
|
7985
|
+
}
|
|
7986
|
+
}
|
|
7987
|
+
if (expose?.allowOrderBy) {
|
|
7988
|
+
const allowedFields = Object.keys(expose.allowOrderBy);
|
|
7989
|
+
parameters.push({
|
|
7990
|
+
name: "orderBy",
|
|
7991
|
+
in: "query",
|
|
7992
|
+
required: false,
|
|
7993
|
+
schema: {
|
|
7994
|
+
type: "string",
|
|
7995
|
+
enum: allowedFields.flatMap((f) => [`${f}:asc`, `${f}:desc`])
|
|
7996
|
+
},
|
|
7997
|
+
description: "Sort order. Format: field:direction"
|
|
7998
|
+
});
|
|
7999
|
+
}
|
|
8000
|
+
parameters.push({ name: "limit", in: "query", required: false, schema: { type: "integer" } }, { name: "after", in: "query", required: false, schema: { type: "string" } }, {
|
|
8001
|
+
name: "q",
|
|
8002
|
+
in: "query",
|
|
8003
|
+
required: false,
|
|
8004
|
+
schema: { type: "string" },
|
|
8005
|
+
description: "Base64-encoded VertzQL query"
|
|
8006
|
+
});
|
|
8007
|
+
return {
|
|
8008
|
+
operationId: `${def.name}_list`,
|
|
8009
|
+
tags: [tag],
|
|
8010
|
+
summary: `List ${def.name}`,
|
|
8011
|
+
parameters,
|
|
8012
|
+
responses: {
|
|
8013
|
+
"200": {
|
|
8014
|
+
description: "OK",
|
|
8015
|
+
content: {
|
|
8016
|
+
"application/json": {
|
|
8017
|
+
schema: {
|
|
8018
|
+
type: "object",
|
|
8019
|
+
properties: {
|
|
8020
|
+
items: {
|
|
8021
|
+
type: "array",
|
|
8022
|
+
items: { $ref: `#/components/schemas/${prefix}Response` }
|
|
8023
|
+
},
|
|
8024
|
+
cursor: { type: "string" }
|
|
8025
|
+
}
|
|
8026
|
+
}
|
|
8027
|
+
}
|
|
8028
|
+
}
|
|
8029
|
+
},
|
|
8030
|
+
...errorRefs("400", "401")
|
|
8031
|
+
}
|
|
8032
|
+
};
|
|
8033
|
+
}
|
|
8034
|
+
function buildCreateOperation(prefix, tag) {
|
|
8035
|
+
return {
|
|
8036
|
+
operationId: `${tag}_create`,
|
|
8037
|
+
tags: [tag],
|
|
8038
|
+
summary: `Create a ${tag}`,
|
|
8039
|
+
requestBody: {
|
|
8040
|
+
required: true,
|
|
8041
|
+
content: {
|
|
8042
|
+
"application/json": {
|
|
8043
|
+
schema: { $ref: `#/components/schemas/${prefix}CreateInput` }
|
|
8044
|
+
}
|
|
8045
|
+
}
|
|
8046
|
+
},
|
|
8047
|
+
responses: {
|
|
8048
|
+
"201": {
|
|
8049
|
+
description: "Created",
|
|
8050
|
+
content: {
|
|
8051
|
+
"application/json": {
|
|
8052
|
+
schema: { $ref: `#/components/schemas/${prefix}Response` }
|
|
8053
|
+
}
|
|
8054
|
+
}
|
|
8055
|
+
},
|
|
8056
|
+
...errorRefs("400", "401")
|
|
8057
|
+
}
|
|
8058
|
+
};
|
|
8059
|
+
}
|
|
8060
|
+
function buildGetOperation(prefix, tag) {
|
|
8061
|
+
return {
|
|
8062
|
+
operationId: `${tag}_get`,
|
|
8063
|
+
tags: [tag],
|
|
8064
|
+
summary: `Get a ${tag} by ID`,
|
|
8065
|
+
parameters: [
|
|
8066
|
+
{ name: "id", in: "path", required: true, schema: { type: "string", format: "uuid" } }
|
|
8067
|
+
],
|
|
8068
|
+
responses: {
|
|
8069
|
+
"200": {
|
|
8070
|
+
description: "OK",
|
|
8071
|
+
content: {
|
|
8072
|
+
"application/json": {
|
|
8073
|
+
schema: { $ref: `#/components/schemas/${prefix}Response` }
|
|
8074
|
+
}
|
|
8075
|
+
}
|
|
8076
|
+
},
|
|
8077
|
+
...errorRefs("401", "404")
|
|
8078
|
+
}
|
|
8079
|
+
};
|
|
8080
|
+
}
|
|
8081
|
+
function buildUpdateOperation(prefix, tag) {
|
|
8082
|
+
return {
|
|
8083
|
+
operationId: `${tag}_update`,
|
|
8084
|
+
tags: [tag],
|
|
8085
|
+
summary: `Update a ${tag}`,
|
|
8086
|
+
parameters: [
|
|
8087
|
+
{ name: "id", in: "path", required: true, schema: { type: "string", format: "uuid" } }
|
|
8088
|
+
],
|
|
8089
|
+
requestBody: {
|
|
8090
|
+
required: true,
|
|
8091
|
+
content: {
|
|
8092
|
+
"application/json": {
|
|
8093
|
+
schema: { $ref: `#/components/schemas/${prefix}UpdateInput` }
|
|
8094
|
+
}
|
|
8095
|
+
}
|
|
8096
|
+
},
|
|
8097
|
+
responses: {
|
|
8098
|
+
"200": {
|
|
8099
|
+
description: "OK",
|
|
8100
|
+
content: {
|
|
8101
|
+
"application/json": {
|
|
8102
|
+
schema: { $ref: `#/components/schemas/${prefix}Response` }
|
|
8103
|
+
}
|
|
8104
|
+
}
|
|
8105
|
+
},
|
|
8106
|
+
...errorRefs("400", "401", "404")
|
|
8107
|
+
}
|
|
8108
|
+
};
|
|
8109
|
+
}
|
|
8110
|
+
function buildDeleteOperation(entityName, tag) {
|
|
8111
|
+
return {
|
|
8112
|
+
operationId: `${entityName}_delete`,
|
|
8113
|
+
tags: [tag],
|
|
8114
|
+
summary: `Delete a ${entityName}`,
|
|
8115
|
+
parameters: [
|
|
8116
|
+
{ name: "id", in: "path", required: true, schema: { type: "string", format: "uuid" } }
|
|
8117
|
+
],
|
|
8118
|
+
responses: {
|
|
8119
|
+
"204": { description: "No Content" },
|
|
8120
|
+
...errorRefs("401", "404")
|
|
8121
|
+
}
|
|
8122
|
+
};
|
|
8123
|
+
}
|
|
8124
|
+
function buildDisabledOperation(entityName, operation, tag) {
|
|
8125
|
+
return {
|
|
8126
|
+
operationId: `${entityName}_${operation}`,
|
|
8127
|
+
tags: [tag],
|
|
8128
|
+
summary: `${operation} is disabled for ${entityName}`,
|
|
8129
|
+
parameters: [
|
|
8130
|
+
{ name: "id", in: "path", required: true, schema: { type: "string", format: "uuid" } }
|
|
8131
|
+
],
|
|
8132
|
+
responses: {
|
|
8133
|
+
"405": {
|
|
8134
|
+
description: `Method Not Allowed — operation "${operation}" is disabled for ${entityName}`
|
|
8135
|
+
}
|
|
8136
|
+
}
|
|
8137
|
+
};
|
|
8138
|
+
}
|
|
8139
|
+
function buildQueryOperation(entityName, prefix, tag) {
|
|
8140
|
+
return {
|
|
8141
|
+
operationId: `${entityName}_query`,
|
|
8142
|
+
tags: [tag],
|
|
8143
|
+
summary: `Query ${entityName} (structured query via POST body)`,
|
|
8144
|
+
requestBody: {
|
|
8145
|
+
required: true,
|
|
8146
|
+
content: {
|
|
8147
|
+
"application/json": {
|
|
8148
|
+
schema: { $ref: `#/components/schemas/${prefix}Query` }
|
|
8149
|
+
}
|
|
8150
|
+
}
|
|
8151
|
+
},
|
|
8152
|
+
responses: {
|
|
8153
|
+
"200": {
|
|
8154
|
+
description: "OK",
|
|
8155
|
+
content: {
|
|
8156
|
+
"application/json": {
|
|
8157
|
+
schema: {
|
|
8158
|
+
type: "object",
|
|
8159
|
+
properties: {
|
|
8160
|
+
items: {
|
|
8161
|
+
type: "array",
|
|
8162
|
+
items: { $ref: `#/components/schemas/${prefix}Response` }
|
|
8163
|
+
},
|
|
8164
|
+
cursor: { type: "string" }
|
|
8165
|
+
}
|
|
8166
|
+
}
|
|
8167
|
+
}
|
|
8168
|
+
}
|
|
8169
|
+
},
|
|
8170
|
+
...errorRefs("400", "401")
|
|
8171
|
+
}
|
|
8172
|
+
};
|
|
8173
|
+
}
|
|
8174
|
+
function buildActionOperation(entityName, actionName, actionDef, tag) {
|
|
8175
|
+
const operation = {
|
|
8176
|
+
operationId: `${entityName}_${actionName}`,
|
|
8177
|
+
tags: [tag],
|
|
8178
|
+
summary: `${actionName} action on ${entityName}`,
|
|
8179
|
+
parameters: [
|
|
8180
|
+
{ name: "id", in: "path", required: true, schema: { type: "string", format: "uuid" } }
|
|
8181
|
+
],
|
|
8182
|
+
responses: {
|
|
8183
|
+
"200": {
|
|
8184
|
+
description: "OK",
|
|
8185
|
+
content: {
|
|
8186
|
+
"application/json": {
|
|
8187
|
+
schema: extractJsonSchema(actionDef.response, entityName, actionName, "response")
|
|
8188
|
+
}
|
|
8189
|
+
}
|
|
8190
|
+
},
|
|
8191
|
+
...errorRefs("400", "401", "404")
|
|
8192
|
+
}
|
|
8193
|
+
};
|
|
8194
|
+
if (actionDef.body) {
|
|
8195
|
+
operation.requestBody = {
|
|
8196
|
+
required: true,
|
|
8197
|
+
content: {
|
|
8198
|
+
"application/json": {
|
|
8199
|
+
schema: extractJsonSchema(actionDef.body, entityName, actionName, "body")
|
|
8200
|
+
}
|
|
8201
|
+
}
|
|
8202
|
+
};
|
|
8203
|
+
}
|
|
8204
|
+
return operation;
|
|
8205
|
+
}
|
|
7630
8206
|
// src/service/service.ts
|
|
7631
8207
|
import { deepFreeze as deepFreeze2 } from "@vertz/core";
|
|
7632
8208
|
var SERVICE_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
|
|
@@ -7664,8 +8240,12 @@ export {
|
|
|
7664
8240
|
google,
|
|
7665
8241
|
github,
|
|
7666
8242
|
getIncompatibleAddOns,
|
|
8243
|
+
generateOpenAPISpec,
|
|
7667
8244
|
generateEntityRoutes,
|
|
8245
|
+
entityUpdateInputSchema,
|
|
8246
|
+
entityResponseSchema,
|
|
7668
8247
|
entityErrorHandler,
|
|
8248
|
+
entityCreateInputSchema,
|
|
7669
8249
|
entity,
|
|
7670
8250
|
enforceAccess,
|
|
7671
8251
|
encodeAccessSet,
|
|
@@ -7693,6 +8273,7 @@ export {
|
|
|
7693
8273
|
computeOverage,
|
|
7694
8274
|
computeEntityAccess,
|
|
7695
8275
|
computeAccessSet,
|
|
8276
|
+
columnToJsonSchema,
|
|
7696
8277
|
checkFva,
|
|
7697
8278
|
checkAddOnCompatibility,
|
|
7698
8279
|
calculateBillingPeriod,
|