diff --git a/.changeset/slick-bugs-enjoy.md b/.changeset/slick-bugs-enjoy.md new file mode 100644 index 000000000..c6480c2c3 --- /dev/null +++ b/.changeset/slick-bugs-enjoy.md @@ -0,0 +1,5 @@ +--- +"@gitbook/react-openapi": patch +--- + +Use precedence for OpenAPI servers diff --git a/packages/react-openapi/src/fixtures/spec-server-precedence.json b/packages/react-openapi/src/fixtures/spec-server-precedence.json new file mode 100644 index 000000000..b38d9b4db --- /dev/null +++ b/packages/react-openapi/src/fixtures/spec-server-precedence.json @@ -0,0 +1,44 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Server Precedence Test", + "version": "1.0.0" + }, + "servers": [{ "url": "https://root.example.com" }], + "paths": { + "/root-only": { + "get": { + "operationId": "rootOnly", + "responses": { "200": { "description": "OK" } } + } + }, + "/path-override": { + "servers": [{ "url": "https://path.example.com" }], + "get": { + "operationId": "pathOverride", + "responses": { "200": { "description": "OK" } } + } + }, + "/operation-override": { + "servers": [{ "url": "https://path.example.com" }], + "get": { + "operationId": "operationOverride", + "servers": [{ "url": "https://operation.example.com" }], + "responses": { "200": { "description": "OK" } } + } + }, + "/operation-skip-path": { + "get": { + "operationId": "operationSkipPath", + "servers": [{ "url": "https://operation.example.com" }], + "responses": { "200": { "description": "OK" } } + } + }, + "/no-servers": { + "get": { + "operationId": "noServers", + "responses": { "200": { "description": "OK" } } + } + } + } +} diff --git a/packages/react-openapi/src/resolveOpenAPIOperation.test.ts b/packages/react-openapi/src/resolveOpenAPIOperation.test.ts index 177d082bb..26f0fe3eb 100644 --- a/packages/react-openapi/src/resolveOpenAPIOperation.test.ts +++ b/packages/react-openapi/src/resolveOpenAPIOperation.test.ts @@ -1,8 +1,17 @@ import { describe, expect, it } from 'bun:test'; import { parseOpenAPI, traverse } from '@gitbook/openapi-parser'; +import serverPrecedenceSpec from './fixtures/spec-server-precedence.json'; import { resolveOpenAPIOperation } from './resolveOpenAPIOperation'; +async function loadFixture(spec: object) { + const { filesystem } = await parseOpenAPI({ + value: JSON.stringify(spec), + rootURL: 'memory://spec.json', + }); + return filesystem; +} + async function fetchFilesystem(url: string) { const response = await fetch(url); const text = await response.text(); @@ -174,4 +183,56 @@ describe('#resolveOpenAPIOperation', () => { }, }); }); + + describe('server precedence', () => { + it('should use root-level servers when no path or operation servers are defined', async () => { + const filesystem = await loadFixture(serverPrecedenceSpec); + const resolved = await resolveOpenAPIOperation(filesystem, { + method: 'get', + path: '/root-only', + }); + + expect(resolved?.servers).toEqual([{ url: 'https://root.example.com' }]); + }); + + it('should use path-level servers over root-level servers', async () => { + const filesystem = await loadFixture(serverPrecedenceSpec); + const resolved = await resolveOpenAPIOperation(filesystem, { + method: 'get', + path: '/path-override', + }); + + expect(resolved?.servers).toEqual([{ url: 'https://path.example.com' }]); + }); + + it('should use operation-level servers over path and root-level servers', async () => { + const filesystem = await loadFixture(serverPrecedenceSpec); + const resolved = await resolveOpenAPIOperation(filesystem, { + method: 'get', + path: '/operation-override', + }); + + expect(resolved?.servers).toEqual([{ url: 'https://operation.example.com' }]); + }); + + it('should use operation-level servers over root-level when no path servers exist', async () => { + const filesystem = await loadFixture(serverPrecedenceSpec); + const resolved = await resolveOpenAPIOperation(filesystem, { + method: 'get', + path: '/operation-skip-path', + }); + + expect(resolved?.servers).toEqual([{ url: 'https://operation.example.com' }]); + }); + + it('should fallback to root-level servers for endpoints without overrides', async () => { + const filesystem = await loadFixture(serverPrecedenceSpec); + const resolved = await resolveOpenAPIOperation(filesystem, { + method: 'get', + path: '/no-servers', + }); + + expect(resolved?.servers).toEqual([{ url: 'https://root.example.com' }]); + }); + }); }); diff --git a/packages/react-openapi/src/resolveOpenAPIOperation.ts b/packages/react-openapi/src/resolveOpenAPIOperation.ts index 9a7c1567d..aa31cea59 100644 --- a/packages/react-openapi/src/resolveOpenAPIOperation.ts +++ b/packages/react-openapi/src/resolveOpenAPIOperation.ts @@ -37,7 +37,7 @@ export async function resolveOpenAPIOperation( }; } - const servers = 'servers' in schema ? (schema.servers ?? []) : []; + const servers = getServers(schema, path, operation); const schemaSecurity = Array.isArray(schema.security) ? schema.security : schema.security @@ -112,6 +112,27 @@ function getPathObjectParameter( return null; } +/** + * Resolve servers for an operation following OpenAPI precedence rules. + * Per the spec, only the lowest-level servers array is used: operation > path > root. + */ +function getServers( + schema: OpenAPIV3.Document | OpenAPIV3_1.Document, + path: string, + operation: OpenAPIV3.OperationObject +): OpenAPIV3.ServerObject[] { + if ('servers' in operation && operation.servers) { + return operation.servers; + } + + const pathObject = getPathObject(schema, path); + if (pathObject && 'servers' in pathObject && pathObject.servers) { + return pathObject.servers; + } + + return 'servers' in schema ? (schema.servers ?? []) : []; +} + /** * Get an operation by its path and method. */