diff --git a/.changeset/fix-openapi-invalid-examples.md b/.changeset/fix-openapi-invalid-examples.md new file mode 100644 index 00000000000..2e9fc460fc6 --- /dev/null +++ b/.changeset/fix-openapi-invalid-examples.md @@ -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. diff --git a/packages/tools/openapi-generator/src/JsonSchemaGenerator.ts b/packages/tools/openapi-generator/src/JsonSchemaGenerator.ts index 51ec605dea6..2ff5e8d3b04 100644 --- a/packages/tools/openapi-generator/src/JsonSchemaGenerator.ts +++ b/packages/tools/openapi-generator/src/JsonSchemaGenerator.ts @@ -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 { @@ -196,13 +202,19 @@ export function make() { return } + const sanitizedSchemas = new WeakMap() 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 } @@ -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 | 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): string { + return types.map((type) => `"${type}"`).join(" or ") +} + function fromSchemaOpenApi(source: Source, jsonSchema: JsonSchema.JsonSchema) { switch (source) { case "openapi-3.1": diff --git a/packages/tools/openapi-generator/src/OpenApiGenerator.ts b/packages/tools/openapi-generator/src/OpenApiGenerator.ts index d447a0452d3..e0e0bcf998b 100644 --- a/packages/tools/openapi-generator/src/OpenApiGenerator.ts +++ b/packages/tools/openapi-generator/src/OpenApiGenerator.ts @@ -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 @@ -159,6 +160,7 @@ export const make = Effect.gen(function*() { withHttpApiMultipartSchemas(spec.components?.schemas ?? {}, multipartSchemaRefs), { onEnter: options.onEnter, + onWarning: emitWarning, multipartSchemaRefs } ) @@ -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 } ) diff --git a/packages/tools/openapi-generator/test/JsonSchemaGenerator.test.ts b/packages/tools/openapi-generator/test/JsonSchemaGenerator.test.ts index 31e33f4a95e..71d7642fd22 100644 --- a/packages/tools/openapi-generator/test/JsonSchemaGenerator.test.ts +++ b/packages/tools/openapi-generator/test/JsonSchemaGenerator.test.ts @@ -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 = [] + + 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" }) diff --git a/packages/tools/openapi-generator/test/OpenApiGenerator.test.ts b/packages/tools/openapi-generator/test/OpenApiGenerator.test.ts index bf93c46b416..c6e8c0f2405 100644 --- a/packages/tools/openapi-generator/test/OpenApiGenerator.test.ts +++ b/packages/tools/openapi-generator/test/OpenApiGenerator.test.ts @@ -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 = [] + + 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( {