1import { assert, describe, it } from "@effect/vitest"
2import { type Context, Effect, Predicate, Schema, SchemaTransformation } from "effect"
3import {
4 HttpApi,
5 HttpApiEndpoint,
6 HttpApiGroup,
7 HttpApiMiddleware,
8 HttpApiSchema,
9 HttpApiSecurity,
10 OpenApi
11} from "effect/http-api"
12
13class HeaderAuthA extends HttpApiMiddleware.Service<HeaderAuthA>()("HeaderAuthA", {
14 security: {
15 auth: HttpApiSecurity.apiKey({ key: "x-api-key" })
16 }
17}) {}
18
19class HeaderAuthB extends HttpApiMiddleware.Service<HeaderAuthB>()("HeaderAuthB", {
20 security: {
21 auth: HttpApiSecurity.apiKey({ key: "x-api-key" })
22 }
23}) {}
24
25class HeaderAuthUpper extends HttpApiMiddleware.Service<HeaderAuthUpper>()("HeaderAuthUpper", {
26 security: {
27 auth: HttpApiSecurity.apiKey({ key: "X-API-Key" })
28 }
29}) {}
30
31class QueryAuth extends HttpApiMiddleware.Service<QueryAuth>()("QueryAuth", {
32 security: {
33 auth: HttpApiSecurity.apiKey({ key: "api_key", in: "query" })
34 }
35}) {}
36
37class TokenAuthUpper extends HttpApiMiddleware.Service<TokenAuthUpper>()("TokenAuthUpper", {
38 security: {
39 auth: HttpApiSecurity.http({ scheme: "Token" })
40 }
41}) {}
42
43class TokenAuthLower extends HttpApiMiddleware.Service<TokenAuthLower>()("TokenAuthLower", {
44 security: {
45 auth: HttpApiSecurity.http({ scheme: "token" })
46 }
47}) {}
48
49class ProtoHeaderAuth extends HttpApiMiddleware.Service<ProtoHeaderAuth>()("ProtoHeaderAuth", {
50 security: {
51 ["__proto__"]: HttpApiSecurity.apiKey({ key: "x-api-key" })
52 }
53}) {}
54
55class ProtoQueryAuth extends HttpApiMiddleware.Service<ProtoQueryAuth>()("ProtoQueryAuth", {
56 security: {
57 ["__proto__"]: HttpApiSecurity.apiKey({ key: "api_key", in: "query" })
58 }
59}) {}
60
61const makeSecurityApi = (
62 first: Context.Key<any, any>,
63 second: Context.Key<any, any>
64) =>
65 HttpApi.make("Api").add(
66 HttpApiGroup.make("test").add(
67 HttpApiEndpoint.get("first", "/first").middleware(first),
68 HttpApiEndpoint.get("second", "/second").middleware(second)
69 )
70 )
71
72describe("OpenApi", () => {
73 it("preserves literal action suffixes in path templates", () => {
74 const Api = HttpApi.make("Api").add(
75 HttpApiGroup.make("operations").add(
76 HttpApiEndpoint.post("wait", "/operations/:id:wait", {
77 params: { id: Schema.String }
78 })
79 )
80 )
81 const spec = OpenApi.fromApi(Api)
82
83 assert.deepStrictEqual(Object.keys(spec.paths), ["/operations/{id}:wait"])
84 assert.deepStrictEqual(spec.paths["/operations/{id}:wait"]?.post?.parameters, [
85 { name: "id", in: "path", required: true, schema: { type: "string" } }
86 ])
87 })
88
89 it("keeps action paths literal when no parameter schema is declared", () => {
90 const Api = HttpApi.make("Api").add(
91 HttpApiGroup.make("offers").add(HttpApiEndpoint.post("resolve", "/offers:resolve"))
92 )
93 const spec = OpenApi.fromApi(Api)
94
95 assert.deepStrictEqual(Object.keys(spec.paths), ["/offers:resolve"])
96 assert.deepStrictEqual(spec.paths["/offers:resolve"]?.post?.parameters, [])
97 })
98
99 it("preserves parameter schemas when an endpoint transform reorders parameters", () => {
100 const Api = HttpApi.make("Api").add(
101 HttpApiGroup.make("test").add(
102 HttpApiEndpoint.get("list", "/list", {
103 query: { first: Schema.Literal("first"), second: Schema.Literal("second") }
104 }).annotate(OpenApi.Transform, (operation: Record<string, any>) => ({
105 ...operation,
106 parameters: [...operation.parameters].reverse()
107 }))
108 )
109 )
110
111 assert.deepStrictEqual(OpenApi.fromApi(Api).paths["/list"]?.get?.parameters, [
112 { name: "second", in: "query", required: true, schema: { type: "string", enum: ["second"] } },
113 { name: "first", in: "query", required: true, schema: { type: "string", enum: ["first"] } }
114 ])
115 })
116
117 it("derives parameter names, optionality, and schemas from the encoded object representation", () => {
118 const Api = HttpApi.make("Api").add(
119 HttpApiGroup.make("test").add(
120 HttpApiEndpoint.get("list", "/list/:cursor", {
121 params: {
122 cursor: Schema.FiniteFromString.pipe(Schema.withDecodingDefaultKey(Effect.succeed("0")))
123 },
124 query: {
125 limit: Schema.FiniteFromString.pipe(Schema.withDecodingDefaultKey(Effect.succeed("10"))),
126 offset: Schema.FiniteFromString.pipe(Schema.withDecodingDefault(Effect.succeed("0"))),
127 search: Schema.String.annotateKey({ description: "Search text" })
128 },
129 headers: {
130 "x-page-size": Schema.FiniteFromString.pipe(Schema.withDecodingDefaultKey(Effect.succeed("10")))
131 }
132 })
133 )
134 )
135
136 assert.deepStrictEqual(OpenApi.fromApi(Api).paths["/list/{cursor}"]?.get?.parameters, [
137 { name: "cursor", in: "path", required: true, schema: { type: "string" } },
138 { name: "x-page-size", in: "header", required: false, schema: { type: "string" } },
139 { name: "limit", in: "query", required: false, schema: { type: "string" } },
140 {
141 name: "offset",
142 in: "query",
143 required: false,
144 schema: { anyOf: [{ type: "string" }, { type: "null" }] }
145 },
146 {
147 name: "search",
148 in: "query",
149 required: true,
150 schema: { type: "string", allOf: [{ description: "Search text" }] }
151 }
152 ])
153 })
154
155 it("does not apply JSON codecs to parameters when codecs are disabled", () => {
156 const OpaqueString = Schema.declare((input): input is string => typeof input === "string", {
157 toCodecJson: () => Schema.link<string>()(Schema.String, SchemaTransformation.passthrough())
158 })
159 const Api = HttpApi.make("Api").add(
160 HttpApiGroup.make("test")
161 .add(HttpApiEndpoint.get("encoded", "/encoded", { query: { value: OpaqueString } }))
162 .add(HttpApiEndpoint.get("unencoded", "/unencoded", {
163 disableCodecs: true,
164 query: { value: OpaqueString }
165 }))
166 )
167
168 const spec = OpenApi.fromApi(Api)
169
170 assert.deepStrictEqual(spec.paths["/encoded"]?.get?.parameters, [
171 { name: "value", in: "query", required: true, schema: { type: "string" } }
172 ])
173 assert.deepStrictEqual(spec.paths["/unencoded"]?.get?.parameters, [
174 { name: "value", in: "query", required: true, schema: {} }
175 ])
176 })
177
178 it("returns fresh spec instances when using the cache", () => {
179 const Api = HttpApi.make("Api").add(
180 HttpApiGroup.make("test").add(
181 HttpApiEndpoint.get("get", "/resource")
182 )
183 )
184
185 const first = OpenApi.fromApi(Api)
186 first.info.title = "mutated"
187 first.paths["/resource"]!.get!.summary = "mutated"
188
189 const second = OpenApi.fromApi(Api)
190
191 assert.notStrictEqual(first, second)
192 assert.notStrictEqual(first.info, second.info)
193 assert.notStrictEqual(first.paths["/resource"]!.get, second.paths["/resource"]!.get)
194 assert.strictEqual(second.info.title, "Api")
195 assert.isUndefined(second.paths["/resource"]!.get!.summary)
196
197 second.info.title = "mutated again"
198 second.paths["/resource"]!.get!.summary = "mutated again"
199 const third = OpenApi.fromApi(Api)
200
201 assert.strictEqual(third.info.title, "Api")
202 assert.isUndefined(third.paths["/resource"]!.get!.summary)
203 })
204
205 it("preserves JSON.rawJSON values when cloning cached specs", () => {
206 const rawJson = (JSON as unknown as { rawJSON: (value: string) => unknown }).rawJSON("9223372036854775807")
207 const Api = HttpApi.make("Api").annotate(OpenApi.Transform, (spec) => ({
208 ...spec,
209 info: {
210 ...spec.info,
211 "x-raw": rawJson
212 }
213 }))
214
215 const expected = `{"title":"Api","version":"0.0.1","x-raw":9223372036854775807}`
216
217 assert.strictEqual(JSON.stringify(OpenApi.fromApi(Api).info), expected)
218 assert.strictEqual(JSON.stringify(OpenApi.fromApi(Api).info), expected)
219 })
220
221 it("isolates the cached spec from external override mutations", () => {
222 const info = { title: "Api", version: "1.0.0" }
223 const Api = HttpApi.make("Api").annotate(OpenApi.Override, { info })
224
225 OpenApi.fromApi(Api)
226 info.title = "mutated"
227
228 const cached = OpenApi.fromApi(Api)
229
230 assert.strictEqual(cached.info.title, "Api")
231 })
232
233 it("preserves every declared payload content type for normalized equivalents", () => {
234 const profileA = "Application/Vnd.Effect+JSON; Profile=A"
235 const profileB = "application/vnd.effect+json; profile=b"
236 const Api = HttpApi.make("Api").add(
237 HttpApiGroup.make("test").add(
238 HttpApiEndpoint.post("create", "/create", {
239 payload: [
240 Schema.Struct({ a: Schema.String }).pipe(HttpApiSchema.asJson({ contentType: profileA })),
241 Schema.Struct({ b: Schema.String }).pipe(HttpApiSchema.asJson({ contentType: profileB }))
242 ]
243 })
244 )
245 )
246
247 const spec = OpenApi.fromApi(Api)
248 const content = spec.paths["/create"]?.post?.requestBody?.content
249
250 assert.isDefined(content)
251 assert.property(content, profileA)
252 assert.property(content, profileB)
253 assert.deepStrictEqual(content[profileA]?.schema, {
254 type: "object",
255 properties: { a: { type: "string" } },
256 required: ["a"],
257 additionalProperties: false
258 })
259 assert.deepStrictEqual(content[profileB]?.schema, {
260 type: "object",
261 properties: { b: { type: "string" } },
262 required: ["b"],
263 additionalProperties: false
264 })
265 })
266
267 it("preserves GET and QUERY schemas on the same path", () => {
268 const Api = HttpApi.make("Api").add(
269 HttpApiGroup.make("search").add(
270 HttpApiEndpoint.get("list", "/search", { success: Schema.Finite }),
271 HttpApiEndpoint.query("search", "/search", {
272 payload: Schema.String,
273 success: Schema.Boolean
274 }).annotate(OpenApi.Transform, (operation: Record<string, any>) => {
275 assert.deepStrictEqual(operation.requestBody.content["application/json"].schema, { type: "string" })
276 return { ...operation, summary: "Search" }
277 })
278 )
279 )
280
281 const spec = OpenApi.fromApi(Api)
282 const pathItem = spec.paths["/search"]
283 const query = pathItem["x-oai-additionalOperations"]?.QUERY
284
285 assert.strictEqual(spec.openapi, "3.1.0")
286 assert.deepStrictEqual(Object.keys(pathItem), ["get", "x-oai-additionalOperations"])
287 assert.strictEqual(query?.summary, "Search")
288 assert.deepStrictEqual(pathItem.get?.responses[200]?.content?.["application/json"]?.schema, { type: "number" })
289 assert.deepStrictEqual(query?.responses[200]?.content?.["application/json"]?.schema, { type: "boolean" })
290 })
291
292 it("emits buffered and stream successes with the same status", () => {
293 const Api = HttpApi.make("Api").add(
294 HttpApiGroup.make("test").add(
295 HttpApiEndpoint.get("chat", "/chat", {
296 success: [
297 Schema.Struct({ message: Schema.String }),
298 HttpApiSchema.StreamSse({
299 events: Schema.Struct({ event: Schema.String, data: Schema.String }),
300 error: Schema.Struct({ reason: Schema.String })
301 })
302 ]
303 })
304 )
305 )
306
307 const spec = OpenApi.fromApi(Api)
308 const content = spec.paths["/chat"]?.get?.responses[200]?.content
309
310 assert.isNotNull(content)
311 assert.property(content, "application/json")
312 assert.property(content, "text/event-stream")
313 const streamExtension = content?.["text/event-stream"]?.["x-effect-stream"]
314 assert.isNotNull(streamExtension)
315 if (streamExtension?.encoding !== "sse") {
316 throw new Error("Expected SSE stream extension")
317 }
318 assert.strictEqual(
319 streamExtension.failureEvent,
320 "effect/http-api/stream/failure"
321 )
322 assert.property(streamExtension, "causeSchema")
323 assert.property(streamExtension, "errorSchema")
324 })
325
326 it("emits the encoded schema of text bodies", () => {
327 class NotFound extends Schema.TaggedError<NotFound>()("NotFound", {}) {}
328 const Api = HttpApi.make("Api").add(
329 HttpApiGroup.make("test").add(
330 HttpApiEndpoint.post("create", "/create", {
331 payload: Schema.String.check(Schema.isPattern(/^[a-z]+$/u)).pipe(HttpApiSchema.asText()),
332 success: Schema.Literal("Created").pipe(HttpApiSchema.asText()),
333 error: [
334 Schema.Literal("Bad Request").pipe(HttpApiSchema.asText(), HttpApiSchema.status(400)),
335 Schema.Literal("Invalid Cursor").pipe(HttpApiSchema.asText(), HttpApiSchema.status(400)),
336 Schema.Literal("Not Found").pipe(
337 Schema.decodeTo(
338 NotFound,
339 SchemaTransformation.transform({
340 decode: () => ({ _tag: "NotFound" as const }),
341 encode: () => "Not Found" as const
342 })
343 ),
344 HttpApiSchema.asText(),
345 HttpApiSchema.status(404)
346 )
347 ]
348 })
349 )
350 )
351
352 const spec = OpenApi.fromApi(Api)
353 const operation = spec.paths["/create"]?.post
354
355 assert.deepStrictEqual(operation?.requestBody?.content["text/plain"]?.schema, {
356 type: "string",
357 pattern: "^[a-z]+$"
358 })
359 assert.deepStrictEqual(operation?.responses[200]?.content?.["text/plain"]?.schema, {
360 type: "string",
361 enum: ["Created"]
362 })
363 assert.deepStrictEqual(operation?.responses[400]?.content?.["text/plain"]?.schema, {
364 type: "string",
365 enum: ["Bad Request", "Invalid Cursor"]
366 })
367 assert.deepStrictEqual(operation?.responses[404]?.content?.["text/plain"]?.schema, {
368 type: "string",
369 $ref: "#/components/schemas/NotFoundEncoded"
370 })
371 assert.deepStrictEqual(spec.components.schemas.NotFoundEncoded, { type: "string", enum: ["Not Found"] })
372 })
373
374 it("keeps opaque text bodies as strings", () => {
375 const Text = Schema.declare<string>(Predicate.isString).pipe(HttpApiSchema.asText())
376 const Api = HttpApi.make("Api").add(
377 HttpApiGroup.make("test").add(
378 HttpApiEndpoint.post("create", "/create", {
379 payload: Text,
380 success: Text,
381 error: Text.pipe(HttpApiSchema.status(400))
382 })
383 )
384 )
385
386 const operation = OpenApi.fromApi(Api).paths["/create"]?.post
387
388 assert.deepStrictEqual(operation?.requestBody?.content["text/plain"]?.schema, { type: "string" })
389 assert.deepStrictEqual(operation?.responses[200]?.content?.["text/plain"]?.schema, { type: "string" })
390 assert.deepStrictEqual(operation?.responses[400]?.content?.["text/plain"]?.schema, { type: "string" })
391 })
392
393 it("constrains referenced opaque text bodies without changing shared JSON schemas", () => {
394 const OpaqueText = Schema.declare<string>(Predicate.isString).annotate({ identifier: "OpaqueText" })
395 const Text = OpaqueText.pipe(HttpApiSchema.asText())
396 const Api = HttpApi.make("Api").add(
397 HttpApiGroup.make("test").add(
398 HttpApiEndpoint.post("text", "/text", {
399 payload: Text,
400 success: Text,
401 error: Text.pipe(HttpApiSchema.status(400))
402 }),
403 HttpApiEndpoint.post("json", "/json", {
404 payload: OpaqueText,
405 success: OpaqueText
406 })
407 )
408 )
409
410 const spec = OpenApi.fromApi(Api)
411 const textOperation = spec.paths["/text"]?.post
412 const jsonOperation = spec.paths["/json"]?.post
413 const jsonReference = { $ref: "#/components/schemas/OpaqueTextEncoded" }
414 const textSchema = { type: "string" }
415
416 assert.deepStrictEqual(textOperation?.requestBody?.content["text/plain"]?.schema, textSchema)
417 assert.deepStrictEqual(textOperation?.responses[200]?.content?.["text/plain"]?.schema, textSchema)
418 assert.deepStrictEqual(textOperation?.responses[400]?.content?.["text/plain"]?.schema, textSchema)
419 assert.deepStrictEqual(jsonOperation?.requestBody?.content["application/json"]?.schema, jsonReference)
420 assert.deepStrictEqual(jsonOperation?.responses[200]?.content?.["application/json"]?.schema, jsonReference)
421 assert.deepStrictEqual(spec.components.schemas, { OpaqueTextEncoded: {} })
422 })
423
424 it("uses encoding-specific codecs for text, JSON, and form bodies", () => {
425 const OpaqueString = Schema.declare<string>(Predicate.isString, {
426 toCodecJson: () =>
427 Schema.link<string>()(
428 Schema.Struct({ value: Schema.String }),
429 SchemaTransformation.transform({
430 decode: ({ value }) => value,
431 encode: (value) => ({ value })
432 })
433 )
434 })
435 const Api = HttpApi.make("Api").add(
436 HttpApiGroup.make("test").add(
437 HttpApiEndpoint.post("create", "/create", {
438 payload: [
439 OpaqueString,
440 OpaqueString.pipe(HttpApiSchema.asText()),
441 OpaqueString.pipe(HttpApiSchema.asFormUrlEncoded())
442 ],
443 success: [
444 OpaqueString,
445 OpaqueString.pipe(HttpApiSchema.asText()),
446 OpaqueString.pipe(HttpApiSchema.asFormUrlEncoded())
447 ]
448 })
449 )
450 )
451
452 const operation = OpenApi.fromApi(Api).paths["/create"]?.post
453 const objectSchema = {
454 type: "object",
455 properties: { value: { type: "string" } },
456 required: ["value"],
457 additionalProperties: false
458 }
459
460 assert.deepStrictEqual(operation?.requestBody?.content["text/plain"]?.schema, { type: "string" })
461 assert.deepStrictEqual(operation?.responses[200]?.content?.["text/plain"]?.schema, { type: "string" })
462 assert.deepStrictEqual(operation?.requestBody?.content["application/json"]?.schema, objectSchema)
463 assert.deepStrictEqual(operation?.responses[200]?.content?.["application/json"]?.schema, objectSchema)
464 assert.deepStrictEqual(
465 operation?.requestBody?.content["application/x-www-form-urlencoded"]?.schema,
466 objectSchema
467 )
468 assert.deepStrictEqual(
469 operation?.responses[200]?.content?.["application/x-www-form-urlencoded"]?.schema,
470 objectSchema
471 )
472 })
473
474 it("emits encoded success response headers", () => {
475 const Api = HttpApi.make("Api").add(
476 HttpApiGroup.make("test").add(
477 HttpApiEndpoint.get("success", "/success", {
478 success: HttpApiSchema.WithHeaders(Schema.String, {
479 "X-Count": Schema.FiniteFromString,
480 "X-Optional": Schema.optionalKey(Schema.FiniteFromString),
481 "Content-Type": Schema.String
482 })
483 })
484 )
485 )
486
487 const spec = OpenApi.fromApi(Api)
488
489 assert.deepStrictEqual(spec.paths["/success"]?.get?.responses[200]?.headers, {
490 "x-count": {
491 schema: { type: "string" },
492 required: true
493 },
494 "x-optional": {
495 schema: { type: "string" },
496 required: false
497 }
498 })
499 })
500
501 it("marks response headers optional from their encoded object representation", () => {
502 const Api = HttpApi.make("Api").add(
503 HttpApiGroup.make("test").add(
504 HttpApiEndpoint.get("success", "/success", {
505 success: HttpApiSchema.WithHeaders(Schema.String, {
506 "X-Total": Schema.FiniteFromString.pipe(Schema.withDecodingDefaultKey(Effect.succeed("0"))),
507 "X-Offset": Schema.FiniteFromString.pipe(Schema.withDecodingDefault(Effect.succeed("0")))
508 })
509 })
510 )
511 )
512
513 assert.deepStrictEqual(OpenApi.fromApi(Api).paths["/success"]?.get?.responses[200]?.headers, {
514 "x-total": {
515 schema: { type: "string" },
516 required: false
517 },
518 "x-offset": {
519 schema: { anyOf: [{ type: "string" }, { type: "null" }] },
520 required: false
521 }
522 })
523 })
524
525 it("emits encoded error response headers", () => {
526 class NotFound extends Schema.TaggedError<NotFound>()("NotFound", {
527 id: Schema.Number
528 }) {}
529 const NotFoundWithHeaders = NotFound.pipe(
530 HttpApiSchema.encodeToWithHeaders({
531 body: HttpApiSchema.Empty(404),
532 headers: { "X-Error-Id": Schema.FiniteFromString }
533 }, {
534 decode: ({ headers }) => new NotFound({ id: headers["X-Error-Id"] }),
535 encode: (error) => ({ body: undefined, headers: { "X-Error-Id": error.id } })
536 })
537 )
538 const Api = HttpApi.make("Api").add(
539 HttpApiGroup.make("test").add(
540 HttpApiEndpoint.get("error", "/error", {
541 error: NotFoundWithHeaders
542 })
543 )
544 )
545
546 const spec = OpenApi.fromApi(Api)
547
548 assert.deepStrictEqual(spec.paths["/error"]?.get?.responses[404]?.headers, {
549 "x-error-id": {
550 schema: { type: "string" },
551 required: true
552 }
553 })
554 })
555
556 it("emits encodeToWithHeaders wire schemas according to codec mode", () => {
557 const ErrorWithHeaders = Schema.Number.pipe(
558 HttpApiSchema.encodeToWithHeaders({
559 body: HttpApiSchema.Empty(404),
560 headers: { "X-Error-Id": Schema.Int }
561 }, {
562 decode: ({ headers }) => headers["X-Error-Id"],
563 encode: (id) => ({ body: undefined, headers: { "X-Error-Id": id } })
564 })
565 )
566 const Api = HttpApi.make("Api").add(
567 HttpApiGroup.make("test")
568 .add(
569 HttpApiEndpoint.get("encoded", "/encoded", {
570 error: ErrorWithHeaders
571 })
572 )
573 .add(
574 HttpApiEndpoint.get("unencoded", "/unencoded", {
575 disableCodecs: true,
576 error: ErrorWithHeaders
577 })
578 )
579 )
580
581 const spec = OpenApi.fromApi(Api)
582
583 assert.deepStrictEqual(
584 spec.paths["/encoded"]?.get?.responses[404]?.headers?.["x-error-id"]?.schema,
585 {
586 type: "string",
587 pattern: "^[+-]?\\d*\\.?\\d+(?:[Ee][+-]?\\d+)?$"
588 }
589 )
590 assert.deepStrictEqual(
591 spec.paths["/unencoded"]?.get?.responses[404]?.headers?.["x-error-id"]?.schema,
592 { type: "integer" }
593 )
594 })
595
596 it("emits headers for an error wrapped in WithHeaders", () => {
597 const Api = HttpApi.make("Api").add(
598 HttpApiGroup.make("test").add(
599 HttpApiEndpoint.get("error", "/error", {
600 error: HttpApiSchema.WithHeaders(
601 Schema.Struct({ message: Schema.String }).pipe(HttpApiSchema.status(429)),
602 { "X-Retry-After": Schema.Int }
603 )
604 })
605 )
606 )
607
608 const spec = OpenApi.fromApi(Api)
609
610 assert.deepStrictEqual(spec.paths["/error"]?.get?.responses[429]?.headers, {
611 "x-retry-after": {
612 schema: {
613 type: "string",
614 pattern: "^[+-]?\\d*\\.?\\d+(?:[Ee][+-]?\\d+)?$"
615 },
616 required: true
617 }
618 })
619 })
620
621 it("collapses a middleware error that repeats an endpoint error", () => {
622 const ApiError = Schema.Struct({ code: Schema.String }).annotate({ identifier: "ApiError" })
623
624 class SameError extends HttpApiMiddleware.Service<SameError>()("SameError", {
625 error: ApiError
626 }) {}
627
628 const Api = HttpApi.make("Api").add(
629 HttpApiGroup.make("test").add(
630 HttpApiEndpoint.get("error", "/error", { error: ApiError })
631 )
632 ).middleware(SameError)
633
634 const spec = OpenApi.fromApi(Api)
635
636 assert.deepStrictEqual(spec.paths["/error"]?.get?.responses[500]?.content, {
637 "application/json": {
638 schema: { $ref: "#/components/schemas/ApiError" }
639 }
640 })
641 })
642
643 it("collapses a repeated middleware error when the endpoint disables codecs", () => {
644 const ApiError = Schema.Struct({ code: Schema.String }).annotate({ identifier: "ApiError" })
645
646 class SameError extends HttpApiMiddleware.Service<SameError>()("SameErrorNoCodecs", {
647 error: ApiError
648 }) {}
649
650 const Api = HttpApi.make("Api").add(
651 HttpApiGroup.make("test").add(
652 HttpApiEndpoint.get("error", "/error", {
653 disableCodecs: true,
654 error: ApiError
655 })
656 )
657 ).middleware(SameError)
658
659 const spec = OpenApi.fromApi(Api)
660
661 assert.deepStrictEqual(spec.paths["/error"]?.get?.responses[500]?.content, {
662 "application/json": {
663 schema: { $ref: "#/components/schemas/ApiError" }
664 }
665 })
666 })
667
668 it("resolves one shared middleware error per endpoint codec mode", () => {
669 const ApiError = Schema.Struct({ code: Schema.String }).annotate({ identifier: "ApiError" })
670
671 class SharedError extends HttpApiMiddleware.Service<SharedError>()("SharedError", {
672 error: ApiError
673 }) {}
674
675 // The same middleware instance is attached to both endpoints, so its single
676 // error set has to be resolved against each endpoint's `disableCodecs`.
677 const Api = HttpApi.make("Api").add(
678 HttpApiGroup.make("test")
679 .add(HttpApiEndpoint.get("codecs", "/codecs", { error: ApiError }))
680 .add(HttpApiEndpoint.get("raw", "/raw", { disableCodecs: true, error: ApiError }))
681 ).middleware(SharedError)
682
683 const spec = OpenApi.fromApi(Api)
684
685 assert.deepStrictEqual(spec.paths["/codecs"]?.get?.responses[500]?.content, {
686 "application/json": {
687 schema: { $ref: "#/components/schemas/ApiError" }
688 }
689 })
690 assert.deepStrictEqual(spec.paths["/raw"]?.get?.responses[500]?.content, {
691 "application/json": {
692 schema: { $ref: "#/components/schemas/ApiError" }
693 }
694 })
695 })
696
697 it("emits an anyOf for a middleware error that differs from the endpoint error", () => {
698 const EndpointError = Schema.Struct({ code: Schema.String }).annotate({ identifier: "EndpointError" })
699 const MiddlewareError = Schema.Struct({ reason: Schema.String }).annotate({ identifier: "MiddlewareError" })
700
701 class OtherError extends HttpApiMiddleware.Service<OtherError>()("OtherError", {
702 error: MiddlewareError
703 }) {}
704
705 const Api = HttpApi.make("Api").add(
706 HttpApiGroup.make("test").add(
707 HttpApiEndpoint.get("error", "/error", { error: EndpointError })
708 )
709 ).middleware(OtherError)
710
711 const spec = OpenApi.fromApi(Api)
712
713 assert.deepStrictEqual(spec.paths["/error"]?.get?.responses[500]?.content, {
714 "application/json": {
715 schema: {
716 anyOf: [
717 { $ref: "#/components/schemas/EndpointError" },
718 { $ref: "#/components/schemas/MiddlewareError" }
719 ]
720 }
721 }
722 })
723 })
724
725 it("emits an anyOf for two different endpoint errors sharing a status", () => {
726 const FirstError = Schema.Struct({ code: Schema.String }).annotate({ identifier: "FirstError" })
727 const SecondError = Schema.Struct({ reason: Schema.String }).annotate({ identifier: "SecondError" })
728
729 const Api = HttpApi.make("Api").add(
730 HttpApiGroup.make("test").add(
731 HttpApiEndpoint.get("error", "/error", { error: [FirstError, SecondError] })
732 )
733 )
734
735 const spec = OpenApi.fromApi(Api)
736
737 assert.deepStrictEqual(spec.paths["/error"]?.get?.responses[500]?.content, {
738 "application/json": {
739 schema: {
740 anyOf: [
741 { $ref: "#/components/schemas/FirstError" },
742 { $ref: "#/components/schemas/SecondError" }
743 ]
744 }
745 }
746 })
747 })
748
749 it("documents a middleware error for a status the endpoint does not declare", () => {
750 const Unauthorized = Schema.Struct({ message: Schema.String }).pipe(HttpApiSchema.status(401)).annotate({
751 identifier: "Unauthorized"
752 })
753
754 class Auth extends HttpApiMiddleware.Service<Auth>()("Auth", {
755 error: Unauthorized
756 }) {}
757
758 const Api = HttpApi.make("Api").add(
759 HttpApiGroup.make("test").add(
760 HttpApiEndpoint.get("error", "/error", {
761 error: Schema.Struct({ code: Schema.String }).annotate({ identifier: "ServerError" })
762 })
763 )
764 ).middleware(Auth)
765
766 const spec = OpenApi.fromApi(Api)
767
768 assert.deepStrictEqual(spec.paths["/error"]?.get?.responses[401]?.content, {
769 "application/json": {
770 schema: { $ref: "#/components/schemas/Unauthorized" }
771 }
772 })
773 assert.deepStrictEqual(spec.paths["/error"]?.get?.responses[500]?.content, {
774 "application/json": {
775 schema: { $ref: "#/components/schemas/ServerError" }
776 }
777 })
778 })
779
780 it("emits stream response headers", () => {
781 const Api = HttpApi.make("Api").add(
782 HttpApiGroup.make("test").add(
783 HttpApiEndpoint.get("stream", "/stream", {
784 success: HttpApiSchema.WithHeaders(HttpApiSchema.StreamUint8Array(), {
785 "X-Stream-Id": Schema.Int
786 })
787 })
788 )
789 )
790
791 const spec = OpenApi.fromApi(Api)
792
793 assert.deepStrictEqual(spec.paths["/stream"]?.get?.responses[200]?.headers, {
794 "x-stream-id": {
795 schema: {
796 type: "string",
797 pattern: "^[+-]?\\d*\\.?\\d+(?:[Ee][+-]?\\d+)?$"
798 },
799 required: true
800 }
801 })
802 })
803
804 it("rejects duplicate method and path pairs", () => {
805 const Api = HttpApi.make("Api").add(
806 HttpApiGroup.make("test").add(
807 HttpApiEndpoint.get("first", "/shared"),
808 HttpApiEndpoint.get("second", "/shared")
809 )
810 )
811
812 assert.throws(() => OpenApi.fromApi(Api), /Duplicate OpenAPI operation for GET \/shared/)
813 })
814
815 it("rejects equivalent templated method and path pairs", () => {
816 const Api = HttpApi.make("Api").add(
817 HttpApiGroup.make("test").add(
818 HttpApiEndpoint.get("first", "/users/:id", { params: { id: Schema.String } }),
819 HttpApiEndpoint.get("second", "/users/:userId", { params: { userId: Schema.String } })
820 )
821 )
822
823 assert.throws(() => OpenApi.fromApi(Api), /Duplicate OpenAPI operation for GET \/users\/\{userId\}/)
824 })
825
826 it("rejects duplicate operation identifiers", () => {
827 const Api = HttpApi.make("Api").add(
828 HttpApiGroup.make("test").add(
829 HttpApiEndpoint.get("first", "/first").annotate(OpenApi.Identifier, "shared"),
830 HttpApiEndpoint.get("second", "/second").annotate(OpenApi.Identifier, "shared")
831 )
832 )
833
834 assert.throws(() => OpenApi.fromApi(Api), /Duplicate OpenAPI operationId: shared/)
835 })
836
837 it("allows operations without identifiers", () => {
838 const removeOperationId = (operation: Record<string, any>) => {
839 const transformed = { ...operation }
840 delete transformed.operationId
841 return transformed
842 }
843 const Api = HttpApi.make("Api").add(
844 HttpApiGroup.make("test").add(
845 HttpApiEndpoint.get("first", "/first").annotate(OpenApi.Transform, removeOperationId),
846 HttpApiEndpoint.get("second", "/second").annotate(OpenApi.Transform, removeOperationId)
847 )
848 )
849
850 const spec = OpenApi.fromApi(Api)
851 const first = spec.paths["/first"]?.get
852 const second = spec.paths["/second"]?.get
853
854 assert.isDefined(first)
855 assert.isDefined(second)
856 assert.isFalse(Object.hasOwn(first, "operationId"))
857 assert.isFalse(Object.hasOwn(second, "operationId"))
858 })
859
860 it("rejects incompatible security schemes with the same name", () => {
861 const Api = makeSecurityApi(HeaderAuthA, QueryAuth)
862
863 assert.throws(() => OpenApi.fromApi(Api), /Conflicting OpenAPI security scheme: auth/)
864 })
865
866 it("deduplicates equivalent security schemes with the same name", () => {
867 const Api = makeSecurityApi(HeaderAuthA, HeaderAuthB)
868
869 const spec = OpenApi.fromApi(Api)
870
871 assert.deepStrictEqual(spec.components.securitySchemes.auth, {
872 type: "apiKey",
873 name: "x-api-key",
874 in: "header"
875 })
876 })
877
878 it("deduplicates API key header names case-insensitively", () => {
879 const Api = makeSecurityApi(HeaderAuthUpper, HeaderAuthA)
880
881 const spec = OpenApi.fromApi(Api)
882
883 assert.deepStrictEqual(spec.components.securitySchemes.auth, {
884 type: "apiKey",
885 name: "X-API-Key",
886 in: "header"
887 })
888 })
889
890 it("deduplicates HTTP security schemes case-insensitively", () => {
891 const Api = makeSecurityApi(TokenAuthUpper, TokenAuthLower)
892
893 const spec = OpenApi.fromApi(Api)
894
895 assert.deepStrictEqual(spec.components.securitySchemes.auth, {
896 type: "http",
897 scheme: "Token"
898 })
899 })
900
901 it("stores __proto__ security scheme names as own properties", () => {
902 const Api = HttpApi.make("Api").add(
903 HttpApiGroup.make("test").add(
904 HttpApiEndpoint.get("first", "/first").middleware(ProtoHeaderAuth)
905 )
906 )
907
908 const spec = OpenApi.fromApi(Api)
909 const schemes = spec.components.securitySchemes
910
911 assert.isTrue(Object.hasOwn(schemes, "__proto__"))
912 assert.deepStrictEqual(Object.keys(schemes), ["__proto__"])
913 assert.strictEqual(Object.getPrototypeOf(schemes), Object.prototype)
914 assert.deepStrictEqual(schemes["__proto__"], {
915 type: "apiKey",
916 name: "x-api-key",
917 in: "header"
918 })
919 })
920
921 it("rejects incompatible __proto__ security schemes", () => {
922 const Api = makeSecurityApi(ProtoHeaderAuth, ProtoQueryAuth)
923
924 assert.throws(() => OpenApi.fromApi(Api), /Conflicting OpenAPI security scheme: __proto__/)
925 })
926})