1import { assert, describe, it } from "@effect/vitest"
2import { Schema } from "effect"
3import { HttpApiEndpoint, HttpApiSchema, OpenApi } from "effect/http-api"
4
5const Events = Schema.Struct({
6 event: Schema.Literal("user.created"),
7 data: Schema.String
8})
9const StreamError = Schema.Struct({ reason: Schema.String })
10
11const sse = () => HttpApiSchema.StreamSse({ events: Events, error: StreamError })
12
13describe("HttpApiEndpoint", () => {
14 it("stores the supplied identifier", () => {
15 const endpoint = HttpApiEndpoint.get("getUser", "/users/:id")
16
17 assert.strictEqual(endpoint.identifier, "getUser")
18 })
19
20 it("can be extended as a class", () => {
21 const endpoint = HttpApiEndpoint.get("getUser", "/users/:id")
22 class GetUser extends endpoint {}
23
24 assert.strictEqual(typeof endpoint, "function")
25 assert.strictEqual(GetUser.name, "GetUser")
26 assert.strictEqual(GetUser.identifier, "getUser")
27 assert.isTrue(HttpApiEndpoint.isHttpApiEndpoint(GetUser))
28
29 const prefixed = GetUser.prefix("/v1")
30 assert.strictEqual(prefixed.identifier, "getUser")
31 assert.strictEqual(prefixed.path, "/v1/users/:id")
32 })
33})
34
35describe("HttpApiEndpoint payload schemas", () => {
36 it("normalizes payload map keys while preserving the declared content type", () => {
37 const contentType = "Application/Vnd.Effect+JSON; Charset=UTF-8"
38 const endpoint = HttpApiEndpoint.post("create", "/", {
39 payload: Schema.Struct({ name: Schema.String }).pipe(HttpApiSchema.asJson({ contentType }))
40 })
41
42 const entry = endpoint.payload.get("application/vnd.effect+json")
43 assert.isDefined(entry)
44 assert.strictEqual(entry.encoding.contentType, contentType)
45 })
46
47 it("rejects incompatible encodings for equivalent content types", () => {
48 const JsonPayload = Schema.Struct({ name: Schema.String }).pipe(
49 HttpApiSchema.asJson({ contentType: "Application/Vnd.Effect+Data; charset=utf-8" })
50 )
51 const TextPayload = Schema.String.pipe(
52 HttpApiSchema.asText({ contentType: "application/vnd.effect+data" })
53 )
54
55 assert.throws(
56 () => HttpApiEndpoint.post("create", "/", { payload: [JsonPayload, TextPayload] }),
57 /Multiple payload encodings/
58 )
59 })
60})
61
62describe("HttpApiEndpoint streaming success schemas", () => {
63 it("GET endpoint accepts StreamSse success", () => {
64 const stream = sse()
65 const endpoint = HttpApiEndpoint.get("events", "/events", {
66 success: stream
67 })
68
69 assert.isTrue(endpoint.success.has(stream))
70 })
71
72 it("GET endpoint accepts StreamUint8Array success", () => {
73 const stream = HttpApiSchema.StreamUint8Array()
74 const endpoint = HttpApiEndpoint.get("download", "/download", {
75 success: stream
76 })
77
78 assert.isTrue(endpoint.success.has(stream))
79 })
80
81 it("streaming schema in error throws during endpoint construction", () => {
82 assert.throws(() =>
83 HttpApiEndpoint.get("events", "/events", {
84 error: sse() as any
85 })
86 )
87 })
88
89 it("HEAD with streaming success throws", () => {
90 assert.throws(() =>
91 HttpApiEndpoint.head("events", "/events", {
92 success: sse()
93 })
94 )
95 })
96
97 it("streaming success mixed with NoContent at the same status throws", () => {
98 assert.throws(() =>
99 HttpApiEndpoint.get("events", "/events", {
100 success: [
101 sse(),
102 HttpApiSchema.NoContent.pipe(HttpApiSchema.status(200))
103 ]
104 })
105 )
106 })
107
108 it("streaming success mixed with a buffered success at the same status is allowed for distinct content types", () => {
109 const stream = sse()
110 const endpoint = HttpApiEndpoint.get("events", "/events", {
111 success: [
112 stream,
113 Schema.Struct({ ok: Schema.Boolean })
114 ]
115 })
116
117 assert.isTrue(endpoint.success.has(stream))
118 })
119
120 it("streaming success mixed with a buffered success at the same content type throws", () => {
121 assert.throws(() =>
122 HttpApiEndpoint.get("events", "/events", {
123 success: [
124 HttpApiSchema.StreamSse({ contentType: "application/json", events: Events, error: StreamError }),
125 Schema.Struct({ ok: Schema.Boolean })
126 ]
127 })
128 )
129 })
130
131 it("streaming success mixed with a buffered success at content types differing only by parameters throws", () => {
132 assert.throws(() =>
133 HttpApiEndpoint.get("events", "/events", {
134 success: [
135 HttpApiSchema.StreamSse({
136 contentType: "application/json; charset=utf-8",
137 events: Events,
138 error: StreamError
139 }),
140 Schema.Struct({ ok: Schema.Boolean })
141 ]
142 })
143 )
144 })
145
146 it("streaming success mixed with a buffered success at distinct statuses is allowed", () => {
147 const stream = HttpApiSchema.status(206)(sse())
148 const endpoint = HttpApiEndpoint.get("events", "/events", {
149 success: [
150 stream,
151 Schema.Struct({ ok: Schema.Boolean })
152 ]
153 })
154
155 assert.isTrue(endpoint.success.has(stream))
156 })
157
158 it("streaming success mixed with NoContent at distinct statuses is allowed", () => {
159 const stream = HttpApiSchema.status(200)(sse())
160 const endpoint = HttpApiEndpoint.get("events", "/events", {
161 success: [
162 stream,
163 HttpApiSchema.NoContent
164 ]
165 })
166
167 assert.isTrue(endpoint.success.has(stream))
168 })
169
170 it("two streaming successes for the same status throw", () => {
171 assert.throws(() =>
172 HttpApiEndpoint.get("events", "/events", {
173 success: [
174 sse(),
175 HttpApiSchema.StreamUint8Array({ contentType: "application/custom-stream" })
176 ]
177 })
178 )
179 })
180
181 it("two streaming successes for distinct statuses throw", () => {
182 const stream = HttpApiSchema.status(206)(sse())
183 const bytes = HttpApiSchema.status(200)(
184 HttpApiSchema.StreamUint8Array({ contentType: "application/custom-stream" })
185 )
186
187 assert.throws(
188 () => HttpApiEndpoint.get("events", "/events", { success: [stream, bytes] }),
189 "Multiple streaming success responses are not supported"
190 )
191 })
192
193 it("statically detectable SSE reserved failure event name throws", () => {
194 const stream = HttpApiSchema.StreamSse({
195 events: Schema.Struct({
196 event: Schema.Literal("effect/http-api/stream/failure"),
197 data: Schema.String
198 }),
199 error: StreamError
200 })
201
202 assert.throws(() =>
203 HttpApiEndpoint.get("events", "/events", {
204 success: stream
205 })
206 )
207 })
208})
209
210describe("HttpApiEndpoint WithHeaders schemas", () => {
211 it("rejects a WithHeaders success sharing its status and content type", () => {
212 assert.throws(
213 () =>
214 HttpApiEndpoint.get("get", "/", {
215 success: [
216 Schema.Struct({ plain: Schema.String }),
217 HttpApiSchema.WithHeaders(
218 Schema.Struct({ wrapped: Schema.String }),
219 { "x-trace-id": Schema.String }
220 )
221 ]
222 }),
223 "Cannot combine a response with headers with another response for status 200 and content-type: application/json"
224 )
225 })
226
227 it("rejects an encodeToWithHeaders error sharing its status and content type", () => {
228 const ErrorWithHeaders = Schema.String.pipe(
229 HttpApiSchema.encodeToWithHeaders({
230 body: Schema.Struct({ wrapped: Schema.String }),
231 headers: { "x-trace-id": Schema.String }
232 }, {
233 decode: ({ body }) => body.wrapped,
234 encode: (message) => ({ body: { wrapped: message }, headers: { "x-trace-id": "trace" } })
235 })
236 )
237
238 assert.throws(
239 () =>
240 HttpApiEndpoint.get("get", "/", {
241 error: [
242 Schema.Struct({ plain: Schema.String }),
243 ErrorWithHeaders
244 ]
245 }),
246 "Cannot combine a response with headers with another response for status 500 and content-type: application/json"
247 )
248 })
249
250 it("rejects two WithHeaders successes sharing a status across content types", () => {
251 assert.throws(
252 () =>
253 HttpApiEndpoint.get("get", "/", {
254 success: [
255 HttpApiSchema.WithHeaders(
256 Schema.Struct({ value: Schema.String }),
257 { "x-json": Schema.String }
258 ),
259 HttpApiSchema.WithHeaders(
260 Schema.String.pipe(HttpApiSchema.asText()),
261 { "x-text": Schema.String }
262 )
263 ]
264 }),
265 "Cannot declare multiple responses with headers for status 200"
266 )
267 })
268
269 it("rejects two encodeToWithHeaders errors sharing a status", () => {
270 const JsonError = Schema.String.pipe(
271 HttpApiSchema.encodeToWithHeaders({
272 body: Schema.Struct({ message: Schema.String }),
273 headers: { "x-json": Schema.String }
274 }, {
275 decode: ({ body }) => body.message,
276 encode: (message) => ({ body: { message }, headers: { "x-json": "json" } })
277 })
278 )
279 const TextError = Schema.String.pipe(
280 HttpApiSchema.encodeToWithHeaders({
281 body: Schema.String.pipe(HttpApiSchema.asText()),
282 headers: { "x-text": Schema.String }
283 }, {
284 decode: ({ body }) => body,
285 encode: (message) => ({ body: message, headers: { "x-text": "text" } })
286 })
287 )
288
289 assert.throws(
290 () => HttpApiEndpoint.get("get", "/", { error: [JsonError, TextError] }),
291 "Cannot declare multiple responses with headers for status 500"
292 )
293 })
294
295 it("rejects mixed WithHeaders and encodeToWithHeaders responses sharing a status", () => {
296 const AnnotatedError = Schema.String.pipe(
297 HttpApiSchema.encodeToWithHeaders({
298 body: Schema.String.pipe(HttpApiSchema.status(429), HttpApiSchema.asText()),
299 headers: { "retry-after": Schema.String }
300 }, {
301 decode: ({ body }) => body,
302 encode: (message) => ({ body: message, headers: { "retry-after": "30" } })
303 })
304 )
305
306 assert.throws(
307 () =>
308 HttpApiEndpoint.get("get", "/", {
309 error: [
310 HttpApiSchema.WithHeaders(
311 Schema.Struct({ message: Schema.String }).pipe(HttpApiSchema.status(429)),
312 { "x-trace-id": Schema.String }
313 ),
314 AnnotatedError
315 ]
316 }),
317 "Cannot declare multiple responses with headers for status 429"
318 )
319 })
320
321 it("allows one response with headers and a plain response for the same status across content types", () => {
322 const endpoint = HttpApiEndpoint.get("get", "/", {
323 success: [
324 HttpApiSchema.WithHeaders(
325 Schema.Struct({ value: Schema.String }),
326 { "x-trace-id": Schema.String }
327 ),
328 Schema.String.pipe(HttpApiSchema.asText())
329 ]
330 })
331
332 assert.strictEqual(endpoint.success.size, 2)
333 })
334
335 it("allows responses with headers on distinct statuses", () => {
336 const endpoint = HttpApiEndpoint.get("get", "/", {
337 success: [
338 HttpApiSchema.WithHeaders(
339 Schema.Struct({ value: Schema.String }),
340 { "x-trace-id": Schema.String }
341 ),
342 HttpApiSchema.WithHeaders(
343 Schema.Struct({ created: Schema.Boolean }).pipe(HttpApiSchema.status(201)),
344 { location: Schema.String }
345 )
346 ]
347 })
348
349 assert.strictEqual(endpoint.success.size, 2)
350 })
351
352 it("keeps the wrapper in the success set with codec-transformed parts", () => {
353 const endpoint = HttpApiEndpoint.get("list", "/users", {
354 success: HttpApiSchema.WithHeaders(Schema.Struct({ a: Schema.String }), {
355 "x-count": Schema.Int
356 })
357 })
358
359 const [schema] = Array.from(endpoint.success)
360 assert.isTrue(HttpApiSchema.isWithHeaders(schema))
361 if (HttpApiSchema.isWithHeaders(schema)) {
362 assert.deepStrictEqual(
363 Schema.encodeSync(schema.headers as any)({ "x-count": 3 }),
364 { "x-count": "3" }
365 )
366 }
367 })
368
369 it("leaves the wrapper untouched when codecs are disabled", () => {
370 const wrapped = HttpApiSchema.WithHeaders(Schema.Struct({ a: Schema.String }), {
371 "x-count": Schema.Int
372 })
373 const endpoint = HttpApiEndpoint.get("list", "/users", {
374 disableCodecs: true,
375 success: wrapped
376 })
377
378 assert.isTrue(endpoint.success.has(wrapped))
379 })
380
381 it("preserves disableCodecs through derived endpoints", () => {
382 const endpoint = HttpApiEndpoint.get("list", "/users", {
383 disableCodecs: true,
384 success: Schema.Struct({ a: Schema.String })
385 })
386
387 assert.isTrue(endpoint.disableCodecs)
388 assert.isTrue(endpoint.prefix("/api").disableCodecs)
389 assert.isTrue(endpoint.annotate(OpenApi.Title, "Users").disableCodecs)
390 assert.isFalse(HttpApiEndpoint.get("plain", "/plain").disableCodecs)
391 })
392
393 it("keeps a wrapped stream schema as the inner schema", () => {
394 const stream = sse()
395 const endpoint = HttpApiEndpoint.get("events", "/events", {
396 success: HttpApiSchema.WithHeaders(stream, { "x-count": Schema.Int })
397 })
398
399 const [schema] = Array.from(endpoint.success)
400 assert.isTrue(HttpApiSchema.isWithHeaders(schema))
401 if (HttpApiSchema.isWithHeaders(schema)) {
402 assert.strictEqual(schema.schema, stream)
403 }
404 })
405
406 it("preserves wrapper annotations through endpoint construction", () => {
407 const endpoint = HttpApiEndpoint.get("list", "/users", {
408 success: HttpApiSchema.WithHeaders(Schema.Struct({ a: Schema.String }), {
409 "x-count": Schema.Int
410 }).pipe(HttpApiSchema.status(201))
411 })
412
413 const [schema] = Array.from(endpoint.success)
414 assert.strictEqual(HttpApiSchema.getStatusSuccessSchema(schema), 201)
415 })
416
417 it("validates a wrapped stream like a bare stream", () => {
418 assert.throws(() =>
419 HttpApiEndpoint.get("events", "/events", {
420 success: [
421 HttpApiSchema.WithHeaders(sse(), { "x-count": Schema.Int }),
422 HttpApiSchema.StreamUint8Array({ contentType: "application/custom-stream" })
423 ]
424 })
425 )
426 assert.throws(() =>
427 HttpApiEndpoint.head("events", "/events", {
428 success: HttpApiSchema.WithHeaders(sse(), { "x-count": Schema.Int }) as any
429 })
430 )
431 })
432
433 it("rejects wrapped and bare streams at distinct statuses", () => {
434 assert.throws(
435 () =>
436 HttpApiEndpoint.get("events", "/events", {
437 success: [
438 HttpApiSchema.status(206)(
439 HttpApiSchema.StreamUint8Array({ contentType: "application/custom-bytes" })
440 ),
441 HttpApiSchema.WithHeaders(
442 HttpApiSchema.status(201)(
443 HttpApiSchema.StreamUint8Array({ contentType: "application/other-bytes" })
444 ),
445 { "x-source": Schema.String }
446 )
447 ]
448 }),
449 "Multiple streaming success responses are not supported"
450 )
451
452 assert.throws(
453 () =>
454 HttpApiEndpoint.get("events", "/events", {
455 success: [
456 HttpApiSchema.WithHeaders(sse(), { "x-count": Schema.Int }),
457 sse()
458 ]
459 }),
460 "Multiple streaming success responses are not supported"
461 )
462 })
463
464 it("keeps WithHeaders in the error set with codec-transformed parts", () => {
465 const endpoint = HttpApiEndpoint.get("list", "/users", {
466 error: HttpApiSchema.WithHeaders(
467 Schema.Struct({ message: Schema.String }).pipe(HttpApiSchema.status(429)),
468 { "retry-after": Schema.Int }
469 )
470 })
471
472 const [schema] = Array.from(endpoint.error)
473 assert.isTrue(HttpApiSchema.isWithHeaders(schema))
474 assert.strictEqual(HttpApiSchema.getStatusErrorSchema(schema), 429)
475 if (HttpApiSchema.isWithHeaders(schema)) {
476 assert.deepStrictEqual(
477 Schema.encodeSync(schema.headers as any)({ "retry-after": 30 }),
478 { "retry-after": "30" }
479 )
480 }
481 })
482})