From 350104b61c123a45f7c99c6319c7b70226d1f367 Mon Sep 17 00:00:00 2001 From: Dimitri Mitropoulos Date: Thu, 8 Oct 2026 17:10:56 -0400 Subject: [PATCH] fix(cli): keep [] and null in authored error examples OpenAPI error-response and webhook examples are converted without a schema by convertToFullExample. It dropped empty arrays and nulls, so an error example such as success: false errors: [...] messages: [] result: null lost `messages` and `result`, and fern check failed with `Example is missing required property "response.body.messages"` (valid-example-error) on every such error declaration. convertToFullExample now keeps `[]` and `null`. This also stops an explicit `[]` on an unknown schema from being replaced with a generated placeholder. The optional-container guard in ExampleTypeFactory now keeps an empty array only when the example the array builder used was an authored `[]`. A non-empty example whose items all fail to build is omitted, as before. --- .../src/schema/examples/ExampleTypeFactory.ts | 18 +- .../__tests__/ExampleTypeFactory.test.ts | 74 +- .../__tests__/convertToFullExample.test.ts | 68 ++ .../schema/examples/convertToFullExample.ts | 21 +- .../error-examples-empty-arrays.json | 891 ++++++++++++++++++ .../openapi/error-examples-empty-arrays.json | 404 ++++++++ .../fern/fern.config.json | 4 + .../fern/generators.yml | 4 + .../error-examples-empty-arrays/openapi.yml | 171 ++++ ...fix-openapi-error-example-empty-arrays.yml | 5 + .../__snapshots__/validate.test.ts.snap | 2 + .../fern/fern.config.json | 4 + .../fern/generators.yml | 3 + .../fern/openapi.yml | 171 ++++ .../src/tests/validate/validate.test.ts | 1 + 15 files changed, 1831 insertions(+), 10 deletions(-) create mode 100644 packages/cli/api-importers/openapi/openapi-ir-parser/src/schema/examples/__tests__/convertToFullExample.test.ts create mode 100644 packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi-ir/error-examples-empty-arrays.json create mode 100644 packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi/error-examples-empty-arrays.json create mode 100644 packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/fixtures/error-examples-empty-arrays/fern/fern.config.json create mode 100644 packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/fixtures/error-examples-empty-arrays/fern/generators.yml create mode 100644 packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/fixtures/error-examples-empty-arrays/openapi.yml create mode 100644 packages/cli/cli/changes/unreleased/fix-openapi-error-example-empty-arrays.yml create mode 100644 packages/cli/ete-tests/src/tests/validate/fixtures/error-examples-empty-arrays/fern/fern.config.json create mode 100644 packages/cli/ete-tests/src/tests/validate/fixtures/error-examples-empty-arrays/fern/generators.yml create mode 100644 packages/cli/ete-tests/src/tests/validate/fixtures/error-examples-empty-arrays/fern/openapi.yml diff --git a/packages/cli/api-importers/openapi/openapi-ir-parser/src/schema/examples/ExampleTypeFactory.ts b/packages/cli/api-importers/openapi/openapi-ir-parser/src/schema/examples/ExampleTypeFactory.ts index dd0509714..b592837ea 100644 --- a/packages/cli/api-importers/openapi/openapi-ir-parser/src/schema/examples/ExampleTypeFactory.ts +++ b/packages/cli/api-importers/openapi/openapi-ir-parser/src/schema/examples/ExampleTypeFactory.ts @@ -148,8 +148,7 @@ export class ExampleTypeFactory { result != null && result.type === "array" && result.value.length === 0 && - !Array.isArray(example) && - !Array.isArray(this.getSchemaExample(schema)) + !this.isExplicitEmptyArrayExample(example, schema.value) ) { return undefined; } @@ -915,6 +914,21 @@ export class ExampleTypeFactory { return schema; } + /** + * True when the example that the array builder uses is an authored `[]`. The + * array builder prefers a parsed array example and falls back to the schema + * example, so this follows the same order. A non-empty example whose items all + * fail to build is not an explicit empty array. + */ + private isExplicitEmptyArrayExample(example: unknown, schema: SchemaWithExample): boolean { + const parsedExample = getFullExampleAsArray(example); + if (parsedExample != null) { + return parsedExample.length === 0; + } + const schemaExample = getFullExampleAsArray(this.getSchemaExample(schema)); + return schemaExample != null && schemaExample.length === 0; + } + private getSchemaExample(schema: SchemaWithExample): unknown | undefined { return schema._visit({ primitive: (s) => s.schema.example, diff --git a/packages/cli/api-importers/openapi/openapi-ir-parser/src/schema/examples/__tests__/ExampleTypeFactory.test.ts b/packages/cli/api-importers/openapi/openapi-ir-parser/src/schema/examples/__tests__/ExampleTypeFactory.test.ts index f4a2cdd9f..527480c39 100644 --- a/packages/cli/api-importers/openapi/openapi-ir-parser/src/schema/examples/__tests__/ExampleTypeFactory.test.ts +++ b/packages/cli/api-importers/openapi/openapi-ir-parser/src/schema/examples/__tests__/ExampleTypeFactory.test.ts @@ -170,10 +170,30 @@ function makeStringSchema(): SchemaWithExample { ); } -function makeArraySchema(): SchemaWithExample { +function makeMissingReferenceSchema(): SchemaWithExample { + return SchemaWithExample.reference({ + schema: "MissingSchema", + description: undefined, + availability: undefined, + generatedName: "MissingSchema", + nameOverride: undefined, + groupName: undefined, + namespace: undefined, + title: undefined, + source: undefined + }); +} + +function makeArraySchema({ + items = makeStringSchema(), + example +}: { + items?: SchemaWithExample; + example?: unknown[]; +} = {}): SchemaWithExample { return SchemaWithExample.array({ - value: makeStringSchema(), - example: undefined, + value: items, + example, minItems: undefined, maxItems: undefined, default: undefined, @@ -577,6 +597,54 @@ describe("ExampleTypeFactory", () => { expect(explicitResult).toMatchObject({ type: "array", value: [] }); expect(absentResult).toBeUndefined(); }); + + it("should keep a required array property whose example is []", () => { + const schema = makeObjectSchema({ + properties: { + success: makeStringSchema(), + messages: makeArraySchema() + }, + additionalProperties: false + }); + + const result = factory.buildExample({ + schema, + exampleId: undefined, + example: { success: "false", messages: [] }, + options: DEFAULT_OPTIONS + }); + + expect(result).toMatchObject({ + type: "object", + properties: { messages: { type: "array", value: [] } } + }); + }); + + it("should keep an optional array whose schema-level example is []", () => { + const schema = makeOptionalSchema(makeArraySchema({ example: [] })); + + const result = factory.buildExample({ + schema, + exampleId: undefined, + example: undefined, + options: DEFAULT_OPTIONS + }); + + expect(result).toMatchObject({ type: "array", value: [] }); + }); + + it("should omit an optional array whose non-empty example has no buildable items", () => { + const schema = makeOptionalSchema(makeArraySchema({ items: makeMissingReferenceSchema() })); + + const result = factory.buildExample({ + schema, + exampleId: undefined, + example: [{ id: "unbuildable" }], + options: DEFAULT_OPTIONS + }); + + expect(result).toBeUndefined(); + }); }); describe("warning message truncation", () => { diff --git a/packages/cli/api-importers/openapi/openapi-ir-parser/src/schema/examples/__tests__/convertToFullExample.test.ts b/packages/cli/api-importers/openapi/openapi-ir-parser/src/schema/examples/__tests__/convertToFullExample.test.ts new file mode 100644 index 000000000..62276aa93 --- /dev/null +++ b/packages/cli/api-importers/openapi/openapi-ir-parser/src/schema/examples/__tests__/convertToFullExample.test.ts @@ -0,0 +1,68 @@ +import { FullExample, PrimitiveExample } from "@fern-api/openapi-ir"; +import { describe, expect, it } from "vitest"; + +import { convertToFullExample } from "../convertToFullExample.js"; + +// FullExample values carry _visit functions, so compare their JSON shape. +function plain(value: unknown): unknown { + return value === undefined ? undefined : JSON.parse(JSON.stringify(value)); +} + +describe("convertToFullExample", () => { + it("keeps empty arrays and nulls in an authored error body", () => { + const result = convertToFullExample({ + success: false, + errors: [{ code: 1001, message: "Missing required parameter: q" }], + messages: [], + result: null + }); + + expect(plain(result)).toEqual( + plain( + FullExample.map([ + { + key: PrimitiveExample.string("success"), + value: FullExample.primitive(PrimitiveExample.boolean(false)) + }, + { + key: PrimitiveExample.string("errors"), + value: FullExample.array([ + FullExample.map([ + { + key: PrimitiveExample.string("code"), + value: FullExample.primitive(PrimitiveExample.int(1001)) + }, + { + key: PrimitiveExample.string("message"), + value: FullExample.primitive( + PrimitiveExample.string("Missing required parameter: q") + ) + } + ]) + ]) + }, + { + key: PrimitiveExample.string("messages"), + value: FullExample.array([]) + }, + { + key: PrimitiveExample.string("result"), + value: FullExample.null({}) + } + ]) + ) + ); + }); + + it("keeps a top-level empty array", () => { + expect(plain(convertToFullExample([]))).toEqual(plain(FullExample.array([]))); + }); + + it("keeps null array items", () => { + expect(plain(convertToFullExample([null]))).toEqual(plain(FullExample.array([FullExample.null({})]))); + }); + + it("returns undefined for undefined", () => { + expect(convertToFullExample(undefined)).toBeUndefined(); + }); +}); diff --git a/packages/cli/api-importers/openapi/openapi-ir-parser/src/schema/examples/convertToFullExample.ts b/packages/cli/api-importers/openapi/openapi-ir-parser/src/schema/examples/convertToFullExample.ts index 06f67d7d9..4bb0604d6 100644 --- a/packages/cli/api-importers/openapi/openapi-ir-parser/src/schema/examples/convertToFullExample.ts +++ b/packages/cli/api-importers/openapi/openapi-ir-parser/src/schema/examples/convertToFullExample.ts @@ -1,7 +1,15 @@ import { FullExample, KeyValuePair, PrimitiveExample } from "@fern-api/openapi-ir"; +/** + * Converts an authored example value into a FullExample without consulting a schema. + * + * Empty arrays and nulls are values the author wrote, so they are kept. Only + * `undefined` and values that have no JSON representation produce `undefined`. + */ export function convertToFullExample(value: unknown): FullExample | undefined { - if (typeof value === "string") { + if (value === null) { + return FullExample.null({}); + } else if (typeof value === "string") { return FullExample.primitive(PrimitiveExample.string(value)); } else if (typeof value === "number") { if (Number.isInteger(value)) { @@ -11,11 +19,14 @@ export function convertToFullExample(value: unknown): FullExample | undefined { } else if (typeof value === "boolean") { return FullExample.primitive(PrimitiveExample.boolean(value)); } else if (Array.isArray(value)) { - const examples = value.map((example) => convertToFullExample(example)); - if (examples.length === 0) { - return undefined; + const examples: FullExample[] = []; + for (const item of value) { + const itemExample = convertToFullExample(item); + if (itemExample != null) { + examples.push(itemExample); + } } - return FullExample.array(examples.filter((example) => example != null) as FullExample[]); + return FullExample.array(examples); } else if ( value != null && typeof value === "object" && diff --git a/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi-ir/error-examples-empty-arrays.json b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi-ir/error-examples-empty-arrays.json new file mode 100644 index 000000000..04bb0d356 --- /dev/null +++ b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi-ir/error-examples-empty-arrays.json @@ -0,0 +1,891 @@ +{ + "specVersion": "1.0.0", + "title": "Error examples with empty arrays", + "servers": [], + "websocketServers": [], + "tags": { + "tagsById": {} + }, + "hasEndpointsMarkedInternal": false, + "endpoints": [ + { + "audiences": [], + "operationId": "searchDomains", + "tags": [], + "pathParameters": [], + "queryParameters": [ + { + "name": "q", + "schema": { + "schema": { + "type": "string" + }, + "generatedName": "SearchDomainsRequestQ", + "groupName": [], + "type": "primitive" + }, + "source": { + "file": "../openapi.yml", + "type": "openapi" + } + } + ], + "headers": [], + "generatedRequestName": "SearchDomainsRequest", + "response": { + "description": "Search results.", + "schema": { + "generatedName": "SearchDomainsResponse", + "schema": "search-response", + "source": { + "file": "../openapi.yml", + "type": "openapi" + }, + "type": "reference" + }, + "fullExamples": [ + { + "name": "No results", + "value": { + "success": true, + "errors": [], + "messages": [], + "result": { + "domains": [] + } + } + } + ], + "source": { + "file": "../openapi.yml", + "type": "openapi" + }, + "statusCode": 200, + "type": "json" + }, + "errors": { + "400": { + "generatedName": "BadRequestError", + "schema": { + "generatedName": "BadRequestErrorBody", + "schema": "api-response-common-failure", + "source": { + "file": "../openapi.yml", + "type": "openapi" + }, + "type": "reference" + }, + "description": "Invalid request parameters.", + "source": { + "file": "../openapi.yml", + "type": "openapi" + }, + "examples": [ + { + "name": "Missing search query", + "example": { + "value": [ + { + "key": { + "value": "success", + "type": "string" + }, + "value": { + "value": { + "value": false, + "type": "boolean" + }, + "type": "primitive" + } + }, + { + "key": { + "value": "errors", + "type": "string" + }, + "value": { + "value": [ + { + "value": [ + { + "key": { + "value": "code", + "type": "string" + }, + "value": { + "value": { + "value": 1001, + "type": "int" + }, + "type": "primitive" + } + }, + { + "key": { + "value": "message", + "type": "string" + }, + "value": { + "value": { + "value": "Missing required parameter: q", + "type": "string" + }, + "type": "primitive" + } + } + ], + "type": "map" + } + ], + "type": "array" + } + }, + { + "key": { + "value": "messages", + "type": "string" + }, + "value": { + "value": [], + "type": "array" + } + }, + { + "key": { + "value": "result", + "type": "string" + }, + "value": { + "type": "null" + } + } + ], + "type": "map" + } + } + ] + } + }, + "servers": [], + "authed": false, + "method": "GET", + "path": "/domains/search", + "examples": [ + { + "name": "No results", + "pathParameters": [], + "queryParameters": [ + { + "name": "q", + "value": { + "value": { + "value": "q", + "type": "string" + }, + "type": "primitive" + } + } + ], + "headers": [], + "response": { + "value": { + "properties": { + "success": { + "value": { + "value": true, + "type": "boolean" + }, + "type": "primitive" + }, + "errors": { + "value": [], + "type": "array" + }, + "messages": { + "value": [], + "type": "array" + }, + "result": { + "properties": { + "domains": { + "value": [], + "type": "array" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "type": "withoutStreaming" + }, + "codeSamples": [], + "type": "full" + } + ], + "source": { + "file": "../openapi.yml", + "type": "openapi" + } + }, + { + "audiences": [], + "operationId": "checkDomains", + "tags": [], + "pathParameters": [], + "queryParameters": [], + "headers": [], + "generatedRequestName": "CheckDomainsRequest", + "request": { + "schema": { + "allOf": [], + "properties": [ + { + "conflict": {}, + "generatedName": "checkDomainsRequestDomains", + "key": "domains", + "schema": { + "value": { + "schema": { + "type": "string" + }, + "generatedName": "CheckDomainsRequestDomainsItem", + "groupName": [], + "type": "primitive" + }, + "generatedName": "CheckDomainsRequestDomains", + "groupName": [], + "type": "array" + }, + "audiences": [] + } + ], + "allOfPropertyConflicts": [], + "generatedName": "CheckDomainsRequest", + "groupName": [], + "additionalProperties": false, + "source": { + "file": "../openapi.yml", + "type": "openapi" + }, + "type": "object" + }, + "contentType": "application/json", + "required": true, + "fullExamples": [], + "additionalProperties": false, + "source": { + "file": "../openapi.yml", + "type": "openapi" + }, + "type": "json" + }, + "response": { + "description": "Check results.", + "schema": { + "generatedName": "CheckDomainsResponse", + "schema": "search-response", + "source": { + "file": "../openapi.yml", + "type": "openapi" + }, + "type": "reference" + }, + "fullExamples": [], + "source": { + "file": "../openapi.yml", + "type": "openapi" + }, + "statusCode": 200, + "type": "json" + }, + "errors": { + "400": { + "generatedName": "BadRequestError", + "schema": { + "generatedName": "BadRequestErrorBody", + "schema": "api-response-common-failure", + "source": { + "file": "../openapi.yml", + "type": "openapi" + }, + "type": "reference" + }, + "description": "Invalid request body.", + "source": { + "file": "../openapi.yml", + "type": "openapi" + }, + "examples": [ + { + "example": { + "value": [ + { + "key": { + "value": "success", + "type": "string" + }, + "value": { + "value": { + "value": false, + "type": "boolean" + }, + "type": "primitive" + } + }, + { + "key": { + "value": "errors", + "type": "string" + }, + "value": { + "value": [ + { + "value": [ + { + "key": { + "value": "code", + "type": "string" + }, + "value": { + "value": { + "value": 1006, + "type": "int" + }, + "type": "primitive" + } + }, + { + "key": { + "value": "message", + "type": "string" + }, + "value": { + "value": { + "value": "domains array must contain at least one domain", + "type": "string" + }, + "type": "primitive" + } + } + ], + "type": "map" + } + ], + "type": "array" + } + }, + { + "key": { + "value": "messages", + "type": "string" + }, + "value": { + "value": [], + "type": "array" + } + }, + { + "key": { + "value": "result", + "type": "string" + }, + "value": { + "type": "null" + } + } + ], + "type": "map" + } + } + ] + }, + "503": { + "generatedName": "ServiceUnavailableError", + "schema": { + "generatedName": "ServiceUnavailableErrorBody", + "schema": "api-response-common-failure", + "source": { + "file": "../openapi.yml", + "type": "openapi" + }, + "type": "reference" + }, + "description": "Upstream failure.", + "source": { + "file": "../openapi.yml", + "type": "openapi" + }, + "examples": [ + { + "name": "All checks failed upstream", + "example": { + "value": [ + { + "key": { + "value": "success", + "type": "string" + }, + "value": { + "value": { + "value": false, + "type": "boolean" + }, + "type": "primitive" + } + }, + { + "key": { + "value": "errors", + "type": "string" + }, + "value": { + "value": [ + { + "value": [ + { + "key": { + "value": "code", + "type": "string" + }, + "value": { + "value": { + "value": 10000, + "type": "int" + }, + "type": "primitive" + } + }, + { + "key": { + "value": "message", + "type": "string" + }, + "value": { + "value": { + "value": "Internal API failure for domain example.com", + "type": "string" + }, + "type": "primitive" + } + }, + { + "key": { + "value": "source", + "type": "string" + }, + "value": { + "value": [ + { + "key": { + "value": "pointer", + "type": "string" + }, + "value": { + "value": { + "value": "/domains/0", + "type": "string" + }, + "type": "primitive" + } + } + ], + "type": "map" + } + } + ], + "type": "map" + } + ], + "type": "array" + } + }, + { + "key": { + "value": "messages", + "type": "string" + }, + "value": { + "value": [], + "type": "array" + } + }, + { + "key": { + "value": "result", + "type": "string" + }, + "value": { + "type": "null" + } + } + ], + "type": "map" + } + } + ] + } + }, + "servers": [], + "authed": false, + "method": "POST", + "path": "/domains/check", + "examples": [ + { + "pathParameters": [], + "queryParameters": [], + "headers": [], + "request": { + "properties": { + "domains": { + "value": [ + { + "value": { + "value": "domains", + "type": "string" + }, + "type": "primitive" + } + ], + "type": "array" + } + }, + "type": "object" + }, + "response": { + "value": { + "properties": { + "success": { + "value": { + "value": true, + "type": "boolean" + }, + "type": "primitive" + }, + "errors": { + "value": [], + "type": "array" + }, + "messages": { + "value": [], + "type": "array" + }, + "result": { + "properties": { + "domains": { + "value": [ + { + "value": { + "value": "domains", + "type": "string" + }, + "type": "primitive" + } + ], + "type": "array" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "type": "withoutStreaming" + }, + "codeSamples": [], + "type": "full" + } + ], + "source": { + "file": "../openapi.yml", + "type": "openapi" + } + } + ], + "webhooks": [], + "channels": {}, + "groupedSchemas": { + "rootSchemas": { + "messages": { + "value": { + "allOf": [], + "properties": [ + { + "conflict": {}, + "generatedName": "messagesItemCode", + "key": "code", + "schema": { + "schema": { + "minimum": 1000, + "type": "int" + }, + "generatedName": "MessagesItemCode", + "groupName": [], + "type": "primitive" + }, + "audiences": [] + }, + { + "conflict": {}, + "generatedName": "messagesItemMessage", + "key": "message", + "schema": { + "schema": { + "type": "string" + }, + "generatedName": "MessagesItemMessage", + "groupName": [], + "type": "primitive" + }, + "audiences": [] + }, + { + "conflict": {}, + "generatedName": "messagesItemSource", + "key": "source", + "schema": { + "generatedName": "MessagesItemSource", + "value": { + "allOf": [], + "properties": [ + { + "conflict": {}, + "generatedName": "messagesItemSourcePointer", + "key": "pointer", + "schema": { + "schema": { + "type": "string" + }, + "generatedName": "MessagesItemSourcePointer", + "groupName": [], + "type": "primitive" + }, + "audiences": [] + } + ], + "allOfPropertyConflicts": [], + "generatedName": "MessagesItemSource", + "groupName": [], + "additionalProperties": false, + "source": { + "file": "../openapi.yml", + "type": "openapi" + }, + "type": "object" + }, + "groupName": [], + "type": "optional" + }, + "audiences": [] + } + ], + "allOfPropertyConflicts": [], + "generatedName": "MessagesItem", + "groupName": [], + "additionalProperties": false, + "source": { + "file": "../openapi.yml", + "type": "openapi" + }, + "type": "object" + }, + "generatedName": "Messages", + "groupName": [], + "type": "array" + }, + "search-response": { + "allOf": [], + "properties": [ + { + "conflict": {}, + "generatedName": "searchResponseSuccess", + "key": "success", + "schema": { + "schema": { + "type": "boolean" + }, + "generatedName": "SearchResponseSuccess", + "groupName": [], + "type": "primitive" + }, + "audiences": [] + }, + { + "conflict": {}, + "generatedName": "searchResponseErrors", + "key": "errors", + "schema": { + "generatedName": "SearchResponseErrors", + "schema": "messages", + "source": { + "file": "../openapi.yml", + "type": "openapi" + }, + "type": "reference" + }, + "audiences": [] + }, + { + "conflict": {}, + "generatedName": "searchResponseMessages", + "key": "messages", + "schema": { + "generatedName": "SearchResponseMessages", + "schema": "messages", + "source": { + "file": "../openapi.yml", + "type": "openapi" + }, + "type": "reference" + }, + "audiences": [] + }, + { + "conflict": {}, + "generatedName": "searchResponseResult", + "key": "result", + "schema": { + "allOf": [], + "properties": [ + { + "conflict": {}, + "generatedName": "searchResponseResultDomains", + "key": "domains", + "schema": { + "value": { + "schema": { + "type": "string" + }, + "generatedName": "SearchResponseResultDomainsItem", + "groupName": [], + "type": "primitive" + }, + "generatedName": "SearchResponseResultDomains", + "groupName": [], + "type": "array" + }, + "audiences": [] + } + ], + "allOfPropertyConflicts": [], + "generatedName": "SearchResponseResult", + "groupName": [], + "additionalProperties": false, + "source": { + "file": "../openapi.yml", + "type": "openapi" + }, + "type": "object" + }, + "audiences": [] + } + ], + "allOfPropertyConflicts": [], + "generatedName": "SearchResponse", + "groupName": [], + "additionalProperties": false, + "source": { + "file": "../openapi.yml", + "type": "openapi" + }, + "type": "object" + }, + "api-response-common-failure": { + "allOf": [], + "properties": [ + { + "conflict": {}, + "generatedName": "apiResponseCommonFailureSuccess", + "key": "success", + "schema": { + "schema": { + "type": "boolean" + }, + "generatedName": "ApiResponseCommonFailureSuccess", + "groupName": [], + "type": "primitive" + }, + "audiences": [] + }, + { + "conflict": {}, + "generatedName": "apiResponseCommonFailureErrors", + "key": "errors", + "schema": { + "generatedName": "ApiResponseCommonFailureErrors", + "schema": "messages", + "groupName": [], + "source": { + "file": "../openapi.yml", + "type": "openapi" + }, + "type": "reference" + }, + "audiences": [] + }, + { + "conflict": {}, + "generatedName": "apiResponseCommonFailureMessages", + "key": "messages", + "schema": { + "generatedName": "ApiResponseCommonFailureMessages", + "schema": "messages", + "groupName": [], + "source": { + "file": "../openapi.yml", + "type": "openapi" + }, + "type": "reference" + }, + "audiences": [] + }, + { + "conflict": {}, + "generatedName": "apiResponseCommonFailureResult", + "key": "result", + "schema": { + "generatedName": "ApiResponseCommonFailureResult", + "value": { + "key": { + "schema": { + "type": "string" + }, + "generatedName": "ApiResponseCommonFailureResultKey", + "groupName": [], + "type": "primitive" + }, + "value": { + "generatedName": "ApiResponseCommonFailureResultValue", + "groupName": [], + "type": "unknown" + }, + "generatedName": "ApiResponseCommonFailureResult", + "groupName": [], + "type": "map" + }, + "groupName": [], + "type": "nullable" + }, + "audiences": [] + } + ], + "allOfPropertyConflicts": [], + "generatedName": "ApiResponseCommonFailure", + "groupName": [], + "additionalProperties": false, + "source": { + "file": "../openapi.yml", + "type": "openapi" + }, + "type": "object" + } + }, + "namespacedSchemas": {} + }, + "variables": {}, + "nonRequestReferencedSchemas": {}, + "securitySchemes": {}, + "globalHeaders": [], + "idempotencyHeaders": [], + "groups": {} +} \ No newline at end of file diff --git a/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi/error-examples-empty-arrays.json b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi/error-examples-empty-arrays.json new file mode 100644 index 000000000..3a40ce3f0 --- /dev/null +++ b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi/error-examples-empty-arrays.json @@ -0,0 +1,404 @@ +{ + "absoluteFilePath": "/DUMMY_PATH", + "importedDefinitions": {}, + "namedDefinitionFiles": { + "__package__.yml": { + "absoluteFilepath": "/DUMMY_PATH", + "contents": { + "errors": { + "BadRequestError": { + "docs": "Invalid request parameters.", + "examples": [ + { + "docs": undefined, + "name": "Missing search query", + "value": { + "errors": [ + { + "code": 1001, + "message": "Missing required parameter: q", + }, + ], + "messages": [], + "result": null, + "success": false, + }, + }, + { + "docs": undefined, + "name": undefined, + "value": { + "errors": [ + { + "code": 1006, + "message": "domains array must contain at least one domain", + }, + ], + "messages": [], + "result": null, + "success": false, + }, + }, + ], + "status-code": 400, + "type": "ApiResponseCommonFailure", + }, + "ServiceUnavailableError": { + "docs": "Upstream failure.", + "examples": [ + { + "docs": undefined, + "name": "All checks failed upstream", + "value": { + "errors": [ + { + "code": 10000, + "message": "Internal API failure for domain example.com", + "source": { + "pointer": "/domains/0", + }, + }, + ], + "messages": [], + "result": null, + "success": false, + }, + }, + ], + "status-code": 503, + "type": "ApiResponseCommonFailure", + }, + }, + "service": { + "auth": false, + "base-path": "", + "endpoints": { + "checkDomains": { + "auth": undefined, + "docs": undefined, + "errors": [ + "BadRequestError", + "ServiceUnavailableError", + ], + "examples": [ + { + "request": { + "domains": [ + "domains", + ], + }, + "response": { + "body": { + "errors": [], + "messages": [], + "result": { + "domains": [ + "domains", + ], + }, + "success": true, + }, + }, + }, + ], + "method": "POST", + "pagination": undefined, + "path": "/domains/check", + "request": { + "body": { + "properties": { + "domains": "list", + }, + }, + "content-type": "application/json", + "headers": undefined, + "name": "CheckDomainsRequest", + "path-parameters": undefined, + "query-parameters": undefined, + }, + "response": { + "docs": "Check results.", + "status-code": 200, + "type": "SearchResponse", + }, + "source": { + "openapi": "../openapi.yml", + }, + }, + "searchDomains": { + "auth": undefined, + "docs": undefined, + "errors": [ + "BadRequestError", + ], + "examples": [ + { + "name": "No results", + "query-parameters": { + "q": "q", + }, + "response": { + "body": { + "errors": [], + "messages": [], + "result": { + "domains": [], + }, + "success": true, + }, + }, + }, + ], + "method": "GET", + "pagination": undefined, + "path": "/domains/search", + "request": { + "name": "SearchDomainsRequest", + "query-parameters": { + "q": "string", + }, + }, + "response": { + "docs": "Search results.", + "status-code": 200, + "type": "SearchResponse", + }, + "source": { + "openapi": "../openapi.yml", + }, + }, + }, + "source": { + "openapi": "../openapi.yml", + }, + }, + "types": { + "ApiResponseCommonFailure": { + "docs": undefined, + "inline": undefined, + "properties": { + "errors": "Messages", + "messages": "Messages", + "result": "nullable>", + "success": "boolean", + }, + "source": { + "openapi": "../openapi.yml", + }, + }, + "Messages": "list", + "MessagesItem": { + "docs": undefined, + "inline": undefined, + "properties": { + "code": { + "type": "integer", + "validation": { + "exclusiveMax": undefined, + "exclusiveMin": undefined, + "max": undefined, + "min": 1000, + "multipleOf": undefined, + }, + }, + "message": "string", + "source": "optional", + }, + "source": { + "openapi": "../openapi.yml", + }, + }, + "MessagesItemSource": { + "docs": undefined, + "inline": true, + "properties": { + "pointer": "string", + }, + "source": { + "openapi": "../openapi.yml", + }, + }, + "SearchResponse": { + "docs": undefined, + "inline": undefined, + "properties": { + "errors": "Messages", + "messages": "Messages", + "result": "SearchResponseResult", + "success": "boolean", + }, + "source": { + "openapi": "../openapi.yml", + }, + }, + "SearchResponseResult": { + "docs": undefined, + "inline": true, + "properties": { + "domains": "list", + }, + "source": { + "openapi": "../openapi.yml", + }, + }, + }, + }, + "rawContents": "errors: + BadRequestError: + status-code: 400 + type: ApiResponseCommonFailure + docs: Invalid request parameters. + examples: + - value: + success: false + errors: + - code: 1001 + message: 'Missing required parameter: q' + messages: [] + result: null + name: Missing search query + - value: + success: false + errors: + - code: 1006 + message: domains array must contain at least one domain + messages: [] + result: null + ServiceUnavailableError: + status-code: 503 + type: ApiResponseCommonFailure + docs: Upstream failure. + examples: + - value: + success: false + errors: + - code: 10000 + message: Internal API failure for domain example.com + source: + pointer: /domains/0 + messages: [] + result: null + name: All checks failed upstream +service: + auth: false + base-path: '' + endpoints: + searchDomains: + path: /domains/search + method: GET + source: + openapi: ../openapi.yml + request: + name: SearchDomainsRequest + query-parameters: + q: string + response: + docs: Search results. + type: SearchResponse + status-code: 200 + errors: + - BadRequestError + examples: + - name: No results + query-parameters: + q: q + response: + body: + success: true + errors: [] + messages: [] + result: + domains: [] + checkDomains: + path: /domains/check + method: POST + source: + openapi: ../openapi.yml + request: + name: CheckDomainsRequest + body: + properties: + domains: list + content-type: application/json + response: + docs: Check results. + type: SearchResponse + status-code: 200 + errors: + - BadRequestError + - ServiceUnavailableError + examples: + - request: + domains: + - domains + response: + body: + success: true + errors: [] + messages: [] + result: + domains: + - domains + source: + openapi: ../openapi.yml +types: + MessagesItemSource: + properties: + pointer: string + source: + openapi: ../openapi.yml + inline: true + MessagesItem: + properties: + code: + type: integer + validation: + min: 1000 + message: string + source: optional + source: + openapi: ../openapi.yml + Messages: list + SearchResponseResult: + properties: + domains: list + source: + openapi: ../openapi.yml + inline: true + SearchResponse: + properties: + success: boolean + errors: Messages + messages: Messages + result: SearchResponseResult + source: + openapi: ../openapi.yml + ApiResponseCommonFailure: + properties: + success: boolean + errors: Messages + messages: Messages + result: nullable> + source: + openapi: ../openapi.yml +", + }, + }, + "packageMarkers": {}, + "rootApiFile": { + "contents": { + "display-name": "Error examples with empty arrays", + "error-discrimination": { + "strategy": "status-code", + }, + "name": "api", + }, + "defaultUrl": undefined, + "rawContents": "name: api +error-discrimination: + strategy: status-code +display-name: Error examples with empty arrays +", + }, + "specVersion": "1.0.0", +} \ No newline at end of file diff --git a/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/fixtures/error-examples-empty-arrays/fern/fern.config.json b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/fixtures/error-examples-empty-arrays/fern/fern.config.json new file mode 100644 index 000000000..7980537f5 --- /dev/null +++ b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/fixtures/error-examples-empty-arrays/fern/fern.config.json @@ -0,0 +1,4 @@ +{ + "organization": "fern", + "version": "*" +} \ No newline at end of file diff --git a/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/fixtures/error-examples-empty-arrays/fern/generators.yml b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/fixtures/error-examples-empty-arrays/fern/generators.yml new file mode 100644 index 000000000..5b01f1e08 --- /dev/null +++ b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/fixtures/error-examples-empty-arrays/fern/generators.yml @@ -0,0 +1,4 @@ +# yaml-language-server: $schema=https://schema.buildwithfern.dev/generators-yml.json +api: + specs: + - openapi: ../openapi.yml diff --git a/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/fixtures/error-examples-empty-arrays/openapi.yml b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/fixtures/error-examples-empty-arrays/openapi.yml new file mode 100644 index 000000000..c94eaec04 --- /dev/null +++ b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/fixtures/error-examples-empty-arrays/openapi.yml @@ -0,0 +1,171 @@ +openapi: 3.0.3 +info: + title: Error examples with empty arrays + version: 1.0.0 +paths: + /domains/search: + get: + operationId: searchDomains + parameters: + - name: q + in: query + required: true + schema: + type: string + responses: + "200": + description: Search results. + content: + application/json: + schema: + $ref: "#/components/schemas/search-response" + examples: + no_results: + summary: No results + value: + success: true + errors: [] + messages: [] + result: + domains: [] + "400": + description: Invalid request parameters. + content: + application/json: + schema: + $ref: "#/components/schemas/api-response-common-failure" + examples: + missing_query: + summary: Missing search query + value: + success: false + errors: + - code: 1001 + message: "Missing required parameter: q" + messages: [] + result: null + /domains/check: + post: + operationId: checkDomains + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - domains + properties: + domains: + type: array + items: + type: string + responses: + "200": + description: Check results. + content: + application/json: + schema: + $ref: "#/components/schemas/search-response" + "400": + description: Invalid request body. + content: + application/json: + schema: + $ref: "#/components/schemas/api-response-common-failure" + example: + success: false + errors: + - code: 1006 + message: domains array must contain at least one domain + messages: [] + result: null + "503": + description: Upstream failure. + content: + application/json: + schema: + $ref: "#/components/schemas/api-response-common-failure" + examples: + all_failed: + summary: All checks failed upstream + value: + success: false + errors: + - code: 10000 + message: Internal API failure for domain example.com + source: + pointer: /domains/0 + messages: [] + result: null +components: + schemas: + messages: + type: array + items: + type: object + required: + - code + - message + properties: + code: + type: integer + minimum: 1000 + message: + type: string + source: + type: object + required: + - pointer + properties: + pointer: + type: string + example: [] + search-response: + type: object + required: + - success + - errors + - messages + - result + properties: + success: + type: boolean + errors: + $ref: "#/components/schemas/messages" + messages: + $ref: "#/components/schemas/messages" + result: + type: object + required: + - domains + properties: + domains: + type: array + items: + type: string + api-response-common-failure: + type: object + required: + - success + - errors + - messages + - result + properties: + success: + type: boolean + enum: + - false + errors: + allOf: + - $ref: "#/components/schemas/messages" + minItems: 1 + messages: + allOf: + - $ref: "#/components/schemas/messages" + example: [] + result: + type: object + nullable: true + enum: + - null diff --git a/packages/cli/cli/changes/unreleased/fix-openapi-error-example-empty-arrays.yml b/packages/cli/cli/changes/unreleased/fix-openapi-error-example-empty-arrays.yml new file mode 100644 index 000000000..430dc4e94 --- /dev/null +++ b/packages/cli/cli/changes/unreleased/fix-openapi-error-example-empty-arrays.yml @@ -0,0 +1,5 @@ +- summary: | + Keep `[]` and `null` values in authored OpenAPI error and webhook examples, so a + required array property such as `messages: []` no longer fails `valid-example-error`. + An optional array example whose items all fail to build is omitted, not replaced with `[]`. + type: fix diff --git a/packages/cli/ete-tests/src/tests/validate/__snapshots__/validate.test.ts.snap b/packages/cli/ete-tests/src/tests/validate/__snapshots__/validate.test.ts.snap index 92a3f9e58..2af11440a 100644 --- a/packages/cli/ete-tests/src/tests/validate/__snapshots__/validate.test.ts.snap +++ b/packages/cli/ete-tests/src/tests/validate/__snapshots__/validate.test.ts.snap @@ -9,6 +9,8 @@ For more information, see https://buildwithfern.com/learn/api-definition/introdu All checks passed" `; +exports[`validate > error-examples-empty-arrays 1`] = `"All checks passed"`; + exports[`validate > no-api 1`] = `"Missing file: api.yml"`; exports[`validate > no-generator 1`] = ` diff --git a/packages/cli/ete-tests/src/tests/validate/fixtures/error-examples-empty-arrays/fern/fern.config.json b/packages/cli/ete-tests/src/tests/validate/fixtures/error-examples-empty-arrays/fern/fern.config.json new file mode 100644 index 000000000..9538944f2 --- /dev/null +++ b/packages/cli/ete-tests/src/tests/validate/fixtures/error-examples-empty-arrays/fern/fern.config.json @@ -0,0 +1,4 @@ +{ + "version": "*", + "organization": "fern" +} diff --git a/packages/cli/ete-tests/src/tests/validate/fixtures/error-examples-empty-arrays/fern/generators.yml b/packages/cli/ete-tests/src/tests/validate/fixtures/error-examples-empty-arrays/fern/generators.yml new file mode 100644 index 000000000..bc9a333a3 --- /dev/null +++ b/packages/cli/ete-tests/src/tests/validate/fixtures/error-examples-empty-arrays/fern/generators.yml @@ -0,0 +1,3 @@ +api: + specs: + - openapi: openapi.yml diff --git a/packages/cli/ete-tests/src/tests/validate/fixtures/error-examples-empty-arrays/fern/openapi.yml b/packages/cli/ete-tests/src/tests/validate/fixtures/error-examples-empty-arrays/fern/openapi.yml new file mode 100644 index 000000000..c94eaec04 --- /dev/null +++ b/packages/cli/ete-tests/src/tests/validate/fixtures/error-examples-empty-arrays/fern/openapi.yml @@ -0,0 +1,171 @@ +openapi: 3.0.3 +info: + title: Error examples with empty arrays + version: 1.0.0 +paths: + /domains/search: + get: + operationId: searchDomains + parameters: + - name: q + in: query + required: true + schema: + type: string + responses: + "200": + description: Search results. + content: + application/json: + schema: + $ref: "#/components/schemas/search-response" + examples: + no_results: + summary: No results + value: + success: true + errors: [] + messages: [] + result: + domains: [] + "400": + description: Invalid request parameters. + content: + application/json: + schema: + $ref: "#/components/schemas/api-response-common-failure" + examples: + missing_query: + summary: Missing search query + value: + success: false + errors: + - code: 1001 + message: "Missing required parameter: q" + messages: [] + result: null + /domains/check: + post: + operationId: checkDomains + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - domains + properties: + domains: + type: array + items: + type: string + responses: + "200": + description: Check results. + content: + application/json: + schema: + $ref: "#/components/schemas/search-response" + "400": + description: Invalid request body. + content: + application/json: + schema: + $ref: "#/components/schemas/api-response-common-failure" + example: + success: false + errors: + - code: 1006 + message: domains array must contain at least one domain + messages: [] + result: null + "503": + description: Upstream failure. + content: + application/json: + schema: + $ref: "#/components/schemas/api-response-common-failure" + examples: + all_failed: + summary: All checks failed upstream + value: + success: false + errors: + - code: 10000 + message: Internal API failure for domain example.com + source: + pointer: /domains/0 + messages: [] + result: null +components: + schemas: + messages: + type: array + items: + type: object + required: + - code + - message + properties: + code: + type: integer + minimum: 1000 + message: + type: string + source: + type: object + required: + - pointer + properties: + pointer: + type: string + example: [] + search-response: + type: object + required: + - success + - errors + - messages + - result + properties: + success: + type: boolean + errors: + $ref: "#/components/schemas/messages" + messages: + $ref: "#/components/schemas/messages" + result: + type: object + required: + - domains + properties: + domains: + type: array + items: + type: string + api-response-common-failure: + type: object + required: + - success + - errors + - messages + - result + properties: + success: + type: boolean + enum: + - false + errors: + allOf: + - $ref: "#/components/schemas/messages" + minItems: 1 + messages: + allOf: + - $ref: "#/components/schemas/messages" + example: [] + result: + type: object + nullable: true + enum: + - null diff --git a/packages/cli/ete-tests/src/tests/validate/validate.test.ts b/packages/cli/ete-tests/src/tests/validate/validate.test.ts index 9e69ffafd..4207dcef0 100644 --- a/packages/cli/ete-tests/src/tests/validate/validate.test.ts +++ b/packages/cli/ete-tests/src/tests/validate/validate.test.ts @@ -49,6 +49,7 @@ describe("validate", () => { itFixture("docs"); itFixture("no-api"); itFixture("no-generator"); + itFixture("error-examples-empty-arrays"); it("check with --api resolves all APIs referenced by docs", async ({ signal }) => { const fixture = await createTempFixture({