1import { assert, describe, it, vi } from "@effect/vitest"
2import { Effect, FileSystem, Layer, Path } from "effect"
3import { Etag, HttpPlatform, HttpRouter, HttpServerResponse } from "effect/http"
4import { HttpApi, HttpApiBuilder, HttpApiScalar, HttpApiSwagger, OpenApi } from "effect/http-api"
5
6const TestServices = Layer.mergeAll(
7 Path.layer,
8 Etag.layerWeak,
9 HttpPlatform.layer
10).pipe(Layer.provideMerge(FileSystem.layerNoop({})))
11
12// Response spies are shared across suites.
13describe("HttpApiBuilder", { concurrent: false }, () => {
14 it.effect("defers and memoizes successful openapiPath responses", () =>
15 Effect.gen(function*() {
16 let transforms = 0
17 const Api = HttpApi.make("OpenApiPath").annotate(
18 OpenApi.Transform,
19 (spec) => {
20 transforms++
21 return spec
22 }
23 )
24 const Health = HttpRouter.use((router) => router.add("GET", "/health", HttpServerResponse.text("OK")))
25
26 yield* Effect.acquireUseRelease(
27 Effect.sync(() => vi.spyOn(HttpServerResponse, "jsonUnsafe")),
28 (jsonUnsafe) =>
29 withHandler(
30 Layer.merge(HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }), Health).pipe(
31 Layer.provide(TestServices)
32 ),
33 (handler) =>
34 Effect.gen(function*() {
35 const health = yield* Effect.promise(() => handler(new Request("http://test/health")))
36 assert.strictEqual(health.status, 200)
37 assert.strictEqual(transforms, 0)
38 assert.strictEqual(jsonUnsafe.mock.calls.length, 0)
39
40 const firstRequests = yield* Effect.promise(() =>
41 Promise.all(
42 Array.from({ length: 20 }, () => handler(new Request("http://test/openapi.json")))
43 )
44 )
45 assert.ok(firstRequests.every((response) => response.status === 200))
46 assert.strictEqual(transforms, 1)
47 assert.strictEqual(jsonUnsafe.mock.calls.length, 1)
48
49 const cached = yield* Effect.promise(() => handler(new Request("http://test/openapi.json")))
50 assert.strictEqual(cached.status, 200)
51 assert.strictEqual(transforms, 1)
52 assert.strictEqual(jsonUnsafe.mock.calls.length, 1)
53 })
54 ),
55 (jsonUnsafe) => Effect.sync(() => jsonUnsafe.mockRestore())
56 )
57 }))
58
59 it.effect("retries openapiPath generation after a defect", () =>
60 Effect.gen(function*() {
61 let transforms = 0
62 const Api = HttpApi.make("OpenApiPathRecovery").annotate(
63 OpenApi.Transform,
64 (spec) => {
65 transforms++
66 if (transforms === 1) throw new Error("OpenAPI generation defect")
67 return spec
68 }
69 )
70
71 yield* withHandler(
72 HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe(Layer.provide(TestServices)),
73 (handler) =>
74 Effect.gen(function*() {
75 const first = yield* Effect.promise(() => handler(new Request("http://test/openapi.json")))
76 assert.strictEqual(first.status, 500)
77 assert.strictEqual(transforms, 1)
78
79 const second = yield* Effect.promise(() => handler(new Request("http://test/openapi.json")))
80 assert.strictEqual(second.status, 200)
81 assert.strictEqual(transforms, 2)
82
83 const cached = yield* Effect.promise(() => handler(new Request("http://test/openapi.json")))
84 assert.strictEqual(cached.status, 200)
85 assert.strictEqual(transforms, 2)
86 })
87 )
88 }))
89})
90
91describe("HttpApiScalar", { concurrent: false }, () => {
92 it.effect("defers and memoizes successful OpenAPI responses", () =>
93 Effect.gen(function*() {
94 let transforms = 0
95 const Api = HttpApi.make("Docs").annotate(
96 OpenApi.Transform,
97 (spec) => {
98 transforms++
99 return spec
100 }
101 )
102 const Health = HttpRouter.use((router) => router.add("GET", "/health", HttpServerResponse.text("OK")))
103
104 yield* Effect.acquireUseRelease(
105 Effect.sync(() => vi.spyOn(HttpServerResponse, "html")),
106 (html) =>
107 withHandler(Layer.merge(HttpApiScalar.layerCdn(Api), Health), (handler) =>
108 Effect.gen(function*() {
109 const health = yield* Effect.promise(() => handler(new Request("http://test/health")))
110 assert.strictEqual(health.status, 200)
111 assert.strictEqual(transforms, 0)
112 assert.strictEqual(html.mock.calls.length, 0)
113
114 const firstRequests = yield* Effect.promise(() =>
115 Promise.all(
116 Array.from({ length: 20 }, () => handler(new Request("http://test/docs")))
117 )
118 )
119 assert.ok(firstRequests.every((response) => response.status === 200))
120 assert.strictEqual(transforms, 1)
121 assert.strictEqual(html.mock.calls.length, 1)
122
123 const cached = yield* Effect.promise(() => handler(new Request("http://test/docs")))
124 assert.strictEqual(cached.status, 200)
125 assert.strictEqual(transforms, 1)
126 assert.strictEqual(html.mock.calls.length, 1)
127 })),
128 (html) => Effect.sync(() => html.mockRestore())
129 )
130 }))
131
132 it.effect("retries OpenAPI generation after a defect", () =>
133 Effect.gen(function*() {
134 let transforms = 0
135 const Api = HttpApi.make("ScalarRecovery").annotate(
136 OpenApi.Transform,
137 (spec) => {
138 transforms++
139 if (transforms === 1) throw new Error("OpenAPI generation defect")
140 return spec
141 }
142 )
143
144 yield* withHandler(HttpApiScalar.layerCdn(Api), (handler) =>
145 Effect.gen(function*() {
146 const first = yield* Effect.promise(() => handler(new Request("http://test/docs")))
147 assert.strictEqual(first.status, 500)
148 assert.strictEqual(transforms, 1)
149
150 const second = yield* Effect.promise(() => handler(new Request("http://test/docs")))
151 assert.strictEqual(second.status, 200)
152 assert.strictEqual(transforms, 2)
153
154 const cached = yield* Effect.promise(() => handler(new Request("http://test/docs")))
155 assert.strictEqual(cached.status, 200)
156 assert.strictEqual(transforms, 2)
157 }))
158 }))
159
160 it.effect("defers inline Scalar generation until the route is requested", () =>
161 Effect.gen(function*() {
162 let transforms = 0
163 const Api = HttpApi.make("ScalarInline").annotate(
164 OpenApi.Transform,
165 (spec) => {
166 transforms++
167 return spec
168 }
169 )
170 const Health = HttpRouter.use((router) => router.add("GET", "/health", HttpServerResponse.text("OK")))
171
172 yield* withHandler(Layer.merge(HttpApiScalar.layer(Api), Health), (handler) =>
173 Effect.gen(function*() {
174 const health = yield* Effect.promise(() => handler(new Request("http://test/health")))
175 assert.strictEqual(health.status, 200)
176 assert.strictEqual(transforms, 0)
177
178 const docs = yield* Effect.promise(() => handler(new Request("http://test/docs")))
179 assert.strictEqual(docs.status, 200)
180 assert.strictEqual(transforms, 1)
181 }))
182 }))
183
184 it.effect("escapes OpenAPI metadata in its HTML contexts", () =>
185 Effect.gen(function*() {
186 const title = `Docs "title" </title><script>`
187 const injectedTag = `<script id="script-data-injected">`
188 const description = `"quoted" 'single' </script >${injectedTag}`
189 const Api = HttpApi.make("Docs")
190 .annotate(OpenApi.Title, title)
191 .annotate(OpenApi.Description, description)
192
193 const html = yield* render(HttpApiScalar.layerCdn(Api))
194
195 assert.ok(html.includes(`<title>Docs "title" &lt;/title&gt;&lt;script&gt;</title>`))
196 const escapedDescription =
197 `&quot;quoted&quot; &#39;single&#39; &lt;/script &gt;&lt;script id=&quot;script-data-injected&quot;&gt;`
198 assert.ok(html.includes(`<meta name="description" content="${escapedDescription}"/>`))
199 assert.ok(html.includes(`<meta name="og:description" content="${escapedDescription}"/>`))
200 assert.ok(!html.includes(`</script >`))
201 assert.deepStrictEqual(extractSpec(html), OpenApi.fromApi(Api))
202 }))
203
204 it.effect("encodes CDN versions before interpolating the script source", () =>
205 Effect.gen(function*() {
206 const version = `1.2.3"></script><script id="injected">`
207 const html = yield* render(HttpApiScalar.layerCdn(HttpApi.make("Docs"), { version }))
208
209 assert.ok(html.includes(
210 `src="https://cdn.jsdelivr.net/npm/@scalar/api-reference@${
211 encodeURIComponent(version)
212 }/dist/browser/standalone.min.js"`
213 ))
214 assert.strictEqual(html.match(/<script\b/g)?.length, 2)
215 assert.ok(!html.includes(`id="injected"`))
216 }))
217})
218
219describe("HttpApiSwagger", { concurrent: false }, () => {
220 it.effect("defers and memoizes successful OpenAPI responses", () =>
221 Effect.gen(function*() {
222 let transforms = 0
223 const Api = HttpApi.make("Docs").annotate(
224 OpenApi.Transform,
225 (spec) => {
226 transforms++
227 return spec
228 }
229 )
230 const Health = HttpRouter.use((router) => router.add("GET", "/health", HttpServerResponse.text("OK")))
231
232 yield* Effect.acquireUseRelease(
233 Effect.sync(() => vi.spyOn(HttpServerResponse, "html")),
234 (html) =>
235 withHandler(Layer.merge(HttpApiSwagger.layer(Api), Health), (handler) =>
236 Effect.gen(function*() {
237 const health = yield* Effect.promise(() => handler(new Request("http://test/health")))
238 assert.strictEqual(health.status, 200)
239 assert.strictEqual(transforms, 0)
240 assert.strictEqual(html.mock.calls.length, 0)
241
242 const firstRequests = yield* Effect.promise(() =>
243 Promise.all(
244 Array.from({ length: 20 }, () => handler(new Request("http://test/docs")))
245 )
246 )
247 assert.ok(firstRequests.every((response) => response.status === 200))
248 assert.strictEqual(transforms, 1)
249 assert.strictEqual(html.mock.calls.length, 1)
250
251 const cached = yield* Effect.promise(() => handler(new Request("http://test/docs")))
252 assert.strictEqual(cached.status, 200)
253 assert.strictEqual(transforms, 1)
254 assert.strictEqual(html.mock.calls.length, 1)
255 })),
256 (html) => Effect.sync(() => html.mockRestore())
257 )
258 }))
259
260 it.effect("retries OpenAPI generation after a defect", () =>
261 Effect.gen(function*() {
262 let transforms = 0
263 const Api = HttpApi.make("SwaggerRecovery").annotate(
264 OpenApi.Transform,
265 (spec) => {
266 transforms++
267 if (transforms === 1) throw new Error("OpenAPI generation defect")
268 return spec
269 }
270 )
271
272 yield* withHandler(HttpApiSwagger.layer(Api), (handler) =>
273 Effect.gen(function*() {
274 const first = yield* Effect.promise(() => handler(new Request("http://test/docs")))
275 assert.strictEqual(first.status, 500)
276 assert.strictEqual(transforms, 1)
277
278 const second = yield* Effect.promise(() => handler(new Request("http://test/docs")))
279 assert.strictEqual(second.status, 200)
280 assert.strictEqual(transforms, 2)
281
282 const cached = yield* Effect.promise(() => handler(new Request("http://test/docs")))
283 assert.strictEqual(cached.status, 200)
284 assert.strictEqual(transforms, 2)
285 }))
286 }))
287
288 it.effect("escapes script end-tag variants in OpenAPI data", () =>
289 Effect.gen(function*() {
290 const injectedTag = `<script id="script-data-injected">`
291 const Api = HttpApi.make("Docs")
292 .annotate(OpenApi.Description, `</script/>${injectedTag}`)
293
294 const html = yield* render(HttpApiSwagger.layer(Api))
295
296 assert.ok(!html.includes(`</script/>`))
297 assert.deepStrictEqual(extractSwaggerSpec(html), OpenApi.fromApi(Api))
298 }))
299})
300
301const withHandler = <A, E, R>(
302 layer: Layer.Layer<never, never, HttpRouter.HttpRouter>,
303 use: (handler: (request: Request) => Promise<Response>) => Effect.Effect<A, E, R>
304) =>
305 Effect.acquireUseRelease(
306 Effect.sync(() => HttpRouter.toWebHandler(layer, { disableLogger: true })),
307 ({ handler }) => use(handler),
308 ({ dispose }) => Effect.promise(dispose)
309 )
310
311const render = (layer: Layer.Layer<never, never, HttpRouter.HttpRouter>) =>
312 withHandler(layer, (handler) =>
313 Effect.flatMap(
314 Effect.promise(() => handler(new Request("http://test/docs"))),
315 (response) => Effect.promise(() => response.text())
316 ))
317
318function extractSpec(html: string): unknown {
319 const marker = " content: "
320 const start = html.indexOf(marker)
321 assert.notStrictEqual(start, -1)
322 const end = html.indexOf("\n })", start)
323 assert.notStrictEqual(end, -1)
324 return JSON.parse(html.slice(start + marker.length, end))
325}
326
327function extractSwaggerSpec(html: string): unknown {
328 const marker = ` <script id="swagger-spec" type="application/json">\n `
329 const start = html.indexOf(marker)
330 assert.notStrictEqual(start, -1)
331 const end = html.indexOf("\n </script>", start)
332 assert.notStrictEqual(end, -1)
333 return JSON.parse(html.slice(start + marker.length, end))
334}