schematic-pg 0.1.11 → 0.1.12

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.
@@ -0,0 +1,7 @@
1
+ import type { Hono } from 'hono';
2
+ /**
3
+ * Mounts public API docs routes:
4
+ * - GET /openapi.json — OpenAPI document
5
+ * - GET /docs — Scalar API reference UI (CDN)
6
+ */
7
+ export declare function mountApiDocs(app: Hono, document: object): void;
@@ -0,0 +1,27 @@
1
+ const SCALAR_HTML = `<!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>API Reference</title>
7
+ </head>
8
+ <body>
9
+ <div id="app"></div>
10
+ <script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
11
+ <script>
12
+ Scalar.createApiReference('#app', {
13
+ url: '/openapi.json',
14
+ });
15
+ </script>
16
+ </body>
17
+ </html>
18
+ `;
19
+ /**
20
+ * Mounts public API docs routes:
21
+ * - GET /openapi.json — OpenAPI document
22
+ * - GET /docs — Scalar API reference UI (CDN)
23
+ */
24
+ export function mountApiDocs(app, document) {
25
+ app.get('/openapi.json', (c) => c.json(document));
26
+ app.get('/docs', (c) => c.html(SCALAR_HTML));
27
+ }
@@ -40,6 +40,7 @@ export class AppGenerator {
40
40
  "import { createDbClient } from './db.js';",
41
41
  "import { POLICIES } from './policies.js';",
42
42
  "import { HOOKS } from './hooks.js';",
43
+ "import { openApiDocument } from './openapi.js';",
43
44
  `import { configurePolicies } from '${PACKAGE_NAME}/api/auth/policy';`,
44
45
  `import { configureHooks } from '${PACKAGE_NAME}/api/hooks';`,
45
46
  `import { createAuthMiddleware } from '${PACKAGE_NAME}/api/auth/middleware';`,
@@ -47,6 +48,7 @@ export class AppGenerator {
47
48
  `import type { AuthResolver } from '${PACKAGE_NAME}/api/auth/types';`,
48
49
  `import { createDbMiddleware } from '${PACKAGE_NAME}/api/middleware/db';`,
49
50
  `import { handleError } from '${PACKAGE_NAME}/api/middleware/errors';`,
51
+ `import { mountApiDocs } from '${PACKAGE_NAME}/api/openapi';`,
50
52
  `import type { AppEnv } from '${PACKAGE_NAME}/api/types';`,
51
53
  '',
52
54
  'configurePolicies(POLICIES);',
@@ -59,6 +61,7 @@ export class AppGenerator {
59
61
  '',
60
62
  'export function createApp(options: CreateAppOptions = {}): Hono<AppEnv> {',
61
63
  ' const app = new Hono<AppEnv>();',
64
+ ' mountApiDocs(app, openApiDocument);',
62
65
  ' app.use(logger());',
63
66
  ' app.use(prettyJSON());',
64
67
  ' app.use(createDbMiddleware({ pool: options.pool, createDbClient }));',
@@ -76,6 +79,7 @@ export class AppGenerator {
76
79
  ' const port = Number(process.env.PORT ?? 3000);',
77
80
  ' serve({ fetch: createApp().fetch, port }, () => {',
78
81
  ' console.log(`Server running at http://localhost:${port}`);',
82
+ ' console.log(`API docs at http://localhost:${port}/docs`);',
79
83
  ' });',
80
84
  '}',
81
85
  '',
@@ -5,6 +5,8 @@ export interface GeneratedApiFiles {
5
5
  validation: string;
6
6
  hooks: string;
7
7
  routes: Map<string, string>;
8
+ openapiTs: string;
9
+ openapiJson: string;
8
10
  }
9
11
  export interface GenerateApiFilesOptions {
10
12
  customRoutesDir?: string;
@@ -1,21 +1,26 @@
1
- import { DEFAULT_HOOKS_DIR } from '../cli/paths.js';
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
4
  import { discoverHooks } from './hook-scanner.js';
4
5
  import { generateHooksFile } from './hooks-generator.js';
6
+ import { generateOpenApiFiles } from './openapi-generator.js';
5
7
  import { generatePoliciesFile } from './policy-generator.js';
6
8
  import { generateRouteFiles } from './route-generator.js';
7
9
  import { generateValidationSchemas } from './zod-schema-generator.js';
8
10
  export function generateApiFiles(schema, options) {
9
- const appOptions = options?.customRoutesDir
10
- ? { customRoutesDir: options.customRoutesDir }
11
- : undefined;
11
+ const customRoutesDir = options?.customRoutesDir ?? DEFAULT_CUSTOM_ROUTES_DIR;
12
+ const appOptions = { customRoutesDir };
12
13
  const hooksDir = options?.hooksDir ?? DEFAULT_HOOKS_DIR;
13
14
  const { entries: hookEntries, modelsWithHooks } = discoverHooks(hooksDir, schema);
15
+ const includeAuthPaths = discoverCustomRoutes(customRoutesDir).some((entry) => entry.basePath === 'auth');
16
+ const openapi = generateOpenApiFiles(schema, { includeAuthPaths });
14
17
  return {
15
18
  app: generateAppFile(schema, appOptions),
16
19
  policies: generatePoliciesFile(schema),
17
20
  validation: generateValidationSchemas(schema),
18
21
  hooks: generateHooksFile(hookEntries),
19
22
  routes: generateRouteFiles(schema, modelsWithHooks),
23
+ openapiTs: openapi.openapiTs,
24
+ openapiJson: openapi.openapiJson,
20
25
  };
21
26
  }
@@ -0,0 +1,29 @@
1
+ import type { Schema } from '../schema-dsl/ast.js';
2
+ export interface OpenApiGeneratorOptions {
3
+ includeAuthPaths?: boolean;
4
+ }
5
+ type OpenApiDocument = Record<string, unknown>;
6
+ export declare class OpenApiGenerator {
7
+ private readonly schema;
8
+ private readonly options;
9
+ constructor(schema: Schema, options?: OpenApiGeneratorOptions);
10
+ generate(): OpenApiDocument;
11
+ generateJson(): string;
12
+ generateTsModule(): string;
13
+ private buildModelComponentSchemas;
14
+ private buildModelPaths;
15
+ private buildListQueryParameters;
16
+ private buildIncludeParameter;
17
+ private buildPathParameters;
18
+ private filterParamSchema;
19
+ private fieldToOpenApiSchema;
20
+ private mapTypeToOpenApi;
21
+ private buildAuthComponentSchemas;
22
+ private buildAuthPaths;
23
+ }
24
+ export declare function generateOpenApiDocument(schema: Schema, options?: OpenApiGeneratorOptions): OpenApiDocument;
25
+ export declare function generateOpenApiFiles(schema: Schema, options?: OpenApiGeneratorOptions): {
26
+ openapiTs: string;
27
+ openapiJson: string;
28
+ };
29
+ export {};
@@ -0,0 +1,500 @@
1
+ import { toRouteBasePath } from '../api/utils/route-naming.js';
2
+ import { fieldHasAttribute, getModelNames, getPrimaryKey, getStoredFields, } from '../sql-generator/utils/ast-helpers.js';
3
+ import { getFilterableFields, getIncludableRelationFields, getOmittedFields, getSortableFieldNames, isStoredScalarField, } from './utils/api-fields.js';
4
+ import { buildFilterFieldMeta, queryParamKey } from './utils/filter-operators.js';
5
+ const ERROR_REF = { $ref: '#/components/schemas/Error' };
6
+ const ERROR_CONTENT = {
7
+ 'application/json': {
8
+ schema: ERROR_REF,
9
+ },
10
+ };
11
+ const OPTIONAL_BEARER_SECURITY = [{}, { bearerAuth: [] }];
12
+ function errorResponse(description) {
13
+ return { description, content: ERROR_CONTENT };
14
+ }
15
+ function jsonContent(schema) {
16
+ return {
17
+ 'application/json': {
18
+ schema,
19
+ },
20
+ };
21
+ }
22
+ export class OpenApiGenerator {
23
+ schema;
24
+ options;
25
+ constructor(schema, options = {}) {
26
+ this.schema = schema;
27
+ this.options = options;
28
+ }
29
+ generate() {
30
+ const schemas = {
31
+ Error: {
32
+ type: 'object',
33
+ required: ['error'],
34
+ properties: {
35
+ error: { type: 'string' },
36
+ },
37
+ },
38
+ };
39
+ const paths = {};
40
+ for (const model of this.schema.models) {
41
+ Object.assign(schemas, this.buildModelComponentSchemas(model));
42
+ Object.assign(paths, this.buildModelPaths(model));
43
+ }
44
+ if (this.options.includeAuthPaths) {
45
+ Object.assign(schemas, this.buildAuthComponentSchemas());
46
+ Object.assign(paths, this.buildAuthPaths());
47
+ }
48
+ return {
49
+ openapi: '3.1.0',
50
+ info: {
51
+ title: 'schematic-pg API',
52
+ version: '1.0.0',
53
+ description: 'Auto-generated OpenAPI document from the schema AST. Regenerated by schematic-pg generate:api.',
54
+ },
55
+ components: {
56
+ securitySchemes: {
57
+ bearerAuth: {
58
+ type: 'http',
59
+ scheme: 'bearer',
60
+ bearerFormat: 'JWT',
61
+ },
62
+ },
63
+ schemas,
64
+ },
65
+ paths,
66
+ };
67
+ }
68
+ generateJson() {
69
+ return `${JSON.stringify(this.generate(), null, 2)}\n`;
70
+ }
71
+ generateTsModule() {
72
+ const document = this.generate();
73
+ return [
74
+ '// Auto-generated by OpenApiGenerator. Do not edit manually.',
75
+ `export const openApiDocument = ${JSON.stringify(document, null, 2)} as const;`,
76
+ '',
77
+ ].join('\n');
78
+ }
79
+ buildModelComponentSchemas(model) {
80
+ const modelNames = getModelNames(this.schema);
81
+ const storedFields = getStoredFields(model, modelNames).filter((field) => isStoredScalarField(field, this.schema));
82
+ const primaryKey = getPrimaryKey(model);
83
+ const pkFields = new Set(primaryKey?.fields ?? []);
84
+ const omitted = new Set(getOmittedFields(model, this.schema).map((field) => field.name));
85
+ const responseProps = {};
86
+ const responseRequired = [];
87
+ for (const field of storedFields) {
88
+ if (omitted.has(field.name)) {
89
+ continue;
90
+ }
91
+ responseProps[field.name] = this.fieldToOpenApiSchema(field, {
92
+ nullable: Boolean(field.type.optional),
93
+ });
94
+ if (!field.type.optional) {
95
+ responseRequired.push(field.name);
96
+ }
97
+ }
98
+ const createProps = {};
99
+ const createRequired = [];
100
+ for (const field of storedFields) {
101
+ if (pkFields.has(field.name)) {
102
+ continue;
103
+ }
104
+ createProps[field.name] = this.fieldToOpenApiSchema(field, {
105
+ nullable: Boolean(field.type.optional),
106
+ });
107
+ const optional = Boolean(field.type.optional) || fieldHasAttribute(field, 'default');
108
+ if (!optional) {
109
+ createRequired.push(field.name);
110
+ }
111
+ }
112
+ const updateProps = {};
113
+ for (const field of storedFields) {
114
+ if (pkFields.has(field.name)) {
115
+ continue;
116
+ }
117
+ updateProps[field.name] = this.fieldToOpenApiSchema(field, {
118
+ nullable: Boolean(field.type.optional),
119
+ });
120
+ }
121
+ return {
122
+ [`${model.name}Response`]: {
123
+ type: 'object',
124
+ properties: responseProps,
125
+ ...(responseRequired.length > 0 ? { required: responseRequired } : {}),
126
+ },
127
+ [`${model.name}Create`]: {
128
+ type: 'object',
129
+ properties: createProps,
130
+ ...(createRequired.length > 0 ? { required: createRequired } : {}),
131
+ },
132
+ [`${model.name}Update`]: {
133
+ type: 'object',
134
+ properties: updateProps,
135
+ },
136
+ };
137
+ }
138
+ buildModelPaths(model) {
139
+ const basePath = toRouteBasePath(model.name);
140
+ const collectionPath = `/${basePath}`;
141
+ const primaryKey = getPrimaryKey(model);
142
+ const pkFields = primaryKey?.fields ?? [];
143
+ const itemPathSegments = pkFields.map((field) => `{${field}}`);
144
+ const itemPath = itemPathSegments.length > 0
145
+ ? `${collectionPath}/${itemPathSegments.join('/')}`
146
+ : collectionPath;
147
+ const responseRef = { $ref: `#/components/schemas/${model.name}Response` };
148
+ const createRef = { $ref: `#/components/schemas/${model.name}Create` };
149
+ const updateRef = { $ref: `#/components/schemas/${model.name}Update` };
150
+ const tag = model.name;
151
+ const paths = {
152
+ [collectionPath]: {
153
+ get: {
154
+ tags: [tag],
155
+ summary: `List ${model.name}`,
156
+ operationId: `list${model.name}`,
157
+ security: OPTIONAL_BEARER_SECURITY,
158
+ parameters: this.buildListQueryParameters(model),
159
+ responses: {
160
+ '200': {
161
+ description: `List of ${model.name}`,
162
+ content: jsonContent({ type: 'array', items: responseRef }),
163
+ },
164
+ '400': errorResponse('Validation error'),
165
+ '401': errorResponse('Unauthorized'),
166
+ '403': errorResponse('Forbidden'),
167
+ '500': errorResponse('Internal server error'),
168
+ },
169
+ },
170
+ post: {
171
+ tags: [tag],
172
+ summary: `Create ${model.name}`,
173
+ operationId: `create${model.name}`,
174
+ security: OPTIONAL_BEARER_SECURITY,
175
+ requestBody: {
176
+ required: true,
177
+ content: jsonContent(createRef),
178
+ },
179
+ responses: {
180
+ '201': {
181
+ description: `Created ${model.name}`,
182
+ content: jsonContent(responseRef),
183
+ },
184
+ '400': errorResponse('Validation error'),
185
+ '401': errorResponse('Unauthorized'),
186
+ '403': errorResponse('Forbidden'),
187
+ '409': errorResponse('Conflict'),
188
+ '500': errorResponse('Internal server error'),
189
+ },
190
+ },
191
+ },
192
+ };
193
+ if (pkFields.length > 0) {
194
+ const pathParams = this.buildPathParameters(pkFields, model);
195
+ paths[itemPath] = {
196
+ get: {
197
+ tags: [tag],
198
+ summary: `Get ${model.name}`,
199
+ operationId: `get${model.name}`,
200
+ security: OPTIONAL_BEARER_SECURITY,
201
+ parameters: [...pathParams, this.buildIncludeParameter(model)],
202
+ responses: {
203
+ '200': {
204
+ description: `${model.name} record`,
205
+ content: jsonContent(responseRef),
206
+ },
207
+ '400': errorResponse('Validation error'),
208
+ '401': errorResponse('Unauthorized'),
209
+ '403': errorResponse('Forbidden'),
210
+ '404': errorResponse('Not found'),
211
+ '500': errorResponse('Internal server error'),
212
+ },
213
+ },
214
+ put: {
215
+ tags: [tag],
216
+ summary: `Update ${model.name}`,
217
+ operationId: `update${model.name}`,
218
+ security: OPTIONAL_BEARER_SECURITY,
219
+ parameters: pathParams,
220
+ requestBody: {
221
+ required: true,
222
+ content: jsonContent(updateRef),
223
+ },
224
+ responses: {
225
+ '200': {
226
+ description: `Updated ${model.name}`,
227
+ content: jsonContent(responseRef),
228
+ },
229
+ '400': errorResponse('Validation error'),
230
+ '401': errorResponse('Unauthorized'),
231
+ '403': errorResponse('Forbidden'),
232
+ '404': errorResponse('Not found'),
233
+ '409': errorResponse('Conflict'),
234
+ '500': errorResponse('Internal server error'),
235
+ },
236
+ },
237
+ delete: {
238
+ tags: [tag],
239
+ summary: `Delete ${model.name}`,
240
+ operationId: `delete${model.name}`,
241
+ security: OPTIONAL_BEARER_SECURITY,
242
+ parameters: pathParams,
243
+ responses: {
244
+ '200': {
245
+ description: `Deleted ${model.name}`,
246
+ content: jsonContent(responseRef),
247
+ },
248
+ '400': errorResponse('Validation error'),
249
+ '401': errorResponse('Unauthorized'),
250
+ '403': errorResponse('Forbidden'),
251
+ '404': errorResponse('Not found'),
252
+ '500': errorResponse('Internal server error'),
253
+ },
254
+ },
255
+ };
256
+ }
257
+ return paths;
258
+ }
259
+ buildListQueryParameters(model) {
260
+ const filterableFields = getFilterableFields(model, this.schema);
261
+ const sortableFields = getSortableFieldNames(model, this.schema);
262
+ const parameters = [];
263
+ for (const field of filterableFields) {
264
+ const meta = buildFilterFieldMeta(field, this.schema);
265
+ for (const operator of meta.operators) {
266
+ const name = queryParamKey(field.name, operator);
267
+ parameters.push({
268
+ name,
269
+ in: 'query',
270
+ required: false,
271
+ schema: this.filterParamSchema(field, operator),
272
+ description: operator === 'equals'
273
+ ? `Filter by ${field.name}`
274
+ : `Filter ${field.name} with ${operator}`,
275
+ });
276
+ }
277
+ }
278
+ parameters.push({
279
+ name: 'limit',
280
+ in: 'query',
281
+ required: false,
282
+ schema: { type: 'integer', minimum: 1, maximum: 100 },
283
+ description: 'Max rows to return (max 100)',
284
+ }, {
285
+ name: 'offset',
286
+ in: 'query',
287
+ required: false,
288
+ schema: { type: 'integer', minimum: 0 },
289
+ description: 'Number of rows to skip',
290
+ }, {
291
+ name: 'sort',
292
+ in: 'query',
293
+ required: false,
294
+ schema: { type: 'string' },
295
+ description: `Sort field (prefix with - for desc). Allowed: ${sortableFields.join(', ') || '(none)'}`,
296
+ }, this.buildIncludeParameter(model));
297
+ return parameters;
298
+ }
299
+ buildIncludeParameter(model) {
300
+ const relationNames = getIncludableRelationFields(model, this.schema).map((field) => field.name);
301
+ return {
302
+ name: 'include',
303
+ in: 'query',
304
+ required: false,
305
+ schema: { type: 'string' },
306
+ description: relationNames.length > 0
307
+ ? `Comma-separated relation paths (dot nesting). Available: ${relationNames.join(', ')}`
308
+ : 'Comma-separated relation paths (dot nesting)',
309
+ };
310
+ }
311
+ buildPathParameters(pkFieldNames, model) {
312
+ const modelNames = getModelNames(this.schema);
313
+ const storedFields = getStoredFields(model, modelNames);
314
+ return pkFieldNames.map((name) => {
315
+ const field = storedFields.find((candidate) => candidate.name === name);
316
+ return {
317
+ name,
318
+ in: 'path',
319
+ required: true,
320
+ schema: field
321
+ ? this.fieldToOpenApiSchema(field, { nullable: false })
322
+ : { type: 'string' },
323
+ };
324
+ });
325
+ }
326
+ filterParamSchema(field, operator) {
327
+ if (operator === 'in') {
328
+ return {
329
+ type: 'string',
330
+ description: 'Comma-separated values',
331
+ };
332
+ }
333
+ return this.fieldToOpenApiSchema(field, { nullable: false });
334
+ }
335
+ fieldToOpenApiSchema(field, options = { nullable: false }) {
336
+ const base = this.mapTypeToOpenApi(field.type);
337
+ if (!options.nullable) {
338
+ return base;
339
+ }
340
+ const type = base.type;
341
+ if (typeof type === 'string') {
342
+ return { ...base, type: [type, 'null'] };
343
+ }
344
+ return { anyOf: [base, { type: 'null' }] };
345
+ }
346
+ mapTypeToOpenApi(type) {
347
+ const enumType = this.schema.enums.find((enumDef) => enumDef.name === type.name);
348
+ if (enumType) {
349
+ return {
350
+ type: 'string',
351
+ enum: enumType.values,
352
+ };
353
+ }
354
+ if (type.array && type.name === 'TEXT') {
355
+ return {
356
+ type: 'array',
357
+ items: { type: 'string' },
358
+ };
359
+ }
360
+ switch (type.name) {
361
+ case 'UUID':
362
+ return { type: 'string', format: 'uuid' };
363
+ case 'VARCHAR':
364
+ case 'TEXT':
365
+ return { type: 'string' };
366
+ case 'INTEGER':
367
+ case 'SERIAL':
368
+ case 'SMALLINT':
369
+ return { type: 'integer' };
370
+ case 'BOOLEAN':
371
+ return { type: 'boolean' };
372
+ case 'TIMESTAMP':
373
+ return { type: 'string', format: 'date-time' };
374
+ case 'DECIMAL':
375
+ // Matches Zod create/update mapping (z.string()).
376
+ return { type: 'string' };
377
+ case 'JSONB':
378
+ return { type: 'object', additionalProperties: true };
379
+ case 'POINT':
380
+ return {};
381
+ default:
382
+ return { type: 'string' };
383
+ }
384
+ }
385
+ buildAuthComponentSchemas() {
386
+ return {
387
+ AuthRegisterRequest: {
388
+ type: 'object',
389
+ required: ['email', 'password'],
390
+ properties: {
391
+ email: { type: 'string', format: 'email' },
392
+ password: { type: 'string', minLength: 1 },
393
+ name: { type: 'string', minLength: 1 },
394
+ },
395
+ },
396
+ AuthLoginRequest: {
397
+ type: 'object',
398
+ required: ['email', 'password'],
399
+ properties: {
400
+ email: { type: 'string', format: 'email' },
401
+ password: { type: 'string', minLength: 1 },
402
+ },
403
+ },
404
+ AuthTokenResponse: {
405
+ type: 'object',
406
+ required: ['token', 'user'],
407
+ properties: {
408
+ token: { type: 'string' },
409
+ user: { type: 'object', additionalProperties: true },
410
+ },
411
+ },
412
+ AuthMeResponse: {
413
+ type: 'object',
414
+ properties: {
415
+ role: { type: 'string' },
416
+ user: {
417
+ anyOf: [
418
+ {
419
+ type: 'object',
420
+ properties: {
421
+ id: { type: 'string' },
422
+ },
423
+ additionalProperties: true,
424
+ },
425
+ { type: 'null' },
426
+ ],
427
+ },
428
+ },
429
+ },
430
+ };
431
+ }
432
+ buildAuthPaths() {
433
+ return {
434
+ '/auth/register': {
435
+ post: {
436
+ tags: ['Auth'],
437
+ summary: 'Register',
438
+ operationId: 'authRegister',
439
+ requestBody: {
440
+ required: true,
441
+ content: jsonContent({ $ref: '#/components/schemas/AuthRegisterRequest' }),
442
+ },
443
+ responses: {
444
+ '201': {
445
+ description: 'Registered user with access token',
446
+ content: jsonContent({ $ref: '#/components/schemas/AuthTokenResponse' }),
447
+ },
448
+ '400': errorResponse('Validation error'),
449
+ '409': errorResponse('Conflict'),
450
+ '500': errorResponse('Internal server error'),
451
+ },
452
+ },
453
+ },
454
+ '/auth/login': {
455
+ post: {
456
+ tags: ['Auth'],
457
+ summary: 'Login',
458
+ operationId: 'authLogin',
459
+ requestBody: {
460
+ required: true,
461
+ content: jsonContent({ $ref: '#/components/schemas/AuthLoginRequest' }),
462
+ },
463
+ responses: {
464
+ '200': {
465
+ description: 'Access token and user',
466
+ content: jsonContent({ $ref: '#/components/schemas/AuthTokenResponse' }),
467
+ },
468
+ '400': errorResponse('Validation error'),
469
+ '401': errorResponse('Invalid email or password'),
470
+ '500': errorResponse('Internal server error'),
471
+ },
472
+ },
473
+ },
474
+ '/auth/me': {
475
+ get: {
476
+ tags: ['Auth'],
477
+ summary: 'Current auth context',
478
+ operationId: 'authMe',
479
+ security: OPTIONAL_BEARER_SECURITY,
480
+ responses: {
481
+ '200': {
482
+ description: 'Current auth context (PUBLIC when unauthenticated)',
483
+ content: jsonContent({ $ref: '#/components/schemas/AuthMeResponse' }),
484
+ },
485
+ },
486
+ },
487
+ },
488
+ };
489
+ }
490
+ }
491
+ export function generateOpenApiDocument(schema, options) {
492
+ return new OpenApiGenerator(schema, options).generate();
493
+ }
494
+ export function generateOpenApiFiles(schema, options) {
495
+ const generator = new OpenApiGenerator(schema, options);
496
+ return {
497
+ openapiTs: generator.generateTsModule(),
498
+ openapiJson: generator.generateJson(),
499
+ };
500
+ }
@@ -36,6 +36,8 @@ export async function generateApi(schemaPath) {
36
36
  await writeFile(path.join(outputDir, 'policies.ts'), files.policies, 'utf8');
37
37
  await writeFile(path.join(outputDir, 'hooks.ts'), files.hooks, 'utf8');
38
38
  await writeFile(path.join(schemasDir, 'validation.ts'), files.validation, 'utf8');
39
+ await writeFile(path.join(outputDir, 'openapi.ts'), files.openapiTs, 'utf8');
40
+ await writeFile(path.join(outputDir, 'openapi.json'), files.openapiJson, 'utf8');
39
41
  for (const [fileName, content] of files.routes) {
40
42
  await writeFile(path.join(routesDir, fileName), content, 'utf8');
41
43
  }
@@ -2,7 +2,7 @@ export declare const AGENTS_TEMPLATE: string;
2
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\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, update], where: \"id = {{auth.user.id}}\")\n @policy(role: ADMIN, allow: all)\n }\n}\n";
3
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";
4
4
  export declare const GITIGNORE_TEMPLATE = "node_modules/\ndist/\n.env\ndocker_data/\n.DS_Store\n*.log\nnpm-debug.log*\n";
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/data\n healthcheck:\n test: [\"CMD-SHELL\", \"pg_isready -U postgrest -d postgrest\"]\n interval: 5s\n timeout: 5s\n retries: 5\n";
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";
7
7
  export declare const MAKEFILE_TEMPLATE = ".PHONY: dev\n\ndev:\n\tdocker compose up -d --wait\n\tnpx schematic-pg dev\n";
8
8
  export declare const HEALTH_ROUTE_TEMPLATE = "import { Hono } from 'hono';\nimport type { AppEnv } from 'schematic-pg/api/types';\n\nconst router = new Hono<AppEnv>();\nrouter.get('/', (c) => c.json({ ok: true }));\nexport default router;\n";
@@ -53,7 +53,7 @@ export const DOCKER_COMPOSE_TEMPLATE = `services:
53
53
  POSTGRES_PASSWORD: postgrest
54
54
  POSTGRES_DB: postgrest
55
55
  volumes:
56
- - ./docker_data/postgres:/var/lib/postgresql/data
56
+ - ./docker_data/postgres:/var/lib/postgresql
57
57
  healthcheck:
58
58
  test: ["CMD-SHELL", "pg_isready -U postgrest -d postgrest"]
59
59
  interval: 5s
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "schematic-pg",
3
- "version": "0.1.11",
3
+ "version": "0.1.12",
4
4
  "description": "Single-file backend framework for PostgreSQL and Node.js",
5
5
  "type": "module",
6
6
  "license": "MIT",