1import * as OpenApiGenerator from "@effect/openapi-generator/OpenApiGenerator"
2import * as Utils from "@effect/openapi-generator/Utils"
3import { assert, describe, expect, it } from "@effect/vitest"
4import * as Data from "effect/Data"
5import * as Effect from "effect/Effect"
6import * as Sse from "effect/encoding/Sse"
7import type { OpenAPISpec } from "effect/http-api/OpenApi"
8import * as HttpClient from "effect/http/HttpClient"
9import * as HttpClientError from "effect/http/HttpClientError"
10import * as HttpClientRequest from "effect/http/HttpClientRequest"
11import * as HttpClientResponse from "effect/http/HttpClientResponse"
12import * as Schema from "effect/Schema"
13import * as Stream from "effect/Stream"
14import { rolldown } from "rolldown"
15
16const modules = {
17 "effect/Data": Data,
18 "effect/Effect": Effect,
19 "effect/Schema": Schema,
20 "effect/Stream": Stream,
21 "effect/encoding/Sse": Sse,
22 "effect/http/HttpClient": HttpClient,
23 "effect/http/HttpClientError": HttpClientError,
24 "effect/http/HttpClientRequest": HttpClientRequest,
25 "effect/http/HttpClientResponse": HttpClientResponse
26}
27
28async function loadClient(source: string, httpClient: HttpClient.HttpClient) {
29 const bundle = await rolldown({
30 input: "client.ts",
31 external: (id) => id !== "client.ts",
32 plugins: [{
33 name: "generated-client",
34 resolveId: (id) => id === "client.ts" ? id : undefined,
35 load: (id) => id === "client.ts" ? source : undefined
36 }]
37 })
38 const { output } = await bundle.generate({ format: "cjs" })
39 await bundle.close()
40 const compiled = output[0]
41 assert.strictEqual(compiled.type, "chunk")
42 const exports = {} as {
43 make: (httpClient: HttpClient.HttpClient) => {
44 submit: (options: { payload: { name: string } }) => Effect.Effect<unknown, unknown>
45 submitSse: (options: { payload: { name: string } }) => Stream.Stream<unknown, unknown>
46 submitStream: (options: { payload: { name: string } }) => Stream.Stream<unknown, unknown>
47 }
48 }
49 new Function("require", "exports", compiled.code)(
50 (id: keyof typeof modules) => {
51 assert.property(modules, id)
52 return modules[id]
53 },
54 exports
55 )
56 return exports.make(httpClient)
57}
58
59describe("Utils", () => {
60 describe("camelize", () => {
61 it("removes hyphens and capitalizes following letters", () => {
62 expect(Utils.camelize("my-operation-id")).toBe("myOperationId")
63 })
64
65 it("removes slashes and capitalizes following letters", () => {
66 expect(Utils.camelize("my/operation/id")).toBe("myOperationId")
67 })
68
69 it("handles numbers", () => {
70 expect(Utils.camelize("operation-2")).toBe("operation2")
71 })
72
73 it("removes leading numbers", () => {
74 expect(Utils.camelize("2operation")).toBe("operation")
75 })
76
77 it("handles empty string", () => {
78 expect(Utils.camelize("")).toBe("")
79 })
80 })
81
82 describe("identifier", () => {
83 it("capitalizes camelized string", () => {
84 expect(Utils.identifier("my-operation")).toBe("MyOperation")
85 expect(Utils.identifier("operation-2")).toBe("Operation2")
86 })
87 })
88
89 describe("generated request encoding", () => {
90 for (const format of ["httpclient", "httpclient-type-only"] as const) {
91 for (const method of ["submit", "submitSse", "submitStream"] as const) {
92 it.effect(`${format} ${method} sends form-urlencoded payloads`, () =>
93 Effect.gen(function*() {
94 const responseContentType = method === "submitSse"
95 ? "text/event-stream"
96 : method === "submitStream"
97 ? "application/octet-stream"
98 : "application/json"
99 const spec: OpenAPISpec = {
100 openapi: "3.1.0",
101 info: { title: "Form API", version: "1.0.0" },
102 components: { schemas: {}, securitySchemes: {} },
103 security: [],
104 tags: [],
105 paths: {
106 "/submit": {
107 post: {
108 operationId: "submit",
109 parameters: [],
110 tags: ["Forms"],
111 security: [],
112 requestBody: {
113 required: true,
114 content: {
115 "application/x-www-form-urlencoded": {
116 schema: {
117 type: "object",
118 properties: { name: { type: "string" } },
119 required: ["name"]
120 }
121 }
122 }
123 },
124 responses: {
125 "200": {
126 description: "Success",
127 content: { [responseContentType]: { schema: { type: "string" } } }
128 }
129 }
130 }
131 }
132 }
133 }
134 const generator = yield* OpenApiGenerator.OpenApiGenerator
135 const source = yield* generator.generate(spec, { name: "TestClient", format })
136 const requests: Array<HttpClientRequest.HttpClientRequest> = []
137 const httpClient = HttpClient.make((request) => {
138 requests.push(request)
139 return Effect.succeed(HttpClientResponse.fromWeb(
140 request,
141 new Response(method === "submitSse" ? "data: \"ok\"\n\n" : "\"ok\"", {
142 headers: { "content-type": responseContentType }
143 })
144 ))
145 }).pipe(HttpClient.mapRequest(HttpClientRequest.prependUrl("https://example.test")))
146 const client = yield* Effect.promise(() => loadClient(source, httpClient))
147 const options = { payload: { name: "Ada Lovelace" } }
148 if (method === "submit") {
149 yield* client.submit(options)
150 } else {
151 yield* Stream.runDrain(client[method](options))
152 }
153 assert.strictEqual(requests.length, 1)
154 const request = yield* HttpClientRequest.toWeb(requests[0])
155 assert.deepStrictEqual({
156 contentType: request.headers.get("content-type"),
157 body: yield* Effect.promise(() => request.text())
158 }, {
159 contentType: "application/x-www-form-urlencoded",
160 body: "name=Ada+Lovelace"
161 })
162 }).pipe(Effect.provide(
163 format === "httpclient" ? OpenApiGenerator.layerTransformerSchema : OpenApiGenerator.layerTransformerTs
164 )))
165 }
166 }
167 })
168
169 it.effect("encodes multipart record payloads as form data", () =>
170 Effect.gen(function*() {
171 const spec: OpenAPISpec = {
172 openapi: "3.1.0",
173 info: { title: "Form API", version: "1.0.0" },
174 components: { schemas: {}, securitySchemes: {} },
175 security: [],
176 tags: [],
177 paths: {
178 "/submit": {
179 post: {
180 operationId: "submit",
181 parameters: [],
182 tags: ["Forms"],
183 security: [],
184 requestBody: {
185 required: true,
186 content: {
187 "multipart/form-data": {
188 schema: {
189 type: "object",
190 properties: { name: { type: "string" } },
191 required: ["name"]
192 }
193 }
194 }
195 },
196 responses: {
197 "200": {
198 description: "Success",
199 content: { "application/json": { schema: { type: "string" } } }
200 }
201 }
202 }
203 }
204 }
205 }
206 const generator = yield* OpenApiGenerator.OpenApiGenerator
207 const source = yield* generator.generate(spec, { name: "TestClient", format: "httpclient" })
208 const requests: Array<HttpClientRequest.HttpClientRequest> = []
209 const httpClient = HttpClient.make((request) => {
210 requests.push(request)
211 return Effect.succeed(HttpClientResponse.fromWeb(
212 request,
213 new Response("\"ok\"", { headers: { "content-type": "application/json" } })
214 ))
215 }).pipe(HttpClient.mapRequest(HttpClientRequest.prependUrl("https://example.test")))
216 const client = yield* Effect.promise(() => loadClient(source, httpClient))
217
218 yield* client.submit({ payload: { name: "Ada Lovelace" } })
219
220 assert.strictEqual(requests.length, 1)
221 const request = yield* HttpClientRequest.toWeb(requests[0])
222 const body = yield* Effect.promise(() => request.clone().text())
223 assert.notStrictEqual(body, "[object Object]")
224 const formData = yield* Effect.promise(() => request.formData())
225 assert.deepStrictEqual(Array.from(formData.entries()), [["name", "Ada Lovelace"]])
226 }).pipe(Effect.provide(OpenApiGenerator.layerTransformerSchema)))
227})