diff --git a/jest.config.js b/jest.config.js index 9173a24198..16548c1d5e 100644 --- a/jest.config.js +++ b/jest.config.js @@ -24,7 +24,7 @@ module.exports = { lines: 64, }, 'packages/respect-core/': { - statements: 84, + statements: 83, branches: 74, functions: 84, lines: 84, diff --git a/packages/respect-core/src/modules/__tests__/flow-runner/get-server-url.test.ts b/packages/respect-core/src/modules/__tests__/flow-runner/get-server-url.test.ts index de59d73740..6ba5a04481 100644 --- a/packages/respect-core/src/modules/__tests__/flow-runner/get-server-url.test.ts +++ b/packages/respect-core/src/modules/__tests__/flow-runner/get-server-url.test.ts @@ -12,7 +12,7 @@ describe('getServerUrl', () => { } as unknown as TestContext; const descriptionName = 'test'; const result = getServerUrl({ ctx, descriptionName }); - expect(result).toEqual({ url: 'https://example.com' }); + expect(result).toEqual({ url: 'https://example.com', parameters: [] }); }); it('should return undefined when path does not include url and no servers provided', () => { @@ -38,7 +38,7 @@ describe('getServerUrl', () => { } as unknown as TestContext; const descriptionName = 'test'; const result = getServerUrl({ ctx, descriptionName }); - expect(result).toEqual({ url: 'https://example.com' }); + expect(result).toEqual({ url: 'https://example.com', parameters: [] }); }); it('should return server url from sourceDescription x-serverUrl resolved from context', () => { @@ -56,7 +56,7 @@ describe('getServerUrl', () => { } as unknown as TestContext; const descriptionName = 'test'; const result = getServerUrl({ ctx, descriptionName }); - expect(result).toEqual({ url: 'https://example.com' }); + expect(result).toEqual({ url: 'https://example.com', parameters: [] }); }); it('should return overwritten server url from sourceDescription x-serverUrl resolved from context', () => { @@ -97,7 +97,7 @@ describe('getServerUrl', () => { } as unknown as OperationDetails & { servers: { url: string }[] }; const descriptionName = 'test'; const result = getServerUrl({ ctx, descriptionName, openapiOperation }); - expect(result).toEqual({ url: 'https://example1.com' }); + expect(result).toEqual({ url: 'https://example1.com', parameters: [] }); }); it('should return "x-operation" url as server url when descriptionName is not provided', () => { @@ -156,7 +156,7 @@ describe('getServerUrl', () => { } as unknown as TestContext; const result = getServerUrl({ ctx, descriptionName: '' }); - expect(result).toEqual({ url: 'https://api.example.com' }); + expect(result).toEqual({ url: 'https://api.example.com', parameters: [] }); }); it('should return undefined when neither x-serverUrl nor $sourceDescriptions server is available when descriptionName is not provided and there is only one sourceDescription', () => { @@ -211,7 +211,7 @@ describe('getServerUrl', () => { openapiOperation: mockDescriptionOperation, } as unknown as GetServerUrlInput); - expect(result).toEqual({ url: 'https://server1.com' }); + expect(result).toEqual({ url: 'https://server1.com', parameters: [] }); }); it('should return undefined when descriptionOperation.servers is empty', () => { @@ -285,4 +285,67 @@ describe('getServerUrl', () => { const result = getServerUrl({ ctx, descriptionName }); expect(result).toEqual({ url: 'https://cli.com' }); }); + + it('should return server url from openapi server with variables', () => { + const ctx = { + $sourceDescriptions: { + test: { + paths: { + '/test': { + get: { + servers: [ + { + url: 'https://{region}@{task}.{region}.openapi-server-with-vars.{domain}/v1?param={task}#fragment,param={task}', + variables: { + domain: { + default: 'com', + enum: ['com', 'net'], + }, + region: { + default: 'us', + enum: ['us', 'eu', 'asia'], + }, + task: { + default: 'ping', + }, + }, + }, + ], + }, + }, + }, + }, + }, + } as unknown as TestContext; + const openapiOperation = { + servers: [ + { + url: 'https://{region}@{task}.{region}.openapi-server-with-vars.{domain}/v1?param={task}#fragment,param={task}', + variables: { + domain: { + default: 'com', + enum: ['com', 'net'], + }, + region: { + default: 'us', + enum: ['us', 'eu', 'asia'], + }, + task: { + default: 'ping', + }, + }, + }, + ], + } as unknown as OperationDetails; + const descriptionName = 'test'; + const result = getServerUrl({ ctx, descriptionName, openapiOperation }); + expect(result).toEqual({ + url: 'https://{region}@{task}.{region}.openapi-server-with-vars.{domain}/v1?param={task}#fragment,param={task}', + parameters: [ + { name: 'domain', value: 'com', in: 'path' }, + { name: 'region', value: 'us', in: 'path' }, + { name: 'task', value: 'ping', in: 'path' }, + ], + }); + }); }); diff --git a/packages/respect-core/src/modules/__tests__/flow-runner/prepare-request.test.ts b/packages/respect-core/src/modules/__tests__/flow-runner/prepare-request.test.ts index 0c9c05b8d7..d84eaea9fc 100644 --- a/packages/respect-core/src/modules/__tests__/flow-runner/prepare-request.test.ts +++ b/packages/respect-core/src/modules/__tests__/flow-runner/prepare-request.test.ts @@ -428,7 +428,7 @@ describe('prepareRequest', () => { summary: 'Get a list of breeds', tags: ['Breeds'], }); - expect(serverUrl).toEqual({ url: 'https://catfact.ninja/' }); + expect(serverUrl).toEqual({ url: 'https://catfact.ninja/', parameters: [] }); expect(requestBody).toEqual(undefined); }); diff --git a/packages/respect-core/src/modules/description-parser/get-request-data-from-openapi.ts b/packages/respect-core/src/modules/description-parser/get-request-data-from-openapi.ts index 226164fad7..d35851a646 100644 --- a/packages/respect-core/src/modules/description-parser/get-request-data-from-openapi.ts +++ b/packages/respect-core/src/modules/description-parser/get-request-data-from-openapi.ts @@ -14,6 +14,7 @@ export interface OpenApiRequestData { requestBody?: Record; contentType?: string; parameters: ParameterWithIn[]; + contentTypeParameters: ParameterWithIn[]; } export function getRequestDataFromOpenApi( @@ -30,13 +31,15 @@ export function getRequestDataFromOpenApi( const accept = getAcceptHeader(operation); const parameters = getUniqueParameters([ ...transformParameters(operation.pathParameters), - { name: 'content-type', in: 'header' as const, value: contentType }, - ...(accept ? [{ name: 'accept', in: 'header' as const, value: accept }] : []), ...transformParameters(operation.parameters), ]).filter(({ value }) => value); return { parameters, + contentTypeParameters: [ + ...(contentType ? [{ name: 'content-type', in: 'header' as const, value: contentType }] : []), + ...(accept ? [{ name: 'accept', in: 'header' as const, value: accept }] : []), + ], requestBody, contentType, }; diff --git a/packages/respect-core/src/modules/flow-runner/get-server-url.ts b/packages/respect-core/src/modules/flow-runner/get-server-url.ts index 5f544b5608..346f081121 100644 --- a/packages/respect-core/src/modules/flow-runner/get-server-url.ts +++ b/packages/respect-core/src/modules/flow-runner/get-server-url.ts @@ -11,6 +11,19 @@ export type GetServerUrlInput = { xOperation?: ExtendedOperation; }; +export type ServerObject = { + url: string; + description?: string; + variables?: Record< + string, + { + default: string; + enum?: string[]; + description?: string; + } + >; +}; + export function getServerUrl({ ctx, descriptionName, @@ -42,9 +55,9 @@ export function getServerUrl({ }; } - return { - url: getValueFromContext('$' + `sourceDescriptions.${descriptionName}.servers.0.url`, ctx), - }; + return resolveOpenApiServerUrlWithVariables( + getValueFromContext('$' + `sourceDescriptions.${descriptionName}.servers.0`, ctx) + ); } if (openapiOperation?.servers?.[0]) { @@ -65,20 +78,23 @@ export function getServerUrl({ : undefined; } - return serverUrlOverride ? { url: serverUrlOverride } : openapiOperation.servers[0]; + return serverUrlOverride + ? { url: serverUrlOverride } + : resolveOpenApiServerUrlWithVariables(openapiOperation.servers[0]); } if (!descriptionName && ctx?.sourceDescriptions && ctx.sourceDescriptions.length === 1) { const sourceDescription = ctx.sourceDescriptions[0]; - let serverUrl = ''; if ('x-serverUrl' in sourceDescription && sourceDescription['x-serverUrl']) { - serverUrl = sourceDescription['x-serverUrl']; + return { url: sourceDescription['x-serverUrl'] }; } else { - serverUrl = ctx.$sourceDescriptions[sourceDescription.name]?.servers[0]?.url || undefined; + return ( + resolveOpenApiServerUrlWithVariables( + ctx.$sourceDescriptions[sourceDescription.name]?.servers[0] + ) || undefined + ); } - - return serverUrl ? { url: serverUrl } : undefined; } if ( @@ -91,5 +107,23 @@ export function getServerUrl({ } // Get first available server url from the description - return ctx.$sourceDescriptions[descriptionName].servers[0]; + return resolveOpenApiServerUrlWithVariables( + ctx.$sourceDescriptions[descriptionName].servers?.[0] + ); +} + +function resolveOpenApiServerUrlWithVariables(server?: ServerObject) { + if (!server) { + return undefined; + } + return { + url: server.url, + parameters: Object.entries(server.variables || {}) + .map(([key, value]) => ({ + in: 'path', + name: key, + value: value.default, + })) + .filter(({ value }) => !!value), + }; } diff --git a/packages/respect-core/src/modules/flow-runner/prepare-request.ts b/packages/respect-core/src/modules/flow-runner/prepare-request.ts index b954053514..c942100f21 100644 --- a/packages/respect-core/src/modules/flow-runner/prepare-request.ts +++ b/packages/respect-core/src/modules/flow-runner/prepare-request.ts @@ -50,7 +50,8 @@ export async function prepareRequest( let path = ''; let method; - const serverUrl: { url: string; descriptionName?: string } | undefined = getServerUrl({ + + const serverUrl: { url: string; parameters?: ParameterWithIn[] } | undefined = getServerUrl({ ctx, descriptionName: openapiOperation?.descriptionName, openapiOperation, @@ -89,7 +90,10 @@ export async function prepareRequest( typeof requestBody === 'object' ? [{ in: 'header', name: 'content-type', value: 'application/json' }] : [], - requestDataFromOpenAPI?.parameters || [], + serverUrl?.parameters || [], + requestDataFromOpenAPI?.contentTypeParameters || [], + // if step.parameters is defined, we do not auto-populate parameters from the openapi operation + step.parameters ? [] : requestDataFromOpenAPI?.parameters || [], resolveParameters(workflowLevelParameters, ctx), stepRequestBodyContentType ? [{ in: 'header', name: 'content-type', value: stepRequestBodyContentType }] diff --git a/packages/respect-core/src/utils/api-fetcher.ts b/packages/respect-core/src/utils/api-fetcher.ts index bed27f18bd..24a8c730c9 100644 --- a/packages/respect-core/src/utils/api-fetcher.ts +++ b/packages/respect-core/src/utils/api-fetcher.ts @@ -153,15 +153,16 @@ export class ApiFetcher implements IFetcher { .join('; '); } - let resolvedPath = resolvePath(path, pathParams) || ''; + const resolvedPath = resolvePath(path, pathParams) || ''; const pathWithSearchParams = `${resolvedPath}${ searchParams.toString() ? '?' + searchParams.toString() : '' }`; - const pathToFetch = `${trimTrailingSlash(serverUrl.url)}${pathWithSearchParams}`; + const resolvedServerUrl = resolvePath(serverUrl.url, pathParams) || ''; + const urlToFetch = `${trimTrailingSlash(resolvedServerUrl)}${pathWithSearchParams}`; - if (pathToFetch.startsWith('/')) { + if (urlToFetch.startsWith('/')) { logger.error( - bgRed(` Wrong url: ${inverse(pathToFetch)} `) + + bgRed(` Wrong url: ${inverse(urlToFetch)} `) + ` Did you forget to provide a correct "serverUrl"?\n` ); } @@ -223,27 +224,16 @@ export class ApiFetcher implements IFetcher { // Start of the verbose logs this.initVerboseLogs({ headerParams: maskedHeaderParams, - host: serverUrl.url, + host: resolvedServerUrl, method: (method || 'get').toUpperCase() as OperationMethod, path: maskedPathParams || '', body: maskedBody, }); const wrappedFetch = this.harLogs ? withHar(this.fetch, { har: this.harLogs }) : fetch; - // Resolve pathToFetch with pathParams for the second time in order - // to handle described servers->variables in the OpenAPI spec. - // E.G.: - // servers: - // - url: 'https://api-sandbox.redocly.com/organizations/{organizationId}' - // TODO: remove/update after the support of the described servers->variables in the Arazzo spec. - resolvedPath = resolvePath(pathToFetch, pathParams) || ''; - if (!resolvedPath) { - throw new Error('Path to fetch is undefined'); - } - const startTime = performance.now(); - const result = await wrappedFetch(resolvedPath, { + const result = await wrappedFetch(urlToFetch, { method: (method || 'get').toUpperCase() as OperationMethod, headers, ...(!isEmpty(requestBody) && { @@ -297,8 +287,6 @@ export class ApiFetcher implements IFetcher { time: responseTime, header: Object.fromEntries(result.headers?.entries() || []), contentType: responseContentType, - query: Object.fromEntries(searchParams.entries()), - path: pathParams, }; }; }