293 lines
9.0 KiB
TypeScript
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__/)
|
|
})
|
|
})
|