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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/lib/api.d.ts +4 -4
  2. package/lib/api.d.ts.map +1 -1
  3. package/lib/api.js +1 -1
  4. package/lib/api.js.map +1 -1
  5. package/lib/category.js +1 -1
  6. package/lib/category.js.map +1 -1
  7. package/lib/codegen/boolean-string-enums.d.ts +8 -8
  8. package/lib/codegen/boolean-string-enums.d.ts.map +1 -1
  9. package/lib/codegen/boolean-string-enums.js +84 -27
  10. package/lib/codegen/boolean-string-enums.js.map +1 -1
  11. package/lib/codegen/boolean-string-enums.test.js +130 -0
  12. package/lib/codegen/boolean-string-enums.test.js.map +1 -1
  13. package/lib/codegen/cli.js +2 -2
  14. package/lib/codegen/cli.js.map +1 -1
  15. package/lib/codegen/generator.d.ts +20 -3
  16. package/lib/codegen/generator.d.ts.map +1 -1
  17. package/lib/codegen/generator.js +36 -9
  18. package/lib/codegen/generator.js.map +1 -1
  19. package/lib/codegen/generator.test.d.ts +2 -0
  20. package/lib/codegen/generator.test.d.ts.map +1 -0
  21. package/lib/codegen/generator.test.js +126 -0
  22. package/lib/codegen/generator.test.js.map +1 -0
  23. package/lib/codegen/graphql-client.d.ts +7 -1
  24. package/lib/codegen/graphql-client.d.ts.map +1 -1
  25. package/lib/codegen/graphql-client.js +262 -125
  26. package/lib/codegen/graphql-client.js.map +1 -1
  27. package/lib/codegen/graphql-client.test.js +104 -236
  28. package/lib/codegen/graphql-client.test.js.map +1 -1
  29. package/lib/codegen/openapi-binary.test.d.ts +2 -0
  30. package/lib/codegen/openapi-binary.test.d.ts.map +1 -0
  31. package/lib/codegen/openapi-binary.test.js +67 -0
  32. package/lib/codegen/openapi-binary.test.js.map +1 -0
  33. package/lib/codegen/openapi.d.ts +7 -1
  34. package/lib/codegen/openapi.d.ts.map +1 -1
  35. package/lib/codegen/openapi.js +15 -4
  36. package/lib/codegen/openapi.js.map +1 -1
  37. package/lib/codegen/patches.d.ts +16 -1
  38. package/lib/codegen/patches.d.ts.map +1 -1
  39. package/lib/codegen/patches.js +70 -4
  40. package/lib/codegen/patches.js.map +1 -1
  41. package/lib/codegen/patches.test.js +48 -2
  42. package/lib/codegen/patches.test.js.map +1 -1
  43. package/lib/codegen/untagged-unions.test.d.ts +2 -0
  44. package/lib/codegen/untagged-unions.test.d.ts.map +1 -0
  45. package/lib/codegen/untagged-unions.test.js +80 -0
  46. package/lib/codegen/untagged-unions.test.js.map +1 -0
  47. package/lib/errors.d.ts +2 -1
  48. package/lib/errors.d.ts.map +1 -1
  49. package/lib/errors.js +5 -2
  50. package/lib/errors.js.map +1 -1
  51. package/lib/fixtures/xml/upstream-validator.json +397 -0
  52. package/lib/graphql.d.ts +296 -205
  53. package/lib/graphql.d.ts.map +1 -1
  54. package/lib/graphql.js +968 -631
  55. package/lib/graphql.js.map +1 -1
  56. package/lib/graphql.test.js +134 -763
  57. package/lib/graphql.test.js.map +1 -1
  58. package/lib/graphql.types.d.ts +10 -0
  59. package/lib/graphql.types.d.ts.map +1 -1
  60. package/lib/graphql.types.js +4 -44
  61. package/lib/graphql.types.js.map +1 -1
  62. package/lib/index.d.ts +27 -0
  63. package/lib/index.d.ts.map +1 -0
  64. package/lib/index.js +27 -0
  65. package/lib/index.js.map +1 -0
  66. package/lib/pagination.d.ts +2 -1
  67. package/lib/pagination.d.ts.map +1 -1
  68. package/lib/pagination.js +24 -16
  69. package/lib/pagination.js.map +1 -1
  70. package/lib/pagination.test.d.ts +2 -0
  71. package/lib/pagination.test.d.ts.map +1 -0
  72. package/lib/pagination.test.js +134 -0
  73. package/lib/pagination.test.js.map +1 -0
  74. package/lib/protocol-http.d.ts +12 -17
  75. package/lib/protocol-http.d.ts.map +1 -1
  76. package/lib/protocol-http.js +100 -45
  77. package/lib/protocol-http.js.map +1 -1
  78. package/lib/protocol-http.test.js +130 -0
  79. package/lib/protocol-http.test.js.map +1 -1
  80. package/lib/protocol-rest.d.ts +18 -1
  81. package/lib/protocol-rest.d.ts.map +1 -1
  82. package/lib/protocol-rest.js +24 -4
  83. package/lib/protocol-rest.js.map +1 -1
  84. package/lib/query.d.ts +29 -0
  85. package/lib/query.d.ts.map +1 -0
  86. package/lib/query.js +27 -0
  87. package/lib/query.js.map +1 -0
  88. package/lib/response-validation.d.ts +67 -0
  89. package/lib/response-validation.d.ts.map +1 -0
  90. package/lib/response-validation.js +76 -0
  91. package/lib/response-validation.js.map +1 -0
  92. package/lib/response-validation.test.d.ts +2 -0
  93. package/lib/response-validation.test.d.ts.map +1 -0
  94. package/lib/response-validation.test.js +67 -0
  95. package/lib/response-validation.test.js.map +1 -0
  96. package/lib/testing.d.ts +39 -0
  97. package/lib/testing.d.ts.map +1 -0
  98. package/lib/testing.js +41 -0
  99. package/lib/testing.js.map +1 -0
  100. package/lib/trait.d.ts +29 -9
  101. package/lib/trait.d.ts.map +1 -1
  102. package/lib/trait.js +8 -0
  103. package/lib/trait.js.map +1 -1
  104. package/lib/xml.d.ts +44 -0
  105. package/lib/xml.d.ts.map +1 -0
  106. package/lib/xml.js +315 -0
  107. package/lib/xml.js.map +1 -0
  108. package/lib/xml.test.d.ts +2 -0
  109. package/lib/xml.test.d.ts.map +1 -0
  110. package/lib/xml.test.js +211 -0
  111. package/lib/xml.test.js.map +1 -0
  112. package/package.json +4 -4
  113. package/src/api.ts +4 -4
  114. package/src/category.ts +1 -1
  115. package/src/codegen/boolean-string-enums.test.ts +147 -0
  116. package/src/codegen/boolean-string-enums.ts +84 -27
  117. package/src/codegen/cli.ts +2 -2
  118. package/src/codegen/generator.test.ts +151 -0
  119. package/src/codegen/generator.ts +70 -12
  120. package/src/codegen/graphql-client.test.ts +116 -283
  121. package/src/codegen/graphql-client.ts +326 -169
  122. package/src/codegen/openapi-binary.test.ts +98 -0
  123. package/src/codegen/openapi.ts +26 -4
  124. package/src/codegen/patches.test.ts +66 -1
  125. package/src/codegen/patches.ts +84 -4
  126. package/src/codegen/untagged-unions.test.ts +89 -0
  127. package/src/errors.ts +5 -2
  128. package/src/fixtures/xml/upstream-validator.json +397 -0
  129. package/src/graphql.test.ts +174 -948
  130. package/src/graphql.ts +1592 -1167
  131. package/src/graphql.types.ts +92 -163
  132. package/src/index.ts +26 -0
  133. package/src/pagination.test.ts +191 -0
  134. package/src/pagination.ts +35 -25
  135. package/src/protocol-http.test.ts +163 -0
  136. package/src/protocol-http.ts +122 -46
  137. package/src/protocol-rest.ts +62 -13
  138. package/src/query.ts +39 -0
  139. package/src/response-validation.test.ts +95 -0
  140. package/src/response-validation.ts +116 -0
  141. package/src/testing.ts +78 -0
  142. package/src/trait.ts +39 -8
  143. package/src/xml.test.ts +269 -0
  144. package/src/xml.ts +339 -0
  145. package/lib/graphql.fixture.d.ts +0 -249
  146. package/lib/graphql.fixture.d.ts.map +0 -1
  147. package/lib/graphql.fixture.js +0 -240
  148. package/lib/graphql.fixture.js.map +0 -1
  149. package/src/graphql.fixture.ts +0 -371
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@distilled.cloud/core",
3
- "version": "1.0.0-rc.11",
3
+ "version": "1.0.0-rc.13",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/alchemy-run/distilled",
@@ -28,13 +28,13 @@
28
28
  }
29
29
  },
30
30
  "devDependencies": {
31
- "@effect/platform-bun": ">=4.0.0-rc.115 || >=4.0.0",
31
+ "@effect/platform-bun": "^4.0.0",
32
32
  "@types/bun": "1.3.14",
33
33
  "@types/node": "26.2.0",
34
- "effect": ">=4.0.0-rc.115 || >=4.0.0"
34
+ "effect": "^4.0.0"
35
35
  },
36
36
  "peerDependencies": {
37
- "effect": ">=4.0.0-rc.115 || >=4.0.0"
37
+ "effect": "^4.0.0"
38
38
  },
39
39
  "dependencies": {
40
40
  "graphql": "16.11.0"
package/src/api.ts CHANGED
@@ -11,10 +11,10 @@ import type * as Stream from "effect/Stream";
11
11
  import { SingleShotGen } from "effect/Utils";
12
12
  import * as Pagination from "./pagination.ts";
13
13
  import { makeDefault, type Policy as RetryPolicy } from "./retry.ts";
14
- import * as HttpClient from "effect/unstable/http/HttpClient";
15
- import type * as HttpClientError from "effect/unstable/http/HttpClientError";
16
- import type * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
17
- import type * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";
14
+ import * as HttpClient from "effect/http/HttpClient";
15
+ import type * as HttpClientError from "effect/http/HttpClientError";
16
+ import type * as HttpClientRequest from "effect/http/HttpClientRequest";
17
+ import type * as HttpClientResponse from "effect/http/HttpClientResponse";
18
18
 
19
19
  //#region Protocol
20
20
 
package/src/category.ts CHANGED
@@ -20,7 +20,7 @@
20
20
  */
21
21
  import * as Effect from "effect/Effect";
22
22
  import * as Predicate from "effect/Predicate";
23
- import * as HttpClientError from "effect/unstable/http/HttpClientError";
23
+ import * as HttpClientError from "effect/http/HttpClientError";
24
24
 
25
25
  // ============================================================================
26
26
  // Error Category Constants
@@ -123,6 +123,108 @@ describe("booleanStringEnums", () => {
123
123
  });
124
124
 
125
125
  describe("generateService", () => {
126
+ test("member schema overrides retain nullability, query bindings and protocol traits", () => {
127
+ const { code } = generateService(
128
+ model({
129
+ "ns#Example": {
130
+ type: "service",
131
+ version: "1",
132
+ operations: [{ target: "ns#GetThing" }],
133
+ },
134
+ "ns#GetThing": {
135
+ type: "operation",
136
+ input: { target: "ns#Input" },
137
+ output: { target: "smithy.api#Unit" },
138
+ traits: { "smithy.api#http": { method: "GET", uri: "/things" } },
139
+ },
140
+ "ns#Input": {
141
+ type: "structure",
142
+ members: {
143
+ secret: {
144
+ target: "smithy.api#String",
145
+ traits: {
146
+ "smithy.api#httpQuery": "secret",
147
+ "smithy.api#sensitive": {},
148
+ "ns#nullable": {},
149
+ },
150
+ },
151
+ },
152
+ },
153
+ }),
154
+ {
155
+ nullableTrait: "ns#nullable",
156
+ memberSchema: (member, ref) =>
157
+ `S.Union([${ref(member.target)}, S.Redacted(${ref(member.target)})])`,
158
+ memberTraitPipes: { "smithy.api#sensitive": "T.SensitiveValue" },
159
+ operationDecl: {
160
+ contextType: "ExampleOpContext",
161
+ commonErrorType: "ExampleOpError",
162
+ commonErrorClasses: [],
163
+ protocol: "ExampleProtocol",
164
+ retry: "Retry.Retry",
165
+ },
166
+ },
167
+ );
168
+ expect(code).toContain(
169
+ '"secret": S.optional(S.NullOr(S.Union([S.String, S.Redacted(S.String)])).pipe(T.Query(), T.SensitiveValue({})))',
170
+ );
171
+ });
172
+
173
+ test("service-free schemas preserve Codec annotations across structures, collections and raw outputs", () => {
174
+ const input = model({
175
+ "ns#Example": {
176
+ type: "service",
177
+ version: "1",
178
+ operations: [{ target: "ns#GetThing" }],
179
+ },
180
+ "ns#GetThing": {
181
+ type: "operation",
182
+ input: { target: "ns#Input" },
183
+ output: { target: "ns#Output" },
184
+ traits: { "smithy.api#http": { method: "GET", uri: "/things" } },
185
+ },
186
+ "ns#Input": {
187
+ type: "structure",
188
+ members: { list: { target: "ns#List" }, map: { target: "ns#Map" } },
189
+ },
190
+ "ns#List": { type: "list", member: { target: "smithy.api#String" } },
191
+ "ns#Map": {
192
+ type: "map",
193
+ key: { target: "smithy.api#String" },
194
+ value: { target: "smithy.api#String" },
195
+ },
196
+ "ns#Output": {
197
+ type: "structure",
198
+ members: { body: { target: "ns#List", traits: { "ns#raw": {} } } },
199
+ },
200
+ });
201
+ const spec = {
202
+ extraBindings: [
203
+ {
204
+ trait: "ns#raw",
205
+ binding: "rawResponse",
206
+ pipe: "T.RawResponse()",
207
+ rootPipe: "T.RawResponseRoot()",
208
+ },
209
+ ],
210
+ operationDecl: {
211
+ contextType: "ExampleOpContext",
212
+ commonErrorType: "ExampleOpError",
213
+ commonErrorClasses: [],
214
+ protocol: "ExampleProtocol",
215
+ retry: "Retry.Retry",
216
+ },
217
+ } as const;
218
+ const { code } = generateService(input, { ...spec, schemaType: "Codec" });
219
+ for (const name of ["Input", "List", "Map", "Output"]) {
220
+ expect(code).toContain(`as any as S.Codec<${name}>`);
221
+ }
222
+ expect(code).not.toContain("S.Schema<");
223
+ expect(generateService(input, spec).code).toContain(
224
+ "as any as S.Schema<Input>",
225
+ );
226
+ });
227
+
126
228
  test("emits a boolean piped through T.StringEncoded()", () => {
127
229
  const { code } = generateService(
128
230
  model({
@@ -166,3 +268,48 @@ describe("generateService", () => {
166
268
  expect(code).not.toContain('"true" | "false"');
167
269
  });
168
270
  });
271
+
272
+ describe("nested structures", () => {
273
+ const nested = (sharedWithResponse: boolean) =>
274
+ model({
275
+ "com.example.x#GetThing": {
276
+ type: "operation",
277
+ input: { target: "com.example.x#GetThingRequest" },
278
+ output: { target: "com.example.x#GetThingResponse" },
279
+ },
280
+ "com.example.x#GetThingRequest": {
281
+ type: "structure",
282
+ traits: { "smithy.api#input": {} },
283
+ members: { profile: { target: "com.example.x#Profile" } },
284
+ },
285
+ "com.example.x#GetThingResponse": {
286
+ type: "structure",
287
+ traits: { "smithy.api#output": {} },
288
+ members: sharedWithResponse
289
+ ? { profile: { target: "com.example.x#Profile" } }
290
+ : {},
291
+ },
292
+ "com.example.x#Profile": {
293
+ type: "structure",
294
+ members: { dynamicMemoryEnabled: { target: "com.example.x#Flag" } },
295
+ },
296
+ "com.example.x#Flag": boolEnum,
297
+ });
298
+
299
+ test("retargets a member of a structure only requests reach", () => {
300
+ const m = nested(false);
301
+ expect(booleanStringEnums(m)).toEqual({ members: 1, lists: 0 });
302
+ const member =
303
+ m.shapes["com.example.x#Profile"].members.dynamicMemoryEnabled;
304
+ expect(member.target).toBe("smithy.api#Boolean");
305
+ expect(member.traits[STRING_ENCODED_TRAIT]).toEqual({});
306
+ });
307
+
308
+ test("leaves a structure a response also delivers", () => {
309
+ const m = nested(true);
310
+ expect(booleanStringEnums(m)).toEqual({ members: 0, lists: 0 });
311
+ expect(
312
+ m.shapes["com.example.x#Profile"].members.dynamicMemoryEnabled.target,
313
+ ).toBe("com.example.x#Flag");
314
+ });
315
+ });
@@ -10,17 +10,17 @@
10
10
  * {@link generateService} runs this pass over the loaded model before
11
11
  * emitting: REQUEST members of such an enum are retargeted to a real
12
12
  * `smithy.api#Boolean` and stamped {@link STRING_ENCODED_TRAIT}, which the
13
- * generator emits as `T.StringEncoded()` and the protocol's `buildRequest`
14
- * honors by sending the value's string spelling. The TS surface becomes
15
- * `boolean` with the wire unchanged. The model on disk keeps the string
16
- * enum the description documents.
13
+ * generator emits as `T.StringEncoded()` and the protocol honors by sending
14
+ * the value's string spelling. The TS surface becomes `boolean` with the
15
+ * wire unchanged. The model on disk keeps the string enum the description
16
+ * documents.
17
17
  *
18
18
  * Deliberately narrow:
19
19
  *
20
- * • request shapes only (`smithy.api#input`) — a response member would
21
- * need the mirror-image decode, which the protocol does not do;
22
- * • a list member is retargeted only when EVERY reference to that list
23
- * comes from a request shape, so a shared list is never rewritten;
20
+ * • request-only shapes — an operation input, or a structure or list the
21
+ * inputs reach and no output or error does. A member a response can
22
+ * deliver would need the mirror-image decode, which the protocol does
23
+ * not do, so a shape used both ways keeps its documented string type;
24
24
  * • the enum must be exactly `{"true", "false"}` — a three-value enum
25
25
  * that happens to include them is left alone.
26
26
  */
@@ -56,40 +56,97 @@ const booleanEnumIds = (shapes: Record<string, any>): Set<string> => {
56
56
  return out;
57
57
  };
58
58
 
59
- const isRequestShape = (shape: any): boolean =>
60
- shape?.type === "structure" && "smithy.api#input" in (shape.traits ?? {});
59
+ /** Every shape id a shape points at: members, list/map entries, mixins. */
60
+ const targetsOf = (shape: any): string[] => {
61
+ const out: string[] = [];
62
+ for (const member of Object.values<any>(shape?.members ?? {})) {
63
+ if (member?.target) out.push(member.target);
64
+ }
65
+ for (const key of ["member", "value", "key"] as const) {
66
+ const target = shape?.[key]?.target;
67
+ if (target) out.push(target);
68
+ }
69
+ for (const mixin of shape?.mixins ?? []) {
70
+ if (mixin?.target) out.push(mixin.target);
71
+ }
72
+ return out;
73
+ };
74
+
75
+ const reachable = (
76
+ shapes: Record<string, any>,
77
+ roots: ReadonlyArray<string | undefined>,
78
+ ): Set<string> => {
79
+ const seen = new Set<string>();
80
+ const stack = roots.filter((r): r is string => r !== undefined);
81
+ while (stack.length > 0) {
82
+ const id = stack.pop()!;
83
+ if (seen.has(id) || shapes[id] === undefined) continue;
84
+ seen.add(id);
85
+ stack.push(...targetsOf(shapes[id]));
86
+ }
87
+ return seen;
88
+ };
89
+
90
+ /**
91
+ * Shapes an operation input reaches that no output or error does. Operation
92
+ * I/O is the spine; the `smithy.api#input`/`#output`/`#error` traits cover
93
+ * models whose shapes are not wired through an operation, and a structure
94
+ * the inputs never reach counts as a response root — unreachable from a
95
+ * request, it can only describe something the API hands back.
96
+ */
97
+ const requestOnlyShapes = (shapes: Record<string, any>): Set<string> => {
98
+ const requestRoots: Array<string | undefined> = [];
99
+ const responseRoots: Array<string | undefined> = [];
100
+ for (const [id, shape] of Object.entries<any>(shapes)) {
101
+ if (shape?.type === "operation") {
102
+ requestRoots.push(shape.input?.target);
103
+ responseRoots.push(shape.output?.target);
104
+ for (const error of shape.errors ?? []) responseRoots.push(error?.target);
105
+ continue;
106
+ }
107
+ const traits = shape?.traits ?? {};
108
+ if ("smithy.api#input" in traits) requestRoots.push(id);
109
+ if ("smithy.api#output" in traits || "smithy.api#error" in traits) {
110
+ responseRoots.push(id);
111
+ }
112
+ }
113
+ const fromRequests = reachable(shapes, requestRoots);
114
+ for (const [id, shape] of Object.entries<any>(shapes)) {
115
+ if (shape?.type === "structure" && !fromRequests.has(id)) {
116
+ responseRoots.push(id);
117
+ }
118
+ }
119
+ const fromResponses = reachable(shapes, responseRoots);
120
+ const out = new Set<string>();
121
+ for (const id of fromRequests) {
122
+ if (!fromResponses.has(id)) out.add(id);
123
+ }
124
+ return out;
125
+ };
61
126
 
62
127
  export const booleanStringEnums = (model: any): BooleanStringEnumResult => {
63
128
  const shapes: Record<string, any> = model?.shapes ?? {};
64
129
  const boolEnums = booleanEnumIds(shapes);
65
130
  if (boolEnums.size === 0) return { members: 0, lists: 0 };
66
131
 
67
- // Lists of a boolean enum, and whether anything outside a request shape
68
- // refers to them (a shared list keeps its documented string type).
132
+ const requestOnly = requestOnlyShapes(shapes);
69
133
  const boolLists = new Set<string>();
70
134
  for (const [id, shape] of Object.entries(shapes)) {
71
- if (shape?.type === "list" && boolEnums.has(shape.member?.target)) {
135
+ if (
136
+ shape?.type === "list" &&
137
+ boolEnums.has(shape.member?.target) &&
138
+ requestOnly.has(id)
139
+ ) {
72
140
  boolLists.add(id);
73
141
  }
74
142
  }
75
- const sharedLists = new Set<string>();
76
- for (const shape of Object.values(shapes)) {
77
- if (isRequestShape(shape)) continue;
78
- for (const member of Object.values<any>(shape?.members ?? {})) {
79
- if (boolLists.has(member?.target)) sharedLists.add(member.target);
80
- }
81
- if (boolLists.has(shape?.member?.target)) {
82
- sharedLists.add(shape.member.target);
83
- }
84
- }
85
143
 
86
144
  let members = 0;
87
145
  const retargeted = new Set<string>();
88
- for (const shape of Object.values<any>(shapes)) {
89
- if (!isRequestShape(shape)) continue;
146
+ for (const [id, shape] of Object.entries<any>(shapes)) {
147
+ if (shape?.type !== "structure" || !requestOnly.has(id)) continue;
90
148
  for (const member of Object.values<any>(shape.members ?? {})) {
91
- const isList =
92
- boolLists.has(member?.target) && !sharedLists.has(member.target);
149
+ const isList = boolLists.has(member?.target);
93
150
  if (!boolEnums.has(member?.target) && !isList) continue;
94
151
  if (isList) {
95
152
  retargeted.add(member.target);
@@ -14,8 +14,8 @@ import { BunRuntime, BunServices } from "@effect/platform-bun";
14
14
  import { Console, Effect } from "effect";
15
15
  import * as FileSystem from "effect/FileSystem";
16
16
  import * as Path from "effect/Path";
17
- import { Flag } from "effect/unstable/cli";
18
- import { Command } from "effect/unstable/cli";
17
+ import { Flag } from "effect/cli";
18
+ import { Command } from "effect/cli";
19
19
  import { barrel } from "./emit.ts";
20
20
  import { formatGenerated } from "./format.ts";
21
21
  import { generateService, type SdkSpec } from "./generator.ts";
@@ -0,0 +1,151 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { generateService, type SdkSpec } from "./generator.ts";
3
+
4
+ const primitiveUnionModel = (kind: "enum" | "intEnum", request: boolean) => ({
5
+ smithy: "2.0",
6
+ shapes: {
7
+ "com.example.union#Example": {
8
+ type: "service",
9
+ version: "2024-01-01",
10
+ operations: [{ target: "com.example.union#GetValue" }],
11
+ },
12
+ "com.example.union#GetValue": {
13
+ type: "operation",
14
+ input: { target: "com.example.union#Request" },
15
+ output: { target: "com.example.union#Response" },
16
+ traits: { "smithy.api#http": { method: "POST", uri: "/value" } },
17
+ },
18
+ "com.example.union#Request": {
19
+ type: "structure",
20
+ traits: { "smithy.api#input": {} },
21
+ members: request ? { value: { target: "com.example.union#Value" } } : {},
22
+ },
23
+ "com.example.union#Response": {
24
+ type: "structure",
25
+ members: { value: { target: "com.example.union#Value" } },
26
+ },
27
+ "com.example.union#Mode": {
28
+ type: kind,
29
+ members: {
30
+ STOP: {
31
+ target: "smithy.api#Unit",
32
+ traits: { "smithy.api#enumValue": kind === "enum" ? "stop" : 1 },
33
+ },
34
+ },
35
+ },
36
+ "com.example.union#Value": {
37
+ type: "union",
38
+ members: {
39
+ mode: { target: "com.example.union#Mode" },
40
+ legacy: { target: "smithy.api#Boolean" },
41
+ },
42
+ },
43
+ },
44
+ });
45
+
46
+ const spec: SdkSpec = {
47
+ unionStyle: "primitive",
48
+ operationDecl: {
49
+ contextType: "ExampleContext",
50
+ commonErrorType: "ExampleError",
51
+ commonErrorClasses: [],
52
+ protocol: "ExampleProtocol",
53
+ retry: "Retry.Retry",
54
+ },
55
+ };
56
+
57
+ describe("operation protocol overrides", () => {
58
+ for (const retry of [undefined, "Retry.Retry"]) {
59
+ for (const overridden of [false, true]) {
60
+ test(`${overridden ? "overridden" : "default"} protocol with ${retry ? "configured" : "disabled"} retries`, () => {
61
+ const { code } = generateService(primitiveUnionModel("enum", false), {
62
+ ...spec,
63
+ operationDecl: {
64
+ contextType: "ExampleContext",
65
+ commonErrorType: "ExampleError",
66
+ commonErrorClasses: [],
67
+ protocol: "ExampleProtocol",
68
+ retry,
69
+ overrides: () =>
70
+ overridden
71
+ ? { contextType: "PublicContext", protocol: "PublicProtocol" }
72
+ : undefined,
73
+ },
74
+ });
75
+ expect(code).toContain(
76
+ ` protocol: ${overridden ? "PublicProtocol" : "ExampleProtocol"},`,
77
+ );
78
+ expect(code).toContain(
79
+ ` ${overridden ? "PublicContext" : "ExampleContext"}\n>`,
80
+ );
81
+ if (retry) expect(code).toContain(` retry: ${retry},`);
82
+ else expect(code).not.toContain(" retry:");
83
+ });
84
+ }
85
+ }
86
+ });
87
+
88
+ describe("primitive union generation", () => {
89
+ for (const kind of ["enum", "intEnum"] as const) {
90
+ for (const request of [false, true]) {
91
+ test(`${kind}/Boolean stays open when ${request ? "request-reachable" : "response-only"}`, () => {
92
+ const { code } = generateService(
93
+ primitiveUnionModel(kind, request),
94
+ spec,
95
+ );
96
+ const primitive = kind === "enum" ? "string" : "number";
97
+ expect(code).toContain(
98
+ `export type Value = Mode | (${primitive} & {}) | boolean;`,
99
+ );
100
+ expect(code).toContain(
101
+ "export const Value: S.Codec<Value> = /*@__PURE__*/ S.Union([Mode, S.Boolean]);",
102
+ );
103
+ expect(code).toContain(
104
+ kind === "enum"
105
+ ? "export const Mode = S.String;"
106
+ : "export const Mode = S.Number;",
107
+ );
108
+ expect(code).not.toContain("export type Value = Mode | boolean;");
109
+ });
110
+ }
111
+ }
112
+
113
+ test("opaque-cases retains its existing primitive union codec", () => {
114
+ const { code } = generateService(primitiveUnionModel("enum", false), {
115
+ ...spec,
116
+ unionStyle: "opaque-cases",
117
+ });
118
+ expect(code).toContain("export type Value = Mode | boolean;");
119
+ expect(code).toContain(
120
+ "export const Value = /*@__PURE__*/ S.Unknown.pipe(T.UnionCases([[],[]]));",
121
+ );
122
+ expect(code).not.toContain("S.Union([Mode, S.Boolean])");
123
+ });
124
+
125
+ test("primitive union emission requires an explicit style", () => {
126
+ expect(() =>
127
+ generateService(primitiveUnionModel("enum", false), {
128
+ ...spec,
129
+ unionStyle: undefined,
130
+ }),
131
+ ).toThrow("no union emission configured for shape com.example.union#Value");
132
+ });
133
+
134
+ test("primitive style rejects object cases", () => {
135
+ const model = primitiveUnionModel("enum", false);
136
+ model.shapes["com.example.union#Value"].members.mode.target =
137
+ "com.example.union#Request";
138
+ expect(() => generateService(model, spec)).toThrow(
139
+ "no union emission configured for shape com.example.union#Value",
140
+ );
141
+ });
142
+
143
+ test("custom union emission retains precedence", () => {
144
+ const { code } = generateService(primitiveUnionModel("enum", false), {
145
+ ...spec,
146
+ union: ({ name }) => [`export type ${name} = CustomUnion;`],
147
+ });
148
+ expect(code).toContain("export type Value = CustomUnion;");
149
+ expect(code).not.toContain("S.Union([Mode, S.Boolean])");
150
+ });
151
+ });