@tetsujs/openapi 0.4.1 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/src/index.js CHANGED
@@ -102,6 +102,104 @@ function failureName(status, error) {
102
102
  return name || `Failure${status}`;
103
103
  }
104
104
 
105
+ // packages/openapi/src/envelopes.ts
106
+ function envelopes(components) {
107
+ const declared = new Map;
108
+ const defined = new Map;
109
+ const refs = new Map;
110
+ const codes = new Map;
111
+ const names = new Map;
112
+ const reported = new Set;
113
+ return {
114
+ declare(status, code, schema) {
115
+ const key = keyOf(status, code);
116
+ if (!declared.has(key)) {
117
+ declared.set(key, schema);
118
+ }
119
+ },
120
+ ref(status, code, schema, warn) {
121
+ const key = keyOf(status, code);
122
+ const definition = declared.get(key) ?? defined.get(key) ?? schema;
123
+ defined.set(key, definition);
124
+ const difference = differenceOf(definition, schema);
125
+ if (difference && !reported.has(`${key} ${difference}`)) {
126
+ reported.add(`${key} ${difference}`);
127
+ warn(`the ${status} "${code}" is described by one definition in the document, and a definition that differs from it in ${difference} is not described`);
128
+ }
129
+ const existing = refs.get(key);
130
+ if (existing) {
131
+ return existing;
132
+ }
133
+ const created = components.ref(nameOf(status, code), definition);
134
+ refs.set(key, created);
135
+ codes.set(created.$ref, code);
136
+ return created;
137
+ },
138
+ codeOf(ref) {
139
+ return codes.get(ref);
140
+ }
141
+ };
142
+ function nameOf(status, code) {
143
+ const name = failureName(status, code);
144
+ const holder = names.get(name);
145
+ if (holder === undefined || holder === keyOf(status, code)) {
146
+ names.set(name, keyOf(status, code));
147
+ return name;
148
+ }
149
+ return `${name}${status}`;
150
+ }
151
+ }
152
+ function envelopeCode(schema) {
153
+ const required = Array.isArray(schema.required) ? schema.required : [];
154
+ if (!required.includes("error")) {
155
+ return;
156
+ }
157
+ const error = schema.properties?.error;
158
+ if (typeof error?.const === "string") {
159
+ return error.const;
160
+ }
161
+ if (Array.isArray(error?.enum) && error.enum.length === 1 && typeof error.enum[0] === "string") {
162
+ return error.enum[0];
163
+ }
164
+ return;
165
+ }
166
+ function branchesOf(schema) {
167
+ const keys = Object.keys(schema);
168
+ if (keys.length !== 1) {
169
+ return [schema];
170
+ }
171
+ if (Array.isArray(schema.anyOf)) {
172
+ return schema.anyOf.flatMap(branchesOf);
173
+ }
174
+ if (Array.isArray(schema.oneOf)) {
175
+ const branches = schema.oneOf.flatMap(branchesOf);
176
+ const codes = branches.map(envelopeCode);
177
+ if (codes.every((code) => code !== undefined) && new Set(codes).size === codes.length) {
178
+ return branches;
179
+ }
180
+ }
181
+ return [schema];
182
+ }
183
+ function keyOf(status, code) {
184
+ return `${status} ${code}`;
185
+ }
186
+ function differenceOf(kept, other) {
187
+ const fields = (schema) => Object.keys(schema.properties ?? {});
188
+ const keptFields = fields(kept);
189
+ const otherFields = fields(other);
190
+ const missing = otherFields.filter((name) => !keptFields.includes(name));
191
+ const extra = keptFields.filter((name) => !otherFields.includes(name));
192
+ const changed = [...missing, ...extra];
193
+ if (changed.length > 0) {
194
+ return `fields: ${changed.join(", ")}`;
195
+ }
196
+ const required = (schema) => JSON.stringify([...Array.isArray(schema.required) ? schema.required : []].sort());
197
+ return required(kept) === required(other) ? undefined : "which fields are required";
198
+ }
199
+
200
+ // packages/openapi/src/operation.ts
201
+ import { errorBody } from "@tetsujs/core";
202
+
105
203
  // packages/openapi/src/emit.ts
106
204
  import { toJsonSchema } from "@tetsujs/core";
107
205
  var target = { target: "draft-2020-12" };
@@ -192,7 +290,7 @@ function objectProperties(schema, where, warn) {
192
290
  }
193
291
 
194
292
  // packages/openapi/src/operation.ts
195
- function operationOf(entry, options, components, ids, warn) {
293
+ function operationOf(entry, options, components, envelopes, ids, warn) {
196
294
  const schema = entry.def.schema;
197
295
  const parameters = [
198
296
  ...pathParameters(entry.path, schema?.params, warn),
@@ -211,9 +309,36 @@ function operationOf(entry, options, components, ids, warn) {
211
309
  ...parameters.length > 0 ? { parameters } : {},
212
310
  ...body ? { requestBody: body } : {},
213
311
  ...contributed.security.length > 0 ? { security: securityOf(contributed.conditions) } : {},
214
- responses: responses(entry, options, contributed, components, warn)
312
+ responses: responses(entry, options, contributed, components, envelopes, warn)
215
313
  };
216
314
  }
315
+ function declareEnvelopes(entry, envelopes) {
316
+ for (const [status, schema] of declaredResponses(entry)) {
317
+ const described = schema === null ? undefined : emitted(schema, "", () => {});
318
+ for (const branch of described ? branchesOf(described) : []) {
319
+ const code = envelopeCode(branch);
320
+ if (code !== undefined) {
321
+ envelopes.declare(Number(status), code, branch);
322
+ }
323
+ }
324
+ }
325
+ }
326
+ function declaredResponses(entry) {
327
+ const response = entry.def.schema?.response;
328
+ if (!response) {
329
+ return [];
330
+ }
331
+ if ("~standard" in response) {
332
+ return [["200", response]];
333
+ }
334
+ return Object.entries(response);
335
+ }
336
+ function branchesFor(schema, status, envelopes, warn) {
337
+ return branchesOf(schema).map((branch) => {
338
+ const code = envelopeCode(branch);
339
+ return code === undefined ? branch : envelopes.ref(status, code, branch, warn);
340
+ });
341
+ }
217
342
  function securityOf(conditions) {
218
343
  let combinations = [{}];
219
344
  for (const alternatives of conditions) {
@@ -317,94 +442,147 @@ function carriesBinary(root) {
317
442
  }
318
443
  return false;
319
444
  }
320
- function responses(entry, options, contributed, components, warn) {
321
- const declared = {};
322
- const response = entry.def.schema?.response;
323
- if (response && "~standard" in response) {
324
- declared["200"] = jsonResponse(response, "Successful response", warn);
325
- } else if (response) {
326
- for (const [status, schema] of Object.entries(response)) {
327
- declared[status] = schema === null ? { description: describeStatus(status) } : jsonResponse(schema, describeStatus(status), warn);
328
- }
445
+ function responses(entry, options, contributed, components, envelopes, warn) {
446
+ const answers = new Map;
447
+ const add = (status, answer) => {
448
+ answers.set(status, [...answers.get(status) ?? [], answer]);
449
+ };
450
+ for (const [status, schema] of declaredResponses(entry)) {
451
+ const described = schema === null ? undefined : emitted(schema, "a response", warn);
452
+ add(status, {
453
+ description: describeStatus(status),
454
+ placeholder: true,
455
+ schemas: described ? branchesFor(described, Number(status), envelopes, warn) : []
456
+ });
329
457
  }
330
- if (!declared["200"] && !hasSuccess(declared)) {
331
- declared["200"] = { description: "Successful response" };
458
+ if (![...answers.keys()].some((status) => status.startsWith("2"))) {
459
+ add("200", {
460
+ description: "Successful response",
461
+ placeholder: true,
462
+ schemas: []
463
+ });
332
464
  }
333
- return {
334
- ...declared,
335
- ...frameworkFailures(entry, options, contributed, declared, components, warn)
336
- };
465
+ for (const [status, answer] of failures(entry, options, contributed, components, envelopes, warn)) {
466
+ add(String(status), answer);
467
+ }
468
+ const described = {};
469
+ for (const [status, list] of answers) {
470
+ described[status] = merge(list, envelopes);
471
+ }
472
+ return described;
337
473
  }
338
- function frameworkFailures(entry, options, contributed, declared, components, warn) {
474
+ function failures(entry, options, contributed, components, envelopes, warn) {
339
475
  const schema = entry.def.schema;
340
- const failures = new Map;
341
- const add = (status, failure) => {
342
- const key = String(status);
343
- failures.set(key, [...failures.get(key) ?? [], failure]);
476
+ const found = [];
477
+ const envelopeRef = (status, error, message, fields) => {
478
+ const described = envelope(status, error, message, fields);
479
+ const ref = error === undefined ? components.ref(failureName(status), described) : envelopes.ref(status, error, described, warn);
480
+ return ref;
344
481
  };
345
- const envelopeRef = (status, error, message) => components.ref(failureName(status, error), envelope(status, error, message));
346
482
  if (schema?.params || schema?.query || schema?.headers || schema?.body) {
347
- add(options.validationStatus, {
348
- description: "Request failed schema validation",
349
- schema: components.ref(failureName(options.validationStatus, "VALIDATION_FAILED"), validationFailed(options.validationStatus))
350
- });
483
+ found.push([
484
+ options.validationStatus,
485
+ {
486
+ description: "Request failed schema validation",
487
+ schemas: [
488
+ envelopes.ref(options.validationStatus, "VALIDATION_FAILED", validationFailed(options.validationStatus), warn)
489
+ ]
490
+ }
491
+ ]);
351
492
  }
352
493
  for (const requirement of contributed.security) {
353
494
  const status = requirement.status ?? 401;
354
- add(status, {
355
- description: requirement.description ?? `Request did not satisfy ${requirement.name}`,
356
- schema: envelopeRef(status, requirement.error, requirement.message)
357
- });
495
+ found.push([
496
+ status,
497
+ {
498
+ description: requirement.description ?? `Request did not satisfy ${requirement.name}`,
499
+ schemas: [envelopeRef(status, requirement.error, requirement.message)]
500
+ }
501
+ ]);
358
502
  }
359
503
  for (const response of contributed.responses) {
360
504
  const described = response.schema ? emitted(response.schema, "a hook's response", warn) : undefined;
361
- add(response.status, {
362
- description: response.description,
363
- schema: described ?? envelopeRef(response.status, response.error, response.message)
364
- });
505
+ found.push([
506
+ response.status,
507
+ {
508
+ description: response.description,
509
+ schemas: described ? branchesFor(described, response.status, envelopes, warn) : [
510
+ envelopeRef(response.status, response.error, response.message, response.fields)
511
+ ],
512
+ ...response.headers ? { headers: response.headers } : {}
513
+ }
514
+ ]);
365
515
  }
366
516
  if (schema?.body || entry.def.bodyType) {
367
517
  const unparsable = parseFailure(entry.def.bodyType);
368
518
  if (unparsable) {
369
- add(400, {
370
- description: "Body could not be parsed in the declared shape",
371
- schema: envelopeRef(400, unparsable.error, unparsable.message)
372
- });
519
+ found.push([
520
+ 400,
521
+ {
522
+ description: "Body could not be parsed in the declared shape",
523
+ schemas: [envelopeRef(400, unparsable.error, unparsable.message)]
524
+ }
525
+ ]);
526
+ }
527
+ found.push([
528
+ 413,
529
+ {
530
+ description: "Body exceeded the configured size limit",
531
+ schemas: [
532
+ envelopeRef(413, "BODY_TOO_LARGE", "Body exceeds the configured limit")
533
+ ]
534
+ }
535
+ ]);
536
+ }
537
+ found.push([
538
+ 500,
539
+ {
540
+ description: "The request failed and nothing mapped the failure",
541
+ schemas: [
542
+ envelopeRef(500, "INTERNAL_SERVER_ERROR", "Internal Server Error")
543
+ ]
544
+ }
545
+ ]);
546
+ return found;
547
+ }
548
+ function merge(list, envelopes) {
549
+ const schemas = distinct(list.flatMap((answer) => answer.schemas));
550
+ const worded = list.filter((answer) => !answer.placeholder);
551
+ const headers = {};
552
+ for (const answer of list) {
553
+ for (const [name, header] of Object.entries(answer.headers ?? {})) {
554
+ headers[name] ??= header;
373
555
  }
374
- add(413, {
375
- description: "Body exceeded the configured size limit",
376
- schema: envelopeRef(413, "BODY_TOO_LARGE", "Body exceeds the configured limit")
377
- });
378
- }
379
- add(500, {
380
- description: "The request failed and nothing mapped the failure",
381
- schema: envelopeRef(500, "INTERNAL_SERVER_ERROR", "Internal Server Error")
382
- });
383
- const responses = {};
384
- for (const [status, list] of failures) {
385
- responses[status] = merge(declared[status], list);
386
556
  }
387
- return responses;
557
+ return {
558
+ description: (worded.length > 0 ? worded : list).map((answer) => answer.description).join("; "),
559
+ ...Object.keys(headers).length > 0 ? { headers } : {},
560
+ ...schemas.length > 0 ? {
561
+ content: {
562
+ "application/json": { schema: unionOf(schemas, envelopes) }
563
+ }
564
+ } : {}
565
+ };
388
566
  }
389
- function merge(declared, list) {
390
- const descriptions = [
391
- ...declared ? [declared.description] : [],
392
- ...list.map((failure) => failure.description)
393
- ];
394
- const schemas = distinct([
395
- ...declaredSchemas(declared),
396
- ...list.map((failure) => failure.schema)
397
- ]);
567
+ function unionOf(schemas, envelopes) {
398
568
  const [only] = schemas;
569
+ if (schemas.length === 1 && only) {
570
+ return only;
571
+ }
572
+ const mapping = {};
573
+ for (const schema of schemas) {
574
+ const ref = schema.$ref;
575
+ const code = typeof ref === "string" ? envelopes.codeOf(ref) : undefined;
576
+ if (typeof ref !== "string" || code === undefined) {
577
+ return { anyOf: schemas };
578
+ }
579
+ mapping[code] = ref;
580
+ }
399
581
  return {
400
- description: descriptions.join("; "),
401
- ...schemas.length === 1 && only ? { content: { "application/json": { schema: only } } } : { content: { "application/json": { schema: { anyOf: schemas } } } }
582
+ anyOf: schemas,
583
+ discriminator: { propertyName: "error", mapping }
402
584
  };
403
585
  }
404
- function declaredSchemas(declared) {
405
- const schema = declared?.content?.["application/json"]?.schema;
406
- return schema ? [schema] : [];
407
- }
408
586
  function distinct(schemas) {
409
587
  const seen = new Set;
410
588
  return schemas.filter((schema) => {
@@ -416,34 +594,23 @@ function distinct(schemas) {
416
594
  return true;
417
595
  });
418
596
  }
419
- function jsonResponse(schema, description, warn) {
420
- const described = emitted(schema, "a response", warn);
421
- if (!described) {
422
- return { description };
423
- }
424
- return {
425
- description,
426
- content: { "application/json": { schema: described } }
427
- };
428
- }
429
- function hasSuccess(declared) {
430
- return Object.keys(declared).some((status) => status.startsWith("2"));
431
- }
432
597
  function describeStatus(status) {
433
598
  if (status === "204") {
434
599
  return "No content";
435
600
  }
436
- return status.startsWith("2") ? "Successful response" : `Response ${status}`;
601
+ return status.startsWith("2") ? "Successful response" : errorBody(Number(status)).message;
437
602
  }
438
- function envelope(status, error, message) {
603
+ function envelope(status, error, message, fields = {}) {
604
+ const own = {
605
+ status: { type: "integer", const: status },
606
+ message: message ? { type: "string", examples: [message] } : { type: "string" },
607
+ error: error ? { type: "string", const: error } : { type: "string" }
608
+ };
609
+ const added = Object.keys(fields).filter((name) => !Object.hasOwn(own, name));
439
610
  return {
440
611
  type: "object",
441
- required: ["status", "message", "error"],
442
- properties: {
443
- status: { type: "integer", const: status },
444
- message: message ? { type: "string", examples: [message] } : { type: "string" },
445
- error: error ? { type: "string", const: error } : { type: "string" }
446
- }
612
+ required: ["status", "message", "error", ...added],
613
+ properties: { ...fields, ...own }
447
614
  };
448
615
  }
449
616
  function validationFailed(status) {
@@ -485,14 +652,24 @@ import { onMount, route } from "@tetsujs/core";
485
652
  // packages/openapi/src/page.ts
486
653
  var defaults = {
487
654
  scalar: {
488
- script: "https://cdn.jsdelivr.net/npm/@scalar/api-reference"
655
+ script: "https://cdn.jsdelivr.net/npm/@scalar/api-reference@1.72.1/dist/browser/standalone.js",
656
+ integrity: {
657
+ script: "sha384-U11tb2XnKvmwt8RlTvnwUnYgrN+ur4Xyh9htLhjajWNR/Oyl5AX5DEz00qRmlrmK"
658
+ }
489
659
  },
490
660
  "swagger-ui": {
491
- script: "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js",
492
- style: "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css"
661
+ script: "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.33.0/swagger-ui-bundle.js",
662
+ style: "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.33.0/swagger-ui.css",
663
+ integrity: {
664
+ script: "sha384-YDALVcy8kj8yltLBVi1vBiBAUqdxvus673gM8XKwiy6aDUJFXivF/KCufekjYbVf",
665
+ style: "sha384-Ov4/wv3j2bmct8cDc5X4ngJZohVPzEmc6uDPH8WeljUxO5vtoykvMEfbu9Vh6RaW"
666
+ }
493
667
  },
494
668
  redoc: {
495
- script: "https://cdn.jsdelivr.net/npm/redoc@2/bundles/redoc.standalone.js"
669
+ script: "https://cdn.jsdelivr.net/npm/redoc@2.5.4/bundles/redoc.standalone.js",
670
+ integrity: {
671
+ script: "sha384-w447zOpYfw/1Tv/5AK9NfHTlQIqE3RVR6KY62jCyy9zNDgO64cMwGGP1Fj0zJVf5"
672
+ }
496
673
  }
497
674
  };
498
675
  function docsPage(options) {
@@ -506,11 +683,11 @@ function docsPage(options) {
506
683
  '<meta charset="utf-8" />',
507
684
  '<meta name="viewport" content="width=device-width, initial-scale=1" />',
508
685
  `<title>${title}</title>`,
509
- assets.style ? `<link rel="stylesheet" href="${escapeHtml(assets.style)}" />` : "",
686
+ assets.style ? `<link rel="stylesheet" href="${escapeHtml(assets.style)}"${verified(assets.integrity?.style)} />` : "",
510
687
  "</head>",
511
688
  "<body>",
512
689
  body(options.ui, url),
513
- `<script src="${escapeHtml(assets.script)}"></script>`,
690
+ `<script src="${escapeHtml(assets.script)}"${verified(assets.integrity?.script)}></script>`,
514
691
  bootstrap(options.ui, url),
515
692
  "</body>",
516
693
  "</html>"
@@ -537,6 +714,9 @@ function bootstrap(ui, url) {
537
714
  ].join(`
538
715
  `);
539
716
  }
717
+ function verified(integrity) {
718
+ return integrity ? ` integrity="${escapeHtml(integrity)}" crossorigin="anonymous"` : "";
719
+ }
540
720
  function escapeHtml(value) {
541
721
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
542
722
  }
@@ -547,7 +727,8 @@ class DocsController {
547
727
  json;
548
728
  ui;
549
729
  document;
550
- constructor(options, documentPath, uiPath, page) {
730
+ page;
731
+ constructor(options, documentPath, uiPath) {
551
732
  this.options = options;
552
733
  const hidden = !options.documentSelf;
553
734
  const json = route({
@@ -560,12 +741,12 @@ class DocsController {
560
741
  method: "GET",
561
742
  path: "/docs",
562
743
  docs: { summary: "Rendered documentation", tags: ["docs"], hidden },
563
- handler: () => new Response(page, {
744
+ handler: () => new Response(this.page, {
564
745
  headers: { "content-type": "text/html; charset=utf-8" }
565
746
  })
566
747
  });
567
748
  this.json = { ...json, path: documentPath };
568
- this.ui = { ...ui, path: uiPath };
749
+ this.ui = options.ui === false ? undefined : { ...ui, path: uiPath };
569
750
  }
570
751
  [onMount](app) {
571
752
  const generated = openapi(app, {
@@ -573,6 +754,17 @@ class DocsController {
573
754
  ...this.options.servers ? { servers: this.options.servers } : {}
574
755
  });
575
756
  this.document = generated.document;
757
+ const ui = this.options.ui ?? "scalar";
758
+ const json = this.json;
759
+ const served = app.entries.find((entry) => entry.def === json);
760
+ if (ui !== false && served) {
761
+ this.page = docsPage({
762
+ ui,
763
+ documentUrl: served.path,
764
+ title: this.options.title ?? this.options.info.title,
765
+ ...this.options.assets ? { assets: this.options.assets } : {}
766
+ });
767
+ }
576
768
  const report = this.options.onWarning ?? warn;
577
769
  for (const warning of generated.warnings) {
578
770
  report(warning);
@@ -580,15 +772,7 @@ class DocsController {
580
772
  }
581
773
  }
582
774
  function docs(options) {
583
- const documentPath = options.path ?? "/openapi.json";
584
- const uiPath = options.uiPath ?? "/docs";
585
- const page = docsPage({
586
- ui: options.ui ?? "scalar",
587
- documentUrl: documentPath,
588
- title: options.title ?? options.info.title,
589
- ...options.assets ? { assets: options.assets } : {}
590
- });
591
- return new DocsController(options, documentPath, uiPath, page);
775
+ return new DocsController(options, options.path ?? "/openapi.json", options.uiPath ?? "/docs");
592
776
  }
593
777
  function warn(warning) {
594
778
  console.warn(`[openapi] ${warning.route}: ${warning.message}`);
@@ -600,15 +784,17 @@ function openapi(app, options) {
600
784
  const warnings = [];
601
785
  const schemes = {};
602
786
  const components = schemaComponents();
787
+ const envelopes2 = envelopes(components);
603
788
  const ids = operationIds();
789
+ const described = app.entries.filter((entry) => !entry.ws && !entry.def.docs?.hidden);
790
+ for (const entry of described) {
791
+ declareEnvelopes(entry, envelopes2);
792
+ }
604
793
  const occupied = new Map;
605
- for (const entry of app.entries) {
606
- if (entry.ws || entry.def.docs?.hidden) {
607
- continue;
608
- }
794
+ for (const entry of described) {
609
795
  const template = toTemplate(entry.path);
610
796
  const route = `${entry.method} ${entry.path}`;
611
- const operation = operationOf(entry, app.options, components, ids, (message) => {
797
+ const operation = operationOf(entry, app.options, components, envelopes2, ids, (message) => {
612
798
  warnings.push({ route, message });
613
799
  });
614
800
  for (const requirement of contributionsOf(entry.hooks).security) {
@@ -665,5 +851,5 @@ export {
665
851
  secured
666
852
  };
667
853
 
668
- //# debugId=AFF7EFC95370A0C664756E2164756E21
854
+ //# debugId=8D8E4C09AE6F825C64756E2164756E21
669
855
  //# sourceMappingURL=index.js.map