@distilled.cloud/core 1.0.0-rc.3 → 1.0.0-rc.5
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/lib/api.d.ts.map +1 -1
- package/lib/api.js +16 -4
- package/lib/api.js.map +1 -1
- package/lib/category.d.ts +4 -4
- package/lib/category.js +4 -4
- package/lib/codegen/emit.d.ts +2 -2
- package/lib/codegen/emit.js +4 -4
- package/lib/codegen/emit.js.map +1 -1
- package/lib/codegen/generator.d.ts.map +1 -1
- package/lib/codegen/generator.js +16 -3
- package/lib/codegen/generator.js.map +1 -1
- package/lib/codegen/graphql.d.ts +201 -0
- package/lib/codegen/graphql.d.ts.map +1 -0
- package/lib/codegen/graphql.js +789 -0
- package/lib/codegen/graphql.js.map +1 -0
- package/lib/codegen/openapi.d.ts +43 -6
- package/lib/codegen/openapi.d.ts.map +1 -1
- package/lib/codegen/openapi.js +81 -12
- package/lib/codegen/openapi.js.map +1 -1
- package/lib/errors.d.ts.map +1 -1
- package/lib/errors.js +17 -13
- package/lib/errors.js.map +1 -1
- package/lib/pagination.d.ts +43 -3
- package/lib/pagination.d.ts.map +1 -1
- package/lib/pagination.js +84 -5
- package/lib/pagination.js.map +1 -1
- package/lib/protocol-http.d.ts.map +1 -1
- package/lib/protocol-http.js +12 -3
- package/lib/protocol-http.js.map +1 -1
- package/lib/schema.js +1 -1
- package/lib/schema.js.map +1 -1
- package/package.json +8 -8
- package/src/api.ts +18 -4
- package/src/category.ts +4 -4
- package/src/codegen/emit.ts +4 -4
- package/src/codegen/generator.ts +15 -3
- package/src/codegen/graphql.ts +1195 -0
- package/src/codegen/openapi.ts +126 -11
- package/src/errors.ts +20 -25
- package/src/pagination.ts +125 -7
- package/src/protocol-http.ts +11 -3
- package/src/schema.ts +1 -1
|
@@ -0,0 +1,1195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared GraphQL → Smithy 2.0 converter (dev-time only).
|
|
3
|
+
*
|
|
4
|
+
* A GraphQL endpoint has no per-operation URLs, no path/query/header bindings
|
|
5
|
+
* and no response shapes to speak of: there is one POST, one request envelope
|
|
6
|
+
* (`{ query, operationName, variables }`) and one response envelope
|
|
7
|
+
* (`{ data, errors }`). What varies per operation is the *document* — which
|
|
8
|
+
* root field is called, which variables it takes, and which selection set
|
|
9
|
+
* comes back. This module turns an introspection schema into a Smithy model
|
|
10
|
+
* carrying exactly that:
|
|
11
|
+
*
|
|
12
|
+
* • operation → `smithy.api#http` (POST <endpoint>) + the op shape
|
|
13
|
+
* • input struct → `<traits.operation>` { query, operationName, type } —
|
|
14
|
+
* the baked GraphQL document for the op
|
|
15
|
+
* • output struct → `<traits.responsePath>` ("account.byId") — the path
|
|
16
|
+
* under `data` the protocol unwraps
|
|
17
|
+
* • nullability → `<traits.nullable>` on members,
|
|
18
|
+
* `<traits.nullableItems>` on list shapes (GraphQL
|
|
19
|
+
* nullability is not Smithy optionality: a selected
|
|
20
|
+
* field is always *delivered*, it just may be `null`)
|
|
21
|
+
* • bare results → `<traits.payload>` on the sole `result` member when
|
|
22
|
+
* the leaf returns a list/scalar (the response IS that
|
|
23
|
+
* value)
|
|
24
|
+
* • connections → `smithy.api#paginated` when a root field looks like a
|
|
25
|
+
* Relay connection (see {@link RelayOptions})
|
|
26
|
+
*
|
|
27
|
+
* Trait *ids* are caller-supplied so each SDK keeps its own vocabulary
|
|
28
|
+
* namespace; the generator side maps them back to that SDK's trait module.
|
|
29
|
+
*
|
|
30
|
+
* ─── Operation discovery ────────────────────────────────────────────────────
|
|
31
|
+
* Most schemas put operations directly on Query/Mutation, but some namespace
|
|
32
|
+
* them behind objects whose fields are the real operations (`account.byId`).
|
|
33
|
+
* `maxNamespaceDepth` bounds how far that expansion goes; set it to 1 for a
|
|
34
|
+
* flat schema. Selection sets are expanded to `maxDepth` levels, cycles
|
|
35
|
+
* short-circuit, and fields that themselves take arguments are not selected
|
|
36
|
+
* (we have no value to pass them).
|
|
37
|
+
*
|
|
38
|
+
* Originally distilled v0's `core/scripts/generate-graphql.ts`, generalized
|
|
39
|
+
* out of packages/expo-eas.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
// ============================================================================
|
|
43
|
+
// GraphQL introspection types (subset of the canonical spec)
|
|
44
|
+
// ============================================================================
|
|
45
|
+
|
|
46
|
+
export type TypeKind =
|
|
47
|
+
| "SCALAR"
|
|
48
|
+
| "OBJECT"
|
|
49
|
+
| "INTERFACE"
|
|
50
|
+
| "UNION"
|
|
51
|
+
| "ENUM"
|
|
52
|
+
| "INPUT_OBJECT"
|
|
53
|
+
| "LIST"
|
|
54
|
+
| "NON_NULL";
|
|
55
|
+
|
|
56
|
+
export interface TypeRef {
|
|
57
|
+
kind: TypeKind;
|
|
58
|
+
name?: string | null;
|
|
59
|
+
ofType?: TypeRef | null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface InputValue {
|
|
63
|
+
name: string;
|
|
64
|
+
description?: string | null;
|
|
65
|
+
type: TypeRef;
|
|
66
|
+
defaultValue?: string | null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface Field {
|
|
70
|
+
name: string;
|
|
71
|
+
description?: string | null;
|
|
72
|
+
args: InputValue[];
|
|
73
|
+
type: TypeRef;
|
|
74
|
+
isDeprecated?: boolean;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface IntrospectionType {
|
|
78
|
+
kind: TypeKind;
|
|
79
|
+
name: string;
|
|
80
|
+
description?: string | null;
|
|
81
|
+
fields?: Field[] | null;
|
|
82
|
+
inputFields?: InputValue[] | null;
|
|
83
|
+
enumValues?: Array<{ name: string; isDeprecated?: boolean }> | null;
|
|
84
|
+
possibleTypes?: TypeRef[] | null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface IntrospectionSchema {
|
|
88
|
+
queryType: { name: string };
|
|
89
|
+
mutationType?: { name: string } | null;
|
|
90
|
+
subscriptionType?: { name: string } | null;
|
|
91
|
+
types: IntrospectionType[];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Pull the `__schema` out of an introspection response, accepting either the
|
|
96
|
+
* bare schema, `{ __schema }`, or a full `{ data: { __schema } }` envelope —
|
|
97
|
+
* all three are shipped by real mirrors.
|
|
98
|
+
*/
|
|
99
|
+
export const readIntrospection = (json: unknown): IntrospectionSchema => {
|
|
100
|
+
const j = json as {
|
|
101
|
+
data?: { __schema?: IntrospectionSchema };
|
|
102
|
+
__schema?: IntrospectionSchema;
|
|
103
|
+
types?: unknown;
|
|
104
|
+
};
|
|
105
|
+
const schema =
|
|
106
|
+
j?.data?.__schema ?? j?.__schema ?? (j?.types ? (j as any) : undefined);
|
|
107
|
+
if (!schema) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
"introspection JSON has none of `data.__schema`, `__schema`, or a top-level `types`",
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
return schema as IntrospectionSchema;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// ============================================================================
|
|
116
|
+
// Smithy prelude targets
|
|
117
|
+
// ============================================================================
|
|
118
|
+
|
|
119
|
+
export const PRELUDE = {
|
|
120
|
+
String: "smithy.api#String",
|
|
121
|
+
Boolean: "smithy.api#Boolean",
|
|
122
|
+
Integer: "smithy.api#Integer",
|
|
123
|
+
Long: "smithy.api#Long",
|
|
124
|
+
Double: "smithy.api#Double",
|
|
125
|
+
Timestamp: "smithy.api#Timestamp",
|
|
126
|
+
Document: "smithy.api#Document",
|
|
127
|
+
} as const;
|
|
128
|
+
|
|
129
|
+
/** The five scalars every GraphQL schema has. */
|
|
130
|
+
const BUILTIN_SCALARS: Record<string, string> = {
|
|
131
|
+
String: PRELUDE.String,
|
|
132
|
+
ID: PRELUDE.String,
|
|
133
|
+
Int: PRELUDE.Integer,
|
|
134
|
+
Float: PRELUDE.Double,
|
|
135
|
+
Boolean: PRELUDE.Boolean,
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// ============================================================================
|
|
139
|
+
// Options
|
|
140
|
+
// ============================================================================
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The Smithy trait ids this converter stamps. Each SDK owns its own trait
|
|
144
|
+
* namespace (`com.expo.graphql#operation`, `com.railway.graphql#operation`,
|
|
145
|
+
* …) and maps them back to its trait module in its `generate.ts`.
|
|
146
|
+
*/
|
|
147
|
+
export interface GraphQLTraitIds {
|
|
148
|
+
/** On the input struct: the baked `{ query, operationName, type }`. */
|
|
149
|
+
readonly operation: string;
|
|
150
|
+
/** On the output struct: the dotted path under `data` to unwrap. */
|
|
151
|
+
readonly responsePath: string;
|
|
152
|
+
/** On members: the GraphQL type was nullable. */
|
|
153
|
+
readonly nullable: string;
|
|
154
|
+
/** On list shapes: the GraphQL *element* type was nullable. */
|
|
155
|
+
readonly nullableItems: string;
|
|
156
|
+
/** On the sole `result` member: the response IS this value. */
|
|
157
|
+
readonly payload: string;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Relay connection detection. A root field paginates when its return type
|
|
162
|
+
* looks like a connection (an `edges` list of objects with a `node`, plus a
|
|
163
|
+
* `pageInfo`) AND it accepts the forward-pagination arguments.
|
|
164
|
+
*
|
|
165
|
+
* The emitted `smithy.api#paginated` trait drives `.pages()` / `.items()` in
|
|
166
|
+
* the generated SDK; `items` deliberately points *through* the edges
|
|
167
|
+
* (`edges.node`) so `.items()` streams nodes rather than edge wrappers.
|
|
168
|
+
*/
|
|
169
|
+
export interface RelayOptions {
|
|
170
|
+
/** Cursor argument fed the previous page's `endCursor`. Default `"after"`. */
|
|
171
|
+
readonly after?: string;
|
|
172
|
+
/** Page-size argument. Default `"first"`. */
|
|
173
|
+
readonly first?: string;
|
|
174
|
+
/** Connection field holding the edge list. Default `"edges"`. */
|
|
175
|
+
readonly edges?: string;
|
|
176
|
+
/** Edge field holding the item. Default `"node"`. */
|
|
177
|
+
readonly node?: string;
|
|
178
|
+
/** Connection field holding the cursor/termination block. Default `"pageInfo"`. */
|
|
179
|
+
readonly pageInfo?: string;
|
|
180
|
+
/** `pageInfo` field holding the next cursor. Default `"endCursor"`. */
|
|
181
|
+
readonly endCursor?: string;
|
|
182
|
+
/** `pageInfo` field saying whether more pages exist. Default `"hasNextPage"`. */
|
|
183
|
+
readonly hasNextPage?: string;
|
|
184
|
+
/** Pagination mode written into the trait. Default `"relay"`. */
|
|
185
|
+
readonly mode?: string;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export interface GraphQLConvertOptions {
|
|
189
|
+
/** The introspection schema (see {@link readIntrospection}). */
|
|
190
|
+
readonly schema: IntrospectionSchema;
|
|
191
|
+
/** Smithy namespace for every generated shape, e.g. `"com.railway.api"`. */
|
|
192
|
+
readonly namespace: string;
|
|
193
|
+
/** Local name of the service shape, e.g. `"Railway"`. */
|
|
194
|
+
readonly serviceName: string;
|
|
195
|
+
/** `smithy.api#title` on the service shape. */
|
|
196
|
+
readonly serviceTitle: string;
|
|
197
|
+
/** `smithy.api#documentation` on the service shape. */
|
|
198
|
+
readonly serviceDocumentation?: string;
|
|
199
|
+
/** Service `version` field. Default `"1.0"`. */
|
|
200
|
+
readonly serviceVersion?: string;
|
|
201
|
+
/** HTTP path every operation POSTs to, e.g. `"/graphql/v2"`. */
|
|
202
|
+
readonly endpoint: string;
|
|
203
|
+
/** Trait ids to stamp (see {@link GraphQLTraitIds}). */
|
|
204
|
+
readonly traits: GraphQLTraitIds;
|
|
205
|
+
/** How many levels of selection set to expand. Default `3`. */
|
|
206
|
+
readonly maxDepth?: number;
|
|
207
|
+
/**
|
|
208
|
+
* How many namespace objects an operation may sit behind. `1` means the
|
|
209
|
+
* schema is flat (every operation is a direct Query/Mutation field).
|
|
210
|
+
* Default `3`.
|
|
211
|
+
*/
|
|
212
|
+
readonly maxNamespaceDepth?: number;
|
|
213
|
+
/** Skip `@deprecated` root fields and namespace subfields. Default `true`. */
|
|
214
|
+
readonly skipDeprecated?: boolean;
|
|
215
|
+
/** Drop root fields by name (placeholders like `_doNotUse`). */
|
|
216
|
+
readonly skipRootField?: (name: string) => boolean;
|
|
217
|
+
/**
|
|
218
|
+
* Custom (non-builtin) scalar name → Smithy prelude target. Unmapped
|
|
219
|
+
* customs fall back to `smithy.api#Document` so nothing hard-fails, but
|
|
220
|
+
* mapping them keeps the generated types honest.
|
|
221
|
+
*/
|
|
222
|
+
readonly customScalars?: Record<string, string>;
|
|
223
|
+
/** Relay connection pagination. Omit (or `false`) to emit no `paginated`. */
|
|
224
|
+
readonly relay?: RelayOptions | false;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export interface GraphQLConvertResult {
|
|
228
|
+
/** The Smithy 2.0 model, ready to `JSON.stringify`. */
|
|
229
|
+
readonly model: {
|
|
230
|
+
smithy: "2.0";
|
|
231
|
+
metadata: Record<string, unknown>;
|
|
232
|
+
shapes: Record<string, any>;
|
|
233
|
+
};
|
|
234
|
+
readonly converted: number;
|
|
235
|
+
readonly failed: number;
|
|
236
|
+
readonly paginated: number;
|
|
237
|
+
readonly shapeCount: number;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ============================================================================
|
|
241
|
+
// Small helpers
|
|
242
|
+
// ============================================================================
|
|
243
|
+
|
|
244
|
+
const capitalize = (s: string): string =>
|
|
245
|
+
s.charAt(0).toUpperCase() + s.slice(1);
|
|
246
|
+
|
|
247
|
+
const toPascalCase = (s: string): string =>
|
|
248
|
+
capitalize(s.replace(/[^a-zA-Z0-9]+(.)/g, (_, c: string) => c.toUpperCase()));
|
|
249
|
+
|
|
250
|
+
const unwrapNonNull = (t: TypeRef): { type: TypeRef; nonNull: boolean } =>
|
|
251
|
+
t.kind === "NON_NULL" && t.ofType
|
|
252
|
+
? { type: t.ofType, nonNull: true }
|
|
253
|
+
: { type: t, nonNull: false };
|
|
254
|
+
|
|
255
|
+
/** Render a GraphQL type ref back to its SDL form (e.g. `[ID!]!`). */
|
|
256
|
+
const renderTypeRef = (t: TypeRef): string => {
|
|
257
|
+
if (t.kind === "NON_NULL" && t.ofType) return `${renderTypeRef(t.ofType)}!`;
|
|
258
|
+
if (t.kind === "LIST" && t.ofType) return `[${renderTypeRef(t.ofType)}]`;
|
|
259
|
+
return t.name ?? "Unknown";
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const local = (id: string): string => id.split("#")[1] ?? id;
|
|
263
|
+
|
|
264
|
+
const memberIdent = (name: string): string => {
|
|
265
|
+
let out = name.replace(/[^A-Za-z0-9_]/g, "_");
|
|
266
|
+
if (/^[0-9]/.test(out)) out = `_${out}`;
|
|
267
|
+
return out || "_";
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const enumMemberName = (value: string): string => {
|
|
271
|
+
let out = value
|
|
272
|
+
.toUpperCase()
|
|
273
|
+
.replace(/[^A-Z0-9]+/g, "_")
|
|
274
|
+
.replace(/^_+|_+$/g, "");
|
|
275
|
+
if (out === "") out = "VALUE";
|
|
276
|
+
if (/^[0-9]/.test(out)) out = `_${out}`;
|
|
277
|
+
return out;
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
const oneLineDoc = (s: string | null | undefined): string | undefined => {
|
|
281
|
+
const t = s?.replace(/\s+/g, " ").trim();
|
|
282
|
+
return t ? t : undefined;
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
/** Strip NON_NULL and LIST wrappers down to the named type underneath. */
|
|
286
|
+
const namedType = (ref: TypeRef): TypeRef => {
|
|
287
|
+
let t = unwrapNonNull(ref).type;
|
|
288
|
+
while (t.kind === "LIST" && t.ofType) t = unwrapNonNull(t.ofType).type;
|
|
289
|
+
return t;
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
// ============================================================================
|
|
293
|
+
// Relay connection recognition
|
|
294
|
+
// ============================================================================
|
|
295
|
+
|
|
296
|
+
/** {@link RelayOptions} with every field resolved. */
|
|
297
|
+
type Relay = Required<RelayOptions>;
|
|
298
|
+
|
|
299
|
+
const RELAY_DEFAULTS: Relay = {
|
|
300
|
+
after: "after",
|
|
301
|
+
first: "first",
|
|
302
|
+
edges: "edges",
|
|
303
|
+
node: "node",
|
|
304
|
+
pageInfo: "pageInfo",
|
|
305
|
+
endCursor: "endCursor",
|
|
306
|
+
hasNextPage: "hasNextPage",
|
|
307
|
+
mode: "relay",
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
interface RelayCtx {
|
|
311
|
+
readonly cfg: Relay;
|
|
312
|
+
/** Type name → true when it is a connection (memoized). */
|
|
313
|
+
readonly isConnection: (name: string | null | undefined) => boolean;
|
|
314
|
+
/** Type name → true when it is a connection's edge type (memoized). */
|
|
315
|
+
readonly isEdge: (name: string | null | undefined) => boolean;
|
|
316
|
+
/** Type name → true when it is a connection's pageInfo type. */
|
|
317
|
+
readonly isPageInfo: (name: string | null | undefined) => boolean;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const makeRelayCtx = (
|
|
321
|
+
cfg: Relay,
|
|
322
|
+
typeMap: Map<string, IntrospectionType>,
|
|
323
|
+
): RelayCtx => {
|
|
324
|
+
const connections = new Map<string, boolean>();
|
|
325
|
+
const edges = new Set<string>();
|
|
326
|
+
const pageInfos = new Set<string>();
|
|
327
|
+
|
|
328
|
+
const isConnection = (name: string | null | undefined): boolean => {
|
|
329
|
+
if (!name) return false;
|
|
330
|
+
const cached = connections.get(name);
|
|
331
|
+
if (cached !== undefined) return cached;
|
|
332
|
+
// Register `false` first: a connection whose node type circles back to
|
|
333
|
+
// the connection must not recurse forever.
|
|
334
|
+
connections.set(name, false);
|
|
335
|
+
|
|
336
|
+
const t = typeMap.get(name);
|
|
337
|
+
const fields = t?.kind === "OBJECT" ? (t.fields ?? []) : [];
|
|
338
|
+
const edgesField = fields.find((f) => f.name === cfg.edges);
|
|
339
|
+
const pageInfoField = fields.find((f) => f.name === cfg.pageInfo);
|
|
340
|
+
if (!edgesField || !pageInfoField) return false;
|
|
341
|
+
|
|
342
|
+
const edgeName = namedType(edgesField.type).name;
|
|
343
|
+
const edgeType = edgeName ? typeMap.get(edgeName) : undefined;
|
|
344
|
+
const hasNode = (edgeType?.fields ?? []).some((f) => f.name === cfg.node);
|
|
345
|
+
if (!hasNode) return false;
|
|
346
|
+
|
|
347
|
+
const pageInfoName = namedType(pageInfoField.type).name;
|
|
348
|
+
const pageInfoType = pageInfoName ? typeMap.get(pageInfoName) : undefined;
|
|
349
|
+
const hasCursor = (pageInfoType?.fields ?? []).some(
|
|
350
|
+
(f) => f.name === cfg.endCursor,
|
|
351
|
+
);
|
|
352
|
+
if (!hasCursor) return false;
|
|
353
|
+
|
|
354
|
+
connections.set(name, true);
|
|
355
|
+
if (edgeName) edges.add(edgeName);
|
|
356
|
+
if (pageInfoName) pageInfos.add(pageInfoName);
|
|
357
|
+
return true;
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
// `edges`/`pageInfo` sets are populated as a side effect of recognizing
|
|
361
|
+
// their connection, so classify every object type once up front.
|
|
362
|
+
for (const t of typeMap.values()) {
|
|
363
|
+
if (t.kind === "OBJECT") isConnection(t.name);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return {
|
|
367
|
+
cfg,
|
|
368
|
+
isConnection,
|
|
369
|
+
isEdge: (name) => (name ? edges.has(name) : false),
|
|
370
|
+
isPageInfo: (name) => (name ? pageInfos.has(name) : false),
|
|
371
|
+
};
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
// ============================================================================
|
|
375
|
+
// Selection-set expansion
|
|
376
|
+
// ============================================================================
|
|
377
|
+
|
|
378
|
+
interface SchemaCtx {
|
|
379
|
+
typeMap: Map<string, IntrospectionType>;
|
|
380
|
+
/** Present only when Relay pagination is enabled. */
|
|
381
|
+
relay?: RelayCtx;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
interface SelectionField {
|
|
385
|
+
name: string;
|
|
386
|
+
type: TypeRef;
|
|
387
|
+
children?: SelectionField[];
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Whether stepping from `parentTypeName` into `fieldName` should spend a
|
|
392
|
+
* level of the depth budget.
|
|
393
|
+
*
|
|
394
|
+
* A Relay connection is pure plumbing: `edges { node { … } }` is three
|
|
395
|
+
* nesting levels that carry one level of *meaning*. Charging them against
|
|
396
|
+
* `maxDepth` would leave nothing for the node itself, so the connection
|
|
397
|
+
* wrapper is traversed transparently and the node lands at the depth the
|
|
398
|
+
* connection occupied.
|
|
399
|
+
*/
|
|
400
|
+
const isTransparentHop = (
|
|
401
|
+
ctx: SchemaCtx,
|
|
402
|
+
parentTypeName: string | undefined,
|
|
403
|
+
fieldName: string,
|
|
404
|
+
): boolean => {
|
|
405
|
+
const relay = ctx.relay;
|
|
406
|
+
if (!relay || !parentTypeName) return false;
|
|
407
|
+
if (relay.isConnection(parentTypeName)) {
|
|
408
|
+
return fieldName === relay.cfg.edges || fieldName === relay.cfg.pageInfo;
|
|
409
|
+
}
|
|
410
|
+
if (relay.isEdge(parentTypeName)) return fieldName === relay.cfg.node;
|
|
411
|
+
return false;
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Recursively expand the selection set for an object/interface return type up
|
|
416
|
+
* to `maxDepth` levels. Scalars/enums become leaves. Cycles short-circuit by
|
|
417
|
+
* tracking type names through `seen`.
|
|
418
|
+
*/
|
|
419
|
+
const expandSelection = (
|
|
420
|
+
ref: TypeRef,
|
|
421
|
+
ctx: SchemaCtx,
|
|
422
|
+
depth: number,
|
|
423
|
+
maxDepth: number,
|
|
424
|
+
seen: Set<string>,
|
|
425
|
+
): SelectionField[] | undefined => {
|
|
426
|
+
const { type } = unwrapNonNull(ref);
|
|
427
|
+
|
|
428
|
+
if (type.kind === "LIST" && type.ofType) {
|
|
429
|
+
return expandSelection(type.ofType, ctx, depth, maxDepth, seen);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (type.kind !== "OBJECT" && type.kind !== "INTERFACE") return undefined;
|
|
433
|
+
if (!type.name) return undefined;
|
|
434
|
+
if (seen.has(type.name)) return undefined;
|
|
435
|
+
if (depth > maxDepth) return undefined;
|
|
436
|
+
|
|
437
|
+
const concrete = ctx.typeMap.get(type.name);
|
|
438
|
+
if (!concrete?.fields) return undefined;
|
|
439
|
+
|
|
440
|
+
const nextSeen = new Set([...seen, type.name]);
|
|
441
|
+
const result: SelectionField[] = [];
|
|
442
|
+
|
|
443
|
+
for (const field of concrete.fields) {
|
|
444
|
+
// Skip fields that themselves require args — without user input we can't
|
|
445
|
+
// safely populate them, so we just don't select them.
|
|
446
|
+
if (field.args && field.args.length > 0) continue;
|
|
447
|
+
|
|
448
|
+
const { type: fieldType } = unwrapNonNull(field.type);
|
|
449
|
+
let actualType = fieldType;
|
|
450
|
+
if (actualType.kind === "LIST" && actualType.ofType) {
|
|
451
|
+
actualType = unwrapNonNull(actualType.ofType).type;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
let children: SelectionField[] | undefined;
|
|
455
|
+
if (actualType.kind === "OBJECT" || actualType.kind === "INTERFACE") {
|
|
456
|
+
const transparent = isTransparentHop(ctx, type.name, field.name);
|
|
457
|
+
children = expandSelection(
|
|
458
|
+
field.type,
|
|
459
|
+
ctx,
|
|
460
|
+
transparent ? depth : depth + 1,
|
|
461
|
+
maxDepth,
|
|
462
|
+
nextSeen,
|
|
463
|
+
);
|
|
464
|
+
if (!children || children.length === 0) {
|
|
465
|
+
// Can't recurse further — skip nested object fields beyond depth.
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
} else if (actualType.kind === "UNION") {
|
|
469
|
+
// Unions need fragments to select fields — skip for the generic walker.
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
result.push({ name: field.name, type: field.type, children });
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
return result;
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
/** Render a SelectionField list as a GraphQL selection-set string. */
|
|
480
|
+
const renderSelectionSet = (fields: SelectionField[], indent: string): string =>
|
|
481
|
+
fields
|
|
482
|
+
.map((f) =>
|
|
483
|
+
f.children && f.children.length > 0
|
|
484
|
+
? `${indent}${f.name} {\n${renderSelectionSet(f.children, indent + " ")}\n${indent}}`
|
|
485
|
+
: `${indent}${f.name}`,
|
|
486
|
+
)
|
|
487
|
+
.join("\n");
|
|
488
|
+
|
|
489
|
+
// ============================================================================
|
|
490
|
+
// Operation-path discovery
|
|
491
|
+
// ============================================================================
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* A path through the GraphQL schema from a Query/Mutation root to a leaf
|
|
495
|
+
* operation. For top-level operations the path has one segment; for ones
|
|
496
|
+
* that go through a namespace object (e.g. `account.byId`) it has multiple.
|
|
497
|
+
*/
|
|
498
|
+
interface OperationStep {
|
|
499
|
+
name: string;
|
|
500
|
+
args: InputValue[];
|
|
501
|
+
returnType: TypeRef;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Decide how to emit operations for a top-level Query/Mutation field.
|
|
506
|
+
*
|
|
507
|
+
* - If selection-set expansion produces real subfields, emit a single op for
|
|
508
|
+
* this path.
|
|
509
|
+
* - If the field's return type is an OBJECT/INTERFACE whose subfields all
|
|
510
|
+
* require args (a "namespace" pattern), recurse into each subfield and
|
|
511
|
+
* emit one op per leaf.
|
|
512
|
+
* - Otherwise emit one op for the field directly (scalar/enum return types).
|
|
513
|
+
*/
|
|
514
|
+
const collectOperationPaths = (
|
|
515
|
+
field: Field,
|
|
516
|
+
parentChain: OperationStep[],
|
|
517
|
+
ctx: SchemaCtx,
|
|
518
|
+
maxNamespaceDepth: number,
|
|
519
|
+
skipDeprecated: boolean,
|
|
520
|
+
): OperationStep[][] => {
|
|
521
|
+
const step: OperationStep = {
|
|
522
|
+
name: field.name,
|
|
523
|
+
args: field.args ?? [],
|
|
524
|
+
returnType: field.type,
|
|
525
|
+
};
|
|
526
|
+
const chain = [...parentChain, step];
|
|
527
|
+
|
|
528
|
+
// Try to expand the selection set on this field's return type. If we get a
|
|
529
|
+
// real selection, emit a single op terminating here.
|
|
530
|
+
const selection = expandSelection(field.type, ctx, 1, 3, new Set());
|
|
531
|
+
if (selection && selection.length > 0) {
|
|
532
|
+
return [chain];
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// No selection: this is either a namespace-style field or a scalar leaf.
|
|
536
|
+
const { type: returnType } = unwrapNonNull(field.type);
|
|
537
|
+
let actualReturn = returnType;
|
|
538
|
+
if (actualReturn.kind === "LIST" && actualReturn.ofType) {
|
|
539
|
+
actualReturn = unwrapNonNull(actualReturn.ofType).type;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const isObject =
|
|
543
|
+
(actualReturn.kind === "OBJECT" || actualReturn.kind === "INTERFACE") &&
|
|
544
|
+
!!actualReturn.name;
|
|
545
|
+
|
|
546
|
+
if (!isObject || chain.length >= maxNamespaceDepth) {
|
|
547
|
+
return [chain];
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const namespaceType = ctx.typeMap.get(actualReturn.name!);
|
|
551
|
+
if (!namespaceType?.fields || namespaceType.fields.length === 0) {
|
|
552
|
+
return [chain];
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const paths: OperationStep[][] = [];
|
|
556
|
+
for (const subfield of namespaceType.fields) {
|
|
557
|
+
if (skipDeprecated && subfield.isDeprecated) continue;
|
|
558
|
+
paths.push(
|
|
559
|
+
...collectOperationPaths(
|
|
560
|
+
subfield,
|
|
561
|
+
chain,
|
|
562
|
+
ctx,
|
|
563
|
+
maxNamespaceDepth,
|
|
564
|
+
skipDeprecated,
|
|
565
|
+
),
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
return paths.length > 0 ? paths : [chain];
|
|
570
|
+
};
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Build the operation function name from a path: `channels.byId` →
|
|
574
|
+
* `channelsById`.
|
|
575
|
+
*/
|
|
576
|
+
const pathToFunctionName = (opPath: OperationStep[]): string => {
|
|
577
|
+
if (opPath.length === 0) throw new Error("empty path");
|
|
578
|
+
if (opPath.length === 1) return opPath[0]!.name;
|
|
579
|
+
return (
|
|
580
|
+
opPath[0]!.name +
|
|
581
|
+
opPath
|
|
582
|
+
.slice(1)
|
|
583
|
+
.map((s) => toPascalCase(s.name))
|
|
584
|
+
.join("")
|
|
585
|
+
);
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Resolve variable-name collisions across path segments: a later segment's
|
|
590
|
+
* clashing arg is renamed `<stepName><ArgName>` (then numbered).
|
|
591
|
+
*/
|
|
592
|
+
const buildArgRenames = (opPath: OperationStep[]): Map<string, string> => {
|
|
593
|
+
const argRenames = new Map<string, string>();
|
|
594
|
+
const usedVarNames = new Set<string>();
|
|
595
|
+
for (const step of opPath) {
|
|
596
|
+
for (const arg of step.args) {
|
|
597
|
+
let varName = arg.name;
|
|
598
|
+
if (usedVarNames.has(varName)) {
|
|
599
|
+
varName = `${step.name}${toPascalCase(arg.name)}`;
|
|
600
|
+
let i = 2;
|
|
601
|
+
while (usedVarNames.has(varName)) {
|
|
602
|
+
varName = `${step.name}${toPascalCase(arg.name)}${i++}`;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
usedVarNames.add(varName);
|
|
606
|
+
argRenames.set(`${step.name}.${arg.name}`, varName);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
return argRenames;
|
|
610
|
+
};
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Build the GraphQL document string for an operation walking through `path`.
|
|
614
|
+
* Variables are scoped to the OUTER operation; each step that has args
|
|
615
|
+
* references those variables by (renamed) name.
|
|
616
|
+
*/
|
|
617
|
+
const buildPathDocument = (
|
|
618
|
+
type: "query" | "mutation",
|
|
619
|
+
operationName: string,
|
|
620
|
+
opPath: OperationStep[],
|
|
621
|
+
selection: SelectionField[] | undefined,
|
|
622
|
+
argRenames: Map<string, string>,
|
|
623
|
+
): string => {
|
|
624
|
+
const varDefs: string[] = [];
|
|
625
|
+
for (const step of opPath) {
|
|
626
|
+
for (const arg of step.args) {
|
|
627
|
+
const varName = argRenames.get(`${step.name}.${arg.name}`) ?? arg.name;
|
|
628
|
+
varDefs.push(`$${varName}: ${renderTypeRef(arg.type)}`);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const header = varDefs.length
|
|
633
|
+
? `${type} ${operationName}(${varDefs.join(", ")})`
|
|
634
|
+
: `${type} ${operationName}`;
|
|
635
|
+
|
|
636
|
+
const renderStep = (
|
|
637
|
+
step: OperationStep,
|
|
638
|
+
inner: string,
|
|
639
|
+
indent: string,
|
|
640
|
+
): string => {
|
|
641
|
+
const argList = step.args
|
|
642
|
+
.map((arg) => {
|
|
643
|
+
const varName = argRenames.get(`${step.name}.${arg.name}`) ?? arg.name;
|
|
644
|
+
return `${arg.name}: $${varName}`;
|
|
645
|
+
})
|
|
646
|
+
.join(", ");
|
|
647
|
+
const call = argList ? `${step.name}(${argList})` : step.name;
|
|
648
|
+
return `${indent}${call} {\n${inner}\n${indent}}`;
|
|
649
|
+
};
|
|
650
|
+
|
|
651
|
+
let body: string;
|
|
652
|
+
const innerIndent = " ".repeat(opPath.length + 1);
|
|
653
|
+
if (selection && selection.length > 0) {
|
|
654
|
+
body = renderSelectionSet(selection, innerIndent);
|
|
655
|
+
} else {
|
|
656
|
+
// No selectable subfields — fall back to __typename so the document is
|
|
657
|
+
// always syntactically valid.
|
|
658
|
+
body = `${innerIndent}__typename`;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
let nested = body;
|
|
662
|
+
for (let i = opPath.length - 1; i >= 0; i--) {
|
|
663
|
+
const indent = " ".repeat(i + 1);
|
|
664
|
+
nested = renderStep(opPath[i]!, nested, indent);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
return `${header} {\n${nested}\n}`;
|
|
668
|
+
};
|
|
669
|
+
|
|
670
|
+
// ============================================================================
|
|
671
|
+
// Smithy shape construction
|
|
672
|
+
// ============================================================================
|
|
673
|
+
|
|
674
|
+
interface Bag {
|
|
675
|
+
shapes: Record<string, any>;
|
|
676
|
+
names: Set<string>;
|
|
677
|
+
namespace: string;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/** Add a shape under a unique PascalCase-ish name; returns its shape id. */
|
|
681
|
+
const addShape = (bag: Bag, base: string, def: any): string => {
|
|
682
|
+
let want = memberIdent(base);
|
|
683
|
+
want = capitalize(want);
|
|
684
|
+
let name = want;
|
|
685
|
+
let n = 2;
|
|
686
|
+
while (bag.names.has(name)) name = `${want}${n++}`;
|
|
687
|
+
bag.names.add(name);
|
|
688
|
+
const id = `${bag.namespace}#${name}`;
|
|
689
|
+
bag.shapes[id] = def;
|
|
690
|
+
return id;
|
|
691
|
+
};
|
|
692
|
+
|
|
693
|
+
/** Add a shape under an exact (pre-reserved) name. */
|
|
694
|
+
const addExact = (bag: Bag, name: string, def: any): string => {
|
|
695
|
+
const id = `${bag.namespace}#${name}`;
|
|
696
|
+
bag.shapes[id] = def;
|
|
697
|
+
return id;
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
interface Converter {
|
|
701
|
+
bag: Bag;
|
|
702
|
+
ctx: SchemaCtx;
|
|
703
|
+
traits: GraphQLTraitIds;
|
|
704
|
+
/** GraphQL scalar name → smithy target. */
|
|
705
|
+
scalarTarget: (name: string) => string;
|
|
706
|
+
/** GraphQL enum type name → shared smithy enum shape id (memoized). */
|
|
707
|
+
enumShape: (name: string) => string;
|
|
708
|
+
/** GraphQL input-object type name → shared smithy structure id (memoized). */
|
|
709
|
+
inputObjectShape: (name: string) => string;
|
|
710
|
+
/** Input type ref → { smithy target, GraphQL-nullability }. */
|
|
711
|
+
inputTypeTarget: (ref: TypeRef) => { target: string; nullable: boolean };
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const makeConverter = (
|
|
715
|
+
bag: Bag,
|
|
716
|
+
ctx: SchemaCtx,
|
|
717
|
+
traits: GraphQLTraitIds,
|
|
718
|
+
customScalars: Record<string, string>,
|
|
719
|
+
): Converter => {
|
|
720
|
+
const enumMemo = new Map<string, string>();
|
|
721
|
+
const inputMemo = new Map<string, string>();
|
|
722
|
+
const inputListMemo = new Map<string, string>();
|
|
723
|
+
|
|
724
|
+
const scalarTarget = (name: string): string =>
|
|
725
|
+
BUILTIN_SCALARS[name] ?? customScalars[name] ?? PRELUDE.Document;
|
|
726
|
+
|
|
727
|
+
const enumShape = (name: string): string => {
|
|
728
|
+
const cached = enumMemo.get(name);
|
|
729
|
+
if (cached) return cached;
|
|
730
|
+
const t = ctx.typeMap.get(name);
|
|
731
|
+
const members: Record<string, any> = {};
|
|
732
|
+
const used = new Set<string>();
|
|
733
|
+
for (const v of t?.enumValues ?? []) {
|
|
734
|
+
let mn = enumMemberName(v.name);
|
|
735
|
+
let k = 2;
|
|
736
|
+
while (used.has(mn)) mn = `${enumMemberName(v.name)}_${k++}`;
|
|
737
|
+
used.add(mn);
|
|
738
|
+
members[mn] = {
|
|
739
|
+
target: "smithy.api#Unit",
|
|
740
|
+
traits: { "smithy.api#enumValue": v.name },
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
const id = addShape(bag, name, { type: "enum", members });
|
|
744
|
+
enumMemo.set(name, id);
|
|
745
|
+
return id;
|
|
746
|
+
};
|
|
747
|
+
|
|
748
|
+
const inputTypeTarget = (
|
|
749
|
+
ref: TypeRef,
|
|
750
|
+
): { target: string; nullable: boolean } => {
|
|
751
|
+
const { type, nonNull } = unwrapNonNull(ref);
|
|
752
|
+
const nullable = !nonNull;
|
|
753
|
+
if (type.kind === "LIST" && type.ofType) {
|
|
754
|
+
const elem = inputTypeTarget(type.ofType);
|
|
755
|
+
const key = `${elem.target}|${elem.nullable}`;
|
|
756
|
+
let listId = inputListMemo.get(key);
|
|
757
|
+
if (!listId) {
|
|
758
|
+
listId = addShape(bag, `${local(elem.target)}List`, {
|
|
759
|
+
type: "list",
|
|
760
|
+
member: { target: elem.target },
|
|
761
|
+
...(elem.nullable ? { traits: { [traits.nullableItems]: {} } } : {}),
|
|
762
|
+
});
|
|
763
|
+
inputListMemo.set(key, listId);
|
|
764
|
+
}
|
|
765
|
+
return { target: listId, nullable };
|
|
766
|
+
}
|
|
767
|
+
if (type.kind === "SCALAR" && type.name) {
|
|
768
|
+
return { target: scalarTarget(type.name), nullable };
|
|
769
|
+
}
|
|
770
|
+
if (type.kind === "ENUM" && type.name) {
|
|
771
|
+
return { target: enumShape(type.name), nullable };
|
|
772
|
+
}
|
|
773
|
+
if (type.kind === "INPUT_OBJECT" && type.name) {
|
|
774
|
+
return { target: inputObjectShape(type.name), nullable };
|
|
775
|
+
}
|
|
776
|
+
return { target: PRELUDE.Document, nullable };
|
|
777
|
+
};
|
|
778
|
+
|
|
779
|
+
const inputObjectShape = (name: string): string => {
|
|
780
|
+
const cached = inputMemo.get(name);
|
|
781
|
+
if (cached) return cached;
|
|
782
|
+
const t = ctx.typeMap.get(name);
|
|
783
|
+
// Register before filling members so input-object cycles resolve to the
|
|
784
|
+
// shared shape (named smithy shapes reference each other naturally).
|
|
785
|
+
const def: any = { type: "structure", members: {} };
|
|
786
|
+
const id = addShape(bag, name, def);
|
|
787
|
+
inputMemo.set(name, id);
|
|
788
|
+
const doc = oneLineDoc(t?.description);
|
|
789
|
+
if (doc) def.traits = { "smithy.api#documentation": doc };
|
|
790
|
+
for (const f of t?.inputFields ?? []) {
|
|
791
|
+
const { nonNull } = unwrapNonNull(f.type);
|
|
792
|
+
const { target, nullable } = inputTypeTarget(f.type);
|
|
793
|
+
const memberTraits: Record<string, any> = {};
|
|
794
|
+
if (nonNull) memberTraits["smithy.api#required"] = {};
|
|
795
|
+
if (nullable) memberTraits[traits.nullable] = {};
|
|
796
|
+
const fdoc = oneLineDoc(f.description);
|
|
797
|
+
if (fdoc) memberTraits["smithy.api#documentation"] = fdoc;
|
|
798
|
+
def.members[memberIdent(f.name)] = {
|
|
799
|
+
target,
|
|
800
|
+
...(Object.keys(memberTraits).length ? { traits: memberTraits } : {}),
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
return id;
|
|
804
|
+
};
|
|
805
|
+
|
|
806
|
+
return {
|
|
807
|
+
bag,
|
|
808
|
+
ctx,
|
|
809
|
+
traits,
|
|
810
|
+
scalarTarget,
|
|
811
|
+
enumShape,
|
|
812
|
+
inputObjectShape,
|
|
813
|
+
inputTypeTarget,
|
|
814
|
+
};
|
|
815
|
+
};
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* Output type ref + selection → { smithy target, GraphQL-nullability }.
|
|
819
|
+
* Selection structs are per-operation (hint-named); enums are shared.
|
|
820
|
+
*/
|
|
821
|
+
const buildOutputTarget = (
|
|
822
|
+
conv: Converter,
|
|
823
|
+
ref: TypeRef,
|
|
824
|
+
children: SelectionField[] | undefined,
|
|
825
|
+
hint: string,
|
|
826
|
+
): { target: string; nullable: boolean } => {
|
|
827
|
+
const { type, nonNull } = unwrapNonNull(ref);
|
|
828
|
+
const nullable = !nonNull;
|
|
829
|
+
|
|
830
|
+
if (type.kind === "LIST" && type.ofType) {
|
|
831
|
+
const elem = buildOutputTarget(conv, type.ofType, children, `${hint}Item`);
|
|
832
|
+
const target = addShape(conv.bag, `${hint}List`, {
|
|
833
|
+
type: "list",
|
|
834
|
+
member: { target: elem.target },
|
|
835
|
+
...(elem.nullable ? { traits: { [conv.traits.nullableItems]: {} } } : {}),
|
|
836
|
+
});
|
|
837
|
+
return { target, nullable };
|
|
838
|
+
}
|
|
839
|
+
if (type.kind === "SCALAR" && type.name) {
|
|
840
|
+
return { target: conv.scalarTarget(type.name), nullable };
|
|
841
|
+
}
|
|
842
|
+
if (type.kind === "ENUM" && type.name) {
|
|
843
|
+
return { target: conv.enumShape(type.name), nullable };
|
|
844
|
+
}
|
|
845
|
+
if (
|
|
846
|
+
(type.kind === "OBJECT" || type.kind === "INTERFACE") &&
|
|
847
|
+
children &&
|
|
848
|
+
children.length > 0
|
|
849
|
+
) {
|
|
850
|
+
const members = buildSelectionMembers(conv, children, hint);
|
|
851
|
+
const target = addShape(conv.bag, hint, { type: "structure", members });
|
|
852
|
+
return { target, nullable };
|
|
853
|
+
}
|
|
854
|
+
// UNION, selection-less objects, unknowns → opaque.
|
|
855
|
+
return { target: PRELUDE.Document, nullable };
|
|
856
|
+
};
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* Structure members for a selection set. Every selected field is delivered
|
|
860
|
+
* by the server (always present), so members are `required`; GraphQL
|
|
861
|
+
* nullability travels as the `nullable` trait.
|
|
862
|
+
*/
|
|
863
|
+
const buildSelectionMembers = (
|
|
864
|
+
conv: Converter,
|
|
865
|
+
fields: SelectionField[],
|
|
866
|
+
hint: string,
|
|
867
|
+
): Record<string, any> => {
|
|
868
|
+
const members: Record<string, any> = {};
|
|
869
|
+
for (const f of fields) {
|
|
870
|
+
const sub = buildOutputTarget(
|
|
871
|
+
conv,
|
|
872
|
+
f.type,
|
|
873
|
+
f.children,
|
|
874
|
+
`${hint}${toPascalCase(f.name)}`,
|
|
875
|
+
);
|
|
876
|
+
const memberTraits: Record<string, any> = { "smithy.api#required": {} };
|
|
877
|
+
if (sub.nullable) memberTraits[conv.traits.nullable] = {};
|
|
878
|
+
members[memberIdent(f.name)] = { target: sub.target, traits: memberTraits };
|
|
879
|
+
}
|
|
880
|
+
return members;
|
|
881
|
+
};
|
|
882
|
+
|
|
883
|
+
// ============================================================================
|
|
884
|
+
// Convert
|
|
885
|
+
// ============================================================================
|
|
886
|
+
|
|
887
|
+
interface PendingOp {
|
|
888
|
+
functionName: string;
|
|
889
|
+
opName: string;
|
|
890
|
+
type: "query" | "mutation";
|
|
891
|
+
path: OperationStep[];
|
|
892
|
+
description: string | undefined;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* Turn a GraphQL introspection schema into a Smithy 2.0 model.
|
|
897
|
+
*
|
|
898
|
+
* Subscriptions are ignored: they are a streaming transport, not a
|
|
899
|
+
* `POST /graphql` request/response pair, and nothing in the generated SDK
|
|
900
|
+
* could speak them.
|
|
901
|
+
*/
|
|
902
|
+
export const convertGraphQLToSmithy = (
|
|
903
|
+
options: GraphQLConvertOptions,
|
|
904
|
+
): GraphQLConvertResult => {
|
|
905
|
+
const {
|
|
906
|
+
schema,
|
|
907
|
+
namespace,
|
|
908
|
+
serviceName,
|
|
909
|
+
serviceTitle,
|
|
910
|
+
serviceDocumentation,
|
|
911
|
+
serviceVersion = "1.0",
|
|
912
|
+
endpoint,
|
|
913
|
+
traits,
|
|
914
|
+
maxDepth = 3,
|
|
915
|
+
maxNamespaceDepth = 3,
|
|
916
|
+
skipDeprecated = true,
|
|
917
|
+
skipRootField = () => false,
|
|
918
|
+
customScalars = {},
|
|
919
|
+
relay = false,
|
|
920
|
+
} = options;
|
|
921
|
+
|
|
922
|
+
const typeMap = new Map<string, IntrospectionType>();
|
|
923
|
+
for (const t of schema.types) typeMap.set(t.name, t);
|
|
924
|
+
|
|
925
|
+
const relayCfg: Relay | undefined = relay
|
|
926
|
+
? { ...RELAY_DEFAULTS, ...relay }
|
|
927
|
+
: undefined;
|
|
928
|
+
const ctx: SchemaCtx = {
|
|
929
|
+
typeMap,
|
|
930
|
+
relay: relayCfg ? makeRelayCtx(relayCfg, typeMap) : undefined,
|
|
931
|
+
};
|
|
932
|
+
|
|
933
|
+
// ---- Discover operation paths (queries first, then mutations; names are
|
|
934
|
+
// deduped globally so a query and a mutation can't collide) ----
|
|
935
|
+
const pending: PendingOp[] = [];
|
|
936
|
+
const seenNames = new Set<string>();
|
|
937
|
+
|
|
938
|
+
const collectRoot = (rootTypeName: string, type: "query" | "mutation") => {
|
|
939
|
+
const rootType = typeMap.get(rootTypeName);
|
|
940
|
+
if (!rootType?.fields) return;
|
|
941
|
+
for (const field of rootType.fields) {
|
|
942
|
+
if (skipDeprecated && field.isDeprecated) continue;
|
|
943
|
+
if (skipRootField(field.name)) continue;
|
|
944
|
+
const paths = collectOperationPaths(
|
|
945
|
+
field,
|
|
946
|
+
[],
|
|
947
|
+
ctx,
|
|
948
|
+
maxNamespaceDepth,
|
|
949
|
+
skipDeprecated,
|
|
950
|
+
);
|
|
951
|
+
for (const opPath of paths) {
|
|
952
|
+
const functionName = pathToFunctionName(opPath);
|
|
953
|
+
if (seenNames.has(functionName)) continue;
|
|
954
|
+
seenNames.add(functionName);
|
|
955
|
+
pending.push({
|
|
956
|
+
functionName,
|
|
957
|
+
opName: toPascalCase(functionName),
|
|
958
|
+
type,
|
|
959
|
+
path: opPath,
|
|
960
|
+
description:
|
|
961
|
+
opPath.length === 1 ? oneLineDoc(field.description) : undefined,
|
|
962
|
+
});
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
};
|
|
966
|
+
|
|
967
|
+
collectRoot(schema.queryType.name, "query");
|
|
968
|
+
if (schema.mutationType) collectRoot(schema.mutationType.name, "mutation");
|
|
969
|
+
|
|
970
|
+
// ---- Build the smithy model ----
|
|
971
|
+
const bag: Bag = { shapes: {}, names: new Set(), namespace };
|
|
972
|
+
// Reserve the op-derived shape names up front so lazily-created shared
|
|
973
|
+
// type shapes (enums, input objects) can never steal them — the operation
|
|
974
|
+
// shape's local name IS the exported const name (via lowerFirst).
|
|
975
|
+
bag.names.add(serviceName);
|
|
976
|
+
for (const op of pending) {
|
|
977
|
+
bag.names.add(op.opName);
|
|
978
|
+
bag.names.add(`${op.opName}Request`);
|
|
979
|
+
bag.names.add(`${op.opName}Response`);
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
const conv = makeConverter(bag, ctx, traits, customScalars);
|
|
983
|
+
const serviceOps: Array<{ target: string }> = [];
|
|
984
|
+
let converted = 0;
|
|
985
|
+
let failed = 0;
|
|
986
|
+
let paginated = 0;
|
|
987
|
+
|
|
988
|
+
for (const op of pending) {
|
|
989
|
+
try {
|
|
990
|
+
const argRenames = buildArgRenames(op.path);
|
|
991
|
+
const leaf = op.path[op.path.length - 1]!;
|
|
992
|
+
const selection = expandSelection(
|
|
993
|
+
leaf.returnType,
|
|
994
|
+
ctx,
|
|
995
|
+
1,
|
|
996
|
+
maxDepth,
|
|
997
|
+
new Set(),
|
|
998
|
+
);
|
|
999
|
+
const document = buildPathDocument(
|
|
1000
|
+
op.type,
|
|
1001
|
+
op.functionName,
|
|
1002
|
+
op.path,
|
|
1003
|
+
selection,
|
|
1004
|
+
argRenames,
|
|
1005
|
+
);
|
|
1006
|
+
const responsePath = op.path.map((s) => s.name).join(".");
|
|
1007
|
+
|
|
1008
|
+
// ---- Input (the GraphQL variables) ----
|
|
1009
|
+
const inputMembers: Record<string, any> = {};
|
|
1010
|
+
for (const step of op.path) {
|
|
1011
|
+
for (const arg of step.args) {
|
|
1012
|
+
const varName = argRenames.get(`${step.name}.${arg.name}`)!;
|
|
1013
|
+
const { nonNull } = unwrapNonNull(arg.type);
|
|
1014
|
+
const { target, nullable } = conv.inputTypeTarget(arg.type);
|
|
1015
|
+
const memberTraits: Record<string, any> = {};
|
|
1016
|
+
if (nonNull) memberTraits["smithy.api#required"] = {};
|
|
1017
|
+
if (nullable) memberTraits[traits.nullable] = {};
|
|
1018
|
+
const adoc = oneLineDoc(arg.description);
|
|
1019
|
+
if (adoc) memberTraits["smithy.api#documentation"] = adoc;
|
|
1020
|
+
inputMembers[memberIdent(varName)] = {
|
|
1021
|
+
target,
|
|
1022
|
+
...(Object.keys(memberTraits).length
|
|
1023
|
+
? { traits: memberTraits }
|
|
1024
|
+
: {}),
|
|
1025
|
+
};
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
const inputId = addExact(bag, `${op.opName}Request`, {
|
|
1029
|
+
type: "structure",
|
|
1030
|
+
members: inputMembers,
|
|
1031
|
+
traits: {
|
|
1032
|
+
"smithy.api#input": {},
|
|
1033
|
+
[traits.operation]: {
|
|
1034
|
+
query: document,
|
|
1035
|
+
operationName: op.functionName,
|
|
1036
|
+
type: op.type,
|
|
1037
|
+
},
|
|
1038
|
+
},
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
// ---- Output (the selection set under `data.<responsePath>`) ----
|
|
1042
|
+
const { type: retType } = unwrapNonNull(leaf.returnType);
|
|
1043
|
+
const isBareObject =
|
|
1044
|
+
(retType.kind === "OBJECT" || retType.kind === "INTERFACE") &&
|
|
1045
|
+
selection !== undefined &&
|
|
1046
|
+
selection.length > 0;
|
|
1047
|
+
|
|
1048
|
+
let outputId: string;
|
|
1049
|
+
if (isBareObject) {
|
|
1050
|
+
// Object result: the response struct's members ARE the selection.
|
|
1051
|
+
outputId = addExact(bag, `${op.opName}Response`, {
|
|
1052
|
+
type: "structure",
|
|
1053
|
+
members: buildSelectionMembers(
|
|
1054
|
+
conv,
|
|
1055
|
+
selection!,
|
|
1056
|
+
`${op.opName}Response`,
|
|
1057
|
+
),
|
|
1058
|
+
traits: {
|
|
1059
|
+
"smithy.api#output": {},
|
|
1060
|
+
[traits.responsePath]: responsePath,
|
|
1061
|
+
"smithy.api#documentation": `Selection set for \`${responsePath}\` (unwrapped from the GraphQL \`data\` envelope).`,
|
|
1062
|
+
},
|
|
1063
|
+
});
|
|
1064
|
+
} else {
|
|
1065
|
+
// List/scalar/enum/opaque result: a sole `result` member carries the
|
|
1066
|
+
// whole value; the payload trait makes the generated response type
|
|
1067
|
+
// the value itself.
|
|
1068
|
+
const inner = buildOutputTarget(
|
|
1069
|
+
conv,
|
|
1070
|
+
leaf.returnType,
|
|
1071
|
+
selection,
|
|
1072
|
+
`${op.opName}Result`,
|
|
1073
|
+
);
|
|
1074
|
+
outputId = addExact(bag, `${op.opName}Response`, {
|
|
1075
|
+
type: "structure",
|
|
1076
|
+
members: {
|
|
1077
|
+
result: {
|
|
1078
|
+
target: inner.target,
|
|
1079
|
+
traits: {
|
|
1080
|
+
[traits.payload]: {},
|
|
1081
|
+
"smithy.api#required": {},
|
|
1082
|
+
...(inner.nullable ? { [traits.nullable]: {} } : {}),
|
|
1083
|
+
"smithy.api#documentation": `The value of \`data.${responsePath}\`.`,
|
|
1084
|
+
},
|
|
1085
|
+
},
|
|
1086
|
+
},
|
|
1087
|
+
traits: {
|
|
1088
|
+
"smithy.api#output": {},
|
|
1089
|
+
[traits.responsePath]: responsePath,
|
|
1090
|
+
},
|
|
1091
|
+
});
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
// ---- Operation ----
|
|
1095
|
+
const opTraits: Record<string, any> = {
|
|
1096
|
+
"smithy.api#http": { method: "POST", uri: endpoint, code: 200 },
|
|
1097
|
+
};
|
|
1098
|
+
if (op.description) {
|
|
1099
|
+
opTraits["smithy.api#documentation"] = op.description;
|
|
1100
|
+
}
|
|
1101
|
+
if (op.type === "query") opTraits["smithy.api#readonly"] = {};
|
|
1102
|
+
|
|
1103
|
+
// Relay connections paginate. The operation must accept the cursor and
|
|
1104
|
+
// page-size arguments AND return a connection; anything else (a plain
|
|
1105
|
+
// list, a connection reached through a namespace hop whose leaf we
|
|
1106
|
+
// didn't select) stays a one-shot call.
|
|
1107
|
+
const pg =
|
|
1108
|
+
relayCfg && isBareObject
|
|
1109
|
+
? relayPaginatedTrait(relayCfg, ctx, leaf, argRenames)
|
|
1110
|
+
: undefined;
|
|
1111
|
+
if (pg) {
|
|
1112
|
+
opTraits["smithy.api#paginated"] = pg;
|
|
1113
|
+
paginated++;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
const opId = addExact(bag, op.opName, {
|
|
1117
|
+
type: "operation",
|
|
1118
|
+
input: { target: inputId },
|
|
1119
|
+
output: { target: outputId },
|
|
1120
|
+
traits: opTraits,
|
|
1121
|
+
});
|
|
1122
|
+
serviceOps.push({ target: opId });
|
|
1123
|
+
converted++;
|
|
1124
|
+
} catch (err) {
|
|
1125
|
+
failed++;
|
|
1126
|
+
console.error(`❌ ${op.type} ${op.functionName}:`, err);
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
addExact(bag, serviceName, {
|
|
1131
|
+
type: "service",
|
|
1132
|
+
version: serviceVersion,
|
|
1133
|
+
operations: serviceOps,
|
|
1134
|
+
traits: {
|
|
1135
|
+
"smithy.api#title": serviceTitle,
|
|
1136
|
+
...(serviceDocumentation
|
|
1137
|
+
? { "smithy.api#documentation": serviceDocumentation }
|
|
1138
|
+
: {}),
|
|
1139
|
+
},
|
|
1140
|
+
});
|
|
1141
|
+
|
|
1142
|
+
return {
|
|
1143
|
+
model: {
|
|
1144
|
+
smithy: "2.0",
|
|
1145
|
+
metadata: {
|
|
1146
|
+
suppressions: [
|
|
1147
|
+
// Every operation is POST <endpoint> — the whole point of GraphQL.
|
|
1148
|
+
{ id: "HttpUriConflict", namespace: "*" },
|
|
1149
|
+
{ id: "HttpMethodSemantics", namespace: "*" },
|
|
1150
|
+
{ id: "UnreferencedShape", namespace: "*" },
|
|
1151
|
+
],
|
|
1152
|
+
},
|
|
1153
|
+
shapes: bag.shapes,
|
|
1154
|
+
},
|
|
1155
|
+
converted,
|
|
1156
|
+
failed,
|
|
1157
|
+
paginated,
|
|
1158
|
+
shapeCount: Object.keys(bag.shapes).length,
|
|
1159
|
+
};
|
|
1160
|
+
};
|
|
1161
|
+
|
|
1162
|
+
/**
|
|
1163
|
+
* The `smithy.api#paginated` trait for a Relay connection leaf, or undefined
|
|
1164
|
+
* when the operation isn't one.
|
|
1165
|
+
*
|
|
1166
|
+
* `inputToken` is the *renamed* variable (namespace hops can rename a
|
|
1167
|
+
* colliding `after`), so pagination feeds the argument the generated input
|
|
1168
|
+
* actually exposes.
|
|
1169
|
+
*/
|
|
1170
|
+
const relayPaginatedTrait = (
|
|
1171
|
+
cfg: Relay,
|
|
1172
|
+
ctx: SchemaCtx,
|
|
1173
|
+
leaf: OperationStep,
|
|
1174
|
+
argRenames: Map<string, string>,
|
|
1175
|
+
): Record<string, unknown> | undefined => {
|
|
1176
|
+
const relay = ctx.relay;
|
|
1177
|
+
if (!relay) return undefined;
|
|
1178
|
+
if (!relay.isConnection(namedType(leaf.returnType).name)) return undefined;
|
|
1179
|
+
|
|
1180
|
+
const hasArg = (name: string) => leaf.args.some((a) => a.name === name);
|
|
1181
|
+
if (!hasArg(cfg.after) || !hasArg(cfg.first)) return undefined;
|
|
1182
|
+
|
|
1183
|
+
const inputToken = argRenames.get(`${leaf.name}.${cfg.after}`) ?? cfg.after;
|
|
1184
|
+
const pageSize = argRenames.get(`${leaf.name}.${cfg.first}`) ?? cfg.first;
|
|
1185
|
+
|
|
1186
|
+
return {
|
|
1187
|
+
mode: cfg.mode,
|
|
1188
|
+
inputToken,
|
|
1189
|
+
outputToken: `${cfg.pageInfo}.${cfg.endCursor}`,
|
|
1190
|
+
hasNextPage: `${cfg.pageInfo}.${cfg.hasNextPage}`,
|
|
1191
|
+
// Points *through* the edges so `.items()` streams nodes, not wrappers.
|
|
1192
|
+
items: `${cfg.edges}.${cfg.node}`,
|
|
1193
|
+
pageSize,
|
|
1194
|
+
};
|
|
1195
|
+
};
|