diff --git a/fern-yml.schema.json b/fern-yml.schema.json index 9f988793bb0c..09260bfca16d 100644 --- a/fern-yml.schema.json +++ b/fern-yml.schema.json @@ -3876,6 +3876,9 @@ "respectReadonlySchemas": { "type": "boolean" }, + "useReadVariantForResponses": { + "type": "boolean" + }, "respectForwardCompatibleEnums": { "type": "boolean" }, @@ -4221,6 +4224,9 @@ } ] }, + "examples": { + "type": "string" + }, "name": { "type": "string" } @@ -4444,6 +4450,9 @@ "respectReadonlySchemas": { "type": "boolean" }, + "useReadVariantForResponses": { + "type": "boolean" + }, "respectForwardCompatibleEnums": { "type": "boolean" }, @@ -4789,6 +4798,9 @@ } ] }, + "examples": { + "type": "string" + }, "name": { "type": "string" } diff --git a/fern/apis/generators-yml/definition/generators.yml b/fern/apis/generators-yml/definition/generators.yml index 5d3724177fc4..893fc09f1c71 100644 --- a/fern/apis/generators-yml/definition/generators.yml +++ b/fern/apis/generators-yml/definition/generators.yml @@ -654,6 +654,7 @@ types: graphql: string origin: optional overrides: optional + examples: optional name: optional ProtobufSpecSchema: diff --git a/generators-yml.schema.json b/generators-yml.schema.json index 95722fc23aa2..593e6aaf4f82 100644 --- a/generators-yml.schema.json +++ b/generators-yml.schema.json @@ -2751,6 +2751,16 @@ } ] }, + "examples": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, "name": { "oneOf": [ { diff --git a/generators/python/sdk/changes/5.14.2/bump-generator-cli-0.9.35.yml b/generators/python/sdk/changes/5.14.2/bump-generator-cli-0.9.35.yml new file mode 100644 index 000000000000..4d1d66d928a4 --- /dev/null +++ b/generators/python/sdk/changes/5.14.2/bump-generator-cli-0.9.35.yml @@ -0,0 +1,7 @@ +# yaml-language-server: $schema=../../../../../fern-changes-yml.schema.json + +- summary: | + Bump @fern-api/generator-cli to 0.9.35, which disables minimatch negation + on `.fernignore` patterns so a stray `!pattern` no longer silently inverts + the match and discards generator output. + type: fix diff --git a/generators/python/sdk/versions.yml b/generators/python/sdk/versions.yml index 160f65696af3..e2cafbf37a5e 100644 --- a/generators/python/sdk/versions.yml +++ b/generators/python/sdk/versions.yml @@ -1,4 +1,13 @@ # yaml-language-server: $schema=../../../fern-versions-yml.schema.json +- version: 5.14.2 + changelogEntry: + - summary: | + Bump @fern-api/generator-cli to 0.9.35, which disables minimatch negation + on `.fernignore` patterns so a stray `!pattern` no longer silently inverts + the match and discards generator output. + type: fix + createdAt: "2026-05-21" + irVersion: 67 - version: 5.14.1 changelogEntry: - summary: | diff --git a/generators/typescript/sdk/changes/3.71.1/bump-generator-cli-0.9.35.yml b/generators/typescript/sdk/changes/3.71.1/bump-generator-cli-0.9.35.yml new file mode 100644 index 000000000000..4d1d66d928a4 --- /dev/null +++ b/generators/typescript/sdk/changes/3.71.1/bump-generator-cli-0.9.35.yml @@ -0,0 +1,7 @@ +# yaml-language-server: $schema=../../../../../fern-changes-yml.schema.json + +- summary: | + Bump @fern-api/generator-cli to 0.9.35, which disables minimatch negation + on `.fernignore` patterns so a stray `!pattern` no longer silently inverts + the match and discards generator output. + type: fix diff --git a/generators/typescript/sdk/versions.yml b/generators/typescript/sdk/versions.yml index 42e764de065c..89fa9c003574 100644 --- a/generators/typescript/sdk/versions.yml +++ b/generators/typescript/sdk/versions.yml @@ -1,4 +1,13 @@ # yaml-language-server: $schema=../../../fern-versions-yml.schema.json +- version: 3.71.1 + changelogEntry: + - summary: | + Bump @fern-api/generator-cli to 0.9.35, which disables minimatch negation + on `.fernignore` patterns so a stray `!pattern` no longer silently inverts + the match and discards generator output. + type: fix + createdAt: "2026-05-21" + irVersion: 67 - version: 3.71.0 changelogEntry: - summary: | diff --git a/packages/cli/api-importers/graphql/src/GraphQLConverter.ts b/packages/cli/api-importers/graphql/src/GraphQLConverter.ts index 11962eaf4c36..0ca6bc592c0e 100644 --- a/packages/cli/api-importers/graphql/src/GraphQLConverter.ts +++ b/packages/cli/api-importers/graphql/src/GraphQLConverter.ts @@ -24,6 +24,20 @@ export interface GraphQLConverterResult { types: Record; } +export interface GraphQlExampleInput { + name?: string; + description?: string; + query: string; + variables?: Record; + response?: unknown; +} + +export interface GraphQlOperationExamplesInput { + operation: string; + operationType?: "query" | "mutation" | "subscription"; + examples: GraphQlExampleInput[]; +} + export class GraphQLConverter { private schema: GraphQLSchema | undefined; private context: TaskContext; @@ -31,15 +45,39 @@ export class GraphQLConverter { private namespace: string | undefined; private processingTypes: Set = new Set(); private types: Record = {}; + private examplesByOperation: Map = new Map(); constructor({ context, filePath, - namespace - }: { context: TaskContext; filePath: AbsoluteFilePath; namespace?: string }) { + namespace, + examples + }: { + context: TaskContext; + filePath: AbsoluteFilePath; + namespace?: string; + examples?: GraphQlOperationExamplesInput[]; + }) { this.context = context; this.filePath = filePath; this.namespace = namespace; + if (examples != null) { + for (const entry of examples) { + const mapped = entry.examples.map((ex) => ({ + name: ex.name ?? undefined, + description: ex.description ?? undefined, + query: ex.query, + variables: ex.variables ?? undefined, + response: ex.response ?? undefined + })); + if (entry.operationType != null) { + const key = `${entry.operationType.toLowerCase()}:${entry.operation}`; + this.examplesByOperation.set(key, mapped); + } else { + this.examplesByOperation.set(entry.operation, mapped); + } + } + } } private isBuiltInScalar(typeName: string): boolean { @@ -249,6 +287,9 @@ export class GraphQLConverter { operationType: FdrAPI.api.v1.register.GraphQlOperationType ): FdrAPI.api.v1.register.GraphQlOperation { const args = field.args.map((arg) => this.convertArgument(arg)); + const examples = + this.examplesByOperation.get(`${operationType.toLowerCase()}:${name}`) ?? + this.examplesByOperation.get(name); return { id: this.getNamespacedOperationId(`${operationType.toLowerCase()}_${name}`), @@ -259,7 +300,7 @@ export class GraphQLConverter { availability: undefined, arguments: args.length > 0 ? args : undefined, returnType: this.convertOutputType(field.type), - examples: undefined, + examples: examples != null && examples.length > 0 ? examples : undefined, snippets: undefined }; } diff --git a/packages/cli/api-importers/graphql/src/index.ts b/packages/cli/api-importers/graphql/src/index.ts index 6d313c0ec934..be8f064710cc 100644 --- a/packages/cli/api-importers/graphql/src/index.ts +++ b/packages/cli/api-importers/graphql/src/index.ts @@ -1,2 +1,2 @@ -export type { GraphQLConverterResult } from "./GraphQLConverter.js"; +export type { GraphQLConverterResult, GraphQlExampleInput, GraphQlOperationExamplesInput } from "./GraphQLConverter.js"; export { GraphQLConverter } from "./GraphQLConverter.js"; diff --git a/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi-ir/readonly-endpoint-response-read-variant.json b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi-ir/readonly-endpoint-response-read-variant.json new file mode 100644 index 000000000000..a362916bada8 --- /dev/null +++ b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi-ir/readonly-endpoint-response-read-variant.json @@ -0,0 +1,511 @@ +{ + "title": "Readonly Endpoint Response API", + "description": "Tests that when a schema with readOnly properties is used as the direct response type of an endpoint (not just nested within another schema), the endpoint response type reference is correctly remapped to the Read variant.\n", + "servers": [], + "websocketServers": [], + "tags": { + "tagsById": {} + }, + "hasEndpointsMarkedInternal": false, + "endpoints": [ + { + "summary": "Create a webhook", + "audiences": [], + "tags": [], + "pathParameters": [], + "queryParameters": [], + "headers": [], + "generatedRequestName": "PostWebhooksRequest", + "request": { + "schema": { + "generatedName": "PostWebhooksRequest", + "schema": "Webhook", + "source": { + "file": "../openapi.yml", + "type": "openapi" + }, + "type": "reference" + }, + "contentType": "application/json", + "fullExamples": [], + "additionalProperties": false, + "source": { + "file": "../openapi.yml", + "type": "openapi" + }, + "type": "json" + }, + "response": { + "description": "Webhook created successfully", + "schema": { + "generatedName": "PostWebhooksResponse", + "schema": "Webhook", + "source": { + "file": "../openapi.yml", + "type": "openapi" + }, + "type": "reference" + }, + "fullExamples": [], + "source": { + "file": "../openapi.yml", + "type": "openapi" + }, + "statusCode": 200, + "type": "json" + }, + "errors": {}, + "servers": [], + "authed": false, + "method": "POST", + "path": "/webhooks", + "examples": [ + { + "pathParameters": [], + "queryParameters": [], + "headers": [], + "request": { + "properties": {}, + "type": "object" + }, + "response": { + "value": { + "properties": { + "id": { + "value": { + "value": "id", + "type": "string" + }, + "type": "primitive" + }, + "uri": { + "value": { + "value": "uri", + "type": "string" + }, + "type": "primitive" + }, + "created_at": { + "value": { + "value": "2024-01-15T09:30:00Z", + "type": "datetime" + }, + "type": "primitive" + }, + "state": { + "value": "enabled", + "type": "enum" + } + }, + "type": "object" + }, + "type": "withoutStreaming" + }, + "codeSamples": [], + "type": "full" + } + ], + "source": { + "file": "../openapi.yml", + "type": "openapi" + } + }, + { + "summary": "Get a webhook", + "audiences": [], + "tags": [], + "pathParameters": [ + { + "name": "id", + "schema": { + "schema": { + "type": "string" + }, + "generatedName": "GetWebhooksIdRequestId", + "groupName": [], + "type": "primitive" + }, + "source": { + "file": "../openapi.yml", + "type": "openapi" + } + } + ], + "queryParameters": [], + "headers": [], + "generatedRequestName": "GetWebhooksIdRequest", + "response": { + "description": "Webhook retrieved successfully", + "schema": { + "generatedName": "GetWebhooksIdResponse", + "schema": "Webhook", + "source": { + "file": "../openapi.yml", + "type": "openapi" + }, + "type": "reference" + }, + "fullExamples": [], + "source": { + "file": "../openapi.yml", + "type": "openapi" + }, + "statusCode": 200, + "type": "json" + }, + "errors": {}, + "servers": [], + "authed": false, + "method": "GET", + "path": "/webhooks/{id}", + "examples": [ + { + "pathParameters": [ + { + "name": "id", + "value": { + "value": { + "value": "id", + "type": "string" + }, + "type": "primitive" + } + } + ], + "queryParameters": [], + "headers": [], + "response": { + "value": { + "properties": { + "id": { + "value": { + "value": "id", + "type": "string" + }, + "type": "primitive" + }, + "uri": { + "value": { + "value": "uri", + "type": "string" + }, + "type": "primitive" + }, + "created_at": { + "value": { + "value": "2024-01-15T09:30:00Z", + "type": "datetime" + }, + "type": "primitive" + }, + "state": { + "value": "enabled", + "type": "enum" + } + }, + "type": "object" + }, + "type": "withoutStreaming" + }, + "codeSamples": [], + "type": "full" + } + ], + "source": { + "file": "../openapi.yml", + "type": "openapi" + } + }, + { + "summary": "Update a webhook", + "audiences": [], + "tags": [], + "pathParameters": [ + { + "name": "id", + "schema": { + "schema": { + "type": "string" + }, + "generatedName": "PatchWebhooksIdRequestId", + "groupName": [], + "type": "primitive" + }, + "source": { + "file": "../openapi.yml", + "type": "openapi" + } + } + ], + "queryParameters": [], + "headers": [], + "generatedRequestName": "PatchWebhooksIdRequest", + "request": { + "schema": { + "generatedName": "PatchWebhooksIdRequestBody", + "schema": "WebhookUpdate", + "source": { + "file": "../openapi.yml", + "type": "openapi" + }, + "type": "reference" + }, + "contentType": "application/json", + "fullExamples": [], + "additionalProperties": false, + "source": { + "file": "../openapi.yml", + "type": "openapi" + }, + "type": "json" + }, + "response": { + "description": "Webhook updated successfully", + "schema": { + "generatedName": "PatchWebhooksIdResponse", + "schema": "Webhook", + "source": { + "file": "../openapi.yml", + "type": "openapi" + }, + "type": "reference" + }, + "fullExamples": [], + "source": { + "file": "../openapi.yml", + "type": "openapi" + }, + "statusCode": 200, + "type": "json" + }, + "errors": {}, + "servers": [], + "authed": false, + "method": "PATCH", + "path": "/webhooks/{id}", + "examples": [ + { + "pathParameters": [ + { + "name": "id", + "value": { + "value": { + "value": "id", + "type": "string" + }, + "type": "primitive" + } + } + ], + "queryParameters": [], + "headers": [], + "request": { + "properties": {}, + "type": "object" + }, + "response": { + "value": { + "properties": { + "id": { + "value": { + "value": "id", + "type": "string" + }, + "type": "primitive" + }, + "uri": { + "value": { + "value": "uri", + "type": "string" + }, + "type": "primitive" + }, + "created_at": { + "value": { + "value": "2024-01-15T09:30:00Z", + "type": "datetime" + }, + "type": "primitive" + }, + "state": { + "value": "enabled", + "type": "enum" + } + }, + "type": "object" + }, + "type": "withoutStreaming" + }, + "codeSamples": [], + "type": "full" + } + ], + "source": { + "file": "../openapi.yml", + "type": "openapi" + } + } + ], + "webhooks": [], + "channels": {}, + "groupedSchemas": { + "rootSchemas": { + "Webhook": { + "allOf": [], + "properties": [ + { + "conflict": {}, + "generatedName": "webhookId", + "key": "id", + "schema": { + "generatedName": "WebhookId", + "description": "Output only. The ID of the webhook.", + "value": { + "description": "Output only. The ID of the webhook.", + "schema": { + "type": "string" + }, + "generatedName": "WebhookId", + "groupName": [], + "type": "primitive" + }, + "groupName": [], + "type": "optional" + }, + "audiences": [], + "readonly": true + }, + { + "conflict": {}, + "generatedName": "webhookUri", + "key": "uri", + "schema": { + "generatedName": "WebhookUri", + "description": "The URI to which webhook events will be sent.", + "value": { + "description": "The URI to which webhook events will be sent.", + "schema": { + "type": "string" + }, + "generatedName": "WebhookUri", + "groupName": [], + "type": "primitive" + }, + "groupName": [], + "type": "optional" + }, + "audiences": [] + }, + { + "conflict": {}, + "generatedName": "webhookCreatedAt", + "key": "created_at", + "schema": { + "generatedName": "WebhookCreatedAt", + "description": "Output only. When the webhook was created.", + "value": { + "description": "Output only. When the webhook was created.", + "schema": { + "type": "datetime" + }, + "generatedName": "WebhookCreatedAt", + "groupName": [], + "type": "primitive" + }, + "groupName": [], + "type": "optional" + }, + "audiences": [], + "readonly": true + }, + { + "conflict": {}, + "generatedName": "webhookState", + "key": "state", + "schema": { + "generatedName": "WebhookState", + "description": "Output only. The state of the webhook.", + "value": { + "description": "Output only. The state of the webhook.", + "generatedName": "WebhookState", + "values": [ + { + "generatedName": "enabled", + "value": "enabled", + "casing": {} + }, + { + "generatedName": "disabled", + "value": "disabled", + "casing": {} + } + ], + "groupName": [], + "source": { + "file": "../openapi.yml", + "type": "openapi" + }, + "type": "enum" + }, + "groupName": [], + "type": "optional" + }, + "audiences": [], + "readonly": true + } + ], + "allOfPropertyConflicts": [], + "generatedName": "Webhook", + "groupName": [], + "additionalProperties": false, + "source": { + "file": "../openapi.yml", + "type": "openapi" + }, + "type": "object" + }, + "WebhookUpdate": { + "allOf": [], + "properties": [ + { + "conflict": {}, + "generatedName": "webhookUpdateUri", + "key": "uri", + "schema": { + "generatedName": "WebhookUpdateUri", + "description": "The URI to which webhook events will be sent.", + "value": { + "description": "The URI to which webhook events will be sent.", + "schema": { + "type": "string" + }, + "generatedName": "WebhookUpdateUri", + "groupName": [], + "type": "primitive" + }, + "groupName": [], + "type": "optional" + }, + "audiences": [] + } + ], + "allOfPropertyConflicts": [], + "generatedName": "WebhookUpdate", + "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-ir/readonly-endpoint-response.json b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi-ir/readonly-endpoint-response.json new file mode 100644 index 000000000000..a362916bada8 --- /dev/null +++ b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi-ir/readonly-endpoint-response.json @@ -0,0 +1,511 @@ +{ + "title": "Readonly Endpoint Response API", + "description": "Tests that when a schema with readOnly properties is used as the direct response type of an endpoint (not just nested within another schema), the endpoint response type reference is correctly remapped to the Read variant.\n", + "servers": [], + "websocketServers": [], + "tags": { + "tagsById": {} + }, + "hasEndpointsMarkedInternal": false, + "endpoints": [ + { + "summary": "Create a webhook", + "audiences": [], + "tags": [], + "pathParameters": [], + "queryParameters": [], + "headers": [], + "generatedRequestName": "PostWebhooksRequest", + "request": { + "schema": { + "generatedName": "PostWebhooksRequest", + "schema": "Webhook", + "source": { + "file": "../openapi.yml", + "type": "openapi" + }, + "type": "reference" + }, + "contentType": "application/json", + "fullExamples": [], + "additionalProperties": false, + "source": { + "file": "../openapi.yml", + "type": "openapi" + }, + "type": "json" + }, + "response": { + "description": "Webhook created successfully", + "schema": { + "generatedName": "PostWebhooksResponse", + "schema": "Webhook", + "source": { + "file": "../openapi.yml", + "type": "openapi" + }, + "type": "reference" + }, + "fullExamples": [], + "source": { + "file": "../openapi.yml", + "type": "openapi" + }, + "statusCode": 200, + "type": "json" + }, + "errors": {}, + "servers": [], + "authed": false, + "method": "POST", + "path": "/webhooks", + "examples": [ + { + "pathParameters": [], + "queryParameters": [], + "headers": [], + "request": { + "properties": {}, + "type": "object" + }, + "response": { + "value": { + "properties": { + "id": { + "value": { + "value": "id", + "type": "string" + }, + "type": "primitive" + }, + "uri": { + "value": { + "value": "uri", + "type": "string" + }, + "type": "primitive" + }, + "created_at": { + "value": { + "value": "2024-01-15T09:30:00Z", + "type": "datetime" + }, + "type": "primitive" + }, + "state": { + "value": "enabled", + "type": "enum" + } + }, + "type": "object" + }, + "type": "withoutStreaming" + }, + "codeSamples": [], + "type": "full" + } + ], + "source": { + "file": "../openapi.yml", + "type": "openapi" + } + }, + { + "summary": "Get a webhook", + "audiences": [], + "tags": [], + "pathParameters": [ + { + "name": "id", + "schema": { + "schema": { + "type": "string" + }, + "generatedName": "GetWebhooksIdRequestId", + "groupName": [], + "type": "primitive" + }, + "source": { + "file": "../openapi.yml", + "type": "openapi" + } + } + ], + "queryParameters": [], + "headers": [], + "generatedRequestName": "GetWebhooksIdRequest", + "response": { + "description": "Webhook retrieved successfully", + "schema": { + "generatedName": "GetWebhooksIdResponse", + "schema": "Webhook", + "source": { + "file": "../openapi.yml", + "type": "openapi" + }, + "type": "reference" + }, + "fullExamples": [], + "source": { + "file": "../openapi.yml", + "type": "openapi" + }, + "statusCode": 200, + "type": "json" + }, + "errors": {}, + "servers": [], + "authed": false, + "method": "GET", + "path": "/webhooks/{id}", + "examples": [ + { + "pathParameters": [ + { + "name": "id", + "value": { + "value": { + "value": "id", + "type": "string" + }, + "type": "primitive" + } + } + ], + "queryParameters": [], + "headers": [], + "response": { + "value": { + "properties": { + "id": { + "value": { + "value": "id", + "type": "string" + }, + "type": "primitive" + }, + "uri": { + "value": { + "value": "uri", + "type": "string" + }, + "type": "primitive" + }, + "created_at": { + "value": { + "value": "2024-01-15T09:30:00Z", + "type": "datetime" + }, + "type": "primitive" + }, + "state": { + "value": "enabled", + "type": "enum" + } + }, + "type": "object" + }, + "type": "withoutStreaming" + }, + "codeSamples": [], + "type": "full" + } + ], + "source": { + "file": "../openapi.yml", + "type": "openapi" + } + }, + { + "summary": "Update a webhook", + "audiences": [], + "tags": [], + "pathParameters": [ + { + "name": "id", + "schema": { + "schema": { + "type": "string" + }, + "generatedName": "PatchWebhooksIdRequestId", + "groupName": [], + "type": "primitive" + }, + "source": { + "file": "../openapi.yml", + "type": "openapi" + } + } + ], + "queryParameters": [], + "headers": [], + "generatedRequestName": "PatchWebhooksIdRequest", + "request": { + "schema": { + "generatedName": "PatchWebhooksIdRequestBody", + "schema": "WebhookUpdate", + "source": { + "file": "../openapi.yml", + "type": "openapi" + }, + "type": "reference" + }, + "contentType": "application/json", + "fullExamples": [], + "additionalProperties": false, + "source": { + "file": "../openapi.yml", + "type": "openapi" + }, + "type": "json" + }, + "response": { + "description": "Webhook updated successfully", + "schema": { + "generatedName": "PatchWebhooksIdResponse", + "schema": "Webhook", + "source": { + "file": "../openapi.yml", + "type": "openapi" + }, + "type": "reference" + }, + "fullExamples": [], + "source": { + "file": "../openapi.yml", + "type": "openapi" + }, + "statusCode": 200, + "type": "json" + }, + "errors": {}, + "servers": [], + "authed": false, + "method": "PATCH", + "path": "/webhooks/{id}", + "examples": [ + { + "pathParameters": [ + { + "name": "id", + "value": { + "value": { + "value": "id", + "type": "string" + }, + "type": "primitive" + } + } + ], + "queryParameters": [], + "headers": [], + "request": { + "properties": {}, + "type": "object" + }, + "response": { + "value": { + "properties": { + "id": { + "value": { + "value": "id", + "type": "string" + }, + "type": "primitive" + }, + "uri": { + "value": { + "value": "uri", + "type": "string" + }, + "type": "primitive" + }, + "created_at": { + "value": { + "value": "2024-01-15T09:30:00Z", + "type": "datetime" + }, + "type": "primitive" + }, + "state": { + "value": "enabled", + "type": "enum" + } + }, + "type": "object" + }, + "type": "withoutStreaming" + }, + "codeSamples": [], + "type": "full" + } + ], + "source": { + "file": "../openapi.yml", + "type": "openapi" + } + } + ], + "webhooks": [], + "channels": {}, + "groupedSchemas": { + "rootSchemas": { + "Webhook": { + "allOf": [], + "properties": [ + { + "conflict": {}, + "generatedName": "webhookId", + "key": "id", + "schema": { + "generatedName": "WebhookId", + "description": "Output only. The ID of the webhook.", + "value": { + "description": "Output only. The ID of the webhook.", + "schema": { + "type": "string" + }, + "generatedName": "WebhookId", + "groupName": [], + "type": "primitive" + }, + "groupName": [], + "type": "optional" + }, + "audiences": [], + "readonly": true + }, + { + "conflict": {}, + "generatedName": "webhookUri", + "key": "uri", + "schema": { + "generatedName": "WebhookUri", + "description": "The URI to which webhook events will be sent.", + "value": { + "description": "The URI to which webhook events will be sent.", + "schema": { + "type": "string" + }, + "generatedName": "WebhookUri", + "groupName": [], + "type": "primitive" + }, + "groupName": [], + "type": "optional" + }, + "audiences": [] + }, + { + "conflict": {}, + "generatedName": "webhookCreatedAt", + "key": "created_at", + "schema": { + "generatedName": "WebhookCreatedAt", + "description": "Output only. When the webhook was created.", + "value": { + "description": "Output only. When the webhook was created.", + "schema": { + "type": "datetime" + }, + "generatedName": "WebhookCreatedAt", + "groupName": [], + "type": "primitive" + }, + "groupName": [], + "type": "optional" + }, + "audiences": [], + "readonly": true + }, + { + "conflict": {}, + "generatedName": "webhookState", + "key": "state", + "schema": { + "generatedName": "WebhookState", + "description": "Output only. The state of the webhook.", + "value": { + "description": "Output only. The state of the webhook.", + "generatedName": "WebhookState", + "values": [ + { + "generatedName": "enabled", + "value": "enabled", + "casing": {} + }, + { + "generatedName": "disabled", + "value": "disabled", + "casing": {} + } + ], + "groupName": [], + "source": { + "file": "../openapi.yml", + "type": "openapi" + }, + "type": "enum" + }, + "groupName": [], + "type": "optional" + }, + "audiences": [], + "readonly": true + } + ], + "allOfPropertyConflicts": [], + "generatedName": "Webhook", + "groupName": [], + "additionalProperties": false, + "source": { + "file": "../openapi.yml", + "type": "openapi" + }, + "type": "object" + }, + "WebhookUpdate": { + "allOf": [], + "properties": [ + { + "conflict": {}, + "generatedName": "webhookUpdateUri", + "key": "uri", + "schema": { + "generatedName": "WebhookUpdateUri", + "description": "The URI to which webhook events will be sent.", + "value": { + "description": "The URI to which webhook events will be sent.", + "schema": { + "type": "string" + }, + "generatedName": "WebhookUpdateUri", + "groupName": [], + "type": "primitive" + }, + "groupName": [], + "type": "optional" + }, + "audiences": [] + } + ], + "allOfPropertyConflicts": [], + "generatedName": "WebhookUpdate", + "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/readonly-endpoint-response-read-variant.json b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi/readonly-endpoint-response-read-variant.json new file mode 100644 index 000000000000..8f47044b3226 --- /dev/null +++ b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi/readonly-endpoint-response-read-variant.json @@ -0,0 +1,320 @@ +{ + "absoluteFilePath": "/DUMMY_PATH", + "importedDefinitions": {}, + "namedDefinitionFiles": { + "__package__.yml": { + "absoluteFilepath": "/DUMMY_PATH", + "contents": { + "service": { + "auth": false, + "base-path": "", + "endpoints": { + "createAWebhook": { + "auth": undefined, + "display-name": "Create a webhook", + "docs": undefined, + "examples": [ + { + "request": {}, + "response": { + "body": { + "created_at": "2024-01-15T09:30:00Z", + "id": "id", + "state": "enabled", + "uri": "uri", + }, + }, + }, + ], + "method": "POST", + "pagination": undefined, + "path": "/webhooks", + "request": "Webhook", + "response": { + "docs": "Webhook created successfully", + "status-code": 200, + "type": "WebhookRead", + }, + "source": { + "openapi": "../openapi.yml", + }, + }, + "getAWebhook": { + "auth": undefined, + "display-name": "Get a webhook", + "docs": undefined, + "examples": [ + { + "path-parameters": { + "id": "id", + }, + "response": { + "body": { + "created_at": "2024-01-15T09:30:00Z", + "id": "id", + "state": "enabled", + "uri": "uri", + }, + }, + }, + ], + "method": "GET", + "pagination": undefined, + "path": "/webhooks/{id}", + "request": { + "name": "GetWebhooksIdRequest", + "path-parameters": { + "id": "string", + }, + }, + "response": { + "docs": "Webhook retrieved successfully", + "status-code": 200, + "type": "WebhookRead", + }, + "source": { + "openapi": "../openapi.yml", + }, + }, + "updateAWebhook": { + "auth": undefined, + "display-name": "Update a webhook", + "docs": undefined, + "examples": [ + { + "path-parameters": { + "id": "id", + }, + "request": {}, + "response": { + "body": { + "created_at": "2024-01-15T09:30:00Z", + "id": "id", + "state": "enabled", + "uri": "uri", + }, + }, + }, + ], + "method": "PATCH", + "pagination": undefined, + "path": "/webhooks/{id}", + "request": { + "body": { + "properties": { + "uri": { + "docs": "The URI to which webhook events will be sent.", + "type": "optional", + }, + }, + }, + "content-type": "application/json", + "headers": undefined, + "name": "WebhookUpdate", + "path-parameters": { + "id": "string", + }, + "query-parameters": undefined, + }, + "response": { + "docs": "Webhook updated successfully", + "status-code": 200, + "type": "WebhookRead", + }, + "source": { + "openapi": "../openapi.yml", + }, + }, + }, + "source": { + "openapi": "../openapi.yml", + }, + }, + "types": { + "Webhook": { + "docs": undefined, + "inline": undefined, + "properties": { + "uri": { + "docs": "The URI to which webhook events will be sent.", + "type": "optional", + }, + }, + "source": { + "openapi": "../openapi.yml", + }, + }, + "WebhookRead": { + "docs": undefined, + "inline": undefined, + "properties": { + "created_at": { + "access": "read-only", + "docs": "Output only. When the webhook was created.", + "type": "optional", + }, + "id": { + "access": "read-only", + "docs": "Output only. The ID of the webhook.", + "type": "optional", + }, + "state": { + "access": "read-only", + "docs": "Output only. The state of the webhook.", + "type": "optional", + }, + "uri": { + "docs": "The URI to which webhook events will be sent.", + "type": "optional", + }, + }, + "source": { + "openapi": "../openapi.yml", + }, + }, + "WebhookState": { + "docs": "Output only. The state of the webhook.", + "enum": [ + "enabled", + "disabled", + ], + "inline": true, + "source": { + "openapi": "../openapi.yml", + }, + }, + }, + }, + "rawContents": "service: + auth: false + base-path: '' + endpoints: + createAWebhook: + path: /webhooks + method: POST + source: + openapi: ../openapi.yml + display-name: Create a webhook + request: Webhook + response: + docs: Webhook created successfully + type: WebhookRead + status-code: 200 + examples: + - request: {} + response: + body: + id: id + uri: uri + created_at: '2024-01-15T09:30:00Z' + state: enabled + getAWebhook: + path: /webhooks/{id} + method: GET + source: + openapi: ../openapi.yml + display-name: Get a webhook + request: + name: GetWebhooksIdRequest + path-parameters: + id: string + response: + docs: Webhook retrieved successfully + type: WebhookRead + status-code: 200 + examples: + - path-parameters: + id: id + response: + body: + id: id + uri: uri + created_at: '2024-01-15T09:30:00Z' + state: enabled + updateAWebhook: + path: /webhooks/{id} + method: PATCH + source: + openapi: ../openapi.yml + display-name: Update a webhook + request: + name: WebhookUpdate + path-parameters: + id: string + body: + properties: + uri: + type: optional + docs: The URI to which webhook events will be sent. + content-type: application/json + response: + docs: Webhook updated successfully + type: WebhookRead + status-code: 200 + examples: + - path-parameters: + id: id + request: {} + response: + body: + id: id + uri: uri + created_at: '2024-01-15T09:30:00Z' + state: enabled + source: + openapi: ../openapi.yml +types: + WebhookState: + enum: + - enabled + - disabled + docs: Output only. The state of the webhook. + inline: true + source: + openapi: ../openapi.yml + WebhookRead: + properties: + id: + type: optional + docs: Output only. The ID of the webhook. + access: read-only + uri: + type: optional + docs: The URI to which webhook events will be sent. + created_at: + type: optional + docs: Output only. When the webhook was created. + access: read-only + state: + type: optional + docs: Output only. The state of the webhook. + access: read-only + source: + openapi: ../openapi.yml + Webhook: + properties: + uri: + type: optional + docs: The URI to which webhook events will be sent. + source: + openapi: ../openapi.yml +", + }, + }, + "packageMarkers": {}, + "rootApiFile": { + "contents": { + "display-name": "Readonly Endpoint Response API", + "error-discrimination": { + "strategy": "status-code", + }, + "name": "api", + }, + "defaultUrl": undefined, + "rawContents": "name: api +error-discrimination: + strategy: status-code +display-name: Readonly Endpoint Response API +", + }, +} \ No newline at end of file diff --git a/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi/readonly-endpoint-response.json b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi/readonly-endpoint-response.json new file mode 100644 index 000000000000..245416221093 --- /dev/null +++ b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi/readonly-endpoint-response.json @@ -0,0 +1,320 @@ +{ + "absoluteFilePath": "/DUMMY_PATH", + "importedDefinitions": {}, + "namedDefinitionFiles": { + "__package__.yml": { + "absoluteFilepath": "/DUMMY_PATH", + "contents": { + "service": { + "auth": false, + "base-path": "", + "endpoints": { + "createAWebhook": { + "auth": undefined, + "display-name": "Create a webhook", + "docs": undefined, + "examples": [ + { + "request": {}, + "response": { + "body": { + "created_at": "2024-01-15T09:30:00Z", + "id": "id", + "state": "enabled", + "uri": "uri", + }, + }, + }, + ], + "method": "POST", + "pagination": undefined, + "path": "/webhooks", + "request": "Webhook", + "response": { + "docs": "Webhook created successfully", + "status-code": 200, + "type": "Webhook", + }, + "source": { + "openapi": "../openapi.yml", + }, + }, + "getAWebhook": { + "auth": undefined, + "display-name": "Get a webhook", + "docs": undefined, + "examples": [ + { + "path-parameters": { + "id": "id", + }, + "response": { + "body": { + "created_at": "2024-01-15T09:30:00Z", + "id": "id", + "state": "enabled", + "uri": "uri", + }, + }, + }, + ], + "method": "GET", + "pagination": undefined, + "path": "/webhooks/{id}", + "request": { + "name": "GetWebhooksIdRequest", + "path-parameters": { + "id": "string", + }, + }, + "response": { + "docs": "Webhook retrieved successfully", + "status-code": 200, + "type": "Webhook", + }, + "source": { + "openapi": "../openapi.yml", + }, + }, + "updateAWebhook": { + "auth": undefined, + "display-name": "Update a webhook", + "docs": undefined, + "examples": [ + { + "path-parameters": { + "id": "id", + }, + "request": {}, + "response": { + "body": { + "created_at": "2024-01-15T09:30:00Z", + "id": "id", + "state": "enabled", + "uri": "uri", + }, + }, + }, + ], + "method": "PATCH", + "pagination": undefined, + "path": "/webhooks/{id}", + "request": { + "body": { + "properties": { + "uri": { + "docs": "The URI to which webhook events will be sent.", + "type": "optional", + }, + }, + }, + "content-type": "application/json", + "headers": undefined, + "name": "WebhookUpdate", + "path-parameters": { + "id": "string", + }, + "query-parameters": undefined, + }, + "response": { + "docs": "Webhook updated successfully", + "status-code": 200, + "type": "Webhook", + }, + "source": { + "openapi": "../openapi.yml", + }, + }, + }, + "source": { + "openapi": "../openapi.yml", + }, + }, + "types": { + "Webhook": { + "docs": undefined, + "inline": undefined, + "properties": { + "uri": { + "docs": "The URI to which webhook events will be sent.", + "type": "optional", + }, + }, + "source": { + "openapi": "../openapi.yml", + }, + }, + "WebhookRead": { + "docs": undefined, + "inline": undefined, + "properties": { + "created_at": { + "access": "read-only", + "docs": "Output only. When the webhook was created.", + "type": "optional", + }, + "id": { + "access": "read-only", + "docs": "Output only. The ID of the webhook.", + "type": "optional", + }, + "state": { + "access": "read-only", + "docs": "Output only. The state of the webhook.", + "type": "optional", + }, + "uri": { + "docs": "The URI to which webhook events will be sent.", + "type": "optional", + }, + }, + "source": { + "openapi": "../openapi.yml", + }, + }, + "WebhookState": { + "docs": "Output only. The state of the webhook.", + "enum": [ + "enabled", + "disabled", + ], + "inline": true, + "source": { + "openapi": "../openapi.yml", + }, + }, + }, + }, + "rawContents": "service: + auth: false + base-path: '' + endpoints: + createAWebhook: + path: /webhooks + method: POST + source: + openapi: ../openapi.yml + display-name: Create a webhook + request: Webhook + response: + docs: Webhook created successfully + type: Webhook + status-code: 200 + examples: + - request: {} + response: + body: + id: id + uri: uri + created_at: '2024-01-15T09:30:00Z' + state: enabled + getAWebhook: + path: /webhooks/{id} + method: GET + source: + openapi: ../openapi.yml + display-name: Get a webhook + request: + name: GetWebhooksIdRequest + path-parameters: + id: string + response: + docs: Webhook retrieved successfully + type: Webhook + status-code: 200 + examples: + - path-parameters: + id: id + response: + body: + id: id + uri: uri + created_at: '2024-01-15T09:30:00Z' + state: enabled + updateAWebhook: + path: /webhooks/{id} + method: PATCH + source: + openapi: ../openapi.yml + display-name: Update a webhook + request: + name: WebhookUpdate + path-parameters: + id: string + body: + properties: + uri: + type: optional + docs: The URI to which webhook events will be sent. + content-type: application/json + response: + docs: Webhook updated successfully + type: Webhook + status-code: 200 + examples: + - path-parameters: + id: id + request: {} + response: + body: + id: id + uri: uri + created_at: '2024-01-15T09:30:00Z' + state: enabled + source: + openapi: ../openapi.yml +types: + WebhookState: + enum: + - enabled + - disabled + docs: Output only. The state of the webhook. + inline: true + source: + openapi: ../openapi.yml + WebhookRead: + properties: + id: + type: optional + docs: Output only. The ID of the webhook. + access: read-only + uri: + type: optional + docs: The URI to which webhook events will be sent. + created_at: + type: optional + docs: Output only. When the webhook was created. + access: read-only + state: + type: optional + docs: Output only. The state of the webhook. + access: read-only + source: + openapi: ../openapi.yml + Webhook: + properties: + uri: + type: optional + docs: The URI to which webhook events will be sent. + source: + openapi: ../openapi.yml +", + }, + }, + "packageMarkers": {}, + "rootApiFile": { + "contents": { + "display-name": "Readonly Endpoint Response API", + "error-discrimination": { + "strategy": "status-code", + }, + "name": "api", + }, + "defaultUrl": undefined, + "rawContents": "name: api +error-discrimination: + strategy: status-code +display-name: Readonly Endpoint Response API +", + }, +} \ No newline at end of file diff --git a/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/fixtures/readonly-endpoint-response-read-variant/fern/fern.config.json b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/fixtures/readonly-endpoint-response-read-variant/fern/fern.config.json new file mode 100644 index 000000000000..dafc642ff7b8 --- /dev/null +++ b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/fixtures/readonly-endpoint-response-read-variant/fern/fern.config.json @@ -0,0 +1,4 @@ +{ + "organization": "fern", + "version": "*" +} diff --git a/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/fixtures/readonly-endpoint-response-read-variant/fern/generators.yml b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/fixtures/readonly-endpoint-response-read-variant/fern/generators.yml new file mode 100644 index 000000000000..7f28dd01e26d --- /dev/null +++ b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/fixtures/readonly-endpoint-response-read-variant/fern/generators.yml @@ -0,0 +1,7 @@ +# yaml-language-server: $schema=https://schema.buildwithfern.dev/generators-yml.json +api: + specs: + - openapi: ../openapi.yml + settings: + respect-readonly-schemas: true + use-read-variant-for-responses: true diff --git a/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/fixtures/readonly-endpoint-response-read-variant/openapi.yml b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/fixtures/readonly-endpoint-response-read-variant/openapi.yml new file mode 100644 index 000000000000..f14301becda1 --- /dev/null +++ b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/fixtures/readonly-endpoint-response-read-variant/openapi.yml @@ -0,0 +1,95 @@ +openapi: 3.1.0 +info: + title: Readonly Endpoint Response API + description: > + Tests that when a schema with readOnly properties is used as the direct + response type of an endpoint (not just nested within another schema), the + endpoint response type reference is correctly remapped to the Read variant. + version: 1.0.0 + +paths: + /webhooks: + post: + summary: Create a webhook + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/Webhook" + responses: + "200": + description: Webhook created successfully + content: + application/json: + schema: + $ref: "#/components/schemas/Webhook" + + /webhooks/{id}: + get: + summary: Get a webhook + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: Webhook retrieved successfully + content: + application/json: + schema: + $ref: "#/components/schemas/Webhook" + + patch: + summary: Update a webhook + parameters: + - name: id + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/WebhookUpdate" + responses: + "200": + description: Webhook updated successfully + content: + application/json: + schema: + $ref: "#/components/schemas/Webhook" + +components: + schemas: + Webhook: + type: object + properties: + id: + type: string + readOnly: true + description: Output only. The ID of the webhook. + uri: + type: string + description: The URI to which webhook events will be sent. + created_at: + type: string + format: date-time + readOnly: true + description: Output only. When the webhook was created. + state: + type: string + readOnly: true + enum: [enabled, disabled] + description: Output only. The state of the webhook. + + WebhookUpdate: + type: object + properties: + uri: + type: string + description: The URI to which webhook events will be sent. diff --git a/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/fixtures/readonly-endpoint-response/fern/fern.config.json b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/fixtures/readonly-endpoint-response/fern/fern.config.json new file mode 100644 index 000000000000..dafc642ff7b8 --- /dev/null +++ b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/fixtures/readonly-endpoint-response/fern/fern.config.json @@ -0,0 +1,4 @@ +{ + "organization": "fern", + "version": "*" +} diff --git a/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/fixtures/readonly-endpoint-response/fern/generators.yml b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/fixtures/readonly-endpoint-response/fern/generators.yml new file mode 100644 index 000000000000..ab2755bd63f7 --- /dev/null +++ b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/fixtures/readonly-endpoint-response/fern/generators.yml @@ -0,0 +1,6 @@ +# yaml-language-server: $schema=https://schema.buildwithfern.dev/generators-yml.json +api: + specs: + - openapi: ../openapi.yml + settings: + respect-readonly-schemas: true diff --git a/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/fixtures/readonly-endpoint-response/openapi.yml b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/fixtures/readonly-endpoint-response/openapi.yml new file mode 100644 index 000000000000..f14301becda1 --- /dev/null +++ b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/fixtures/readonly-endpoint-response/openapi.yml @@ -0,0 +1,95 @@ +openapi: 3.1.0 +info: + title: Readonly Endpoint Response API + description: > + Tests that when a schema with readOnly properties is used as the direct + response type of an endpoint (not just nested within another schema), the + endpoint response type reference is correctly remapped to the Read variant. + version: 1.0.0 + +paths: + /webhooks: + post: + summary: Create a webhook + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/Webhook" + responses: + "200": + description: Webhook created successfully + content: + application/json: + schema: + $ref: "#/components/schemas/Webhook" + + /webhooks/{id}: + get: + summary: Get a webhook + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: Webhook retrieved successfully + content: + application/json: + schema: + $ref: "#/components/schemas/Webhook" + + patch: + summary: Update a webhook + parameters: + - name: id + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/WebhookUpdate" + responses: + "200": + description: Webhook updated successfully + content: + application/json: + schema: + $ref: "#/components/schemas/Webhook" + +components: + schemas: + Webhook: + type: object + properties: + id: + type: string + readOnly: true + description: Output only. The ID of the webhook. + uri: + type: string + description: The URI to which webhook events will be sent. + created_at: + type: string + format: date-time + readOnly: true + description: Output only. When the webhook was created. + state: + type: string + readOnly: true + enum: [enabled, disabled] + description: Output only. The state of the webhook. + + WebhookUpdate: + type: object + properties: + uri: + type: string + description: The URI to which webhook events will be sent. diff --git a/packages/cli/api-importers/openapi/openapi-ir-to-fern/src/ConvertOpenAPIOptions.ts b/packages/cli/api-importers/openapi/openapi-ir-to-fern/src/ConvertOpenAPIOptions.ts index 5f067e702113..a186800b2d27 100644 --- a/packages/cli/api-importers/openapi/openapi-ir-to-fern/src/ConvertOpenAPIOptions.ts +++ b/packages/cli/api-importers/openapi/openapi-ir-to-fern/src/ConvertOpenAPIOptions.ts @@ -24,6 +24,13 @@ export interface ConvertOpenAPIOptions { */ respectReadonlySchemas: boolean; + /** + * If true, endpoint response type references will use the Read variant of schemas + * (e.g. `WebhookRead` instead of `Webhook`) when `respectReadonlySchemas` is enabled. + * Defaults to false for backward compatibility. + */ + useReadVariantForResponses: boolean; + /** * If true, the converter will respect nullable properties in OpenAPI schemas. */ @@ -126,6 +133,7 @@ export const DEFAULT_CONVERT_OPENAPI_OPTIONS: ConvertOpenAPIOptions = { detectGlobalHeaders: true, objectQueryParameters: true, respectReadonlySchemas: false, + useReadVariantForResponses: false, respectNullableSchemas: true, onlyIncludeReferencedSchemas: false, inlinePathParameters: true, diff --git a/packages/cli/api-importers/openapi/openapi-ir-to-fern/src/buildEndpoint.ts b/packages/cli/api-importers/openapi/openapi-ir-to-fern/src/buildEndpoint.ts index 784e8daa54d7..3729cf02133c 100644 --- a/packages/cli/api-importers/openapi/openapi-ir-to-fern/src/buildEndpoint.ts +++ b/packages/cli/api-importers/openapi/openapi-ir-to-fern/src/buildEndpoint.ts @@ -213,7 +213,11 @@ export function buildEndpoint({ context, fileContainingReference: declarationFile, namespace: maybeEndpointNamespace, - declarationDepth: 0 + declarationDepth: 0, + variant: + context.options.respectReadonlySchemas && context.options.useReadVariantForResponses + ? "read" + : undefined }); convertedEndpoint.response = { docs: jsonResponse.description ?? undefined, @@ -232,7 +236,11 @@ export function buildEndpoint({ context, fileContainingReference: declarationFile, namespace: maybeEndpointNamespace, - declarationDepth: 0 + declarationDepth: 0, + variant: + context.options.respectReadonlySchemas && context.options.useReadVariantForResponses + ? "read" + : undefined }); convertedEndpoint["response-stream"] = { docs: jsonResponse.description ?? undefined, @@ -247,7 +255,11 @@ export function buildEndpoint({ context, fileContainingReference: declarationFile, namespace: maybeEndpointNamespace, - declarationDepth: 0 + declarationDepth: 0, + variant: + context.options.respectReadonlySchemas && context.options.useReadVariantForResponses + ? "read" + : undefined }); convertedEndpoint["response-stream"] = { docs: jsonResponse.description ?? undefined, diff --git a/packages/cli/api-importers/openapi/openapi-ir-to-fern/src/buildFernDefinition.ts b/packages/cli/api-importers/openapi/openapi-ir-to-fern/src/buildFernDefinition.ts index 88aa8e3e881d..90f38d06e7a0 100644 --- a/packages/cli/api-importers/openapi/openapi-ir-to-fern/src/buildFernDefinition.ts +++ b/packages/cli/api-importers/openapi/openapi-ir-to-fern/src/buildFernDefinition.ts @@ -168,9 +168,26 @@ export function buildFernDefinition(context: OpenApiIrConverterContext): FernDef context.builder.addAudience(EXTERNAL_AUDIENCE); } + // Compute reachability-based variant plan before building services only when + // useReadVariantForResponses is enabled, so that endpoint response type references + // can use the correct Read variant. Otherwise, compute after buildServices (old behavior). + if (context.options.respectReadonlySchemas && context.options.useReadVariantForResponses) { + const reachability = computeSchemaReachability(context.ir); + const variantPlan = computeVariantPlan(context.ir, reachability); + context.setVariantPlan(variantPlan, reachability); + } + const convertedServices = buildServices(context); const sdkGroups = convertedServices.sdkGroups; + // If useReadVariantForResponses is not enabled, compute the variant plan now (old behavior). + // This ensures addSchemas still has the variant plan for type declarations. + if (context.options.respectReadonlySchemas && !context.options.useReadVariantForResponses) { + const reachability = computeSchemaReachability(context.ir); + const variantPlan = computeVariantPlan(context.ir, reachability); + context.setVariantPlan(variantPlan, reachability); + } + context.setInState(State.Webhook); buildWebhooks(context); context.unsetInState(State.Webhook); @@ -189,13 +206,6 @@ export function buildFernDefinition(context: OpenApiIrConverterContext): FernDef schemaIdsToExcludeFromServices: convertedServices.schemaIdsToExclude }); - // Compute reachability-based variant plan for readonly schema handling - if (context.options.respectReadonlySchemas) { - const reachability = computeSchemaReachability(context.ir); - const variantPlan = computeVariantPlan(context.ir, reachability); - context.setVariantPlan(variantPlan, reachability); - } - // Build type declarations addSchemas({ schemas: context.ir.groupedSchemas.rootSchemas, schemaIdsToExclude, namespace: undefined, context }); for (const [namespace, schemas] of Object.entries(context.ir.groupedSchemas.namespacedSchemas)) { diff --git a/packages/cli/cli-v2/src/api/adapter/LegacyApiSpecAdapter.ts b/packages/cli/cli-v2/src/api/adapter/LegacyApiSpecAdapter.ts index 526eec548805..963d5d094e2a 100644 --- a/packages/cli/cli-v2/src/api/adapter/LegacyApiSpecAdapter.ts +++ b/packages/cli/cli-v2/src/api/adapter/LegacyApiSpecAdapter.ts @@ -157,6 +157,7 @@ export class LegacyApiSpecAdapter { type: "graphql" as const, absoluteFilepath: spec.graphql, absoluteFilepathToOverrides: spec.overrides, + absoluteFilepathToExamples: spec.examples, namespace: spec.name }; } @@ -184,6 +185,7 @@ export class LegacyApiSpecAdapter { // OpenAPI-specific settings respectReadonlySchemas: settings.respectReadonlySchemas, + useReadVariantForResponses: settings.useReadVariantForResponses, onlyIncludeReferencedSchemas: settings.onlyIncludeReferencedSchemas, inlinePathParameters: settings.inlinePathParameters, shouldUseUndiscriminatedUnionsWithLiterals: settings.preferUndiscriminatedUnionsWithLiterals, diff --git a/packages/cli/cli-v2/src/api/config/GraphQlSpec.ts b/packages/cli/cli-v2/src/api/config/GraphQlSpec.ts index 244aaa839e8f..0dfc8b78facd 100644 --- a/packages/cli/cli-v2/src/api/config/GraphQlSpec.ts +++ b/packages/cli/cli-v2/src/api/config/GraphQlSpec.ts @@ -16,6 +16,9 @@ export interface GraphQlSpec { /** Path to the overrides file(s) */ overrides?: AbsoluteFilePath | AbsoluteFilePath[]; + /** Path to a YAML/JSON file containing named examples for GraphQL operations */ + examples?: AbsoluteFilePath; + /** Name used to group this GraphQL spec in the docs (rendered as a top-level section) */ name?: string; } diff --git a/packages/cli/cli-v2/src/api/config/converter/ApiDefinitionConverter.ts b/packages/cli/cli-v2/src/api/config/converter/ApiDefinitionConverter.ts index 6efa60a05749..bb2af3d0bbed 100644 --- a/packages/cli/cli-v2/src/api/config/converter/ApiDefinitionConverter.ts +++ b/packages/cli/cli-v2/src/api/config/converter/ApiDefinitionConverter.ts @@ -458,6 +458,13 @@ export class ApiDefinitionConverter { sourced: sourced.overrides }); } + if (spec.examples != null && !isNullish(sourced.examples)) { + result.examples = await this.resolvePath({ + absoluteFernYmlPath, + path: spec.examples, + sourced: sourced.examples + }); + } if (spec.name != null) { result.name = spec.name; } diff --git a/packages/cli/cli-v2/src/migrator/converters/convertSettings.ts b/packages/cli/cli-v2/src/migrator/converters/convertSettings.ts index 4ec44cdedab2..cbc392f3b383 100644 --- a/packages/cli/cli-v2/src/migrator/converters/convertSettings.ts +++ b/packages/cli/cli-v2/src/migrator/converters/convertSettings.ts @@ -27,6 +27,7 @@ const SETTINGS_KEY_MAP: Record = { "prefer-undiscriminated-unions-with-literals": "preferUndiscriminatedUnionsWithLiterals", "object-query-parameters": "objectQueryParameters", "respect-readonly-schemas": "respectReadonlySchemas", + "use-read-variant-for-responses": "useReadVariantForResponses", "respect-forward-compatible-enums": "respectForwardCompatibleEnums", "use-bytes-for-binary-response": "useBytesForBinaryResponse", "default-form-parameter-encoding": "defaultFormParameterEncoding", diff --git a/packages/cli/cli/changes/5.35.0/graphql-examples-support.yml b/packages/cli/cli/changes/5.35.0/graphql-examples-support.yml new file mode 100644 index 000000000000..eba806e91f68 --- /dev/null +++ b/packages/cli/cli/changes/5.35.0/graphql-examples-support.yml @@ -0,0 +1,5 @@ +- summary: | + Add support for user-provided examples in GraphQL specs. Users can now specify + an `examples` field in their GraphQL spec configuration pointing to a YAML file + containing named examples (with query, variables, and response) for each operation. + type: feat diff --git a/packages/cli/cli/changes/5.35.1/fix-readonly-endpoint-response-types.yml b/packages/cli/cli/changes/5.35.1/fix-readonly-endpoint-response-types.yml new file mode 100644 index 000000000000..3c52e2a18a4e --- /dev/null +++ b/packages/cli/cli/changes/5.35.1/fix-readonly-endpoint-response-types.yml @@ -0,0 +1,5 @@ +- summary: | + Fix OpenAPI `respect-readonly-schemas` so that endpoint response types correctly + reference the `Read` variant (e.g. `WebhookRead` instead of `Webhook`) when the + same schema is used in both request and response contexts. + type: fix diff --git a/packages/cli/cli/changes/5.35.2/bump-generator-cli-0.9.35.yml b/packages/cli/cli/changes/5.35.2/bump-generator-cli-0.9.35.yml new file mode 100644 index 000000000000..4d1d66d928a4 --- /dev/null +++ b/packages/cli/cli/changes/5.35.2/bump-generator-cli-0.9.35.yml @@ -0,0 +1,7 @@ +# yaml-language-server: $schema=../../../../../fern-changes-yml.schema.json + +- summary: | + Bump @fern-api/generator-cli to 0.9.35, which disables minimatch negation + on `.fernignore` patterns so a stray `!pattern` no longer silently inverts + the match and discards generator output. + type: fix diff --git a/packages/cli/cli/versions.yml b/packages/cli/cli/versions.yml index fc779e5047a4..f3fde3e660d9 100644 --- a/packages/cli/cli/versions.yml +++ b/packages/cli/cli/versions.yml @@ -1,4 +1,31 @@ # yaml-language-server: $schema=../../../fern-versions-yml.schema.json +- version: 5.35.2 + changelogEntry: + - summary: | + Bump @fern-api/generator-cli to 0.9.35, which disables minimatch negation + on `.fernignore` patterns so a stray `!pattern` no longer silently inverts + the match and discards generator output. + type: fix + createdAt: "2026-05-21" + irVersion: 66 +- version: 5.35.1 + changelogEntry: + - summary: | + Fix OpenAPI `respect-readonly-schemas` so that endpoint response types correctly + reference the `Read` variant (e.g. `WebhookRead` instead of `Webhook`) when the + same schema is used in both request and response contexts. + type: fix + createdAt: "2026-05-21" + irVersion: 66 +- version: 5.35.0 + changelogEntry: + - summary: | + Add support for user-provided examples in GraphQL specs. Users can now specify + an `examples` field in their GraphQL spec configuration pointing to a YAML file + containing named examples (with query, variables, and response) for each operation. + type: feat + createdAt: "2026-05-21" + irVersion: 66 - version: 5.34.0 changelogEntry: - summary: | diff --git a/packages/cli/config/src/schemas/settings/OpenApiSettingsSchema.ts b/packages/cli/config/src/schemas/settings/OpenApiSettingsSchema.ts index 29ce47bbe5c7..e0b5b8de4022 100644 --- a/packages/cli/config/src/schemas/settings/OpenApiSettingsSchema.ts +++ b/packages/cli/config/src/schemas/settings/OpenApiSettingsSchema.ts @@ -26,6 +26,9 @@ export const OpenApiSettingsSchema = BaseApiSettingsSchema.extend({ /** Enables exploring readonly schemas in OpenAPI specifications. */ respectReadonlySchemas: z.boolean().optional(), + /** If true, endpoint response types will use the Read variant of schemas when respect-readonly-schemas is enabled. Defaults to false. */ + useReadVariantForResponses: z.boolean().optional(), + /** Enables respecting forward compatible enums in OpenAPI specifications. Defaults to false. */ respectForwardCompatibleEnums: z.boolean().optional(), diff --git a/packages/cli/config/src/schemas/specs/GraphQlSpecSchema.ts b/packages/cli/config/src/schemas/specs/GraphQlSpecSchema.ts index b8a62667a529..fa52f5b7a193 100644 --- a/packages/cli/config/src/schemas/specs/GraphQlSpecSchema.ts +++ b/packages/cli/config/src/schemas/specs/GraphQlSpecSchema.ts @@ -13,6 +13,9 @@ export const GraphQlSpecSchema = z.object({ /** Path to overrides file for the GraphQL spec. */ overrides: z.union([z.string(), z.array(z.string()).nonempty()]).optional(), + /** Path to a YAML/JSON file containing named examples for GraphQL operations. */ + examples: z.string().optional(), + /** Name used to group this GraphQL spec in the docs (rendered as a top-level section). */ name: z.string().optional() }); diff --git a/packages/cli/configuration-loader/src/generators-yml/convertGeneratorsConfiguration.ts b/packages/cli/configuration-loader/src/generators-yml/convertGeneratorsConfiguration.ts index 634f09a8b872..eaecb95f58d3 100644 --- a/packages/cli/configuration-loader/src/generators-yml/convertGeneratorsConfiguration.ts +++ b/packages/cli/configuration-loader/src/generators-yml/convertGeneratorsConfiguration.ts @@ -29,6 +29,7 @@ const UNDEFINED_API_DEFINITION_SETTINGS: generatorsYml.APIDefinitionSettings = { coerceEnumsToLiterals: undefined, objectQueryParameters: undefined, respectReadonlySchemas: undefined, + useReadVariantForResponses: undefined, respectNullableSchemas: undefined, inlinePathParameters: undefined, useBytesForBinaryResponse: undefined, @@ -135,6 +136,7 @@ function parseOpenApiDefinitionSettingsSchema( onlyIncludeReferencedSchemas: settings?.["only-include-referenced-schemas"], objectQueryParameters: settings?.["object-query-parameters"], respectReadonlySchemas: settings?.["respect-readonly-schemas"], + useReadVariantForResponses: settings?.["use-read-variant-for-responses"], inlinePathParameters: settings?.["inline-path-parameters"], filter: settings?.filter, exampleGeneration: settings?.["example-generation"], @@ -464,7 +466,8 @@ async function parseApiConfigurationV2Schema({ definitionLocation = { schema: { type: "graphql", - path: spec.graphql + path: spec.graphql, + examples: spec.examples }, origin: spec.origin, overrides: spec.overrides, diff --git a/packages/cli/configuration/src/generators-yml/GeneratorsConfiguration.ts b/packages/cli/configuration/src/generators-yml/GeneratorsConfiguration.ts index 08dbeffa0bfa..01c73ca14022 100644 --- a/packages/cli/configuration/src/generators-yml/GeneratorsConfiguration.ts +++ b/packages/cli/configuration/src/generators-yml/GeneratorsConfiguration.ts @@ -72,6 +72,7 @@ export interface APIDefinitionSettings { coerceEnumsToLiterals: boolean | undefined; objectQueryParameters: boolean | undefined; respectReadonlySchemas: boolean | undefined; + useReadVariantForResponses: boolean | undefined; respectNullableSchemas: boolean | undefined; onlyIncludeReferencedSchemas: boolean | undefined; inlinePathParameters: boolean | undefined; @@ -137,6 +138,7 @@ export interface OpenRPCDefinitionSchema { export interface GraphQLDefinitionSchema { type: "graphql"; path: string; + examples: string | undefined; } /** diff --git a/packages/cli/configuration/src/generators-yml/schemas/api/resources/generators/types/GraphQlSpecSchema.ts b/packages/cli/configuration/src/generators-yml/schemas/api/resources/generators/types/GraphQlSpecSchema.ts index 5f4b4d3dd34e..34568d803bb4 100644 --- a/packages/cli/configuration/src/generators-yml/schemas/api/resources/generators/types/GraphQlSpecSchema.ts +++ b/packages/cli/configuration/src/generators-yml/schemas/api/resources/generators/types/GraphQlSpecSchema.ts @@ -4,5 +4,6 @@ export interface GraphQlSpecSchema { graphql: string; origin?: string; overrides?: string; + examples?: string; name?: string; } diff --git a/packages/cli/configuration/src/generators-yml/schemas/api/resources/generators/types/OpenApiSettingsSchema.ts b/packages/cli/configuration/src/generators-yml/schemas/api/resources/generators/types/OpenApiSettingsSchema.ts index 3df6750ecac4..81a966843e91 100644 --- a/packages/cli/configuration/src/generators-yml/schemas/api/resources/generators/types/OpenApiSettingsSchema.ts +++ b/packages/cli/configuration/src/generators-yml/schemas/api/resources/generators/types/OpenApiSettingsSchema.ts @@ -13,6 +13,8 @@ export interface OpenApiSettingsSchema extends GeneratorsYml.BaseApiSettingsSche "object-query-parameters"?: boolean; /** Enables exploring readonly schemas in OpenAPI specifications */ "respect-readonly-schemas"?: boolean; + /** If true, endpoint response types will use the Read variant of schemas when respect-readonly-schemas is enabled. Defaults to false. */ + "use-read-variant-for-responses"?: boolean; /** Enables respecting forward compatible enums in OpenAPI specifications. Defaults to false. */ "respect-forward-compatible-enums"?: boolean; /** Enables using the `bytes` type for binary responsesin OpenAPI specifications. Defaults to a file stream. */ diff --git a/packages/cli/configuration/src/generators-yml/schemas/serialization/resources/generators/types/GraphQlSpecSchema.ts b/packages/cli/configuration/src/generators-yml/schemas/serialization/resources/generators/types/GraphQlSpecSchema.ts index 0a00013a1368..c9398158f9cd 100644 --- a/packages/cli/configuration/src/generators-yml/schemas/serialization/resources/generators/types/GraphQlSpecSchema.ts +++ b/packages/cli/configuration/src/generators-yml/schemas/serialization/resources/generators/types/GraphQlSpecSchema.ts @@ -11,6 +11,7 @@ export const GraphQlSpecSchema: core.serialization.ObjectSchema< graphql: core.serialization.string(), origin: core.serialization.string().optional(), overrides: core.serialization.string().optional(), + examples: core.serialization.string().optional(), name: core.serialization.string().optional(), }); @@ -19,6 +20,7 @@ export declare namespace GraphQlSpecSchema { graphql: string; origin?: string | null; overrides?: string | null; + examples?: string | null; name?: string | null; } } diff --git a/packages/cli/configuration/src/generators-yml/schemas/serialization/resources/generators/types/OpenApiSettingsSchema.ts b/packages/cli/configuration/src/generators-yml/schemas/serialization/resources/generators/types/OpenApiSettingsSchema.ts index 4bf9c000e70f..c59503f967cb 100644 --- a/packages/cli/configuration/src/generators-yml/schemas/serialization/resources/generators/types/OpenApiSettingsSchema.ts +++ b/packages/cli/configuration/src/generators-yml/schemas/serialization/resources/generators/types/OpenApiSettingsSchema.ts @@ -20,6 +20,7 @@ export const OpenApiSettingsSchema: core.serialization.ObjectSchema< "prefer-undiscriminated-unions-with-literals": core.serialization.boolean().optional(), "object-query-parameters": core.serialization.boolean().optional(), "respect-readonly-schemas": core.serialization.boolean().optional(), + "use-read-variant-for-responses": core.serialization.boolean().optional(), "respect-forward-compatible-enums": core.serialization.boolean().optional(), "use-bytes-for-binary-response": core.serialization.boolean().optional(), "default-form-parameter-encoding": FormParameterEncoding.optional(), @@ -43,6 +44,7 @@ export declare namespace OpenApiSettingsSchema { "prefer-undiscriminated-unions-with-literals"?: boolean | null; "object-query-parameters"?: boolean | null; "respect-readonly-schemas"?: boolean | null; + "use-read-variant-for-responses"?: boolean | null; "respect-forward-compatible-enums"?: boolean | null; "use-bytes-for-binary-response"?: boolean | null; "default-form-parameter-encoding"?: FormParameterEncoding.Raw | null; diff --git a/packages/cli/docs-resolver/src/DocsDefinitionResolver.ts b/packages/cli/docs-resolver/src/DocsDefinitionResolver.ts index 68576bd51ec1..e5793f66341a 100644 --- a/packages/cli/docs-resolver/src/DocsDefinitionResolver.ts +++ b/packages/cli/docs-resolver/src/DocsDefinitionResolver.ts @@ -20,7 +20,7 @@ import { } from "@fern-api/docs-markdown-utils"; import { APIV1Write, DocsV1Write, FdrAPI, FernNavigation } from "@fern-api/fdr-sdk"; import { AbsoluteFilePath, join, listFiles, RelativeFilePath, relative, resolve } from "@fern-api/fs-utils"; -import { GraphQLConverter } from "@fern-api/graphql-to-fdr"; +import { GraphQLConverter, type GraphQlOperationExamplesInput } from "@fern-api/graphql-to-fdr"; import { generateIntermediateRepresentation } from "@fern-api/ir-generator"; import { IntermediateRepresentation } from "@fern-api/ir-sdk"; import { getSnakeCaseUnsafe } from "@fern-api/ir-utils"; @@ -1718,10 +1718,12 @@ export class DocsDefinitionResolver { const graphqlSpecs = workspace.allSpecs.filter((spec): spec is GraphQLSpec => spec.type === "graphql"); for (const spec of graphqlSpecs) { try { + const examples = await this.loadGraphQlExamples(spec.absoluteFilepathToExamples); const converter = new GraphQLConverter({ context: this.taskContext, filePath: spec.absoluteFilepath, - namespace: spec.namespace + namespace: spec.namespace, + examples }); const graphqlResult = await converter.convert(); @@ -1744,6 +1746,81 @@ export class DocsDefinitionResolver { return { operations: graphqlOperations, types: graphqlTypes, namespacesByOperationId }; } + private async loadGraphQlExamples( + absoluteFilepathToExamples: AbsoluteFilePath | undefined + ): Promise { + if (absoluteFilepathToExamples == null) { + return undefined; + } + try { + const contents = (await readFile(absoluteFilepathToExamples)).toString(); + const parsed = jsYaml.load(contents); + if (!Array.isArray(parsed)) { + return undefined; + } + for (const entry of parsed) { + if (typeof entry !== "object" || entry == null) { + this.taskContext.logger.warn( + `Skipping invalid entry in GraphQL examples file ${absoluteFilepathToExamples}: expected object` + ); + continue; + } + if (typeof entry.operation !== "string") { + this.taskContext.logger.warn( + `Skipping entry in GraphQL examples file ${absoluteFilepathToExamples}: missing or invalid 'operation' field` + ); + continue; + } + if (!Array.isArray(entry.examples)) { + this.taskContext.logger.warn( + `Skipping entry for operation '${entry.operation}' in ${absoluteFilepathToExamples}: 'examples' must be an array` + ); + continue; + } + } + const validEntries = parsed + .filter( + (entry): entry is { operation: string; operationType?: string; examples: unknown[] } => + typeof entry === "object" && + entry != null && + typeof entry.operation === "string" && + Array.isArray(entry.examples) + ) + .map((entry) => { + const validExamples = entry.examples.filter((ex: unknown) => { + if ( + typeof ex !== "object" || + ex == null || + typeof (ex as Record).query !== "string" + ) { + this.taskContext.logger.warn( + `Skipping malformed example for operation '${entry.operation}' in ${absoluteFilepathToExamples}: missing or invalid 'query' field` + ); + return false; + } + return true; + }); + if (entry.operationType != null) { + const lower = entry.operationType.toLowerCase(); + if (lower !== "query" && lower !== "mutation" && lower !== "subscription") { + this.taskContext.logger.warn( + `Invalid operationType '${entry.operationType}' for operation '${entry.operation}' in ${absoluteFilepathToExamples}: must be 'query', 'mutation', or 'subscription'` + ); + } + } + return { ...entry, examples: validExamples } as GraphQlOperationExamplesInput; + }) + .filter((entry) => entry.examples.length > 0); + return validEntries.length > 0 ? validEntries : undefined; + } catch (error) { + this.taskContext.logger.error( + `Failed to load GraphQL examples from ${absoluteFilepathToExamples}:`, + String(error) + ); + return undefined; + } + } + /** * Handles librarySection navigation items by reading pre-generated MDX files * and _navigation.yml from the library's output directory. diff --git a/packages/cli/generation/local-generation/local-workspace-runner/src/__test__/rawSpecs.test.ts b/packages/cli/generation/local-generation/local-workspace-runner/src/__test__/rawSpecs.test.ts index 191bacb44f5f..69bc78fac06f 100644 --- a/packages/cli/generation/local-generation/local-workspace-runner/src/__test__/rawSpecs.test.ts +++ b/packages/cli/generation/local-generation/local-workspace-runner/src/__test__/rawSpecs.test.ts @@ -325,7 +325,8 @@ describe("collectRawSpecs", () => { { type: "graphql", absoluteFilepath: AbsoluteFilePath.of(graphqlFile), - absoluteFilepathToOverrides: undefined + absoluteFilepathToOverrides: undefined, + absoluteFilepathToExamples: undefined } ], hostOutputDir: AbsoluteFilePath.of(outputDir), @@ -412,7 +413,8 @@ describe("collectRawSpecs", () => { { type: "graphql", absoluteFilepath: AbsoluteFilePath.of(graphqlFile), - absoluteFilepathToOverrides: undefined + absoluteFilepathToOverrides: undefined, + absoluteFilepathToExamples: undefined } ], hostOutputDir: AbsoluteFilePath.of(outputDir), @@ -545,7 +547,8 @@ describe("collectRawSpecs", () => { { type: "graphql", absoluteFilepath: AbsoluteFilePath.of(graphqlFile), - absoluteFilepathToOverrides: AbsoluteFilePath.of(overrideFile) + absoluteFilepathToOverrides: AbsoluteFilePath.of(overrideFile), + absoluteFilepathToExamples: undefined } ], hostOutputDir: AbsoluteFilePath.of(outputDir), diff --git a/packages/cli/workspace/browser-compatible-fern-workspace/src/OpenAPIWorkspace.ts b/packages/cli/workspace/browser-compatible-fern-workspace/src/OpenAPIWorkspace.ts index 849a606b56a5..126042695a3b 100644 --- a/packages/cli/workspace/browser-compatible-fern-workspace/src/OpenAPIWorkspace.ts +++ b/packages/cli/workspace/browser-compatible-fern-workspace/src/OpenAPIWorkspace.ts @@ -45,6 +45,7 @@ export class OpenAPIWorkspace extends BaseOpenAPIWorkspaceSync { ...DEFAULT_WORKSPACE_ARGS, generatorsConfiguration, respectReadonlySchemas: spec.settings?.respectReadonlySchemas, + useReadVariantForResponses: spec.settings?.useReadVariantForResponses, respectNullableSchemas: spec.settings?.respectNullableSchemas, wrapReferencesToNullableInOptional: spec.settings?.wrapReferencesToNullableInOptional, coerceOptionalSchemasToNullable: spec.settings?.coerceOptionalSchemasToNullable, diff --git a/packages/cli/workspace/lazy-fern-workspace/src/OSSWorkspace.ts b/packages/cli/workspace/lazy-fern-workspace/src/OSSWorkspace.ts index d581d11bcf69..0521b83facd4 100644 --- a/packages/cli/workspace/lazy-fern-workspace/src/OSSWorkspace.ts +++ b/packages/cli/workspace/lazy-fern-workspace/src/OSSWorkspace.ts @@ -17,6 +17,7 @@ import { extractErrorMessage, isNonNullish } from "@fern-api/core-utils"; import { FdrAPI } from "@fern-api/fdr-sdk"; import { RawSchemas } from "@fern-api/fern-definition-schema"; import { AbsoluteFilePath, cwd, dirname, join, RelativeFilePath, relativize } from "@fern-api/fs-utils"; +import type { GraphQlOperationExamplesInput } from "@fern-api/graphql-to-fdr"; import { IntermediateRepresentation, serialization } from "@fern-api/ir-sdk"; import { mergeIntermediateRepresentation } from "@fern-api/ir-utils"; import { OpenApiIntermediateRepresentation } from "@fern-api/openapi-ir"; @@ -122,6 +123,7 @@ export class OSSWorkspace extends BaseOpenAPIWorkspace { super({ ...superArgs, respectReadonlySchemas: collapseSpecBooleanSetting(specs, (s) => s?.respectReadonlySchemas), + useReadVariantForResponses: collapseSpecBooleanSetting(specs, (s) => s?.useReadVariantForResponses), respectNullableSchemas: collapseSpecBooleanSetting(specs, (s) => s?.respectNullableSchemas), wrapReferencesToNullableInOptional: collapseSpecBooleanSetting( specs, @@ -221,9 +223,12 @@ export class OSSWorkspace extends BaseOpenAPIWorkspace { for (const spec of graphqlSpecs) { try { + const examples = await loadGraphQlExamples(spec.absoluteFilepathToExamples, context); const converter = new GraphQLConverter({ context, - filePath: spec.absoluteFilepath + filePath: spec.absoluteFilepath, + namespace: spec.namespace, + examples }); const result = await converter.convert(); @@ -717,7 +722,8 @@ export class OSSWorkspace extends BaseOpenAPIWorkspace { : spec.absoluteFilepathToOverrides != null ? [spec.absoluteFilepathToOverrides] : []; - return [mainPath, ...overridePaths]; + const examplesPath = spec.type === "graphql" ? spec.absoluteFilepathToExamples : undefined; + return [mainPath, ...overridePaths, ...(examplesPath != null ? [examplesPath] : [])]; }) .filter(isNonNullish) ]; @@ -749,6 +755,82 @@ export class OSSWorkspace extends BaseOpenAPIWorkspace { } } +async function loadGraphQlExamples( + absoluteFilepathToExamples: AbsoluteFilePath | undefined, + context: TaskContext +): Promise { + if (absoluteFilepathToExamples == null) { + return undefined; + } + try { + const contents = (await readFile(absoluteFilepathToExamples)).toString(); + const parsed = yaml.load(contents); + if (!Array.isArray(parsed)) { + return undefined; + } + for (const entry of parsed) { + if (typeof entry !== "object" || entry == null) { + context.logger.warn( + `Skipping invalid entry in GraphQL examples file ${absoluteFilepathToExamples}: expected object` + ); + continue; + } + if (typeof entry.operation !== "string") { + context.logger.warn( + `Skipping entry in GraphQL examples file ${absoluteFilepathToExamples}: missing or invalid 'operation' field` + ); + continue; + } + if (!Array.isArray(entry.examples)) { + context.logger.warn( + `Skipping entry for operation '${entry.operation}' in ${absoluteFilepathToExamples}: 'examples' must be an array` + ); + continue; + } + } + const validEntries = parsed + .filter( + (entry): entry is { operation: string; operationType?: string; examples: unknown[] } => + typeof entry === "object" && + entry != null && + typeof entry.operation === "string" && + Array.isArray(entry.examples) + ) + .map((entry) => { + const validExamples = entry.examples.filter((ex: unknown) => { + if ( + typeof ex !== "object" || + ex == null || + typeof (ex as Record).query !== "string" + ) { + context.logger.warn( + `Skipping malformed example for operation '${entry.operation}' in ${absoluteFilepathToExamples}: missing or invalid 'query' field` + ); + return false; + } + return true; + }); + if (entry.operationType != null) { + const lower = entry.operationType.toLowerCase(); + if (lower !== "query" && lower !== "mutation" && lower !== "subscription") { + context.logger.warn( + `Invalid operationType '${entry.operationType}' for operation '${entry.operation}' in ${absoluteFilepathToExamples}: must be 'query', 'mutation', or 'subscription'` + ); + } + } + return { ...entry, examples: validExamples } as GraphQlOperationExamplesInput; + }) + .filter((entry) => entry.examples.length > 0); + return validEntries.length > 0 ? validEntries : undefined; + } catch (error) { + context.logger.error( + `Failed to load GraphQL examples from ${absoluteFilepathToExamples}:`, + extractErrorMessage(error) + ); + return undefined; + } +} + async function getAuthFromOverrideFiles(specs: Spec[]): Promise { for (const spec of specs) { if (spec.type !== "openapi") { diff --git a/packages/cli/workspace/loader/src/loadAPIWorkspace.ts b/packages/cli/workspace/loader/src/loadAPIWorkspace.ts index a1d5ab6dcd5a..e10ef1cc2b11 100644 --- a/packages/cli/workspace/loader/src/loadAPIWorkspace.ts +++ b/packages/cli/workspace/loader/src/loadAPIWorkspace.ts @@ -120,10 +120,29 @@ export async function loadSingleNamespaceAPIWorkspace({ } }; } + const absoluteFilepathToExamples = + definition.schema.examples != null + ? join(absolutePathToWorkspace, RelativeFilePath.of(definition.schema.examples)) + : undefined; + if ( + definition.schema.examples != null && + absoluteFilepathToExamples != null && + !(await doesPathExist(absoluteFilepathToExamples)) + ) { + return { + didSucceed: false, + failures: { + [RelativeFilePath.of(definition.schema.examples)]: { + type: WorkspaceLoaderFailureType.FILE_MISSING + } + } + }; + } specs.push({ type: "graphql", absoluteFilepath: absoluteFilepathToGraphQL, absoluteFilepathToOverrides, + absoluteFilepathToExamples, namespace }); continue; diff --git a/packages/commons/api-workspace-commons/src/Spec.ts b/packages/commons/api-workspace-commons/src/Spec.ts index 1bb2f7caeccf..5612045b61b9 100644 --- a/packages/commons/api-workspace-commons/src/Spec.ts +++ b/packages/commons/api-workspace-commons/src/Spec.ts @@ -38,5 +38,6 @@ export interface GraphQLSpec { type: "graphql"; absoluteFilepath: AbsoluteFilePath; absoluteFilepathToOverrides: AbsoluteFilePath | AbsoluteFilePath[] | undefined; + absoluteFilepathToExamples: AbsoluteFilePath | undefined; namespace?: string; } diff --git a/packages/commons/api-workspace-commons/src/openapi/BaseOpenAPIWorkspace.ts b/packages/commons/api-workspace-commons/src/openapi/BaseOpenAPIWorkspace.ts index 932e1d968f27..1b611cdc3f4f 100644 --- a/packages/commons/api-workspace-commons/src/openapi/BaseOpenAPIWorkspace.ts +++ b/packages/commons/api-workspace-commons/src/openapi/BaseOpenAPIWorkspace.ts @@ -12,6 +12,7 @@ export declare namespace BaseOpenAPIWorkspace { objectQueryParameters: boolean | undefined; onlyIncludeReferencedSchemas: boolean | undefined; respectReadonlySchemas: boolean | undefined; + useReadVariantForResponses: boolean | undefined; respectNullableSchemas: boolean | undefined; wrapReferencesToNullableInOptional: boolean | undefined; coerceOptionalSchemasToNullable: boolean | undefined; @@ -38,6 +39,7 @@ export abstract class BaseOpenAPIWorkspace extends AbstractAPIWorkspace = { coerceEnumsToLiterals: "coerceEnumsToLiterals", objectQueryParameters: "objectQueryParameters", respectReadonlySchemas: "respectReadonlySchemas", + useReadVariantForResponses: "useReadVariantForResponses", respectNullableSchemas: "respectNullableSchemas", onlyIncludeReferencedSchemas: "onlyIncludeReferencedSchemas", inlinePathParameters: "inlinePathParameters", diff --git a/packages/commons/github/package.json b/packages/commons/github/package.json index 0763ad582e98..a7ea09978d97 100644 --- a/packages/commons/github/package.json +++ b/packages/commons/github/package.json @@ -36,6 +36,7 @@ }, "dependencies": { "@fern-api/core-utils": "workspace:*", + "minimatch": "catalog:", "octokit": "^4.1.4", "semver": "^7.7.3", "simple-git": "catalog:", diff --git a/packages/commons/github/src/ClonedRepository.ts b/packages/commons/github/src/ClonedRepository.ts index 1f7e2eaa0276..b7cc5982d9ab 100644 --- a/packages/commons/github/src/ClonedRepository.ts +++ b/packages/commons/github/src/ClonedRepository.ts @@ -75,6 +75,12 @@ export class ClonedRepository { await this.git.raw([...args, ...(Array.isArray(files) ? files : [files])]); } + public async listTrackedFiles(): Promise { + await this.git.cwd(this.clonePath); + const result = await this.git.raw(["ls-tree", "-r", "HEAD", "--name-only"]); + return result.split("\n").filter((line) => line.length > 0); + } + public async restoreFilesFromCommit(commitSha: string, files: string | string[]): Promise { await this.git.cwd(this.clonePath); const fileList = Array.isArray(files) ? files : [files]; diff --git a/packages/commons/github/src/__test__/expandFernignorePatterns.test.ts b/packages/commons/github/src/__test__/expandFernignorePatterns.test.ts new file mode 100644 index 000000000000..87f3bda05183 --- /dev/null +++ b/packages/commons/github/src/__test__/expandFernignorePatterns.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; + +import { expandFernignorePatterns } from "../expandFernignorePatterns.js"; + +describe("expandFernignorePatterns", () => { + it("expands ** glob across nested files", () => { + const fernignore = "src/foo/**"; + const tracked = ["src/foo/keep.py", "src/foo/nested/also.py", "src/bar/del.py"]; + + const preserved = expandFernignorePatterns(fernignore, tracked); + + expect(preserved).toEqual(["src/foo/keep.py", "src/foo/nested/also.py"]); + }); + + it("matches a literal file path", () => { + const fernignore = "LICENSE"; + const tracked = ["LICENSE", "README.md", "src/index.ts"]; + + const preserved = expandFernignorePatterns(fernignore, tracked); + + expect(preserved).toEqual(["LICENSE"]); + }); + + it("treats a bare directory path as a prefix so its contents are preserved", () => { + const fernignore = "src/foo"; + const tracked = ["src/foo/a.py", "src/foo/nested/b.py", "src/bar/c.py"]; + + const preserved = expandFernignorePatterns(fernignore, tracked); + + expect([...preserved].sort()).toEqual(["src/foo/a.py", "src/foo/nested/b.py"].sort()); + }); + + it("treats a trailing-slash directory path the same as a bare directory", () => { + const fernignore = "src/foo/"; + const tracked = ["src/foo/a.py", "src/foo/nested/b.py", "src/bar/c.py"]; + + const preserved = expandFernignorePatterns(fernignore, tracked); + + expect([...preserved].sort()).toEqual(["src/foo/a.py", "src/foo/nested/b.py"].sort()); + }); + + it("trims whitespace and ignores comments and blank lines", () => { + const fernignore = ["# leading comment", "", " src/foo/** ", " ", "# trailing comment"].join("\n"); + const tracked = ["src/foo/a.py", "src/bar/b.py"]; + + const preserved = expandFernignorePatterns(fernignore, tracked); + + expect(preserved).toEqual(["src/foo/a.py"]); + }); + + it("matches dotfiles when expanded by a glob", () => { + const fernignore = "src/**"; + const tracked = ["src/.hidden.py", "src/visible.py"]; + + const preserved = expandFernignorePatterns(fernignore, tracked); + + expect(preserved).toEqual(["src/.hidden.py", "src/visible.py"]); + }); + + it("expands extglob patterns through minimatch", () => { + const fernignore = "src/+(foo|bar)/**"; + const tracked = ["src/foo/a.py", "src/bar/b.py", "src/baz/c.py"]; + + const preserved = expandFernignorePatterns(fernignore, tracked); + + expect([...preserved].sort()).toEqual(["src/bar/b.py", "src/foo/a.py"].sort()); + }); + + it("treats a leading `!` as literal so minimatch does not silently invert the result", () => { + const fernignore = "!src/bar"; + const tracked = ["src/foo/a.py", "src/bar/b.py"]; + + const preserved = expandFernignorePatterns(fernignore, tracked); + + expect(preserved).toEqual([]); + }); +}); diff --git a/packages/commons/github/src/__test__/listTrackedFiles.test.ts b/packages/commons/github/src/__test__/listTrackedFiles.test.ts new file mode 100644 index 000000000000..e43c5574700c --- /dev/null +++ b/packages/commons/github/src/__test__/listTrackedFiles.test.ts @@ -0,0 +1,42 @@ +import { mkdir, writeFile } from "fs/promises"; +import path from "path"; +import { simpleGit } from "simple-git"; +import tmp from "tmp-promise"; +import { describe, expect, it } from "vitest"; + +import { ClonedRepository } from "../ClonedRepository.js"; + +async function makeRepoWith(files: Record): Promise { + const dir = (await tmp.dir({ unsafeCleanup: true })).path; + const git = simpleGit(dir); + await git.init(); + await git.addConfig("user.name", "test"); + await git.addConfig("user.email", "test@example.com"); + for (const [relPath, contents] of Object.entries(files)) { + const absPath = path.join(dir, relPath); + await mkdir(path.dirname(absPath), { recursive: true }); + await writeFile(absPath, contents); + } + await git.add("."); + await git.commit("initial"); + return dir; +} + +describe("ClonedRepository.listTrackedFiles", () => { + it("returns every file path tracked at HEAD", async () => { + const dir = await makeRepoWith({ + ".fernignore": "src/keep/**\n", + "README.md": "# hi", + "src/keep/a.py": "a", + "src/keep/nested/b.py": "b", + "src/drop/c.py": "c" + }); + const repo = ClonedRepository.createAtPath(dir); + + const tracked = await repo.listTrackedFiles(); + + expect([...tracked].sort()).toEqual( + [".fernignore", "README.md", "src/drop/c.py", "src/keep/a.py", "src/keep/nested/b.py"].sort() + ); + }); +}); diff --git a/packages/commons/github/src/__test__/publishSequence.integration.test.ts b/packages/commons/github/src/__test__/publishSequence.integration.test.ts new file mode 100644 index 000000000000..f69e564ab142 --- /dev/null +++ b/packages/commons/github/src/__test__/publishSequence.integration.test.ts @@ -0,0 +1,74 @@ +import { mkdir, readFile, writeFile } from "fs/promises"; +import path from "path"; +import { simpleGit } from "simple-git"; +import tmp from "tmp-promise"; +import { describe, expect, it } from "vitest"; + +import { ClonedRepository } from "../ClonedRepository.js"; +import { expandFernignorePatterns } from "../expandFernignorePatterns.js"; + +async function makeRepoWith(files: Record): Promise { + const dir = (await tmp.dir({ unsafeCleanup: true })).path; + const git = simpleGit(dir); + await git.init(); + await git.addConfig("user.name", "test"); + await git.addConfig("user.email", "test@example.com"); + for (const [relPath, contents] of Object.entries(files)) { + const absPath = path.join(dir, relPath); + await mkdir(path.dirname(absPath), { recursive: true }); + await writeFile(absPath, contents); + } + await git.add("."); + await git.commit("initial"); + return dir; +} + +async function makeSourceDirWith(files: Record): Promise { + const dir = (await tmp.dir({ unsafeCleanup: true })).path; + for (const [relPath, contents] of Object.entries(files)) { + const absPath = path.join(dir, relPath); + await mkdir(path.dirname(absPath), { recursive: true }); + await writeFile(absPath, contents); + } + return dir; +} + +async function readIfExists(absPath: string): Promise { + try { + return await readFile(absPath, "utf-8"); + } catch { + return undefined; + } +} + +describe(".fernignore preservation through the publish sequence", () => { + it("preserves files matching a glob and lets the generator replace the rest", async () => { + const repoDir = await makeRepoWith({ + ".fernignore": "src/keep/**\n", + "src/keep/a.py": "customer-a", + "src/keep/nested/b.py": "customer-b", + "src/replace/c.py": "old-generator-c" + }); + const sourceDir = await makeSourceDirWith({ + "src/replace/c.py": "new-generator-c", + "src/new/d.py": "new-generator-d" + }); + const repo = ClonedRepository.createAtPath(repoDir); + + const fernignore = await repo.getFernignore(); + const tracked = await repo.listTrackedFiles(); + const preserved = fernignore !== undefined ? expandFernignorePatterns(fernignore, tracked) : []; + + await repo.overwriteLocalContents(sourceDir); + await repo.add("."); + if (preserved.length > 0) { + await repo.restoreFiles({ files: preserved, staged: true }); + await repo.restoreFiles({ files: preserved }); + } + + expect(await readIfExists(path.join(repoDir, "src/keep/a.py"))).toBe("customer-a"); + expect(await readIfExists(path.join(repoDir, "src/keep/nested/b.py"))).toBe("customer-b"); + expect(await readIfExists(path.join(repoDir, "src/replace/c.py"))).toBe("new-generator-c"); + expect(await readIfExists(path.join(repoDir, "src/new/d.py"))).toBe("new-generator-d"); + }); +}); diff --git a/packages/commons/github/src/expandFernignorePatterns.ts b/packages/commons/github/src/expandFernignorePatterns.ts new file mode 100644 index 000000000000..de22e8d10387 --- /dev/null +++ b/packages/commons/github/src/expandFernignorePatterns.ts @@ -0,0 +1,23 @@ +import { minimatch } from "minimatch"; + +// Negation (`!pattern`) is NOT honored — every entry is treated as include-only. +// `.fernignore` is a flat list of paths to protect; gitignore-style re-inclusion +// isn't part of the contract. +export function expandFernignorePatterns(fernignoreContent: string, candidatePaths: string[]): string[] { + const patterns = fernignoreContent + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith("#")); + return candidatePaths.filter((candidate) => patterns.some((pattern) => matches(candidate, pattern))); +} + +function matches(candidate: string, pattern: string): boolean { + const normalized = pattern.endsWith("/") ? pattern.slice(0, -1) : pattern; + // `nonegate` makes `!` a literal character. Without it, minimatch would + // invert the result and a stray `!pattern` would silently preserve nearly + // every file — discarding generator output instead of customer code. + if (minimatch(candidate, normalized, { dot: true, nonegate: true })) { + return true; + } + return candidate === normalized || candidate.startsWith(normalized + "/"); +} diff --git a/packages/commons/github/src/index.ts b/packages/commons/github/src/index.ts index ab65e02911ad..f2e46ce396d1 100644 --- a/packages/commons/github/src/index.ts +++ b/packages/commons/github/src/index.ts @@ -2,6 +2,7 @@ export { ClonedRepository } from "./ClonedRepository.js"; export { cloneRepository } from "./cloneRepository.js"; export { createOrUpdatePullRequest } from "./createOrUpdatePullRequest.js"; export { deleteBranch } from "./deleteBranch.js"; +export { expandFernignorePatterns } from "./expandFernignorePatterns.js"; export { getFileContent } from "./getFileContent.js"; export { getGithubApiBaseUrl } from "./getGithubApiBaseUrl.js"; export { getLatestRelease } from "./getLatestRelease.js"; diff --git a/packages/generator-cli/package.json b/packages/generator-cli/package.json index 6c6ce62088a8..9e54a0ef5eee 100644 --- a/packages/generator-cli/package.json +++ b/packages/generator-cli/package.json @@ -41,7 +41,7 @@ "@fern-api/docker-utils": "workspace:*", "@fern-api/logger": "workspace:*", "@fern-api/logging-execa": "workspace:*", - "@fern-api/replay": "0.16.1", + "@fern-api/replay": "0.16.2", "@fern-api/task-context": "workspace:*", "@octokit/rest": "catalog:", "es-toolkit": "catalog:", diff --git a/packages/generator-cli/src/github/GitHub.ts b/packages/generator-cli/src/github/GitHub.ts index ed281b6ee861..db490e9ecc4a 100644 --- a/packages/generator-cli/src/github/GitHub.ts +++ b/packages/generator-cli/src/github/GitHub.ts @@ -1,5 +1,11 @@ import { cwd, resolve } from "@fern-api/fs-utils"; -import { ClonedRepository, cloneRepository, getGithubApiBaseUrl, parseRepository } from "@fern-api/github"; +import { + ClonedRepository, + cloneRepository, + expandFernignorePatterns, + getGithubApiBaseUrl, + parseRepository +} from "@fern-api/github"; import { Octokit } from "@octokit/rest"; import type { FernGeneratorCli } from "../configuration/sdk/index.js"; @@ -136,19 +142,8 @@ export class GitHub { if (fernignore === undefined) { return []; } - const fernignoreLines = fernignore.split("\n"); - const fernignoreFiles: string[] = []; - for (const line of fernignoreLines) { - const trimmedLine = line.trim(); - if ( - !trimmedLine.startsWith("#") && - trimmedLine.length > 0 && - (await repository.fileExists({ relativeFilePath: trimmedLine })) - ) { - fernignoreFiles.push(trimmedLine); - } - } - return fernignoreFiles; + const tracked = await repository.listTrackedFiles(); + return expandFernignorePatterns(fernignore, tracked); } private async restoreFiles(repository: ClonedRepository, files: string[]): Promise { diff --git a/packages/generator-cli/versions.yml b/packages/generator-cli/versions.yml index 14f17c5065bd..86d06f96da51 100644 --- a/packages/generator-cli/versions.yml +++ b/packages/generator-cli/versions.yml @@ -1,4 +1,30 @@ # yaml-language-server: $schema=../../versions-yml.schema.json +- changelogEntry: + - summary: | + Disable minimatch negation on `.fernignore` patterns so a stray `!pattern` doesn't silently invert the match and discard generator output. + type: fix + createdAt: "2026-05-21" + version: 0.9.35 + +- changelogEntry: + - summary: | + Expand `.fernignore` glob patterns before the generator wipe. + `getFernignoreFiles` previously matched each line literally via + `fileExists`, so a pattern like `src/foo/**` was checked as a file + literally named `**` and silently produced no matches — the wipe + then deleted the customer's files. Now patterns are run through + the same `minimatch` (with `dot: true`) that `@fern-api/replay` + uses, expanded against the HEAD-tracked file list, and passed + concrete paths to the existing `restoreFiles` step. Trailing-slash + (`src/foo/`), bare-directory (`src/foo`), extglob (`+(a|b)`), and + dotfile (`.github/**`) patterns are all honored. + type: fix + - summary: | + Bump @fern-api/replay to 0.16.2. + type: chore + createdAt: "2026-05-21" + version: 0.9.34 + - changelogEntry: - summary: | Bump @fern-api/replay to 0.16.1. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 34f03d069ec6..fd7c8e604751 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,8 +37,8 @@ catalogs: specifier: 0.0.6-2ee1b7e28 version: 0.0.6-2ee1b7e28 '@fern-api/generator-cli': - specifier: 0.9.33 - version: 0.9.33 + specifier: 0.9.35 + version: 0.9.35 '@fern-api/venus-api-sdk': specifier: 0.22.34 version: 0.22.34 @@ -630,7 +630,7 @@ importers: version: link:packages/configs '@fern-api/generator-cli': specifier: 'catalog:' - version: 0.9.33 + version: 0.9.35 '@rolldown/binding-darwin-arm64': specifier: 'catalog:' version: 1.0.0 @@ -699,7 +699,7 @@ importers: version: link:../../packages/commons/fs-utils '@fern-api/generator-cli': specifier: 'catalog:' - version: 0.9.33 + version: 0.9.35 '@fern-api/ir-sdk': specifier: workspace:* version: link:../../packages/ir-sdk @@ -2466,7 +2466,7 @@ importers: version: link:../../../packages/commons/fs-utils '@fern-api/generator-cli': specifier: 'catalog:' - version: 0.9.33 + version: 0.9.35 '@fern-api/logger': specifier: workspace:* version: link:../../../packages/cli/logger @@ -7713,6 +7713,9 @@ importers: '@fern-api/core-utils': specifier: workspace:* version: link:../core-utils + minimatch: + specifier: '>=10.2.3' + version: 10.2.5 octokit: specifier: ^4.1.4 version: 4.1.4 @@ -7975,8 +7978,8 @@ importers: specifier: workspace:* version: link:../commons/logging-execa '@fern-api/replay': - specifier: 0.16.1 - version: 0.16.1 + specifier: 0.16.2 + version: 0.16.2 '@fern-api/task-context': specifier: workspace:* version: link:../cli/task-context @@ -9486,12 +9489,12 @@ packages: '@fern-api/fdr-sdk@1.2.4-f661387fb2': resolution: {integrity: sha512-wlk1lTCIZ7biND4vQf8jvhUw9P/rBQ5pXASCrumv8R96up0B3DY6yiY1C4VmFyHmp/kPhcjzc5T9TvHZZxFdrA==} - '@fern-api/generator-cli@0.9.33': - resolution: {integrity: sha512-KG5lQEbJr5x2Z7Ggbzyv/2/JGCSJ5IYBMFFYVhzx2MCf+XJPhw+G4X1l199M7PCrIoNXKD7WBiqCmQkgiKSjQQ==} + '@fern-api/generator-cli@0.9.35': + resolution: {integrity: sha512-84+K5K4jquuqpMo3p2NDk/OJuiyZtRcQW5tlEYH3R9y7G5wX71qwAWM21qWqu6+vYfN1oZ+4fUcqgqp6tOhxVA==} hasBin: true - '@fern-api/replay@0.16.1': - resolution: {integrity: sha512-860fIHdRORH4Xg908oxBC6pduiujRdgsb8NguvHt+cQE2lHcEOqsHwUIjiWtmBW09h8Hpi9xf8nGdk6vtBpe+g==} + '@fern-api/replay@0.16.2': + resolution: {integrity: sha512-eUg5d5qay8C+3nin5dWCOLGji/HcmWsA9hQL1cISO+eH58dJjufGd92hxjsD02sw5eX0CuDzzq6LMuYVW2Swzg==} engines: {node: '>=18'} hasBin: true @@ -16423,10 +16426,10 @@ snapshots: - encoding - typescript - '@fern-api/generator-cli@0.9.33': + '@fern-api/generator-cli@0.9.35': dependencies: '@boundaryml/baml': 0.219.0 - '@fern-api/replay': 0.16.1 + '@fern-api/replay': 0.16.2 '@octokit/rest': 22.0.1 es-toolkit: 1.45.1 semver: 7.7.4 @@ -16434,7 +16437,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@fern-api/replay@0.16.1': + '@fern-api/replay@0.16.2': dependencies: minimatch: 10.2.5 node-diff3: 3.2.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5f8095fec254..96838f04dec5 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -68,7 +68,7 @@ catalog: "@bufbuild/protoplugin": 2.2.5 "@fern-api/fai-sdk": 0.0.6-2ee1b7e28 "@fern-api/fdr-sdk": 1.2.4-f661387fb2 - "@fern-api/generator-cli": 0.9.33 + "@fern-api/generator-cli": 0.9.35 "@fern-api/ui-core-utils": 0.129.4-b6c699ad2 "@fern-api/venus-api-sdk": 0.22.34 "@fern-fern/docs-config": 0.0.80