@aws/nx-plugin 1.0.0 → 1.0.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.
Files changed (124) hide show
  1. package/LICENSE-THIRD-PARTY +11686 -23398
  2. package/README.md +18 -0
  3. package/generators.json +7 -0
  4. package/migrations.json +39 -1
  5. package/package.json +6 -6
  6. package/src/agentcore-gateway/agent-connection/generator.js +6 -0
  7. package/src/agentcore-gateway/agent-connection/generator.js.map +1 -1
  8. package/src/agentcore-gateway/schema.json +1 -0
  9. package/src/agentcore-harness/generator.d.ts +1 -1
  10. package/src/connection/scaffold-catalog.d.ts +21 -0
  11. package/src/connection/scaffold-catalog.js +14 -6
  12. package/src/connection/scaffold-catalog.js.map +1 -1
  13. package/src/infra/app/generator.d.ts +1 -1
  14. package/src/init/generator.d.ts +1 -1
  15. package/src/mcp-server/guide-pipeline.d.ts +2 -1
  16. package/src/mcp-server/guide-pipeline.js +6 -0
  17. package/src/mcp-server/guide-pipeline.js.map +1 -1
  18. package/src/mcp-server/mdx-ast.d.ts +7 -0
  19. package/src/mcp-server/mdx-ast.js +15 -0
  20. package/src/mcp-server/mdx-ast.js.map +1 -1
  21. package/src/mcp-server/schema-registry.d.ts +2 -2
  22. package/src/mcp-server/schema-registry.js +2 -2
  23. package/src/mcp-server/schema-registry.js.map +1 -1
  24. package/src/migrations/latest/terraform-harness-environment-variables/metadata.json +3 -0
  25. package/src/migrations/latest/terraform-harness-environment-variables/migration.d.ts +6 -0
  26. package/src/migrations/latest/terraform-harness-environment-variables/migration.js +54 -0
  27. package/src/migrations/latest/terraform-harness-environment-variables/migration.js.map +1 -0
  28. package/src/open-api/py-client/__snapshots__/generator.additional-properties.spec.ts.snap +34 -0
  29. package/src/open-api/py-client/__snapshots__/generator.arrays.spec.ts.snap +1750 -0
  30. package/src/open-api/py-client/__snapshots__/generator.complex-types.spec.ts.snap +82 -0
  31. package/src/open-api/py-client/__snapshots__/generator.composite-types.spec.ts.snap +447 -0
  32. package/src/open-api/py-client/__snapshots__/generator.duplicate-types.spec.ts.snap +404 -0
  33. package/src/open-api/py-client/__snapshots__/generator.errors.spec.ts.snap +430 -0
  34. package/src/open-api/py-client/__snapshots__/generator.fast-api.spec.ts.snap +447 -0
  35. package/src/open-api/py-client/__snapshots__/generator.petstore.spec.ts.snap +3488 -0
  36. package/src/open-api/py-client/__snapshots__/generator.primitive-types.spec.ts.snap +1444 -0
  37. package/src/open-api/py-client/__snapshots__/generator.request.spec.ts.snap +390 -0
  38. package/src/open-api/py-client/__snapshots__/generator.reserved-keywords.spec.ts.snap +364 -0
  39. package/src/open-api/py-client/__snapshots__/generator.response.spec.ts.snap +739 -0
  40. package/src/open-api/py-client/__snapshots__/generator.streaming.spec.ts.snap +384 -0
  41. package/src/open-api/py-client/__snapshots__/generator.tags.spec.ts.snap +414 -0
  42. package/src/open-api/py-client/files/client/__clientModuleName__.py.template +1076 -0
  43. package/src/open-api/py-client/files/shared/__init__.py.template +28 -0
  44. package/src/open-api/py-client/files/shared/errors.py.template +37 -0
  45. package/src/open-api/py-client/files/shared/types.py.template +319 -0
  46. package/src/open-api/py-client/generator.d.ts +53 -0
  47. package/src/open-api/py-client/generator.js +119 -0
  48. package/src/open-api/py-client/generator.js.map +1 -0
  49. package/src/open-api/py-client/schema.d.js +6 -0
  50. package/src/open-api/py-client/schema.d.js.map +1 -0
  51. package/src/open-api/py-client/schema.d.ts +12 -0
  52. package/src/open-api/py-client/schema.json +28 -0
  53. package/src/open-api/py-client/vessel-registry-spec.json +2716 -0
  54. package/src/open-api/ts-client/__snapshots__/generator.edge-cases.spec.ts.snap +274 -0
  55. package/src/open-api/ts-client/petstore-spec.d.ts +6 -0
  56. package/src/open-api/ts-client/petstore-spec.js +1275 -0
  57. package/src/open-api/ts-client/petstore-spec.js.map +1 -0
  58. package/src/open-api/ts-hooks/generator.spec.tsx +1 -1
  59. package/src/open-api/utils/codegen-data/languages.d.ts +68 -2
  60. package/src/open-api/utils/codegen-data/languages.js +433 -21
  61. package/src/open-api/utils/codegen-data/languages.js.map +1 -1
  62. package/src/open-api/utils/codegen-data/types.d.ts +187 -0
  63. package/src/open-api/utils/codegen-data/types.js +10 -1
  64. package/src/open-api/utils/codegen-data/types.js.map +1 -1
  65. package/src/open-api/utils/codegen-data.d.ts +40 -1
  66. package/src/open-api/utils/codegen-data.js +622 -17
  67. package/src/open-api/utils/codegen-data.js.map +1 -1
  68. package/src/open-api/utils/normalise.js +175 -41
  69. package/src/open-api/utils/normalise.js.map +1 -1
  70. package/src/open-api/utils/parser.js +1 -1
  71. package/src/open-api/utils/parser.js.map +1 -1
  72. package/src/preset/__snapshots__/generator.spec.ts.snap +3 -3
  73. package/src/preset/generator.d.ts +1 -1
  74. package/src/py/agent/gateway-connection/generator.js +0 -3
  75. package/src/py/agent/gateway-connection/generator.js.map +1 -1
  76. package/src/py/agent/react-connection/generator.d.ts +1 -1
  77. package/src/py/agent/schema.json +2 -0
  78. package/src/py/fast-api/react/generator.d.ts +1 -1
  79. package/src/py/mcp-server/schema.json +1 -0
  80. package/src/sdk/open-api.d.ts +2 -0
  81. package/src/sdk/open-api.js +1 -0
  82. package/src/sdk/open-api.js.map +1 -1
  83. package/src/smithy/project/generator.d.ts +1 -1
  84. package/src/smithy/react-connection/generator.d.ts +1 -1
  85. package/src/terraform/project/generator.d.ts +1 -1
  86. package/src/trpc/react/generator.d.ts +1 -1
  87. package/src/ts/agent/a2a-connection/generator.d.ts +1 -1
  88. package/src/ts/agent/gateway-connection/generator.d.ts +1 -1
  89. package/src/ts/agent/gateway-connection/generator.js +0 -3
  90. package/src/ts/agent/gateway-connection/generator.js.map +1 -1
  91. package/src/ts/agent/mcp-connection/generator.d.ts +1 -1
  92. package/src/ts/agent/react-connection/generator.d.ts +1 -1
  93. package/src/ts/agent/schema.json +1 -0
  94. package/src/ts/api/schema.json +2 -0
  95. package/src/ts/astro-docs/generator.d.ts +1 -1
  96. package/src/ts/dcr-proxy/generator.d.ts +1 -1
  97. package/src/ts/dynamodb/generator.d.ts +1 -1
  98. package/src/ts/lambda-function/generator.d.ts +1 -1
  99. package/src/ts/lib/generator.d.ts +1 -1
  100. package/src/ts/mcp-server/schema.json +1 -0
  101. package/src/ts/nx-generator/__snapshots__/generator.spec.ts.snap +1 -1
  102. package/src/ts/nx-generator/files/nx-plugin-for-aws/generator/generator.spec.ts.template +1 -1
  103. package/src/ts/nx-generator/generator.d.ts +1 -1
  104. package/src/ts/nx-migration/generator.d.ts +1 -1
  105. package/src/ts/nx-plugin/generator.d.ts +1 -1
  106. package/src/ts/rdb/generator.d.ts +1 -1
  107. package/src/ts/react-website/agui/generator.d.ts +1 -1
  108. package/src/ts/react-website/app/__snapshots__/generator.spec.ts.snap +10 -10
  109. package/src/ts/react-website/app/schema.json +1 -0
  110. package/src/ts/website/app/schema.json +1 -0
  111. package/src/utils/agent-core-constructs/files/terraform/app/agentcore-harness/__nameKebabCase__/__nameKebabCase__.tf.template +1 -1
  112. package/src/utils/metrics-assertions.d.ts +18 -0
  113. package/src/utils/metrics-assertions.js +42 -0
  114. package/src/utils/metrics-assertions.js.map +1 -0
  115. package/src/utils/test/python-dependencies.d.ts +8 -0
  116. package/src/utils/test/python-dependencies.js +14 -0
  117. package/src/utils/test/python-dependencies.js.map +1 -0
  118. package/src/utils/test/python-worker/worker.py +703 -0
  119. package/src/utils/test/warm-python-cache.d.ts +18 -0
  120. package/src/utils/test/warm-python-cache.js +46 -0
  121. package/src/utils/test/warm-python-cache.js.map +1 -0
  122. package/src/utils/versions.d.ts +10 -9
  123. package/src/utils/versions.js +9 -8
  124. package/src/utils/versions.js.map +1 -1
@@ -1,11 +1,12 @@
1
1
  /**
2
2
  * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3
3
  * SPDX-License-Identifier: Apache-2.0
4
- */ import orderBy from "lodash.orderby";
4
+ */ import { createHash } from "node:crypto";
5
+ import orderBy from "lodash.orderby";
5
6
  import trim from "lodash.trim";
6
7
  import uniqBy from "lodash.uniqby";
7
8
  import { camelCase, pascalCase, snakeCase, toClassName, upperFirst } from "../../utils/names.js";
8
- import { toPythonName, toPythonType, toTypeScriptModelName, toTypeScriptName, toTypeScriptType } from "./codegen-data/languages.js";
9
+ import { qualifyPythonType, toPythonAnnotation, toPythonClassName, toPythonName, toPythonType, toPythonTypeTree, toTypeScriptModelName, toTypeScriptName, toTypeScriptType } from "./codegen-data/languages.js";
9
10
  import { COLLECTION_TYPES, COMPOSED_SCHEMA_TYPES, createModel, DEFAULT_SERVICE_NAME, indexModelsByName, PRIMITIVE_TYPES, STREAMING_CONTENT_TYPES, VENDOR_EXTENSIONS } from "./codegen-data/types.js";
10
11
  import { normaliseOpenApiSpecForCodeGen } from "./normalise.js";
11
12
  import { buildClientData, buildInlineModel, compositeMemberSchemas, getSpecOperation, getSpecParametersByKey, getSpecPathParameters, linkModel, specParameterKey } from "./parser.js";
@@ -21,9 +22,13 @@ import { isRef, resolveIfRef, splitRef } from "./refs.js";
21
22
  }
22
23
  const allOperations = uniqBy(data.services.flatMap((s)=>s.operations), (o)=>o.uniqueName);
23
24
  // A model per operation request parameter position (query/path/body/...).
25
+ // Named against the schemas already declared, since a spec may declare one
26
+ // called `FooRequestQueryParameters` itself — emitting that name twice made the
27
+ // second definition win and silently retyped every reference to the first.
28
+ const takenModelNames = new Set(data.models.map((model)=>model.name));
24
29
  data.models = [
25
30
  ...data.models,
26
- ...allOperations.flatMap((op)=>buildRequestParameterModels(op, modelsByName))
31
+ ...allOperations.flatMap((op)=>buildRequestParameterModels(op, modelsByName, takenModelNames))
27
32
  ];
28
33
  for (const model of data.models){
29
34
  augmentModel(spec, model, modelsByName);
@@ -43,7 +48,11 @@ import { isRef, resolveIfRef, splitRef } from "./refs.js";
43
48
  // Default service first, then by name.
44
49
  data.services = orderBy(data.services, (s)=>s.name === DEFAULT_SERVICE_NAME ? '' : s.name);
45
50
  const { operationsByTag, untaggedOperations } = groupOperationsByTag(allOperations);
46
- return {
51
+ annotateAllOfFlattening(data.models);
52
+ for (const op of allOperations){
53
+ annotateRequestAndErrorShapes(op, modelsByName);
54
+ }
55
+ const result = {
47
56
  ...data,
48
57
  operationsByTag,
49
58
  untaggedOperations,
@@ -52,6 +61,433 @@ import { isRef, resolveIfRef, splitRef } from "./refs.js";
52
61
  vendorExtensions: vendorExtensionsOf(spec),
53
62
  className: toClassName(spec.info.title)
54
63
  };
64
+ return result;
65
+ };
66
+ /**
67
+ * For each `all-of` composite: precompute the flat property list templates
68
+ * that flatten composition will emit (`effectiveProperties`), and mark
69
+ * hoisted (normaliser-synthesised) components the parent inlines
70
+ * (`isInlinedByAllOf`) so templates can skip emitting them separately.
71
+ */ const annotateAllOfFlattening = (models)=>{
72
+ /**
73
+ * Collect a composed model's own properties, recursing through a member that
74
+ * is itself an `all-of` so a nested composition contributes the leaf
75
+ * properties rather than the composite itself (which has no property name).
76
+ * `visiting` breaks a cycle in a self-referential composition.
77
+ */ const collect = (model, into, seen, visiting)=>{
78
+ if (visiting.has(model)) return;
79
+ visiting.add(model);
80
+ for (const composed of model.composedModels ?? []){
81
+ // Only a member whose properties this flattening actually absorbs may be
82
+ // marked inlined. A hoisted `oneOf` member has no named properties, so
83
+ // marking it would have templates skip it while nothing carried its
84
+ // constraint — the union would silently disappear.
85
+ const isFlattenable = composed.export === 'interface' || composed.export === 'all-of';
86
+ if (composed.vendorExtensions?.['x-aws-nx-hoisted'] && isFlattenable) {
87
+ composed.isInlinedByAllOf = model.name;
88
+ }
89
+ if (composed.export === 'all-of') {
90
+ collect(composed, into, seen, visiting);
91
+ continue;
92
+ }
93
+ for (const prop of composed.properties ?? []){
94
+ if (!prop.name) continue;
95
+ const existing = seen.get(prop.name);
96
+ if (existing) {
97
+ // `allOf` is a conjunction, so a property any branch requires is
98
+ // required — taking only the first branch's flag would make it
99
+ // optional because of the order the branches happen to appear in.
100
+ existing.isRequired = existing.isRequired || prop.isRequired;
101
+ continue;
102
+ }
103
+ // Copied, so raising `isRequired` above describes this composition
104
+ // rather than mutating the composed schema every user of it shares.
105
+ const flattenedProp = {
106
+ ...prop
107
+ };
108
+ seen.set(prop.name, flattenedProp);
109
+ into.push(flattenedProp);
110
+ }
111
+ }
112
+ visiting.delete(model);
113
+ };
114
+ for (const model of models){
115
+ if (model.export !== 'all-of') continue;
116
+ const flattened = [];
117
+ collect(model, flattened, new Map(), new Set());
118
+ model.effectiveProperties = flattened;
119
+ }
120
+ };
121
+ /**
122
+ * The object-shaped properties a body model exposes for flattening, walking
123
+ * allOf composition via `effectiveProperties` when present.
124
+ */ const flattenableBodyProperties = (bodyModel)=>{
125
+ if (!bodyModel) return [];
126
+ if (bodyModel.export === 'interface' && !bodyModel.hasAdditionalProperties) {
127
+ return bodyModel.properties ?? [];
128
+ }
129
+ if (bodyModel.export === 'all-of') {
130
+ return bodyModel.effectiveProperties ?? [];
131
+ }
132
+ return [];
133
+ };
134
+ /**
135
+ * Whether a body's fields can be flattened into the call signature without
136
+ * clashing with path/query/header/cookie parameters. Only object-shaped
137
+ * bodies (references to an interface or allOf) are eligible; discriminated
138
+ * bases stay whole so marshalling can dispatch on the discriminator.
139
+ */ const canFlattenBodyIntoRequest = (op, bodyParam, bodyModel)=>{
140
+ if (!bodyParam || bodyParam.export !== 'reference') return false;
141
+ if (bodyModel?.discriminator) return false;
142
+ const props = flattenableBodyProperties(bodyModel);
143
+ if (props.length === 0) return false;
144
+ const otherNames = new Set((op.parameters ?? []).filter((p)=>p.in !== 'body').map((p)=>p.name));
145
+ return props.every((prop)=>!otherNames.has(prop.name));
146
+ };
147
+ /**
148
+ * Whether a response code always describes a successful response: a concrete
149
+ * 2xx code, or the `2XX` range.
150
+ *
151
+ * `default` is excluded — it covers whatever the spec did not enumerate, which
152
+ * spans both success and failure, so whether it is a success depends on the
153
+ * operation's other responses. See {@link successResponsesOf}.
154
+ */ const isSuccessCode = (code)=>typeof code === 'number' ? code >= 200 && code < 300 : code === '2XX';
155
+ /**
156
+ * Every response the operation returns rather than raises for, in the order a
157
+ * client checks them: concrete codes, then `2XX`, then `default`.
158
+ *
159
+ * An operation declaring `200` and `2XX` has two, since a 201 is described by
160
+ * the range and is still a success. A `default` is only a success when the
161
+ * operation declares no other — as the sole response it must describe the
162
+ * success case, whereas alongside one it is the fallback for everything else.
163
+ */ const successResponsesOf = (responses)=>{
164
+ const declared = responses.filter((r)=>isSuccessCode(r.code));
165
+ if (declared.length > 0) return declared;
166
+ return responses.filter((r)=>r.code === 'default');
167
+ };
168
+ /**
169
+ * Build a language-agnostic description of an operation's inputs. Each input
170
+ * carries its source (where in the HTTP request it is placed) plus the
171
+ * underlying model — language templates decide how to translate that into
172
+ * their idiomatic call signature (kwargs, a wrapper interface, etc).
173
+ */ const buildRequestShape = (op, modelsByName)=>{
174
+ const nonBody = (op.parameters ?? []).filter((p)=>p.in !== 'body');
175
+ const inputs = nonBody.map((p)=>({
176
+ source: {
177
+ kind: p.in,
178
+ wireName: p.prop ?? p.name,
179
+ ...p.collectionFormat ? {
180
+ collectionFormat: p.collectionFormat
181
+ } : {}
182
+ },
183
+ model: p,
184
+ isRequired: !!p.isRequired,
185
+ isNullable: !!p.isNullable,
186
+ description: p.description,
187
+ specName: p.prop ?? p.name
188
+ }));
189
+ const shape = {
190
+ inputs,
191
+ isSingleBodyInput: false
192
+ };
193
+ const bodyParam = op.parametersBody ?? undefined;
194
+ if (bodyParam) {
195
+ const mediaType = bodyParam.mediaType ?? (bodyParam.mediaTypes ? bodyParam.mediaTypes[0] : undefined) ?? undefined;
196
+ const bodyModel = modelsByName[bodyParam.type];
197
+ if (canFlattenBodyIntoRequest(op, bodyParam, bodyModel)) {
198
+ for (const prop of flattenableBodyProperties(bodyModel)){
199
+ inputs.push({
200
+ source: {
201
+ kind: 'body-field',
202
+ fieldName: prop.name
203
+ },
204
+ model: prop,
205
+ isRequired: !!prop.isRequired,
206
+ isNullable: !!prop.isNullable,
207
+ description: prop.description,
208
+ specName: prop.name
209
+ });
210
+ }
211
+ shape.bodyFromFields = {
212
+ model: bodyModel,
213
+ mediaType
214
+ };
215
+ } else {
216
+ inputs.push({
217
+ source: {
218
+ kind: 'body',
219
+ wireName: 'body'
220
+ },
221
+ model: bodyParam,
222
+ isRequired: !!bodyParam.isRequired,
223
+ isNullable: !!bodyParam.isNullable,
224
+ description: bodyParam.description,
225
+ specName: 'body'
226
+ });
227
+ shape.bodyAsSingleInput = {
228
+ model: bodyParam,
229
+ mediaType
230
+ };
231
+ if (nonBody.length === 0) shape.isSingleBodyInput = true;
232
+ }
233
+ }
234
+ return shape;
235
+ };
236
+ /**
237
+ * Build a language-agnostic error taxonomy for the operation: one entry per
238
+ * non-success response bucket. Language templates turn these into typed
239
+ * exception / error union shapes as appropriate.
240
+ */ const buildErrorShape = (op)=>({
241
+ // A response that can describe a success is not an error, even when it isn't
242
+ // the one whose type the operation returns: raising on a declared `2XX` or
243
+ // `default` would turn a 201 into an exception.
244
+ entries: (op.responses ?? []).filter((r)=>!(op.successResponses ?? []).includes(r)).map((resp)=>({
245
+ code: resp.code,
246
+ responseModel: resp
247
+ }))
248
+ });
249
+ /**
250
+ * Annotate a typed entry (response, parameter, result) with whether the type
251
+ * it references resolves to a module-level alias — a collection
252
+ * (`list[...]` / `dict[str, ...]`), union or literal — rather than a class.
253
+ * Python templates read `referencedCollectionKind` to pick pydantic's
254
+ * `TypeAdapter(X)` over `X.model_validate(...)`.
255
+ */ const annotateReferencedCollectionKind = (entry, modelsByName)=>{
256
+ if (!entry?.type) return;
257
+ const referenced = modelsByName[entry.type];
258
+ if (!referenced) return;
259
+ if (referenced.export === 'dictionary') {
260
+ entry.referencedCollectionKind = 'dictionary';
261
+ } else if (referenced.export === 'array') {
262
+ entry.referencedCollectionKind = 'array';
263
+ } else if (referenced.export === 'one-of' || referenced.export === 'any-of' || referenced.export === 'enum' || referenced.export === 'tuple') {
264
+ entry.referencedCollectionKind = 'alias';
265
+ }
266
+ };
267
+ /**
268
+ * Attach the request shape, error shape and referenced-collection-kind
269
+ * annotations to an operation.
270
+ */ const annotateRequestAndErrorShapes = (op, modelsByName)=>{
271
+ for (const parameter of op.parameters ?? []){
272
+ annotateReferencedCollectionKind(parameter, modelsByName);
273
+ }
274
+ annotateReferencedCollectionKind(op.parametersBody ?? undefined, modelsByName);
275
+ for (const response of op.responses ?? []){
276
+ annotateReferencedCollectionKind(response, modelsByName);
277
+ }
278
+ annotateReferencedCollectionKind(op.result, modelsByName);
279
+ op.requestShape = buildRequestShape(op, modelsByName);
280
+ op.errorShape = buildErrorShape(op);
281
+ };
282
+ const TYPES_GEN_PREFIX = 'types.';
283
+ /**
284
+ * Attributes and methods the generated Python client defines on itself. An
285
+ * operation or tag whose name would land on one of these is suffixed, so a
286
+ * spec can't silently replace the client's own plumbing — an operation called
287
+ * `close` would otherwise shadow the method that releases the httpx client.
288
+ */ export const PYTHON_CLIENT_MEMBERS = new Set([
289
+ 'aclose',
290
+ 'close',
291
+ 'config',
292
+ 'url',
293
+ 'query',
294
+ 'query_string',
295
+ 'headers',
296
+ 'cookies',
297
+ 'deep_object',
298
+ 'dump',
299
+ // Every remaining private helper the client template emits. A name missing
300
+ // here is not merely shadowed: the operation replaces the helper, so every
301
+ // *other* operation that calls it fails.
302
+ 'as_dict',
303
+ 'cookie_value',
304
+ 'default_content_type',
305
+ 'error',
306
+ 'error_payload',
307
+ 'is_file_part',
308
+ 'is_object',
309
+ 'json_body',
310
+ 'multipart',
311
+ 'part_text',
312
+ 'scalar',
313
+ 'send_with_url',
314
+ 'stream_with_url',
315
+ 'client',
316
+ 'base_url',
317
+ 'owns_client',
318
+ 'parent'
319
+ ]);
320
+ /**
321
+ * A Python member name for an operation or tag that cannot collide with the
322
+ * client's own members, nor with another operation or tag on the same client.
323
+ */ const uniquePythonMemberName = (name, taken, fallback)=>{
324
+ // A leading underscore is how the client marks its own internals, so an
325
+ // operation that snake-cases onto one is pushed out of that namespace too.
326
+ let candidate = PYTHON_CLIENT_MEMBERS.has(name.replace(/^_+/, '')) ? `${name.replace(/^_+/, '')}_op` : name;
327
+ // snake_case strips everything that isn't alphanumeric, so a name written
328
+ // entirely in another script yields the empty string, and one starting with a
329
+ // digit isn't a valid identifier. Either would emit code that doesn't parse.
330
+ if (!candidate) {
331
+ candidate = fallback;
332
+ } else if (/^\d/.test(candidate)) {
333
+ candidate = `${fallback}_${candidate}`;
334
+ }
335
+ while(taken.has(candidate)){
336
+ candidate = `${candidate}_`;
337
+ }
338
+ taken.add(candidate);
339
+ return candidate;
340
+ };
341
+ /**
342
+ * Resolve the Python member name of every operation and tag, keeping them
343
+ * distinct from each other and from the client's own members.
344
+ */ const annotatePythonMemberNames = (data)=>{
345
+ const taken = new Set();
346
+ data.pythonTagNames = Object.fromEntries(Object.keys(data.operationsByTag).map((tag, index)=>[
347
+ tag,
348
+ uniquePythonMemberName(toPythonName('property', tag), taken, `tag_${index + 1}`)
349
+ ]));
350
+ data.allOperations.forEach((op, index)=>{
351
+ op.pythonMethodName = uniquePythonMemberName(op.operationIdSnakeCase ?? toPythonName('operation', op.name), taken, `operation_${index + 1}`);
352
+ });
353
+ };
354
+ /**
355
+ * Attach `pythonClientType` to a typed entry — the bare `pythonType`
356
+ * qualified with `types.` so client templates can reference model
357
+ * classes through a single import.
358
+ */ const annotatePythonClientType = (entry)=>{
359
+ if (!entry) return;
360
+ entry.pythonTypeTree ??= toPythonTypeTree(entry);
361
+ entry.pythonClientType = qualifyPythonType(entry.pythonTypeTree, TYPES_GEN_PREFIX);
362
+ };
363
+ /**
364
+ * A name for a generated class that no schema and no other generated class has
365
+ * taken, recording it so later ones see it. The suffix keeps the name
366
+ * recognisable rather than renaming it to something positional, and is a digit
367
+ * rather than an underscore because class-name normalisation strips a trailing
368
+ * one — so `FooError_` collapsed back onto `FooError`.
369
+ */ const uniqueGeneratedName = (candidate, taken)=>{
370
+ let name = candidate;
371
+ for(let suffix = 2; taken.has(name); suffix++){
372
+ name = `${candidate}${suffix}`;
373
+ }
374
+ taken.add(name);
375
+ return name;
376
+ };
377
+ /**
378
+ * Final pass over all models + operation payloads to re-derive python type
379
+ * annotations after links/composites are resolved, and to add the
380
+ * python-specific annotations the py-client templates consume:
381
+ * - `pythonType` / `pythonAnnotation` (refreshed — necessary for collection
382
+ * aliases whose element type wasn't available first time through)
383
+ * - `pythonClassName` / `pythonClientType`
384
+ * - `requestShape.inputs[*].pythonName` / `.pythonAnnotation` (kwargs)
385
+ * - `errorShape.exceptionClassName` / `.unionTypeName` / per-entry names
386
+ * - `pythonMethodName` per operation and `pythonTagNames` per tag, escaped
387
+ * clear of the members the generated client defines
388
+ *
389
+ * Called by the py-client generator rather than from the shared pipeline, so a
390
+ * TypeScript consumer of the same spec never pays for fields only the Python
391
+ * templates read — the same split as {@link assertNoClashingPythonNames}.
392
+ */ export const annotatePythonData = (data)=>{
393
+ const modelsByName = indexModelsByName(data.models);
394
+ annotatePythonMemberNames(data);
395
+ // Every class `types.py` declares for a schema. The names derived per operation
396
+ // below share that module, so they are checked against it rather than assumed
397
+ // free — taking one emitted the same class twice and Python kept the last,
398
+ // silently retyping every reference to the schema it replaced.
399
+ const takenClassNames = new Set(data.models.map((model)=>toPythonClassName(model.name)));
400
+ for (const model of data.models){
401
+ model.pythonClassName = toPythonClassName(model.name);
402
+ model.pythonTypeTree = toPythonTypeTree(model);
403
+ model.pythonType = toPythonType(model);
404
+ model.pythonAnnotation = toPythonAnnotation(model);
405
+ annotatePythonClientType(model);
406
+ for (const prop of model.properties ?? []){
407
+ prop.pythonTypeTree = toPythonTypeTree(prop);
408
+ prop.pythonType = toPythonType(prop);
409
+ prop.pythonAnnotation = toPythonAnnotation(prop);
410
+ annotatePythonClientType(prop);
411
+ }
412
+ for (const prop of model.effectiveProperties ?? []){
413
+ prop.pythonTypeTree = toPythonTypeTree(prop);
414
+ prop.pythonType = toPythonType(prop);
415
+ prop.pythonAnnotation = toPythonAnnotation(prop);
416
+ annotatePythonClientType(prop);
417
+ }
418
+ annotatePythonClientType(model.additionalPropertiesModel);
419
+ }
420
+ for (const op of data.allOperations){
421
+ for (const parameter of op.parameters ?? []){
422
+ parameter.pythonTypeTree = toPythonTypeTree(parameter);
423
+ parameter.pythonType = toPythonType(parameter);
424
+ parameter.pythonAnnotation = toPythonAnnotation(parameter);
425
+ annotatePythonClientType(parameter);
426
+ }
427
+ annotatePythonClientType(op.parametersBody ?? undefined);
428
+ annotatePythonClientType(op.result);
429
+ for (const response of op.responses ?? []){
430
+ annotatePythonClientType(response);
431
+ annotatePythonClientType(response.itemSchemaModel);
432
+ }
433
+ const requestShape = op.requestShape;
434
+ if (requestShape) {
435
+ const seenNames = new Set();
436
+ for (const input of requestShape.inputs){
437
+ const rawName = input.model.pythonName || input.specName;
438
+ // An operation input becomes a keyword argument, so it is escaped clear
439
+ // of the locals and builtins the method body uses as well.
440
+ const base = toPythonName('argument', rawName);
441
+ let pythonName = base;
442
+ if (seenNames.has(pythonName)) {
443
+ // Qualify by where the input goes, then keep suffixing until the name
444
+ // is free: the qualified form can itself collide (a header
445
+ // `fooBarHeader` next to a query `fooBar` and a header `foo_bar` all
446
+ // reach `foo_bar_header`), which would emit a duplicate argument.
447
+ pythonName = `${base}_${input.source.kind.replace('-', '_')}`;
448
+ while(seenNames.has(pythonName)){
449
+ pythonName = `${pythonName}_`;
450
+ }
451
+ }
452
+ seenNames.add(pythonName);
453
+ const baseType = input.model.pythonTypeTree ?? toPythonTypeTree(input.model);
454
+ // An optional or nullable input may be left unset, so its annotation
455
+ // admits None. Built as a tree so nothing has to unwrap it by name.
456
+ const annotationType = input.isRequired && !input.isNullable ? baseType : {
457
+ kind: 'optional',
458
+ inner: baseType
459
+ };
460
+ input.pythonName = pythonName;
461
+ input.pythonTypeTree = annotationType;
462
+ input.pythonAnnotation = qualifyPythonType(annotationType, TYPES_GEN_PREFIX);
463
+ }
464
+ // Required-first so the generated keyword-only signature reads naturally.
465
+ requestShape.inputs.sort((a, b)=>Number(b.isRequired) - Number(a.isRequired));
466
+ }
467
+ if (op.requestTypeName) {
468
+ op.pythonRequestTypeName = uniqueGeneratedName(toPythonClassName(op.requestTypeName), takenClassNames);
469
+ }
470
+ const errorShape = op.errorShape;
471
+ if (errorShape) {
472
+ // The pascal-cased operation id is shared with TypeScript, where a leading
473
+ // digit is legal; a Python class name beginning with one does not parse.
474
+ const opPascal = toPythonClassName(op.operationIdPascalCase);
475
+ errorShape.exceptionClassName = `${opPascal}ApiError`;
476
+ // Always a valid class name: templates emit `<name> = Never` for an
477
+ // operation with no error responses, so the name is needed either way.
478
+ errorShape.unionTypeName = uniqueGeneratedName(`${opPascal}Error`, takenClassNames);
479
+ errorShape.hasErrorEntries = errorShape.entries.length > 0;
480
+ for (const entry of errorShape.entries){
481
+ const suffix = entry.code === 'default' ? 'Default' : String(entry.code).toUpperCase();
482
+ entry.className = uniqueGeneratedName(`${opPascal}${suffix}Error`, takenClassNames);
483
+ entry.isExactCode = typeof entry.code === 'number';
484
+ // Only collapse to a Literal for exact numeric codes — ranges like
485
+ // 5XX expand to 100 codes, which produce a massive, unreadable
486
+ // Literal and don't narrow `response.status_code` usefully anyway.
487
+ entry.statusAnnotation = entry.isExactCode ? `Literal[${entry.code}]` : 'int';
488
+ }
489
+ }
490
+ }
55
491
  };
56
492
  /**
57
493
  * Augment a service and each of its operations with the data needed for code
@@ -79,9 +515,19 @@ import { isRef, resolveIfRef, splitRef } from "./refs.js";
79
515
  ...augmentParameters(spec, op, specOp, modelsByName)
80
516
  ];
81
517
  op.responses.forEach(addLanguageTypes);
82
- op.responses = orderBy(op.responses, (r)=>r.code);
518
+ // Concrete codes first, then wildcard ranges, then `default` — templates emit
519
+ // the checks in this order, and `default` renders as an unconditional branch
520
+ // that would shadow anything after it. Sorting on `code` alone would leave
521
+ // that to how the spec happened to enumerate its keys.
522
+ op.responses = orderBy(op.responses, [
523
+ (r)=>r.code === 'default' ? 2 : typeof r.code === 'number' ? 0 : 1,
524
+ (r)=>r.code
525
+ ]);
83
526
  // Result is the lowest successful response, otherwise the 2XX or default.
84
527
  op.result = op.responses.find((r)=>typeof r.code === 'number' && r.code >= 200 && r.code < 300) ?? op.responses.find((r)=>r.code === '2XX' || r.code === 'default');
528
+ // Already ordered concrete → wildcard → `default` by the sort above, which is
529
+ // the order a client must check them in.
530
+ op.successResponses = successResponsesOf(op.responses);
85
531
  op.operationIdPascalCase = pascalCase(op.uniqueName);
86
532
  op.operationIdSnakeCase = toPythonName('operation', op.uniqueName);
87
533
  if (op.parameters.length > 0) {
@@ -109,13 +555,70 @@ import { isRef, resolveIfRef, splitRef } from "./refs.js";
109
555
  * Augment an operation's response models with schema-derived data, resolving
110
556
  * void responses and streaming item schemas. Returns the response model names
111
557
  * to import.
112
- */ const augmentResponses = (spec, op, specOp, modelsByName)=>{
558
+ */ /**
559
+ * A short, stable digest of a string. Each level of {@link schemaShapeKey} is
560
+ * reduced to one of these rather than carrying its children's text upwards: a
561
+ * deeply nested schema in a large document otherwise built a key past the
562
+ * maximum string length, and the biggest published specs failed outright.
563
+ */ const digest = (value)=>createHash('sha1').update(value).digest('base64');
564
+ /**
565
+ * A key identifying a schema by its shape rather than by how it is written, so
566
+ * two schemas can be compared for equivalence.
567
+ *
568
+ * Normalisation hoists only the preferred media type's inline schema into
569
+ * `components`, and does so at any depth, so the same shape reaches here as a
570
+ * `$ref` under one media type and inline under another — following references is
571
+ * what lets those compare equal.
572
+ *
573
+ * Each reference is expanded once and its key reused: a large document shares a
574
+ * handful of schemas across thousands of sites, and expanding every occurrence
575
+ * exhausted the heap on the biggest published specs. `seen` breaks a cycle in a
576
+ * self-referential schema by keying the reference itself, and such a schema is
577
+ * left out of the cache since its key depends on where it was reached from.
578
+ */ const schemaShapeKey = (spec, schema, cache, seen = new Set())=>{
579
+ if (!schema || typeof schema !== 'object') return JSON.stringify(schema);
580
+ if (Array.isArray(schema)) {
581
+ return digest(`[${schema.map((member)=>schemaShapeKey(spec, member, cache, seen)).join(',')}]`);
582
+ }
583
+ if (isRef(schema)) {
584
+ const ref = schema.$ref;
585
+ if (seen.has(ref)) return `cycle:${ref}`;
586
+ const cached = cache.get(ref);
587
+ if (cached !== undefined) return cached;
588
+ const nested = new Set(seen).add(ref);
589
+ const key = schemaShapeKey(spec, resolveIfRef(spec, schema), cache, nested);
590
+ if (!key.startsWith('cycle:')) {
591
+ cache.set(ref, key);
592
+ }
593
+ return key;
594
+ }
595
+ return digest(`{${Object.entries(schema)// Bookkeeping the normaliser adds to what it hoists, not part of the shape.
596
+ .filter(([key])=>!key.startsWith('x-aws-nx-')).sort(([a], [b])=>a < b ? -1 : a > b ? 1 : 0).map(([key, value])=>`${key}:${schemaShapeKey(spec, value, cache, seen)}`).join(',')}}`);
597
+ };
598
+ /**
599
+ * Keyed by the document, since its schemas recur across every operation and a
600
+ * cache shared between documents would answer for the wrong one. Held weakly so
601
+ * a spec's entries go when the spec does.
602
+ */ const schemaShapeCaches = new WeakMap();
603
+ const schemaShapeCacheFor = (spec)=>{
604
+ let cache = schemaShapeCaches.get(spec);
605
+ if (!cache) {
606
+ cache = new Map();
607
+ schemaShapeCaches.set(spec, cache);
608
+ }
609
+ return cache;
610
+ };
611
+ const augmentResponses = (spec, op, specOp, modelsByName)=>{
113
612
  const modelImports = op.responses.filter((r)=>r.export === 'reference').map((r)=>r.type);
114
613
  for (const response of op.responses){
115
614
  // We cannot distinguish a composite of primitives at runtime (it all comes
116
615
  // back as text), so validate this away.
117
616
  if (response.export === 'reference' && COMPOSED_SCHEMA_TYPES.has(modelsByName[response.type]?.export)) {
118
- const composedPrimitives = (modelsByName[response.type].composedPrimitives ?? []).filter((p)=>!COLLECTION_TYPES.has(p.export));
617
+ const composedPrimitives = (modelsByName[response.type].composedPrimitives ?? []).filter((p)=>!COLLECTION_TYPES.has(p.export) && // `null` is the one primitive a runtime CAN tell from the others, and
618
+ // it is how OpenAPI 3.1 spells an optional: FastAPI emits
619
+ // `anyOf: [X, {type: 'null'}]` for `Optional[X]`. Counting it here
620
+ // failed generation outright for an idiomatic optional response.
621
+ p.type !== 'null');
119
622
  if (composedPrimitives.length > 0) {
120
623
  throw new Error(`Operation "${op.method} ${op.path}" returns a composite schema of primitives with ${camelCase(modelsByName[response.type].export)}, which cannot be distinguished at runtime`);
121
624
  }
@@ -130,11 +633,49 @@ import { isRef, resolveIfRef, splitRef } from "./refs.js";
130
633
  }
131
634
  const mediaTypes = Object.keys(specResponse.content);
132
635
  response.mediaTypes = mediaTypes;
636
+ // One status can only be parsed one way: the response's type comes from the
637
+ // first media type, and nothing inspects the response's Content-Type to pick
638
+ // between them. Where the schemas agree that is harmless (a `string` served
639
+ // as either JSON or text), but where they differ the client would coerce one
640
+ // wire form into the other's type and raise deep inside pydantic. Rejected
641
+ // here for the same reason a composite of primitives is: it cannot be told
642
+ // apart at runtime.
643
+ // A streaming media type describes each item of the stream rather than the
644
+ // whole body, so it is compared through `itemSchema` elsewhere and excluded
645
+ // here — a JSONL response legitimately pairs with an `application/json`
646
+ // declaration of the same item type.
647
+ // Compared through their resolved form, and only where both declare one:
648
+ // normalisation hoists just the preferred media type's inline schema into
649
+ // `components` and leaves the others inline, so comparing as written pitted a
650
+ // `$ref` against the very schema it was hoisted from and never matched. An
651
+ // absent `schema` means unconstrained rather than different (a Media Type
652
+ // Object may omit it), so it does not count as a distinct shape.
653
+ const bodySchemas = new Set(mediaTypes.filter((mediaType)=>!STREAMING_CONTENT_TYPES.has(mediaType)).map((mediaType)=>specResponse.content[mediaType]?.schema).filter((schema)=>schema !== undefined).map((schema)=>schemaShapeKey(spec, schema, schemaShapeCacheFor(spec))));
654
+ if (bodySchemas.size > 1) {
655
+ throw new Error(`Operation "${op.method} ${op.path}" declares response ${response.code} with different schemas per media type (${mediaTypes.join(', ')}), which cannot be distinguished at runtime. Declare one schema for the status, or split the media types across separate operations.`);
656
+ }
133
657
  for (const mediaType of mediaTypes){
134
658
  const responseContent = specResponse.content[mediaType];
659
+ const declaredSchema = responseContent.schema;
660
+ // A composite whose members include `null` — how OpenAPI 3.1 spells an
661
+ // optional — is nullable, even though the `null` member itself is dropped
662
+ // from the composite so it stays distinguishable at runtime.
663
+ const composedModel = modelsByName[response.type];
664
+ const hasNullMember = (composedModel?.composedPrimitives ?? []).some((member)=>member.type === 'null');
135
665
  const responseSchema = resolveIfRef(spec, responseContent.schema);
136
666
  if (responseSchema) {
137
667
  augmentModelFromSchema(spec, response, responseSchema, modelsByName);
668
+ // A response body the spec marks nullable may arrive as JSON `null`, so
669
+ // the flag is carried onto the response for the templates that parse it.
670
+ // Set here rather than in `augmentModelFromSchema`, which every property
671
+ // also goes through: doing it there would change how a property
672
+ // referencing a nullable schema is typed.
673
+ //
674
+ // Read from the declared schema as well as the resolved one: `nullable`
675
+ // may sit beside a `$ref`, which resolution replaces wholesale.
676
+ if (declaredSchema?.nullable || hasNullMember || responseSchema.nullable) {
677
+ response.isNullable = true;
678
+ }
138
679
  }
139
680
  if (STREAMING_CONTENT_TYPES.has(mediaType) && 'itemSchema' in responseContent) {
140
681
  response.isJsonlStreaming = true;
@@ -192,9 +733,20 @@ import { isRef, resolveIfRef, splitRef } from "./refs.js";
192
733
  const specBody = resolveIfRef(spec, specOp?.requestBody);
193
734
  if (!specBody) return;
194
735
  if (parameter.mediaType) {
736
+ const declaredSchema = specBody.content?.[parameter.mediaType]?.schema;
195
737
  const bodySchema = resolveIfRef(spec, specBody.content?.[parameter.mediaType]?.schema);
196
738
  if (bodySchema) {
197
739
  augmentModelFromSchema(spec, parameter, bodySchema, modelsByName);
740
+ // A body the spec marks nullable may legitimately be sent as JSON `null`.
741
+ // Read from the declared schema too: `nullable` may sit beside a `$ref`,
742
+ // which resolution replaces wholesale.
743
+ // A composite whose members include `null` is the 3.1 spelling of an
744
+ // optional, and reaches here as a reference to a hoisted schema.
745
+ const referenced = modelsByName[parameter.type];
746
+ const hasNullMember = (referenced?.composedPrimitives ?? []).some((member)=>member.type === 'null');
747
+ if (declaredSchema?.nullable || hasNullMember || bodySchema.nullable) {
748
+ parameter.isNullable = true;
749
+ }
198
750
  }
199
751
  }
200
752
  // Track all the media types that can be accepted in the request body
@@ -228,7 +780,7 @@ import { isRef, resolveIfRef, splitRef } from "./refs.js";
228
780
  * `FooRequestQueryParameters`. Request bodies that can be represented directly
229
781
  * (the sole parameter, or a non-clashing object reference) are inlined rather
230
782
  * than given a wrapper model, and recorded on `op.explicitRequestBodyParameter`.
231
- */ const buildRequestParameterModels = (op, modelsByName)=>{
783
+ */ const buildRequestParameterModels = (op, modelsByName, takenModelNames)=>{
232
784
  if (!op.parameters || op.parameters.length === 0) {
233
785
  return [];
234
786
  }
@@ -257,7 +809,7 @@ import { isRef, resolveIfRef, splitRef } from "./refs.js";
257
809
  // The body parameter was already renamed to "body" by augmentBodyParameter.
258
810
  op.explicitRequestBodyParameter = parametersByPosition['body']?.[0];
259
811
  return Object.entries(parametersByPosition).map(([position, parameters])=>{
260
- const name = `${op.operationIdPascalCase}Request${upperFirst(position)}Parameters`;
812
+ const name = uniqueGeneratedName(`${op.operationIdPascalCase}Request${upperFirst(position)}Parameters`, takenModelNames);
261
813
  return createModel({
262
814
  description: op.description,
263
815
  export: 'interface',
@@ -286,9 +838,10 @@ import { isRef, resolveIfRef, splitRef } from "./refs.js";
286
838
  }
287
839
  }
288
840
  model.properties.forEach(addLanguageTypes);
289
- // Resolve the discriminator's TypeScript property name for marshalling.
841
+ // Resolve the discriminator's language property names for marshalling.
290
842
  if (model.discriminator) {
291
843
  model.discriminator.typescriptPropertyName = toTypeScriptName(model.discriminator.propertyName);
844
+ model.discriminator.pythonPropertyName = toPythonName('property', model.discriminator.propertyName);
292
845
  }
293
846
  };
294
847
  /**
@@ -309,6 +862,34 @@ import { isRef, resolveIfRef, splitRef } from "./refs.js";
309
862
  seen.set(property.typescriptName, property.name);
310
863
  }
311
864
  };
865
+ /**
866
+ * The Python counterpart of {@link assertNoClashingPropertyNames}, run over the
867
+ * flattened property list an `all-of` composite emits. Called by the Python
868
+ * generator rather than the shared pipeline, so a TypeScript consumer is never
869
+ * failed by a Python-specific name clash. Two members whose wire
870
+ * names snake_case alike (`fooBar` and `foo_bar`) would emit the same field
871
+ * twice, silently keeping only the last, so fail fast instead.
872
+ *
873
+ * A model's own properties are checked as well as the flattened list. The
874
+ * TypeScript assertion does not cover them: escaping a keyword makes `from` and
875
+ * `var_from` distinct in TypeScript (`from`/`varFrom`) but identical in Python,
876
+ * so the class emitted the same field twice and pydantic kept only the last —
877
+ * binding the wire value to the wrong type.
878
+ */ export const assertNoClashingPythonNames = (model)=>{
879
+ const seen = new Map();
880
+ for (const property of [
881
+ ...model.effectiveProperties ?? [],
882
+ ...model.properties ?? []
883
+ ]){
884
+ if (!property.name) continue;
885
+ const pythonName = toPythonName('property', property.name);
886
+ const existing = seen.get(pythonName);
887
+ if (existing !== undefined && existing !== property.name) {
888
+ throw new Error(`Property name conflict in "${model.name}": "${existing}" and "${property.name}" both map to the Python name "${pythonName}". Please rename one of these in your OpenAPI specification.`);
889
+ }
890
+ seen.set(pythonName, property.name);
891
+ }
892
+ };
312
893
  /**
313
894
  * The marshalling semantics of a property — two properties with the same key
314
895
  * convert identically on the wire (so either branch's conversion is safe).
@@ -343,15 +924,15 @@ import { isRef, resolveIfRef, splitRef } from "./refs.js";
343
924
  ...new Set(members)
344
925
  ].join('|')}>`;
345
926
  }
346
- if (referenced.export === 'interface') {
927
+ if (referenced.export === 'interface' || referenced.export === 'all-of') {
347
928
  // Keyed on the shape too, for the same reason: two members declaring an
348
- // identical inline object property are hoisted to different names.
349
- const fields = (referenced.properties ?? []).filter((property)=>property.name).map((property)=>`${property.name}:${property.isRequired ? '!' : '?'}${marshallingKey(property, modelsByName, nested)}`).sort();
929
+ // identical inline object property are hoisted to different names, and
930
+ // keying on the name alone rejected a union whose members convert the same
931
+ // way. `all-of` composes first, so its flattened properties are used.
932
+ const properties = referenced.export === 'all-of' ? referenced.effectiveProperties ?? [] : referenced.properties ?? [];
933
+ const fields = properties.filter((property)=>property.name).map((property)=>`${property.name}:${property.isRequired ? '!' : '?'}${marshallingKey(property, modelsByName, nested)}`).sort();
350
934
  return `object<${fields.join(',')}>`;
351
935
  }
352
- // An `all-of` composes members that are resolved later, so it keeps its name
353
- // as its key rather than a shape this pass cannot see yet.
354
- if (referenced.export === 'all-of') return `ref:${m.type}`;
355
936
  return marshallingKey(referenced, modelsByName, nested);
356
937
  }
357
938
  return 'plain';
@@ -401,12 +982,36 @@ import { isRef, resolveIfRef, splitRef } from "./refs.js";
401
982
  // Match the wire media type the client actually sends: JSON is preferred, so
402
983
  // an array body offering both JSON and urlencoded is sent as JSON and is fine.
403
984
  const chosenMediaType = mediaTypes.find((mt)=>mt === 'application/json' || mt.endsWith('+json')) ?? mediaTypes[0];
404
- if (chosenMediaType !== 'application/x-www-form-urlencoded') return;
985
+ if (chosenMediaType !== 'application/x-www-form-urlencoded') {
986
+ assertEncodableNonJsonBody(op, chosenMediaType, body);
987
+ return;
988
+ }
405
989
  if (body.isPrimitive) return;
406
990
  if (body.export === 'array' || body.export === 'tuple') {
407
991
  throw new Error(`Operation ${op.method} ${op.path} has an application/x-www-form-urlencoded request body whose schema is a ${body.export}, which has no defined form encoding. Use an object schema (its properties become the form fields) or a primitive schema (sent verbatim) in your OpenAPI specification.`);
408
992
  }
409
993
  };
994
+ /**
995
+ * A structured body under a media type that is neither JSON nor a form has no
996
+ * defined encoding. Both generators JSON-encoded it while asserting the declared
997
+ * Content-Type, so an object went out as JSON bytes labelled `text/plain` — which
998
+ * a conforming server rejects or mis-parses. Fail fast instead of shipping a wire
999
+ * form that cannot be right.
1000
+ *
1001
+ * A scalar body is fine: it has a text form of its own and is sent verbatim.
1002
+ */ const assertEncodableNonJsonBody = (op, mediaType, body)=>{
1003
+ const base = mediaType.split(';')[0];
1004
+ if (base === 'application/json' || base.endsWith('+json')) return;
1005
+ if (FORM_BODY_MEDIA_TYPES.has(base)) return;
1006
+ // Binary bodies are sent as raw content, and a scalar as its text form.
1007
+ if (body.type === 'binary' || body.isPrimitive || body.isEnum) return;
1008
+ if (body.export === 'enum' || body.export === 'generic') return;
1009
+ throw new Error(`Operation ${op.method} ${op.path} declares a "${mediaType}" request body whose schema is an object or array, which has no defined encoding for that media type — it would be sent as JSON under a "${mediaType}" header. Declare the body as "application/json", or use a scalar schema which is sent verbatim.`);
1010
+ };
1011
+ const FORM_BODY_MEDIA_TYPES = new Set([
1012
+ 'multipart/form-data',
1013
+ 'application/x-www-form-urlencoded'
1014
+ ]);
410
1015
  /**
411
1016
  * Group operations by their (camelCased) tags, collecting any untagged
412
1017
  * operations separately.