diff --git a/packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/generateIr.ts b/packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/generateIr.ts index 393f078ceb15..e2f11ce02e36 100644 --- a/packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/generateIr.ts +++ b/packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/generateIr.ts @@ -144,6 +144,10 @@ export function generateIr({ taskContext.logger.warn(message); } } + const plainBasePath = + fernBasePathParsed != null && fernBasePathParsed.pathParameters.length === 0 + ? fernBasePathParsed.basePath + : undefined; Object.entries(openApi.paths ?? {}).forEach(([path, pathItem]) => { if (pathItem == null) { @@ -157,16 +161,16 @@ export function generateIr({ } switch (operation.type) { case "async": - endpointsWithExample.push(...operation.sync); - endpointsWithExample.push(...operation.async); + endpointsWithExample.push(...prependBasePathToEndpoints(operation.sync, plainBasePath)); + endpointsWithExample.push(...prependBasePathToEndpoints(operation.async, plainBasePath)); break; case "http": - endpointsWithExample.push(...operation.value); + endpointsWithExample.push(...prependBasePathToEndpoints(operation.value, plainBasePath)); break; case "streaming": - endpointsWithExample.push(...operation.streaming); + endpointsWithExample.push(...prependBasePathToEndpoints(operation.streaming, plainBasePath)); if (operation.nonStreaming) { - endpointsWithExample.push(...operation.nonStreaming); + endpointsWithExample.push(...prependBasePathToEndpoints(operation.nonStreaming, plainBasePath)); } break; case "webhook": @@ -413,17 +417,14 @@ export function generateIr({ document: openApi }), specVersion: openApi.info.version != null && openApi.info.version.length > 0 ? openApi.info.version : undefined, - basePath: (() => { - const parsed = getFernBasePath(openApi); - return parsed?.basePath; - })(), - basePathParameters: (() => { - const parsed = getFernBasePath(openApi); - if (parsed == null || parsed.pathParameters.length === 0) { - return undefined; - } - return parsed.pathParameters; - })(), + basePath: + fernBasePathParsed != null && fernBasePathParsed.pathParameters.length > 0 + ? fernBasePathParsed.basePath + : undefined, + basePathParameters: + fernBasePathParsed != null && fernBasePathParsed.pathParameters.length > 0 + ? fernBasePathParsed.pathParameters + : undefined, title: openApi.info.title ?? "", description: openApi.info.description, groups: Object.fromEntries( @@ -531,6 +532,33 @@ function maybeRemoveDiscriminantsFromSchemas( return result; } +function prependBasePath(path: string, basePath: string | undefined): string { + if (basePath == null || basePath === "/") { + return path; + } + + const normalizedBasePath = basePath.replace(/\/+$/, ""); + if (path === normalizedBasePath || path.startsWith(`${normalizedBasePath}/`)) { + return path; + } + + const normalizedPath = path.replace(/^\/+/, ""); + return normalizedPath.length > 0 ? `${normalizedBasePath}/${normalizedPath}` : normalizedBasePath; +} + +function prependBasePathToEndpoints( + endpoints: EndpointWithExample[], + basePath: string | undefined +): EndpointWithExample[] { + if (basePath == null || basePath === "/") { + return endpoints; + } + return endpoints.map((endpoint) => ({ + ...endpoint, + path: prependBasePath(endpoint.path, basePath) + })); +} + /** * Collects parent schema IDs that have at least one allOf child NOT participating * in any discriminated union. These parents are "shared" across union and non-union diff --git a/packages/cli/api-importers/openapi/openapi-ir-parser/src/parse.ts b/packages/cli/api-importers/openapi/openapi-ir-parser/src/parse.ts index 33cdb822eb6b..5b873cf7826b 100644 --- a/packages/cli/api-importers/openapi/openapi-ir-parser/src/parse.ts +++ b/packages/cli/api-importers/openapi/openapi-ir-parser/src/parse.ts @@ -88,7 +88,12 @@ export function parse({ source, namespace: document.namespace }); - ir = merge(ir, openapiIr, getParseOptions({ options: document.settings, overrides: options })); + ir = merge( + ir, + openapiIr, + getParseOptions({ options: document.settings, overrides: options }), + context + ); documentIndex++; break; } @@ -403,11 +408,31 @@ function hasGroupedServers(servers: AnyServerInput[]): boolean { return servers.some((server) => server.type === "grouped"); } +function mergeBasePath( + ir1: OpenApiIntermediateRepresentation, + ir2: OpenApiIntermediateRepresentation, + context: TaskContext +): Pick { + if (ir1.basePath != null && ir2.basePath != null && ir1.basePath !== ir2.basePath) { + context.failWithoutThrowing( + `Conflicting parameterized x-fern-base-path values: '${ir1.basePath}' and '${ir2.basePath}'.` + ); + } + + return { + basePath: ir1.basePath ?? ir2.basePath, + basePathParameters: ir1.basePathParameters ?? ir2.basePathParameters + }; +} + function merge( ir1: OpenApiIntermediateRepresentation, ir2: OpenApiIntermediateRepresentation, - options?: ParseOpenAPIOptions + options: ParseOpenAPIOptions | undefined, + context: TaskContext ): OpenApiIntermediateRepresentation { + const mergedBasePath = mergeBasePath(ir1, ir2, context); + // Only perform multi-API environment grouping if the feature flag is enabled const shouldGroupEnvironments = options?.groupMultiApiEnvironments === true; @@ -418,8 +443,8 @@ function merge( specVersion: ir1.specVersion ?? ir2.specVersion, title: ir1.title ?? ir2.title, description: ir1.description ?? ir2.description, - basePath: ir1.basePath ?? ir2.basePath, - basePathParameters: ir1.basePathParameters ?? ir2.basePathParameters, + basePath: mergedBasePath.basePath, + basePathParameters: mergedBasePath.basePathParameters, servers: [...ir1.servers, ...ir2.servers], websocketServers: [...ir1.websocketServers, ...ir2.websocketServers], tags: { @@ -591,8 +616,8 @@ function merge( specVersion: ir1.specVersion ?? ir2.specVersion, title: ir1.title ?? ir2.title, description: ir1.description ?? ir2.description, - basePath: ir1.basePath ?? ir2.basePath, - basePathParameters: ir1.basePathParameters ?? ir2.basePathParameters, + basePath: mergedBasePath.basePath, + basePathParameters: mergedBasePath.basePathParameters, // Cast grouped servers to Server[] - buildEnvironments.ts handles the grouped structure // biome-ignore lint/suspicious/noExplicitAny: Required to preserve grouped server metadata through type system servers: mergedServers as any as Server[], @@ -655,8 +680,8 @@ function merge( specVersion: ir1.specVersion ?? ir2.specVersion, title: ir1.title ?? ir2.title, description: ir1.description ?? ir2.description, - basePath: ir1.basePath ?? ir2.basePath, - basePathParameters: ir1.basePathParameters ?? ir2.basePathParameters, + basePath: mergedBasePath.basePath, + basePathParameters: mergedBasePath.basePathParameters, servers: dedupeServers([...ir1.servers, ...ir2.servers] as AnyServerInput[]) as Server[], websocketServers: [...ir1.websocketServers, ...ir2.websocketServers], tags: { diff --git a/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi-ir/per-spec-base-path.json b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi-ir/per-spec-base-path.json new file mode 100644 index 000000000000..b85257b7e02b --- /dev/null +++ b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi-ir/per-spec-base-path.json @@ -0,0 +1,83 @@ +{ + "specVersion": "1.0.0", + "title": "Auth API", + "servers": [], + "websocketServers": [], + "tags": { + "tagsById": {} + }, + "hasEndpointsMarkedInternal": false, + "endpoints": [ + { + "audiences": [], + "operationId": "getToken", + "tags": [ + "auth" + ], + "namespace": "auth", + "pathParameters": [], + "queryParameters": [], + "headers": [], + "generatedRequestName": "GetTokenRequest", + "errors": {}, + "servers": [], + "authed": false, + "method": "POST", + "path": "/token", + "examples": [ + { + "pathParameters": [], + "queryParameters": [], + "headers": [], + "codeSamples": [], + "type": "full" + } + ], + "source": { + "file": "../auth-api.yml", + "type": "openapi" + } + }, + { + "audiences": [], + "operationId": "listItems", + "tags": [], + "pathParameters": [], + "queryParameters": [], + "headers": [], + "generatedRequestName": "ListItemsRequest", + "errors": {}, + "servers": [], + "authed": false, + "method": "GET", + "path": "/api/v3/items", + "examples": [ + { + "pathParameters": [], + "queryParameters": [], + "headers": [], + "codeSamples": [], + "type": "full" + } + ], + "source": { + "file": "../main-api.yml", + "type": "openapi" + } + } + ], + "webhooks": [], + "channels": {}, + "groupedSchemas": { + "rootSchemas": {}, + "namespacedSchemas": { + "auth": {} + } + }, + "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/x-fern-base-path.json b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi-ir/x-fern-base-path.json index d84ebcfa1428..c90ef658ebd0 100644 --- a/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi-ir/x-fern-base-path.json +++ b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi-ir/x-fern-base-path.json @@ -2,7 +2,6 @@ "specVersion": "1.0", "title": "Acme API", "description": "The Acme API.", - "basePath": "/v1", "servers": [ { "url": "https://api.acme.com" @@ -29,7 +28,7 @@ "servers": [], "authed": false, "method": "GET", - "path": "/example", + "path": "/v1/example", "examples": [ { "pathParameters": [], diff --git a/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi/per-spec-base-path.json b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi/per-spec-base-path.json new file mode 100644 index 000000000000..a4cc7c1cbc3c --- /dev/null +++ b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi/per-spec-base-path.json @@ -0,0 +1,105 @@ +{ + "absoluteFilePath": "/DUMMY_PATH", + "importedDefinitions": {}, + "namedDefinitionFiles": { + "__package__.yml": { + "absoluteFilepath": "/DUMMY_PATH", + "contents": { + "service": { + "auth": false, + "base-path": "", + "endpoints": { + "listItems": { + "auth": undefined, + "docs": undefined, + "examples": [ + {}, + ], + "method": "GET", + "pagination": undefined, + "path": "/api/v3/items", + "source": { + "openapi": "../main-api.yml", + }, + }, + }, + "source": { + "openapi": "../main-api.yml", + }, + }, + }, + "rawContents": "service: + auth: false + base-path: '' + endpoints: + listItems: + path: /api/v3/items + method: GET + source: + openapi: ../main-api.yml + examples: + - {} + source: + openapi: ../main-api.yml +", + }, + "auth/__package__.yml": { + "absoluteFilepath": "/DUMMY_PATH", + "contents": { + "service": { + "auth": false, + "base-path": "", + "endpoints": { + "getToken": { + "auth": undefined, + "docs": undefined, + "examples": [ + {}, + ], + "method": "POST", + "pagination": undefined, + "path": "/token", + "source": { + "openapi": "../auth-api.yml", + }, + }, + }, + "source": { + "openapi": "../auth-api.yml", + }, + }, + }, + "rawContents": "service: + auth: false + base-path: '' + endpoints: + getToken: + path: /token + method: POST + source: + openapi: ../auth-api.yml + examples: + - {} + source: + openapi: ../auth-api.yml +", + }, + }, + "packageMarkers": {}, + "rootApiFile": { + "contents": { + "display-name": "Auth API", + "error-discrimination": { + "strategy": "status-code", + }, + "name": "api", + }, + "defaultUrl": undefined, + "rawContents": "name: api +error-discrimination: + strategy: status-code +display-name: Auth API +", + }, + "specVersion": "1.0.0", +} \ No newline at end of file diff --git a/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/fixtures/per-spec-base-path/auth-api.yml b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/fixtures/per-spec-base-path/auth-api.yml new file mode 100644 index 000000000000..666ddafcefad --- /dev/null +++ b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/fixtures/per-spec-base-path/auth-api.yml @@ -0,0 +1,12 @@ +openapi: 3.0.0 +info: + title: Auth API + version: 1.0.0 +x-fern-base-path: / +paths: + /token: + post: + operationId: getToken + responses: + "200": + description: Token response diff --git a/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/fixtures/per-spec-base-path/fern.config.json b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/fixtures/per-spec-base-path/fern.config.json new file mode 100644 index 000000000000..c8911294210f --- /dev/null +++ b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/fixtures/per-spec-base-path/fern.config.json @@ -0,0 +1,4 @@ +{ + "organization": "seed", + "version": "0.0.0" +} diff --git a/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/fixtures/per-spec-base-path/fern/generators.yml b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/fixtures/per-spec-base-path/fern/generators.yml new file mode 100644 index 000000000000..24f9960db8d4 --- /dev/null +++ b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/fixtures/per-spec-base-path/fern/generators.yml @@ -0,0 +1,6 @@ +# yaml-language-server: $schema=https://schema.buildwithfern.dev/generators-yml.json +api: + specs: + - openapi: ../auth-api.yml + namespace: auth + - openapi: ../main-api.yml diff --git a/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/fixtures/per-spec-base-path/main-api.yml b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/fixtures/per-spec-base-path/main-api.yml new file mode 100644 index 000000000000..ebf853f7f4ff --- /dev/null +++ b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/fixtures/per-spec-base-path/main-api.yml @@ -0,0 +1,12 @@ +openapi: 3.0.0 +info: + title: Main API + version: 1.0.0 +x-fern-base-path: /api/v3 +paths: + /items: + get: + operationId: listItems + responses: + "200": + description: Item response diff --git a/packages/cli/cli/changes/unreleased/fix-openapi-per-spec-base-path.yml b/packages/cli/cli/changes/unreleased/fix-openapi-per-spec-base-path.yml new file mode 100644 index 000000000000..09f5a87330c5 --- /dev/null +++ b/packages/cli/cli/changes/unreleased/fix-openapi-per-spec-base-path.yml @@ -0,0 +1,3 @@ +- summary: | + Apply plain OpenAPI x-fern-base-path values to endpoints within their own documents. + type: fix