Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-openapi-invalid-examples.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@effect/openapi-generator": patch
---

Drop schema examples that are incompatible with the schema's declared JSON type and emit an `invalid-schema-example-dropped` warning instead of generating TypeScript that does not typecheck.
89 changes: 88 additions & 1 deletion packages/tools/openapi-generator/src/JsonSchemaGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ type Source = "openapi-3.0" | "openapi-3.1"

interface GenerateOptions {
readonly onEnter?: ((js: JsonSchema.JsonSchema) => JsonSchema.JsonSchema) | undefined
readonly onWarning?:
| ((warning: {
readonly code: "invalid-schema-example-dropped"
readonly message: string
}) => void)
| undefined
}

interface GenerateHttpApiOptions extends GenerateOptions {
Expand Down Expand Up @@ -196,13 +202,19 @@ export function make() {
return
}

const sanitizedSchemas = new WeakMap<JsonSchema.JsonSchema, JsonSchema.JsonSchema>()
const multiDocument: SchemaRepresentation.MultiDocument = SchemaRepresentation.fromJsonSchemaMultiDocument({
dialect: "draft-2020-12",
schemas,
definitions
}, {
onEnter(js) {
const out = { ...js }
let out = sanitizedSchemas.get(js)
if (out === undefined) {
out = dropInvalidExamples(js, options?.onWarning)
sanitizedSchemas.set(js, out)
}
out = { ...out }
if (out.type === "object" && out.additionalProperties === undefined) {
out.additionalProperties = false
}
Expand All @@ -219,6 +231,81 @@ export function make() {
return { addSchema, generate, generateHttpApi } as const
}

function dropInvalidExamples(
schema: JsonSchema.JsonSchema,
onWarning: GenerateOptions["onWarning"]
): JsonSchema.JsonSchema {
if (!Array.isArray(schema.examples)) return schema

const types = getSchemaTypes(schema.type)
if (types === undefined) return schema

const examples = schema.examples.filter((example) => {
if (types.some((type) => matchesType(example, type))) return true

onWarning?.({
code: "invalid-schema-example-dropped",
message: `Dropped an example value of type "${
getValueType(example)
}" because it is incompatible with schema type ${formatTypes(types)}.`
})
return false
})

if (examples.length === schema.examples.length) return schema

const out = { ...schema }
if (examples.length === 0) {
delete out.examples
} else {
out.examples = examples
}
return out
}

function getSchemaTypes(type: unknown): ReadonlyArray<JsonSchema.Type> | undefined {
if (isSchemaType(type)) return [type]
if (Array.isArray(type) && type.length > 0 && type.every(isSchemaType)) return type
}

function isSchemaType(type: unknown): type is JsonSchema.Type {
return type === "string" ||
type === "number" ||
type === "integer" ||
type === "boolean" ||
type === "array" ||
type === "object" ||
type === "null"
}

function matchesType(value: unknown, type: JsonSchema.Type): boolean {
switch (type) {
case "string":
case "boolean":
return typeof value === type
case "number":
return typeof value === "number" && Number.isFinite(value)
case "integer":
return typeof value === "number" && Number.isInteger(value)
case "array":
return Array.isArray(value)
case "object":
return value !== null && typeof value === "object" && !Array.isArray(value)
case "null":
return value === null
}
}

function getValueType(value: unknown): string {
if (value === null) return "null"
if (Array.isArray(value)) return "array"
return typeof value
}

function formatTypes(types: ReadonlyArray<JsonSchema.Type>): string {
return types.map((type) => `"${type}"`).join(" or ")
}

function fromSchemaOpenApi(source: Source, jsonSchema: JsonSchema.JsonSchema) {
switch (source) {
case "openapi-3.1":
Expand Down
9 changes: 6 additions & 3 deletions packages/tools/openapi-generator/src/OpenApiGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,11 @@ export type OpenApiGeneratorWarningCode =
| "security-and-downgraded"
| "no-body-method-request-body-skipped"
| "naming-collision"
| "invalid-schema-example-dropped"

/**
* Describes a non-fatal issue encountered while mapping an OpenAPI operation to
* generated Effect source.
* Describes a non-fatal issue encountered while generating Effect source from
* an OpenAPI document.
*
* @category models
* @since 4.0.0
Expand Down Expand Up @@ -159,6 +160,7 @@ export const make = Effect.gen(function*() {
withHttpApiMultipartSchemas(spec.components?.schemas ?? {}, multipartSchemaRefs),
{
onEnter: options.onEnter,
onWarning: emitWarning,
multipartSchemaRefs
}
)
Expand All @@ -167,7 +169,8 @@ export const make = Effect.gen(function*() {
spec.components?.schemas ?? {},
options.format === "httpclient-type-only",
{
onEnter: options.onEnter
onEnter: options.onEnter,
onWarning: emitWarning
}
)

Expand Down
38 changes: 38 additions & 0 deletions packages/tools/openapi-generator/test/JsonSchemaGenerator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,44 @@ export const A = Schema.String.annotate({ "description": "desc", "examples": ["e
`)
})

it("retains compatible examples and drops incompatible examples", () => {
const generator = JsonSchemaGenerator.make()
generator.addSchema("Arrays", {
type: "array",
items: { type: "string" },
examples: [["create"], "create", []]
})
generator.addSchema("NullableStrings", {
type: ["string", "null"],
examples: ["create", null, 1]
})
generator.addSchema("Unknown", {
examples: ["create"]
})
const warnings: Array<unknown> = []

const result = generator.generate("openapi-3.1", {}, false, {
onWarning: (warning) => {
warnings.push(warning)
}
})

expect(result).toContain("\"examples\": [[\"create\"],[]]")
expect(result).toContain("\"examples\": [\"create\",null]")
expect(result).toContain("export const Unknown = Schema.Json.annotate({ \"examples\": [\"create\"] })")
expect(warnings).toEqual([
{
code: "invalid-schema-example-dropped",
message: "Dropped an example value of type \"string\" because it is incompatible with schema type \"array\"."
},
{
code: "invalid-schema-example-dropped",
message:
"Dropped an example value of type \"number\" because it is incompatible with schema type \"string\" or \"null\"."
}
])
})

it("generateHttpApi emits explicit type and const declarations", () => {
const generator = JsonSchemaGenerator.make()
generator.addSchema("A", { type: "string" })
Expand Down
61 changes: 61 additions & 0 deletions packages/tools/openapi-generator/test/OpenApiGenerator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2063,6 +2063,67 @@ export const __HttpApiMultipartFiles = Multipart.FilesSchema`,
})

describe("regression", () => {
it.effect("drops type-incompatible schema examples and emits a warning", () =>
Effect.gen(function*() {
const generator = yield* OpenApiGenerator.OpenApiGenerator
const warnings: Array<OpenApiGenerator.OpenApiGeneratorWarning> = []

const result = yield* generator.generate({
openapi: "3.0.0",
info: {
title: "Invalid schema example API",
version: "1.0.0"
},
paths: {
"/templates": {
get: {
operationId: "getTemplates",
parameters: [],
responses: {
200: {
description: "OK",
content: {
"application/json": {
schema: {
$ref: "#/components/schemas/TemplateTriggers"
}
}
}
}
}
}
}
},
components: {
schemas: {
TemplateTriggers: {
type: "array",
items: { type: "string" },
example: "create"
}
}
}
} as unknown as OpenAPISpec, {
name: "TestClient",
format: "httpclient",
onWarning: (warning) => {
warnings.push(warning)
}
})

assert.include(result, "export const TemplateTriggers = Schema.Array(Schema.String)")
assert.notInclude(result, "\"examples\": [\"create\"]")
assert.deepStrictEqual(warnings, [
{
code: "invalid-schema-example-dropped",
message:
"Dropped an example value of type \"string\" because it is incompatible with schema type \"array\"."
}
])
}).pipe(
Effect.provide(OpenApiGenerator.layerTransformerSchema)
))

it.effect("runtime warnings do not report additional-tags-dropped outside httpapi", () =>
assertRuntimeStableWithWarnings(
{
Expand Down