@trpc/openapi 11.19.0-alpha → 11.21.1-canary.20.10e2ef98f

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/src/generate.ts CHANGED
@@ -12,6 +12,7 @@ import type {
12
12
  OperationObject,
13
13
  PathItemObject,
14
14
  PathsObject,
15
+ ResponseObject,
15
16
  SchemaObject,
16
17
  ServerObject,
17
18
  } from './types';
@@ -21,6 +22,7 @@ interface ProcedureInfo {
21
22
  type: 'query' | 'mutation' | 'subscription';
22
23
  inputSchema: SchemaObject | null;
23
24
  outputSchema: SchemaObject | null;
25
+ errorSchema: SchemaObject | null;
24
26
  description?: string;
25
27
  }
26
28
 
@@ -450,32 +452,87 @@ function convertUnionType(
450
452
  schemas.push({ type: 'null' });
451
453
  }
452
454
 
453
- if (schemas.length === 0) {
455
+ const distinctSchemas = dedupeSchemas(schemas, ctx.schemas);
456
+ if (distinctSchemas.length === 0) {
454
457
  return {};
455
458
  }
456
459
 
457
- const [firstSchema] = schemas;
458
- if (schemas.length === 1 && firstSchema !== undefined) {
460
+ const [firstSchema] = distinctSchemas;
461
+ if (distinctSchemas.length === 1 && firstSchema !== undefined) {
459
462
  return firstSchema;
460
463
  }
461
464
 
462
465
  // When all schemas are simple type-only schemas (no other properties),
463
466
  // collapse into a single `type` array. e.g. string | null → type: ["string", "null"]
464
- if (schemas.every(isSimpleTypeSchema)) {
465
- return { type: schemas.map((s) => s.type as string) };
467
+ if (distinctSchemas.every(isSimpleTypeSchema)) {
468
+ return { type: distinctSchemas.map((s) => s.type as string) };
466
469
  }
467
470
 
468
471
  // Detect discriminated unions: all oneOf members are objects sharing a common
469
472
  // required property whose value is a `const`. If found, add a `discriminator`.
470
- const discriminatorProp = detectDiscriminatorProperty(schemas);
473
+ const discriminatorProp = detectDiscriminatorProperty(distinctSchemas);
471
474
  if (discriminatorProp) {
472
475
  return {
473
- oneOf: schemas,
476
+ oneOf: distinctSchemas,
474
477
  discriminator: { propertyName: discriminatorProp },
475
478
  };
476
479
  }
477
480
 
478
- return { oneOf: schemas };
481
+ return { oneOf: distinctSchemas };
482
+ }
483
+
484
+ function dedupeSchemas(
485
+ schemas: SchemaObject[],
486
+ components: Record<string, SchemaObject>,
487
+ ): SchemaObject[] {
488
+ const seen = new Set<string>();
489
+
490
+ return schemas.filter((schema) => {
491
+ const key = canonicalize(resolveBareRef(schema, components));
492
+ if (seen.has(key)) {
493
+ return false;
494
+ }
495
+ seen.add(key);
496
+ return true;
497
+ });
498
+ }
499
+
500
+ /**
501
+ * A `$ref` on its own describes exactly the component it points at, so compare
502
+ * through the component - otherwise the same shape reached both by reference
503
+ * and inline looks like two distinct schemas.
504
+ */
505
+ function resolveBareRef(
506
+ schema: SchemaObject,
507
+ components: Record<string, SchemaObject>,
508
+ ): SchemaObject {
509
+ if (!isBareRef(schema)) {
510
+ return schema;
511
+ }
512
+ const target = getReferencedSchema(schema, components);
513
+ // A component mid-conversion is still an empty placeholder (cyclic types);
514
+ // compare those by their `$ref` so distinct ones stay distinct.
515
+ return target && isNonEmptySchema(target) ? target : schema;
516
+ }
517
+
518
+ /** A `$ref` with no sibling keys, i.e. one that stands for its component. */
519
+ function isBareRef(schema: SchemaObject): boolean {
520
+ return schema.$ref !== undefined && Object.keys(schema).length === 1;
521
+ }
522
+
523
+ function canonicalize(value: unknown): string {
524
+ if (Array.isArray(value)) {
525
+ return `[${value.map(canonicalize).join(',')}]`;
526
+ }
527
+
528
+ if (value && typeof value === 'object') {
529
+ const entries = Object.entries(value)
530
+ .filter(([, v]) => v !== undefined)
531
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
532
+ .map(([k, v]) => `${JSON.stringify(k)}:${canonicalize(v)}`);
533
+ return `{${entries.join(',')}}`;
534
+ }
535
+ return JSON.stringify(value) ?? 'undefined';
479
536
  }
480
537
 
481
538
  /**
@@ -872,6 +929,8 @@ interface WalkCtx {
872
929
  schemaCtx: SchemaCtx;
873
930
  /** Runtime descriptions keyed by procedure path (when a router instance is available). */
874
931
  runtimeDescriptions: Map<string, RuntimeDescriptions>;
932
+ /** The router-wide error shape, used to spot procedures that differ from it. */
933
+ routerErrorType: ts.Type | null;
875
934
  }
876
935
 
877
936
  /**
@@ -948,7 +1007,12 @@ function getProcedureInputTypeName(type: ts.Type, path: string): string {
948
1007
  }
949
1008
  }
950
1009
 
951
- const fallbackName = path
1010
+ return `${pascalCasePath(path) || 'Procedure'}Input`;
1011
+ }
1012
+
1013
+ /** `user.getById` → `UserGetById`, for naming components after a procedure. */
1014
+ function pascalCasePath(path: string): string {
1015
+ return path
952
1016
  .split('.')
953
1017
  .filter(Boolean)
954
1018
  .map((segment) =>
@@ -959,8 +1023,6 @@ function getProcedureInputTypeName(type: ts.Type, path: string): string {
959
1023
  .join(''),
960
1024
  )
961
1025
  .join('');
962
-
963
- return `${fallbackName || 'Procedure'}Input`;
964
1026
  }
965
1027
 
966
1028
  function isUnknownLikeType(type: ts.Type): boolean {
@@ -1142,10 +1204,64 @@ function extractProcedure(def: ProcedureDef, ctx: WalkCtx): void {
1142
1204
  type: def.typeName as 'query' | 'mutation' | 'subscription',
1143
1205
  inputSchema,
1144
1206
  outputSchema,
1207
+ errorSchema: extractProcedureErrorSchema($typesType, ctx),
1145
1208
  description: def.description,
1146
1209
  });
1147
1210
  }
1148
1211
 
1212
+ function extractProcedureErrorSchema(
1213
+ $typesType: ts.Type,
1214
+ ctx: WalkCtx,
1215
+ ): SchemaObject | null {
1216
+ const { schemaCtx } = ctx;
1217
+ const errorSym = $typesType.getProperty('errorShape');
1218
+ if (!errorSym) {
1219
+ return null;
1220
+ }
1221
+
1222
+ const { checker } = schemaCtx;
1223
+ const errorType = checker.getTypeOfSymbol(errorSym);
1224
+ if (
1225
+ hasFlag(errorType, ts.TypeFlags.Any) ||
1226
+ isUnknownLikeType(errorType) ||
1227
+ addsNothingToRouterErrorType(errorType, ctx.routerErrorType, checker)
1228
+ ) {
1229
+ return null;
1230
+ }
1231
+
1232
+ const schema = typeToJsonSchema(errorType, schemaCtx);
1233
+ return isNonEmptySchema(schema) ? schema : null;
1234
+ }
1235
+
1236
+ function addsNothingToRouterErrorType(
1237
+ errorType: ts.Type,
1238
+ routerErrorType: ts.Type | null,
1239
+ checker: ts.TypeChecker,
1240
+ ): boolean {
1241
+ if (!routerErrorType) {
1242
+ return false;
1243
+ }
1244
+ if (errorType === routerErrorType) {
1245
+ return true;
1246
+ }
1247
+
1248
+ const describe = (type: ts.Type) =>
1249
+ checker.typeToString(
1250
+ type,
1251
+ undefined,
1252
+ ts.TypeFormatFlags.NoTruncation |
1253
+ ts.TypeFormatFlags.UseFullyQualifiedType,
1254
+ );
1255
+ const unionMembers = (type: ts.Type) =>
1256
+ type.isUnion() ? type.types : [type];
1257
+
1258
+ const routerMembers = new Set(unionMembers(routerErrorType).map(describe));
1259
+
1260
+ return unionMembers(errorType).every((member) =>
1261
+ routerMembers.has(describe(member)),
1262
+ );
1263
+ }
1264
+
1149
1265
  /** Extract the JSDoc comment text from a symbol, if any. */
1150
1266
  function getJsDocComment(
1151
1267
  sym: ts.Symbol,
@@ -1337,11 +1453,10 @@ function loadCompilerOptions(startDir: string): ts.CompilerOptions {
1337
1453
  * it to a JSON Schema. Returns `null` when the path cannot be resolved
1338
1454
  * (e.g. older tRPC versions or missing type info).
1339
1455
  */
1340
- function extractErrorSchema(
1456
+ function getRouterErrorType(
1341
1457
  routerType: ts.Type,
1342
1458
  checker: ts.TypeChecker,
1343
- schemaCtx: SchemaCtx,
1344
- ): SchemaObject | null {
1459
+ ): ts.Type | null {
1345
1460
  const walk = (type: ts.Type, keys: string[]): ts.Type | null => {
1346
1461
  const [head, ...rest] = keys;
1347
1462
  if (!head) {
@@ -1360,11 +1475,20 @@ function extractErrorSchema(
1360
1475
  '$types',
1361
1476
  'errorShape',
1362
1477
  ]);
1363
- if (!errorShapeType) {
1478
+ if (!errorShapeType || hasFlag(errorShapeType, ts.TypeFlags.Any)) {
1364
1479
  return null;
1365
1480
  }
1366
1481
 
1367
- if (hasFlag(errorShapeType, ts.TypeFlags.Any)) {
1482
+ return errorShapeType;
1483
+ }
1484
+
1485
+ function extractErrorSchema(
1486
+ routerType: ts.Type,
1487
+ checker: ts.TypeChecker,
1488
+ schemaCtx: SchemaCtx,
1489
+ ): SchemaObject | null {
1490
+ const errorShapeType = getRouterErrorType(routerType, checker);
1491
+ if (!errorShapeType) {
1368
1492
  return null;
1369
1493
  }
1370
1494
 
@@ -1375,6 +1499,14 @@ function extractErrorSchema(
1375
1499
  // OpenAPI document builder
1376
1500
  // ---------------------------------------------------------------------------
1377
1501
 
1502
+ function wrapInErrorEnvelope(errorSchema: SchemaObject): SchemaObject {
1503
+ return {
1504
+ type: 'object',
1505
+ properties: { error: errorSchema },
1506
+ required: ['error'],
1507
+ };
1508
+ }
1509
+
1378
1510
  /** Fallback error schema when the router type doesn't expose an error shape. */
1379
1511
  const DEFAULT_ERROR_SCHEMA: SchemaObject = {
1380
1512
  type: 'object',
@@ -1418,6 +1550,7 @@ function wrapInSuccessEnvelope(
1418
1550
  function buildProcedureOperation(
1419
1551
  proc: ProcedureInfo,
1420
1552
  method: 'get' | 'post',
1553
+ errorResponseRef: string,
1421
1554
  ): OperationObject {
1422
1555
  const [tag = proc.path] = proc.path.split('.');
1423
1556
  const operation: OperationObject = {
@@ -1433,7 +1566,7 @@ function buildProcedureOperation(
1433
1566
  },
1434
1567
  },
1435
1568
  },
1436
- default: { $ref: '#/components/responses/Error' },
1569
+ default: { $ref: errorResponseRef },
1437
1570
  },
1438
1571
  };
1439
1572
 
@@ -1463,18 +1596,85 @@ function buildProcedureOperation(
1463
1596
  return operation;
1464
1597
  }
1465
1598
 
1599
+ function buildErrorResponse(schema: SchemaObject): ResponseObject {
1600
+ return {
1601
+ description: 'Error response',
1602
+ content: {
1603
+ 'application/json': { schema: wrapInErrorEnvelope(schema) },
1604
+ },
1605
+ };
1606
+ }
1607
+
1608
+ function buildErrorResponses(
1609
+ procedures: ProcedureInfo[],
1610
+ routerErrorSchema: SchemaObject,
1611
+ schemas: Record<string, SchemaObject>,
1612
+ ): {
1613
+ responses: Record<string, ResponseObject>;
1614
+ refByProcedurePath: Map<string, string>;
1615
+ } {
1616
+ const responses: Record<string, ResponseObject> = {
1617
+ Error: buildErrorResponse(routerErrorSchema),
1618
+ };
1619
+ const shapeKey = (schema: SchemaObject) =>
1620
+ canonicalize(resolveBareRef(schema, schemas));
1621
+ const refByShape = new Map<string, string>([
1622
+ [shapeKey(routerErrorSchema), '#/components/responses/Error'],
1623
+ ]);
1624
+ const refByProcedurePath = new Map<string, string>();
1625
+
1626
+ for (const proc of procedures) {
1627
+ if (!proc.errorSchema) {
1628
+ continue;
1629
+ }
1630
+
1631
+ const key = shapeKey(proc.errorSchema);
1632
+ let ref = refByShape.get(key);
1633
+ if (!ref) {
1634
+ // A shape that already is a component needs no second name for it.
1635
+ const name = `${pascalCasePath(proc.path) || 'Procedure'}Error`;
1636
+ const shape = isBareRef(proc.errorSchema)
1637
+ ? proc.errorSchema
1638
+ : registerSchema(`${name}Shape`, proc.errorSchema, schemas);
1639
+ const responseName = ensureUniqueName(name, responses);
1640
+ responses[responseName] = buildErrorResponse(shape);
1641
+ ref = `#/components/responses/${responseName}`;
1642
+ refByShape.set(key, ref);
1643
+ }
1644
+ refByProcedurePath.set(proc.path, ref);
1645
+ }
1646
+
1647
+ return { responses, refByProcedurePath };
1648
+ }
1649
+
1650
+ function registerSchema(
1651
+ name: string,
1652
+ schema: SchemaObject,
1653
+ schemas: Record<string, SchemaObject>,
1654
+ ): SchemaObject {
1655
+ const refName = ensureUniqueName(name, schemas);
1656
+ schemas[refName] = schema;
1657
+ return { $ref: `#/components/schemas/${refName}` };
1658
+ }
1659
+
1466
1660
  function buildOpenAPIDocument(
1467
1661
  procedures: ProcedureInfo[],
1468
1662
  options: GenerateOptions,
1469
1663
  meta: RouterMeta = { errorSchema: null },
1470
1664
  ): Document {
1471
1665
  const paths: PathsObject = {};
1666
+ const included = procedures.filter((proc) =>
1667
+ shouldIncludeProcedureInOpenAPI(proc.type),
1668
+ );
1472
1669
 
1473
- for (const proc of procedures) {
1474
- if (!shouldIncludeProcedureInOpenAPI(proc.type)) {
1475
- continue;
1476
- }
1670
+ const schemas = meta.schemas ?? {};
1671
+ const { responses: errorResponses, refByProcedurePath } = buildErrorResponses(
1672
+ included,
1673
+ meta.errorSchema ?? DEFAULT_ERROR_SCHEMA,
1674
+ schemas,
1675
+ );
1477
1676
 
1677
+ for (const proc of included) {
1478
1678
  const opPath = `/${proc.path}`;
1479
1679
  const method = proc.type === 'query' ? 'get' : 'post';
1480
1680
 
@@ -1483,11 +1683,11 @@ function buildOpenAPIDocument(
1483
1683
  pathItem[method] = buildProcedureOperation(
1484
1684
  proc,
1485
1685
  method,
1686
+ refByProcedurePath.get(proc.path) ?? '#/components/responses/Error',
1486
1687
  ) as PathItemObject[typeof method];
1487
1688
  }
1488
1689
 
1489
- const hasNamedSchemas =
1490
- meta.schemas !== undefined && Object.keys(meta.schemas).length > 0;
1690
+ const hasNamedSchemas = Object.keys(schemas).length > 0;
1491
1691
 
1492
1692
  return {
1493
1693
  openapi: '3.1.1',
@@ -1499,23 +1699,8 @@ function buildOpenAPIDocument(
1499
1699
  ...(options.servers?.length ? { servers: options.servers } : {}),
1500
1700
  paths,
1501
1701
  components: {
1502
- ...(hasNamedSchemas && meta.schemas ? { schemas: meta.schemas } : {}),
1503
- responses: {
1504
- Error: {
1505
- description: 'Error response',
1506
- content: {
1507
- 'application/json': {
1508
- schema: {
1509
- type: 'object',
1510
- properties: {
1511
- error: meta.errorSchema ?? DEFAULT_ERROR_SCHEMA,
1512
- },
1513
- required: ['error'],
1514
- },
1515
- },
1516
- },
1517
- },
1518
- },
1702
+ ...(hasNamedSchemas ? { schemas } : {}),
1703
+ responses: errorResponses,
1519
1704
  },
1520
1705
  };
1521
1706
  }
@@ -1595,6 +1780,7 @@ export async function generateOpenAPIDocument(
1595
1780
  seen: new Set(),
1596
1781
  schemaCtx,
1597
1782
  runtimeDescriptions,
1783
+ routerErrorType: getRouterErrorType(routerType, checker),
1598
1784
  };
1599
1785
  walkType({ type: routerType, ctx: walkCtx, currentPath: '' });
1600
1786