1import { assert, describe, it } from "@effect/vitest"
2import { Schema } from "effect"
3import { HttpApiSchema } from "effect/http-api"
4
5const getStreamMetadata = (self: HttpApiSchema.StreamSchema) =>
6 self._tag === "StreamSse" ?
7 {
8 mode: self.mode,
9 sseMode: self.sseMode,
10 contentType: self.contentType,
11 events: self.events,
12 error: self.error
13 } :
14 {
15 mode: self.mode,
16 contentType: self.contentType
17 }
18
19describe("HttpApiSchema", () => {
20 describe("StreamSse", () => {
21 it("stores default metadata", () => {
22 const events = Schema.Struct({
23 event: Schema.Literal("user.created"),
24 data: Schema.String
25 })
26 const error = Schema.Struct({ reason: Schema.String })
27 const stream = HttpApiSchema.StreamSse({ events, error })
28
29 assert.isTrue(Schema.isSchema(stream))
30 assert.isTrue(HttpApiSchema.isStreamSchema(stream))
31 assert.isTrue(HttpApiSchema.isStreamSse(stream))
32 assert.isFalse(HttpApiSchema.isStreamUint8Array(stream))
33 assert.strictEqual(stream.mode, "sse")
34 assert.strictEqual(stream.sseMode, "events")
35 assert.strictEqual(stream.contentType, "text/event-stream")
36 assert.strictEqual(stream.events, events)
37 assert.strictEqual(stream.error, error)
38
39 const metadata = getStreamMetadata(stream)
40 assert.strictEqual(metadata.mode, "sse")
41 assert.strictEqual(metadata.contentType, "text/event-stream")
42 if (metadata.mode === "sse") {
43 assert.strictEqual(metadata.sseMode, "events")
44 assert.strictEqual(metadata.events, events)
45 assert.strictEqual(metadata.error, error)
46 }
47 })
48
49 it("stores custom content type", () => {
50 const events = Schema.Struct({
51 event: Schema.Literal("custom"),
52 data: Schema.String
53 })
54 const error = Schema.String
55 const stream = HttpApiSchema.StreamSse({
56 contentType: "text/event-stream; charset=utf-8",
57 events,
58 error
59 })
60
61 assert.strictEqual(stream.contentType, "text/event-stream; charset=utf-8")
62 })
63
64 it("defaults the stream error schema to Never", () => {
65 const events = Schema.Struct({
66 event: Schema.Literal("custom"),
67 data: Schema.String
68 })
69 const stream = HttpApiSchema.StreamSse({ events })
70
71 assert.strictEqual(stream.error, Schema.Never)
72 })
73
74 it("creates an event schema from a JSON data schema", () => {
75 const Data = Schema.Struct({ id: Schema.String })
76 const error = Schema.Struct({ reason: Schema.String })
77 const stream = HttpApiSchema.StreamSse({ data: Data, error })
78 const encode = Schema.encodeUnknownSync(stream.events)
79 const decode = Schema.decodeUnknownSync(stream.events)
80
81 assert.strictEqual(stream.sseMode, "data")
82 assert.deepStrictEqual(
83 encode({ id: undefined, event: "user.created", data: { id: "123" } }),
84 { id: undefined, event: "user.created", data: "{\"id\":\"123\"}" }
85 )
86 assert.deepStrictEqual(
87 decode({ id: undefined, event: "user.created", data: "{\"id\":\"123\"}" }),
88 { id: undefined, event: "user.created", data: { id: "123" } }
89 )
90 })
91 })
92
93 describe("StreamUint8Array", () => {
94 it("stores default metadata", () => {
95 const stream = HttpApiSchema.StreamUint8Array()
96
97 assert.isTrue(Schema.isSchema(stream))
98 assert.isTrue(HttpApiSchema.isStreamSchema(stream))
99 assert.isFalse(HttpApiSchema.isStreamSse(stream))
100 assert.isTrue(HttpApiSchema.isStreamUint8Array(stream))
101 assert.strictEqual(stream.mode, "uint8array")
102 assert.strictEqual(stream.contentType, "application/octet-stream")
103 assert.deepStrictEqual(getStreamMetadata(stream), {
104 mode: "uint8array",
105 contentType: "application/octet-stream"
106 })
107 })
108
109 it("stores custom content type", () => {
110 const stream = HttpApiSchema.StreamUint8Array({
111 contentType: "application/custom-binary"
112 })
113
114 assert.strictEqual(stream.contentType, "application/custom-binary")
115 })
116 })
117
118 describe("WithHeaders", () => {
119 it("stores the inner schema and headers schema", () => {
120 const headers = Schema.Struct({ "x-total-count": Schema.FiniteFromString })
121 const schema = HttpApiSchema.WithHeaders(Schema.String, headers)
122
123 assert.isTrue(Schema.isSchema(schema))
124 assert.isTrue(HttpApiSchema.isWithHeaders(schema))
125 assert.strictEqual(schema.schema, Schema.String)
126 assert.strictEqual(schema.headers, headers)
127 })
128
129 it("does not identify other schemas as WithHeaders", () => {
130 assert.isFalse(HttpApiSchema.isWithHeaders(Schema.String))
131 assert.isFalse(HttpApiSchema.isWithHeaders(HttpApiSchema.StreamUint8Array()))
132 })
133
134 it("supports a fields shorthand for headers", () => {
135 const schema = HttpApiSchema.WithHeaders(Schema.String, {
136 "x-total-count": Schema.FiniteFromString
137 })
138
139 assert.isTrue(Schema.isSchema(schema.headers))
140 assert.deepStrictEqual(Object.keys(schema.headers.fields), ["x-total-count"])
141 })
142
143 it("rejects nested WithHeaders schemas", () => {
144 const inner = HttpApiSchema.WithHeaders(Schema.String, { "x-a": Schema.String })
145
146 assert.throws(
147 () => HttpApiSchema.WithHeaders(inner, { "x-b": Schema.String }),
148 "WithHeaders schemas cannot be nested"
149 )
150 })
151
152 it("accepts only branded values when decoding", () => {
153 const schema = HttpApiSchema.WithHeaders(Schema.String, { "x-a": Schema.String })
154 const value = HttpApiSchema.withHeaders({ body: "a", headers: { "x-a": "1" } })
155
156 assert.strictEqual(Schema.decodeUnknownSync(schema)(value), value)
157 assert.throws(() => Schema.decodeUnknownSync(schema)({ body: "a", headers: { "x-a": "1" } }))
158 })
159
160 it("keeps the wrapper intact through annotate", () => {
161 const headers = Schema.Struct({ "x-a": Schema.String })
162 const schema = HttpApiSchema.WithHeaders(Schema.String, headers).annotate({ description: "described" })
163
164 assert.isTrue(HttpApiSchema.isWithHeaders(schema))
165 assert.strictEqual(schema.schema, Schema.String)
166 assert.strictEqual(schema.headers, headers)
167 })
168
169 it("resolves the status from the wrapper first, falling through to the inner schema", () => {
170 const headers = Schema.Struct({ "x-a": Schema.String })
171
172 const onInner = HttpApiSchema.WithHeaders(Schema.String.pipe(HttpApiSchema.status(201)), headers)
173 assert.strictEqual(HttpApiSchema.getStatusSuccessSchema(onInner), 201)
174
175 const onWrapper = HttpApiSchema.WithHeaders(Schema.String, headers).pipe(HttpApiSchema.status(202))
176 assert.isTrue(HttpApiSchema.isWithHeaders(onWrapper))
177 assert.strictEqual(HttpApiSchema.getStatusSuccessSchema(onWrapper), 202)
178
179 const onBoth = HttpApiSchema.WithHeaders(Schema.String.pipe(HttpApiSchema.status(201)), headers)
180 .pipe(HttpApiSchema.status(202))
181 assert.strictEqual(HttpApiSchema.getStatusSuccessSchema(onBoth), 202)
182
183 const onNeither = HttpApiSchema.WithHeaders(Schema.String, headers)
184 assert.strictEqual(HttpApiSchema.getStatusSuccessSchema(onNeither), 200)
185
186 assert.strictEqual(HttpApiSchema.getStatusSuccessSchema(Schema.String.pipe(HttpApiSchema.status(201))), 201)
187 })
188
189 it("resolves the response encoding from the wrapper first, falling through to the inner schema", () => {
190 const headers = Schema.Struct({ "x-a": Schema.String })
191
192 const onInner = HttpApiSchema.WithHeaders(Schema.String.pipe(HttpApiSchema.asText()), headers)
193 assert.strictEqual(HttpApiSchema.getResponseEncodingSchema(onInner)._tag, "Text")
194
195 const onWrapper = HttpApiSchema.WithHeaders(Schema.String, headers).pipe(
196 HttpApiSchema.asJson({ contentType: "application/vnd.custom+json" })
197 )
198 assert.isTrue(HttpApiSchema.isWithHeaders(onWrapper))
199 assert.strictEqual(
200 HttpApiSchema.getResponseEncodingSchema(onWrapper).contentType,
201 "application/vnd.custom+json"
202 )
203
204 const onNeither = HttpApiSchema.WithHeaders(Schema.String, headers)
205 assert.strictEqual(HttpApiSchema.getResponseEncodingSchema(onNeither)._tag, "Json")
206 })
207 })
208
209 describe("withHeaders", () => {
210 it("constructs a branded response value", () => {
211 const value = HttpApiSchema.withHeaders({ body: "a", headers: { "x-a": "1" } })
212
213 assert.strictEqual(value.body, "a")
214 assert.deepStrictEqual(value.headers, { "x-a": "1" })
215 })
216 })
217
218 describe("encodeToWithHeaders", () => {
219 class UserNotFound extends Schema.TaggedError<UserNotFound>()("UserNotFound", {
220 userId: Schema.Int
221 }) {}
222
223 const WrappedUserNotFound = UserNotFound.pipe(
224 HttpApiSchema.encodeToWithHeaders({
225 body: HttpApiSchema.Empty(404),
226 headers: { "x-user-id": Schema.Int }
227 }, {
228 decode: ({ headers }) => new UserNotFound({ userId: headers["x-user-id"] }),
229 encode: (error) => ({
230 headers: { "x-user-id": error.userId },
231 body: undefined
232 })
233 })
234 )
235
236 it("encodes to a body and headers pair", () => {
237 assert.deepStrictEqual(
238 Schema.encodeSync(WrappedUserNotFound)(new UserNotFound({ userId: 1 })),
239 { body: undefined, headers: { "x-user-id": 1 } }
240 )
241 })
242
243 it("decodes a pair back to the source type", () => {
244 assert.deepStrictEqual(
245 Schema.decodeUnknownSync(WrappedUserNotFound)({ body: undefined, headers: { "x-user-id": 1 } }),
246 new UserNotFound({ userId: 1 })
247 )
248 })
249
250 it("resolves the status from the body schema", () => {
251 assert.strictEqual(HttpApiSchema.getStatusError(WrappedUserNotFound.ast), 404)
252 })
253
254 it("resolves the response encoding from the body schema", () => {
255 const schema = Schema.String.pipe(
256 HttpApiSchema.encodeToWithHeaders({
257 body: Schema.String.pipe(HttpApiSchema.asText()),
258 headers: { "x-a": Schema.String }
259 }, {
260 decode: ({ body }) => body,
261 encode: (value) => ({ body: value, headers: { "x-a": "1" } })
262 })
263 )
264
265 assert.strictEqual(HttpApiSchema.getResponseEncoding(schema.ast)._tag, "Text")
266 })
267
268 it("defaults the response encoding to Json", () => {
269 assert.strictEqual(HttpApiSchema.getResponseEncoding(WrappedUserNotFound.ast)._tag, "Json")
270 })
271
272 it("uses the body defaults instead of source response metadata", () => {
273 const schema = Schema.String.pipe(
274 HttpApiSchema.status(429),
275 HttpApiSchema.asText(),
276 HttpApiSchema.encodeToWithHeaders({
277 body: Schema.String,
278 headers: { "x-note": Schema.String }
279 }, {
280 decode: ({ body }) => body,
281 encode: (body) => ({ body, headers: { "x-note": "test" } })
282 })
283 )
284
285 assert.deepStrictEqual({
286 status: HttpApiSchema.getStatusError(schema.ast),
287 encoding: HttpApiSchema.getResponseEncoding(schema.ast)
288 }, {
289 status: 500,
290 encoding: { _tag: "Json", contentType: "application/json" }
291 })
292 })
293 })
294
295 it("does not identify buffered schemas as stream schemas", () => {
296 assert.isFalse(HttpApiSchema.isStreamSchema(Schema.String))
297 assert.isFalse(HttpApiSchema.isStreamSchema(Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array())))
298 })
299
300 describe("status", () => {
301 it("accepts status literals", () => {
302 assert.strictEqual(HttpApiSchema.getStatusSuccess(Schema.String.pipe(HttpApiSchema.status("Created")).ast), 201)
303 assert.strictEqual(HttpApiSchema.getStatusError(Schema.String.pipe(HttpApiSchema.status("Conflict")).ast), 409)
304 })
305 })
306})