t3-code-android-nightly/.repos/effect-smol/packages/tools/openapi-generator/test/OpenApiGenerator.test.ts
Julius Marminge 6f9cea00ae
chore(refs): sync Effect and Alchemy references to 4.0.1 and beta.80 (#16170)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 13:22:30 -07:00

3320 lines
110 KiB
TypeScript

import * as OpenApiGenerator from "@effect/openapi-generator/OpenApiGenerator"
import { assert, describe, it } from "@effect/vitest"
import * as Effect from "effect/Effect"
import type { OpenAPISpec, OpenAPISpecOperation, OpenAPISpecPathItem } from "effect/http-api/OpenApi"
import type * as JsonSchema from "effect/JsonSchema"
import { spawnSync } from "node:child_process"
import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
import { dirname, join } from "node:path"
import { fileURLToPath } from "node:url"
// These integration checks start a fresh TypeScript compiler under CI load.
const compilationTimeout = 60_000
function assertRuntime(spec: OpenAPISpec, expected: string) {
return Effect.gen(function*() {
const generator = yield* OpenApiGenerator.OpenApiGenerator
const result = yield* generator.generate(spec, {
name: "TestClient",
format: "httpclient"
})
// console.log(result)
assert.strictEqual(result, expected)
}).pipe(
Effect.provide(OpenApiGenerator.layerTransformerSchema)
)
}
function assertTypeOnly(spec: OpenAPISpec, expected: string) {
return Effect.gen(function*() {
const generator = yield* OpenApiGenerator.OpenApiGenerator
const result = yield* generator.generate(spec, {
name: "TestClient",
format: "httpclient-type-only"
})
// console.log(result)
assert.strictEqual(result, expected)
}).pipe(
Effect.provide(OpenApiGenerator.layerTransformerTs)
)
}
function assertRuntimeIncludes(
spec: OpenAPISpec,
includes: ReadonlyArray<string>,
excludes: ReadonlyArray<string> = []
) {
return Effect.gen(function*() {
const generator = yield* OpenApiGenerator.OpenApiGenerator
const result = yield* generator.generate(spec, {
name: "TestClient",
format: "httpclient"
})
for (const expected of includes) {
assert.include(result, expected)
}
for (const excluded of excludes) {
assert.notInclude(result, excluded)
}
}).pipe(
Effect.provide(OpenApiGenerator.layerTransformerSchema)
)
}
function assertTypeOnlyIncludes(
spec: OpenAPISpec,
includes: ReadonlyArray<string>,
excludes: ReadonlyArray<string> = []
) {
return Effect.gen(function*() {
const generator = yield* OpenApiGenerator.OpenApiGenerator
const result = yield* generator.generate(spec, {
name: "TestClient",
format: "httpclient-type-only"
})
for (const expected of includes) {
assert.include(result, expected)
}
for (const excluded of excludes) {
assert.notInclude(result, excluded)
}
}).pipe(
Effect.provide(OpenApiGenerator.layerTransformerTs)
)
}
function assertGeneratedClientsCompile(
spec: OpenAPISpec,
options: {
readonly exactOptionalPropertyTypes?: boolean | undefined
readonly formats?: ReadonlyArray<"httpclient" | "httpclient-type-only"> | undefined
readonly usage?: string | undefined
} = {}
) {
const generate = (
format: "httpclient" | "httpclient-type-only",
layer: typeof OpenApiGenerator.layerTransformerSchema
) =>
Effect.gen(function*() {
const generator = yield* OpenApiGenerator.OpenApiGenerator
return yield* generator.generate(spec, { name: "TestClient", format })
}).pipe(Effect.provide(layer))
return Effect.gen(function*() {
const formats = options.formats ?? ["httpclient", "httpclient-type-only"]
const clients = yield* Effect.all(formats.map((format) =>
generate(
format,
format === "httpclient"
? OpenApiGenerator.layerTransformerSchema
: OpenApiGenerator.layerTransformerTs
)
))
const directory = mkdtempSync(join(dirname(fileURLToPath(import.meta.url)), ".generated-clients-"))
try {
const files = clients.map((client, index) => {
const path = join(directory, `Client${index}.ts`)
writeFileSync(path, `${client}\n${options.usage ?? ""}`)
return path
})
const configPath = join(directory, "tsconfig.json")
writeFileSync(
configPath,
JSON.stringify({
compilerOptions: {
allowImportingTsExtensions: true,
exactOptionalPropertyTypes: options.exactOptionalPropertyTypes ?? true,
lib: ["ESNext", "DOM"],
module: "NodeNext",
noEmit: true,
skipLibCheck: true,
strict: true,
target: "ES2022",
types: [],
verbatimModuleSyntax: true
},
files
})
)
const repositoryRoot = join(dirname(fileURLToPath(import.meta.url)), "../../../..")
const result = spawnSync(join(repositoryRoot, "node_modules/.bin/tsc"), ["-p", configPath, "--pretty", "false"], {
cwd: repositoryRoot,
encoding: "utf8"
})
assert.strictEqual(result.status, 0, `${result.stdout}${result.stderr}`)
} finally {
rmSync(directory, { recursive: true, force: true })
}
})
}
function assertHttpApiIncludes(
spec: OpenAPISpec,
includes: ReadonlyArray<string>,
excludes: ReadonlyArray<string> = []
) {
return Effect.gen(function*() {
const generator = yield* OpenApiGenerator.OpenApiGenerator
const result = yield* generator.generate(spec, {
name: "TestClient",
format: "httpapi"
})
for (const expected of includes) {
assert.include(result, expected)
}
for (const excluded of excludes) {
assert.notInclude(result, excluded)
}
}).pipe(
Effect.provide(OpenApiGenerator.layerTransformerSchema)
)
}
function assertHttpApiWithWarnings(
spec: OpenAPISpec,
options: {
readonly includes?: ReadonlyArray<string> | undefined
readonly excludes?: ReadonlyArray<string> | undefined
readonly occurrences?:
| ReadonlyArray<{
readonly substring: string
readonly count: number
}>
| undefined
readonly warnings: ReadonlyArray<
Pick<OpenApiGenerator.OpenApiGeneratorWarning, "code" | "path" | "method" | "operationId">
>
}
) {
return Effect.gen(function*() {
const generator = yield* OpenApiGenerator.OpenApiGenerator
const warnings: Array<OpenApiGenerator.OpenApiGeneratorWarning> = []
const result = yield* generator.generate(spec, {
name: "TestClient",
format: "httpapi",
onWarning: (warning) => {
warnings.push(warning)
}
})
for (const expected of options.includes ?? []) {
assert.include(result, expected)
}
for (const excluded of options.excludes ?? []) {
assert.notInclude(result, excluded)
}
for (const { substring, count } of options.occurrences ?? []) {
assert.strictEqual(result.split(substring).length - 1, count)
}
assert.deepStrictEqual(
warnings.map((warning) => ({
code: warning.code,
path: warning.path,
method: warning.method,
operationId: warning.operationId
})),
options.warnings
)
}).pipe(
Effect.provide(OpenApiGenerator.layerTransformerSchema)
)
}
function assertRuntimeStableWithWarnings(
spec: OpenAPISpec,
expectedWarnings: ReadonlyArray<
Pick<OpenApiGenerator.OpenApiGeneratorWarning, "code" | "path" | "method" | "operationId">
>
) {
return Effect.gen(function*() {
const generator = yield* OpenApiGenerator.OpenApiGenerator
const warnings: Array<OpenApiGenerator.OpenApiGeneratorWarning> = []
const baseline = yield* generator.generate(spec, {
name: "TestClient",
format: "httpclient"
})
const withWarnings = yield* generator.generate(spec, {
name: "TestClient",
format: "httpclient",
onWarning: (warning) => {
warnings.push(warning)
}
})
assert.strictEqual(withWarnings, baseline)
assert.deepStrictEqual(
warnings.map((warning) => ({
code: warning.code,
path: warning.path,
method: warning.method,
operationId: warning.operationId
})),
expectedWarnings
)
}).pipe(
Effect.provide(OpenApiGenerator.layerTransformerSchema)
)
}
function assertTypeOnlyStableWithWarnings(
spec: OpenAPISpec,
expectedWarnings: ReadonlyArray<
Pick<OpenApiGenerator.OpenApiGeneratorWarning, "code" | "path" | "method" | "operationId">
>
) {
return Effect.gen(function*() {
const generator = yield* OpenApiGenerator.OpenApiGenerator
const warnings: Array<OpenApiGenerator.OpenApiGeneratorWarning> = []
const baseline = yield* generator.generate(spec, {
name: "TestClient",
format: "httpclient-type-only"
})
const withWarnings = yield* generator.generate(spec, {
name: "TestClient",
format: "httpclient-type-only",
onWarning: (warning) => {
warnings.push(warning)
}
})
assert.strictEqual(withWarnings, baseline)
assert.deepStrictEqual(
warnings.map((warning) => ({
code: warning.code,
path: warning.path,
method: warning.method,
operationId: warning.operationId
})),
expectedWarnings
)
}).pipe(
Effect.provide(OpenApiGenerator.layerTransformerTs)
)
}
const regressionSpec: OpenAPISpec = {
openapi: "3.1.0",
info: {
title: "Regression API",
version: "1.0.0"
},
paths: {
"/users/{id}": {
get: {
operationId: "getUser",
summary: "Get user",
description: "Read a user",
parameters: [
{
name: "id",
in: "path",
schema: {
type: "string"
},
required: true
},
{
name: "filter",
in: "query",
schema: {
type: "string"
},
required: false
},
{
name: "trace-id",
in: "header",
schema: {
type: "string"
},
required: false
},
{
name: "session",
in: "cookie",
schema: {
type: "string"
},
required: false
}
],
responses: {
default: {
description: "Default user response",
content: {
"application/json": {
schema: {
type: "object",
properties: {
id: {
type: "string"
}
},
required: ["id"],
additionalProperties: false
}
}
}
}
} as any,
tags: ["Users"],
security: [{ apiKey: [] }]
}
}
},
components: {
schemas: {},
securitySchemes: {
apiKey: {
type: "apiKey",
name: "x-api-key",
in: "header"
}
}
},
security: [],
tags: [{ name: "Users" }]
}
const responseMatchingSpec: OpenAPISpec = {
openapi: "3.1.0",
info: {
title: "Response matching API",
version: "1.0.0"
},
paths: {
"/auth/device/token": {
post: {
operationId: "pollDeviceToken",
parameters: [],
tags: ["ResponseMatching"],
security: [],
responses: {
400: {
description: "Problem details or OAuth polling state",
content: {
"application/problem+json": {
schema: {
type: "object",
properties: {
kind: { const: "InvalidRequestError" },
code: { type: "string" }
},
required: ["kind", "code"],
additionalProperties: false
}
},
"application/json": {
schema: {
type: "object",
properties: {
kind: { const: "DeviceTokenOAuthError" },
error: { type: "string" }
},
required: ["kind", "error"],
additionalProperties: false
}
}
}
}
}
}
},
"/archive": {
get: {
operationId: "downloadArchive",
parameters: [],
tags: ["ResponseMatching"],
security: [],
responses: {
200: {
description: "Archive",
content: {
"application/zip": {
schema: { type: "string", format: "binary" }
}
}
},
404: {
description: "Not found",
content: {
"application/problem+json": {
schema: {
type: "object",
properties: {
title: { type: "string" }
},
required: ["title"],
additionalProperties: false
}
}
}
}
}
}
},
"/avatar": {
get: {
operationId: "downloadAvatar",
parameters: [],
tags: ["ResponseMatching"],
security: [],
responses: {
200: {
description: "Avatar",
content: {
"image/png": {
schema: { type: "string" }
}
}
}
}
}
},
"/custom-binary": {
get: {
operationId: "downloadCustomBinary",
parameters: [],
tags: ["ResponseMatching"],
security: [],
responses: {
200: {
description: "Custom binary",
content: {
"application/vnd.example.data": {
schema: { type: "string", format: "binary" }
}
}
}
}
}
},
"/mixed-content": {
get: {
operationId: "downloadMixedContent",
parameters: [],
tags: ["ResponseMatching"],
security: [],
responses: {
200: {
description: "JSON metadata or binary content",
content: {
"application/json; charset=utf-8": {
schema: {
type: "object",
properties: { href: { type: "string" } },
required: ["href"],
additionalProperties: false
}
},
"application/octet-stream; boundary=payload": {
schema: { type: "string" }
}
}
},
400: {
description: "Invalid request",
content: {
"application/problem+json; charset=utf-8": {
schema: {
type: "object",
properties: { title: { type: "string" } },
required: ["title"],
additionalProperties: false
}
}
}
}
}
}
},
"/resources/{id}": {
head: {
operationId: "checkResource",
parameters: [{
name: "id",
in: "path",
required: true,
schema: { type: "string" }
}],
tags: ["ResponseMatching"],
security: [],
responses: {
200: { description: "Exists" },
404: { description: "Not found" },
500: { description: "Server error" }
}
}
}
},
components: {
schemas: {},
securitySchemes: {}
},
security: [],
tags: [{ name: "ResponseMatching" }]
}
const voidSuccessSpec: OpenAPISpec = {
openapi: "3.1.0",
info: {
title: "Void success API",
version: "1.0.0"
},
paths: {
"/mixed": {
get: {
operationId: "mixedSuccess",
parameters: [],
tags: ["VoidSuccess"],
security: [],
responses: {
200: {
description: "JSON value",
content: {
"application/json": {
schema: { type: "string" }
}
}
},
204: { description: "No content" }
}
}
},
"/cached": {
get: {
operationId: "cached",
parameters: [],
tags: ["VoidSuccess"],
security: [],
responses: {
304: { description: "Not modified" }
}
}
}
},
components: {
schemas: {},
securitySchemes: {}
},
security: [],
tags: [{ name: "VoidSuccess" }]
}
function multipartSpec(schema: JsonSchema.JsonSchema, schemas: JsonSchema.Definitions = {}): OpenAPISpec {
return {
openapi: "3.1.0",
info: { title: "Upload API", version: "1.0.0" },
paths: {
"/upload": {
post: {
operationId: "upload",
parameters: [],
requestBody: {
required: true,
content: { "multipart/form-data": { schema } }
},
responses: { "204": { description: "Uploaded" } },
tags: ["Upload"],
security: []
}
}
},
components: { schemas, securitySchemes: {} },
security: [],
tags: [{ name: "Upload" }]
}
}
// spawnSync blocks this worker, so compilation must not consume another test's deadline.
describe("OpenApiGenerator", { concurrent: false }, () => {
describe("schema", () => {
it.effect("get operation", () =>
assertRuntime(
{
openapi: "3.1.0",
info: {
title: "Test API",
version: "1.0.0"
},
paths: {
"/users/{id}": {
get: {
operationId: "getUser",
parameters: [
{
name: "id",
in: "path",
schema: {
type: "string"
},
required: true
}
],
responses: {
200: {
description: "User retrieved successfully",
content: {
"application/json": {
schema: {
type: "object",
properties: {
id: {
type: "string"
},
name: {
type: "string"
}
},
required: ["id", "name"],
additionalProperties: false,
description: "User object"
}
}
}
}
},
tags: ["Users"],
security: []
}
}
},
components: {
schemas: {},
securitySchemes: {}
},
security: [],
tags: []
},
`import * as Data from "effect/Data"
import * as Effect from "effect/Effect"
import type { SchemaError } from "effect/Schema"
import * as Schema from "effect/Schema"
import type * as HttpClient from "effect/http/HttpClient"
import * as HttpClientError from "effect/http/HttpClientError"
import * as HttpClientRequest from "effect/http/HttpClientRequest"
import * as HttpClientResponse from "effect/http/HttpClientResponse"
// schemas
export type GetUser200 = { readonly "id": string, readonly "name": string }
export const GetUser200 = Schema.Struct({ "id": Schema.String, "name": Schema.String }).annotate({ "description": "User object" })
export interface OperationConfig {
/**
* Whether or not the response should be included in the value returned from
* an operation.
*
* If set to \`true\`, a tuple of \`[A, HttpClientResponse]\` will be returned,
* where \`A\` is the success type of the operation.
*
* If set to \`false\`, only the success type of the operation will be returned.
*/
readonly includeResponse?: boolean | undefined
}
/**
* A utility type which optionally includes the response in the return result
* of an operation based upon the value of the \`includeResponse\` configuration
* option.
*/
export type WithOptionalResponse<A, Config extends OperationConfig | undefined> = Config extends {
readonly includeResponse: true
} ? [A, HttpClientResponse.HttpClientResponse]
: Config extends { readonly includeResponse?: false | undefined } | undefined ? A
: A | [A, HttpClientResponse.HttpClientResponse]
export const make = (
httpClient: HttpClient.HttpClient,
options: {
readonly transformClient?: ((client: HttpClient.HttpClient) => Effect.Effect<HttpClient.HttpClient>) | undefined
} = {}
): TestClient => {
const unexpectedStatus = (response: HttpClientResponse.HttpClientResponse) =>
Effect.flatMap(
Effect.orElseSucceed(response.json, () => "Unexpected status code"),
(description) =>
Effect.fail(
new HttpClientError.HttpClientError({
reason: new HttpClientError.StatusCodeError({
request: response.request,
response,
description: typeof description === "string" ? description : JSON.stringify(description),
}),
}),
),
)
const withResponse = <Config extends OperationConfig>(config: Config | undefined) => (
f: (response: HttpClientResponse.HttpClientResponse) => Effect.Effect<any, any>,
): (request: HttpClientRequest.HttpClientRequest) => Effect.Effect<any, any> => {
const withOptionalResponse = (
config?.includeResponse
? (response: HttpClientResponse.HttpClientResponse) => Effect.map(f(response), (a) => [a, response])
: (response: HttpClientResponse.HttpClientResponse) => f(response)
) as any
return options?.transformClient
? (request) =>
Effect.flatMap(
Effect.flatMap(options.transformClient!(httpClient), (client) => client.execute(request)),
withOptionalResponse
)
: (request) => Effect.flatMap(httpClient.execute(request), withOptionalResponse)
}
const __encodePathParam = encodeURIComponent
const __makePathRequest = (
method: (url: string) => HttpClientRequest.HttpClientRequest,
parameters: ReadonlyArray<string>,
getPath: () => string,
) => Effect.suspend(() => {
const fail = (description: string, cause?: unknown) => Effect.fail(
new HttpClientError.HttpClientError({
reason: new HttpClientError.InvalidUrlError({
request: method(""),
cause,
description,
}),
}),
)
if (parameters.some((value) => value === "" || /^(?:\\.|%2e){1,2}$/i.test(value))) {
return fail("Path parameters must be non-empty and cannot be dot segments")
}
let path: string
try {
path = getPath()
} catch (cause) {
return fail("Failed to encode path parameter", cause)
}
if (path.split("/").some((segment) => /^(?:\\.|%2e){1,2}$/i.test(segment))) {
return fail("Request paths cannot contain dot segments")
}
return Effect.succeed(method(path))
})
const decodeSuccess =
<Schema extends Schema.Constraint>(schema: Schema) =>
(response: HttpClientResponse.HttpClientResponse) =>
HttpClientResponse.schemaBodyJson(schema)(response)
const decodeError =
<const Tag extends string, Schema extends Schema.Constraint>(tag: Tag, schema: Schema) =>
(response: HttpClientResponse.HttpClientResponse) =>
Effect.flatMap(
HttpClientResponse.schemaBodyJson(schema)(response),
(cause) => Effect.fail(TestClientError(tag, cause, response)),
)
return {
httpClient,
"getUser": (id, options: Parameters<TestClient["getUser"]>[1]) => __makePathRequest(HttpClientRequest.get, [id], () => "/users/" + __encodePathParam(id) + "").pipe(
Effect.flatMap((request) => request.pipe(
withResponse(options?.config)(HttpClientResponse.matchStatus({
"2xx": decodeSuccess(GetUser200),
orElse: unexpectedStatus
}))
))
)
}
}
export interface TestClient {
readonly httpClient: HttpClient.HttpClient
readonly "getUser": {
<Config extends OperationConfig | undefined = undefined>(id: string, options: { readonly config: Config }): Effect.Effect<WithOptionalResponse<typeof GetUser200.Type, Config>, HttpClientError.HttpClientError | SchemaError>;
<Config extends OperationConfig | undefined = undefined>(id: string, options: { readonly config?: Config | undefined } | undefined): Effect.Effect<WithOptionalResponse<typeof GetUser200.Type, Config | undefined>, HttpClientError.HttpClientError | SchemaError>;
}
}
export interface TestClientError<Tag extends string, E> {
readonly _tag: Tag
readonly request: HttpClientRequest.HttpClientRequest
readonly response: HttpClientResponse.HttpClientResponse
readonly cause: E
}
class TestClientErrorImpl extends Data.Error<{
_tag: string
cause: any
request: HttpClientRequest.HttpClientRequest
response: HttpClientResponse.HttpClientResponse
}> {}
export const TestClientError = <Tag extends string, E>(
tag: Tag,
cause: E,
response: HttpClientResponse.HttpClientResponse,
): TestClientError<Tag, E> =>
new TestClientErrorImpl({
_tag: tag,
cause,
response,
request: response.request,
}) as any`
))
it.effect("resolves JSON Pointer-escaped local references", () =>
assertRuntimeIncludes(
{
openapi: "3.1.0",
info: {
title: "Test API",
version: "1.0.0"
},
paths: {
"/widgets": {
get: {
operationId: "listWidgets",
parameters: [
{ $ref: "#/components/parameters/Page~1Limit" } as any,
{ $ref: "#/components/parameters/Tilde~0Name" } as any
],
responses: {
204: {
description: "No content"
}
},
tags: ["Widgets"],
security: []
}
}
},
components: {
schemas: {},
parameters: {
"Page/Limit": {
name: "limit",
in: "query",
required: false,
schema: { type: "integer" }
},
"Tilde~Name": {
name: "tilde",
in: "query",
required: false,
schema: { type: "string" }
}
}
} as any,
security: [],
tags: [{ name: "Widgets" }]
},
[
`readonly "limit"?: number`,
`readonly "tilde"?: string`,
`HttpClientRequest.setUrlParams({ "limit": options?.params?.["limit"] as any, "tilde": options?.params?.["tilde"] as any })`
]
))
it.effect("sse operation decodes event payload from json string", () =>
assertRuntimeIncludes(
{
openapi: "3.1.0",
info: {
title: "Test API",
version: "1.0.0"
},
paths: {
"/events": {
get: {
operationId: "streamEvents",
parameters: [],
responses: {
200: {
description: "Events streamed successfully",
content: {
"text/event-stream": {
schema: {
type: "object",
properties: {
type: {
type: "string"
},
value: {
type: "string"
}
},
required: ["type", "value"],
additionalProperties: false
}
}
}
}
},
tags: ["Events"],
security: []
}
}
},
components: {
schemas: {},
securitySchemes: {}
},
security: [],
tags: []
},
[
`import * as Sse from "effect/encoding/Sse"`,
`readonly "streamEventsSse": () => Stream.Stream<{ readonly event: string; readonly id: string | undefined; readonly data: typeof StreamEvents200Sse.Type }, HttpClientError.HttpClientError | SchemaError | Sse.Retry | Sse.SseError, typeof StreamEvents200Sse.DecodingServices>`,
`"streamEventsSse": () => HttpClientRequest.get("/events").pipe(`,
`sseRequest(StreamEvents200Sse)`,
`schema: Schema.ConstraintDecoder<Type, DecodingServices>`
]
))
it.effect("annotated sse operation decodes the full event schema", () =>
assertRuntimeIncludes(
{
openapi: "3.1.0",
info: {
title: "Test API",
version: "1.0.0"
},
paths: {
"/events": {
get: {
operationId: "streamEvents",
parameters: [],
responses: {
200: {
description: "Events streamed successfully",
content: {
"text/event-stream": {
schema: {
type: "object",
properties: {
id: {
anyOf: [{ type: "string" }, { type: "null" }]
},
event: { const: "message" },
data: { type: "string" }
},
required: ["id", "event", "data"],
additionalProperties: false
},
"x-effect-stream": {
encoding: "sse",
errorSchema: {},
causeSchema: {},
failureEvent: "effect/http-api/stream/failure"
}
}
}
}
},
tags: ["Events"],
security: []
}
}
},
components: {
schemas: {},
securitySchemes: {}
},
security: [],
tags: []
},
[
`"id": Schema.optionalKey(Schema.String), "event": Schema.Literal("message"), "data": Schema.String`,
`"id": Schema.optionalKey(Schema.String), "event": Schema.Literal("effect/http-api/stream/failure"), "data": Schema.String`,
`readonly "streamEventsSse": () => Stream.Stream<typeof StreamEvents200Sse.Type, HttpClientError.HttpClientError | SchemaError | Sse.Retry | Sse.SseError, typeof StreamEvents200Sse.DecodingServices>`,
`sseEventRequest(StreamEvents200Sse)`,
`Stream.pipeThroughChannel(Sse.decodeSchema(schema))`
]
))
it.effect("form-urlencoded request body generates bodyUrlParams", () =>
assertRuntimeIncludes(
{
openapi: "3.1.0",
info: {
title: "Test API",
version: "1.0.0"
},
paths: {
"/auth/token": {
post: {
operationId: "issueToken",
parameters: [],
requestBody: {
required: true,
content: {
"application/x-www-form-urlencoded": {
schema: {
type: "object",
properties: {
grant_type: { type: "string" },
client_id: { type: "string" }
},
required: ["grant_type", "client_id"],
additionalProperties: false
}
}
}
} as any,
responses: {
200: {
description: "Token response",
content: {
"application/json": {
schema: {
type: "object",
properties: {
access_token: { type: "string" }
},
required: ["access_token"],
additionalProperties: false
}
}
}
}
},
tags: ["Auth"],
security: []
}
}
},
components: {
schemas: {},
securitySchemes: {}
},
security: [],
tags: []
},
[
`HttpClientRequest.bodyUrlParams(options.payload as any)`,
`readonly payload: typeof IssueTokenRequestFormUrlEncoded.Encoded`
]
))
it.effect("preserves response variants and routes binary and bodiless statuses", () =>
assertRuntimeIncludes(responseMatchingSpec, [
`export const PollDeviceToken400 = Schema.Union(`,
`Schema.Literal("InvalidRequestError")`,
`Schema.Literal("DeviceTokenOAuthError")`,
`"400": decodeError("PollDeviceToken400", PollDeviceToken400)`,
`export type DownloadArchive404 = { readonly "title": string }`,
`const decodeBinary = (response: HttpClientResponse.HttpClientResponse) =>`,
`Effect.map(response.arrayBuffer, (buffer) => new Uint8Array(buffer))`,
`"2xx": decodeBinary`,
`readonly "downloadArchive": {`,
`<Config extends OperationConfig | undefined = undefined>(options: { readonly config: Config }): Effect.Effect<WithOptionalResponse<Uint8Array, Config>`,
`<Config extends OperationConfig | undefined = undefined>(options: { readonly config?: Config | undefined } | undefined): Effect.Effect<WithOptionalResponse<Uint8Array, Config | undefined>`,
`readonly "downloadArchiveStream": () => Stream.Stream<Uint8Array, HttpClientError.HttpClientError>`,
`readonly "downloadAvatarStream": () => Stream.Stream<Uint8Array, HttpClientError.HttpClientError>`,
`"downloadAvatar": (options: Parameters<TestClient["downloadAvatar"]>[0]) => HttpClientRequest.get("/avatar").pipe(
withResponse(options?.config)(HttpClientResponse.matchStatus({
"2xx": decodeBinary`,
`readonly "downloadCustomBinaryStream": () => Stream.Stream<Uint8Array, HttpClientError.HttpClientError>`,
`"400": decodeError("DownloadMixedContent400", DownloadMixedContent400)`,
`readonly "downloadMixedContent": {`,
`<Config extends OperationConfig | undefined = undefined>(options: { readonly config: Config }): Effect.Effect<WithOptionalResponse<Uint8Array, Config>`,
`<Config extends OperationConfig | undefined = undefined>(options: { readonly config?: Config | undefined } | undefined): Effect.Effect<WithOptionalResponse<Uint8Array, Config | undefined>`,
`readonly "downloadMixedContentStream": () => Stream.Stream<Uint8Array, HttpClientError.HttpClientError>`,
`"200": () => Effect.void`,
`"404": decodeVoidError("404")`,
`"500": decodeVoidError("500")`,
`TestClientError<"404", undefined>`,
`TestClientError<"500", undefined>`
], [
`"200": decodeSuccess(DownloadMixedContent200)`,
`typeof DownloadMixedContent200.Type | Uint8Array`
]))
it.effect("routes mixed and non-2xx bodiless success responses", () =>
assertRuntimeIncludes(voidSuccessSpec, [
`"200": decodeSuccess(MixedSuccess200)`,
`"204": () => Effect.void`,
`"304": () => Effect.void`,
`WithOptionalResponse<typeof MixedSuccess200.Type | void, Config>`
]))
})
describe("type-only", () => {
it.effect("get operation", () =>
assertTypeOnly(
{
openapi: "3.1.0",
info: {
title: "Test API",
version: "1.0.0"
},
paths: {
"/users/{id}": {
get: {
operationId: "getUser",
parameters: [
{
name: "id",
in: "path",
schema: {
type: "string"
},
required: true
}
],
responses: {
200: {
description: "User retrieved successfully",
content: {
"application/json": {
schema: {
type: "object",
properties: {
id: {
type: "string"
},
name: {
type: "string"
}
},
required: ["id", "name"],
additionalProperties: false
}
}
}
}
},
tags: ["Users"],
security: []
}
}
},
components: {
schemas: {},
securitySchemes: {}
},
security: [],
tags: []
},
`import * as Data from "effect/Data"
import * as Effect from "effect/Effect"
import type * as HttpClient from "effect/http/HttpClient"
import * as HttpClientError from "effect/http/HttpClientError"
import * as HttpClientRequest from "effect/http/HttpClientRequest"
import * as HttpClientResponse from "effect/http/HttpClientResponse"
// schemas
export type GetUser200 = { readonly "id": string, readonly "name": string }
export interface OperationConfig {
/**
* Whether or not the response should be included in the value returned from
* an operation.
*
* If set to \`true\`, a tuple of \`[A, HttpClientResponse]\` will be returned,
* where \`A\` is the success type of the operation.
*
* If set to \`false\`, only the success type of the operation will be returned.
*/
readonly includeResponse?: boolean | undefined
}
/**
* A utility type which optionally includes the response in the return result
* of an operation based upon the value of the \`includeResponse\` configuration
* option.
*/
export type WithOptionalResponse<A, Config extends OperationConfig | undefined> = Config extends {
readonly includeResponse: true
} ? [A, HttpClientResponse.HttpClientResponse]
: Config extends { readonly includeResponse?: false | undefined } | undefined ? A
: A | [A, HttpClientResponse.HttpClientResponse]
export const make = (
httpClient: HttpClient.HttpClient,
options: {
readonly transformClient?: ((client: HttpClient.HttpClient) => Effect.Effect<HttpClient.HttpClient>) | undefined
} = {}
): TestClient => {
const unexpectedStatus = (response: HttpClientResponse.HttpClientResponse) =>
Effect.flatMap(
Effect.orElseSucceed(response.json, () => "Unexpected status code"),
(description) =>
Effect.fail(
new HttpClientError.HttpClientError({
reason: new HttpClientError.StatusCodeError({
request: response.request,
response,
description: typeof description === "string" ? description : JSON.stringify(description),
}),
}),
),
)
const withResponse = <Config extends OperationConfig>(config: Config | undefined) => (
f: (response: HttpClientResponse.HttpClientResponse) => Effect.Effect<any, any>,
): (request: HttpClientRequest.HttpClientRequest) => Effect.Effect<any, any> => {
const withOptionalResponse = (
config?.includeResponse
? (response: HttpClientResponse.HttpClientResponse) => Effect.map(f(response), (a) => [a, response])
: (response: HttpClientResponse.HttpClientResponse) => f(response)
) as any
return options?.transformClient
? (request) =>
Effect.flatMap(
Effect.flatMap(options.transformClient!(httpClient), (client) => client.execute(request)),
withOptionalResponse
)
: (request) => Effect.flatMap(httpClient.execute(request), withOptionalResponse)
}
const __encodePathParam = encodeURIComponent
const __makePathRequest = (
method: (url: string) => HttpClientRequest.HttpClientRequest,
parameters: ReadonlyArray<string>,
getPath: () => string,
) => Effect.suspend(() => {
const fail = (description: string, cause?: unknown) => Effect.fail(
new HttpClientError.HttpClientError({
reason: new HttpClientError.InvalidUrlError({
request: method(""),
cause,
description,
}),
}),
)
if (parameters.some((value) => value === "" || /^(?:\\.|%2e){1,2}$/i.test(value))) {
return fail("Path parameters must be non-empty and cannot be dot segments")
}
let path: string
try {
path = getPath()
} catch (cause) {
return fail("Failed to encode path parameter", cause)
}
if (path.split("/").some((segment) => /^(?:\\.|%2e){1,2}$/i.test(segment))) {
return fail("Request paths cannot contain dot segments")
}
return Effect.succeed(method(path))
})
const decodeSuccess = <A>(response: HttpClientResponse.HttpClientResponse) =>
response.json as Effect.Effect<A, HttpClientError.HttpClientError>
const decodeVoid = (_response: HttpClientResponse.HttpClientResponse) =>
Effect.void
const decodeError =
<Tag extends string, E>(tag: Tag) =>
(
response: HttpClientResponse.HttpClientResponse,
): Effect.Effect<
never,
TestClientError<Tag, E> | HttpClientError.HttpClientError
> =>
Effect.flatMap(
response.json as Effect.Effect<E, HttpClientError.HttpClientError>,
(cause) => Effect.fail(TestClientError(tag, cause, response)),
)
const onRequest = <Config extends OperationConfig>(config: Config | undefined) => (
successCodes: ReadonlyArray<string>,
errorCodes?: Record<string, string>,
) => {
const cases: any = { orElse: unexpectedStatus }
for (const code of successCodes) {
cases[code] = decodeSuccess
}
if (errorCodes) {
for (const [code, tag] of Object.entries(errorCodes)) {
cases[code] = decodeError(tag)
}
}
if (successCodes.length === 0) {
cases["2xx"] = decodeVoid
}
return withResponse(config)(HttpClientResponse.matchStatus(cases) as any)
}
return {
httpClient,
"getUser": (id, options: Parameters<TestClient["getUser"]>[1]) => __makePathRequest(HttpClientRequest.get, [id], () => "/users/" + __encodePathParam(id) + "").pipe(
Effect.flatMap((request) => request.pipe(
onRequest(options?.config)(["2xx"])
))
)
}
}
export interface TestClient {
readonly httpClient: HttpClient.HttpClient
readonly "getUser": {
<Config extends OperationConfig | undefined = undefined>(id: string, options: { readonly config: Config }): Effect.Effect<WithOptionalResponse<GetUser200, Config>, HttpClientError.HttpClientError>;
<Config extends OperationConfig | undefined = undefined>(id: string, options: { readonly config?: Config | undefined } | undefined): Effect.Effect<WithOptionalResponse<GetUser200, Config | undefined>, HttpClientError.HttpClientError>;
}
}
export interface TestClientError<Tag extends string, E> {
readonly _tag: Tag
readonly request: HttpClientRequest.HttpClientRequest
readonly response: HttpClientResponse.HttpClientResponse
readonly cause: E
}
class TestClientErrorImpl extends Data.Error<{
_tag: string
cause: any
request: HttpClientRequest.HttpClientRequest
response: HttpClientResponse.HttpClientResponse
}> {}
export const TestClientError = <Tag extends string, E>(
tag: Tag,
cause: E,
response: HttpClientResponse.HttpClientResponse,
): TestClientError<Tag, E> =>
new TestClientErrorImpl({
_tag: tag,
cause,
response,
request: response.request,
}) as any`
))
it.effect("preserves response variants and routes binary and bodiless statuses", () =>
assertTypeOnlyIncludes(responseMatchingSpec, [
`export type PollDeviceToken400 =`,
`readonly "kind": "InvalidRequestError"`,
`readonly "kind": "DeviceTokenOAuthError"`,
`onRequest(options?.config)([], {"400":"PollDeviceToken400"})`,
`const decodeBinary = (response: HttpClientResponse.HttpClientResponse) =>`,
`onRequest(options?.config)([], {"404":"DownloadArchive404"}, {"binary":["2xx"],"voidSuccess":[],"voidError":[]})`,
`readonly "downloadArchive": {`,
`<Config extends OperationConfig | undefined = undefined>(options: { readonly config: Config }): Effect.Effect<WithOptionalResponse<Uint8Array, Config>`,
`<Config extends OperationConfig | undefined = undefined>(options: { readonly config?: Config | undefined } | undefined): Effect.Effect<WithOptionalResponse<Uint8Array, Config | undefined>`,
`readonly "downloadAvatarStream": () => Stream.Stream<Uint8Array, HttpClientError.HttpClientError>`,
`"downloadAvatar": (options: Parameters<TestClient["downloadAvatar"]>[0]) => HttpClientRequest.get("/avatar").pipe(
onRequest(options?.config)([], undefined, {"binary":["2xx"],"voidSuccess":[],"voidError":[]})`,
`readonly "downloadCustomBinaryStream": () => Stream.Stream<Uint8Array, HttpClientError.HttpClientError>`,
`import * as HttpClient from "effect/http/HttpClient"`,
`onRequest(options?.config)([], {"400":"DownloadMixedContent400"}, {"binary":["2xx"],"voidSuccess":[],"voidError":[]})`,
`readonly "downloadMixedContent": {`,
`<Config extends OperationConfig | undefined = undefined>(options: { readonly config: Config }): Effect.Effect<WithOptionalResponse<Uint8Array, Config>`,
`<Config extends OperationConfig | undefined = undefined>(options: { readonly config?: Config | undefined } | undefined): Effect.Effect<WithOptionalResponse<Uint8Array, Config | undefined>`,
`readonly "downloadMixedContentStream": () => Stream.Stream<Uint8Array, HttpClientError.HttpClientError>`,
`onRequest(options?.config)([], undefined, {"binary":[],"voidSuccess":["200"],"voidError":["404","500"]})`,
`cases[code] = decodeVoidError(code)`,
`TestClientError<"404", undefined>`,
`TestClientError<"500", undefined>`
], [
`DownloadMixedContent200 | Uint8Array`
]))
it.effect("routes mixed and non-2xx bodiless success responses", () =>
assertTypeOnlyIncludes(voidSuccessSpec, [
`onRequest(options?.config)(["200"], undefined, {"binary":[],"voidSuccess":["204"],"voidError":[]})`,
`onRequest(options?.config)([], undefined, {"binary":[],"voidSuccess":["304"],"voidError":[]})`,
`WithOptionalResponse<MixedSuccess200 | void, Config>`
]))
it.effect(
"emits compilable clients for schema-backed and type-only formats",
() => assertGeneratedClientsCompile(responseMatchingSpec),
compilationTimeout
)
})
describe("httpapi", () => {
it.effect("generates tagged groups with endpoint annotations and representable parameters", () =>
assertHttpApiIncludes(
{
openapi: "3.1.0",
info: {
title: "Test API",
version: "1.0.0",
summary: "Summary",
description: "Description"
},
paths: {
"/users/{id}": {
get: {
operationId: "getUser",
summary: "Get user",
description: "Read a user",
deprecated: true,
externalDocs: {
url: "https://example.com/get-user"
},
parameters: [
{
name: "id",
in: "path",
schema: { type: "string" },
required: true
},
{
name: "filter",
in: "query",
schema: { type: "string" },
required: false
},
{
name: "trace-id",
in: "header",
schema: { type: "string" },
required: false
}
],
responses: {
200: {
description: "User",
content: {
"application/json": {
schema: {
type: "object",
properties: {
id: { type: "string" }
},
required: ["id"],
additionalProperties: false
}
}
}
},
404: {
description: "Not found"
}
},
tags: ["Users"],
security: []
}
}
},
components: {
schemas: {},
securitySchemes: {}
},
security: [],
tags: [
{
name: "Users",
description: "User operations",
externalDocs: {
url: "https://example.com/users"
}
}
]
},
[
`import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, HttpApiSecurity, OpenApi } from "effect/http-api"`,
`export type GetUserPathParams = { readonly "id": string }`,
`export const GetUserPathParams = Schema.Struct({ "id": Schema.String })`,
`class UsersGroup extends HttpApiGroup.make("Users")`,
`.annotate(OpenApi.Description, "User operations")`,
`.annotate(OpenApi.ExternalDocs, {"url":"https://example.com/users"})`,
`HttpApiEndpoint.get("getUser", "/users/:id", { params: GetUserPathParams, query: GetUserQuery, headers: GetUserHeaders, success: GetUser200, error: HttpApiSchema.Empty(404) })`,
`.annotate(OpenApi.Identifier, "getUser")`,
`.annotate(OpenApi.Summary, "Get user")`,
`.annotate(OpenApi.Description, "Read a user")`,
`.annotate(OpenApi.Deprecated, true)`,
`.annotate(OpenApi.ExternalDocs, {"url":"https://example.com/get-user"})`,
`export class TestClient extends HttpApi.make("TestClient")`,
`.annotate(OpenApi.Title, "Test API")`,
`.annotate(OpenApi.Version, "1.0.0")`,
`.annotate(OpenApi.Summary, "Summary")`,
`.annotate(OpenApi.Description, "Description")`,
`.add(UsersGroup)`
],
[
`export class GetUserPathParams extends Schema.Class<GetUserPathParams>("GetUserPathParams")({ "id": Schema.String }) {}`
]
))
it.effect("includes path-level parameters in generated httpapi endpoints", () =>
assertHttpApiIncludes(
{
openapi: "3.1.0",
info: {
title: "Discord-style API",
version: "1.0.0"
},
paths: {
"/applications/{application_id}": {
parameters: [
{
name: "application_id",
in: "path",
schema: { type: "string" },
required: true
}
],
get: {
operationId: "getApplication",
parameters: [],
responses: {
200: {
description: "Application"
}
},
tags: ["Applications"],
security: []
}
}
},
components: {
schemas: {},
securitySchemes: {}
},
security: [],
tags: [{ name: "Applications" }]
},
[
`export type GetApplicationPathParams = { readonly "application_id": string }`,
`export const GetApplicationPathParams = Schema.Struct({ "application_id": Schema.String })`,
`HttpApiEndpoint.get("getApplication", "/applications/:application_id", { params: GetApplicationPathParams, success: HttpApiSchema.Empty(200) })`
],
[
`export class GetApplicationPathParams extends Schema.Class<GetApplicationPathParams>("GetApplicationPathParams")({ "application_id": Schema.String }) {}`
]
))
it.effect("creates top-level fallback group for untagged operations", () =>
assertHttpApiIncludes(
{
openapi: "3.1.0",
info: {
title: "Test API",
version: "1.0.0"
},
paths: {
"/health": {
get: {
operationId: "getHealth",
parameters: [],
responses: {
204: {
description: "No content"
}
},
tags: [] as any,
security: []
}
}
},
components: {
schemas: {},
securitySchemes: {}
},
security: [],
tags: []
},
[
`class DefaultGroup extends HttpApiGroup.make("default", { topLevel: true })`,
`HttpApiEndpoint.get("getHealth", "/health", { success: HttpApiSchema.Empty(204) })`,
`.add(DefaultGroup)`
]
))
it.effect("keeps the fallback group top-level when tagged default operations are present", () =>
assertHttpApiIncludes(
{
openapi: "3.1.0",
info: {
title: "Test API",
version: "1.0.0"
},
paths: {
"/tagged": {
get: {
operationId: "getTagged",
parameters: [],
responses: {
200: {
description: "Tagged"
}
},
tags: ["default"],
security: []
}
},
"/untagged": {
get: {
operationId: "getUntagged",
parameters: [],
responses: {
204: {
description: "Untagged"
}
},
tags: [] as any,
security: []
}
}
},
components: {
schemas: {},
securitySchemes: {}
},
security: [],
tags: []
},
[
`class DefaultGroup extends HttpApiGroup.make("default", { topLevel: true })`,
`HttpApiEndpoint.get("getTagged", "/tagged", { success: HttpApiSchema.Empty(200) })`,
`HttpApiEndpoint.get("getUntagged", "/untagged", { success: HttpApiSchema.Empty(204) })`,
`.add(DefaultGroup)`
]
))
it.effect("maps request and response encodings including optional request body approximation", () =>
assertHttpApiIncludes(
{
openapi: "3.1.0",
info: {
title: "Test API",
version: "1.0.0"
},
paths: {
"/payload": {
post: {
operationId: "createPayload",
parameters: [],
requestBody: {
required: false,
content: {
"application/json": {
schema: {
type: "object",
properties: {
a: { type: "string" }
},
required: ["a"],
additionalProperties: false
}
},
"multipart/form-data": {
schema: {
type: "object",
properties: {
file: { type: "string", format: "binary" },
files: {
type: "array",
items: { type: "string", format: "binary" }
}
},
required: ["file", "files"],
additionalProperties: false
}
},
"application/x-www-form-urlencoded": {
schema: {
type: "object",
properties: {
form: { type: "string" }
},
required: ["form"],
additionalProperties: false
}
},
"text/plain": {
schema: {
type: "string"
}
},
"application/octet-stream": {
schema: {
type: "string",
format: "binary"
}
}
}
} as any,
responses: {
200: {
description: "Payload",
content: {
"application/json": {
schema: {
type: "object",
properties: {
ok: { type: "boolean" }
},
required: ["ok"],
additionalProperties: false
}
},
"text/plain": {
schema: {
type: "string"
}
},
"application/octet-stream": {
schema: {
type: "string",
format: "binary"
}
}
}
},
201: {
description: "Created"
}
},
tags: ["Payload"],
security: []
}
}
},
components: {
schemas: {},
securitySchemes: {}
},
security: [],
tags: [{ name: "Payload" }]
},
[
`import { Multipart } from "effect/http"`,
`export type __HttpApiMultipartSingleFile = Multipart.PersistedFile
export const __HttpApiMultipartSingleFile = Multipart.SingleFileSchema`,
`export type __HttpApiMultipartFiles = ReadonlyArray<Multipart.PersistedFile>
export const __HttpApiMultipartFiles = Multipart.FilesSchema`,
`export type CreatePayloadRequestFormData = { readonly "file": __HttpApiMultipartSingleFile, readonly "files": __HttpApiMultipartFiles }`,
`export const CreatePayloadRequestFormData = Schema.Struct({ "file": __HttpApiMultipartSingleFile, "files": __HttpApiMultipartFiles })`,
`export type CreatePayloadRequestJson = { readonly "a": string }`,
`export type CreatePayloadRequestText = string
export const CreatePayloadRequestText = Schema.String`,
`payload: [HttpApiSchema.NoContent, CreatePayloadRequestJson, CreatePayloadRequestFormData.pipe(HttpApiSchema.asMultipart()), CreatePayloadRequestFormUrlEncoded.pipe(HttpApiSchema.asFormUrlEncoded()), CreatePayloadRequestText.pipe(HttpApiSchema.asText()), CreatePayloadRequestBinary.pipe(HttpApiSchema.asUint8Array())]`,
`success: [CreatePayload200, CreatePayload200Text.pipe(HttpApiSchema.asText()), CreatePayload200Binary.pipe(HttpApiSchema.asUint8Array()), HttpApiSchema.Empty(201)]`
],
[
"Schema.Opaque",
`extends Schema.Class<CreatePayloadRequestJson>("CreatePayloadRequestJson")`
]
))
it.effect("preserves multiple JSON response representations with application/json preferred", () =>
assertHttpApiIncludes(
{
openapi: "3.1.0",
info: {
title: "Test API",
version: "1.0.0"
},
paths: {
"/dual": {
get: {
operationId: "dualJson",
parameters: [],
responses: {
200: {
description: "Dual JSON response",
content: {
"application/problem+json": {
schema: {
type: "object",
properties: {
title: { type: "string" }
},
required: ["title"],
additionalProperties: false
}
},
"application/json": {
schema: {
type: "object",
properties: {
value: { type: "string" }
},
required: ["value"],
additionalProperties: false
}
}
}
}
},
tags: ["DualJson"],
security: []
}
}
},
components: {
schemas: {},
securitySchemes: {}
},
security: [],
tags: [{ name: "DualJson" }]
},
[
`export type DualJson200 = { readonly "value": string }`,
`export type DualJson200ApplicationProblemJson = { readonly "title": string }`,
`HttpApiEndpoint.get("dualJson", "/dual", { success: [DualJson200, DualJson200ApplicationProblemJson.pipe(HttpApiSchema.asJson({ contentType: "application/problem+json" }))] })`
]
))
it.effect("maps explicit SSE stream responses to HttpApiSchema.StreamSse", () =>
assertHttpApiIncludes(
{
openapi: "3.1.0",
info: {
title: "Test API",
version: "1.0.0"
},
paths: {
"/events": {
get: {
operationId: "streamEvents",
parameters: [],
responses: {
200: {
description: "Events",
content: {
"text/event-stream": {
schema: {
type: "object",
properties: {
event: { const: "message" },
data: { type: "string" }
},
required: ["event", "data"],
additionalProperties: false
},
"x-effect-stream": {
encoding: "sse",
errorSchema: {
type: "object",
properties: {
message: { type: "string" }
},
required: ["message"],
additionalProperties: false
},
causeSchema: {
type: "object"
},
failureEvent: "effect/http-api/stream/failure"
}
}
}
}
},
tags: ["Events"],
security: []
}
},
"/events/custom": {
get: {
operationId: "streamEventsCustom",
parameters: [],
responses: {
200: {
description: "Custom events",
content: {
"application/custom-sse": {
schema: {
type: "object",
properties: {
event: { const: "message" },
data: { type: "string" }
},
required: ["event", "data"],
additionalProperties: false
},
"x-effect-stream": {
encoding: "sse",
errorSchema: {
type: "object",
properties: {
message: { type: "string" }
},
required: ["message"],
additionalProperties: false
},
causeSchema: {
type: "object"
},
failureEvent: "effect/http-api/stream/failure"
}
}
}
}
},
tags: ["Events"],
security: []
}
}
},
components: {
schemas: {},
securitySchemes: {}
},
security: [],
tags: [{ name: "Events" }]
},
[
`HttpApiEndpoint.get("streamEvents", "/events", { success: HttpApiSchema.StreamSse({ events: StreamEvents200Sse, error: StreamEvents200SseError }) })`,
`HttpApiEndpoint.get("streamEventsCustom", "/events/custom", { success: HttpApiSchema.StreamSse({ contentType: "application/custom-sse", events: StreamEventsCustom200Sse, error: StreamEventsCustom200SseError }) })`
],
[
`StreamEvents200Sse.pipe(HttpApiSchema.asText())`,
`StreamEventsCustom200ApplicationCustomSse.pipe(HttpApiSchema.asText({ contentType: "application/custom-sse" }))`
]
))
it.effect("warns and skips unannotated successful SSE responses in HttpApi generation", () =>
assertHttpApiWithWarnings(
{
openapi: "3.1.0",
info: {
title: "Test API",
version: "1.0.0"
},
paths: {
"/events": {
get: {
operationId: "streamEvents",
parameters: [],
responses: {
200: {
description: "Events",
content: {
"text/event-stream": {
schema: {
type: "string"
}
}
}
}
},
tags: ["Events"],
security: []
}
},
"/events/cause-only": {
get: {
operationId: "streamEventsCauseOnly",
parameters: [],
responses: {
200: {
description: "Events",
content: {
"text/event-stream": {
schema: {
type: "string"
},
"x-effect-stream": {
encoding: "sse",
causeSchema: {
type: "object"
},
failureEvent: "effect/http-api/stream/failure"
} as any
}
}
}
},
tags: ["Events"],
security: []
}
}
},
components: {
schemas: {},
securitySchemes: {}
},
security: [],
tags: [{ name: "Events" }]
},
{
excludes: [
`HttpApiEndpoint.get("streamEvents", "/events"`,
`HttpApiEndpoint.get("streamEventsCauseOnly", "/events/cause-only"`
],
warnings: [
{
code: "sse-operation-skipped",
path: "/events",
method: "get",
operationId: "streamEvents"
},
{
code: "sse-operation-skipped",
path: "/events/cause-only",
method: "get",
operationId: "streamEventsCauseOnly"
}
]
}
))
it.effect("maps explicit uint8array stream responses to HttpApiSchema.StreamUint8Array", () =>
assertHttpApiIncludes(
{
openapi: "3.1.0",
info: {
title: "Test API",
version: "1.0.0"
},
paths: {
"/download": {
get: {
operationId: "download",
parameters: [],
responses: {
200: {
description: "Download",
content: {
"application/octet-stream": {
schema: {
type: "string",
format: "binary"
},
"x-effect-stream": {
encoding: "uint8array"
}
}
}
}
},
tags: ["Downloads"],
security: []
}
},
"/download/custom": {
get: {
operationId: "downloadCustom",
parameters: [],
responses: {
200: {
description: "Custom download",
content: {
"application/custom-bytes": {
schema: {
type: "string",
format: "binary"
},
"x-effect-stream": {
encoding: "uint8array"
}
}
}
}
},
tags: ["Downloads"],
security: []
}
}
},
components: {
schemas: {},
securitySchemes: {}
},
security: [],
tags: [{ name: "Downloads" }]
},
[
`HttpApiEndpoint.get("download", "/download", { success: HttpApiSchema.StreamUint8Array() })`,
`HttpApiEndpoint.get("downloadCustom", "/download/custom", { success: HttpApiSchema.StreamUint8Array({ contentType: "application/custom-bytes" }) })`
],
[
`Download200Binary.pipe(HttpApiSchema.asUint8Array())`,
`DownloadCustom200ApplicationCustomBytes.pipe(HttpApiSchema.asUint8Array({ contentType: "application/custom-bytes" }))`
]
))
it.effect("keeps unannotated octet-stream responses as buffered Uint8Array schemas", () =>
assertHttpApiIncludes(
{
openapi: "3.1.0",
info: {
title: "Test API",
version: "1.0.0"
},
paths: {
"/download/buffered": {
get: {
operationId: "downloadBuffered",
parameters: [],
responses: {
200: {
description: "Buffered download",
content: {
"application/octet-stream": {
schema: {
type: "string",
format: "binary"
}
}
}
}
},
tags: ["Downloads"],
security: []
}
}
},
components: {
schemas: {},
securitySchemes: {}
},
security: [],
tags: [{ name: "Downloads" }]
},
[
`HttpApiEndpoint.get("downloadBuffered", "/download/buffered", { success: DownloadBuffered200Binary.pipe(HttpApiSchema.asUint8Array()) })`
],
[
`HttpApiSchema.StreamUint8Array()`
]
))
it.effect("maps multipart schemas referenced through components to Multipart file schemas", () =>
assertHttpApiIncludes(
{
openapi: "3.1.0",
info: {
title: "Test API",
version: "1.0.0"
},
paths: {
"/upload": {
post: {
operationId: "upload",
parameters: [],
requestBody: {
required: true,
content: {
"multipart/form-data": {
schema: {
$ref: "#/components/schemas/UploadBody"
}
}
}
} as any,
responses: {
200: {
description: "Uploaded"
}
},
tags: ["Payload"],
security: []
}
}
},
components: {
schemas: {
UploadBody: {
type: "object",
properties: {
file: { type: "string", format: "binary" },
files: {
type: "array",
items: { type: "string", format: "binary" }
}
},
required: ["file", "files"],
additionalProperties: false
}
},
securitySchemes: {}
},
security: [],
tags: [{ name: "Payload" }]
},
[
`import { Multipart } from "effect/http"`,
`export type __HttpApiMultipartSingleFile = Multipart.PersistedFile
export const __HttpApiMultipartSingleFile = Multipart.SingleFileSchema`,
`export type __HttpApiMultipartFiles = ReadonlyArray<Multipart.PersistedFile>
export const __HttpApiMultipartFiles = Multipart.FilesSchema`,
`export type UploadRequestFormData = { readonly "file": __HttpApiMultipartSingleFile, readonly "files": __HttpApiMultipartFiles }`,
`export const UploadRequestFormData = Schema.Struct({ "file": __HttpApiMultipartSingleFile, "files": __HttpApiMultipartFiles })`,
`HttpApiEndpoint.post("upload", "/upload", { payload: UploadRequestFormData.pipe(HttpApiSchema.asMultipart()), success: HttpApiSchema.Empty(200) })`
],
[
`Schema.String.annotate({ "format": "binary" })`,
`export type UploadBody =`
]
))
it.effect("preserves multipart component reference siblings", () =>
assertHttpApiIncludes(
{
openapi: "3.1.0",
info: {
title: "Test API",
version: "1.0.0"
},
paths: {
"/upload": {
post: {
operationId: "upload",
parameters: [],
requestBody: {
required: true,
content: {
"multipart/form-data": {
schema: {
type: "object",
properties: {
first: {
$ref: "#/components/schemas/UploadBody",
minProperties: 1,
description: "First upload"
},
second: {
$ref: "#/components/schemas/UploadBody",
minProperties: 2,
description: "Second upload"
}
},
required: ["first", "second"],
additionalProperties: false
}
}
}
} as any,
responses: {
200: {
description: "Uploaded"
}
},
tags: ["Payload"],
security: []
}
}
},
components: {
schemas: {
UploadBody: {
type: "object",
properties: {
file: { type: "string", format: "binary" }
},
required: ["file"],
additionalProperties: false
}
},
securitySchemes: {}
},
security: [],
tags: [{ name: "Payload" }]
},
[
`"first": Schema.Struct({ "file": __HttpApiMultipartSingleFile })`,
`Schema.isMinProperties(1)`,
`"description": "First upload"`,
`"second": Schema.Struct({ "file": __HttpApiMultipartSingleFile })`,
`Schema.isMinProperties(2)`,
`"description": "Second upload"`
]
))
it.effect("preserves multipart component alias siblings", () =>
assertHttpApiIncludes(
{
openapi: "3.1.0",
info: {
title: "Test API",
version: "1.0.0"
},
paths: {
"/upload": {
post: {
operationId: "upload",
parameters: [],
requestBody: {
required: true,
content: {
"multipart/form-data": {
schema: {
$ref: "#/components/schemas/UploadAlias"
}
}
}
} as any,
responses: {
200: {
description: "Uploaded"
}
},
tags: ["Payload"],
security: []
}
}
},
components: {
schemas: {
UploadAlias: {
$ref: "#/components/schemas/UploadBody",
minProperties: 1,
description: "Aliased upload"
},
UploadBody: {
type: "object",
properties: {
file: { type: "string", format: "binary" }
},
required: ["file"],
additionalProperties: false
}
},
securitySchemes: {}
},
security: [],
tags: [{ name: "Payload" }]
},
[
`export type UploadRequestFormData = { readonly "file": __HttpApiMultipartSingleFile }`,
`Schema.isMinProperties(1)`,
`"description": "Aliased upload"`
],
[
`export type UploadAlias =`,
`export type UploadBody =`
]
))
it.effect("maps multipart contentEncoding binary schemas (case-insensitive) to Multipart file schemas", () =>
assertHttpApiIncludes(
{
openapi: "3.1.0",
info: {
title: "Test API",
version: "1.0.0"
},
paths: {
"/upload-content-encoding": {
post: {
operationId: "uploadWithContentEncoding",
parameters: [],
requestBody: {
required: true,
content: {
"multipart/form-data": {
schema: {
$ref: "#/components/schemas/UploadBodyContentEncoding"
}
}
}
} as any,
responses: {
200: {
description: "Uploaded"
}
},
tags: ["Payload"],
security: []
}
}
},
components: {
schemas: {
UploadBodyContentEncoding: {
type: "object",
properties: {
file: { type: "string", contentEncoding: "BINARY" },
files: {
type: "array",
items: { type: "string", contentEncoding: "BINARY" }
}
},
required: ["file", "files"],
additionalProperties: false
}
},
securitySchemes: {}
},
security: [],
tags: [{ name: "Payload" }]
},
[
`import { Multipart } from "effect/http"`,
`export type UploadWithContentEncodingRequestFormData = { readonly "file": __HttpApiMultipartSingleFile, readonly "files": __HttpApiMultipartFiles }`,
`export const UploadWithContentEncodingRequestFormData = Schema.Struct({ "file": __HttpApiMultipartSingleFile, "files": __HttpApiMultipartFiles })`,
`HttpApiEndpoint.post("uploadWithContentEncoding", "/upload-content-encoding", { payload: UploadWithContentEncodingRequestFormData.pipe(HttpApiSchema.asMultipart()), success: HttpApiSchema.Empty(200) })`
],
[
`contentEncoding`,
`export type UploadBodyContentEncoding =`
]
))
it.effect("generates security declarations and middleware placeholders", () =>
assertHttpApiWithWarnings(
{
openapi: "3.1.0",
info: {
title: "Security API",
version: "1.0.0"
},
paths: {
"/secure": {
get: {
operationId: "getSecure",
parameters: [],
responses: {
200: {
description: "Secure"
}
},
tags: ["Security"],
security: [
{ apiKeyAuth: [] },
{ bearerAuth: [] },
{ apiKeyAuth: [], basicAuth: [] }
]
}
},
"/public": {
get: {
operationId: "getPublic",
parameters: [],
responses: {
200: {
description: "Public"
}
},
tags: ["Security"],
security: []
}
}
},
components: {
schemas: {},
securitySchemes: {
apiKeyAuth: {
type: "apiKey",
name: "x-api-key",
in: "header",
description: "API key"
},
bearerAuth: {
type: "http",
scheme: "bearer",
bearerFormat: "JWT",
description: "Bearer token"
},
basicAuth: {
type: "http",
scheme: "basic"
}
}
},
security: [],
tags: [{ name: "Security" }]
},
{
includes: [
`const ApiKeyAuthSecurity = HttpApiSecurity.apiKey({ key: "x-api-key", in: "header" }).pipe(HttpApiSecurity.annotate(OpenApi.Description, "API key"))`,
`const BearerAuthSecurity = HttpApiSecurity.bearer.pipe(HttpApiSecurity.annotate(OpenApi.Description, "Bearer token")).pipe(HttpApiSecurity.annotate(OpenApi.Format, "JWT"))`,
`const BasicAuthSecurity = HttpApiSecurity.basic`,
`class ApiKeyAuthOrBearerAuthSecurityMiddleware extends HttpApiMiddleware.Service<ApiKeyAuthOrBearerAuthSecurityMiddleware>()("apiKeyAuth | bearerAuth security", { security: { "apiKeyAuth": ApiKeyAuthSecurity, "bearerAuth": BearerAuthSecurity } }) {}`,
`class ApiKeyAuthAndBasicAuthSecurityMiddleware extends HttpApiMiddleware.Service<ApiKeyAuthAndBasicAuthSecurityMiddleware>()("apiKeyAuth & basicAuth security") {}`,
`HttpApiEndpoint.get("getSecure", "/secure", { success: HttpApiSchema.Empty(200) })\n .middleware(ApiKeyAuthOrBearerAuthSecurityMiddleware)\n .middleware(ApiKeyAuthAndBasicAuthSecurityMiddleware)`,
`HttpApiEndpoint.get("getPublic", "/public", { success: HttpApiSchema.Empty(200) })`
],
excludes: [
`HttpApiEndpoint.get("getPublic", "/public", { success: HttpApiSchema.Empty(200) })\n .middleware(`
],
warnings: [
{
code: "security-and-downgraded",
path: "/secure",
method: "get",
operationId: "getSecure"
}
]
}
))
it.effect("generates custom http security schemes", () =>
assertHttpApiWithWarnings(
{
openapi: "3.1.0",
info: {
title: "Custom Security API",
version: "1.0.0"
},
paths: {
"/secure": {
get: {
operationId: "getSecure",
parameters: [],
responses: {
200: {
description: "Secure"
}
},
tags: ["Security"],
security: [{ digestAuth: [] }]
}
}
},
components: {
schemas: {},
securitySchemes: {
digestAuth: {
type: "http",
scheme: "Digest",
bearerFormat: "DigestToken",
description: "Digest token"
}
}
},
security: [],
tags: [{ name: "Security" }]
},
{
includes: [
`const DigestAuthSecurity = HttpApiSecurity.http({ scheme: "Digest" }).pipe(HttpApiSecurity.annotate(OpenApi.Description, "Digest token")).pipe(HttpApiSecurity.annotate(OpenApi.Format, "DigestToken"))`,
`class DigestAuthSecurityMiddleware extends HttpApiMiddleware.Service<DigestAuthSecurityMiddleware>()("digestAuth security", { security: { "digestAuth": DigestAuthSecurity } }) {}`
],
warnings: []
}
))
it.effect("inherits global security and respects operation-level clearing", () =>
assertHttpApiWithWarnings(
{
openapi: "3.1.0",
info: {
title: "Security inheritance API",
version: "1.0.0"
},
paths: {
"/inherited": {
get: {
operationId: "getInherited",
parameters: [],
responses: {
200: {
description: "Inherited"
}
},
tags: ["Security"]
} as any
},
"/inherited-two": {
get: {
operationId: "getInheritedTwo",
parameters: [],
responses: {
200: {
description: "Inherited two"
}
},
tags: ["Security"]
} as any
},
"/cleared": {
get: {
operationId: "getCleared",
parameters: [],
responses: {
200: {
description: "Cleared"
}
},
tags: ["Security"],
security: []
}
},
"/anonymous": {
get: {
operationId: "getAnonymous",
parameters: [],
responses: {
200: {
description: "Anonymous"
}
},
tags: ["Security"],
security: [{}]
}
},
"/override": {
get: {
operationId: "getOverride",
parameters: [],
responses: {
200: {
description: "Override"
}
},
tags: ["Security"],
security: [{ bearerAuth: [] }]
}
}
},
components: {
schemas: {},
securitySchemes: {
apiKeyAuth: {
type: "apiKey",
name: "x-api-key",
in: "header"
},
bearerAuth: {
type: "http",
scheme: "bearer"
}
}
},
security: [{ apiKeyAuth: [] }],
tags: [{ name: "Security" }]
},
{
includes: [
`class ApiKeyAuthSecurityMiddleware extends HttpApiMiddleware.Service<ApiKeyAuthSecurityMiddleware>()("apiKeyAuth security", { security: { "apiKeyAuth": ApiKeyAuthSecurity } }) {}`,
`class BearerAuthSecurityMiddleware extends HttpApiMiddleware.Service<BearerAuthSecurityMiddleware>()("bearerAuth security", { security: { "bearerAuth": BearerAuthSecurity } }) {}`,
`HttpApiEndpoint.get("getInherited", "/inherited", { success: HttpApiSchema.Empty(200) })\n .middleware(ApiKeyAuthSecurityMiddleware)`,
`HttpApiEndpoint.get("getInheritedTwo", "/inherited-two", { success: HttpApiSchema.Empty(200) })\n .middleware(ApiKeyAuthSecurityMiddleware)`,
`HttpApiEndpoint.get("getCleared", "/cleared", { success: HttpApiSchema.Empty(200) })`,
`HttpApiEndpoint.get("getAnonymous", "/anonymous", { success: HttpApiSchema.Empty(200) })`,
`HttpApiEndpoint.get("getOverride", "/override", { success: HttpApiSchema.Empty(200) })\n .middleware(BearerAuthSecurityMiddleware)`
],
excludes: [
`HttpApiEndpoint.get("getCleared", "/cleared", { success: HttpApiSchema.Empty(200) })\n .middleware(`,
`HttpApiEndpoint.get("getAnonymous", "/anonymous", { success: HttpApiSchema.Empty(200) })\n .middleware(`
],
occurrences: [
{
substring:
`class ApiKeyAuthSecurityMiddleware extends HttpApiMiddleware.Service<ApiKeyAuthSecurityMiddleware>()("apiKeyAuth security", { security: { "apiKeyAuth": ApiKeyAuthSecurity } }) {}`,
count: 1
}
],
warnings: []
}
))
it.effect("emits lossy warnings and skips unsupported httpapi operations", () =>
assertHttpApiWithWarnings(
{
openapi: "3.1.0",
info: {
title: "Warnings API",
version: "1.0.0"
},
paths: {
"/cookies": {
get: {
operationId: "getCookie",
parameters: [
{
name: "session",
in: "cookie",
schema: { type: "string" },
required: false
}
],
responses: {
200: {
description: "Cookie"
}
},
tags: ["Warnings"],
security: []
}
},
"/tags": {
get: {
operationId: "getTagged",
parameters: [],
responses: {
200: {
description: "Tagged"
}
},
tags: ["Warnings", "ExtraTag"],
security: []
}
},
"/sse": {
get: {
operationId: "streamEvents",
parameters: [],
responses: {
200: {
description: "Events",
content: {
"text/event-stream": {
schema: {
type: "string"
}
}
}
}
},
tags: ["Warnings"],
security: []
}
},
"/headers": {
get: {
operationId: "getHeaders",
parameters: [],
responses: {
200: {
description: "Headers",
headers: {
"x-rate-limit": {
schema: {
type: "integer"
}
}
}
}
} as any,
tags: ["Warnings"],
security: []
}
},
"/default-success": {
get: {
operationId: "getDefaultSuccess",
parameters: [],
responses: {
200: {
description: "OK"
},
default: {
description: "Fallback"
}
} as any,
tags: ["Warnings"],
security: []
}
},
"/default-only": {
get: {
operationId: "getDefaultOnly",
parameters: [],
responses: {
default: {
description: "Fallback"
}
} as any,
tags: ["Warnings"],
security: []
}
},
"/nobody": {
get: {
operationId: "getNoBody",
parameters: [],
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
properties: {
a: { type: "string" }
},
required: ["a"],
additionalProperties: false
}
}
}
},
responses: {
200: {
description: "No body"
}
},
tags: ["Warnings"],
security: []
}
}
},
components: {
schemas: {},
securitySchemes: {}
},
security: [],
tags: [{ name: "Warnings" }]
},
{
includes: [
`HttpApiEndpoint.get("getCookie", "/cookies", { success: HttpApiSchema.Empty(200) })`,
`HttpApiEndpoint.get("getHeaders", "/headers", { success: HttpApiSchema.Empty(200) })`,
`HttpApiEndpoint.get("getDefaultSuccess", "/default-success", { success: HttpApiSchema.Empty(200), error: HttpApiSchema.Empty(500) })`,
`HttpApiEndpoint.get("getDefaultOnly", "/default-only", { success: HttpApiSchema.Empty(200) })`
],
excludes: [
`HttpApiEndpoint.get("streamEvents", "/sse"`,
`HttpApiEndpoint.get("getNoBody", "/nobody"`
],
warnings: [
{
code: "cookie-parameter-dropped",
path: "/cookies",
method: "get",
operationId: "getCookie"
},
{
code: "additional-tags-dropped",
path: "/tags",
method: "get",
operationId: "getTagged"
},
{
code: "sse-operation-skipped",
path: "/sse",
method: "get",
operationId: "streamEvents"
},
{
code: "response-headers-ignored",
path: "/headers",
method: "get",
operationId: "getHeaders"
},
{
code: "default-response-remapped",
path: "/default-success",
method: "get",
operationId: "getDefaultSuccess"
},
{
code: "default-response-remapped",
path: "/default-only",
method: "get",
operationId: "getDefaultOnly"
},
{
code: "no-body-method-request-body-skipped",
path: "/nobody",
method: "get",
operationId: "getNoBody"
}
]
}
))
})
describe("QUERY", () => {
const queryOperation: OpenAPISpecOperation = {
operationId: "search",
parameters: [],
tags: ["Search"],
security: [],
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
properties: { query: { type: "string" } },
required: ["query"],
additionalProperties: false
}
}
}
},
responses: { "204": { description: "No content" } }
}
const makeQuerySpec = (pathItem: OpenAPISpecPathItem & { query?: OpenAPISpecOperation }): OpenAPISpec => ({
openapi: "3.1.0",
info: { title: "QUERY API", version: "1.0.0" },
components: { schemas: {}, securitySchemes: {} },
security: [],
tags: [{ name: "Search" }],
paths: {
"/search": {
get: {
operationId: "list",
parameters: [],
tags: ["Search"],
security: [],
responses: { "204": { description: "No content" } }
},
...pathItem
}
}
})
it.effect.each([
{
name: "OpenAPI 3.1 extension",
spec: makeQuerySpec({ "x-oai-additionalOperations": { QUERY: queryOperation } })
},
{
name: "native OpenAPI 3.2 field",
spec: {
...makeQuerySpec({
query: queryOperation,
"x-oai-additionalOperations": { QUERY: { ...queryOperation, operationId: "legacySearch" } }
}),
openapi: "3.2.0"
} as unknown as OpenAPISpec
}
])("generates QUERY request bodies from the $name", ({ spec }) =>
Effect.all([
assertRuntimeIncludes(spec, [
`HttpClientRequest.get("/search")`,
`HttpClientRequest.query("/search")`,
`export const SearchRequestJson = Schema.Struct({ "query": Schema.String })`,
`HttpClientRequest.bodyJsonUnsafe(options.payload)`,
`readonly payload: typeof SearchRequestJson.Encoded`
], ["legacySearch", "LegacySearch"]),
assertTypeOnlyIncludes(spec, [
`HttpClientRequest.get("/search")`,
`HttpClientRequest.query("/search")`,
`HttpClientRequest.bodyJsonUnsafe(options.payload)`,
`export type SearchRequestJson = { readonly "query": string }`,
`readonly payload: SearchRequestJson`
], ["legacySearch", "LegacySearch"]),
assertHttpApiIncludes(spec, [
`HttpApiEndpoint.get("list", "/search"`,
`export const SearchRequestJson = Schema.Struct({ "query": Schema.String })`,
`HttpApiEndpoint.query("search", "/search", { payload: SearchRequestJson, success: HttpApiSchema.Empty(204) })`
], ["legacySearch", "LegacySearch"])
]))
})
describe("regression", () => {
for (const format of ["httpclient", "httpclient-type-only"] as const) {
for (const exactOptionalPropertyTypes of [true, false]) {
it.effect(
`preserves includeResponse success types (${format}, exactOptionalPropertyTypes=${exactOptionalPropertyTypes})`,
() =>
assertGeneratedClientsCompile({
openapi: "3.1.0",
info: { title: "Value", version: "1.0.0" },
components: { schemas: {}, securitySchemes: {} },
security: [],
tags: [],
paths: {
"/value": {
get: {
operationId: "getValue",
parameters: [],
security: [],
tags: ["Value"],
responses: {
"200": {
description: "Value",
content: { "application/json": { schema: { type: "string" } } }
}
}
}
}
}
}, {
formats: [format],
exactOptionalPropertyTypes,
usage: `type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends
(<T>() => T extends B ? 1 : 2) ? true : false
type Assert<T extends true> = T
type WithResponse = [string, HttpClientResponse.HttpClientResponse]
declare const client: TestClient
declare const dynamic: boolean
declare const optionalTrue: { readonly includeResponse?: true }
declare const optionalBoolean: { readonly includeResponse?: boolean }
declare const nullableConfig: { readonly includeResponse: true } | undefined
const enabled = client.getValue({ config: { includeResponse: true } })
const disabled = client.getValue({ config: { includeResponse: false } })
const omitted = client.getValue(undefined)
const emptyOptions = client.getValue({})
const undefinedConfig = client.getValue({ config: undefined })
const dynamicResult = client.getValue({ config: { includeResponse: dynamic } })
const optionalTrueResult = client.getValue({ config: optionalTrue })
const optionalBooleanResult = client.getValue({ config: optionalBoolean })
const nullableConfigResult = client.getValue({ config: nullableConfig })
const conditionalConfig = Math.random() > 0.5 ? { includeResponse: true as const } : undefined
const conditionalConfigResult = client.getValue({ config: conditionalConfig })
export type Enabled = Assert<Equal<Effect.Success<typeof enabled>, WithResponse>>
export type Disabled = Assert<Equal<Effect.Success<typeof disabled>, string>>
export type Omitted = Assert<Equal<Effect.Success<typeof omitted>, string>>
export type EmptyOptions = Assert<Equal<Effect.Success<typeof emptyOptions>, string>>
export type UndefinedConfig = Assert<Equal<Effect.Success<typeof undefinedConfig>, string>>
export type Dynamic = Assert<Equal<Effect.Success<typeof dynamicResult>, string | WithResponse>>
export type OptionalTrue = Assert<Equal<Effect.Success<typeof optionalTrueResult>, string | WithResponse>>
export type OptionalBoolean = Assert<Equal<Effect.Success<typeof optionalBooleanResult>, string | WithResponse>>
export type NullableConfig = Assert<Equal<Effect.Success<typeof nullableConfigResult>, string | WithResponse>>
export type ConditionalConfig = Assert<Equal<Effect.Success<typeof conditionalConfigResult>, string | WithResponse>>
`
}),
compilationTimeout
)
}
}
it.effect("quotes static path text with and without parameters", () => {
const prefix = "/files/\"`\\\n/"
const suffix = "/content\"`"
const operation: OpenAPISpecOperation = {
operationId: "read",
parameters: [],
tags: ["Files"],
security: [],
responses: { "204": { description: "No content" } }
}
return assertRuntimeIncludes({
openapi: "3.1.0",
info: { title: "Quoted paths", version: "1.0.0" },
components: { schemas: {}, securitySchemes: {} },
security: [],
tags: [],
paths: {
[prefix + suffix]: { get: operation },
[prefix + "{id}" + suffix]: {
get: {
...operation,
operationId: "readById",
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }]
}
}
}
}, [
`HttpClientRequest.get(${JSON.stringify(prefix + suffix)})`,
`() => ${JSON.stringify(prefix)} + __encodePathParam(id) + ${JSON.stringify(suffix)}`
])
})
it.effect("emits compilable clients when schema examples are invalid", () =>
assertGeneratedClientsCompile({
openapi: "3.0.3",
info: {
title: "Invalid examples API",
version: "1.0.0"
},
paths: {
"/triggers": {
get: {
operationId: "getTriggers",
parameters: [],
responses: {
200: {
description: "OK",
content: {
"application/json": {
schema: {
type: "array",
items: { type: "string" },
example: "create"
}
}
}
}
},
tags: ["Triggers"],
security: []
}
}
},
components: {
schemas: {},
securitySchemes: {}
},
security: [],
tags: [{ name: "Triggers" }]
} as unknown as OpenAPISpec), compilationTimeout)
it.effect("runtime warnings do not report additional-tags-dropped outside httpapi", () =>
assertRuntimeStableWithWarnings(
{
openapi: "3.1.0",
info: {
title: "Warnings regression API",
version: "1.0.0"
},
paths: {
"/multi-tag": {
get: {
operationId: "getMultiTag",
parameters: [],
responses: {
200: {
description: "OK"
}
},
tags: ["One", "Two"],
security: []
}
}
},
components: {
schemas: {},
securitySchemes: {}
},
security: [],
tags: [{ name: "One" }, { name: "Two" }]
},
[]
))
it.effect("runtime output remains stable when using onWarning", () =>
assertRuntimeStableWithWarnings(regressionSpec, [
{
code: "cookie-parameter-dropped",
path: "/users/{id}",
method: "get",
operationId: "getUser"
},
{
code: "default-response-remapped",
path: "/users/{id}",
method: "get",
operationId: "getUser"
}
]))
it.effect("type-only output remains stable when using onWarning", () =>
assertTypeOnlyStableWithWarnings(regressionSpec, [
{
code: "cookie-parameter-dropped",
path: "/users/{id}",
method: "get",
operationId: "getUser"
},
{
code: "default-response-remapped",
path: "/users/{id}",
method: "get",
operationId: "getUser"
}
]))
it.effect("emits compilable open objects with optional properties", () =>
assertGeneratedClientsCompile(
{
openapi: "3.1.0",
info: {
title: "Open object regression API",
version: "1.0.0"
},
paths: {
"/widget": {
get: {
operationId: "getWidget",
parameters: [],
responses: {
200: {
description: "OK",
content: {
"application/json": {
schema: {
type: "object",
properties: {
optionalValue: {
type: "string"
}
},
additionalProperties: true
}
}
}
}
},
tags: ["Widgets"],
security: []
}
}
},
components: {
schemas: {},
securitySchemes: {}
},
security: [],
tags: []
},
{
exactOptionalPropertyTypes: false,
formats: ["httpclient"]
}
), compilationTimeout)
it.effect("types multipart binary fields as File | Blob for generated clients", () =>
assertGeneratedClientsCompile(
multipartSpec({
type: "object",
properties: { file: { type: "string", format: "binary" } },
required: ["file"],
additionalProperties: false
}),
{
usage: `export const withFile: UploadRequestFormData = { file: new File([], "upload.txt") }
export const withBlob: UploadRequestFormData = { file: new Blob([]) }
// @ts-expect-error Binary multipart fields do not accept strings.
export const withString: UploadRequestFormData = { file: "upload.txt" }
`
}
), compilationTimeout)
it.effect(
"types recursive multipart binary references as File | Blob for generated clients",
() =>
assertGeneratedClientsCompile(
multipartSpec({ $ref: "#/components/schemas/Node" }, {
Node: {
type: "object",
properties: {
file: { type: "string", format: "binary" },
child: { $ref: "#/components/schemas/Node" }
},
required: ["file"],
additionalProperties: false
}
}),
{
usage: `const file = new File([], "upload.txt")
const blob = new Blob([])
export const payload: UploadRequestFormData = { file, child: { file: blob, child: { file } } }
// @ts-expect-error Recursive binary fields do not accept strings.
export const withString: UploadRequestFormData = { file, child: { file: "upload.txt" } }
`
}
),
compilationTimeout
)
for (
const [format, assertIncludes] of [
["httpclient", assertRuntimeIncludes],
["httpclient-type-only", assertTypeOnlyIncludes]
] as const
) {
it.effect(`preserves named multipart components without binary fields (${format})`, () =>
assertIncludes(
multipartSpec({ $ref: "#/components/schemas/FormBody" }, {
FormBody: {
type: "object",
properties: {
name: { type: "string" },
meta: { $ref: "#/components/schemas/Meta" }
},
required: ["name"],
additionalProperties: false
},
Meta: {
type: "object",
properties: { k: { type: "string" } },
required: ["k"],
additionalProperties: false
}
}),
[
"export type Meta =",
"export type FormBody =",
"export type UploadRequestFormData = FormBody"
]
))
}
it.effect(
"types multipart binary arrays as File | Blob arrays for generated clients",
() =>
assertGeneratedClientsCompile(
multipartSpec({
type: "object",
properties: { files: { type: "array", items: { type: "string", format: "binary" } } },
required: ["files"],
additionalProperties: false
}),
{
usage: `export const payload: UploadRequestFormData = { files: [new File([], "upload.txt"), new Blob([])] }
// @ts-expect-error Binary multipart arrays do not accept strings.
export const withString: UploadRequestFormData = { files: ["upload.txt"] }
`
}
),
compilationTimeout
)
})
})