Files
zopu-code/repos/effect-smol/packages/effect/test/unstable/httpapi/OpenApi.test.ts

293 lines
9.0 KiB
TypeScript

import { assert, describe, it } from "@effect/vitest"
import { type Context, Schema } from "effect"
import {
HttpApi,
HttpApiEndpoint,
HttpApiGroup,
HttpApiMiddleware,
HttpApiSchema,
HttpApiSecurity,
OpenApi
} from "effect/unstable/httpapi"
class HeaderAuthA extends HttpApiMiddleware.Service<HeaderAuthA>()("HeaderAuthA", {
security: {
auth: HttpApiSecurity.apiKey({ key: "x-api-key" })
}
}) {}
class HeaderAuthB extends HttpApiMiddleware.Service<HeaderAuthB>()("HeaderAuthB", {
security: {
auth: HttpApiSecurity.apiKey({ key: "x-api-key" })
}
}) {}
class HeaderAuthUpper extends HttpApiMiddleware.Service<HeaderAuthUpper>()("HeaderAuthUpper", {
security: {
auth: HttpApiSecurity.apiKey({ key: "X-API-Key" })
}
}) {}
class QueryAuth extends HttpApiMiddleware.Service<QueryAuth>()("QueryAuth", {
security: {
auth: HttpApiSecurity.apiKey({ key: "api_key", in: "query" })
}
}) {}
class TokenAuthUpper extends HttpApiMiddleware.Service<TokenAuthUpper>()("TokenAuthUpper", {
security: {
auth: HttpApiSecurity.http({ scheme: "Token" })
}
}) {}
class TokenAuthLower extends HttpApiMiddleware.Service<TokenAuthLower>()("TokenAuthLower", {
security: {
auth: HttpApiSecurity.http({ scheme: "token" })
}
}) {}
class ProtoHeaderAuth extends HttpApiMiddleware.Service<ProtoHeaderAuth>()("ProtoHeaderAuth", {
security: {
["__proto__"]: HttpApiSecurity.apiKey({ key: "x-api-key" })
}
}) {}
class ProtoQueryAuth extends HttpApiMiddleware.Service<ProtoQueryAuth>()("ProtoQueryAuth", {
security: {
["__proto__"]: HttpApiSecurity.apiKey({ key: "api_key", in: "query" })
}
}) {}
const makeSecurityApi = (
first: Context.Key<any, any>,
second: Context.Key<any, any>
) =>
HttpApi.make("Api").add(
HttpApiGroup.make("test").add(
HttpApiEndpoint.get("first", "/first").middleware(first),
HttpApiEndpoint.get("second", "/second").middleware(second)
)
)
describe("OpenApi", () => {
it("preserves every declared payload content type for normalized equivalents", () => {
const profileA = "Application/Vnd.Effect+JSON; Profile=A"
const profileB = "application/vnd.effect+json; profile=b"
const Api = HttpApi.make("Api").add(
HttpApiGroup.make("test").add(
HttpApiEndpoint.post("create", "/create", {
payload: [
Schema.Struct({ a: Schema.String }).pipe(HttpApiSchema.asJson({ contentType: profileA })),
Schema.Struct({ b: Schema.String }).pipe(HttpApiSchema.asJson({ contentType: profileB }))
]
})
)
)
const spec = OpenApi.fromApi(Api)
const content = spec.paths["/create"]?.post?.requestBody?.content
assert.isDefined(content)
assert.property(content, profileA)
assert.property(content, profileB)
assert.deepStrictEqual(content[profileA]?.schema, {
type: "object",
properties: { a: { type: "string" } },
required: ["a"],
additionalProperties: false
})
assert.deepStrictEqual(content[profileB]?.schema, {
type: "object",
properties: { b: { type: "string" } },
required: ["b"],
additionalProperties: false
})
})
it("emits buffered and stream successes with the same status", () => {
const Api = HttpApi.make("Api").add(
HttpApiGroup.make("test").add(
HttpApiEndpoint.get("chat", "/chat", {
success: [
Schema.Struct({ message: Schema.String }),
HttpApiSchema.StreamSse({
events: Schema.Struct({ event: Schema.String, data: Schema.String }),
error: Schema.Struct({ reason: Schema.String })
})
]
})
)
)
const spec = OpenApi.fromApi(Api)
const content = spec.paths["/chat"]?.get?.responses[200]?.content
assert.isNotNull(content)
assert.property(content, "application/json")
assert.property(content, "text/event-stream")
const streamExtension = content?.["text/event-stream"]?.["x-effect-stream"]
assert.isNotNull(streamExtension)
if (streamExtension?.encoding !== "sse") {
throw new Error("Expected SSE stream extension")
}
assert.strictEqual(
streamExtension.failureEvent,
"effect/httpapi/stream/failure"
)
assert.property(streamExtension, "causeSchema")
assert.property(streamExtension, "errorSchema")
})
it("preserves the data schema identifier for SSE streams", () => {
const Event = Schema.Struct({
kind: Schema.String,
payload: Schema.String
}).annotate({ identifier: "MyEvent" })
const Api = HttpApi.make("Api").add(
HttpApiGroup.make("test").add(
HttpApiEndpoint.get("stream", "/stream", {
success: [HttpApiSchema.StreamSse({ data: Event })]
})
)
)
const spec = OpenApi.fromApi(Api)
const schemas = spec.components?.schemas
// The decoded data schema keeps its identifier.
assert.deepStrictEqual(schemas?.MyEvent, {
type: "object",
properties: {
kind: { type: "string" },
payload: { type: "string" }
},
required: ["kind", "payload"],
additionalProperties: false
})
})
it("rejects duplicate method and path pairs", () => {
const Api = HttpApi.make("Api").add(
HttpApiGroup.make("test").add(
HttpApiEndpoint.get("first", "/shared"),
HttpApiEndpoint.get("second", "/shared")
)
)
assert.throws(() => OpenApi.fromApi(Api), /Duplicate OpenAPI operation for GET \/shared/)
})
it("rejects equivalent templated method and path pairs", () => {
const Api = HttpApi.make("Api").add(
HttpApiGroup.make("test").add(
HttpApiEndpoint.get("first", "/users/:id"),
HttpApiEndpoint.get("second", "/users/:userId")
)
)
assert.throws(() => OpenApi.fromApi(Api), /Duplicate OpenAPI operation for GET \/users\/\{userId\}/)
})
it("rejects duplicate operation identifiers", () => {
const Api = HttpApi.make("Api").add(
HttpApiGroup.make("test").add(
HttpApiEndpoint.get("first", "/first").annotate(OpenApi.Identifier, "shared"),
HttpApiEndpoint.get("second", "/second").annotate(OpenApi.Identifier, "shared")
)
)
assert.throws(() => OpenApi.fromApi(Api), /Duplicate OpenAPI operationId: shared/)
})
it("allows operations without identifiers", () => {
const removeOperationId = (operation: Record<string, any>) => {
const transformed = { ...operation }
delete transformed.operationId
return transformed
}
const Api = HttpApi.make("Api").add(
HttpApiGroup.make("test").add(
HttpApiEndpoint.get("first", "/first").annotate(OpenApi.Transform, removeOperationId),
HttpApiEndpoint.get("second", "/second").annotate(OpenApi.Transform, removeOperationId)
)
)
const spec = OpenApi.fromApi(Api)
const first = spec.paths["/first"]?.get
const second = spec.paths["/second"]?.get
assert.isDefined(first)
assert.isDefined(second)
assert.isFalse(Object.hasOwn(first, "operationId"))
assert.isFalse(Object.hasOwn(second, "operationId"))
})
it("rejects incompatible security schemes with the same name", () => {
const Api = makeSecurityApi(HeaderAuthA, QueryAuth)
assert.throws(() => OpenApi.fromApi(Api), /Conflicting OpenAPI security scheme: auth/)
})
it("deduplicates equivalent security schemes with the same name", () => {
const Api = makeSecurityApi(HeaderAuthA, HeaderAuthB)
const spec = OpenApi.fromApi(Api)
assert.deepStrictEqual(spec.components.securitySchemes.auth, {
type: "apiKey",
name: "x-api-key",
in: "header"
})
})
it("deduplicates API key header names case-insensitively", () => {
const Api = makeSecurityApi(HeaderAuthUpper, HeaderAuthA)
const spec = OpenApi.fromApi(Api)
assert.deepStrictEqual(spec.components.securitySchemes.auth, {
type: "apiKey",
name: "X-API-Key",
in: "header"
})
})
it("deduplicates HTTP security schemes case-insensitively", () => {
const Api = makeSecurityApi(TokenAuthUpper, TokenAuthLower)
const spec = OpenApi.fromApi(Api)
assert.deepStrictEqual(spec.components.securitySchemes.auth, {
type: "http",
scheme: "Token"
})
})
it("stores __proto__ security scheme names as own properties", () => {
const Api = HttpApi.make("Api").add(
HttpApiGroup.make("test").add(
HttpApiEndpoint.get("first", "/first").middleware(ProtoHeaderAuth)
)
)
const spec = OpenApi.fromApi(Api)
const schemes = spec.components.securitySchemes
assert.isTrue(Object.hasOwn(schemes, "__proto__"))
assert.deepStrictEqual(Object.keys(schemes), ["__proto__"])
assert.strictEqual(Object.getPrototypeOf(schemes), Object.prototype)
assert.deepStrictEqual(schemes["__proto__"], {
type: "apiKey",
name: "x-api-key",
in: "header"
})
})
it("rejects incompatible __proto__ security schemes", () => {
const Api = makeSecurityApi(ProtoHeaderAuth, ProtoQueryAuth)
assert.throws(() => OpenApi.fromApi(Api), /Conflicting OpenAPI security scheme: __proto__/)
})
})