schematic-pg 0.1.15 → 0.1.17
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 +4 -1
- package/dist/api/middleware/cors.d.ts +6 -0
- package/dist/api/middleware/cors.js +35 -0
- package/dist/api-generator/app-generator.d.ts +5 -0
- package/dist/api-generator/app-generator.js +16 -6
- package/dist/api-generator/custom-route-scanner.d.ts +9 -0
- package/dist/api-generator/custom-route-scanner.js +17 -1
- package/dist/api-generator/index.js +10 -4
- package/dist/api-generator/openapi-generator.js +74 -52
- package/dist/api-generator/route-generator.d.ts +11 -4
- package/dist/api-generator/route-generator.js +151 -51
- package/dist/api-generator/utils/rest.d.ts +9 -0
- package/dist/api-generator/utils/rest.js +93 -0
- package/dist/cli/generate.d.ts +1 -0
- package/dist/cli/generate.js +10 -3
- package/dist/cli/templates/agents.md +8 -3
- package/dist/cli/templates.d.ts +2 -2
- package/dist/cli/templates.js +3 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -425,7 +425,7 @@ The `init` command creates everything you need to get running:
|
|
|
425
425
|
|------------------|---------|
|
|
426
426
|
| `AGENTS.md` | Agent-oriented guide for working with schematic-pg in this project |
|
|
427
427
|
| `app.schema` | Starter schema (one `User` model) — edit this |
|
|
428
|
-
| `.env` | `DATABASE_URL`, JWT settings |
|
|
428
|
+
| `.env` | `DATABASE_URL`, JWT settings, `CORS_ORIGIN` |
|
|
429
429
|
| `docker-compose.yml` | Local PostgreSQL on `:5432` |
|
|
430
430
|
| `Makefile` | `make dev` — docker compose (with health wait) + `schematic-pg dev` |
|
|
431
431
|
| `tsconfig.json` | TypeScript config for `generated/` and `src/routes/` |
|
|
@@ -456,9 +456,12 @@ Generated code imports the runtime from the `schematic-pg` package (`schematic-p
|
|
|
456
456
|
| `AUTH_ACCESS_TOKEN_TTL` | `1h` | Access token lifetime (`15m`, `1h`, or seconds) |
|
|
457
457
|
| `JWT_ROLE_CLAIM` | `role` | JWT claim mapped to `auth.role` |
|
|
458
458
|
| `JWT_USER_ID_CLAIM` | `sub` | JWT claim mapped to `auth.user.id` |
|
|
459
|
+
| `CORS_ORIGIN` | — (disabled) | Allowed browser origins. Unset disables CORS. Use `*` for any origin, or a comma-separated list (`http://localhost:5173,https://app.example.com`) |
|
|
459
460
|
|
|
460
461
|
Set these in `.env` before running `dev`, `start`, or `db:bootstrap`.
|
|
461
462
|
|
|
463
|
+
Browser frontends on another origin need `CORS_ORIGIN`. The generated app reads it at runtime (no regenerate). Preflight `OPTIONS` is handled automatically; `Authorization` and `Content-Type` are allowed. See [CORS](docs/rest-api.md#cors).
|
|
464
|
+
|
|
462
465
|
---
|
|
463
466
|
|
|
464
467
|
## Authentication
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { MiddlewareHandler } from 'hono';
|
|
2
|
+
import type { AppEnv } from '../types.js';
|
|
3
|
+
export declare function parseCorsOrigin(raw?: string | undefined): string | string[] | null;
|
|
4
|
+
export declare function createCorsMiddleware(options?: {
|
|
5
|
+
origin?: string | string[] | null;
|
|
6
|
+
}): MiddlewareHandler<AppEnv>;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { cors } from 'hono/cors';
|
|
2
|
+
const CORS_ALLOW_HEADERS = ['Authorization', 'Content-Type'];
|
|
3
|
+
const CORS_ALLOW_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'];
|
|
4
|
+
const CORS_MAX_AGE_SECONDS = 86400;
|
|
5
|
+
export function parseCorsOrigin(raw = process.env.CORS_ORIGIN) {
|
|
6
|
+
const trimmed = raw?.trim() ?? '';
|
|
7
|
+
if (!trimmed) {
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
if (trimmed === '*') {
|
|
11
|
+
return '*';
|
|
12
|
+
}
|
|
13
|
+
const origins = trimmed
|
|
14
|
+
.split(',')
|
|
15
|
+
.map((origin) => origin.trim())
|
|
16
|
+
.filter((origin) => origin.length > 0);
|
|
17
|
+
if (origins.length === 0) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
return origins.length === 1 ? origins[0] : origins;
|
|
21
|
+
}
|
|
22
|
+
export function createCorsMiddleware(options = {}) {
|
|
23
|
+
const origin = options.origin !== undefined ? options.origin : parseCorsOrigin();
|
|
24
|
+
if (origin == null) {
|
|
25
|
+
return async (_c, next) => {
|
|
26
|
+
await next();
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
return cors({
|
|
30
|
+
origin,
|
|
31
|
+
allowHeaders: CORS_ALLOW_HEADERS,
|
|
32
|
+
allowMethods: CORS_ALLOW_METHODS,
|
|
33
|
+
maxAge: CORS_MAX_AGE_SECONDS,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import type { Schema } from '../schema-dsl/ast.js';
|
|
2
|
+
import { type CustomRouteMountEntry } from './custom-route-scanner.js';
|
|
2
3
|
export interface AppGeneratorOptions {
|
|
3
4
|
customRoutesDir?: string;
|
|
5
|
+
/** When provided, skip re-discovering and use these standalone mounts only. */
|
|
6
|
+
standaloneCustomRoutes?: CustomRouteMountEntry[];
|
|
7
|
+
overlays?: ReadonlyMap<string, CustomRouteMountEntry>;
|
|
4
8
|
}
|
|
5
9
|
export declare class AppGenerator {
|
|
6
10
|
private readonly schema;
|
|
7
11
|
private readonly options;
|
|
8
12
|
constructor(schema: Schema, options?: AppGeneratorOptions);
|
|
9
13
|
generate(): string;
|
|
14
|
+
private resolveStandaloneCustomRoutes;
|
|
10
15
|
}
|
|
11
16
|
export declare function generateAppFile(schema: Schema, options?: AppGeneratorOptions): string;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { PACKAGE_NAME } from '../constants.js';
|
|
3
|
-
import { discoverCustomRoutes } from './custom-route-scanner.js';
|
|
3
|
+
import { discoverCustomRoutes, partitionCustomRoutes, } from './custom-route-scanner.js';
|
|
4
4
|
import { getRouteMountEntries } from './route-generator.js';
|
|
5
5
|
const DEFAULT_CUSTOM_ROUTES_DIR = path.resolve('src/routes');
|
|
6
6
|
export class AppGenerator {
|
|
@@ -11,19 +11,19 @@ export class AppGenerator {
|
|
|
11
11
|
this.options = options;
|
|
12
12
|
}
|
|
13
13
|
generate() {
|
|
14
|
-
const
|
|
14
|
+
const overlays = this.options.overlays ?? new Map();
|
|
15
|
+
const mounts = getRouteMountEntries(this.schema, overlays);
|
|
15
16
|
const generatedImports = mounts
|
|
16
17
|
.map((entry) => `import ${entry.importName} from './routes/${entry.fileName.replace(/\.ts$/, '.js')}';`)
|
|
17
18
|
.join('\n');
|
|
18
19
|
const generatedRoutes = mounts
|
|
19
20
|
.map((entry) => ` app.route('/${entry.basePath}', ${entry.importName});`)
|
|
20
21
|
.join('\n');
|
|
21
|
-
const
|
|
22
|
-
const
|
|
23
|
-
const customImports = customMounts
|
|
22
|
+
const standaloneMounts = this.resolveStandaloneCustomRoutes();
|
|
23
|
+
const customImports = standaloneMounts
|
|
24
24
|
.map((entry) => `import ${entry.importName} from '${entry.importPath}';`)
|
|
25
25
|
.join('\n');
|
|
26
|
-
const customRoutes =
|
|
26
|
+
const customRoutes = standaloneMounts
|
|
27
27
|
.map((entry) => ` app.route('/${entry.basePath}', ${entry.importName});`)
|
|
28
28
|
.join('\n');
|
|
29
29
|
const routeImports = [generatedImports, customImports].filter(Boolean).join('\n');
|
|
@@ -46,6 +46,7 @@ export class AppGenerator {
|
|
|
46
46
|
`import { createAuthMiddleware } from '${PACKAGE_NAME}/api/auth/middleware';`,
|
|
47
47
|
`import { createJwtResolver } from '${PACKAGE_NAME}/api/auth/jwt-resolver';`,
|
|
48
48
|
`import type { AuthResolver } from '${PACKAGE_NAME}/api/auth/types';`,
|
|
49
|
+
`import { createCorsMiddleware } from '${PACKAGE_NAME}/api/middleware/cors';`,
|
|
49
50
|
`import { createDbMiddleware } from '${PACKAGE_NAME}/api/middleware/db';`,
|
|
50
51
|
`import { handleError } from '${PACKAGE_NAME}/api/middleware/errors';`,
|
|
51
52
|
`import { mountApiDocs } from '${PACKAGE_NAME}/api/openapi';`,
|
|
@@ -61,6 +62,7 @@ export class AppGenerator {
|
|
|
61
62
|
'',
|
|
62
63
|
'export function createApp(options: CreateAppOptions = {}): Hono<AppEnv> {',
|
|
63
64
|
' const app = new Hono<AppEnv>();',
|
|
65
|
+
' app.use(createCorsMiddleware());',
|
|
64
66
|
' mountApiDocs(app, openApiDocument);',
|
|
65
67
|
' app.use(logger());',
|
|
66
68
|
' app.use(prettyJSON());',
|
|
@@ -85,6 +87,14 @@ export class AppGenerator {
|
|
|
85
87
|
'',
|
|
86
88
|
].join('\n');
|
|
87
89
|
}
|
|
90
|
+
resolveStandaloneCustomRoutes() {
|
|
91
|
+
if (this.options.standaloneCustomRoutes) {
|
|
92
|
+
return this.options.standaloneCustomRoutes;
|
|
93
|
+
}
|
|
94
|
+
const customRoutesDir = this.options.customRoutesDir ?? DEFAULT_CUSTOM_ROUTES_DIR;
|
|
95
|
+
const { standalone } = partitionCustomRoutes(discoverCustomRoutes(customRoutesDir), this.schema);
|
|
96
|
+
return standalone;
|
|
97
|
+
}
|
|
88
98
|
}
|
|
89
99
|
export function generateAppFile(schema, options) {
|
|
90
100
|
return new AppGenerator(schema, options).generate();
|
|
@@ -1,6 +1,15 @@
|
|
|
1
|
+
import type { Schema } from '../schema-dsl/ast.js';
|
|
1
2
|
export interface CustomRouteMountEntry {
|
|
2
3
|
basePath: string;
|
|
3
4
|
importName: string;
|
|
5
|
+
/** Import path relative to generated/app.ts */
|
|
4
6
|
importPath: string;
|
|
7
|
+
/** Import path relative to generated/routes/*.ts */
|
|
8
|
+
routeImportPath: string;
|
|
9
|
+
}
|
|
10
|
+
export interface PartitionedCustomRoutes {
|
|
11
|
+
overlays: Map<string, CustomRouteMountEntry>;
|
|
12
|
+
standalone: CustomRouteMountEntry[];
|
|
5
13
|
}
|
|
6
14
|
export declare function discoverCustomRoutes(customRoutesDir: string): CustomRouteMountEntry[];
|
|
15
|
+
export declare function partitionCustomRoutes(entries: CustomRouteMountEntry[], schema: Schema): PartitionedCustomRoutes;
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
205
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
7
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
23
|
-
const
|
|
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
|
|
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 {
|
|
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,
|
|
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,
|
|
271
|
-
|
|
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
|
-
|
|
276
|
-
|
|
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
|
+
}
|
package/dist/cli/generate.d.ts
CHANGED
|
@@ -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>;
|
package/dist/cli/generate.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
1
|
+
import { mkdir, readdir, readFile, unlink, writeFile } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { parse } from '../schema-dsl/index.js';
|
|
4
4
|
import { generateApiFiles } from '../api-generator/index.js';
|
|
@@ -38,10 +38,17 @@ export async function generateApi(schemaPath) {
|
|
|
38
38
|
await writeFile(path.join(schemasDir, 'validation.ts'), files.validation, 'utf8');
|
|
39
39
|
await writeFile(path.join(outputDir, 'openapi.ts'), files.openapiTs, 'utf8');
|
|
40
40
|
await writeFile(path.join(outputDir, 'openapi.json'), files.openapiJson, 'utf8');
|
|
41
|
-
|
|
41
|
+
await syncGeneratedRouteFiles(routesDir, files.routes);
|
|
42
|
+
console.log(`Generated API files in ${outputDir}`);
|
|
43
|
+
}
|
|
44
|
+
export async function syncGeneratedRouteFiles(routesDir, routes) {
|
|
45
|
+
for (const [fileName, content] of routes) {
|
|
42
46
|
await writeFile(path.join(routesDir, fileName), content, 'utf8');
|
|
43
47
|
}
|
|
44
|
-
|
|
48
|
+
const existing = await readdir(routesDir);
|
|
49
|
+
await Promise.all(existing
|
|
50
|
+
.filter((fileName) => fileName.endsWith('.ts') && !routes.has(fileName))
|
|
51
|
+
.map((fileName) => unlink(path.join(routesDir, fileName))));
|
|
45
52
|
}
|
|
46
53
|
export async function generateAll(schemaPath) {
|
|
47
54
|
const resolvedSchemaPath = resolveSchemaPath(schemaPath);
|
|
@@ -25,7 +25,7 @@ my-app/
|
|
|
25
25
|
├── app.schema # Source of truth — edit this
|
|
26
26
|
├── schema.sql # Generated PostgreSQL DDL (read-only)
|
|
27
27
|
├── AGENTS.md # This file
|
|
28
|
-
├── .env # DATABASE_URL, JWT_
|
|
28
|
+
├── .env # DATABASE_URL, JWT_*, CORS_ORIGIN
|
|
29
29
|
├── docker-compose.yml # Local PostgreSQL
|
|
30
30
|
├── generated/ # Generated — do not edit
|
|
31
31
|
│ ├── db.ts # createDbClient(pool)
|
|
@@ -59,6 +59,7 @@ npx schematic-pg dev
|
|
|
59
59
|
| `JWT_SECRET` | HMAC secret for Bearer JWT auth |
|
|
60
60
|
| `JWT_ROLE_CLAIM` | JWT claim for role (default `role`) |
|
|
61
61
|
| `JWT_USER_ID_CLAIM` | JWT claim for user id (default `sub`) |
|
|
62
|
+
| `CORS_ORIGIN` | Allowed browser origins (`*` or comma-separated). Unset disables CORS |
|
|
62
63
|
|
|
63
64
|
## Schema DSL Essentials
|
|
64
65
|
|
|
@@ -77,7 +78,8 @@ models {
|
|
|
77
78
|
|
|
78
79
|
orders: Order[]
|
|
79
80
|
|
|
80
|
-
@
|
|
81
|
+
@rest(except: [create, update, delete])
|
|
82
|
+
@policy(role: USER, allow: [select], where: "id = {{auth.user.id}}")
|
|
81
83
|
@policy(role: ADMIN, allow: all)
|
|
82
84
|
|
|
83
85
|
@@index(fields: [role])
|
|
@@ -93,6 +95,7 @@ models {
|
|
|
93
95
|
**Key concepts:**
|
|
94
96
|
|
|
95
97
|
- **Relations:** Put `@relation(fields: [...], references: [...])` on the FK-owning side. The inverse side is inferred.
|
|
98
|
+
- **REST surface:** `@rest(only: [...])`, `@rest(except: [...])`, or `@rest(false)` controls which CRUD handlers are generated (`list`/`get`/`create`/`update`/`delete`).
|
|
96
99
|
- **Policies:** `@policy(role: ..., allow: [select|insert|update|delete|all], where: "...")` — `where` supports `{{auth.user.id}}`.
|
|
97
100
|
- **Validation:** `@regex(...)`, `@range(min: ..., max: ...)` flow into generated Zod schemas.
|
|
98
101
|
- **Indexes / triggers:** `@@index(...)`, `@@trigger { timing, event, level, execute: """...""" }`.
|
|
@@ -225,8 +228,9 @@ Also available: `ForeignKeyConstraintError`, `DatabaseError`.
|
|
|
225
228
|
| Location | `src/routes/**/*.ts` |
|
|
226
229
|
| Export | `export default router` (`Hono<AppEnv>`) |
|
|
227
230
|
| Mount path | File path relative to `src/routes/` |
|
|
231
|
+
| Same-path overlay | `src/routes/users.ts` merges into `generated/routes/users.ts` |
|
|
228
232
|
|
|
229
|
-
`src/routes/health.ts` → `GET /health
|
|
233
|
+
`src/routes/health.ts` → `GET /health` (standalone). When the file name matches a model route (`users.ts`), it is imported into the generated model router after remaining `@rest` handlers. Regenerate after adding files.
|
|
230
234
|
|
|
231
235
|
```typescript
|
|
232
236
|
import { Hono } from 'hono';
|
|
@@ -276,6 +280,7 @@ schematic-pg hooks:add [--model X] # scaffold src/hooks/{Model}.ts
|
|
|
276
280
|
- Unauthenticated requests default to `{ role: 'PUBLIC' }`.
|
|
277
281
|
- `@policy` `allow` maps to HTTP: GET→select, POST→insert, PUT→update, DELETE→delete.
|
|
278
282
|
- Row-level `where` is injected on read/update/delete; POST checks permission only.
|
|
283
|
+
- `@rest` controls which handlers exist; disabled methods return `404` and are omitted from OpenAPI.
|
|
279
284
|
|
|
280
285
|
## Generated Outputs (Read-Only)
|
|
281
286
|
|
package/dist/cli/templates.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export declare const AGENTS_TEMPLATE: string;
|
|
2
|
-
export declare const APP_SCHEMA_TEMPLATE = "extensions {\n\n}\n\nenums {\n UserRole { ADMIN, USER }\n}\n\nmodels {\n model User {\n id: UUID @id @default(gen_random_uuid())\n email: VARCHAR(255) @unique @regex(pattern: \"^[\\w.-]+@[\\w.-]+\\.\\w+$\", message: \"Invalid email address\")\n name: VARCHAR(150)?\n role: UserRole @default(USER)\n passwordHash: VARCHAR(255)? @omit @unfilterable\n createdAt: TIMESTAMP @default(now())\n\n @policy(role: USER, allow: [select
|
|
3
|
-
export declare const ENV_TEMPLATE = "DATABASE_URL=postgresql://postgrest:postgrest@localhost:5432/postgrest\nJWT_SECRET=\nAUTH_PEPPER=\nAUTH_ACCESS_TOKEN_TTL=1h\nJWT_ROLE_CLAIM=role\nJWT_USER_ID_CLAIM=sub\n";
|
|
2
|
+
export declare const APP_SCHEMA_TEMPLATE = "extensions {\n\n}\n\nenums {\n UserRole { ADMIN, USER }\n}\n\nmodels {\n model User {\n id: UUID @id @default(gen_random_uuid())\n email: VARCHAR(255) @unique @regex(pattern: \"^[\\w.-]+@[\\w.-]+\\.\\w+$\", message: \"Invalid email address\")\n name: VARCHAR(150)?\n role: UserRole @default(USER)\n passwordHash: VARCHAR(255)? @omit @unfilterable\n createdAt: TIMESTAMP @default(now())\n\n @rest(except: [create, update, delete])\n @policy(role: USER, allow: [select], where: \"id = {{auth.user.id}}\")\n @policy(role: ADMIN, allow: all)\n }\n}\n";
|
|
3
|
+
export declare const ENV_TEMPLATE = "DATABASE_URL=postgresql://postgrest:postgrest@localhost:5432/postgrest\nJWT_SECRET=\nAUTH_PEPPER=\nAUTH_ACCESS_TOKEN_TTL=1h\nJWT_ROLE_CLAIM=role\nJWT_USER_ID_CLAIM=sub\nCORS_ORIGIN=\n";
|
|
4
4
|
export declare const GITIGNORE_TEMPLATE = "node_modules/\ndist/\n.env\ndocker_data/\n.DS_Store\n*.log\nnpm-debug.log*\n";
|
|
5
5
|
export declare const DOCKER_COMPOSE_TEMPLATE = "services:\n postgres:\n image: postgres:18.4-bookworm\n container_name: schematic-pg\n restart: unless-stopped\n ports:\n - \"5432:5432\"\n environment:\n POSTGRES_USER: postgrest\n POSTGRES_PASSWORD: postgrest\n POSTGRES_DB: postgrest\n volumes:\n - ./docker_data/postgres:/var/lib/postgresql\n healthcheck:\n test: [\"CMD-SHELL\", \"pg_isready -U postgrest -d postgrest\"]\n interval: 5s\n timeout: 5s\n retries: 5\n";
|
|
6
6
|
export declare const TSCONFIG_TEMPLATE = "{\n \"compilerOptions\": {\n \"target\": \"ES2022\",\n \"module\": \"NodeNext\",\n \"moduleResolution\": \"NodeNext\",\n \"strict\": true,\n \"esModuleInterop\": true,\n \"skipLibCheck\": true,\n \"outDir\": \"dist\",\n \"rootDir\": \".\"\n },\n \"include\": [\"generated/**/*\", \"src/**/*\"]\n}\n";
|
package/dist/cli/templates.js
CHANGED
|
@@ -21,7 +21,8 @@ models {
|
|
|
21
21
|
passwordHash: VARCHAR(255)? @omit @unfilterable
|
|
22
22
|
createdAt: TIMESTAMP @default(now())
|
|
23
23
|
|
|
24
|
-
@
|
|
24
|
+
@rest(except: [create, update, delete])
|
|
25
|
+
@policy(role: USER, allow: [select], where: "id = {{auth.user.id}}")
|
|
25
26
|
@policy(role: ADMIN, allow: all)
|
|
26
27
|
}
|
|
27
28
|
}
|
|
@@ -32,6 +33,7 @@ AUTH_PEPPER=
|
|
|
32
33
|
AUTH_ACCESS_TOKEN_TTL=1h
|
|
33
34
|
JWT_ROLE_CLAIM=role
|
|
34
35
|
JWT_USER_ID_CLAIM=sub
|
|
36
|
+
CORS_ORIGIN=
|
|
35
37
|
`;
|
|
36
38
|
export const GITIGNORE_TEMPLATE = `node_modules/
|
|
37
39
|
dist/
|