@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.
- package/LICENSE-THIRD-PARTY +11686 -23398
- package/README.md +18 -0
- package/generators.json +7 -0
- package/migrations.json +39 -1
- package/package.json +6 -6
- package/src/agentcore-gateway/agent-connection/generator.js +6 -0
- package/src/agentcore-gateway/agent-connection/generator.js.map +1 -1
- package/src/agentcore-gateway/schema.json +1 -0
- package/src/agentcore-harness/generator.d.ts +1 -1
- package/src/connection/scaffold-catalog.d.ts +21 -0
- package/src/connection/scaffold-catalog.js +14 -6
- package/src/connection/scaffold-catalog.js.map +1 -1
- package/src/infra/app/generator.d.ts +1 -1
- package/src/init/generator.d.ts +1 -1
- package/src/mcp-server/guide-pipeline.d.ts +2 -1
- package/src/mcp-server/guide-pipeline.js +6 -0
- package/src/mcp-server/guide-pipeline.js.map +1 -1
- package/src/mcp-server/mdx-ast.d.ts +7 -0
- package/src/mcp-server/mdx-ast.js +15 -0
- package/src/mcp-server/mdx-ast.js.map +1 -1
- package/src/mcp-server/schema-registry.d.ts +2 -2
- package/src/mcp-server/schema-registry.js +2 -2
- package/src/mcp-server/schema-registry.js.map +1 -1
- package/src/migrations/latest/terraform-harness-environment-variables/metadata.json +3 -0
- package/src/migrations/latest/terraform-harness-environment-variables/migration.d.ts +6 -0
- package/src/migrations/latest/terraform-harness-environment-variables/migration.js +54 -0
- package/src/migrations/latest/terraform-harness-environment-variables/migration.js.map +1 -0
- package/src/open-api/py-client/__snapshots__/generator.additional-properties.spec.ts.snap +34 -0
- package/src/open-api/py-client/__snapshots__/generator.arrays.spec.ts.snap +1750 -0
- package/src/open-api/py-client/__snapshots__/generator.complex-types.spec.ts.snap +82 -0
- package/src/open-api/py-client/__snapshots__/generator.composite-types.spec.ts.snap +447 -0
- package/src/open-api/py-client/__snapshots__/generator.duplicate-types.spec.ts.snap +404 -0
- package/src/open-api/py-client/__snapshots__/generator.errors.spec.ts.snap +430 -0
- package/src/open-api/py-client/__snapshots__/generator.fast-api.spec.ts.snap +447 -0
- package/src/open-api/py-client/__snapshots__/generator.petstore.spec.ts.snap +3488 -0
- package/src/open-api/py-client/__snapshots__/generator.primitive-types.spec.ts.snap +1444 -0
- package/src/open-api/py-client/__snapshots__/generator.request.spec.ts.snap +390 -0
- package/src/open-api/py-client/__snapshots__/generator.reserved-keywords.spec.ts.snap +364 -0
- package/src/open-api/py-client/__snapshots__/generator.response.spec.ts.snap +739 -0
- package/src/open-api/py-client/__snapshots__/generator.streaming.spec.ts.snap +384 -0
- package/src/open-api/py-client/__snapshots__/generator.tags.spec.ts.snap +414 -0
- package/src/open-api/py-client/files/client/__clientModuleName__.py.template +1076 -0
- package/src/open-api/py-client/files/shared/__init__.py.template +28 -0
- package/src/open-api/py-client/files/shared/errors.py.template +37 -0
- package/src/open-api/py-client/files/shared/types.py.template +319 -0
- package/src/open-api/py-client/generator.d.ts +53 -0
- package/src/open-api/py-client/generator.js +119 -0
- package/src/open-api/py-client/generator.js.map +1 -0
- package/src/open-api/py-client/schema.d.js +6 -0
- package/src/open-api/py-client/schema.d.js.map +1 -0
- package/src/open-api/py-client/schema.d.ts +12 -0
- package/src/open-api/py-client/schema.json +28 -0
- package/src/open-api/py-client/vessel-registry-spec.json +2716 -0
- package/src/open-api/ts-client/__snapshots__/generator.edge-cases.spec.ts.snap +274 -0
- package/src/open-api/ts-client/petstore-spec.d.ts +6 -0
- package/src/open-api/ts-client/petstore-spec.js +1275 -0
- package/src/open-api/ts-client/petstore-spec.js.map +1 -0
- package/src/open-api/ts-hooks/generator.spec.tsx +1 -1
- package/src/open-api/utils/codegen-data/languages.d.ts +68 -2
- package/src/open-api/utils/codegen-data/languages.js +433 -21
- package/src/open-api/utils/codegen-data/languages.js.map +1 -1
- package/src/open-api/utils/codegen-data/types.d.ts +187 -0
- package/src/open-api/utils/codegen-data/types.js +10 -1
- package/src/open-api/utils/codegen-data/types.js.map +1 -1
- package/src/open-api/utils/codegen-data.d.ts +40 -1
- package/src/open-api/utils/codegen-data.js +622 -17
- package/src/open-api/utils/codegen-data.js.map +1 -1
- package/src/open-api/utils/normalise.js +175 -41
- package/src/open-api/utils/normalise.js.map +1 -1
- package/src/open-api/utils/parser.js +1 -1
- package/src/open-api/utils/parser.js.map +1 -1
- package/src/preset/__snapshots__/generator.spec.ts.snap +3 -3
- package/src/preset/generator.d.ts +1 -1
- package/src/py/agent/gateway-connection/generator.js +0 -3
- package/src/py/agent/gateway-connection/generator.js.map +1 -1
- package/src/py/agent/react-connection/generator.d.ts +1 -1
- package/src/py/agent/schema.json +2 -0
- package/src/py/fast-api/react/generator.d.ts +1 -1
- package/src/py/mcp-server/schema.json +1 -0
- package/src/sdk/open-api.d.ts +2 -0
- package/src/sdk/open-api.js +1 -0
- package/src/sdk/open-api.js.map +1 -1
- package/src/smithy/project/generator.d.ts +1 -1
- package/src/smithy/react-connection/generator.d.ts +1 -1
- package/src/terraform/project/generator.d.ts +1 -1
- package/src/trpc/react/generator.d.ts +1 -1
- package/src/ts/agent/a2a-connection/generator.d.ts +1 -1
- package/src/ts/agent/gateway-connection/generator.d.ts +1 -1
- package/src/ts/agent/gateway-connection/generator.js +0 -3
- package/src/ts/agent/gateway-connection/generator.js.map +1 -1
- package/src/ts/agent/mcp-connection/generator.d.ts +1 -1
- package/src/ts/agent/react-connection/generator.d.ts +1 -1
- package/src/ts/agent/schema.json +1 -0
- package/src/ts/api/schema.json +2 -0
- package/src/ts/astro-docs/generator.d.ts +1 -1
- package/src/ts/dcr-proxy/generator.d.ts +1 -1
- package/src/ts/dynamodb/generator.d.ts +1 -1
- package/src/ts/lambda-function/generator.d.ts +1 -1
- package/src/ts/lib/generator.d.ts +1 -1
- package/src/ts/mcp-server/schema.json +1 -0
- package/src/ts/nx-generator/__snapshots__/generator.spec.ts.snap +1 -1
- package/src/ts/nx-generator/files/nx-plugin-for-aws/generator/generator.spec.ts.template +1 -1
- package/src/ts/nx-generator/generator.d.ts +1 -1
- package/src/ts/nx-migration/generator.d.ts +1 -1
- package/src/ts/nx-plugin/generator.d.ts +1 -1
- package/src/ts/rdb/generator.d.ts +1 -1
- package/src/ts/react-website/agui/generator.d.ts +1 -1
- package/src/ts/react-website/app/__snapshots__/generator.spec.ts.snap +10 -10
- package/src/ts/react-website/app/schema.json +1 -0
- package/src/ts/website/app/schema.json +1 -0
- package/src/utils/agent-core-constructs/files/terraform/app/agentcore-harness/__nameKebabCase__/__nameKebabCase__.tf.template +1 -1
- package/src/utils/metrics-assertions.d.ts +18 -0
- package/src/utils/metrics-assertions.js +42 -0
- package/src/utils/metrics-assertions.js.map +1 -0
- package/src/utils/test/python-dependencies.d.ts +8 -0
- package/src/utils/test/python-dependencies.js +14 -0
- package/src/utils/test/python-dependencies.js.map +1 -0
- package/src/utils/test/python-worker/worker.py +703 -0
- package/src/utils/test/warm-python-cache.d.ts +18 -0
- package/src/utils/test/warm-python-cache.js +46 -0
- package/src/utils/test/warm-python-cache.js.map +1 -0
- package/src/utils/versions.d.ts +10 -9
- package/src/utils/versions.js +9 -8
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*/
|
|
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
|
|
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
|
-
|
|
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')
|
|
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.
|