schematic-pg 0.1.14 → 0.1.16

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.
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readdirSync, statSync } from 'node:fs';
2
2
  import path from 'node:path';
3
- import { toRouteImportName } from '../api/utils/route-naming.js';
3
+ import { toRouteBasePath, toRouteImportName } from '../api/utils/route-naming.js';
4
4
  function isRouteFile(filename) {
5
5
  return (filename.endsWith('.ts') &&
6
6
  !filename.endsWith('.test.ts') &&
@@ -29,6 +29,7 @@ function scanDirectory(customRoutesDir, relativeDir, entries) {
29
29
  basePath,
30
30
  importName: toRouteImportName(basePath),
31
31
  importPath: `../src/routes/${basePath}.js`,
32
+ routeImportPath: `../../src/routes/${basePath}.js`,
32
33
  });
33
34
  }
34
35
  }
@@ -40,3 +41,18 @@ export function discoverCustomRoutes(customRoutesDir) {
40
41
  scanDirectory(customRoutesDir, '', entries);
41
42
  return entries.sort((left, right) => left.basePath.localeCompare(right.basePath));
42
43
  }
44
+ export function partitionCustomRoutes(entries, schema) {
45
+ const modelBasePaths = new Map(schema.models.map((model) => [toRouteBasePath(model.name), model.name]));
46
+ const overlays = new Map();
47
+ const standalone = [];
48
+ for (const entry of entries) {
49
+ const modelName = modelBasePaths.get(entry.basePath);
50
+ if (modelName) {
51
+ overlays.set(modelName, entry);
52
+ }
53
+ else {
54
+ standalone.push(entry);
55
+ }
56
+ }
57
+ return { overlays, standalone };
58
+ }
@@ -1,6 +1,6 @@
1
1
  import { DEFAULT_CUSTOM_ROUTES_DIR, DEFAULT_HOOKS_DIR } from '../cli/paths.js';
2
2
  import { generateAppFile } from './app-generator.js';
3
- import { discoverCustomRoutes } from './custom-route-scanner.js';
3
+ import { discoverCustomRoutes, partitionCustomRoutes } from './custom-route-scanner.js';
4
4
  import { discoverHooks } from './hook-scanner.js';
5
5
  import { generateHooksFile } from './hooks-generator.js';
6
6
  import { generateOpenApiFiles } from './openapi-generator.js';
@@ -9,17 +9,23 @@ import { generateRouteFiles } from './route-generator.js';
9
9
  import { generateValidationSchemas } from './zod-schema-generator.js';
10
10
  export function generateApiFiles(schema, options) {
11
11
  const customRoutesDir = options?.customRoutesDir ?? DEFAULT_CUSTOM_ROUTES_DIR;
12
- const appOptions = { customRoutesDir };
13
12
  const hooksDir = options?.hooksDir ?? DEFAULT_HOOKS_DIR;
14
13
  const { entries: hookEntries, modelsWithHooks } = discoverHooks(hooksDir, schema);
15
- const includeAuthPaths = discoverCustomRoutes(customRoutesDir).some((entry) => entry.basePath === 'auth');
14
+ const customEntries = discoverCustomRoutes(customRoutesDir);
15
+ const { overlays, standalone } = partitionCustomRoutes(customEntries, schema);
16
+ const includeAuthPaths = customEntries.some((entry) => entry.basePath === 'auth');
16
17
  const openapi = generateOpenApiFiles(schema, { includeAuthPaths });
18
+ const appOptions = {
19
+ customRoutesDir,
20
+ standaloneCustomRoutes: standalone,
21
+ overlays,
22
+ };
17
23
  return {
18
24
  app: generateAppFile(schema, appOptions),
19
25
  policies: generatePoliciesFile(schema),
20
26
  validation: generateValidationSchemas(schema),
21
27
  hooks: generateHooksFile(hookEntries),
22
- routes: generateRouteFiles(schema, modelsWithHooks),
28
+ routes: generateRouteFiles(schema, { modelsWithHooks, overlays }),
23
29
  openapiTs: openapi.openapiTs,
24
30
  openapiJson: openapi.openapiJson,
25
31
  };
@@ -2,6 +2,7 @@ import { toRouteBasePath } from '../api/utils/route-naming.js';
2
2
  import { fieldHasAttribute, getModelNames, getPrimaryKey, getStoredFields, } from '../sql-generator/utils/ast-helpers.js';
3
3
  import { getFilterableFields, getIncludableRelationFields, getOmittedFields, getSortableFieldNames, isStoredScalarField, } from './utils/api-fields.js';
4
4
  import { buildFilterFieldMeta, queryParamKey } from './utils/filter-operators.js';
5
+ import { hasRestOperation, isRestEnabled } from './utils/rest.js';
5
6
  const ERROR_REF = { $ref: '#/components/schemas/Error' };
6
7
  const ERROR_EXAMPLE_VALIDATION = 'Validation failed';
7
8
  const ERROR_EXAMPLE_FORBIDDEN = 'Role "USER" is not allowed to list this resource';
@@ -47,6 +48,9 @@ export class OpenApiGenerator {
47
48
  };
48
49
  const paths = {};
49
50
  for (const model of this.schema.models) {
51
+ if (!isRestEnabled(model)) {
52
+ continue;
53
+ }
50
54
  Object.assign(schemas, this.buildModelComponentSchemas(model));
51
55
  Object.assign(paths, this.buildModelPaths(model));
52
56
  }
@@ -127,22 +131,27 @@ export class OpenApiGenerator {
127
131
  nullable: Boolean(field.type.optional),
128
132
  });
129
133
  }
130
- return {
134
+ const schemas = {
131
135
  [`${model.name}Response`]: {
132
136
  type: 'object',
133
137
  properties: responseProps,
134
138
  ...(responseRequired.length > 0 ? { required: responseRequired } : {}),
135
139
  },
136
- [`${model.name}Create`]: {
140
+ };
141
+ if (hasRestOperation(model, 'create')) {
142
+ schemas[`${model.name}Create`] = {
137
143
  type: 'object',
138
144
  properties: createProps,
139
145
  ...(createRequired.length > 0 ? { required: createRequired } : {}),
140
- },
141
- [`${model.name}Update`]: {
146
+ };
147
+ }
148
+ if (hasRestOperation(model, 'update')) {
149
+ schemas[`${model.name}Update`] = {
142
150
  type: 'object',
143
151
  properties: updateProps,
144
- },
145
- };
152
+ };
153
+ }
154
+ return schemas;
146
155
  }
147
156
  buildModelPaths(model) {
148
157
  const basePath = toRouteBasePath(model.name);
@@ -157,52 +166,58 @@ export class OpenApiGenerator {
157
166
  const createRef = { $ref: `#/components/schemas/${model.name}Create` };
158
167
  const updateRef = { $ref: `#/components/schemas/${model.name}Update` };
159
168
  const tag = model.name;
160
- const paths = {
161
- [collectionPath]: {
162
- get: {
163
- tags: [tag],
164
- summary: `List ${model.name}`,
165
- operationId: `list${model.name}`,
166
- security: OPTIONAL_BEARER_SECURITY,
167
- parameters: this.buildListQueryParameters(model),
168
- responses: {
169
- '200': {
170
- description: `List of ${model.name}`,
171
- content: jsonContent({ type: 'array', items: responseRef }),
172
- },
173
- '400': errorResponse('Validation error', ERROR_EXAMPLE_VALIDATION),
174
- '401': errorResponse('Unauthorized'),
175
- '403': errorResponse('Forbidden', ERROR_EXAMPLE_FORBIDDEN),
176
- '500': errorResponse('Internal server error'),
169
+ const paths = {};
170
+ const collectionOps = {};
171
+ if (hasRestOperation(model, 'list')) {
172
+ collectionOps.get = {
173
+ tags: [tag],
174
+ summary: `List ${model.name}`,
175
+ operationId: `list${model.name}`,
176
+ security: OPTIONAL_BEARER_SECURITY,
177
+ parameters: this.buildListQueryParameters(model),
178
+ responses: {
179
+ '200': {
180
+ description: `List of ${model.name}`,
181
+ content: jsonContent({ type: 'array', items: responseRef }),
177
182
  },
183
+ '400': errorResponse('Validation error', ERROR_EXAMPLE_VALIDATION),
184
+ '401': errorResponse('Unauthorized'),
185
+ '403': errorResponse('Forbidden', ERROR_EXAMPLE_FORBIDDEN),
186
+ '500': errorResponse('Internal server error'),
178
187
  },
179
- post: {
180
- tags: [tag],
181
- summary: `Create ${model.name}`,
182
- operationId: `create${model.name}`,
183
- security: OPTIONAL_BEARER_SECURITY,
184
- requestBody: {
185
- required: true,
186
- content: jsonContent(createRef),
187
- },
188
- responses: {
189
- '201': {
190
- description: `Created ${model.name}`,
191
- content: jsonContent(responseRef),
192
- },
193
- '400': errorResponse('Validation error', ERROR_EXAMPLE_VALIDATION),
194
- '401': errorResponse('Unauthorized'),
195
- '403': errorResponse('Forbidden', ERROR_EXAMPLE_FORBIDDEN),
196
- '409': errorResponse('Conflict', ERROR_EXAMPLE_CONFLICT),
197
- '500': errorResponse('Internal server error'),
188
+ };
189
+ }
190
+ if (hasRestOperation(model, 'create')) {
191
+ collectionOps.post = {
192
+ tags: [tag],
193
+ summary: `Create ${model.name}`,
194
+ operationId: `create${model.name}`,
195
+ security: OPTIONAL_BEARER_SECURITY,
196
+ requestBody: {
197
+ required: true,
198
+ content: jsonContent(createRef),
199
+ },
200
+ responses: {
201
+ '201': {
202
+ description: `Created ${model.name}`,
203
+ content: jsonContent(responseRef),
198
204
  },
205
+ '400': errorResponse('Validation error', ERROR_EXAMPLE_VALIDATION),
206
+ '401': errorResponse('Unauthorized'),
207
+ '403': errorResponse('Forbidden', ERROR_EXAMPLE_FORBIDDEN),
208
+ '409': errorResponse('Conflict', ERROR_EXAMPLE_CONFLICT),
209
+ '500': errorResponse('Internal server error'),
199
210
  },
200
- },
201
- };
211
+ };
212
+ }
213
+ if (Object.keys(collectionOps).length > 0) {
214
+ paths[collectionPath] = collectionOps;
215
+ }
202
216
  if (pkFields.length > 0) {
203
217
  const pathParams = this.buildPathParameters(pkFields, model);
204
- paths[itemPath] = {
205
- get: {
218
+ const itemOps = {};
219
+ if (hasRestOperation(model, 'get')) {
220
+ itemOps.get = {
206
221
  tags: [tag],
207
222
  summary: `Get ${model.name}`,
208
223
  operationId: `get${model.name}`,
@@ -219,8 +234,10 @@ export class OpenApiGenerator {
219
234
  '404': errorResponse('Not found'),
220
235
  '500': errorResponse('Internal server error'),
221
236
  },
222
- },
223
- put: {
237
+ };
238
+ }
239
+ if (hasRestOperation(model, 'update')) {
240
+ itemOps.put = {
224
241
  tags: [tag],
225
242
  summary: `Update ${model.name}`,
226
243
  operationId: `update${model.name}`,
@@ -242,8 +259,10 @@ export class OpenApiGenerator {
242
259
  '409': errorResponse('Conflict', ERROR_EXAMPLE_CONFLICT),
243
260
  '500': errorResponse('Internal server error'),
244
261
  },
245
- },
246
- delete: {
262
+ };
263
+ }
264
+ if (hasRestOperation(model, 'delete')) {
265
+ itemOps.delete = {
247
266
  tags: [tag],
248
267
  summary: `Delete ${model.name}`,
249
268
  operationId: `delete${model.name}`,
@@ -260,8 +279,11 @@ export class OpenApiGenerator {
260
279
  '404': errorResponse('Not found'),
261
280
  '500': errorResponse('Internal server error'),
262
281
  },
263
- },
264
- };
282
+ };
283
+ }
284
+ if (Object.keys(itemOps).length > 0) {
285
+ paths[itemPath] = itemOps;
286
+ }
265
287
  }
266
288
  return paths;
267
289
  }
@@ -1,10 +1,17 @@
1
1
  import type { Model, Schema } from '../schema-dsl/ast.js';
2
+ import type { CustomRouteMountEntry } from './custom-route-scanner.js';
3
+ export interface RouteGeneratorOptions {
4
+ modelsWithHooks?: ReadonlySet<string>;
5
+ overlays?: ReadonlyMap<string, CustomRouteMountEntry>;
6
+ }
2
7
  export declare class RouteGenerator {
3
8
  private readonly model;
4
9
  private readonly schema;
5
10
  private readonly modelsWithHooks;
6
- constructor(model: Model, schema: Schema, modelsWithHooks?: ReadonlySet<string>);
7
- generate(): string;
11
+ private readonly overlays;
12
+ constructor(model: Model, schema: Schema, options?: RouteGeneratorOptions);
13
+ generate(): string | null;
14
+ private generateOverlayOnly;
8
15
  private jsonRow;
9
16
  private jsonRows;
10
17
  private mutationJsonRow;
@@ -16,8 +23,8 @@ export declare class RouteGenerator {
16
23
  getRouteFileName(): string;
17
24
  getRouteBasePath(): string;
18
25
  }
19
- export declare function generateRouteFiles(schema: Schema, modelsWithHooks?: ReadonlySet<string>): Map<string, string>;
20
- export declare function getRouteMountEntries(schema: Schema): {
26
+ export declare function generateRouteFiles(schema: Schema, modelsWithHooksOrOptions?: ReadonlySet<string> | RouteGeneratorOptions): Map<string, string>;
27
+ export declare function getRouteMountEntries(schema: Schema, overlays?: ReadonlyMap<string, CustomRouteMountEntry>): {
21
28
  basePath: string;
22
29
  fileName: string;
23
30
  importName: string;
@@ -4,74 +4,154 @@ import { getClientExportName } from '../db/type-generator.js';
4
4
  import { toRouteBasePath, toRouteFileName, toRouteImportName } from '../api/utils/route-naming.js';
5
5
  import { toModelConstantPrefix } from './utils/api-fields.js';
6
6
  import { hasPolicies } from './utils/policy.js';
7
+ import { isRestEnabled, normalizeRest } from './utils/rest.js';
7
8
  export class RouteGenerator {
8
9
  model;
9
10
  schema;
10
11
  modelsWithHooks;
11
- constructor(model, schema, modelsWithHooks = new Set()) {
12
+ overlays;
13
+ constructor(model, schema, options = {}) {
12
14
  this.model = model;
13
15
  this.schema = schema;
14
- this.modelsWithHooks = modelsWithHooks;
16
+ this.modelsWithHooks = options.modelsWithHooks ?? new Set();
17
+ this.overlays = options.overlays ?? new Map();
15
18
  }
16
19
  generate() {
17
- const clientKey = getClientExportName(this.model.name);
20
+ const rest = normalizeRest(this.model);
21
+ const overlay = this.overlays.get(this.model.name);
22
+ const operations = rest.operations;
23
+ if (operations.size === 0 && !overlay) {
24
+ return null;
25
+ }
26
+ if (operations.size === 0 && overlay) {
27
+ return this.generateOverlayOnly(overlay);
28
+ }
29
+ const needsPk = operations.has('get') || operations.has('update') || operations.has('delete');
18
30
  const primaryKey = getPrimaryKey(this.model);
19
- if (!primaryKey) {
31
+ if (needsPk && !primaryKey) {
20
32
  throw new Error(`Model ${this.model.name} has no primary key`);
21
33
  }
22
- const pathParams = primaryKey.fields.map((field) => `:${field}`).join('/');
23
- const whereFromParams = primaryKey.fields.map((field) => `${field}: params.${field}`).join(', ');
34
+ const clientKey = getClientExportName(this.model.name);
35
+ const pathParams = primaryKey?.fields.map((field) => `:${field}`).join('/') ?? '';
36
+ const whereFromParams = primaryKey?.fields.map((field) => `${field}: params.${field}`).join(', ') ?? '';
24
37
  const paramSchemaName = `${this.model.name}ParamSchema`;
25
38
  const listQuerySchemaName = `${this.model.name}ListQuerySchema`;
26
39
  const getQuerySchemaName = `${this.model.name}GetQuerySchema`;
27
40
  const modelHasPolicies = hasPolicies(this.model);
28
- const modelHasHooks = this.modelsWithHooks.has(this.model.name);
41
+ const hasWriteOps = operations.has('create') || operations.has('update') || operations.has('delete');
42
+ const modelHasHooks = hasWriteOps && this.modelsWithHooks.has(this.model.name);
29
43
  const constantPrefix = toModelConstantPrefix(this.model.name);
44
+ const needsValidateJson = operations.has('create') || operations.has('update');
45
+ const needsValidateParam = operations.has('get') || operations.has('update') || operations.has('delete');
46
+ const needsValidateQuery = operations.has('list') || operations.has('get');
47
+ const needsNotFound = operations.has('get');
48
+ const needsBuildReadQuery = operations.has('list');
49
+ const needsParseInclude = operations.has('get');
50
+ const needsOmitFields = hasWriteOps;
51
+ const needsShapeResponse = operations.has('list') || operations.has('get');
52
+ const validateImports = [];
53
+ if (needsValidateJson)
54
+ validateImports.push('validateJson');
55
+ if (needsValidateParam)
56
+ validateImports.push('validateParam');
57
+ if (needsValidateQuery)
58
+ validateImports.push('validateQuery');
59
+ const schemaImports = [];
60
+ if (operations.has('create'))
61
+ schemaImports.push(` ${this.model.name}CreateSchema,`);
62
+ if (operations.has('update'))
63
+ schemaImports.push(` ${this.model.name}UpdateSchema,`);
64
+ if (needsValidateParam)
65
+ schemaImports.push(` ${paramSchemaName},`);
66
+ if (operations.has('list'))
67
+ schemaImports.push(` ${listQuerySchemaName},`);
68
+ if (operations.has('get'))
69
+ schemaImports.push(` ${getQuerySchemaName},`);
70
+ if (operations.has('list')) {
71
+ schemaImports.push(` ${constantPrefix}_LIST_QUERY_FIELDS,`);
72
+ schemaImports.push(` ${constantPrefix}_SORTABLE_FIELDS,`);
73
+ }
74
+ if (operations.has('list') || operations.has('get')) {
75
+ schemaImports.push(` ${constantPrefix}_INCLUDABLE_RELATIONS,`);
76
+ }
77
+ if (needsOmitFields)
78
+ schemaImports.push(` ${constantPrefix}_OMIT_FIELDS,`);
79
+ if (needsShapeResponse) {
80
+ schemaImports.push(` API_OMIT_FIELDS_BY_MODEL,`);
81
+ schemaImports.push(` API_RELATION_TARGETS,`);
82
+ }
83
+ const handlerBlocks = [];
84
+ if (operations.has('list')) {
85
+ handlerBlocks.push(this.generateListRoute(clientKey, modelHasPolicies, listQuerySchemaName, constantPrefix));
86
+ }
87
+ if (operations.has('get')) {
88
+ handlerBlocks.push(this.generateGetRoute(clientKey, pathParams, paramSchemaName, getQuerySchemaName, whereFromParams, modelHasPolicies, constantPrefix));
89
+ }
90
+ if (operations.has('create')) {
91
+ handlerBlocks.push(this.generateCreateRoute(clientKey, modelHasPolicies, modelHasHooks, constantPrefix));
92
+ }
93
+ if (operations.has('update')) {
94
+ handlerBlocks.push(this.generateUpdateRoute(clientKey, pathParams, paramSchemaName, whereFromParams, modelHasPolicies, modelHasHooks, constantPrefix));
95
+ }
96
+ if (operations.has('delete')) {
97
+ handlerBlocks.push(this.generateDeleteRoute(clientKey, pathParams, paramSchemaName, whereFromParams, modelHasPolicies, modelHasHooks, constantPrefix));
98
+ }
99
+ const lines = [
100
+ '// Auto-generated by RouteGenerator. Do not edit manually.',
101
+ "import { Hono } from 'hono';",
102
+ `import type { AppEnv } from '${PACKAGE_NAME}/api/types';`,
103
+ ];
104
+ if (validateImports.length > 0) {
105
+ lines.push(`import { ${validateImports.join(', ')} } from '${PACKAGE_NAME}/api/middleware/validate';`);
106
+ }
107
+ if (needsNotFound) {
108
+ lines.push(`import { notFoundResponse } from '${PACKAGE_NAME}/api/middleware/errors';`);
109
+ }
110
+ if (needsBuildReadQuery) {
111
+ lines.push(`import { buildReadQuery } from '${PACKAGE_NAME}/api/utils/read-query';`);
112
+ }
113
+ if (needsParseInclude) {
114
+ lines.push(`import { parseIncludeQuery } from '${PACKAGE_NAME}/api/utils/include-query';`);
115
+ }
116
+ if (needsOmitFields) {
117
+ lines.push(`import { omitFields } from '${PACKAGE_NAME}/api/utils/omit-fields';`);
118
+ }
119
+ if (needsShapeResponse) {
120
+ lines.push(`import { shapeResponse, shapeResponseMany } from '${PACKAGE_NAME}/api/utils/response-shape';`);
121
+ }
122
+ if (modelHasPolicies) {
123
+ lines.push(`import { assertPolicy, mergeWhere, resolvePolicyWhere } from '${PACKAGE_NAME}/api/auth/policy';`);
124
+ }
125
+ if (modelHasHooks) {
126
+ lines.push(`import { cancelledResponse, createHookContext, runAfterHooks, runBeforeHooks } from '${PACKAGE_NAME}/api/hooks';`);
127
+ }
128
+ if (schemaImports.length > 0) {
129
+ lines.push('import {');
130
+ lines.push(...schemaImports);
131
+ lines.push(`} from '../schemas/validation.js';`);
132
+ }
133
+ if (overlay) {
134
+ lines.push(`import ${overlay.importName}Overlay from '${overlay.routeImportPath}';`);
135
+ }
136
+ lines.push('', 'const router = new Hono<AppEnv>();', '');
137
+ for (const block of handlerBlocks) {
138
+ lines.push(...block, '');
139
+ }
140
+ if (overlay) {
141
+ lines.push(`router.route('/', ${overlay.importName}Overlay);`, '');
142
+ }
143
+ lines.push('export default router;', '');
144
+ return lines.join('\n');
145
+ }
146
+ generateOverlayOnly(overlay) {
30
147
  return [
31
148
  '// Auto-generated by RouteGenerator. Do not edit manually.',
32
149
  "import { Hono } from 'hono';",
33
150
  `import type { AppEnv } from '${PACKAGE_NAME}/api/types';`,
34
- `import { validateJson, validateParam, validateQuery } from '${PACKAGE_NAME}/api/middleware/validate';`,
35
- `import { notFoundResponse } from '${PACKAGE_NAME}/api/middleware/errors';`,
36
- `import { buildReadQuery } from '${PACKAGE_NAME}/api/utils/read-query';`,
37
- `import { parseIncludeQuery } from '${PACKAGE_NAME}/api/utils/include-query';`,
38
- `import { omitFields } from '${PACKAGE_NAME}/api/utils/omit-fields';`,
39
- `import { shapeResponse, shapeResponseMany } from '${PACKAGE_NAME}/api/utils/response-shape';`,
40
- ...(modelHasPolicies
41
- ? [
42
- `import { assertPolicy, mergeWhere, resolvePolicyWhere } from '${PACKAGE_NAME}/api/auth/policy';`,
43
- ]
44
- : []),
45
- ...(modelHasHooks
46
- ? [
47
- `import { cancelledResponse, createHookContext, runAfterHooks, runBeforeHooks } from '${PACKAGE_NAME}/api/hooks';`,
48
- ]
49
- : []),
50
- `import {`,
51
- ` ${this.model.name}CreateSchema,`,
52
- ` ${this.model.name}UpdateSchema,`,
53
- ` ${paramSchemaName},`,
54
- ` ${listQuerySchemaName},`,
55
- ` ${getQuerySchemaName},`,
56
- ` ${constantPrefix}_LIST_QUERY_FIELDS,`,
57
- ` ${constantPrefix}_INCLUDABLE_RELATIONS,`,
58
- ` ${constantPrefix}_OMIT_FIELDS,`,
59
- ` ${constantPrefix}_SORTABLE_FIELDS,`,
60
- ` API_OMIT_FIELDS_BY_MODEL,`,
61
- ` API_RELATION_TARGETS,`,
62
- `} from '../schemas/validation.js';`,
151
+ `import ${overlay.importName}Overlay from '${overlay.routeImportPath}';`,
63
152
  '',
64
153
  'const router = new Hono<AppEnv>();',
65
- '',
66
- ...this.generateListRoute(clientKey, modelHasPolicies, listQuerySchemaName, constantPrefix),
67
- '',
68
- ...this.generateGetRoute(clientKey, pathParams, paramSchemaName, getQuerySchemaName, whereFromParams, modelHasPolicies, constantPrefix),
69
- '',
70
- ...this.generateCreateRoute(clientKey, modelHasPolicies, modelHasHooks, constantPrefix),
71
- '',
72
- ...this.generateUpdateRoute(clientKey, pathParams, paramSchemaName, whereFromParams, modelHasPolicies, modelHasHooks, constantPrefix),
73
- '',
74
- ...this.generateDeleteRoute(clientKey, pathParams, paramSchemaName, whereFromParams, modelHasPolicies, modelHasHooks, constantPrefix),
154
+ `router.route('/', ${overlay.importName}Overlay);`,
75
155
  '',
76
156
  'export default router;',
77
157
  '',
@@ -264,16 +344,36 @@ export class RouteGenerator {
264
344
  return toRouteBasePath(this.model.name);
265
345
  }
266
346
  }
267
- export function generateRouteFiles(schema, modelsWithHooks = new Set()) {
347
+ export function generateRouteFiles(schema, modelsWithHooksOrOptions = new Set()) {
348
+ const options = normalizeRouteGeneratorOptions(modelsWithHooksOrOptions);
268
349
  const files = new Map();
269
350
  for (const model of schema.models) {
270
- const generator = new RouteGenerator(model, schema, modelsWithHooks);
271
- files.set(generator.getRouteFileName(), generator.generate());
351
+ const generator = new RouteGenerator(model, schema, options);
352
+ const content = generator.generate();
353
+ if (content !== null) {
354
+ files.set(generator.getRouteFileName(), content);
355
+ }
272
356
  }
273
357
  return files;
274
358
  }
275
- export function getRouteMountEntries(schema) {
276
- return schema.models.map((model) => {
359
+ function normalizeRouteGeneratorOptions(value) {
360
+ if (value instanceof Set) {
361
+ return { modelsWithHooks: value };
362
+ }
363
+ if (value &&
364
+ typeof value === 'object' &&
365
+ 'has' in value &&
366
+ typeof value.has === 'function' &&
367
+ !('overlays' in value) &&
368
+ !('modelsWithHooks' in value)) {
369
+ return { modelsWithHooks: value };
370
+ }
371
+ return value;
372
+ }
373
+ export function getRouteMountEntries(schema, overlays = new Map()) {
374
+ return schema.models
375
+ .filter((model) => isRestEnabled(model) || overlays.has(model.name))
376
+ .map((model) => {
277
377
  const basePath = toRouteBasePath(model.name);
278
378
  const fileName = toRouteFileName(model.name);
279
379
  const importName = toRouteImportName(basePath);
@@ -0,0 +1,9 @@
1
+ import type { Model } from '../../schema-dsl/ast.js';
2
+ export type RestOperation = 'list' | 'get' | 'create' | 'update' | 'delete';
3
+ export declare const REST_OPERATIONS: RestOperation[];
4
+ export interface NormalizedRest {
5
+ operations: Set<RestOperation>;
6
+ }
7
+ export declare function normalizeRest(model: Model): NormalizedRest;
8
+ export declare function isRestEnabled(model: Model): boolean;
9
+ export declare function hasRestOperation(model: Model, operation: RestOperation): boolean;
@@ -0,0 +1,93 @@
1
+ import { assertKeyValueArgs, getOptionalKvPair } from '../../sql-generator/utils/ast-helpers.js';
2
+ export const REST_OPERATIONS = ['list', 'get', 'create', 'update', 'delete'];
3
+ const HTTP_VERB_HINTS = {
4
+ GET: 'list or get',
5
+ POST: 'create',
6
+ PUT: 'update',
7
+ PATCH: 'update',
8
+ DELETE: 'delete',
9
+ };
10
+ export function normalizeRest(model) {
11
+ const fieldRest = model.fields.find((field) => field.attributes.some((attribute) => attribute.name === 'rest'));
12
+ if (fieldRest) {
13
+ throw new Error(`@rest on model ${model.name} must be a model attribute, not a field attribute on "${fieldRest.name}"`);
14
+ }
15
+ const restAttributes = model.attributes.filter((attribute) => attribute.name === 'rest');
16
+ if (restAttributes.length === 0) {
17
+ return { operations: new Set(REST_OPERATIONS) };
18
+ }
19
+ if (restAttributes.length > 1) {
20
+ throw new Error(`Model ${model.name} has duplicate @rest attributes`);
21
+ }
22
+ return normalizeRestAttribute(restAttributes[0], model.name);
23
+ }
24
+ export function isRestEnabled(model) {
25
+ return normalizeRest(model).operations.size > 0;
26
+ }
27
+ export function hasRestOperation(model, operation) {
28
+ return normalizeRest(model).operations.has(operation);
29
+ }
30
+ function normalizeRestAttribute(attribute, modelName) {
31
+ if (!attribute.args) {
32
+ return { operations: new Set() };
33
+ }
34
+ if (attribute.args.kind === 'ExpressionArgs') {
35
+ if (attribute.args.expressions.length === 0) {
36
+ throw new Error(`@rest() on model ${modelName} is empty; use @rest, @rest(false), only, or except`);
37
+ }
38
+ if (attribute.args.expressions.length !== 1) {
39
+ throw new Error(`@rest on model ${modelName} expects a single boolean or key-value args`);
40
+ }
41
+ const expression = attribute.args.expressions[0];
42
+ if (expression.kind === 'BooleanLiteral') {
43
+ if (expression.value === false) {
44
+ return { operations: new Set() };
45
+ }
46
+ return { operations: new Set(REST_OPERATIONS) };
47
+ }
48
+ throw new Error(`@rest on model ${modelName} expects false, only, or except`);
49
+ }
50
+ const args = assertKeyValueArgs(attribute.args);
51
+ const onlyPair = getOptionalKvPair(args, 'only');
52
+ const exceptPair = getOptionalKvPair(args, 'except');
53
+ if (onlyPair && exceptPair) {
54
+ throw new Error(`@rest on model ${modelName} cannot mix only and except`);
55
+ }
56
+ if (!onlyPair && !exceptPair) {
57
+ throw new Error(`@rest on model ${modelName} requires only, except, or false`);
58
+ }
59
+ if (onlyPair) {
60
+ return { operations: new Set(parseRestOperations(onlyPair.value, modelName, 'only')) };
61
+ }
62
+ const excluded = new Set(parseRestOperations(exceptPair.value, modelName, 'except'));
63
+ return {
64
+ operations: new Set(REST_OPERATIONS.filter((operation) => !excluded.has(operation))),
65
+ };
66
+ }
67
+ function parseRestOperations(value, modelName, fieldName) {
68
+ if (value.kind !== 'ArrayLiteral') {
69
+ throw new Error(`@rest ${fieldName} on model ${modelName} must be an array of operations`);
70
+ }
71
+ return value.elements.map((element) => parseRestOperation(element, modelName));
72
+ }
73
+ function parseRestOperation(value, modelName) {
74
+ if (value.kind !== 'Identifier') {
75
+ throw new Error(`@rest operation on model ${modelName} must be an identifier`);
76
+ }
77
+ const raw = value.name;
78
+ const httpHint = HTTP_VERB_HINTS[raw.toUpperCase()];
79
+ if (httpHint && raw === raw.toUpperCase()) {
80
+ throw new Error(`Unknown @rest operation "${raw}" on model ${modelName}; use ${httpHint} instead of HTTP verb "${raw}"`);
81
+ }
82
+ const operation = raw.toLowerCase();
83
+ if (isRestOperation(operation)) {
84
+ return operation;
85
+ }
86
+ if (httpHint) {
87
+ throw new Error(`Unknown @rest operation "${raw}" on model ${modelName}; use ${httpHint} instead of HTTP verb "${raw}"`);
88
+ }
89
+ throw new Error(`Unknown @rest operation "${raw}" on model ${modelName}; expected one of ${REST_OPERATIONS.join(', ')}`);
90
+ }
91
+ function isRestOperation(value) {
92
+ return REST_OPERATIONS.includes(value);
93
+ }
@@ -1,4 +1,5 @@
1
1
  export declare function generateSql(schemaPath?: string): Promise<string>;
2
2
  export declare function generateClient(schemaPath?: string): Promise<void>;
3
3
  export declare function generateApi(schemaPath?: string): Promise<void>;
4
+ export declare function syncGeneratedRouteFiles(routesDir: string, routes: Map<string, string>): Promise<void>;
4
5
  export declare function generateAll(schemaPath?: string): Promise<void>;