@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
package/src/codegen/openapi.ts
CHANGED
|
@@ -7,20 +7,26 @@
|
|
|
7
7
|
* `generateService` compiler. Conversion fidelity follows distilled v0's
|
|
8
8
|
* `generate-openapi.ts` feature matrix:
|
|
9
9
|
*
|
|
10
|
-
* • one operation per (path × get/post/put/patch/delete
|
|
11
|
-
*
|
|
10
|
+
* • one operation per (path × get/post/put/patch/delete — plus head/options
|
|
11
|
+
* when a provider opts in via `extraHttpMethods`), deprecated skipped by
|
|
12
|
+
* default; op shape name = PascalCase(operationId)
|
|
12
13
|
* • input `<Op>Request`: path params → `smithy.api#httpLabel` (+required),
|
|
13
|
-
* query params → `smithy.api#httpQuery`, header
|
|
14
|
-
*
|
|
14
|
+
* query params → `smithy.api#httpQuery`, header params →
|
|
15
|
+
* `smithy.api#httpHeader` when `headerParams` is on (dropped otherwise,
|
|
16
|
+
* as cookie params always are),
|
|
17
|
+
* body properties flattened alongside
|
|
18
|
+
* (labels win, then query, then headers, then body);
|
|
15
19
|
* non-object bodies become a sole `body` member with `smithy.api#httpPayload`
|
|
16
20
|
* • `$ref`s become NAMED shapes (`components/schemas/X` → `<ns>#X`), reused
|
|
17
21
|
* across operations; anonymous nested objects synthesize names from the
|
|
18
22
|
* parent + member path
|
|
19
|
-
* • responses: 200 → 201 → 204 precedence
|
|
23
|
+
* • responses: 200 → 201 → 204 precedence (`successStatuses` overrides),
|
|
24
|
+
* `application/json` only; object
|
|
20
25
|
* results become `<Op>Response` structures (a sole `$ref` reuses the named
|
|
21
26
|
* shape); bare array/scalar results wrap in a structure whose single
|
|
22
27
|
* member carries `com.distilled.openapi#rawResponse` (the SdkSpec maps it
|
|
23
|
-
* to a root pipe); no schema → `smithy.api#Unit`
|
|
28
|
+
* to a root pipe); no schema → `smithy.api#Unit`; `head` is always
|
|
29
|
+
* `smithy.api#Unit`, whatever content the spec declares
|
|
24
30
|
* • nullability (3.0 `nullable`, 3.1 `type: [..., "null"]`, 2.0
|
|
25
31
|
* `x-nullable`, `oneOf`/`anyOf` null branches) → member-level
|
|
26
32
|
* `com.distilled.openapi#nullable` trait
|
|
@@ -122,6 +128,37 @@ export interface OpenApiConvertOptions {
|
|
|
122
128
|
readonly defaultErrorStatuses?: Iterable<string>;
|
|
123
129
|
/** Skip operations marked `deprecated: true`. Default true. */
|
|
124
130
|
readonly skipDeprecated?: boolean;
|
|
131
|
+
/**
|
|
132
|
+
* HTTP methods to convert IN ADDITION to get/post/put/patch/delete —
|
|
133
|
+
* currently `"head"` and `"options"`. Opt-in, so a provider that models
|
|
134
|
+
* them (Vercel probes cache artifacts and sandbox files with HEAD) gets
|
|
135
|
+
* them without every other provider silently gaining operations.
|
|
136
|
+
*
|
|
137
|
+
* `head` operations always output `smithy.api#Unit` — see the output-shape
|
|
138
|
+
* comment in {@link convertOpenApiToSmithy} for why a declared response
|
|
139
|
+
* body on a HEAD can never arrive.
|
|
140
|
+
*/
|
|
141
|
+
readonly extraHttpMethods?: readonly ("head" | "options")[];
|
|
142
|
+
/**
|
|
143
|
+
* Emit `in: header` parameters as `smithy.api#httpHeader` members instead
|
|
144
|
+
* of dropping them. Opt-in for the same reason as
|
|
145
|
+
* {@link extraHttpMethods}: turning it on adds input members to every
|
|
146
|
+
* operation whose spec declares a header parameter, and most providers
|
|
147
|
+
* declare ones the protocol already sends itself (Accept, Authorization,
|
|
148
|
+
* an API-version pin). Providers whose headers are real per-call inputs
|
|
149
|
+
* — Vercel's `x-Artifact-*` remote-cache metadata, `x-Vercel-Digest` —
|
|
150
|
+
* ask for them.
|
|
151
|
+
*/
|
|
152
|
+
readonly headerParams?: boolean;
|
|
153
|
+
/**
|
|
154
|
+
* Response statuses to read the operation's output shape from, most
|
|
155
|
+
* preferred first. Default `["200", "201", "204"]`. Extend it for an API
|
|
156
|
+
* that answers asynchronous work with a body under another status — Vercel
|
|
157
|
+
* returns `202 Accepted` with a payload from nine endpoints (artifact
|
|
158
|
+
* upload, account deletion, VCR blob/manifest writes), which would
|
|
159
|
+
* otherwise generate as a `void` output.
|
|
160
|
+
*/
|
|
161
|
+
readonly successStatuses?: readonly string[];
|
|
125
162
|
/**
|
|
126
163
|
* Azure-style fixed `api-version`: drops `api-version` query params and
|
|
127
164
|
* stamps {@link API_VERSION_TRAIT} on every operation.
|
|
@@ -177,6 +214,24 @@ const memberIdent = (name: string): string => {
|
|
|
177
214
|
return out || "_";
|
|
178
215
|
};
|
|
179
216
|
|
|
217
|
+
/**
|
|
218
|
+
* Header name → member identifier (`x-Artifact-Tag` → `xArtifactTag`). The
|
|
219
|
+
* wire name is carried by the `smithy.api#httpHeader` trait, so the member
|
|
220
|
+
* can read like the rest of the input rather than like a header.
|
|
221
|
+
*/
|
|
222
|
+
const headerMemberName = (name: string): string => {
|
|
223
|
+
const parts = name.split(/[^A-Za-z0-9]+/).filter(Boolean);
|
|
224
|
+
if (parts.length === 0) return "_";
|
|
225
|
+
const out = parts
|
|
226
|
+
.map((p, i) =>
|
|
227
|
+
i === 0
|
|
228
|
+
? p.charAt(0).toLowerCase() + p.slice(1)
|
|
229
|
+
: p.charAt(0).toUpperCase() + p.slice(1),
|
|
230
|
+
)
|
|
231
|
+
.join("");
|
|
232
|
+
return /^[0-9]/.test(out) ? `_${out}` : out;
|
|
233
|
+
};
|
|
234
|
+
|
|
180
235
|
const enumMemberName = (value: string): string => {
|
|
181
236
|
let out = value
|
|
182
237
|
.toUpperCase()
|
|
@@ -937,7 +992,11 @@ const collectParams = (ctx: Ctx, pathItem: any, op: any): Param[] => {
|
|
|
937
992
|
byKey.set(`${p.in}${p.name}`, p);
|
|
938
993
|
}
|
|
939
994
|
return [...byKey.values()].filter(
|
|
940
|
-
(p) =>
|
|
995
|
+
(p) =>
|
|
996
|
+
p.in === "path" ||
|
|
997
|
+
p.in === "query" ||
|
|
998
|
+
p.in === "body" ||
|
|
999
|
+
p.in === "header",
|
|
941
1000
|
);
|
|
942
1001
|
};
|
|
943
1002
|
|
|
@@ -1001,12 +1060,13 @@ const opDoc = (op: any): string | undefined => {
|
|
|
1001
1060
|
// Responses
|
|
1002
1061
|
// ============================================================================
|
|
1003
1062
|
|
|
1004
|
-
/**
|
|
1063
|
+
/** First declared status in `order` wins; response-level `$ref` resolved; JSON only. */
|
|
1005
1064
|
const successSchema = (
|
|
1006
1065
|
ctx: Ctx,
|
|
1007
1066
|
responses: any,
|
|
1067
|
+
order: readonly string[],
|
|
1008
1068
|
): { schema: any | undefined } => {
|
|
1009
|
-
for (const code of
|
|
1069
|
+
for (const code of order) {
|
|
1010
1070
|
const raw = responses?.[code];
|
|
1011
1071
|
if (!raw) continue;
|
|
1012
1072
|
const resp = raw.$ref ? resolvePointer(ctx.spec, raw.$ref) : raw;
|
|
@@ -1099,6 +1159,14 @@ const detectPagination = (
|
|
|
1099
1159
|
|
|
1100
1160
|
const HTTP_METHODS = ["get", "post", "put", "patch", "delete"] as const;
|
|
1101
1161
|
|
|
1162
|
+
/**
|
|
1163
|
+
* Methods a spec may declare beyond {@link HTTP_METHODS}, opt-in via
|
|
1164
|
+
* {@link OpenApiConvertOptions.extraHttpMethods} — a provider that models
|
|
1165
|
+
* `head` (Vercel's cache-artifact and file existence probes) asks for it
|
|
1166
|
+
* rather than every provider silently gaining operations on regeneration.
|
|
1167
|
+
*/
|
|
1168
|
+
const OPTIONAL_HTTP_METHODS = ["head", "options"] as const;
|
|
1169
|
+
|
|
1102
1170
|
const DEFAULT_STATUS_TO_ERROR_CLASS: Readonly<Record<string, string>> = {
|
|
1103
1171
|
"400": "BadRequest",
|
|
1104
1172
|
"403": "Forbidden",
|
|
@@ -1109,6 +1177,8 @@ const DEFAULT_STATUS_TO_ERROR_CLASS: Readonly<Record<string, string>> = {
|
|
|
1109
1177
|
|
|
1110
1178
|
const DEFAULT_ERROR_STATUSES = ["401", "429", "500", "503"];
|
|
1111
1179
|
|
|
1180
|
+
const DEFAULT_SUCCESS_STATUSES = ["200", "201", "204"];
|
|
1181
|
+
|
|
1112
1182
|
export const convertOpenApiToSmithy = (
|
|
1113
1183
|
spec: unknown,
|
|
1114
1184
|
options: OpenApiConvertOptions,
|
|
@@ -1131,6 +1201,13 @@ export const convertOpenApiToSmithy = (
|
|
|
1131
1201
|
options.defaultErrorStatuses ?? DEFAULT_ERROR_STATUSES,
|
|
1132
1202
|
);
|
|
1133
1203
|
const skipDeprecated = options.skipDeprecated ?? true;
|
|
1204
|
+
const successStatuses = options.successStatuses ?? DEFAULT_SUCCESS_STATUSES;
|
|
1205
|
+
const httpMethods = [
|
|
1206
|
+
...HTTP_METHODS,
|
|
1207
|
+
...OPTIONAL_HTTP_METHODS.filter((m) =>
|
|
1208
|
+
options.extraHttpMethods?.includes(m),
|
|
1209
|
+
),
|
|
1210
|
+
];
|
|
1134
1211
|
|
|
1135
1212
|
// Error class names and the service name are reserved up front so schema
|
|
1136
1213
|
// components can never steal them.
|
|
@@ -1145,7 +1222,7 @@ export const convertOpenApiToSmithy = (
|
|
|
1145
1222
|
|
|
1146
1223
|
for (const [rawPath, pathItem] of Object.entries(doc.paths ?? {})) {
|
|
1147
1224
|
if (!pathItem || typeof pathItem !== "object") continue;
|
|
1148
|
-
for (const method of
|
|
1225
|
+
for (const method of httpMethods) {
|
|
1149
1226
|
const op = (pathItem as any)[method];
|
|
1150
1227
|
if (!op || typeof op !== "object") continue;
|
|
1151
1228
|
if (skipDeprecated && op.deprecated === true) continue;
|
|
@@ -1212,6 +1289,32 @@ export const convertOpenApiToSmithy = (
|
|
|
1212
1289
|
});
|
|
1213
1290
|
}
|
|
1214
1291
|
|
|
1292
|
+
// ---- Header params (opt-in) ----
|
|
1293
|
+
if (options.headerParams) {
|
|
1294
|
+
for (const p of params) {
|
|
1295
|
+
if (p.in !== "header") continue;
|
|
1296
|
+
const conv = convertSchema(
|
|
1297
|
+
ctx,
|
|
1298
|
+
p.schema,
|
|
1299
|
+
`${opName}Request${pascal(p.name)}`,
|
|
1300
|
+
0,
|
|
1301
|
+
"in",
|
|
1302
|
+
);
|
|
1303
|
+
addMember(headerMemberName(p.name), {
|
|
1304
|
+
target: conv.target,
|
|
1305
|
+
traits: {
|
|
1306
|
+
// The wire name rides on the trait, so the member is free to be
|
|
1307
|
+
// a normal identifier.
|
|
1308
|
+
"smithy.api#httpHeader": p.name,
|
|
1309
|
+
...(p.required ? { "smithy.api#required": {} } : {}),
|
|
1310
|
+
...(p.description
|
|
1311
|
+
? { "smithy.api#documentation": p.description }
|
|
1312
|
+
: {}),
|
|
1313
|
+
},
|
|
1314
|
+
});
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1215
1318
|
// ---- Request body (json > form-urlencoded > multipart) ----
|
|
1216
1319
|
let contentType: "form-urlencoded" | "multipart" | undefined;
|
|
1217
1320
|
let bodySchema: any;
|
|
@@ -1289,7 +1392,19 @@ export const convertOpenApiToSmithy = (
|
|
|
1289
1392
|
: PRELUDE.Unit;
|
|
1290
1393
|
|
|
1291
1394
|
// ---- Output shape ----
|
|
1292
|
-
|
|
1395
|
+
// A HEAD response carries no content, and not merely by convention:
|
|
1396
|
+
// HTTP/1.1 framing terminates it at the end of the header section
|
|
1397
|
+
// "regardless of the header fields present in the message" (RFC 9112
|
|
1398
|
+
// §6.3), so a declared `Content-Length` describes what a GET *would*
|
|
1399
|
+
// return and no body can reach the client. Specs declare one anyway —
|
|
1400
|
+
// Vercel mirrors the GET's schema onto two of its HEAD probes and
|
|
1401
|
+
// gives the other two a bare `{"nullable": true}` — and honouring it
|
|
1402
|
+
// generates an output struct with required members that then fails to
|
|
1403
|
+
// decode against the empty body on every successful call.
|
|
1404
|
+
const { schema: respSchema } =
|
|
1405
|
+
method === "head"
|
|
1406
|
+
? { schema: undefined }
|
|
1407
|
+
: successSchema(ctx, op.responses, successStatuses);
|
|
1293
1408
|
let outputTarget: string = PRELUDE.Unit;
|
|
1294
1409
|
if (respSchema !== undefined) {
|
|
1295
1410
|
const flat = flattenObject(ctx, respSchema);
|
package/src/errors.ts
CHANGED
|
@@ -33,7 +33,7 @@ export const DurationSchema = Schema.declare<Duration.Duration>(
|
|
|
33
33
|
/**
|
|
34
34
|
* Unauthorized - Authentication failure (401).
|
|
35
35
|
*/
|
|
36
|
-
export class Unauthorized extends Schema.
|
|
36
|
+
export class Unauthorized extends Schema.TaggedError<Unauthorized>()(
|
|
37
37
|
"Unauthorized",
|
|
38
38
|
{ message: Schema.String },
|
|
39
39
|
).pipe(Category.withAuthError) {}
|
|
@@ -41,37 +41,35 @@ export class Unauthorized extends Schema.TaggedErrorClass<Unauthorized>()(
|
|
|
41
41
|
/**
|
|
42
42
|
* Forbidden - Access denied (403).
|
|
43
43
|
*/
|
|
44
|
-
export class Forbidden extends Schema.
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
).pipe(Category.withAuthError) {}
|
|
44
|
+
export class Forbidden extends Schema.TaggedError<Forbidden>()("Forbidden", {
|
|
45
|
+
message: Schema.String,
|
|
46
|
+
}).pipe(Category.withAuthError) {}
|
|
48
47
|
|
|
49
48
|
/**
|
|
50
49
|
* NotFound - Resource not found (404).
|
|
51
50
|
*/
|
|
52
|
-
export class NotFound extends Schema.
|
|
51
|
+
export class NotFound extends Schema.TaggedError<NotFound>()("NotFound", {
|
|
53
52
|
message: Schema.String,
|
|
54
53
|
}).pipe(Category.withNotFoundError) {}
|
|
55
54
|
|
|
56
55
|
/**
|
|
57
56
|
* BadRequest - Invalid request (400).
|
|
58
57
|
*/
|
|
59
|
-
export class BadRequest extends Schema.
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
).pipe(Category.withBadRequestError) {}
|
|
58
|
+
export class BadRequest extends Schema.TaggedError<BadRequest>()("BadRequest", {
|
|
59
|
+
message: Schema.String,
|
|
60
|
+
}).pipe(Category.withBadRequestError) {}
|
|
63
61
|
|
|
64
62
|
/**
|
|
65
63
|
* Conflict - Resource conflict (409).
|
|
66
64
|
*/
|
|
67
|
-
export class Conflict extends Schema.
|
|
65
|
+
export class Conflict extends Schema.TaggedError<Conflict>()("Conflict", {
|
|
68
66
|
message: Schema.String,
|
|
69
67
|
}).pipe(Category.withConflictError) {}
|
|
70
68
|
|
|
71
69
|
/**
|
|
72
70
|
* UnprocessableEntity - Validation error (422).
|
|
73
71
|
*/
|
|
74
|
-
export class UnprocessableEntity extends Schema.
|
|
72
|
+
export class UnprocessableEntity extends Schema.TaggedError<UnprocessableEntity>()(
|
|
75
73
|
"UnprocessableEntity",
|
|
76
74
|
{ message: Schema.String },
|
|
77
75
|
).pipe(Category.withBadRequestError) {}
|
|
@@ -79,7 +77,7 @@ export class UnprocessableEntity extends Schema.TaggedErrorClass<UnprocessableEn
|
|
|
79
77
|
/**
|
|
80
78
|
* TooManyRequests - Rate limited (429).
|
|
81
79
|
*/
|
|
82
|
-
export class TooManyRequests extends Schema.
|
|
80
|
+
export class TooManyRequests extends Schema.TaggedError<TooManyRequests>()(
|
|
83
81
|
"TooManyRequests",
|
|
84
82
|
{
|
|
85
83
|
message: Schema.String,
|
|
@@ -93,7 +91,7 @@ export class TooManyRequests extends Schema.TaggedErrorClass<TooManyRequests>()(
|
|
|
93
91
|
/**
|
|
94
92
|
* Locked - Resource locked (423).
|
|
95
93
|
*/
|
|
96
|
-
export class Locked extends Schema.
|
|
94
|
+
export class Locked extends Schema.TaggedError<Locked>()("Locked", {
|
|
97
95
|
message: Schema.String,
|
|
98
96
|
retryAfter: Schema.optional(DurationSchema),
|
|
99
97
|
}).pipe(Category.withLockedError, Category.withRetryable()) {}
|
|
@@ -106,7 +104,7 @@ export class Locked extends Schema.TaggedErrorClass<Locked>()("Locked", {
|
|
|
106
104
|
* 200 envelope carrying `{ code: 10002, message: "An unknown error has
|
|
107
105
|
* occurred" }` for transient backend failures on some endpoints).
|
|
108
106
|
*/
|
|
109
|
-
export class InternalServerError extends Schema.
|
|
107
|
+
export class InternalServerError extends Schema.TaggedError<InternalServerError>()(
|
|
110
108
|
"InternalServerError",
|
|
111
109
|
{
|
|
112
110
|
message: Schema.String,
|
|
@@ -118,18 +116,15 @@ export class InternalServerError extends Schema.TaggedErrorClass<InternalServerE
|
|
|
118
116
|
/**
|
|
119
117
|
* BadGateway - Bad gateway (502).
|
|
120
118
|
*/
|
|
121
|
-
export class BadGateway extends Schema.
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
retryAfter: Schema.optional(DurationSchema),
|
|
126
|
-
},
|
|
127
|
-
).pipe(Category.withServerError, Category.withRetryable()) {}
|
|
119
|
+
export class BadGateway extends Schema.TaggedError<BadGateway>()("BadGateway", {
|
|
120
|
+
message: Schema.String,
|
|
121
|
+
retryAfter: Schema.optional(DurationSchema),
|
|
122
|
+
}).pipe(Category.withServerError, Category.withRetryable()) {}
|
|
128
123
|
|
|
129
124
|
/**
|
|
130
125
|
* ServiceUnavailable - Service unavailable (503).
|
|
131
126
|
*/
|
|
132
|
-
export class ServiceUnavailable extends Schema.
|
|
127
|
+
export class ServiceUnavailable extends Schema.TaggedError<ServiceUnavailable>()(
|
|
133
128
|
"ServiceUnavailable",
|
|
134
129
|
{
|
|
135
130
|
message: Schema.String,
|
|
@@ -140,7 +135,7 @@ export class ServiceUnavailable extends Schema.TaggedErrorClass<ServiceUnavailab
|
|
|
140
135
|
/**
|
|
141
136
|
* GatewayTimeout - Gateway timeout (504).
|
|
142
137
|
*/
|
|
143
|
-
export class GatewayTimeout extends Schema.
|
|
138
|
+
export class GatewayTimeout extends Schema.TaggedError<GatewayTimeout>()(
|
|
144
139
|
"GatewayTimeout",
|
|
145
140
|
{
|
|
146
141
|
message: Schema.String,
|
|
@@ -151,7 +146,7 @@ export class GatewayTimeout extends Schema.TaggedErrorClass<GatewayTimeout>()(
|
|
|
151
146
|
/**
|
|
152
147
|
* Configuration error - missing or invalid configuration.
|
|
153
148
|
*/
|
|
154
|
-
export class ConfigError extends Schema.
|
|
149
|
+
export class ConfigError extends Schema.TaggedError<ConfigError>()(
|
|
155
150
|
"ConfigError",
|
|
156
151
|
{ message: Schema.String },
|
|
157
152
|
).pipe(Category.withConfigurationError) {}
|
package/src/pagination.ts
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* - Page-based: page/per_page with a page number that advances
|
|
6
6
|
* - Cursor-based: cursor/limit with an opaque next-cursor string
|
|
7
7
|
* - Token-based (AWS style): NextToken/MaxResults continuation tokens
|
|
8
|
+
* - Relay (GraphQL connections): after/first with a `pageInfo` block whose
|
|
9
|
+
* `hasNextPage` — not the cursor — marks the end
|
|
8
10
|
* - Single: one-shot list endpoints that still expose the paginated surface
|
|
9
11
|
*
|
|
10
12
|
* Each SDK stores a {@link PaginatedTrait} on its operations (sourced from the
|
|
@@ -31,6 +33,33 @@ export const getPath = (obj: unknown, path: string): unknown => {
|
|
|
31
33
|
return current;
|
|
32
34
|
};
|
|
33
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Collect the items a dot-separated path selects, flattening arrays as it
|
|
38
|
+
* goes. Unlike {@link getPath} (which walks a single value and is used for
|
|
39
|
+
* cursors/tokens), every segment here fans out over whatever the previous one
|
|
40
|
+
* produced, so a path may cross a list:
|
|
41
|
+
*
|
|
42
|
+
* - `"items"` on `{ items: [a, b] }` → `[a, b]` (the common flat case)
|
|
43
|
+
* - `"edges.node"` on a Relay connection → every edge's `node`
|
|
44
|
+
*
|
|
45
|
+
* `null`/`undefined` links are dropped rather than propagated, so a partially
|
|
46
|
+
* null page yields the items it does have instead of nothing.
|
|
47
|
+
*/
|
|
48
|
+
export const getItems = (obj: unknown, path: string): readonly unknown[] => {
|
|
49
|
+
let current: unknown[] = [obj];
|
|
50
|
+
for (const part of path.split(".")) {
|
|
51
|
+
const next: unknown[] = [];
|
|
52
|
+
for (const value of current) {
|
|
53
|
+
if (value == null || typeof value !== "object") continue;
|
|
54
|
+
const child = (value as Record<string, unknown>)[part];
|
|
55
|
+
if (Array.isArray(child)) next.push(...child);
|
|
56
|
+
else if (child != null) next.push(child);
|
|
57
|
+
}
|
|
58
|
+
current = next;
|
|
59
|
+
}
|
|
60
|
+
return current;
|
|
61
|
+
};
|
|
62
|
+
|
|
34
63
|
// ============================================================================
|
|
35
64
|
// Pagination Trait
|
|
36
65
|
// ============================================================================
|
|
@@ -38,15 +67,26 @@ export const getPath = (obj: unknown, path: string): unknown => {
|
|
|
38
67
|
/** Pagination trait describing how to navigate between pages. */
|
|
39
68
|
export interface PaginatedTrait {
|
|
40
69
|
/** Pagination strategy */
|
|
41
|
-
readonly mode?: "token" | "page" | "cursor" | "single";
|
|
70
|
+
readonly mode?: "token" | "page" | "cursor" | "relay" | "single";
|
|
42
71
|
/** The name of the input member containing the page/cursor token */
|
|
43
72
|
readonly inputToken?: string;
|
|
44
73
|
/** The path to the output member containing the next page/cursor token */
|
|
45
74
|
readonly outputToken?: string;
|
|
46
|
-
/**
|
|
75
|
+
/**
|
|
76
|
+
* The path to the output member containing the paginated items. Segments
|
|
77
|
+
* may cross arrays — `"edges.node"` walks every edge and collects its
|
|
78
|
+
* `node` (see {@link getItems}).
|
|
79
|
+
*/
|
|
47
80
|
readonly items?: string;
|
|
48
81
|
/** The name of the input member that limits page size */
|
|
49
82
|
readonly pageSize?: string;
|
|
83
|
+
/**
|
|
84
|
+
* Relay extension: the path to the boolean that says whether another page
|
|
85
|
+
* exists (`"pageInfo.hasNextPage"`). Relay connections keep returning the
|
|
86
|
+
* last page's `endCursor` after the end, so the cursor alone can't
|
|
87
|
+
* terminate traversal — see {@link paginateRelay}.
|
|
88
|
+
*/
|
|
89
|
+
readonly hasNextPage?: string;
|
|
50
90
|
}
|
|
51
91
|
|
|
52
92
|
export type PaginationStrategy = <
|
|
@@ -261,6 +301,82 @@ export const paginateToken = <
|
|
|
261
301
|
);
|
|
262
302
|
};
|
|
263
303
|
|
|
304
|
+
// ============================================================================
|
|
305
|
+
// Relay Pagination (GraphQL connections)
|
|
306
|
+
// ============================================================================
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Stream of pages over a Relay connection — pass `pageInfo.endCursor` back as
|
|
310
|
+
* `after` for as long as `pageInfo.hasNextPage` is true.
|
|
311
|
+
*
|
|
312
|
+
* Relay is cursor pagination with one twist that breaks
|
|
313
|
+
* {@link paginateCursor}: the terminal page still carries an `endCursor` (it
|
|
314
|
+
* points at the last edge, not at "nothing left"). Only `hasNextPage`
|
|
315
|
+
* distinguishes "more to fetch" from "that was everything", so this strategy
|
|
316
|
+
* reads the boolean and treats the cursor as a pure position marker. A
|
|
317
|
+
* connection that omits `pageInfo` entirely (or returns an empty page) also
|
|
318
|
+
* terminates, so a malformed response can't spin forever.
|
|
319
|
+
*/
|
|
320
|
+
export const paginateRelay = <
|
|
321
|
+
Input extends Record<string, unknown>,
|
|
322
|
+
Output,
|
|
323
|
+
E,
|
|
324
|
+
R,
|
|
325
|
+
>(
|
|
326
|
+
operation: (input: Input) => Effect.Effect<Output, E, R>,
|
|
327
|
+
input: Input,
|
|
328
|
+
pagination: PaginatedTrait,
|
|
329
|
+
): Stream.Stream<Output, E, R> => {
|
|
330
|
+
const inputToken = pagination.inputToken;
|
|
331
|
+
const outputToken = pagination.outputToken;
|
|
332
|
+
if (!inputToken || !outputToken) {
|
|
333
|
+
return missingPaginationConfig(
|
|
334
|
+
"Relay pagination requires inputToken and outputToken",
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
// `pageInfo.endCursor` → `pageInfo.hasNextPage` when the trait doesn't say.
|
|
338
|
+
const hasNextPath =
|
|
339
|
+
pagination.hasNextPage ??
|
|
340
|
+
`${outputToken.split(".").slice(0, -1).concat("hasNextPage").join(".")}`;
|
|
341
|
+
|
|
342
|
+
type State = { cursor: string | undefined; done: boolean };
|
|
343
|
+
const startCursor =
|
|
344
|
+
typeof input[inputToken] === "string"
|
|
345
|
+
? (input[inputToken] as string)
|
|
346
|
+
: undefined;
|
|
347
|
+
|
|
348
|
+
return Stream.unfold({ cursor: startCursor, done: false } as State, (state) =>
|
|
349
|
+
Effect.gen(function* () {
|
|
350
|
+
if (state.done) return undefined;
|
|
351
|
+
|
|
352
|
+
const requestPayload = {
|
|
353
|
+
...input,
|
|
354
|
+
...(state.cursor ? { [inputToken]: state.cursor } : {}),
|
|
355
|
+
} as Input;
|
|
356
|
+
|
|
357
|
+
const response = yield* operation(requestPayload);
|
|
358
|
+
|
|
359
|
+
const nextCursor = getPath(response, outputToken) as
|
|
360
|
+
| string
|
|
361
|
+
| null
|
|
362
|
+
| undefined;
|
|
363
|
+
const hasNext = getPath(response, hasNextPath) === true;
|
|
364
|
+
// An empty page means the connection is exhausted regardless of what
|
|
365
|
+
// `hasNextPage` claims — re-requesting the same cursor would loop.
|
|
366
|
+
const emptyPage =
|
|
367
|
+
pagination.items !== undefined &&
|
|
368
|
+
getItems(response, pagination.items).length === 0;
|
|
369
|
+
|
|
370
|
+
const nextState: State = {
|
|
371
|
+
cursor: nextCursor ?? undefined,
|
|
372
|
+
done: !hasNext || isTerminalToken(nextCursor) || emptyPage,
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
return [response, nextState] as const;
|
|
376
|
+
}),
|
|
377
|
+
);
|
|
378
|
+
};
|
|
379
|
+
|
|
264
380
|
/**
|
|
265
381
|
* Shared default pagination dispatcher for SDKs that use generic
|
|
266
382
|
* token/cursor/page traversal.
|
|
@@ -277,6 +393,8 @@ export const paginateWithDefaults: PaginationStrategy = (
|
|
|
277
393
|
return paginatePageNumber(operation, input, pagination);
|
|
278
394
|
case "cursor":
|
|
279
395
|
return paginateCursor(operation, input, pagination);
|
|
396
|
+
case "relay":
|
|
397
|
+
return paginateRelay(operation, input, pagination);
|
|
280
398
|
case "single":
|
|
281
399
|
return paginateSingle(operation, input, pagination);
|
|
282
400
|
case "token":
|
|
@@ -293,7 +411,8 @@ export const paginateWithDefaults: PaginationStrategy = (
|
|
|
293
411
|
* Extracts individual items from a page stream.
|
|
294
412
|
*
|
|
295
413
|
* @param pages - A stream of page responses
|
|
296
|
-
* @param itemsPath - Dot-separated path to the items
|
|
414
|
+
* @param itemsPath - Dot-separated path to the items in the page; segments may
|
|
415
|
+
* cross arrays (see {@link getItems})
|
|
297
416
|
* @returns A Stream of individual items
|
|
298
417
|
*/
|
|
299
418
|
export const extractItems = <Output, Item, E, R>(
|
|
@@ -301,8 +420,7 @@ export const extractItems = <Output, Item, E, R>(
|
|
|
301
420
|
itemsPath: string,
|
|
302
421
|
): Stream.Stream<Item, E, R> =>
|
|
303
422
|
pages.pipe(
|
|
304
|
-
Stream.flatMap((page) =>
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
}),
|
|
423
|
+
Stream.flatMap((page) =>
|
|
424
|
+
Stream.fromIterable(getItems(page, itemsPath) as readonly Item[]),
|
|
425
|
+
),
|
|
308
426
|
);
|
package/src/protocol-http.ts
CHANGED
|
@@ -616,9 +616,17 @@ export const buildRequest = ({
|
|
|
616
616
|
request = request.pipe(HttpClientRequest.bodyJsonUnsafe(rawBody));
|
|
617
617
|
}
|
|
618
618
|
} else if (
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
619
|
+
// A GET/HEAD sends a body only when body members actually carry values.
|
|
620
|
+
// Some APIs really do document GET-with-body: MongoDB Atlas's
|
|
621
|
+
// `…/lineItems:search` declares a `requestBody` on `get`, and AWS EFS's
|
|
622
|
+
// DescribeAccountPreferences takes `{ MaxResults, NextToken }` on a GET.
|
|
623
|
+
// Suppressing it outright dropped those members silently — the request
|
|
624
|
+
// succeeded, unfiltered, with nothing on the wire to show why.
|
|
625
|
+
//
|
|
626
|
+
// The `{}`-when-empty rule below stays limited to methods that expect a
|
|
627
|
+
// body, so a bodyless method with nothing set still sends nothing.
|
|
628
|
+
Object.keys(body).length > 0 ||
|
|
629
|
+
(hasBodyMembers && !BODYLESS.has(http.method) && http.method !== "DELETE")
|
|
622
630
|
) {
|
|
623
631
|
// Send `{}` rather than no body when the schema declares body members —
|
|
624
632
|
// some endpoints reject a missing JSON body outright.
|
package/src/schema.ts
CHANGED
|
@@ -48,6 +48,6 @@ export const Null: any = S.Null;
|
|
|
48
48
|
export const Void: any = S.Void;
|
|
49
49
|
export const Any: any = S.Any;
|
|
50
50
|
|
|
51
|
-
// NOTE: `Schema` / `Codec` (types) and `
|
|
51
|
+
// NOTE: `Schema` / `Codec` (types) and `TaggedError` are intentionally NOT
|
|
52
52
|
// overridden — they flow through `export *` with real types so cast targets and
|
|
53
53
|
// typed error classes stay precise.
|