@distilled.cloud/core 1.0.0-rc.12 → 1.0.0-rc.13

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.
Files changed (149) hide show
  1. package/lib/api.d.ts +4 -4
  2. package/lib/api.d.ts.map +1 -1
  3. package/lib/api.js +1 -1
  4. package/lib/api.js.map +1 -1
  5. package/lib/category.js +1 -1
  6. package/lib/category.js.map +1 -1
  7. package/lib/codegen/boolean-string-enums.d.ts +8 -8
  8. package/lib/codegen/boolean-string-enums.d.ts.map +1 -1
  9. package/lib/codegen/boolean-string-enums.js +84 -27
  10. package/lib/codegen/boolean-string-enums.js.map +1 -1
  11. package/lib/codegen/boolean-string-enums.test.js +130 -0
  12. package/lib/codegen/boolean-string-enums.test.js.map +1 -1
  13. package/lib/codegen/cli.js +2 -2
  14. package/lib/codegen/cli.js.map +1 -1
  15. package/lib/codegen/generator.d.ts +20 -3
  16. package/lib/codegen/generator.d.ts.map +1 -1
  17. package/lib/codegen/generator.js +36 -9
  18. package/lib/codegen/generator.js.map +1 -1
  19. package/lib/codegen/generator.test.d.ts +2 -0
  20. package/lib/codegen/generator.test.d.ts.map +1 -0
  21. package/lib/codegen/generator.test.js +126 -0
  22. package/lib/codegen/generator.test.js.map +1 -0
  23. package/lib/codegen/graphql-client.d.ts +7 -1
  24. package/lib/codegen/graphql-client.d.ts.map +1 -1
  25. package/lib/codegen/graphql-client.js +262 -125
  26. package/lib/codegen/graphql-client.js.map +1 -1
  27. package/lib/codegen/graphql-client.test.js +104 -236
  28. package/lib/codegen/graphql-client.test.js.map +1 -1
  29. package/lib/codegen/openapi-binary.test.d.ts +2 -0
  30. package/lib/codegen/openapi-binary.test.d.ts.map +1 -0
  31. package/lib/codegen/openapi-binary.test.js +67 -0
  32. package/lib/codegen/openapi-binary.test.js.map +1 -0
  33. package/lib/codegen/openapi.d.ts +7 -1
  34. package/lib/codegen/openapi.d.ts.map +1 -1
  35. package/lib/codegen/openapi.js +15 -4
  36. package/lib/codegen/openapi.js.map +1 -1
  37. package/lib/codegen/patches.d.ts +16 -1
  38. package/lib/codegen/patches.d.ts.map +1 -1
  39. package/lib/codegen/patches.js +70 -4
  40. package/lib/codegen/patches.js.map +1 -1
  41. package/lib/codegen/patches.test.js +48 -2
  42. package/lib/codegen/patches.test.js.map +1 -1
  43. package/lib/codegen/untagged-unions.test.d.ts +2 -0
  44. package/lib/codegen/untagged-unions.test.d.ts.map +1 -0
  45. package/lib/codegen/untagged-unions.test.js +80 -0
  46. package/lib/codegen/untagged-unions.test.js.map +1 -0
  47. package/lib/errors.d.ts +2 -1
  48. package/lib/errors.d.ts.map +1 -1
  49. package/lib/errors.js +5 -2
  50. package/lib/errors.js.map +1 -1
  51. package/lib/fixtures/xml/upstream-validator.json +397 -0
  52. package/lib/graphql.d.ts +296 -205
  53. package/lib/graphql.d.ts.map +1 -1
  54. package/lib/graphql.js +968 -631
  55. package/lib/graphql.js.map +1 -1
  56. package/lib/graphql.test.js +134 -763
  57. package/lib/graphql.test.js.map +1 -1
  58. package/lib/graphql.types.d.ts +10 -0
  59. package/lib/graphql.types.d.ts.map +1 -1
  60. package/lib/graphql.types.js +4 -44
  61. package/lib/graphql.types.js.map +1 -1
  62. package/lib/index.d.ts +27 -0
  63. package/lib/index.d.ts.map +1 -0
  64. package/lib/index.js +27 -0
  65. package/lib/index.js.map +1 -0
  66. package/lib/pagination.d.ts +2 -1
  67. package/lib/pagination.d.ts.map +1 -1
  68. package/lib/pagination.js +24 -16
  69. package/lib/pagination.js.map +1 -1
  70. package/lib/pagination.test.d.ts +2 -0
  71. package/lib/pagination.test.d.ts.map +1 -0
  72. package/lib/pagination.test.js +134 -0
  73. package/lib/pagination.test.js.map +1 -0
  74. package/lib/protocol-http.d.ts +12 -17
  75. package/lib/protocol-http.d.ts.map +1 -1
  76. package/lib/protocol-http.js +100 -45
  77. package/lib/protocol-http.js.map +1 -1
  78. package/lib/protocol-http.test.js +130 -0
  79. package/lib/protocol-http.test.js.map +1 -1
  80. package/lib/protocol-rest.d.ts +18 -1
  81. package/lib/protocol-rest.d.ts.map +1 -1
  82. package/lib/protocol-rest.js +24 -4
  83. package/lib/protocol-rest.js.map +1 -1
  84. package/lib/query.d.ts +29 -0
  85. package/lib/query.d.ts.map +1 -0
  86. package/lib/query.js +27 -0
  87. package/lib/query.js.map +1 -0
  88. package/lib/response-validation.d.ts +67 -0
  89. package/lib/response-validation.d.ts.map +1 -0
  90. package/lib/response-validation.js +76 -0
  91. package/lib/response-validation.js.map +1 -0
  92. package/lib/response-validation.test.d.ts +2 -0
  93. package/lib/response-validation.test.d.ts.map +1 -0
  94. package/lib/response-validation.test.js +67 -0
  95. package/lib/response-validation.test.js.map +1 -0
  96. package/lib/testing.d.ts +39 -0
  97. package/lib/testing.d.ts.map +1 -0
  98. package/lib/testing.js +41 -0
  99. package/lib/testing.js.map +1 -0
  100. package/lib/trait.d.ts +29 -9
  101. package/lib/trait.d.ts.map +1 -1
  102. package/lib/trait.js +8 -0
  103. package/lib/trait.js.map +1 -1
  104. package/lib/xml.d.ts +44 -0
  105. package/lib/xml.d.ts.map +1 -0
  106. package/lib/xml.js +315 -0
  107. package/lib/xml.js.map +1 -0
  108. package/lib/xml.test.d.ts +2 -0
  109. package/lib/xml.test.d.ts.map +1 -0
  110. package/lib/xml.test.js +211 -0
  111. package/lib/xml.test.js.map +1 -0
  112. package/package.json +4 -4
  113. package/src/api.ts +4 -4
  114. package/src/category.ts +1 -1
  115. package/src/codegen/boolean-string-enums.test.ts +147 -0
  116. package/src/codegen/boolean-string-enums.ts +84 -27
  117. package/src/codegen/cli.ts +2 -2
  118. package/src/codegen/generator.test.ts +151 -0
  119. package/src/codegen/generator.ts +70 -12
  120. package/src/codegen/graphql-client.test.ts +116 -283
  121. package/src/codegen/graphql-client.ts +326 -169
  122. package/src/codegen/openapi-binary.test.ts +98 -0
  123. package/src/codegen/openapi.ts +26 -4
  124. package/src/codegen/patches.test.ts +66 -1
  125. package/src/codegen/patches.ts +84 -4
  126. package/src/codegen/untagged-unions.test.ts +89 -0
  127. package/src/errors.ts +5 -2
  128. package/src/fixtures/xml/upstream-validator.json +397 -0
  129. package/src/graphql.test.ts +174 -948
  130. package/src/graphql.ts +1592 -1167
  131. package/src/graphql.types.ts +92 -163
  132. package/src/index.ts +26 -0
  133. package/src/pagination.test.ts +191 -0
  134. package/src/pagination.ts +35 -25
  135. package/src/protocol-http.test.ts +163 -0
  136. package/src/protocol-http.ts +122 -46
  137. package/src/protocol-rest.ts +62 -13
  138. package/src/query.ts +39 -0
  139. package/src/response-validation.test.ts +95 -0
  140. package/src/response-validation.ts +116 -0
  141. package/src/testing.ts +78 -0
  142. package/src/trait.ts +39 -8
  143. package/src/xml.test.ts +269 -0
  144. package/src/xml.ts +339 -0
  145. package/lib/graphql.fixture.d.ts +0 -249
  146. package/lib/graphql.fixture.d.ts.map +0 -1
  147. package/lib/graphql.fixture.js +0 -240
  148. package/lib/graphql.fixture.js.map +0 -1
  149. package/src/graphql.fixture.ts +0 -371
@@ -206,114 +206,236 @@ export interface GenerateGraphQLClientOptions {
206
206
  readonly requirementsImport: string;
207
207
  /** Optional legacy-name aliases, e.g. createProject -> projectCreate. */
208
208
  readonly operationAliases?: Readonly<Record<string, string>>;
209
+ /** Export name for the root object (`Railway`, `Eas`, …). Default `"Sdk"`. */
210
+ readonly sdkName?: string;
209
211
  }
210
212
 
211
- /** Emit schema-indexed types and the matching runtime graph from one patched model. */
213
+ const peelRef = (
214
+ ref: string,
215
+ ): { name: string; list: boolean; nonNull: boolean } => {
216
+ let text = ref;
217
+ let nonNull = false;
218
+ let list = false;
219
+ if (text.endsWith("!")) {
220
+ nonNull = true;
221
+ text = text.slice(0, -1);
222
+ }
223
+ if (text.startsWith("[") && text.endsWith("]")) {
224
+ list = true;
225
+ text = text.slice(1, -1);
226
+ if (text.endsWith("!")) text = text.slice(0, -1);
227
+ }
228
+ return { name: text, list, nonNull };
229
+ };
230
+
231
+ /**
232
+ * Emit a Query-algebra SDK: TypeMeta + TS types + named roots
233
+ * (`me`, `project`, …). Combinators stay in `@distilled.cloud/core/query`;
234
+ * this file only describes the schema as lazy lenses.
235
+ */
212
236
  export const generateGraphQLClient = (
213
237
  model: GraphQLModel,
214
238
  options: GenerateGraphQLClientOptions,
215
239
  ): string => {
216
240
  validateGraphQLModel(model);
217
- const inputType = (ref: string): string => {
218
- if (ref.endsWith("!")) return requiredInput(ref.slice(0, -1));
219
- return `${requiredInput(ref)} | null`;
241
+ const sdkName = options.sdkName ?? "Sdk";
242
+ const skipRoots = new Set(
243
+ [model.queryType, model.mutationType, model.subscriptionType].filter(
244
+ (name): name is string => typeof name === "string",
245
+ ),
246
+ );
247
+ const tsNamed = (name: string): string => {
248
+ const type = model.types[name];
249
+ if (!type) return "unknown";
250
+ if (type.kind === "SCALAR") return type.scalar ?? "unknown";
251
+ if (type.kind === "ENUM")
252
+ return (
253
+ (type.enumValues ?? [])
254
+ .map((value) => JSON.stringify(value))
255
+ .join(" | ") || "string"
256
+ );
257
+ if (type.kind === "UNION")
258
+ return (
259
+ (type.possibleTypes ?? []).filter(Boolean).join(" | ") || "unknown"
260
+ );
261
+ return name;
220
262
  };
221
- const requiredInput = (ref: string): string => {
222
- if (ref.startsWith("["))
223
- return `ReadonlyArray<${inputType(ref.slice(1, -1))}>`;
224
- const type = model.types[ref]!;
225
- return type.kind === "INPUT_OBJECT"
226
- ? `Inputs[${literal(ref)}]`
227
- : `Scalars[${literal(ref)}]`;
263
+ /** Relay `edges { node }` → node type name, else undefined. */
264
+ const connectionNode = (typeName: string): string | undefined => {
265
+ const type = model.types[typeName];
266
+ const edges = type?.fields?.edges;
267
+ if (!edges) return undefined;
268
+ const edgeRef = peelRef(edges.type);
269
+ if (!edgeRef.list) return undefined;
270
+ const node = model.types[edgeRef.name]?.fields?.node;
271
+ if (!node) return undefined;
272
+ const nodeName = peelRef(node.type).name;
273
+ const kind = model.types[nodeName]?.kind;
274
+ if (kind !== "OBJECT" && kind !== "INTERFACE" && kind !== "UNION") {
275
+ return undefined;
276
+ }
277
+ return nodeName;
228
278
  };
229
- const argsType = (args: Record<string, GraphQLArgument>): string =>
230
- `{ ${Object.entries(args)
231
- .map(
232
- ([name, arg]) =>
233
- `${literal(name)}${arg.type.endsWith("!") && arg.defaultValue === undefined ? "" : "?"}: ${inputType(arg.type)}`,
234
- )
235
- .join("; ")} }`;
236
- // GraphQL names are not necessarily valid or unoccupied TypeScript symbols.
237
- // Schema names remain quoted map keys; exported values get stable safe names.
238
- const occupied = new Set([
239
- ..."await break case catch class const continue debugger default delete do else enum export extends false finally for function if import in instanceof new null return super switch this throw true try typeof var void while with yield implements interface let package private protected public static arguments eval abstract any as asserts async bigint boolean constructor declare from get global infer intrinsic is keyof module namespace never number object of override readonly require satisfies set string symbol type undefined unique unknown using".split(
240
- " ",
241
- ),
242
- "G",
243
- "S",
244
- "Category",
245
- "Scalars",
246
- "Inputs",
247
- "Errors",
248
- "Types",
249
- "PossibleTypes",
250
- "Schema",
251
- "Selection",
252
- "Result",
253
- "SelectedErrors",
254
- "GraphQLIssue",
255
- "Report",
256
- "catchTags",
257
- "isErrorTag",
258
- "GraphQLFailure",
259
- "GraphQLTransportError",
260
- "GraphQLDecodeError",
261
- "GraphQLRequestError",
262
- "UnknownGraphQLError",
263
- "query",
264
- "mutation",
265
- "report",
266
- "client",
267
- "schema",
268
- "errorClasses",
269
- options.transportName ?? "transport",
270
- options.requirementsType,
279
+ /** Input type: nullable GraphQL inputs (no `!`) accept `null`. */
280
+ const tsArg = (ref: string): string => {
281
+ const nonNull = ref.endsWith("!");
282
+ const inner = nonNull ? ref.slice(0, -1) : ref;
283
+ const ts =
284
+ inner.startsWith("[") && inner.endsWith("]")
285
+ ? `ReadonlyArray<${tsArg(inner.slice(1, -1))}>`
286
+ : tsNamed(inner);
287
+ return nonNull ? ts : `${ts} | null`;
288
+ };
289
+ /** Response type: nullable GraphQL types (no `!`) include `null`. */
290
+ const tsOutput = (ref: string): string => {
291
+ const nonNull = ref.endsWith("!");
292
+ const inner = nonNull ? ref.slice(0, -1) : ref;
293
+ let ts: string;
294
+ if (inner.startsWith("[") && inner.endsWith("]")) {
295
+ ts = `ReadonlyArray<${tsOutput(inner.slice(1, -1))}>`;
296
+ } else {
297
+ const node = connectionNode(inner);
298
+ ts = node ? `ReadonlyArray<${tsNamed(node)}>` : tsNamed(inner);
299
+ }
300
+ return nonNull ? ts : `${ts} | null`;
301
+ };
302
+ const isObjectLike = (name: string): boolean => {
303
+ const kind = model.types[name]?.kind;
304
+ return kind === "OBJECT" || kind === "INTERFACE";
305
+ };
306
+ const fieldMeta = (fieldName: string, field: GraphQLField): string => {
307
+ const { name, list } = peelRef(field.type);
308
+ const argTypes = Object.keys(field.args).length
309
+ ? `{ ${Object.entries(field.args)
310
+ .map(([argName, arg]) => `${argName}: ${JSON.stringify(arg.type)}`)
311
+ .join(", ")} }`
312
+ : undefined;
313
+ const node = !list ? connectionNode(name) : undefined;
314
+ if (node) {
315
+ return argTypes
316
+ ? `connectionField(${JSON.stringify(fieldName)}, ${node}, ${argTypes})`
317
+ : `connectionField(${JSON.stringify(fieldName)}, ${node})`;
318
+ }
319
+ if (list && isObjectLike(name)) {
320
+ return argTypes
321
+ ? `listField(${JSON.stringify(fieldName)}, ${name}, ${argTypes})`
322
+ : `listField(${JSON.stringify(fieldName)}, ${name})`;
323
+ }
324
+ if (isObjectLike(name) || model.types[name]?.kind === "UNION") {
325
+ return `objectField(${JSON.stringify(fieldName)}, ${name})`;
326
+ }
327
+ return `scalarField(${JSON.stringify(fieldName)})`;
328
+ };
329
+ // Errors: one tagged class per declared error, a runtime spec per class,
330
+ // and per-root error unions (field errors ∪ global errors).
331
+ const errorTags = Object.keys(model.errors).sort();
332
+ const globalErrorType = `${sdkName}GlobalError`;
333
+ const reserved = new Set([
334
+ ...Object.keys(model.types),
335
+ sdkName,
336
+ `${sdkName}Error`,
337
+ globalErrorType,
271
338
  ]);
272
- const identifier = (name: string) =>
273
- name.replace(/[^A-Za-z0-9_$]/g, "_").replace(/^(?=[0-9])/, "_") ||
274
- "generated";
275
- const allocate = (preferred: string, fallback: string): string => {
276
- let name = identifier(preferred);
277
- if (occupied.has(name)) name = identifier(fallback);
278
- const base = name;
279
- for (let suffix = 2; occupied.has(name); suffix++)
280
- name = `${base}${suffix}`;
281
- occupied.add(name);
282
- return name;
339
+ for (const tag of errorTags) {
340
+ if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(tag))
341
+ throw new Error(`GraphQL error tag ${tag} is not a valid identifier`);
342
+ for (const name of [tag, `${tag}Spec`])
343
+ if (reserved.has(name))
344
+ throw new Error(`GraphQL error ${tag} collides with ${name}`);
345
+ }
346
+ const globalErrors = [...model.globalErrors].sort();
347
+ const rootErrors = (field: GraphQLField) => {
348
+ const fieldOnly = [...new Set(field.errors)]
349
+ .filter((tag) => !globalErrors.includes(tag))
350
+ .sort();
351
+ const type = union([...fieldOnly, globalErrorType]);
352
+ const specs = fieldOnly.length
353
+ ? `[${fieldOnly.map((tag) => `${tag}Spec`).join(", ")}, ...globalErrors]`
354
+ : "globalErrors";
355
+ return { type, specs };
283
356
  };
284
- const errorNames = new Map(
285
- Object.keys(model.errors).map((tag) => [
286
- tag,
287
- allocate(tag, `${identifier(tag)}Error`),
288
- ]),
289
- );
357
+ const rootFn = (
358
+ kind: "query" | "mutation",
359
+ fieldName: string,
360
+ field: GraphQLField,
361
+ ): string => {
362
+ const errors = rootErrors(field);
363
+ const { name, list } = peelRef(field.type);
364
+ const node = !list ? connectionNode(name) : undefined;
365
+ const resultTs = tsOutput(field.type);
366
+ const argEntries = Object.entries(field.args);
367
+ const argTypes =
368
+ argEntries.length > 0
369
+ ? `{ ${argEntries.map(([argName, arg]) => `${argName}: ${JSON.stringify(arg.type)}`).join(", ")} }`
370
+ : undefined;
371
+ const required = argEntries.filter(([, arg]) => arg.type.endsWith("!"));
372
+ const optional = argEntries.filter(([, arg]) => !arg.type.endsWith("!"));
373
+ const argFields = [
374
+ ...required.map(
375
+ ([argName, arg]) => `readonly ${argName}: ${tsArg(arg.type)}`,
376
+ ),
377
+ ...optional.map(
378
+ ([argName, arg]) => `readonly ${argName}?: ${tsArg(arg.type)}`,
379
+ ),
380
+ ].join("; ");
381
+ const sig =
382
+ argEntries.length === 0
383
+ ? ""
384
+ : required.length === 0
385
+ ? `args?: { ${argFields} }`
386
+ : `args: { ${argFields} }`;
387
+ const argsExpr = argEntries.length === 0 ? ", undefined" : ", args";
388
+ const argTypesExpr = argTypes ? `, ${argTypes}` : ", undefined";
389
+ const rest = `${argsExpr}${argTypesExpr}, ${errors.specs}`;
390
+ const result = `Query<${resultTs}, ${errors.type}>`;
391
+ const call = (helper: string, target: string) =>
392
+ `${helper}(${JSON.stringify(kind)}, ${JSON.stringify(fieldName)}, ${target}${rest})`;
393
+ const body = node
394
+ ? call("rootConnection", node)
395
+ : isObjectLike(name)
396
+ ? call(list ? "rootList" : "root", name)
397
+ : call("rootLeaf", String(list));
398
+ return ` ${fieldName}: (${sig}): ${result} =>\n ${body},`;
399
+ };
400
+
401
+ const objects: string[] = [];
402
+ const enums: string[] = [];
403
+ const inputs: string[] = [];
404
+ const unions: string[] = [];
405
+ for (const [name, type] of Object.entries(model.types)) {
406
+ if (name.startsWith("__") || skipRoots.has(name)) continue;
407
+ if (type.kind === "OBJECT" || type.kind === "INTERFACE") objects.push(name);
408
+ else if (type.kind === "ENUM") enums.push(name);
409
+ else if (type.kind === "INPUT_OBJECT") inputs.push(name);
410
+ else if (type.kind === "UNION") unions.push(name);
411
+ }
412
+ objects.sort();
413
+ enums.sort();
414
+ inputs.sort();
415
+ unions.sort();
416
+
290
417
  const lines: string[] = [
291
418
  "// Generated by @distilled.cloud/core/codegen/graphql-client. DO NOT EDIT.",
292
- 'import * as G from "@distilled.cloud/core/graphql";',
293
- 'import * as S from "effect/Schema";',
294
- 'import * as Category from "@distilled.cloud/core/category";',
295
- `import { ${options.transportName ?? "transport"} } from ${literal(options.transportImport)};`,
296
- `import type { ${options.requirementsType} } from ${literal(options.requirementsImport)};`,
297
- `export type { ${options.requirementsType} } from ${literal(options.requirementsImport)};`,
298
- 'export { catchTags, isErrorTag, GraphQLFailure, GraphQLTransportError, GraphQLDecodeError, GraphQLRequestError, UnknownGraphQLError, type GraphQLIssue, type Report } from "@distilled.cloud/core/graphql";',
419
+ `import * as S from "effect/Schema";`,
420
+ `import * as Category from "@distilled.cloud/core/category";`,
421
+ `import {`,
422
+ ` connectionField,`,
423
+ ` errorFields,`,
424
+ ` errorSpec,`,
425
+ ` listField,`,
426
+ ` objectField,`,
427
+ ` root,`,
428
+ ` rootConnection,`,
429
+ ` rootLeaf,`,
430
+ ` rootList,`,
431
+ ` scalarField,`,
432
+ ` type ErrorSpec,`,
433
+ ` type Query,`,
434
+ ` type TypeMeta,`,
435
+ `} from "@distilled.cloud/core/graphql";`,
299
436
  "",
300
- "export type Scalars = {",
301
437
  ];
302
- for (const [name, type] of Object.entries(model.types)) {
303
- if (type.kind === "SCALAR" || type.kind === "ENUM")
304
- lines.push(
305
- `${literal(name)}: ${type.kind === "SCALAR" ? (type.scalar ?? "unknown") : union(type.enumValues!.map(literal))};`,
306
- );
307
- }
308
- lines.push("}", "export type Inputs = {");
309
- for (const [name, type] of Object.entries(model.types)) {
310
- if (type.kind === "INPUT_OBJECT")
311
- lines.push(
312
- documentation(type.description),
313
- `${literal(name)}: ${argsType(type.inputFields ?? {})};`,
314
- );
315
- }
316
- lines.push("}");
438
+
317
439
  const categories: Record<string, string> = {
318
440
  auth: "withAuthError",
319
441
  badRequest: "withBadRequestError",
@@ -323,97 +445,132 @@ export const generateGraphQLClient = (
323
445
  server: "withServerError",
324
446
  conflict: "withConflictError",
325
447
  };
326
- for (const [tag, error] of Object.entries(model.errors)) {
327
- const name = errorNames.get(tag)!;
448
+ for (const tag of errorTags) {
449
+ const error = model.errors[tag]!;
450
+ if (error.category && !categories[error.category])
451
+ throw new Error(
452
+ `GraphQL error ${tag}: unknown category ${error.category}`,
453
+ );
328
454
  const pipes = [
329
- error.category
330
- ? categories[error.category] && `Category.${categories[error.category]}`
331
- : undefined,
455
+ error.category ? `Category.${categories[error.category]}` : undefined,
332
456
  error.retryable
333
457
  ? `Category.withRetryable(${error.category === "throttling" ? "{ throttling: true }" : ""})`
334
458
  : undefined,
335
459
  ].filter(Boolean);
460
+ const options = [
461
+ error.retryable ? "retryable: true" : undefined,
462
+ globalErrors.includes(tag) ? "global: true" : undefined,
463
+ ].filter(Boolean);
336
464
  lines.push(
337
- documentation(error.description),
338
- `export class ${name} extends S.TaggedError<${name}>()(${literal(tag)}, G.errorFields)${pipes.length ? `.pipe(${pipes.join(", ")})` : ""} {}`,
465
+ documentation(error.description) +
466
+ `export class ${tag} extends S.TaggedError<${tag}>()(${literal(tag)}, errorFields)${pipes.length ? `.pipe(${pipes.join(", ")})` : ""} {}`,
467
+ `const ${tag}Spec: ErrorSpec<${tag}> = errorSpec(${tag}, ${literal(tag)}, ${JSON.stringify(error.matchers)}${options.length ? `, { ${options.join(", ")} }` : ""});`,
468
+ "",
339
469
  );
340
470
  }
341
- lines.push("export type Errors = {");
342
- for (const [tag, name] of errorNames) lines.push(`${literal(tag)}: ${name};`);
343
- lines.push("}", "export type Types = {");
344
- for (const [name, type] of Object.entries(model.types)) {
345
- if (!["OBJECT", "INTERFACE", "UNION"].includes(type.kind)) continue;
346
- lines.push(documentation(type.description), `${literal(name)}: {`);
471
+ lines.push(
472
+ `/** Errors every ${sdkName} root field can return. */`,
473
+ `export type ${globalErrorType} = ${union(globalErrors)};`,
474
+ `const globalErrors: ReadonlyArray<ErrorSpec> = [${globalErrors.map((tag) => `${tag}Spec`).join(", ")}];`,
475
+ "",
476
+ `/** Every typed error the ${sdkName} SDK declares. */`,
477
+ `export type ${sdkName}Error = ${union(errorTags)};`,
478
+ "",
479
+ );
480
+
481
+ for (const name of enums) {
482
+ const type = model.types[name]!;
483
+ lines.push(
484
+ documentation(type.description) +
485
+ `export type ${name} = ${union((type.enumValues ?? []).map(literal))};`,
486
+ "",
487
+ );
488
+ }
489
+
490
+ for (const name of inputs) {
491
+ const type = model.types[name]!;
492
+ const fields = Object.entries(type.inputFields ?? {}).map(
493
+ ([fieldName, field]) =>
494
+ ` readonly ${fieldName}${field.type.endsWith("!") ? "" : "?"}: ${tsArg(field.type)};`,
495
+ );
496
+ lines.push(
497
+ documentation(type.description) + `export interface ${name} {`,
498
+ ...fields,
499
+ "}",
500
+ "",
501
+ );
502
+ }
503
+
504
+ for (const name of unions) {
505
+ const type = model.types[name]!;
506
+ lines.push(
507
+ documentation(type.description) +
508
+ `export type ${name} = ${(type.possibleTypes ?? []).join(" | ") || "unknown"};`,
509
+ "",
510
+ );
511
+ }
512
+
513
+ for (const name of objects) {
514
+ const type = model.types[name]!;
515
+ const fields = Object.entries(type.fields ?? {}).map(
516
+ ([fieldName, field]) =>
517
+ ` readonly ${fieldName}: ${tsOutput(field.type)};`,
518
+ );
519
+ lines.push(
520
+ documentation(type.description) + `export interface ${name} {`,
521
+ ...fields,
522
+ "}",
523
+ "",
524
+ );
525
+ }
526
+
527
+ for (const name of [...objects, ...unions]) {
528
+ lines.push(
529
+ `export const ${name}: TypeMeta = { name: ${JSON.stringify(name)}, fields: {} };`,
530
+ );
531
+ }
532
+ lines.push("");
533
+
534
+ for (const name of objects) {
535
+ const type = model.types[name]!;
536
+ lines.push(`Object.assign(${name}.fields, {`);
347
537
  for (const [fieldName, field] of Object.entries(type.fields ?? {})) {
348
- lines.push(
349
- documentation(field.description),
350
- `${literal(fieldName)}: G.Field<${argsType(field.args)}, ${literal(field.type)}, ${union(field.errors.map(literal))}>;`,
351
- );
538
+ lines.push(` ${fieldName}: ${fieldMeta(fieldName, field)},`);
352
539
  }
353
- lines.push("};");
540
+ lines.push("});", "");
354
541
  }
355
- lines.push("}", "export type PossibleTypes = {");
356
- for (const [name, type] of Object.entries(model.types)) {
357
- if (type.kind === "INTERFACE" || type.kind === "UNION")
358
- lines.push(
359
- `${literal(name)}: ${union((type.possibleTypes ?? []).map(literal))};`,
360
- );
542
+ for (const name of unions) {
543
+ lines.push(
544
+ `Object.assign(${name}.fields, { __typename: scalarField("__typename") });`,
545
+ "",
546
+ );
361
547
  }
362
- lines.push(
363
- "}",
364
- "export type Schema = {",
365
- "types: Types; scalars: Scalars; errors: Errors; possibleTypes: PossibleTypes;",
366
- `query: ${literal(model.queryType)};`,
367
- `mutation: ${model.mutationType ? literal(model.mutationType) : "never"};`,
368
- `subscription: ${model.subscriptionType ? literal(model.subscriptionType) : "never"};`,
369
- `globalErrors: ${union(model.globalErrors.map(literal))};`,
370
- "};",
371
- );
372
- lines.push(
373
- "export type Selection<Name extends keyof Types> = G.Selection<Schema, Name>;",
374
- "export type Result<Name extends string, Select> = G.Result<Schema, Name, Select>;",
375
- "export type SelectedErrors<Name extends keyof Types, Select> = G.Errors<Schema, Name, Select>;",
376
- );
377
- lines.push(`export const schema: G.GraphQLModel = ${JSON.stringify(model)};`);
378
- lines.push(
379
- `export const errorClasses = { ${[...errorNames].map(([tag, name]) => `${literal(tag)}: ${name}`).join(", ")} };`,
380
- );
381
- lines.push(
382
- `export const client = G.makeClient<Schema, ${options.requirementsType}>(schema, ${options.transportName ?? "transport"}, errorClasses);`,
383
- "export const query = client.query;",
384
- "export const mutation = client.mutation;",
385
- "export const report = client.report;",
386
- );
387
- const operationNames = new Map<string, string>();
388
- for (const [kind, name] of [
389
- ["query", model.queryType],
390
- ["mutation", model.mutationType],
391
- ] as const) {
392
- for (const field of Object.keys(model.types[name ?? ""]?.fields ?? {})) {
393
- const exportName = allocate(
394
- field,
395
- `${kind}${field[0]!.toUpperCase()}${field.slice(1)}`,
396
- );
397
- if (!operationNames.has(field)) operationNames.set(field, exportName);
398
- lines.push(
399
- `export const ${exportName} = client.operation(${literal(kind)}, ${literal(field)});`,
400
- );
548
+
549
+ lines.push(`export const ${sdkName} = {`);
550
+ const queryNames = new Set<string>();
551
+ const queryType = model.types[model.queryType];
552
+ if (queryType?.fields) {
553
+ for (const [fieldName, field] of Object.entries(queryType.fields)) {
554
+ queryNames.add(fieldName);
555
+ lines.push(rootFn("query", fieldName, field));
401
556
  }
402
557
  }
403
- for (const [alias, target] of Object.entries(
404
- options.operationAliases ?? {},
405
- )) {
406
- const targetName = operationNames.get(target);
407
- // Legacy aliases are optional conveniences; never displace a real export.
408
- if (
409
- targetName &&
410
- alias !== target &&
411
- identifier(alias) === alias &&
412
- !occupied.has(alias)
413
- ) {
414
- occupied.add(alias);
415
- lines.push(`export const ${alias} = ${targetName};`);
558
+ if (model.mutationType) {
559
+ const mutationType = model.types[model.mutationType];
560
+ if (mutationType?.fields) {
561
+ for (const [fieldName, field] of Object.entries(mutationType.fields)) {
562
+ const exportName = queryNames.has(fieldName)
563
+ ? `${fieldName}Mutation`
564
+ : fieldName;
565
+ const line = rootFn("mutation", fieldName, field);
566
+ lines.push(
567
+ exportName === fieldName
568
+ ? line
569
+ : line.replace(` ${fieldName}:`, ` ${exportName}:`),
570
+ );
571
+ }
416
572
  }
417
573
  }
574
+ lines.push("};", "");
418
575
  return lines.join("\n") + "\n";
419
576
  };
@@ -0,0 +1,98 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { convertOpenApiToSmithy } from "./openapi.ts";
3
+
4
+ const model = (
5
+ schema: unknown,
6
+ mediaType = "multipart/form-data",
7
+ responseSchema: unknown = { type: "string", format: "binary" },
8
+ components: Record<string, unknown> = {},
9
+ options: { binaryTypes?: boolean } = { binaryTypes: true },
10
+ ) =>
11
+ convertOpenApiToSmithy(
12
+ {
13
+ openapi: "3.0.3",
14
+ info: { title: "Binary", version: "1" },
15
+ components,
16
+ paths: {
17
+ "/files": {
18
+ post: {
19
+ operationId: "uploadFile",
20
+ requestBody: { content: { [mediaType]: { schema } } },
21
+ responses: {
22
+ "200": {
23
+ content: {
24
+ "application/octet-stream": {
25
+ schema: responseSchema,
26
+ },
27
+ },
28
+ },
29
+ },
30
+ },
31
+ },
32
+ },
33
+ },
34
+ { namespace: "com.example.binary", serviceName: "Binary", ...options },
35
+ );
36
+
37
+ describe("OpenAPI binary contracts", () => {
38
+ for (const options of [{}, { binaryTypes: false }]) {
39
+ test(`binary conversion preserves existing contracts with ${JSON.stringify(options)}`, () => {
40
+ const result = model(
41
+ {
42
+ type: "object",
43
+ properties: {
44
+ zip: { type: "string", format: "binary" },
45
+ archive: { $ref: "#/components/schemas/Archive" },
46
+ },
47
+ },
48
+ "multipart/form-data",
49
+ { $ref: "#/components/schemas/Archive" },
50
+ { schemas: { Archive: { type: "string", format: "binary" } } },
51
+ options,
52
+ );
53
+ const request = result.shapes["com.example.binary#UploadFileRequest"];
54
+ expect(request.members.zip.target).toBe("smithy.api#String");
55
+ expect(request.members.archive.target).toBe("smithy.api#String");
56
+ expect(result.shapes["com.example.binary#UploadFile"].output.target).toBe(
57
+ "smithy.api#Unit",
58
+ );
59
+ expect(
60
+ result.shapes["com.example.binary#UploadFileResponse"],
61
+ ).toBeUndefined();
62
+ });
63
+ }
64
+
65
+ test("component-referenced binary responses retain the original reference's nullability", () => {
66
+ const result = model(
67
+ { type: "object" },
68
+ "application/json",
69
+ { $ref: "#/components/schemas/Archive", nullable: true },
70
+ { schemas: { Archive: { type: "string", format: "binary" } } },
71
+ );
72
+ const body =
73
+ result.shapes["com.example.binary#UploadFileResponse"].members.body;
74
+ expect(body.target).toBe("smithy.api#Blob");
75
+ expect(body.traits["com.distilled.openapi#rawResponse"]).toEqual({});
76
+ expect(body.traits["com.distilled.openapi#nullable"]).toEqual({});
77
+ });
78
+
79
+ test("multipart binary fields become blobs, not text or base64", () => {
80
+ const result = model({
81
+ type: "object",
82
+ properties: {
83
+ zip: { type: "string", format: "binary" },
84
+ encoded: { type: "string", format: "byte" },
85
+ environment: { type: "string" },
86
+ },
87
+ });
88
+ const request = result.shapes["com.example.binary#UploadFileRequest"];
89
+ expect(request.members.zip.target).toBe("smithy.api#Blob");
90
+ expect(request.members.encoded.target).toBe("smithy.api#String");
91
+ expect(request.members.environment.target).toBe("smithy.api#String");
92
+ const response = result.shapes["com.example.binary#UploadFileResponse"];
93
+ expect(response.members.body.target).toBe("smithy.api#Blob");
94
+ expect(
95
+ response.members.body.traits["com.distilled.openapi#rawResponse"],
96
+ ).toEqual({});
97
+ });
98
+ });