1import * as OpenApiGenerator from "@effect/openapi-generator/OpenApiGenerator"
2import { assert, describe, it } from "@effect/vitest"
3import * as Effect from "effect/Effect"
4import type { OpenAPISpec, OpenAPISpecOperation, OpenAPISpecPathItem } from "effect/http-api/OpenApi"
5import type * as JsonSchema from "effect/JsonSchema"
6import { spawnSync } from "node:child_process"
7import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
8import { dirname, join } from "node:path"
9import { fileURLToPath } from "node:url"
10
11// These integration checks start a fresh TypeScript compiler under CI load.
12const compilationTimeout = 60_000
13
14function assertRuntime(spec: OpenAPISpec, expected: string) {
15 return Effect.gen(function*() {
16 const generator = yield* OpenApiGenerator.OpenApiGenerator
17
18 const result = yield* generator.generate(spec, {
19 name: "TestClient",
20 format: "httpclient"
21 })
22
23 // console.log(result)
24 assert.strictEqual(result, expected)
25 }).pipe(
26 Effect.provide(OpenApiGenerator.layerTransformerSchema)
27 )
28}
29
30function assertTypeOnly(spec: OpenAPISpec, expected: string) {
31 return Effect.gen(function*() {
32 const generator = yield* OpenApiGenerator.OpenApiGenerator
33
34 const result = yield* generator.generate(spec, {
35 name: "TestClient",
36 format: "httpclient-type-only"
37 })
38
39 // console.log(result)
40 assert.strictEqual(result, expected)
41 }).pipe(
42 Effect.provide(OpenApiGenerator.layerTransformerTs)
43 )
44}
45
46function assertRuntimeIncludes(
47 spec: OpenAPISpec,
48 includes: ReadonlyArray<string>,
49 excludes: ReadonlyArray<string> = []
50) {
51 return Effect.gen(function*() {
52 const generator = yield* OpenApiGenerator.OpenApiGenerator
53
54 const result = yield* generator.generate(spec, {
55 name: "TestClient",
56 format: "httpclient"
57 })
58
59 for (const expected of includes) {
60 assert.include(result, expected)
61 }
62 for (const excluded of excludes) {
63 assert.notInclude(result, excluded)
64 }
65 }).pipe(
66 Effect.provide(OpenApiGenerator.layerTransformerSchema)
67 )
68}
69
70function assertTypeOnlyIncludes(
71 spec: OpenAPISpec,
72 includes: ReadonlyArray<string>,
73 excludes: ReadonlyArray<string> = []
74) {
75 return Effect.gen(function*() {
76 const generator = yield* OpenApiGenerator.OpenApiGenerator
77
78 const result = yield* generator.generate(spec, {
79 name: "TestClient",
80 format: "httpclient-type-only"
81 })
82
83 for (const expected of includes) {
84 assert.include(result, expected)
85 }
86 for (const excluded of excludes) {
87 assert.notInclude(result, excluded)
88 }
89 }).pipe(
90 Effect.provide(OpenApiGenerator.layerTransformerTs)
91 )
92}
93
94function assertGeneratedClientsCompile(
95 spec: OpenAPISpec,
96 options: {
97 readonly exactOptionalPropertyTypes?: boolean | undefined
98 readonly formats?: ReadonlyArray<"httpclient" | "httpclient-type-only"> | undefined
99 readonly usage?: string | undefined
100 } = {}
101) {
102 const generate = (
103 format: "httpclient" | "httpclient-type-only",
104 layer: typeof OpenApiGenerator.layerTransformerSchema
105 ) =>
106 Effect.gen(function*() {
107 const generator = yield* OpenApiGenerator.OpenApiGenerator
108 return yield* generator.generate(spec, { name: "TestClient", format })
109 }).pipe(Effect.provide(layer))
110
111 return Effect.gen(function*() {
112 const formats = options.formats ?? ["httpclient", "httpclient-type-only"]
113 const clients = yield* Effect.all(formats.map((format) =>
114 generate(
115 format,
116 format === "httpclient"
117 ? OpenApiGenerator.layerTransformerSchema
118 : OpenApiGenerator.layerTransformerTs
119 )
120 ))
121 const directory = mkdtempSync(join(dirname(fileURLToPath(import.meta.url)), ".generated-clients-"))
122 try {
123 const files = clients.map((client, index) => {
124 const path = join(directory, `Client${index}.ts`)
125 writeFileSync(path, `${client}\n${options.usage ?? ""}`)
126 return path
127 })
128 const configPath = join(directory, "tsconfig.json")
129 writeFileSync(
130 configPath,
131 JSON.stringify({
132 compilerOptions: {
133 allowImportingTsExtensions: true,
134 exactOptionalPropertyTypes: options.exactOptionalPropertyTypes ?? true,
135 lib: ["ESNext", "DOM"],
136 module: "NodeNext",
137 noEmit: true,
138 skipLibCheck: true,
139 strict: true,
140 target: "ES2022",
141 types: [],
142 verbatimModuleSyntax: true
143 },
144 files
145 })
146 )
147 const repositoryRoot = join(dirname(fileURLToPath(import.meta.url)), "../../../..")
148 const result = spawnSync(join(repositoryRoot, "node_modules/.bin/tsc"), ["-p", configPath, "--pretty", "false"], {
149 cwd: repositoryRoot,
150 encoding: "utf8"
151 })
152 assert.strictEqual(result.status, 0, `${result.stdout}${result.stderr}`)
153 } finally {
154 rmSync(directory, { recursive: true, force: true })
155 }
156 })
157}
158
159function assertHttpApiIncludes(
160 spec: OpenAPISpec,
161 includes: ReadonlyArray<string>,
162 excludes: ReadonlyArray<string> = []
163) {
164 return Effect.gen(function*() {
165 const generator = yield* OpenApiGenerator.OpenApiGenerator
166
167 const result = yield* generator.generate(spec, {
168 name: "TestClient",
169 format: "httpapi"
170 })
171
172 for (const expected of includes) {
173 assert.include(result, expected)
174 }
175 for (const excluded of excludes) {
176 assert.notInclude(result, excluded)
177 }
178 }).pipe(
179 Effect.provide(OpenApiGenerator.layerTransformerSchema)
180 )
181}
182
183function assertHttpApiWithWarnings(
184 spec: OpenAPISpec,
185 options: {
186 readonly includes?: ReadonlyArray<string> | undefined
187 readonly excludes?: ReadonlyArray<string> | undefined
188 readonly occurrences?:
189 | ReadonlyArray<{
190 readonly substring: string
191 readonly count: number
192 }>
193 | undefined
194 readonly warnings: ReadonlyArray<
195 Pick<OpenApiGenerator.OpenApiGeneratorWarning, "code" | "path" | "method" | "operationId">
196 >
197 }
198) {
199 return Effect.gen(function*() {
200 const generator = yield* OpenApiGenerator.OpenApiGenerator
201 const warnings: Array<OpenApiGenerator.OpenApiGeneratorWarning> = []
202
203 const result = yield* generator.generate(spec, {
204 name: "TestClient",
205 format: "httpapi",
206 onWarning: (warning) => {
207 warnings.push(warning)
208 }
209 })
210
211 for (const expected of options.includes ?? []) {
212 assert.include(result, expected)
213 }
214 for (const excluded of options.excludes ?? []) {
215 assert.notInclude(result, excluded)
216 }
217 for (const { substring, count } of options.occurrences ?? []) {
218 assert.strictEqual(result.split(substring).length - 1, count)
219 }
220
221 assert.deepStrictEqual(
222 warnings.map((warning) => ({
223 code: warning.code,
224 path: warning.path,
225 method: warning.method,
226 operationId: warning.operationId
227 })),
228 options.warnings
229 )
230 }).pipe(
231 Effect.provide(OpenApiGenerator.layerTransformerSchema)
232 )
233}
234
235function assertRuntimeStableWithWarnings(
236 spec: OpenAPISpec,
237 expectedWarnings: ReadonlyArray<
238 Pick<OpenApiGenerator.OpenApiGeneratorWarning, "code" | "path" | "method" | "operationId">
239 >
240) {
241 return Effect.gen(function*() {
242 const generator = yield* OpenApiGenerator.OpenApiGenerator
243 const warnings: Array<OpenApiGenerator.OpenApiGeneratorWarning> = []
244
245 const baseline = yield* generator.generate(spec, {
246 name: "TestClient",
247 format: "httpclient"
248 })
249 const withWarnings = yield* generator.generate(spec, {
250 name: "TestClient",
251 format: "httpclient",
252 onWarning: (warning) => {
253 warnings.push(warning)
254 }
255 })
256
257 assert.strictEqual(withWarnings, baseline)
258 assert.deepStrictEqual(
259 warnings.map((warning) => ({
260 code: warning.code,
261 path: warning.path,
262 method: warning.method,
263 operationId: warning.operationId
264 })),
265 expectedWarnings
266 )
267 }).pipe(
268 Effect.provide(OpenApiGenerator.layerTransformerSchema)
269 )
270}
271
272function assertTypeOnlyStableWithWarnings(
273 spec: OpenAPISpec,
274 expectedWarnings: ReadonlyArray<
275 Pick<OpenApiGenerator.OpenApiGeneratorWarning, "code" | "path" | "method" | "operationId">
276 >
277) {
278 return Effect.gen(function*() {
279 const generator = yield* OpenApiGenerator.OpenApiGenerator
280 const warnings: Array<OpenApiGenerator.OpenApiGeneratorWarning> = []
281
282 const baseline = yield* generator.generate(spec, {
283 name: "TestClient",
284 format: "httpclient-type-only"
285 })
286 const withWarnings = yield* generator.generate(spec, {
287 name: "TestClient",
288 format: "httpclient-type-only",
289 onWarning: (warning) => {
290 warnings.push(warning)
291 }
292 })
293
294 assert.strictEqual(withWarnings, baseline)
295 assert.deepStrictEqual(
296 warnings.map((warning) => ({
297 code: warning.code,
298 path: warning.path,
299 method: warning.method,
300 operationId: warning.operationId
301 })),
302 expectedWarnings
303 )
304 }).pipe(
305 Effect.provide(OpenApiGenerator.layerTransformerTs)
306 )
307}
308
309const regressionSpec: OpenAPISpec = {
310 openapi: "3.1.0",
311 info: {
312 title: "Regression API",
313 version: "1.0.0"
314 },
315 paths: {
316 "/users/{id}": {
317 get: {
318 operationId: "getUser",
319 summary: "Get user",
320 description: "Read a user",
321 parameters: [
322 {
323 name: "id",
324 in: "path",
325 schema: {
326 type: "string"
327 },
328 required: true
329 },
330 {
331 name: "filter",
332 in: "query",
333 schema: {
334 type: "string"
335 },
336 required: false
337 },
338 {
339 name: "trace-id",
340 in: "header",
341 schema: {
342 type: "string"
343 },
344 required: false
345 },
346 {
347 name: "session",
348 in: "cookie",
349 schema: {
350 type: "string"
351 },
352 required: false
353 }
354 ],
355 responses: {
356 default: {
357 description: "Default user response",
358 content: {
359 "application/json": {
360 schema: {
361 type: "object",
362 properties: {
363 id: {
364 type: "string"
365 }
366 },
367 required: ["id"],
368 additionalProperties: false
369 }
370 }
371 }
372 }
373 } as any,
374 tags: ["Users"],
375 security: [{ apiKey: [] }]
376 }
377 }
378 },
379 components: {
380 schemas: {},
381 securitySchemes: {
382 apiKey: {
383 type: "apiKey",
384 name: "x-api-key",
385 in: "header"
386 }
387 }
388 },
389 security: [],
390 tags: [{ name: "Users" }]
391}
392
393const responseMatchingSpec: OpenAPISpec = {
394 openapi: "3.1.0",
395 info: {
396 title: "Response matching API",
397 version: "1.0.0"
398 },
399 paths: {
400 "/auth/device/token": {
401 post: {
402 operationId: "pollDeviceToken",
403 parameters: [],
404 tags: ["ResponseMatching"],
405 security: [],
406 responses: {
407 400: {
408 description: "Problem details or OAuth polling state",
409 content: {
410 "application/problem+json": {
411 schema: {
412 type: "object",
413 properties: {
414 kind: { const: "InvalidRequestError" },
415 code: { type: "string" }
416 },
417 required: ["kind", "code"],
418 additionalProperties: false
419 }
420 },
421 "application/json": {
422 schema: {
423 type: "object",
424 properties: {
425 kind: { const: "DeviceTokenOAuthError" },
426 error: { type: "string" }
427 },
428 required: ["kind", "error"],
429 additionalProperties: false
430 }
431 }
432 }
433 }
434 }
435 }
436 },
437 "/archive": {
438 get: {
439 operationId: "downloadArchive",
440 parameters: [],
441 tags: ["ResponseMatching"],
442 security: [],
443 responses: {
444 200: {
445 description: "Archive",
446 content: {
447 "application/zip": {
448 schema: { type: "string", format: "binary" }
449 }
450 }
451 },
452 404: {
453 description: "Not found",
454 content: {
455 "application/problem+json": {
456 schema: {
457 type: "object",
458 properties: {
459 title: { type: "string" }
460 },
461 required: ["title"],
462 additionalProperties: false
463 }
464 }
465 }
466 }
467 }
468 }
469 },
470 "/avatar": {
471 get: {
472 operationId: "downloadAvatar",
473 parameters: [],
474 tags: ["ResponseMatching"],
475 security: [],
476 responses: {
477 200: {
478 description: "Avatar",
479 content: {
480 "image/png": {
481 schema: { type: "string" }
482 }
483 }
484 }
485 }
486 }
487 },
488 "/custom-binary": {
489 get: {
490 operationId: "downloadCustomBinary",
491 parameters: [],
492 tags: ["ResponseMatching"],
493 security: [],
494 responses: {
495 200: {
496 description: "Custom binary",
497 content: {
498 "application/vnd.example.data": {
499 schema: { type: "string", format: "binary" }
500 }
501 }
502 }
503 }
504 }
505 },
506 "/mixed-content": {
507 get: {
508 operationId: "downloadMixedContent",
509 parameters: [],
510 tags: ["ResponseMatching"],
511 security: [],
512 responses: {
513 200: {
514 description: "JSON metadata or binary content",
515 content: {
516 "application/json; charset=utf-8": {
517 schema: {
518 type: "object",
519 properties: { href: { type: "string" } },
520 required: ["href"],
521 additionalProperties: false
522 }
523 },
524 "application/octet-stream; boundary=payload": {
525 schema: { type: "string" }
526 }
527 }
528 },
529 400: {
530 description: "Invalid request",
531 content: {
532 "application/problem+json; charset=utf-8": {
533 schema: {
534 type: "object",
535 properties: { title: { type: "string" } },
536 required: ["title"],
537 additionalProperties: false
538 }
539 }
540 }
541 }
542 }
543 }
544 },
545 "/resources/{id}": {
546 head: {
547 operationId: "checkResource",
548 parameters: [{
549 name: "id",
550 in: "path",
551 required: true,
552 schema: { type: "string" }
553 }],
554 tags: ["ResponseMatching"],
555 security: [],
556 responses: {
557 200: { description: "Exists" },
558 404: { description: "Not found" },
559 500: { description: "Server error" }
560 }
561 }
562 }
563 },
564 components: {
565 schemas: {},
566 securitySchemes: {}
567 },
568 security: [],
569 tags: [{ name: "ResponseMatching" }]
570}
571
572const voidSuccessSpec: OpenAPISpec = {
573 openapi: "3.1.0",
574 info: {
575 title: "Void success API",
576 version: "1.0.0"
577 },
578 paths: {
579 "/mixed": {
580 get: {
581 operationId: "mixedSuccess",
582 parameters: [],
583 tags: ["VoidSuccess"],
584 security: [],
585 responses: {
586 200: {
587 description: "JSON value",
588 content: {
589 "application/json": {
590 schema: { type: "string" }
591 }
592 }
593 },
594 204: { description: "No content" }
595 }
596 }
597 },
598 "/cached": {
599 get: {
600 operationId: "cached",
601 parameters: [],
602 tags: ["VoidSuccess"],
603 security: [],
604 responses: {
605 304: { description: "Not modified" }
606 }
607 }
608 }
609 },
610 components: {
611 schemas: {},
612 securitySchemes: {}
613 },
614 security: [],
615 tags: [{ name: "VoidSuccess" }]
616}
617
618function multipartSpec(schema: JsonSchema.JsonSchema, schemas: JsonSchema.Definitions = {}): OpenAPISpec {
619 return {
620 openapi: "3.1.0",
621 info: { title: "Upload API", version: "1.0.0" },
622 paths: {
623 "/upload": {
624 post: {
625 operationId: "upload",
626 parameters: [],
627 requestBody: {
628 required: true,
629 content: { "multipart/form-data": { schema } }
630 },
631 responses: { "204": { description: "Uploaded" } },
632 tags: ["Upload"],
633 security: []
634 }
635 }
636 },
637 components: { schemas, securitySchemes: {} },
638 security: [],
639 tags: [{ name: "Upload" }]
640 }
641}
642
643// spawnSync blocks this worker, so compilation must not consume another test's deadline.
644describe("OpenApiGenerator", { concurrent: false }, () => {
645 describe("schema", () => {
646 it.effect("get operation", () =>
647 assertRuntime(
648 {
649 openapi: "3.1.0",
650 info: {
651 title: "Test API",
652 version: "1.0.0"
653 },
654 paths: {
655 "/users/{id}": {
656 get: {
657 operationId: "getUser",
658 parameters: [
659 {
660 name: "id",
661 in: "path",
662 schema: {
663 type: "string"
664 },
665 required: true
666 }
667 ],
668 responses: {
669 200: {
670 description: "User retrieved successfully",
671 content: {
672 "application/json": {
673 schema: {
674 type: "object",
675 properties: {
676 id: {
677 type: "string"
678 },
679 name: {
680 type: "string"
681 }
682 },
683 required: ["id", "name"],
684 additionalProperties: false,
685 description: "User object"
686 }
687 }
688 }
689 }
690 },
691 tags: ["Users"],
692 security: []
693 }
694 }
695 },
696 components: {
697 schemas: {},
698 securitySchemes: {}
699 },
700 security: [],
701 tags: []
702 },
703 `import * as Data from "effect/Data"
704import * as Effect from "effect/Effect"
705import type { SchemaError } from "effect/Schema"
706import * as Schema from "effect/Schema"
707import type * as HttpClient from "effect/http/HttpClient"
708import * as HttpClientError from "effect/http/HttpClientError"
709import * as HttpClientRequest from "effect/http/HttpClientRequest"
710import * as HttpClientResponse from "effect/http/HttpClientResponse"
711// schemas
712export type GetUser200 = { readonly "id": string, readonly "name": string }
713export const GetUser200 = Schema.Struct({ "id": Schema.String, "name": Schema.String }).annotate({ "description": "User object" })
714
715export interface OperationConfig {
716 /**
717 * Whether or not the response should be included in the value returned from
718 * an operation.
719 *
720 * If set to \`true\`, a tuple of \`[A, HttpClientResponse]\` will be returned,
721 * where \`A\` is the success type of the operation.
722 *
723 * If set to \`false\`, only the success type of the operation will be returned.
724 */
725 readonly includeResponse?: boolean | undefined
726}
727
728/**
729 * A utility type which optionally includes the response in the return result
730 * of an operation based upon the value of the \`includeResponse\` configuration
731 * option.
732 */
733export type WithOptionalResponse<A, Config extends OperationConfig | undefined> = Config extends {
734 readonly includeResponse: true
735} ? [A, HttpClientResponse.HttpClientResponse]
736 : Config extends { readonly includeResponse?: false | undefined } | undefined ? A
737 : A | [A, HttpClientResponse.HttpClientResponse]
738
739export const make = (
740 httpClient: HttpClient.HttpClient,
741 options: {
742 readonly transformClient?: ((client: HttpClient.HttpClient) => Effect.Effect<HttpClient.HttpClient>) | undefined
743 } = {}
744): TestClient => {
745 const unexpectedStatus = (response: HttpClientResponse.HttpClientResponse) =>
746 Effect.flatMap(
747 Effect.orElseSucceed(response.json, () => "Unexpected status code"),
748 (description) =>
749 Effect.fail(
750 new HttpClientError.HttpClientError({
751 reason: new HttpClientError.StatusCodeError({
752 request: response.request,
753 response,
754 description: typeof description === "string" ? description : JSON.stringify(description),
755 }),
756 }),
757 ),
758 )
759 const withResponse = <Config extends OperationConfig>(config: Config | undefined) => (
760 f: (response: HttpClientResponse.HttpClientResponse) => Effect.Effect<any, any>,
761 ): (request: HttpClientRequest.HttpClientRequest) => Effect.Effect<any, any> => {
762 const withOptionalResponse = (
763 config?.includeResponse
764 ? (response: HttpClientResponse.HttpClientResponse) => Effect.map(f(response), (a) => [a, response])
765 : (response: HttpClientResponse.HttpClientResponse) => f(response)
766 ) as any
767 return options?.transformClient
768 ? (request) =>
769 Effect.flatMap(
770 Effect.flatMap(options.transformClient!(httpClient), (client) => client.execute(request)),
771 withOptionalResponse
772 )
773 : (request) => Effect.flatMap(httpClient.execute(request), withOptionalResponse)
774 }
775 const __encodePathParam = encodeURIComponent
776 const __makePathRequest = (
777 method: (url: string) => HttpClientRequest.HttpClientRequest,
778 parameters: ReadonlyArray<string>,
779 getPath: () => string,
780 ) => Effect.suspend(() => {
781 const fail = (description: string, cause?: unknown) => Effect.fail(
782 new HttpClientError.HttpClientError({
783 reason: new HttpClientError.InvalidUrlError({
784 request: method(""),
785 cause,
786 description,
787 }),
788 }),
789 )
790 if (parameters.some((value) => value === "" || /^(?:\\.|%2e){1,2}$/i.test(value))) {
791 return fail("Path parameters must be non-empty and cannot be dot segments")
792 }
793 let path: string
794 try {
795 path = getPath()
796 } catch (cause) {
797 return fail("Failed to encode path parameter", cause)
798 }
799 if (path.split("/").some((segment) => /^(?:\\.|%2e){1,2}$/i.test(segment))) {
800 return fail("Request paths cannot contain dot segments")
801 }
802 return Effect.succeed(method(path))
803 })
804 const decodeSuccess =
805 <Schema extends Schema.Constraint>(schema: Schema) =>
806 (response: HttpClientResponse.HttpClientResponse) =>
807 HttpClientResponse.schemaBodyJson(schema)(response)
808 const decodeError =
809 <const Tag extends string, Schema extends Schema.Constraint>(tag: Tag, schema: Schema) =>
810 (response: HttpClientResponse.HttpClientResponse) =>
811 Effect.flatMap(
812 HttpClientResponse.schemaBodyJson(schema)(response),
813 (cause) => Effect.fail(TestClientError(tag, cause, response)),
814 )
815 return {
816 httpClient,
817 "getUser": (id, options: Parameters<TestClient["getUser"]>[1]) => __makePathRequest(HttpClientRequest.get, [id], () => "/users/" + __encodePathParam(id) + "").pipe(
818 Effect.flatMap((request) => request.pipe(
819 withResponse(options?.config)(HttpClientResponse.matchStatus({
820 "2xx": decodeSuccess(GetUser200),
821 orElse: unexpectedStatus
822 }))
823 ))
824 )
825 }
826}
827
828export interface TestClient {
829 readonly httpClient: HttpClient.HttpClient
830 readonly "getUser": {
831 <Config extends OperationConfig | undefined = undefined>(id: string, options: { readonly config: Config }): Effect.Effect<WithOptionalResponse<typeof GetUser200.Type, Config>, HttpClientError.HttpClientError | SchemaError>;
832 <Config extends OperationConfig | undefined = undefined>(id: string, options: { readonly config?: Config | undefined } | undefined): Effect.Effect<WithOptionalResponse<typeof GetUser200.Type, Config | undefined>, HttpClientError.HttpClientError | SchemaError>;
833 }
834}
835
836export interface TestClientError<Tag extends string, E> {
837 readonly _tag: Tag
838 readonly request: HttpClientRequest.HttpClientRequest
839 readonly response: HttpClientResponse.HttpClientResponse
840 readonly cause: E
841}
842
843class TestClientErrorImpl extends Data.Error<{
844 _tag: string
845 cause: any
846 request: HttpClientRequest.HttpClientRequest
847 response: HttpClientResponse.HttpClientResponse
848}> {}
849
850export const TestClientError = <Tag extends string, E>(
851 tag: Tag,
852 cause: E,
853 response: HttpClientResponse.HttpClientResponse,
854): TestClientError<Tag, E> =>
855 new TestClientErrorImpl({
856 _tag: tag,
857 cause,
858 response,
859 request: response.request,
860 }) as any`
861 ))
862
863 it.effect("resolves JSON Pointer-escaped local references", () =>
864 assertRuntimeIncludes(
865 {
866 openapi: "3.1.0",
867 info: {
868 title: "Test API",
869 version: "1.0.0"
870 },
871 paths: {
872 "/widgets": {
873 get: {
874 operationId: "listWidgets",
875 parameters: [
876 { $ref: "#/components/parameters/Page~1Limit" } as any,
877 { $ref: "#/components/parameters/Tilde~0Name" } as any
878 ],
879 responses: {
880 204: {
881 description: "No content"
882 }
883 },
884 tags: ["Widgets"],
885 security: []
886 }
887 }
888 },
889 components: {
890 schemas: {},
891 parameters: {
892 "Page/Limit": {
893 name: "limit",
894 in: "query",
895 required: false,
896 schema: { type: "integer" }
897 },
898 "Tilde~Name": {
899 name: "tilde",
900 in: "query",
901 required: false,
902 schema: { type: "string" }
903 }
904 }
905 } as any,
906 security: [],
907 tags: [{ name: "Widgets" }]
908 },
909 [
910 `readonly "limit"?: number`,
911 `readonly "tilde"?: string`,
912 `HttpClientRequest.setUrlParams({ "limit": options?.params?.["limit"] as any, "tilde": options?.params?.["tilde"] as any })`
913 ]
914 ))
915
916 it.effect("sse operation decodes event payload from json string", () =>
917 assertRuntimeIncludes(
918 {
919 openapi: "3.1.0",
920 info: {
921 title: "Test API",
922 version: "1.0.0"
923 },
924 paths: {
925 "/events": {
926 get: {
927 operationId: "streamEvents",
928 parameters: [],
929 responses: {
930 200: {
931 description: "Events streamed successfully",
932 content: {
933 "text/event-stream": {
934 schema: {
935 type: "object",
936 properties: {
937 type: {
938 type: "string"
939 },
940 value: {
941 type: "string"
942 }
943 },
944 required: ["type", "value"],
945 additionalProperties: false
946 }
947 }
948 }
949 }
950 },
951 tags: ["Events"],
952 security: []
953 }
954 }
955 },
956 components: {
957 schemas: {},
958 securitySchemes: {}
959 },
960 security: [],
961 tags: []
962 },
963 [
964 `import * as Sse from "effect/encoding/Sse"`,
965 `readonly "streamEventsSse": () => Stream.Stream<{ readonly event: string; readonly id: string | undefined; readonly data: typeof StreamEvents200Sse.Type }, HttpClientError.HttpClientError | SchemaError | Sse.Retry | Sse.SseError, typeof StreamEvents200Sse.DecodingServices>`,
966 `"streamEventsSse": () => HttpClientRequest.get("/events").pipe(`,
967 `sseRequest(StreamEvents200Sse)`,
968 `schema: Schema.ConstraintDecoder<Type, DecodingServices>`
969 ]
970 ))
971
972 it.effect("annotated sse operation decodes the full event schema", () =>
973 assertRuntimeIncludes(
974 {
975 openapi: "3.1.0",
976 info: {
977 title: "Test API",
978 version: "1.0.0"
979 },
980 paths: {
981 "/events": {
982 get: {
983 operationId: "streamEvents",
984 parameters: [],
985 responses: {
986 200: {
987 description: "Events streamed successfully",
988 content: {
989 "text/event-stream": {
990 schema: {
991 type: "object",
992 properties: {
993 id: {
994 anyOf: [{ type: "string" }, { type: "null" }]
995 },
996 event: { const: "message" },
997 data: { type: "string" }
998 },
999 required: ["id", "event", "data"],
1000 additionalProperties: false
1001 },
1002 "x-effect-stream": {
1003 encoding: "sse",
1004 errorSchema: {},
1005 causeSchema: {},
1006 failureEvent: "effect/http-api/stream/failure"
1007 }
1008 }
1009 }
1010 }
1011 },
1012 tags: ["Events"],
1013 security: []
1014 }
1015 }
1016 },
1017 components: {
1018 schemas: {},
1019 securitySchemes: {}
1020 },
1021 security: [],
1022 tags: []
1023 },
1024 [
1025 `"id": Schema.optionalKey(Schema.String), "event": Schema.Literal("message"), "data": Schema.String`,
1026 `"id": Schema.optionalKey(Schema.String), "event": Schema.Literal("effect/http-api/stream/failure"), "data": Schema.String`,
1027 `readonly "streamEventsSse": () => Stream.Stream<typeof StreamEvents200Sse.Type, HttpClientError.HttpClientError | SchemaError | Sse.Retry | Sse.SseError, typeof StreamEvents200Sse.DecodingServices>`,
1028 `sseEventRequest(StreamEvents200Sse)`,
1029 `Stream.pipeThroughChannel(Sse.decodeSchema(schema))`
1030 ]
1031 ))
1032
1033 it.effect("form-urlencoded request body generates bodyUrlParams", () =>
1034 assertRuntimeIncludes(
1035 {
1036 openapi: "3.1.0",
1037 info: {
1038 title: "Test API",
1039 version: "1.0.0"
1040 },
1041 paths: {
1042 "/auth/token": {
1043 post: {
1044 operationId: "issueToken",
1045 parameters: [],
1046 requestBody: {
1047 required: true,
1048 content: {
1049 "application/x-www-form-urlencoded": {
1050 schema: {
1051 type: "object",
1052 properties: {
1053 grant_type: { type: "string" },
1054 client_id: { type: "string" }
1055 },
1056 required: ["grant_type", "client_id"],
1057 additionalProperties: false
1058 }
1059 }
1060 }
1061 } as any,
1062 responses: {
1063 200: {
1064 description: "Token response",
1065 content: {
1066 "application/json": {
1067 schema: {
1068 type: "object",
1069 properties: {
1070 access_token: { type: "string" }
1071 },
1072 required: ["access_token"],
1073 additionalProperties: false
1074 }
1075 }
1076 }
1077 }
1078 },
1079 tags: ["Auth"],
1080 security: []
1081 }
1082 }
1083 },
1084 components: {
1085 schemas: {},
1086 securitySchemes: {}
1087 },
1088 security: [],
1089 tags: []
1090 },
1091 [
1092 `HttpClientRequest.bodyUrlParams(options.payload as any)`,
1093 `readonly payload: typeof IssueTokenRequestFormUrlEncoded.Encoded`
1094 ]
1095 ))
1096
1097 it.effect("preserves response variants and routes binary and bodiless statuses", () =>
1098 assertRuntimeIncludes(responseMatchingSpec, [
1099 `export const PollDeviceToken400 = Schema.Union(`,
1100 `Schema.Literal("InvalidRequestError")`,
1101 `Schema.Literal("DeviceTokenOAuthError")`,
1102 `"400": decodeError("PollDeviceToken400", PollDeviceToken400)`,
1103 `export type DownloadArchive404 = { readonly "title": string }`,
1104 `const decodeBinary = (response: HttpClientResponse.HttpClientResponse) =>`,
1105 `Effect.map(response.arrayBuffer, (buffer) => new Uint8Array(buffer))`,
1106 `"2xx": decodeBinary`,
1107 `readonly "downloadArchive": {`,
1108 `<Config extends OperationConfig | undefined = undefined>(options: { readonly config: Config }): Effect.Effect<WithOptionalResponse<Uint8Array, Config>`,
1109 `<Config extends OperationConfig | undefined = undefined>(options: { readonly config?: Config | undefined } | undefined): Effect.Effect<WithOptionalResponse<Uint8Array, Config | undefined>`,
1110 `readonly "downloadArchiveStream": () => Stream.Stream<Uint8Array, HttpClientError.HttpClientError>`,
1111 `readonly "downloadAvatarStream": () => Stream.Stream<Uint8Array, HttpClientError.HttpClientError>`,
1112 `"downloadAvatar": (options: Parameters<TestClient["downloadAvatar"]>[0]) => HttpClientRequest.get("/avatar").pipe(
1113 withResponse(options?.config)(HttpClientResponse.matchStatus({
1114 "2xx": decodeBinary`,
1115 `readonly "downloadCustomBinaryStream": () => Stream.Stream<Uint8Array, HttpClientError.HttpClientError>`,
1116 `"400": decodeError("DownloadMixedContent400", DownloadMixedContent400)`,
1117 `readonly "downloadMixedContent": {`,
1118 `<Config extends OperationConfig | undefined = undefined>(options: { readonly config: Config }): Effect.Effect<WithOptionalResponse<Uint8Array, Config>`,
1119 `<Config extends OperationConfig | undefined = undefined>(options: { readonly config?: Config | undefined } | undefined): Effect.Effect<WithOptionalResponse<Uint8Array, Config | undefined>`,
1120 `readonly "downloadMixedContentStream": () => Stream.Stream<Uint8Array, HttpClientError.HttpClientError>`,
1121 `"200": () => Effect.void`,
1122 `"404": decodeVoidError("404")`,
1123 `"500": decodeVoidError("500")`,
1124 `TestClientError<"404", undefined>`,
1125 `TestClientError<"500", undefined>`
1126 ], [
1127 `"200": decodeSuccess(DownloadMixedContent200)`,
1128 `typeof DownloadMixedContent200.Type | Uint8Array`
1129 ]))
1130
1131 it.effect("routes mixed and non-2xx bodiless success responses", () =>
1132 assertRuntimeIncludes(voidSuccessSpec, [
1133 `"200": decodeSuccess(MixedSuccess200)`,
1134 `"204": () => Effect.void`,
1135 `"304": () => Effect.void`,
1136 `WithOptionalResponse<typeof MixedSuccess200.Type | void, Config>`
1137 ]))
1138 })
1139
1140 describe("type-only", () => {
1141 it.effect("get operation", () =>
1142 assertTypeOnly(
1143 {
1144 openapi: "3.1.0",
1145 info: {
1146 title: "Test API",
1147 version: "1.0.0"
1148 },
1149 paths: {
1150 "/users/{id}": {
1151 get: {
1152 operationId: "getUser",
1153 parameters: [
1154 {
1155 name: "id",
1156 in: "path",
1157 schema: {
1158 type: "string"
1159 },
1160 required: true
1161 }
1162 ],
1163 responses: {
1164 200: {
1165 description: "User retrieved successfully",
1166 content: {
1167 "application/json": {
1168 schema: {
1169 type: "object",
1170 properties: {
1171 id: {
1172 type: "string"
1173 },
1174 name: {
1175 type: "string"
1176 }
1177 },
1178 required: ["id", "name"],
1179 additionalProperties: false
1180 }
1181 }
1182 }
1183 }
1184 },
1185 tags: ["Users"],
1186 security: []
1187 }
1188 }
1189 },
1190 components: {
1191 schemas: {},
1192 securitySchemes: {}
1193 },
1194 security: [],
1195 tags: []
1196 },
1197 `import * as Data from "effect/Data"
1198import * as Effect from "effect/Effect"
1199import type * as HttpClient from "effect/http/HttpClient"
1200import * as HttpClientError from "effect/http/HttpClientError"
1201import * as HttpClientRequest from "effect/http/HttpClientRequest"
1202import * as HttpClientResponse from "effect/http/HttpClientResponse"
1203// schemas
1204export type GetUser200 = { readonly "id": string, readonly "name": string }
1205
1206export interface OperationConfig {
1207 /**
1208 * Whether or not the response should be included in the value returned from
1209 * an operation.
1210 *
1211 * If set to \`true\`, a tuple of \`[A, HttpClientResponse]\` will be returned,
1212 * where \`A\` is the success type of the operation.
1213 *
1214 * If set to \`false\`, only the success type of the operation will be returned.
1215 */
1216 readonly includeResponse?: boolean | undefined
1217}
1218
1219/**
1220 * A utility type which optionally includes the response in the return result
1221 * of an operation based upon the value of the \`includeResponse\` configuration
1222 * option.
1223 */
1224export type WithOptionalResponse<A, Config extends OperationConfig | undefined> = Config extends {
1225 readonly includeResponse: true
1226} ? [A, HttpClientResponse.HttpClientResponse]
1227 : Config extends { readonly includeResponse?: false | undefined } | undefined ? A
1228 : A | [A, HttpClientResponse.HttpClientResponse]
1229
1230export const make = (
1231 httpClient: HttpClient.HttpClient,
1232 options: {
1233 readonly transformClient?: ((client: HttpClient.HttpClient) => Effect.Effect<HttpClient.HttpClient>) | undefined
1234 } = {}
1235): TestClient => {
1236 const unexpectedStatus = (response: HttpClientResponse.HttpClientResponse) =>
1237 Effect.flatMap(
1238 Effect.orElseSucceed(response.json, () => "Unexpected status code"),
1239 (description) =>
1240 Effect.fail(
1241 new HttpClientError.HttpClientError({
1242 reason: new HttpClientError.StatusCodeError({
1243 request: response.request,
1244 response,
1245 description: typeof description === "string" ? description : JSON.stringify(description),
1246 }),
1247 }),
1248 ),
1249 )
1250 const withResponse = <Config extends OperationConfig>(config: Config | undefined) => (
1251 f: (response: HttpClientResponse.HttpClientResponse) => Effect.Effect<any, any>,
1252 ): (request: HttpClientRequest.HttpClientRequest) => Effect.Effect<any, any> => {
1253 const withOptionalResponse = (
1254 config?.includeResponse
1255 ? (response: HttpClientResponse.HttpClientResponse) => Effect.map(f(response), (a) => [a, response])
1256 : (response: HttpClientResponse.HttpClientResponse) => f(response)
1257 ) as any
1258 return options?.transformClient
1259 ? (request) =>
1260 Effect.flatMap(
1261 Effect.flatMap(options.transformClient!(httpClient), (client) => client.execute(request)),
1262 withOptionalResponse
1263 )
1264 : (request) => Effect.flatMap(httpClient.execute(request), withOptionalResponse)
1265 }
1266 const __encodePathParam = encodeURIComponent
1267 const __makePathRequest = (
1268 method: (url: string) => HttpClientRequest.HttpClientRequest,
1269 parameters: ReadonlyArray<string>,
1270 getPath: () => string,
1271 ) => Effect.suspend(() => {
1272 const fail = (description: string, cause?: unknown) => Effect.fail(
1273 new HttpClientError.HttpClientError({
1274 reason: new HttpClientError.InvalidUrlError({
1275 request: method(""),
1276 cause,
1277 description,
1278 }),
1279 }),
1280 )
1281 if (parameters.some((value) => value === "" || /^(?:\\.|%2e){1,2}$/i.test(value))) {
1282 return fail("Path parameters must be non-empty and cannot be dot segments")
1283 }
1284 let path: string
1285 try {
1286 path = getPath()
1287 } catch (cause) {
1288 return fail("Failed to encode path parameter", cause)
1289 }
1290 if (path.split("/").some((segment) => /^(?:\\.|%2e){1,2}$/i.test(segment))) {
1291 return fail("Request paths cannot contain dot segments")
1292 }
1293 return Effect.succeed(method(path))
1294 })
1295 const decodeSuccess = <A>(response: HttpClientResponse.HttpClientResponse) =>
1296 response.json as Effect.Effect<A, HttpClientError.HttpClientError>
1297 const decodeVoid = (_response: HttpClientResponse.HttpClientResponse) =>
1298 Effect.void
1299 const decodeError =
1300 <Tag extends string, E>(tag: Tag) =>
1301 (
1302 response: HttpClientResponse.HttpClientResponse,
1303 ): Effect.Effect<
1304 never,
1305 TestClientError<Tag, E> | HttpClientError.HttpClientError
1306 > =>
1307 Effect.flatMap(
1308 response.json as Effect.Effect<E, HttpClientError.HttpClientError>,
1309 (cause) => Effect.fail(TestClientError(tag, cause, response)),
1310 )
1311 const onRequest = <Config extends OperationConfig>(config: Config | undefined) => (
1312 successCodes: ReadonlyArray<string>,
1313 errorCodes?: Record<string, string>,
1314 ) => {
1315 const cases: any = { orElse: unexpectedStatus }
1316 for (const code of successCodes) {
1317 cases[code] = decodeSuccess
1318 }
1319 if (errorCodes) {
1320 for (const [code, tag] of Object.entries(errorCodes)) {
1321 cases[code] = decodeError(tag)
1322 }
1323 }
1324 if (successCodes.length === 0) {
1325 cases["2xx"] = decodeVoid
1326 }
1327 return withResponse(config)(HttpClientResponse.matchStatus(cases) as any)
1328 }
1329 return {
1330 httpClient,
1331 "getUser": (id, options: Parameters<TestClient["getUser"]>[1]) => __makePathRequest(HttpClientRequest.get, [id], () => "/users/" + __encodePathParam(id) + "").pipe(
1332 Effect.flatMap((request) => request.pipe(
1333 onRequest(options?.config)(["2xx"])
1334 ))
1335 )
1336 }
1337}
1338
1339export interface TestClient {
1340 readonly httpClient: HttpClient.HttpClient
1341 readonly "getUser": {
1342 <Config extends OperationConfig | undefined = undefined>(id: string, options: { readonly config: Config }): Effect.Effect<WithOptionalResponse<GetUser200, Config>, HttpClientError.HttpClientError>;
1343 <Config extends OperationConfig | undefined = undefined>(id: string, options: { readonly config?: Config | undefined } | undefined): Effect.Effect<WithOptionalResponse<GetUser200, Config | undefined>, HttpClientError.HttpClientError>;
1344 }
1345}
1346
1347export interface TestClientError<Tag extends string, E> {
1348 readonly _tag: Tag
1349 readonly request: HttpClientRequest.HttpClientRequest
1350 readonly response: HttpClientResponse.HttpClientResponse
1351 readonly cause: E
1352}
1353
1354class TestClientErrorImpl extends Data.Error<{
1355 _tag: string
1356 cause: any
1357 request: HttpClientRequest.HttpClientRequest
1358 response: HttpClientResponse.HttpClientResponse
1359}> {}
1360
1361export const TestClientError = <Tag extends string, E>(
1362 tag: Tag,
1363 cause: E,
1364 response: HttpClientResponse.HttpClientResponse,
1365): TestClientError<Tag, E> =>
1366 new TestClientErrorImpl({
1367 _tag: tag,
1368 cause,
1369 response,
1370 request: response.request,
1371 }) as any`
1372 ))
1373
1374 it.effect("preserves response variants and routes binary and bodiless statuses", () =>
1375 assertTypeOnlyIncludes(responseMatchingSpec, [
1376 `export type PollDeviceToken400 =`,
1377 `readonly "kind": "InvalidRequestError"`,
1378 `readonly "kind": "DeviceTokenOAuthError"`,
1379 `onRequest(options?.config)([], {"400":"PollDeviceToken400"})`,
1380 `const decodeBinary = (response: HttpClientResponse.HttpClientResponse) =>`,
1381 `onRequest(options?.config)([], {"404":"DownloadArchive404"}, {"binary":["2xx"],"voidSuccess":[],"voidError":[]})`,
1382 `readonly "downloadArchive": {`,
1383 `<Config extends OperationConfig | undefined = undefined>(options: { readonly config: Config }): Effect.Effect<WithOptionalResponse<Uint8Array, Config>`,
1384 `<Config extends OperationConfig | undefined = undefined>(options: { readonly config?: Config | undefined } | undefined): Effect.Effect<WithOptionalResponse<Uint8Array, Config | undefined>`,
1385 `readonly "downloadAvatarStream": () => Stream.Stream<Uint8Array, HttpClientError.HttpClientError>`,
1386 `"downloadAvatar": (options: Parameters<TestClient["downloadAvatar"]>[0]) => HttpClientRequest.get("/avatar").pipe(
1387 onRequest(options?.config)([], undefined, {"binary":["2xx"],"voidSuccess":[],"voidError":[]})`,
1388 `readonly "downloadCustomBinaryStream": () => Stream.Stream<Uint8Array, HttpClientError.HttpClientError>`,
1389 `import * as HttpClient from "effect/http/HttpClient"`,
1390 `onRequest(options?.config)([], {"400":"DownloadMixedContent400"}, {"binary":["2xx"],"voidSuccess":[],"voidError":[]})`,
1391 `readonly "downloadMixedContent": {`,
1392 `<Config extends OperationConfig | undefined = undefined>(options: { readonly config: Config }): Effect.Effect<WithOptionalResponse<Uint8Array, Config>`,
1393 `<Config extends OperationConfig | undefined = undefined>(options: { readonly config?: Config | undefined } | undefined): Effect.Effect<WithOptionalResponse<Uint8Array, Config | undefined>`,
1394 `readonly "downloadMixedContentStream": () => Stream.Stream<Uint8Array, HttpClientError.HttpClientError>`,
1395 `onRequest(options?.config)([], undefined, {"binary":[],"voidSuccess":["200"],"voidError":["404","500"]})`,
1396 `cases[code] = decodeVoidError(code)`,
1397 `TestClientError<"404", undefined>`,
1398 `TestClientError<"500", undefined>`
1399 ], [
1400 `DownloadMixedContent200 | Uint8Array`
1401 ]))
1402
1403 it.effect("routes mixed and non-2xx bodiless success responses", () =>
1404 assertTypeOnlyIncludes(voidSuccessSpec, [
1405 `onRequest(options?.config)(["200"], undefined, {"binary":[],"voidSuccess":["204"],"voidError":[]})`,
1406 `onRequest(options?.config)([], undefined, {"binary":[],"voidSuccess":["304"],"voidError":[]})`,
1407 `WithOptionalResponse<MixedSuccess200 | void, Config>`
1408 ]))
1409
1410 it.effect(
1411 "emits compilable clients for schema-backed and type-only formats",
1412 () => assertGeneratedClientsCompile(responseMatchingSpec),
1413 compilationTimeout
1414 )
1415 })
1416
1417 describe("httpapi", () => {
1418 it.effect("generates tagged groups with endpoint annotations and representable parameters", () =>
1419 assertHttpApiIncludes(
1420 {
1421 openapi: "3.1.0",
1422 info: {
1423 title: "Test API",
1424 version: "1.0.0",
1425 summary: "Summary",
1426 description: "Description"
1427 },
1428 paths: {
1429 "/users/{id}": {
1430 get: {
1431 operationId: "getUser",
1432 summary: "Get user",
1433 description: "Read a user",
1434 deprecated: true,
1435 externalDocs: {
1436 url: "https://example.com/get-user"
1437 },
1438 parameters: [
1439 {
1440 name: "id",
1441 in: "path",
1442 schema: { type: "string" },
1443 required: true
1444 },
1445 {
1446 name: "filter",
1447 in: "query",
1448 schema: { type: "string" },
1449 required: false
1450 },
1451 {
1452 name: "trace-id",
1453 in: "header",
1454 schema: { type: "string" },
1455 required: false
1456 }
1457 ],
1458 responses: {
1459 200: {
1460 description: "User",
1461 content: {
1462 "application/json": {
1463 schema: {
1464 type: "object",
1465 properties: {
1466 id: { type: "string" }
1467 },
1468 required: ["id"],
1469 additionalProperties: false
1470 }
1471 }
1472 }
1473 },
1474 404: {
1475 description: "Not found"
1476 }
1477 },
1478 tags: ["Users"],
1479 security: []
1480 }
1481 }
1482 },
1483 components: {
1484 schemas: {},
1485 securitySchemes: {}
1486 },
1487 security: [],
1488 tags: [
1489 {
1490 name: "Users",
1491 description: "User operations",
1492 externalDocs: {
1493 url: "https://example.com/users"
1494 }
1495 }
1496 ]
1497 },
1498 [
1499 `import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, HttpApiSecurity, OpenApi } from "effect/http-api"`,
1500 `export type GetUserPathParams = { readonly "id": string }`,
1501 `export const GetUserPathParams = Schema.Struct({ "id": Schema.String })`,
1502 `class UsersGroup extends HttpApiGroup.make("Users")`,
1503 `.annotate(OpenApi.Description, "User operations")`,
1504 `.annotate(OpenApi.ExternalDocs, {"url":"https://example.com/users"})`,
1505 `HttpApiEndpoint.get("getUser", "/users/:id", { params: GetUserPathParams, query: GetUserQuery, headers: GetUserHeaders, success: GetUser200, error: HttpApiSchema.Empty(404) })`,
1506 `.annotate(OpenApi.Identifier, "getUser")`,
1507 `.annotate(OpenApi.Summary, "Get user")`,
1508 `.annotate(OpenApi.Description, "Read a user")`,
1509 `.annotate(OpenApi.Deprecated, true)`,
1510 `.annotate(OpenApi.ExternalDocs, {"url":"https://example.com/get-user"})`,
1511 `export class TestClient extends HttpApi.make("TestClient")`,
1512 `.annotate(OpenApi.Title, "Test API")`,
1513 `.annotate(OpenApi.Version, "1.0.0")`,
1514 `.annotate(OpenApi.Summary, "Summary")`,
1515 `.annotate(OpenApi.Description, "Description")`,
1516 `.add(UsersGroup)`
1517 ],
1518 [
1519 `export class GetUserPathParams extends Schema.Class<GetUserPathParams>("GetUserPathParams")({ "id": Schema.String }) {}`
1520 ]
1521 ))
1522
1523 it.effect("includes path-level parameters in generated httpapi endpoints", () =>
1524 assertHttpApiIncludes(
1525 {
1526 openapi: "3.1.0",
1527 info: {
1528 title: "Discord-style API",
1529 version: "1.0.0"
1530 },
1531 paths: {
1532 "/applications/{application_id}": {
1533 parameters: [
1534 {
1535 name: "application_id",
1536 in: "path",
1537 schema: { type: "string" },
1538 required: true
1539 }
1540 ],
1541 get: {
1542 operationId: "getApplication",
1543 parameters: [],
1544 responses: {
1545 200: {
1546 description: "Application"
1547 }
1548 },
1549 tags: ["Applications"],
1550 security: []
1551 }
1552 }
1553 },
1554 components: {
1555 schemas: {},
1556 securitySchemes: {}
1557 },
1558 security: [],
1559 tags: [{ name: "Applications" }]
1560 },
1561 [
1562 `export type GetApplicationPathParams = { readonly "application_id": string }`,
1563 `export const GetApplicationPathParams = Schema.Struct({ "application_id": Schema.String })`,
1564 `HttpApiEndpoint.get("getApplication", "/applications/:application_id", { params: GetApplicationPathParams, success: HttpApiSchema.Empty(200) })`
1565 ],
1566 [
1567 `export class GetApplicationPathParams extends Schema.Class<GetApplicationPathParams>("GetApplicationPathParams")({ "application_id": Schema.String }) {}`
1568 ]
1569 ))
1570
1571 it.effect("creates top-level fallback group for untagged operations", () =>
1572 assertHttpApiIncludes(
1573 {
1574 openapi: "3.1.0",
1575 info: {
1576 title: "Test API",
1577 version: "1.0.0"
1578 },
1579 paths: {
1580 "/health": {
1581 get: {
1582 operationId: "getHealth",
1583 parameters: [],
1584 responses: {
1585 204: {
1586 description: "No content"
1587 }
1588 },
1589 tags: [] as any,
1590 security: []
1591 }
1592 }
1593 },
1594 components: {
1595 schemas: {},
1596 securitySchemes: {}
1597 },
1598 security: [],
1599 tags: []
1600 },
1601 [
1602 `class DefaultGroup extends HttpApiGroup.make("default", { topLevel: true })`,
1603 `HttpApiEndpoint.get("getHealth", "/health", { success: HttpApiSchema.Empty(204) })`,
1604 `.add(DefaultGroup)`
1605 ]
1606 ))
1607
1608 it.effect("keeps the fallback group top-level when tagged default operations are present", () =>
1609 assertHttpApiIncludes(
1610 {
1611 openapi: "3.1.0",
1612 info: {
1613 title: "Test API",
1614 version: "1.0.0"
1615 },
1616 paths: {
1617 "/tagged": {
1618 get: {
1619 operationId: "getTagged",
1620 parameters: [],
1621 responses: {
1622 200: {
1623 description: "Tagged"
1624 }
1625 },
1626 tags: ["default"],
1627 security: []
1628 }
1629 },
1630 "/untagged": {
1631 get: {
1632 operationId: "getUntagged",
1633 parameters: [],
1634 responses: {
1635 204: {
1636 description: "Untagged"
1637 }
1638 },
1639 tags: [] as any,
1640 security: []
1641 }
1642 }
1643 },
1644 components: {
1645 schemas: {},
1646 securitySchemes: {}
1647 },
1648 security: [],
1649 tags: []
1650 },
1651 [
1652 `class DefaultGroup extends HttpApiGroup.make("default", { topLevel: true })`,
1653 `HttpApiEndpoint.get("getTagged", "/tagged", { success: HttpApiSchema.Empty(200) })`,
1654 `HttpApiEndpoint.get("getUntagged", "/untagged", { success: HttpApiSchema.Empty(204) })`,
1655 `.add(DefaultGroup)`
1656 ]
1657 ))
1658
1659 it.effect("maps request and response encodings including optional request body approximation", () =>
1660 assertHttpApiIncludes(
1661 {
1662 openapi: "3.1.0",
1663 info: {
1664 title: "Test API",
1665 version: "1.0.0"
1666 },
1667 paths: {
1668 "/payload": {
1669 post: {
1670 operationId: "createPayload",
1671 parameters: [],
1672 requestBody: {
1673 required: false,
1674 content: {
1675 "application/json": {
1676 schema: {
1677 type: "object",
1678 properties: {
1679 a: { type: "string" }
1680 },
1681 required: ["a"],
1682 additionalProperties: false
1683 }
1684 },
1685 "multipart/form-data": {
1686 schema: {
1687 type: "object",
1688 properties: {
1689 file: { type: "string", format: "binary" },
1690 files: {
1691 type: "array",
1692 items: { type: "string", format: "binary" }
1693 }
1694 },
1695 required: ["file", "files"],
1696 additionalProperties: false
1697 }
1698 },
1699 "application/x-www-form-urlencoded": {
1700 schema: {
1701 type: "object",
1702 properties: {
1703 form: { type: "string" }
1704 },
1705 required: ["form"],
1706 additionalProperties: false
1707 }
1708 },
1709 "text/plain": {
1710 schema: {
1711 type: "string"
1712 }
1713 },
1714 "application/octet-stream": {
1715 schema: {
1716 type: "string",
1717 format: "binary"
1718 }
1719 }
1720 }
1721 } as any,
1722 responses: {
1723 200: {
1724 description: "Payload",
1725 content: {
1726 "application/json": {
1727 schema: {
1728 type: "object",
1729 properties: {
1730 ok: { type: "boolean" }
1731 },
1732 required: ["ok"],
1733 additionalProperties: false
1734 }
1735 },
1736 "text/plain": {
1737 schema: {
1738 type: "string"
1739 }
1740 },
1741 "application/octet-stream": {
1742 schema: {
1743 type: "string",
1744 format: "binary"
1745 }
1746 }
1747 }
1748 },
1749 201: {
1750 description: "Created"
1751 }
1752 },
1753 tags: ["Payload"],
1754 security: []
1755 }
1756 }
1757 },
1758 components: {
1759 schemas: {},
1760 securitySchemes: {}
1761 },
1762 security: [],
1763 tags: [{ name: "Payload" }]
1764 },
1765 [
1766 `import { Multipart } from "effect/http"`,
1767 `export type __HttpApiMultipartSingleFile = Multipart.PersistedFile
1768export const __HttpApiMultipartSingleFile = Multipart.SingleFileSchema`,
1769 `export type __HttpApiMultipartFiles = ReadonlyArray<Multipart.PersistedFile>
1770export const __HttpApiMultipartFiles = Multipart.FilesSchema`,
1771 `export type CreatePayloadRequestFormData = { readonly "file": __HttpApiMultipartSingleFile, readonly "files": __HttpApiMultipartFiles }`,
1772 `export const CreatePayloadRequestFormData = Schema.Struct({ "file": __HttpApiMultipartSingleFile, "files": __HttpApiMultipartFiles })`,
1773 `export type CreatePayloadRequestJson = { readonly "a": string }`,
1774 `export type CreatePayloadRequestText = string
1775export const CreatePayloadRequestText = Schema.String`,
1776 `payload: [HttpApiSchema.NoContent, CreatePayloadRequestJson, CreatePayloadRequestFormData.pipe(HttpApiSchema.asMultipart()), CreatePayloadRequestFormUrlEncoded.pipe(HttpApiSchema.asFormUrlEncoded()), CreatePayloadRequestText.pipe(HttpApiSchema.asText()), CreatePayloadRequestBinary.pipe(HttpApiSchema.asUint8Array())]`,
1777 `success: [CreatePayload200, CreatePayload200Text.pipe(HttpApiSchema.asText()), CreatePayload200Binary.pipe(HttpApiSchema.asUint8Array()), HttpApiSchema.Empty(201)]`
1778 ],
1779 [
1780 "Schema.Opaque",
1781 `extends Schema.Class<CreatePayloadRequestJson>("CreatePayloadRequestJson")`
1782 ]
1783 ))
1784
1785 it.effect("preserves multiple JSON response representations with application/json preferred", () =>
1786 assertHttpApiIncludes(
1787 {
1788 openapi: "3.1.0",
1789 info: {
1790 title: "Test API",
1791 version: "1.0.0"
1792 },
1793 paths: {
1794 "/dual": {
1795 get: {
1796 operationId: "dualJson",
1797 parameters: [],
1798 responses: {
1799 200: {
1800 description: "Dual JSON response",
1801 content: {
1802 "application/problem+json": {
1803 schema: {
1804 type: "object",
1805 properties: {
1806 title: { type: "string" }
1807 },
1808 required: ["title"],
1809 additionalProperties: false
1810 }
1811 },
1812 "application/json": {
1813 schema: {
1814 type: "object",
1815 properties: {
1816 value: { type: "string" }
1817 },
1818 required: ["value"],
1819 additionalProperties: false
1820 }
1821 }
1822 }
1823 }
1824 },
1825 tags: ["DualJson"],
1826 security: []
1827 }
1828 }
1829 },
1830 components: {
1831 schemas: {},
1832 securitySchemes: {}
1833 },
1834 security: [],
1835 tags: [{ name: "DualJson" }]
1836 },
1837 [
1838 `export type DualJson200 = { readonly "value": string }`,
1839 `export type DualJson200ApplicationProblemJson = { readonly "title": string }`,
1840 `HttpApiEndpoint.get("dualJson", "/dual", { success: [DualJson200, DualJson200ApplicationProblemJson.pipe(HttpApiSchema.asJson({ contentType: "application/problem+json" }))] })`
1841 ]
1842 ))
1843
1844 it.effect("maps explicit SSE stream responses to HttpApiSchema.StreamSse", () =>
1845 assertHttpApiIncludes(
1846 {
1847 openapi: "3.1.0",
1848 info: {
1849 title: "Test API",
1850 version: "1.0.0"
1851 },
1852 paths: {
1853 "/events": {
1854 get: {
1855 operationId: "streamEvents",
1856 parameters: [],
1857 responses: {
1858 200: {
1859 description: "Events",
1860 content: {
1861 "text/event-stream": {
1862 schema: {
1863 type: "object",
1864 properties: {
1865 event: { const: "message" },
1866 data: { type: "string" }
1867 },
1868 required: ["event", "data"],
1869 additionalProperties: false
1870 },
1871 "x-effect-stream": {
1872 encoding: "sse",
1873 errorSchema: {
1874 type: "object",
1875 properties: {
1876 message: { type: "string" }
1877 },
1878 required: ["message"],
1879 additionalProperties: false
1880 },
1881 causeSchema: {
1882 type: "object"
1883 },
1884 failureEvent: "effect/http-api/stream/failure"
1885 }
1886 }
1887 }
1888 }
1889 },
1890 tags: ["Events"],
1891 security: []
1892 }
1893 },
1894 "/events/custom": {
1895 get: {
1896 operationId: "streamEventsCustom",
1897 parameters: [],
1898 responses: {
1899 200: {
1900 description: "Custom events",
1901 content: {
1902 "application/custom-sse": {
1903 schema: {
1904 type: "object",
1905 properties: {
1906 event: { const: "message" },
1907 data: { type: "string" }
1908 },
1909 required: ["event", "data"],
1910 additionalProperties: false
1911 },
1912 "x-effect-stream": {
1913 encoding: "sse",
1914 errorSchema: {
1915 type: "object",
1916 properties: {
1917 message: { type: "string" }
1918 },
1919 required: ["message"],
1920 additionalProperties: false
1921 },
1922 causeSchema: {
1923 type: "object"
1924 },
1925 failureEvent: "effect/http-api/stream/failure"
1926 }
1927 }
1928 }
1929 }
1930 },
1931 tags: ["Events"],
1932 security: []
1933 }
1934 }
1935 },
1936 components: {
1937 schemas: {},
1938 securitySchemes: {}
1939 },
1940 security: [],
1941 tags: [{ name: "Events" }]
1942 },
1943 [
1944 `HttpApiEndpoint.get("streamEvents", "/events", { success: HttpApiSchema.StreamSse({ events: StreamEvents200Sse, error: StreamEvents200SseError }) })`,
1945 `HttpApiEndpoint.get("streamEventsCustom", "/events/custom", { success: HttpApiSchema.StreamSse({ contentType: "application/custom-sse", events: StreamEventsCustom200Sse, error: StreamEventsCustom200SseError }) })`
1946 ],
1947 [
1948 `StreamEvents200Sse.pipe(HttpApiSchema.asText())`,
1949 `StreamEventsCustom200ApplicationCustomSse.pipe(HttpApiSchema.asText({ contentType: "application/custom-sse" }))`
1950 ]
1951 ))
1952
1953 it.effect("warns and skips unannotated successful SSE responses in HttpApi generation", () =>
1954 assertHttpApiWithWarnings(
1955 {
1956 openapi: "3.1.0",
1957 info: {
1958 title: "Test API",
1959 version: "1.0.0"
1960 },
1961 paths: {
1962 "/events": {
1963 get: {
1964 operationId: "streamEvents",
1965 parameters: [],
1966 responses: {
1967 200: {
1968 description: "Events",
1969 content: {
1970 "text/event-stream": {
1971 schema: {
1972 type: "string"
1973 }
1974 }
1975 }
1976 }
1977 },
1978 tags: ["Events"],
1979 security: []
1980 }
1981 },
1982 "/events/cause-only": {
1983 get: {
1984 operationId: "streamEventsCauseOnly",
1985 parameters: [],
1986 responses: {
1987 200: {
1988 description: "Events",
1989 content: {
1990 "text/event-stream": {
1991 schema: {
1992 type: "string"
1993 },
1994 "x-effect-stream": {
1995 encoding: "sse",
1996 causeSchema: {
1997 type: "object"
1998 },
1999 failureEvent: "effect/http-api/stream/failure"
2000 } as any
2001 }
2002 }
2003 }
2004 },
2005 tags: ["Events"],
2006 security: []
2007 }
2008 }
2009 },
2010 components: {
2011 schemas: {},
2012 securitySchemes: {}
2013 },
2014 security: [],
2015 tags: [{ name: "Events" }]
2016 },
2017 {
2018 excludes: [
2019 `HttpApiEndpoint.get("streamEvents", "/events"`,
2020 `HttpApiEndpoint.get("streamEventsCauseOnly", "/events/cause-only"`
2021 ],
2022 warnings: [
2023 {
2024 code: "sse-operation-skipped",
2025 path: "/events",
2026 method: "get",
2027 operationId: "streamEvents"
2028 },
2029 {
2030 code: "sse-operation-skipped",
2031 path: "/events/cause-only",
2032 method: "get",
2033 operationId: "streamEventsCauseOnly"
2034 }
2035 ]
2036 }
2037 ))
2038
2039 it.effect("maps explicit uint8array stream responses to HttpApiSchema.StreamUint8Array", () =>
2040 assertHttpApiIncludes(
2041 {
2042 openapi: "3.1.0",
2043 info: {
2044 title: "Test API",
2045 version: "1.0.0"
2046 },
2047 paths: {
2048 "/download": {
2049 get: {
2050 operationId: "download",
2051 parameters: [],
2052 responses: {
2053 200: {
2054 description: "Download",
2055 content: {
2056 "application/octet-stream": {
2057 schema: {
2058 type: "string",
2059 format: "binary"
2060 },
2061 "x-effect-stream": {
2062 encoding: "uint8array"
2063 }
2064 }
2065 }
2066 }
2067 },
2068 tags: ["Downloads"],
2069 security: []
2070 }
2071 },
2072 "/download/custom": {
2073 get: {
2074 operationId: "downloadCustom",
2075 parameters: [],
2076 responses: {
2077 200: {
2078 description: "Custom download",
2079 content: {
2080 "application/custom-bytes": {
2081 schema: {
2082 type: "string",
2083 format: "binary"
2084 },
2085 "x-effect-stream": {
2086 encoding: "uint8array"
2087 }
2088 }
2089 }
2090 }
2091 },
2092 tags: ["Downloads"],
2093 security: []
2094 }
2095 }
2096 },
2097 components: {
2098 schemas: {},
2099 securitySchemes: {}
2100 },
2101 security: [],
2102 tags: [{ name: "Downloads" }]
2103 },
2104 [
2105 `HttpApiEndpoint.get("download", "/download", { success: HttpApiSchema.StreamUint8Array() })`,
2106 `HttpApiEndpoint.get("downloadCustom", "/download/custom", { success: HttpApiSchema.StreamUint8Array({ contentType: "application/custom-bytes" }) })`
2107 ],
2108 [
2109 `Download200Binary.pipe(HttpApiSchema.asUint8Array())`,
2110 `DownloadCustom200ApplicationCustomBytes.pipe(HttpApiSchema.asUint8Array({ contentType: "application/custom-bytes" }))`
2111 ]
2112 ))
2113
2114 it.effect("keeps unannotated octet-stream responses as buffered Uint8Array schemas", () =>
2115 assertHttpApiIncludes(
2116 {
2117 openapi: "3.1.0",
2118 info: {
2119 title: "Test API",
2120 version: "1.0.0"
2121 },
2122 paths: {
2123 "/download/buffered": {
2124 get: {
2125 operationId: "downloadBuffered",
2126 parameters: [],
2127 responses: {
2128 200: {
2129 description: "Buffered download",
2130 content: {
2131 "application/octet-stream": {
2132 schema: {
2133 type: "string",
2134 format: "binary"
2135 }
2136 }
2137 }
2138 }
2139 },
2140 tags: ["Downloads"],
2141 security: []
2142 }
2143 }
2144 },
2145 components: {
2146 schemas: {},
2147 securitySchemes: {}
2148 },
2149 security: [],
2150 tags: [{ name: "Downloads" }]
2151 },
2152 [
2153 `HttpApiEndpoint.get("downloadBuffered", "/download/buffered", { success: DownloadBuffered200Binary.pipe(HttpApiSchema.asUint8Array()) })`
2154 ],
2155 [
2156 `HttpApiSchema.StreamUint8Array()`
2157 ]
2158 ))
2159
2160 it.effect("maps multipart schemas referenced through components to Multipart file schemas", () =>
2161 assertHttpApiIncludes(
2162 {
2163 openapi: "3.1.0",
2164 info: {
2165 title: "Test API",
2166 version: "1.0.0"
2167 },
2168 paths: {
2169 "/upload": {
2170 post: {
2171 operationId: "upload",
2172 parameters: [],
2173 requestBody: {
2174 required: true,
2175 content: {
2176 "multipart/form-data": {
2177 schema: {
2178 $ref: "#/components/schemas/UploadBody"
2179 }
2180 }
2181 }
2182 } as any,
2183 responses: {
2184 200: {
2185 description: "Uploaded"
2186 }
2187 },
2188 tags: ["Payload"],
2189 security: []
2190 }
2191 }
2192 },
2193 components: {
2194 schemas: {
2195 UploadBody: {
2196 type: "object",
2197 properties: {
2198 file: { type: "string", format: "binary" },
2199 files: {
2200 type: "array",
2201 items: { type: "string", format: "binary" }
2202 }
2203 },
2204 required: ["file", "files"],
2205 additionalProperties: false
2206 }
2207 },
2208 securitySchemes: {}
2209 },
2210 security: [],
2211 tags: [{ name: "Payload" }]
2212 },
2213 [
2214 `import { Multipart } from "effect/http"`,
2215 `export type __HttpApiMultipartSingleFile = Multipart.PersistedFile
2216export const __HttpApiMultipartSingleFile = Multipart.SingleFileSchema`,
2217 `export type __HttpApiMultipartFiles = ReadonlyArray<Multipart.PersistedFile>
2218export const __HttpApiMultipartFiles = Multipart.FilesSchema`,
2219 `export type UploadRequestFormData = { readonly "file": __HttpApiMultipartSingleFile, readonly "files": __HttpApiMultipartFiles }`,
2220 `export const UploadRequestFormData = Schema.Struct({ "file": __HttpApiMultipartSingleFile, "files": __HttpApiMultipartFiles })`,
2221 `HttpApiEndpoint.post("upload", "/upload", { payload: UploadRequestFormData.pipe(HttpApiSchema.asMultipart()), success: HttpApiSchema.Empty(200) })`
2222 ],
2223 [
2224 `Schema.String.annotate({ "format": "binary" })`,
2225 `export type UploadBody =`
2226 ]
2227 ))
2228
2229 it.effect("preserves multipart component reference siblings", () =>
2230 assertHttpApiIncludes(
2231 {
2232 openapi: "3.1.0",
2233 info: {
2234 title: "Test API",
2235 version: "1.0.0"
2236 },
2237 paths: {
2238 "/upload": {
2239 post: {
2240 operationId: "upload",
2241 parameters: [],
2242 requestBody: {
2243 required: true,
2244 content: {
2245 "multipart/form-data": {
2246 schema: {
2247 type: "object",
2248 properties: {
2249 first: {
2250 $ref: "#/components/schemas/UploadBody",
2251 minProperties: 1,
2252 description: "First upload"
2253 },
2254 second: {
2255 $ref: "#/components/schemas/UploadBody",
2256 minProperties: 2,
2257 description: "Second upload"
2258 }
2259 },
2260 required: ["first", "second"],
2261 additionalProperties: false
2262 }
2263 }
2264 }
2265 } as any,
2266 responses: {
2267 200: {
2268 description: "Uploaded"
2269 }
2270 },
2271 tags: ["Payload"],
2272 security: []
2273 }
2274 }
2275 },
2276 components: {
2277 schemas: {
2278 UploadBody: {
2279 type: "object",
2280 properties: {
2281 file: { type: "string", format: "binary" }
2282 },
2283 required: ["file"],
2284 additionalProperties: false
2285 }
2286 },
2287 securitySchemes: {}
2288 },
2289 security: [],
2290 tags: [{ name: "Payload" }]
2291 },
2292 [
2293 `"first": Schema.Struct({ "file": __HttpApiMultipartSingleFile })`,
2294 `Schema.isMinProperties(1)`,
2295 `"description": "First upload"`,
2296 `"second": Schema.Struct({ "file": __HttpApiMultipartSingleFile })`,
2297 `Schema.isMinProperties(2)`,
2298 `"description": "Second upload"`
2299 ]
2300 ))
2301
2302 it.effect("preserves multipart component alias siblings", () =>
2303 assertHttpApiIncludes(
2304 {
2305 openapi: "3.1.0",
2306 info: {
2307 title: "Test API",
2308 version: "1.0.0"
2309 },
2310 paths: {
2311 "/upload": {
2312 post: {
2313 operationId: "upload",
2314 parameters: [],
2315 requestBody: {
2316 required: true,
2317 content: {
2318 "multipart/form-data": {
2319 schema: {
2320 $ref: "#/components/schemas/UploadAlias"
2321 }
2322 }
2323 }
2324 } as any,
2325 responses: {
2326 200: {
2327 description: "Uploaded"
2328 }
2329 },
2330 tags: ["Payload"],
2331 security: []
2332 }
2333 }
2334 },
2335 components: {
2336 schemas: {
2337 UploadAlias: {
2338 $ref: "#/components/schemas/UploadBody",
2339 minProperties: 1,
2340 description: "Aliased upload"
2341 },
2342 UploadBody: {
2343 type: "object",
2344 properties: {
2345 file: { type: "string", format: "binary" }
2346 },
2347 required: ["file"],
2348 additionalProperties: false
2349 }
2350 },
2351 securitySchemes: {}
2352 },
2353 security: [],
2354 tags: [{ name: "Payload" }]
2355 },
2356 [
2357 `export type UploadRequestFormData = { readonly "file": __HttpApiMultipartSingleFile }`,
2358 `Schema.isMinProperties(1)`,
2359 `"description": "Aliased upload"`
2360 ],
2361 [
2362 `export type UploadAlias =`,
2363 `export type UploadBody =`
2364 ]
2365 ))
2366
2367 it.effect("maps multipart contentEncoding binary schemas (case-insensitive) to Multipart file schemas", () =>
2368 assertHttpApiIncludes(
2369 {
2370 openapi: "3.1.0",
2371 info: {
2372 title: "Test API",
2373 version: "1.0.0"
2374 },
2375 paths: {
2376 "/upload-content-encoding": {
2377 post: {
2378 operationId: "uploadWithContentEncoding",
2379 parameters: [],
2380 requestBody: {
2381 required: true,
2382 content: {
2383 "multipart/form-data": {
2384 schema: {
2385 $ref: "#/components/schemas/UploadBodyContentEncoding"
2386 }
2387 }
2388 }
2389 } as any,
2390 responses: {
2391 200: {
2392 description: "Uploaded"
2393 }
2394 },
2395 tags: ["Payload"],
2396 security: []
2397 }
2398 }
2399 },
2400 components: {
2401 schemas: {
2402 UploadBodyContentEncoding: {
2403 type: "object",
2404 properties: {
2405 file: { type: "string", contentEncoding: "BINARY" },
2406 files: {
2407 type: "array",
2408 items: { type: "string", contentEncoding: "BINARY" }
2409 }
2410 },
2411 required: ["file", "files"],
2412 additionalProperties: false
2413 }
2414 },
2415 securitySchemes: {}
2416 },
2417 security: [],
2418 tags: [{ name: "Payload" }]
2419 },
2420 [
2421 `import { Multipart } from "effect/http"`,
2422 `export type UploadWithContentEncodingRequestFormData = { readonly "file": __HttpApiMultipartSingleFile, readonly "files": __HttpApiMultipartFiles }`,
2423 `export const UploadWithContentEncodingRequestFormData = Schema.Struct({ "file": __HttpApiMultipartSingleFile, "files": __HttpApiMultipartFiles })`,
2424 `HttpApiEndpoint.post("uploadWithContentEncoding", "/upload-content-encoding", { payload: UploadWithContentEncodingRequestFormData.pipe(HttpApiSchema.asMultipart()), success: HttpApiSchema.Empty(200) })`
2425 ],
2426 [
2427 `contentEncoding`,
2428 `export type UploadBodyContentEncoding =`
2429 ]
2430 ))
2431
2432 it.effect("generates security declarations and middleware placeholders", () =>
2433 assertHttpApiWithWarnings(
2434 {
2435 openapi: "3.1.0",
2436 info: {
2437 title: "Security API",
2438 version: "1.0.0"
2439 },
2440 paths: {
2441 "/secure": {
2442 get: {
2443 operationId: "getSecure",
2444 parameters: [],
2445 responses: {
2446 200: {
2447 description: "Secure"
2448 }
2449 },
2450 tags: ["Security"],
2451 security: [
2452 { apiKeyAuth: [] },
2453 { bearerAuth: [] },
2454 { apiKeyAuth: [], basicAuth: [] }
2455 ]
2456 }
2457 },
2458 "/public": {
2459 get: {
2460 operationId: "getPublic",
2461 parameters: [],
2462 responses: {
2463 200: {
2464 description: "Public"
2465 }
2466 },
2467 tags: ["Security"],
2468 security: []
2469 }
2470 }
2471 },
2472 components: {
2473 schemas: {},
2474 securitySchemes: {
2475 apiKeyAuth: {
2476 type: "apiKey",
2477 name: "x-api-key",
2478 in: "header",
2479 description: "API key"
2480 },
2481 bearerAuth: {
2482 type: "http",
2483 scheme: "bearer",
2484 bearerFormat: "JWT",
2485 description: "Bearer token"
2486 },
2487 basicAuth: {
2488 type: "http",
2489 scheme: "basic"
2490 }
2491 }
2492 },
2493 security: [],
2494 tags: [{ name: "Security" }]
2495 },
2496 {
2497 includes: [
2498 `const ApiKeyAuthSecurity = HttpApiSecurity.apiKey({ key: "x-api-key", in: "header" }).pipe(HttpApiSecurity.annotate(OpenApi.Description, "API key"))`,
2499 `const BearerAuthSecurity = HttpApiSecurity.bearer.pipe(HttpApiSecurity.annotate(OpenApi.Description, "Bearer token")).pipe(HttpApiSecurity.annotate(OpenApi.Format, "JWT"))`,
2500 `const BasicAuthSecurity = HttpApiSecurity.basic`,
2501 `class ApiKeyAuthOrBearerAuthSecurityMiddleware extends HttpApiMiddleware.Service<ApiKeyAuthOrBearerAuthSecurityMiddleware>()("apiKeyAuth | bearerAuth security", { security: { "apiKeyAuth": ApiKeyAuthSecurity, "bearerAuth": BearerAuthSecurity } }) {}`,
2502 `class ApiKeyAuthAndBasicAuthSecurityMiddleware extends HttpApiMiddleware.Service<ApiKeyAuthAndBasicAuthSecurityMiddleware>()("apiKeyAuth & basicAuth security") {}`,
2503 `HttpApiEndpoint.get("getSecure", "/secure", { success: HttpApiSchema.Empty(200) })\n .middleware(ApiKeyAuthOrBearerAuthSecurityMiddleware)\n .middleware(ApiKeyAuthAndBasicAuthSecurityMiddleware)`,
2504 `HttpApiEndpoint.get("getPublic", "/public", { success: HttpApiSchema.Empty(200) })`
2505 ],
2506 excludes: [
2507 `HttpApiEndpoint.get("getPublic", "/public", { success: HttpApiSchema.Empty(200) })\n .middleware(`
2508 ],
2509 warnings: [
2510 {
2511 code: "security-and-downgraded",
2512 path: "/secure",
2513 method: "get",
2514 operationId: "getSecure"
2515 }
2516 ]
2517 }
2518 ))
2519
2520 it.effect("generates custom http security schemes", () =>
2521 assertHttpApiWithWarnings(
2522 {
2523 openapi: "3.1.0",
2524 info: {
2525 title: "Custom Security API",
2526 version: "1.0.0"
2527 },
2528 paths: {
2529 "/secure": {
2530 get: {
2531 operationId: "getSecure",
2532 parameters: [],
2533 responses: {
2534 200: {
2535 description: "Secure"
2536 }
2537 },
2538 tags: ["Security"],
2539 security: [{ digestAuth: [] }]
2540 }
2541 }
2542 },
2543 components: {
2544 schemas: {},
2545 securitySchemes: {
2546 digestAuth: {
2547 type: "http",
2548 scheme: "Digest",
2549 bearerFormat: "DigestToken",
2550 description: "Digest token"
2551 }
2552 }
2553 },
2554 security: [],
2555 tags: [{ name: "Security" }]
2556 },
2557 {
2558 includes: [
2559 `const DigestAuthSecurity = HttpApiSecurity.http({ scheme: "Digest" }).pipe(HttpApiSecurity.annotate(OpenApi.Description, "Digest token")).pipe(HttpApiSecurity.annotate(OpenApi.Format, "DigestToken"))`,
2560 `class DigestAuthSecurityMiddleware extends HttpApiMiddleware.Service<DigestAuthSecurityMiddleware>()("digestAuth security", { security: { "digestAuth": DigestAuthSecurity } }) {}`
2561 ],
2562 warnings: []
2563 }
2564 ))
2565
2566 it.effect("inherits global security and respects operation-level clearing", () =>
2567 assertHttpApiWithWarnings(
2568 {
2569 openapi: "3.1.0",
2570 info: {
2571 title: "Security inheritance API",
2572 version: "1.0.0"
2573 },
2574 paths: {
2575 "/inherited": {
2576 get: {
2577 operationId: "getInherited",
2578 parameters: [],
2579 responses: {
2580 200: {
2581 description: "Inherited"
2582 }
2583 },
2584 tags: ["Security"]
2585 } as any
2586 },
2587 "/inherited-two": {
2588 get: {
2589 operationId: "getInheritedTwo",
2590 parameters: [],
2591 responses: {
2592 200: {
2593 description: "Inherited two"
2594 }
2595 },
2596 tags: ["Security"]
2597 } as any
2598 },
2599 "/cleared": {
2600 get: {
2601 operationId: "getCleared",
2602 parameters: [],
2603 responses: {
2604 200: {
2605 description: "Cleared"
2606 }
2607 },
2608 tags: ["Security"],
2609 security: []
2610 }
2611 },
2612 "/anonymous": {
2613 get: {
2614 operationId: "getAnonymous",
2615 parameters: [],
2616 responses: {
2617 200: {
2618 description: "Anonymous"
2619 }
2620 },
2621 tags: ["Security"],
2622 security: [{}]
2623 }
2624 },
2625 "/override": {
2626 get: {
2627 operationId: "getOverride",
2628 parameters: [],
2629 responses: {
2630 200: {
2631 description: "Override"
2632 }
2633 },
2634 tags: ["Security"],
2635 security: [{ bearerAuth: [] }]
2636 }
2637 }
2638 },
2639 components: {
2640 schemas: {},
2641 securitySchemes: {
2642 apiKeyAuth: {
2643 type: "apiKey",
2644 name: "x-api-key",
2645 in: "header"
2646 },
2647 bearerAuth: {
2648 type: "http",
2649 scheme: "bearer"
2650 }
2651 }
2652 },
2653 security: [{ apiKeyAuth: [] }],
2654 tags: [{ name: "Security" }]
2655 },
2656 {
2657 includes: [
2658 `class ApiKeyAuthSecurityMiddleware extends HttpApiMiddleware.Service<ApiKeyAuthSecurityMiddleware>()("apiKeyAuth security", { security: { "apiKeyAuth": ApiKeyAuthSecurity } }) {}`,
2659 `class BearerAuthSecurityMiddleware extends HttpApiMiddleware.Service<BearerAuthSecurityMiddleware>()("bearerAuth security", { security: { "bearerAuth": BearerAuthSecurity } }) {}`,
2660 `HttpApiEndpoint.get("getInherited", "/inherited", { success: HttpApiSchema.Empty(200) })\n .middleware(ApiKeyAuthSecurityMiddleware)`,
2661 `HttpApiEndpoint.get("getInheritedTwo", "/inherited-two", { success: HttpApiSchema.Empty(200) })\n .middleware(ApiKeyAuthSecurityMiddleware)`,
2662 `HttpApiEndpoint.get("getCleared", "/cleared", { success: HttpApiSchema.Empty(200) })`,
2663 `HttpApiEndpoint.get("getAnonymous", "/anonymous", { success: HttpApiSchema.Empty(200) })`,
2664 `HttpApiEndpoint.get("getOverride", "/override", { success: HttpApiSchema.Empty(200) })\n .middleware(BearerAuthSecurityMiddleware)`
2665 ],
2666 excludes: [
2667 `HttpApiEndpoint.get("getCleared", "/cleared", { success: HttpApiSchema.Empty(200) })\n .middleware(`,
2668 `HttpApiEndpoint.get("getAnonymous", "/anonymous", { success: HttpApiSchema.Empty(200) })\n .middleware(`
2669 ],
2670 occurrences: [
2671 {
2672 substring:
2673 `class ApiKeyAuthSecurityMiddleware extends HttpApiMiddleware.Service<ApiKeyAuthSecurityMiddleware>()("apiKeyAuth security", { security: { "apiKeyAuth": ApiKeyAuthSecurity } }) {}`,
2674 count: 1
2675 }
2676 ],
2677 warnings: []
2678 }
2679 ))
2680
2681 it.effect("emits lossy warnings and skips unsupported httpapi operations", () =>
2682 assertHttpApiWithWarnings(
2683 {
2684 openapi: "3.1.0",
2685 info: {
2686 title: "Warnings API",
2687 version: "1.0.0"
2688 },
2689 paths: {
2690 "/cookies": {
2691 get: {
2692 operationId: "getCookie",
2693 parameters: [
2694 {
2695 name: "session",
2696 in: "cookie",
2697 schema: { type: "string" },
2698 required: false
2699 }
2700 ],
2701 responses: {
2702 200: {
2703 description: "Cookie"
2704 }
2705 },
2706 tags: ["Warnings"],
2707 security: []
2708 }
2709 },
2710 "/tags": {
2711 get: {
2712 operationId: "getTagged",
2713 parameters: [],
2714 responses: {
2715 200: {
2716 description: "Tagged"
2717 }
2718 },
2719 tags: ["Warnings", "ExtraTag"],
2720 security: []
2721 }
2722 },
2723 "/sse": {
2724 get: {
2725 operationId: "streamEvents",
2726 parameters: [],
2727 responses: {
2728 200: {
2729 description: "Events",
2730 content: {
2731 "text/event-stream": {
2732 schema: {
2733 type: "string"
2734 }
2735 }
2736 }
2737 }
2738 },
2739 tags: ["Warnings"],
2740 security: []
2741 }
2742 },
2743 "/headers": {
2744 get: {
2745 operationId: "getHeaders",
2746 parameters: [],
2747 responses: {
2748 200: {
2749 description: "Headers",
2750 headers: {
2751 "x-rate-limit": {
2752 schema: {
2753 type: "integer"
2754 }
2755 }
2756 }
2757 }
2758 } as any,
2759 tags: ["Warnings"],
2760 security: []
2761 }
2762 },
2763 "/default-success": {
2764 get: {
2765 operationId: "getDefaultSuccess",
2766 parameters: [],
2767 responses: {
2768 200: {
2769 description: "OK"
2770 },
2771 default: {
2772 description: "Fallback"
2773 }
2774 } as any,
2775 tags: ["Warnings"],
2776 security: []
2777 }
2778 },
2779 "/default-only": {
2780 get: {
2781 operationId: "getDefaultOnly",
2782 parameters: [],
2783 responses: {
2784 default: {
2785 description: "Fallback"
2786 }
2787 } as any,
2788 tags: ["Warnings"],
2789 security: []
2790 }
2791 },
2792 "/nobody": {
2793 get: {
2794 operationId: "getNoBody",
2795 parameters: [],
2796 requestBody: {
2797 required: true,
2798 content: {
2799 "application/json": {
2800 schema: {
2801 type: "object",
2802 properties: {
2803 a: { type: "string" }
2804 },
2805 required: ["a"],
2806 additionalProperties: false
2807 }
2808 }
2809 }
2810 },
2811 responses: {
2812 200: {
2813 description: "No body"
2814 }
2815 },
2816 tags: ["Warnings"],
2817 security: []
2818 }
2819 }
2820 },
2821 components: {
2822 schemas: {},
2823 securitySchemes: {}
2824 },
2825 security: [],
2826 tags: [{ name: "Warnings" }]
2827 },
2828 {
2829 includes: [
2830 `HttpApiEndpoint.get("getCookie", "/cookies", { success: HttpApiSchema.Empty(200) })`,
2831 `HttpApiEndpoint.get("getHeaders", "/headers", { success: HttpApiSchema.Empty(200) })`,
2832 `HttpApiEndpoint.get("getDefaultSuccess", "/default-success", { success: HttpApiSchema.Empty(200), error: HttpApiSchema.Empty(500) })`,
2833 `HttpApiEndpoint.get("getDefaultOnly", "/default-only", { success: HttpApiSchema.Empty(200) })`
2834 ],
2835 excludes: [
2836 `HttpApiEndpoint.get("streamEvents", "/sse"`,
2837 `HttpApiEndpoint.get("getNoBody", "/nobody"`
2838 ],
2839 warnings: [
2840 {
2841 code: "cookie-parameter-dropped",
2842 path: "/cookies",
2843 method: "get",
2844 operationId: "getCookie"
2845 },
2846 {
2847 code: "additional-tags-dropped",
2848 path: "/tags",
2849 method: "get",
2850 operationId: "getTagged"
2851 },
2852 {
2853 code: "sse-operation-skipped",
2854 path: "/sse",
2855 method: "get",
2856 operationId: "streamEvents"
2857 },
2858 {
2859 code: "response-headers-ignored",
2860 path: "/headers",
2861 method: "get",
2862 operationId: "getHeaders"
2863 },
2864 {
2865 code: "default-response-remapped",
2866 path: "/default-success",
2867 method: "get",
2868 operationId: "getDefaultSuccess"
2869 },
2870 {
2871 code: "default-response-remapped",
2872 path: "/default-only",
2873 method: "get",
2874 operationId: "getDefaultOnly"
2875 },
2876 {
2877 code: "no-body-method-request-body-skipped",
2878 path: "/nobody",
2879 method: "get",
2880 operationId: "getNoBody"
2881 }
2882 ]
2883 }
2884 ))
2885 })
2886
2887 describe("QUERY", () => {
2888 const queryOperation: OpenAPISpecOperation = {
2889 operationId: "search",
2890 parameters: [],
2891 tags: ["Search"],
2892 security: [],
2893 requestBody: {
2894 required: true,
2895 content: {
2896 "application/json": {
2897 schema: {
2898 type: "object",
2899 properties: { query: { type: "string" } },
2900 required: ["query"],
2901 additionalProperties: false
2902 }
2903 }
2904 }
2905 },
2906 responses: { "204": { description: "No content" } }
2907 }
2908
2909 const makeQuerySpec = (pathItem: OpenAPISpecPathItem & { query?: OpenAPISpecOperation }): OpenAPISpec => ({
2910 openapi: "3.1.0",
2911 info: { title: "QUERY API", version: "1.0.0" },
2912 components: { schemas: {}, securitySchemes: {} },
2913 security: [],
2914 tags: [{ name: "Search" }],
2915 paths: {
2916 "/search": {
2917 get: {
2918 operationId: "list",
2919 parameters: [],
2920 tags: ["Search"],
2921 security: [],
2922 responses: { "204": { description: "No content" } }
2923 },
2924 ...pathItem
2925 }
2926 }
2927 })
2928
2929 it.effect.each([
2930 {
2931 name: "OpenAPI 3.1 extension",
2932 spec: makeQuerySpec({ "x-oai-additionalOperations": { QUERY: queryOperation } })
2933 },
2934 {
2935 name: "native OpenAPI 3.2 field",
2936 spec: {
2937 ...makeQuerySpec({
2938 query: queryOperation,
2939 "x-oai-additionalOperations": { QUERY: { ...queryOperation, operationId: "legacySearch" } }
2940 }),
2941 openapi: "3.2.0"
2942 } as unknown as OpenAPISpec
2943 }
2944 ])("generates QUERY request bodies from the $name", ({ spec }) =>
2945 Effect.all([
2946 assertRuntimeIncludes(spec, [
2947 `HttpClientRequest.get("/search")`,
2948 `HttpClientRequest.query("/search")`,
2949 `export const SearchRequestJson = Schema.Struct({ "query": Schema.String })`,
2950 `HttpClientRequest.bodyJsonUnsafe(options.payload)`,
2951 `readonly payload: typeof SearchRequestJson.Encoded`
2952 ], ["legacySearch", "LegacySearch"]),
2953 assertTypeOnlyIncludes(spec, [
2954 `HttpClientRequest.get("/search")`,
2955 `HttpClientRequest.query("/search")`,
2956 `HttpClientRequest.bodyJsonUnsafe(options.payload)`,
2957 `export type SearchRequestJson = { readonly "query": string }`,
2958 `readonly payload: SearchRequestJson`
2959 ], ["legacySearch", "LegacySearch"]),
2960 assertHttpApiIncludes(spec, [
2961 `HttpApiEndpoint.get("list", "/search"`,
2962 `export const SearchRequestJson = Schema.Struct({ "query": Schema.String })`,
2963 `HttpApiEndpoint.query("search", "/search", { payload: SearchRequestJson, success: HttpApiSchema.Empty(204) })`
2964 ], ["legacySearch", "LegacySearch"])
2965 ]))
2966 })
2967
2968 describe("regression", () => {
2969 for (const format of ["httpclient", "httpclient-type-only"] as const) {
2970 for (const exactOptionalPropertyTypes of [true, false]) {
2971 it.effect(
2972 `preserves includeResponse success types (${format}, exactOptionalPropertyTypes=${exactOptionalPropertyTypes})`,
2973 () =>
2974 assertGeneratedClientsCompile({
2975 openapi: "3.1.0",
2976 info: { title: "Value", version: "1.0.0" },
2977 components: { schemas: {}, securitySchemes: {} },
2978 security: [],
2979 tags: [],
2980 paths: {
2981 "/value": {
2982 get: {
2983 operationId: "getValue",
2984 parameters: [],
2985 security: [],
2986 tags: ["Value"],
2987 responses: {
2988 "200": {
2989 description: "Value",
2990 content: { "application/json": { schema: { type: "string" } } }
2991 }
2992 }
2993 }
2994 }
2995 }
2996 }, {
2997 formats: [format],
2998 exactOptionalPropertyTypes,
2999 usage: `type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends
3000 (<T>() => T extends B ? 1 : 2) ? true : false
3001type Assert<T extends true> = T
3002type WithResponse = [string, HttpClientResponse.HttpClientResponse]
3003declare const client: TestClient
3004declare const dynamic: boolean
3005declare const optionalTrue: { readonly includeResponse?: true }
3006declare const optionalBoolean: { readonly includeResponse?: boolean }
3007declare const nullableConfig: { readonly includeResponse: true } | undefined
3008
3009const enabled = client.getValue({ config: { includeResponse: true } })
3010const disabled = client.getValue({ config: { includeResponse: false } })
3011const omitted = client.getValue(undefined)
3012const emptyOptions = client.getValue({})
3013const undefinedConfig = client.getValue({ config: undefined })
3014const dynamicResult = client.getValue({ config: { includeResponse: dynamic } })
3015const optionalTrueResult = client.getValue({ config: optionalTrue })
3016const optionalBooleanResult = client.getValue({ config: optionalBoolean })
3017const nullableConfigResult = client.getValue({ config: nullableConfig })
3018const conditionalConfig = Math.random() > 0.5 ? { includeResponse: true as const } : undefined
3019const conditionalConfigResult = client.getValue({ config: conditionalConfig })
3020
3021export type Enabled = Assert<Equal<Effect.Success<typeof enabled>, WithResponse>>
3022export type Disabled = Assert<Equal<Effect.Success<typeof disabled>, string>>
3023export type Omitted = Assert<Equal<Effect.Success<typeof omitted>, string>>
3024export type EmptyOptions = Assert<Equal<Effect.Success<typeof emptyOptions>, string>>
3025export type UndefinedConfig = Assert<Equal<Effect.Success<typeof undefinedConfig>, string>>
3026export type Dynamic = Assert<Equal<Effect.Success<typeof dynamicResult>, string | WithResponse>>
3027export type OptionalTrue = Assert<Equal<Effect.Success<typeof optionalTrueResult>, string | WithResponse>>
3028export type OptionalBoolean = Assert<Equal<Effect.Success<typeof optionalBooleanResult>, string | WithResponse>>
3029export type NullableConfig = Assert<Equal<Effect.Success<typeof nullableConfigResult>, string | WithResponse>>
3030export type ConditionalConfig = Assert<Equal<Effect.Success<typeof conditionalConfigResult>, string | WithResponse>>
3031`
3032 }),
3033 compilationTimeout
3034 )
3035 }
3036 }
3037
3038 it.effect("quotes static path text with and without parameters", () => {
3039 const prefix = "/files/\"`\\\n/"
3040 const suffix = "/content\"`"
3041 const operation: OpenAPISpecOperation = {
3042 operationId: "read",
3043 parameters: [],
3044 tags: ["Files"],
3045 security: [],
3046 responses: { "204": { description: "No content" } }
3047 }
3048 return assertRuntimeIncludes({
3049 openapi: "3.1.0",
3050 info: { title: "Quoted paths", version: "1.0.0" },
3051 components: { schemas: {}, securitySchemes: {} },
3052 security: [],
3053 tags: [],
3054 paths: {
3055 [prefix + suffix]: { get: operation },
3056 [prefix + "{id}" + suffix]: {
3057 get: {
3058 ...operation,
3059 operationId: "readById",
3060 parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }]
3061 }
3062 }
3063 }
3064 }, [
3065 `HttpClientRequest.get(${JSON.stringify(prefix + suffix)})`,
3066 `() => ${JSON.stringify(prefix)} + __encodePathParam(id) + ${JSON.stringify(suffix)}`
3067 ])
3068 })
3069
3070 it.effect("emits compilable clients when schema examples are invalid", () =>
3071 assertGeneratedClientsCompile({
3072 openapi: "3.0.3",
3073 info: {
3074 title: "Invalid examples API",
3075 version: "1.0.0"
3076 },
3077 paths: {
3078 "/triggers": {
3079 get: {
3080 operationId: "getTriggers",
3081 parameters: [],
3082 responses: {
3083 200: {
3084 description: "OK",
3085 content: {
3086 "application/json": {
3087 schema: {
3088 type: "array",
3089 items: { type: "string" },
3090 example: "create"
3091 }
3092 }
3093 }
3094 }
3095 },
3096 tags: ["Triggers"],
3097 security: []
3098 }
3099 }
3100 },
3101 components: {
3102 schemas: {},
3103 securitySchemes: {}
3104 },
3105 security: [],
3106 tags: [{ name: "Triggers" }]
3107 } as unknown as OpenAPISpec), compilationTimeout)
3108
3109 it.effect("runtime warnings do not report additional-tags-dropped outside httpapi", () =>
3110 assertRuntimeStableWithWarnings(
3111 {
3112 openapi: "3.1.0",
3113 info: {
3114 title: "Warnings regression API",
3115 version: "1.0.0"
3116 },
3117 paths: {
3118 "/multi-tag": {
3119 get: {
3120 operationId: "getMultiTag",
3121 parameters: [],
3122 responses: {
3123 200: {
3124 description: "OK"
3125 }
3126 },
3127 tags: ["One", "Two"],
3128 security: []
3129 }
3130 }
3131 },
3132 components: {
3133 schemas: {},
3134 securitySchemes: {}
3135 },
3136 security: [],
3137 tags: [{ name: "One" }, { name: "Two" }]
3138 },
3139 []
3140 ))
3141
3142 it.effect("runtime output remains stable when using onWarning", () =>
3143 assertRuntimeStableWithWarnings(regressionSpec, [
3144 {
3145 code: "cookie-parameter-dropped",
3146 path: "/users/{id}",
3147 method: "get",
3148 operationId: "getUser"
3149 },
3150 {
3151 code: "default-response-remapped",
3152 path: "/users/{id}",
3153 method: "get",
3154 operationId: "getUser"
3155 }
3156 ]))
3157
3158 it.effect("type-only output remains stable when using onWarning", () =>
3159 assertTypeOnlyStableWithWarnings(regressionSpec, [
3160 {
3161 code: "cookie-parameter-dropped",
3162 path: "/users/{id}",
3163 method: "get",
3164 operationId: "getUser"
3165 },
3166 {
3167 code: "default-response-remapped",
3168 path: "/users/{id}",
3169 method: "get",
3170 operationId: "getUser"
3171 }
3172 ]))
3173
3174 it.effect("emits compilable open objects with optional properties", () =>
3175 assertGeneratedClientsCompile(
3176 {
3177 openapi: "3.1.0",
3178 info: {
3179 title: "Open object regression API",
3180 version: "1.0.0"
3181 },
3182 paths: {
3183 "/widget": {
3184 get: {
3185 operationId: "getWidget",
3186 parameters: [],
3187 responses: {
3188 200: {
3189 description: "OK",
3190 content: {
3191 "application/json": {
3192 schema: {
3193 type: "object",
3194 properties: {
3195 optionalValue: {
3196 type: "string"
3197 }
3198 },
3199 additionalProperties: true
3200 }
3201 }
3202 }
3203 }
3204 },
3205 tags: ["Widgets"],
3206 security: []
3207 }
3208 }
3209 },
3210 components: {
3211 schemas: {},
3212 securitySchemes: {}
3213 },
3214 security: [],
3215 tags: []
3216 },
3217 {
3218 exactOptionalPropertyTypes: false,
3219 formats: ["httpclient"]
3220 }
3221 ), compilationTimeout)
3222
3223 it.effect("types multipart binary fields as File | Blob for generated clients", () =>
3224 assertGeneratedClientsCompile(
3225 multipartSpec({
3226 type: "object",
3227 properties: { file: { type: "string", format: "binary" } },
3228 required: ["file"],
3229 additionalProperties: false
3230 }),
3231 {
3232 usage: `export const withFile: UploadRequestFormData = { file: new File([], "upload.txt") }
3233export const withBlob: UploadRequestFormData = { file: new Blob([]) }
3234// @ts-expect-error Binary multipart fields do not accept strings.
3235export const withString: UploadRequestFormData = { file: "upload.txt" }
3236`
3237 }
3238 ), compilationTimeout)
3239
3240 it.effect(
3241 "types recursive multipart binary references as File | Blob for generated clients",
3242 () =>
3243 assertGeneratedClientsCompile(
3244 multipartSpec({ $ref: "#/components/schemas/Node" }, {
3245 Node: {
3246 type: "object",
3247 properties: {
3248 file: { type: "string", format: "binary" },
3249 child: { $ref: "#/components/schemas/Node" }
3250 },
3251 required: ["file"],
3252 additionalProperties: false
3253 }
3254 }),
3255 {
3256 usage: `const file = new File([], "upload.txt")
3257const blob = new Blob([])
3258export const payload: UploadRequestFormData = { file, child: { file: blob, child: { file } } }
3259// @ts-expect-error Recursive binary fields do not accept strings.
3260export const withString: UploadRequestFormData = { file, child: { file: "upload.txt" } }
3261`
3262 }
3263 ),
3264 compilationTimeout
3265 )
3266
3267 for (
3268 const [format, assertIncludes] of [
3269 ["httpclient", assertRuntimeIncludes],
3270 ["httpclient-type-only", assertTypeOnlyIncludes]
3271 ] as const
3272 ) {
3273 it.effect(`preserves named multipart components without binary fields (${format})`, () =>
3274 assertIncludes(
3275 multipartSpec({ $ref: "#/components/schemas/FormBody" }, {
3276 FormBody: {
3277 type: "object",
3278 properties: {
3279 name: { type: "string" },
3280 meta: { $ref: "#/components/schemas/Meta" }
3281 },
3282 required: ["name"],
3283 additionalProperties: false
3284 },
3285 Meta: {
3286 type: "object",
3287 properties: { k: { type: "string" } },
3288 required: ["k"],
3289 additionalProperties: false
3290 }
3291 }),
3292 [
3293 "export type Meta =",
3294 "export type FormBody =",
3295 "export type UploadRequestFormData = FormBody"
3296 ]
3297 ))
3298 }
3299
3300 it.effect(
3301 "types multipart binary arrays as File | Blob arrays for generated clients",
3302 () =>
3303 assertGeneratedClientsCompile(
3304 multipartSpec({
3305 type: "object",
3306 properties: { files: { type: "array", items: { type: "string", format: "binary" } } },
3307 required: ["files"],
3308 additionalProperties: false
3309 }),
3310 {
3311 usage: `export const payload: UploadRequestFormData = { files: [new File([], "upload.txt"), new Blob([])] }
3312// @ts-expect-error Binary multipart arrays do not accept strings.
3313export const withString: UploadRequestFormData = { files: ["upload.txt"] }
3314`
3315 }
3316 ),
3317 compilationTimeout
3318 )
3319 })
3320})