tina4-nodejs 3.13.132 → 3.13.133

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.
@@ -157,16 +157,85 @@ function sanitizeSecurity(
157
157
  });
158
158
  }
159
159
 
160
+ /**
161
+ * Shared, mutable state threaded through the per-route builders. Bundled so
162
+ * `generate()` reads as a flat orchestrator and each helper keeps a single
163
+ * responsibility (parity with the Python/PHP decomposition of `generate`).
164
+ */
165
+ interface BuildContext {
166
+ models: ModelDefinition[];
167
+ tableToSchema: Map<string, string>;
168
+ schemes: Record<string, Record<string, unknown>>;
169
+ defaultScheme: string;
170
+ includePrefixes: string[];
171
+ excludePrefixes: string[];
172
+ refSchemas: Set<string>;
173
+ usedTags: string[];
174
+ seenIds: Set<string>;
175
+ }
176
+
160
177
  export function generate(
161
178
  routes: RouteDefinition[],
162
179
  models: ModelDefinition[] = []
163
180
  ): OpenAPISpec {
181
+ const schemes = resolveSecuritySchemes();
182
+ const spec: OpenAPISpec = {
183
+ openapi: resolveOpenApiVersion(),
184
+ info: buildInfo(),
185
+ servers: resolveServers(),
186
+ paths: {},
187
+ components: {
188
+ schemas: {},
189
+ // Configurable security schemes (v3.13.42): bearerFormat via env, optional
190
+ // apiKey scheme, plus any programmatically-registered schemes (which may
191
+ // override bearerAuth — e.g. an oauth2 scheme with scopes).
192
+ securitySchemes: schemes,
193
+ },
194
+ };
195
+
196
+ // Generate schemas from models, keyed by the model CLASS name ('Item'), which
197
+ // is the type name a generated client wants — never the tableName ('items').
198
+ // `tableToSchema` maps a path-inferred tableName back to that schema key so a
199
+ // POST/PUT request body $ref resolves to the same 'Item' entry.
200
+ const tableToSchema = new Map<string, string>();
201
+ buildComponentSchemas(models, spec, tableToSchema);
202
+
203
+ const ctx: BuildContext = {
204
+ models,
205
+ tableToSchema,
206
+ schemes,
207
+ // Default scheme secured routes use when no explicit meta.security is set.
208
+ defaultScheme: process.env.TINA4_SWAGGER_DEFAULT_SCHEME ?? "bearerAuth",
209
+ // Path filters (comma-separated raw-path prefixes).
210
+ includePrefixes: csv(process.env.TINA4_SWAGGER_INCLUDE),
211
+ excludePrefixes: csv(process.env.TINA4_SWAGGER_EXCLUDE),
212
+ // Reusable custom schemas referenced by routes via meta.requestSchema/responseSchemas.
213
+ refSchemas: new Set<string>(),
214
+ usedTags: [],
215
+ seenIds: new Set<string>(),
216
+ };
217
+
218
+ // Generate paths from routes
219
+ for (const route of routes) {
220
+ buildOperation(route, spec, ctx);
221
+ }
222
+
223
+ buildRefSchemas(spec, ctx.refSchemas);
224
+ buildTags(spec, ctx.usedTags);
225
+
226
+ return spec;
227
+ }
228
+
229
+ /**
230
+ * The `info` block: title/version/description, plus optional contact and
231
+ * license. The app's version defaults to 1.0.0 — NOT the framework's (Node
232
+ * shipped 0.0.1) — and description to the empty string, not a canned sentence;
233
+ * both are the settled cross-framework defaults (parity with the Python master),
234
+ * with TINA4_SWAGGER_VERSION / _DESCRIPTION still overriding.
235
+ */
236
+ function buildInfo(): OpenAPISpecInfo {
164
237
  const info: OpenAPISpecInfo = {
165
238
  title: process.env.TINA4_SWAGGER_TITLE ?? "Tina4 API",
166
- // The app's version, defaulting to 1.0.0 — NOT the framework's (Node shipped
167
- // 0.0.1). description defaults to the empty string, not a canned sentence.
168
- // Both are the settled cross-framework defaults (parity with the Python
169
- // master); TINA4_SWAGGER_VERSION / _DESCRIPTION still override.
170
239
  version: process.env.TINA4_SWAGGER_VERSION ?? "1.0.0",
171
240
  description: process.env.TINA4_SWAGGER_DESCRIPTION ?? "",
172
241
  };
@@ -190,183 +259,220 @@ export function generate(
190
259
  info.license = url ? { name, url } : { name };
191
260
  }
192
261
 
193
- const schemes = resolveSecuritySchemes();
194
- const spec: OpenAPISpec = {
195
- openapi: resolveOpenApiVersion(),
196
- info,
197
- servers: resolveServers(),
198
- paths: {},
199
- components: {
200
- schemas: {},
201
- // Configurable security schemes (v3.13.42): bearerFormat via env, optional
202
- // apiKey scheme, plus any programmatically-registered schemes (which may
203
- // override bearerAuth — e.g. an oauth2 scheme with scopes).
204
- securitySchemes: schemes,
205
- },
206
- };
207
-
208
- // Default scheme secured routes use when no explicit meta.security is set.
209
- const defaultScheme = process.env.TINA4_SWAGGER_DEFAULT_SCHEME ?? "bearerAuth";
210
-
211
- // Path filters (comma-separated raw-path prefixes).
212
- const includePrefixes = csv(process.env.TINA4_SWAGGER_INCLUDE);
213
- const excludePrefixes = csv(process.env.TINA4_SWAGGER_EXCLUDE);
214
-
215
- // Reusable custom schemas referenced by routes via meta.requestSchema/responseSchemas.
216
- const refSchemas = new Set<string>();
262
+ return info;
263
+ }
217
264
 
218
- // Generate schemas from models, keyed by the model CLASS name ('Item'), which
219
- // is the type name a generated client wants — never the tableName ('items').
220
- // `tableToSchema` maps a path-inferred tableName back to that schema key so a
221
- // POST/PUT request body $ref resolves to the same 'Item' entry.
222
- const tableToSchema = new Map<string, string>();
265
+ /**
266
+ * Populate `components.schemas` from ORM models (keyed by the class name) and
267
+ * record the tableName -> schema-key map used to resolve request-body $refs.
268
+ */
269
+ function buildComponentSchemas(
270
+ models: ModelDefinition[],
271
+ spec: OpenAPISpec,
272
+ tableToSchema: Map<string, string>
273
+ ): void {
223
274
  for (const model of models) {
224
275
  const schemaKey = schemaNameForModel(model);
225
276
  tableToSchema.set(model.tableName, schemaKey);
226
277
  spec.components!.schemas![schemaKey] = modelToSchema(model);
227
278
  }
279
+ }
228
280
 
229
- const usedTags: string[] = [];
230
- const seenIds = new Set<string>();
281
+ /**
282
+ * Build ONE path operation for a route and attach it to `spec.paths`, honouring
283
+ * the include/exclude filter and delegating each part of the operation to a
284
+ * focused sub-builder (parameters, request body, response schemas, security).
285
+ */
286
+ function buildOperation(route: RouteDefinition, spec: OpenAPISpec, ctx: BuildContext): void {
287
+ if (!isIncludedPath(route.pattern, ctx.includePrefixes, ctx.excludePrefixes)) return;
288
+ const openApiPath = patternToOpenAPI(route.pattern);
289
+ const method = route.method.toLowerCase();
231
290
 
232
- // Generate paths from routes
233
- for (const route of routes) {
234
- if (!isIncludedPath(route.pattern, includePrefixes, excludePrefixes)) continue;
235
- const openApiPath = patternToOpenAPI(route.pattern);
236
- const method = route.method.toLowerCase();
291
+ if (!spec.paths[openApiPath]) {
292
+ spec.paths[openApiPath] = {};
293
+ }
237
294
 
238
- if (!spec.paths[openApiPath]) {
239
- spec.paths[openApiPath] = {};
240
- }
295
+ const tags = route.meta?.tags ?? inferTags(route.pattern);
296
+ for (const t of tags) {
297
+ if (!ctx.usedTags.includes(t)) ctx.usedTags.push(t);
298
+ }
241
299
 
242
- const tags = route.meta?.tags ?? inferTags(route.pattern);
243
- for (const t of tags) {
244
- if (!usedTags.includes(t)) usedTags.push(t);
245
- }
300
+ const operation: Record<string, unknown> = {
301
+ operationId: uniqueOperationId(method, openApiPath, ctx.seenIds),
302
+ summary: route.meta?.summary ?? `${route.method} ${route.pattern}`,
303
+ tags,
304
+ responses: route.meta?.responses ?? {
305
+ "200": { description: "Successful response" },
306
+ },
307
+ };
246
308
 
247
- const operation: Record<string, unknown> = {
248
- operationId: uniqueOperationId(method, openApiPath, seenIds),
249
- summary: route.meta?.summary ?? `${route.method} ${route.pattern}`,
250
- tags,
251
- responses: route.meta?.responses ?? {
252
- "200": { description: "Successful response" },
253
- },
254
- };
309
+ if (route.meta?.description) operation.description = route.meta.description;
310
+ if (route.meta?.deprecated) operation.deprecated = true;
255
311
 
256
- if (route.meta?.description) operation.description = route.meta.description;
257
- if (route.meta?.deprecated) operation.deprecated = true;
258
-
259
- // Add path parameters. Each typed token maps to its JSON Schema fragment
260
- // (int -> integer, uuid -> string+format, slug -> string+pattern, ...) via
261
- // PARAM_TYPE_SCHEMA below, mirroring the Python master's _PARAM_TYPE_SCHEMA
262
- // and the router's accepted token set. An unknown token degrades to string
263
- // rather than dropping the parameter, and the token never reaches the key.
264
- const pathParams = extractPathParams(route.pattern);
265
- if (pathParams.length > 0) {
266
- operation.parameters = pathParams.map(({ name, schema }) => ({
267
- name,
268
- in: "path",
269
- required: true,
270
- schema,
271
- }));
272
- }
312
+ const parameters = operationParameters(route, method, ctx.models);
313
+ if (parameters.length > 0) operation.parameters = parameters;
273
314
 
274
- // Add query parameters for GET list endpoints
275
- if (method === "get" && !route.pattern.includes("[id]") && !route.pattern.includes("[...")) {
276
- const modelName = inferModelFromPath(route.pattern);
277
- if (modelName && models.some((m) => m.tableName === modelName)) {
278
- operation.parameters = [
279
- ...(operation.parameters as unknown[] ?? []),
280
- { name: "page", in: "query", schema: { type: "integer", default: 1 } },
281
- { name: "limit", in: "query", schema: { type: "integer", default: 20 } },
282
- { name: "sort", in: "query", schema: { type: "string" }, description: "Sort fields (prefix with - for descending)" },
283
- ];
284
- }
315
+ operationRequestBody(route, method, operation, ctx);
316
+ operationResponseSchemas(route, operation, ctx.refSchemas);
317
+ operationSecurity(route, method, operation, ctx.schemes, ctx.defaultScheme);
318
+
319
+ spec.paths[openApiPath][method] = operation;
320
+ }
321
+
322
+ /**
323
+ * The operation's parameters: path parameters (each typed token mapped to its
324
+ * JSON Schema fragment via PARAM_TYPE_SCHEMA — int -> integer, uuid ->
325
+ * string+format, slug -> string+pattern, ...; an unknown token degrades to
326
+ * string rather than dropping the parameter), followed by the page/limit/sort
327
+ * query parameters a GET list endpoint over a known model gets.
328
+ */
329
+ function operationParameters(
330
+ route: RouteDefinition,
331
+ method: string,
332
+ models: ModelDefinition[]
333
+ ): Array<Record<string, unknown>> {
334
+ let parameters: Array<Record<string, unknown>> = [];
335
+
336
+ const pathParams = extractPathParams(route.pattern);
337
+ if (pathParams.length > 0) {
338
+ parameters = pathParams.map(({ name, schema }) => ({
339
+ name,
340
+ in: "path",
341
+ required: true,
342
+ schema,
343
+ }));
344
+ }
345
+
346
+ // Add query parameters for GET list endpoints
347
+ if (method === "get" && !route.pattern.includes("[id]") && !route.pattern.includes("[...")) {
348
+ const modelName = inferModelFromPath(route.pattern);
349
+ if (modelName && models.some((m) => m.tableName === modelName)) {
350
+ parameters = [
351
+ ...parameters,
352
+ { name: "page", in: "query", schema: { type: "integer", default: 1 } },
353
+ { name: "limit", in: "query", schema: { type: "integer", default: 20 } },
354
+ { name: "sort", in: "query", schema: { type: "string" }, description: "Sort fields (prefix with - for descending)" },
355
+ ];
285
356
  }
357
+ }
286
358
 
287
- // Request body — a registered custom schema $ref (meta.requestSchema) wins,
288
- // else the inferred-from-model body (POST/PUT to a resource), else an
289
- // example-only body.
290
- const reqSchemaRef = parseRequestSchema(route.meta?.requestSchema);
291
- if (reqSchemaRef && (method === "post" || method === "put" || method === "patch")) {
292
- refSchemas.add(reqSchemaRef.name);
293
- const media: Record<string, unknown> = {
294
- schema: { $ref: `#/components/schemas/${reqSchemaRef.name}` },
295
- };
296
- if (route.meta?.example !== undefined) media.example = route.meta.example;
359
+ return parameters;
360
+ }
361
+
362
+ /** A media-type object: the schema, plus meta.example only when one was given.
363
+ * Key order (schema, then example) matches the emitted document. */
364
+ function mediaWithExample(schema: unknown, example: unknown): Record<string, unknown> {
365
+ const media: Record<string, unknown> = { schema };
366
+ if (example !== undefined) media.example = example;
367
+ return media;
368
+ }
369
+
370
+ /**
371
+ * Attach the request body (and, for a path-inferred model write, its 200
372
+ * response schema). A registered custom schema $ref (meta.requestSchema) wins,
373
+ * else the inferred-from-model body (POST/PUT to a resource), else an
374
+ * example-only body.
375
+ */
376
+ function operationRequestBody(
377
+ route: RouteDefinition,
378
+ method: string,
379
+ operation: Record<string, unknown>,
380
+ ctx: BuildContext
381
+ ): void {
382
+ const reqSchemaRef = parseRequestSchema(route.meta?.requestSchema);
383
+ if (reqSchemaRef && (method === "post" || method === "put" || method === "patch")) {
384
+ ctx.refSchemas.add(reqSchemaRef.name);
385
+ const media = mediaWithExample({ $ref: `#/components/schemas/${reqSchemaRef.name}` }, route.meta?.example);
386
+ operation.requestBody = {
387
+ content: { [reqSchemaRef.contentType]: media },
388
+ };
389
+ } else if (method === "post" || method === "put") {
390
+ const modelName = inferModelFromPath(route.pattern);
391
+ const schemaKey = modelName ? ctx.tableToSchema.get(modelName) : undefined;
392
+ if (schemaKey) {
393
+ const sref = `#/components/schemas/${schemaKey}`;
297
394
  operation.requestBody = {
298
- content: { [reqSchemaRef.contentType]: media },
395
+ required: true,
396
+ content: { "application/json": mediaWithExample({ $ref: sref }, route.meta?.example) },
299
397
  };
300
- } else if (method === "post" || method === "put") {
301
- const modelName = inferModelFromPath(route.pattern);
302
- const schemaKey = modelName ? tableToSchema.get(modelName) : undefined;
303
- if (schemaKey) {
304
- const sref = `#/components/schemas/${schemaKey}`;
305
- const media: Record<string, unknown> = { schema: { $ref: sref } };
306
- if (route.meta?.example !== undefined) media.example = route.meta.example;
307
- operation.requestBody = {
308
- required: true,
309
- content: { "application/json": media },
310
- };
311
398
 
312
- // Response documents 200 with the resource schema — parity with the
313
- // Python master, which emits ONLY 200 for a model write. The old code
314
- // stamped an unconditional 422 and a 201 the generator has no way to know
315
- // a given route returns; both were fiction on a path-inferred write. A
316
- // route that genuinely answers another code declares it via
317
- // meta.responses, which is honoured above and never clobbered here.
318
- if (route.meta?.responses === undefined) {
319
- operation.responses = {
320
- "200": { description: "Successful response", content: { "application/json": { schema: { $ref: sref } } } },
321
- };
322
- }
323
- } else if (route.meta?.example !== undefined) {
324
- // Non-model body with an explicit example.
325
- operation.requestBody = {
326
- content: { "application/json": { schema: inferSchema(route.meta.example), example: route.meta.example } },
399
+ // Response documents 200 with the resource schema — parity with the
400
+ // Python master, which emits ONLY 200 for a model write. The old code
401
+ // stamped an unconditional 422 and a 201 the generator has no way to know
402
+ // a given route returns; both were fiction on a path-inferred write. A
403
+ // route that genuinely answers another code declares it via
404
+ // meta.responses, which is honoured above and never clobbered here.
405
+ if (route.meta?.responses === undefined) {
406
+ operation.responses = {
407
+ "200": { description: "Successful response", content: { "application/json": { schema: { $ref: sref } } } },
327
408
  };
328
409
  }
410
+ } else if (route.meta?.example !== undefined) {
411
+ // Non-model body with an explicit example.
412
+ operation.requestBody = {
413
+ content: { "application/json": { schema: inferSchema(route.meta.example), example: route.meta.example } },
414
+ };
329
415
  }
416
+ }
417
+ }
330
418
 
331
- // Registered response schemas ($ref) — explicit and authoritative, keyed by status.
332
- const respSchemas = parseResponseSchemas(route.meta?.responseSchemas);
333
- if (respSchemas.length > 0) {
334
- const responses = operation.responses as Record<string, unknown>;
335
- for (const { status, name, isList } of respSchemas) {
336
- refSchemas.add(name);
337
- const sref = `#/components/schemas/${name}`;
338
- const schema = isList ? { type: "array", items: { $ref: sref } } : { $ref: sref };
339
- responses[status] = {
340
- description: status.startsWith("2") ? "Successful response" : "Response",
341
- content: { "application/json": { schema } },
342
- };
343
- }
419
+ /**
420
+ * Registered response schemas ($ref) — explicit and authoritative, keyed by
421
+ * status; each maps to the resource, or an array of it when marked as a list.
422
+ */
423
+ function operationResponseSchemas(
424
+ route: RouteDefinition,
425
+ operation: Record<string, unknown>,
426
+ refSchemas: Set<string>
427
+ ): void {
428
+ const respSchemas = parseResponseSchemas(route.meta?.responseSchemas);
429
+ if (respSchemas.length > 0) {
430
+ const responses = operation.responses as Record<string, unknown>;
431
+ for (const { status, name, isList } of respSchemas) {
432
+ refSchemas.add(name);
433
+ const sref = `#/components/schemas/${name}`;
434
+ const schema = isList ? { type: "array", items: { $ref: sref } } : { $ref: sref };
435
+ responses[status] = {
436
+ description: status.startsWith("2") ? "Successful response" : "Response",
437
+ content: { "application/json": { schema } },
438
+ };
344
439
  }
440
+ }
441
+ }
345
442
 
346
- // Security (v3.13.42) — explicit meta.security wins (empty list = explicitly
347
- // public); otherwise a secured route gets the default scheme. Scopes are kept
348
- // valid (only oauth2/openIdConnect carry them).
349
- const hasExplicitSecurity =
350
- route.meta?.security !== undefined || (route.meta?.scopes !== undefined && route.meta.scopes.length > 0);
351
- if (hasExplicitSecurity) {
352
- const normalized = normalizeSecurity(route.meta?.security, route.meta?.scopes);
353
- operation.security = normalized.length > 0 ? sanitizeSecurity(normalized, schemes) : [];
354
- if (normalized.length > 0) {
355
- const responses = operation.responses as Record<string, unknown>;
356
- if (!responses["401"]) responses["401"] = { description: "Unauthorized" };
357
- }
358
- } else if (routeRequiresAuth(route, method)) {
359
- const requirements = [{ [defaultScheme]: [] }];
360
- if (defaultScheme === "bearerAuth" && schemes.ssoSession) requirements.push({ ssoSession: [] });
361
- operation.security = sanitizeSecurity(requirements, schemes);
443
+ /**
444
+ * Per-route security (v3.13.42). Explicit meta.security wins (empty list =
445
+ * explicitly public); otherwise a secured route gets the default scheme (plus
446
+ * ssoSession when bearer + SSO is configured). A secured op also documents a
447
+ * 401. Scopes are kept valid (only oauth2/openIdConnect carry them).
448
+ */
449
+ function operationSecurity(
450
+ route: RouteDefinition,
451
+ method: string,
452
+ operation: Record<string, unknown>,
453
+ schemes: Record<string, Record<string, unknown>>,
454
+ defaultScheme: string
455
+ ): void {
456
+ const hasExplicitSecurity =
457
+ route.meta?.security !== undefined || (route.meta?.scopes !== undefined && route.meta.scopes.length > 0);
458
+ if (hasExplicitSecurity) {
459
+ const normalized = normalizeSecurity(route.meta?.security, route.meta?.scopes);
460
+ operation.security = normalized.length > 0 ? sanitizeSecurity(normalized, schemes) : [];
461
+ if (normalized.length > 0) {
362
462
  const responses = operation.responses as Record<string, unknown>;
363
463
  if (!responses["401"]) responses["401"] = { description: "Unauthorized" };
364
464
  }
365
-
366
- spec.paths[openApiPath][method] = operation;
465
+ } else if (routeRequiresAuth(route, method)) {
466
+ const requirements = [{ [defaultScheme]: [] }];
467
+ if (defaultScheme === "bearerAuth" && schemes.ssoSession) requirements.push({ ssoSession: [] });
468
+ operation.security = sanitizeSecurity(requirements, schemes);
469
+ const responses = operation.responses as Record<string, unknown>;
470
+ if (!responses["401"]) responses["401"] = { description: "Unauthorized" };
367
471
  }
472
+ }
368
473
 
369
- // Registered component schemas referenced via meta.requestSchema/responseSchemas.
474
+ /** Registered component schemas referenced via meta.requestSchema/responseSchemas. */
475
+ function buildRefSchemas(spec: OpenAPISpec, refSchemas: Set<string>): void {
370
476
  if (refSchemas.size > 0) {
371
477
  const schemas = spec.components!.schemas!;
372
478
  for (const name of refSchemas) {
@@ -375,12 +481,13 @@ export function generate(
375
481
  }
376
482
  }
377
483
  }
484
+ }
378
485
 
486
+ /** Top-level tags[] from the tags collected across operations (registration order). */
487
+ function buildTags(spec: OpenAPISpec, usedTags: string[]): void {
379
488
  if (usedTags.length > 0) {
380
489
  spec.tags = usedTags.map((name) => ({ name }));
381
490
  }
382
-
383
- return spec;
384
491
  }
385
492
 
386
493
  function routeRequiresAuth(route: RouteDefinition, method: string): boolean {