diff --git a/.changeset/funny-dingos-spend.md b/.changeset/funny-dingos-spend.md new file mode 100644 index 0000000000..077b9e09e4 --- /dev/null +++ b/.changeset/funny-dingos-spend.md @@ -0,0 +1,7 @@ +--- +"@redocly/cli": minor +"@redocly/openapi-core": minor +"@redocly/respect-core": minor +--- + +Added support for OpenAPI `allowReserved` on query parameters in Respect. diff --git a/packages/core/src/types/arazzo.ts b/packages/core/src/types/arazzo.ts index db00fad02e..350a7dd098 100755 --- a/packages/core/src/types/arazzo.ts +++ b/packages/core/src/types/arazzo.ts @@ -145,6 +145,11 @@ const Parameter: NodeType = { description: 'REQUIRED. The name of the parameter. Parameter names are case sensitive.', }, value: {}, // any + allowReserved: { + type: 'boolean', + description: + 'When true, RFC 3986 reserved characters in query parameter values are left unencoded.', + }, }, required: ['name', 'value'], extensionsPrefix: 'x-', diff --git a/packages/respect-core/src/arazzo-schema.ts b/packages/respect-core/src/arazzo-schema.ts index 2694ae6622..e1d263fcba 100644 --- a/packages/respect-core/src/arazzo-schema.ts +++ b/packages/respect-core/src/arazzo-schema.ts @@ -161,6 +161,7 @@ export const parameter = { value: { oneOf: [{ type: 'string' }, { type: 'number' }, { type: 'boolean' }], }, + allowReserved: { type: 'boolean' }, }, required: ['name', 'value'], additionalProperties: false, diff --git a/packages/respect-core/src/modules/__tests__/description-parser/get-request-data-from-openapi.test.ts b/packages/respect-core/src/modules/__tests__/description-parser/get-request-data-from-openapi.test.ts new file mode 100644 index 0000000000..260bdc67b0 --- /dev/null +++ b/packages/respect-core/src/modules/__tests__/description-parser/get-request-data-from-openapi.test.ts @@ -0,0 +1,41 @@ +import { logger } from '@redocly/openapi-core'; + +import type { OperationDetails } from '../../description-parser/get-operation-from-description.js'; +import { getRequestDataFromOpenApi } from '../../description-parser/get-request-data-from-openapi.js'; + +describe('getRequestDataFromOpenApi', () => { + it('should pass allowReserved only on query parameters where it is set', () => { + const operation = { + method: 'get', + path: '/search', + descriptionName: 'test', + pathParameters: [], + parameters: [ + { + name: 'filter', + in: 'query', + required: true, + allowReserved: true, + schema: { type: 'string' }, + }, + { + name: 'page', + in: 'query', + required: true, + example: 1, + schema: { type: 'integer' }, + }, + ], + responses: {}, + } as OperationDetails; + + const result = getRequestDataFromOpenApi(operation, logger); + + const filterParam = result.parameters.find((p) => p.name === 'filter'); + expect(filterParam?.allowReserved).toBe(true); + + const pageParam = result.parameters.find((p) => p.name === 'page'); + expect(pageParam).toBeDefined(); + expect(pageParam?.allowReserved).toBeUndefined(); + }); +}); 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 8748bb6a78..6c36911dbf 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 @@ -427,6 +427,63 @@ describe('prepareRequest', () => { expect(requestBody).toEqual(undefined); }); + it('should pass allowReserved on query params when OpenAPI operation has allowReserved', async () => { + const allowReservedCtx = { + ...ctx, + $sourceDescriptions: { + cats: { + paths: { + '/search': { + get: { + operationId: 'searchAllowReserved', + summary: 'Search', + parameters: [ + { + name: 'filter', + in: 'query', + required: true, + allowReserved: true, + schema: { type: 'string' }, + }, + ], + responses: { '200': { description: 'ok' } }, + }, + }, + }, + servers: [{ url: 'https://api.example.com/' }], + }, + }, + workflows: [ + { + workflowId: 'search-workflow', + steps: [ + { + stepId: 'search-step', + operationId: 'cats.searchAllowReserved', + checks: [], + response: {}, + }, + ], + }, + ], + $workflows: { + 'search-workflow': { + steps: { + 'search-step': { request: {} }, + }, + }, + }, + $steps: { + 'search-step': { request: {} }, + }, + } as unknown as TestContext; + const searchStep = allowReservedCtx.workflows[0].steps[0]; + const { parameters } = await prepareRequest(allowReservedCtx, searchStep, 'search-workflow'); + const filterParam = parameters.find((p) => p.name === 'filter'); + expect(filterParam).toBeDefined(); + expect(filterParam?.allowReserved).toBe(true); + }); + it('should set apiClient step params when descriptionOperation not provided', async () => { const step = { stepId: 'get-breeds-step', 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 457da50837..d48f971150 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 @@ -72,6 +72,7 @@ function transformParameters(params: Parameter[], logger: LoggerInterface): Para name: parameter.name, in: parameter.in, value: generateExampleValue(parameter, logger), + ...(parameter.allowReserved && { allowReserved: parameter.allowReserved }), } as ParameterWithIn; } // Return undefined for non-matching parameters diff --git a/packages/respect-core/src/types.ts b/packages/respect-core/src/types.ts index 448498a3cf..cfc81e18b7 100644 --- a/packages/respect-core/src/types.ts +++ b/packages/respect-core/src/types.ts @@ -59,6 +59,7 @@ export type AdditionalParameterProperties = { schema?: Record; example?: unknown; examples?: Record | unknown; + allowReserved?: boolean; }; type ExtendedParameter = T & AdditionalParameterProperties; export type Parameter = ExtendedParameter; diff --git a/packages/respect-core/src/utils/__tests__/url-encoding.test.ts b/packages/respect-core/src/utils/__tests__/url-encoding.test.ts new file mode 100644 index 0000000000..c893a4b334 --- /dev/null +++ b/packages/respect-core/src/utils/__tests__/url-encoding.test.ts @@ -0,0 +1,33 @@ +import { encodeURIValue, buildQueryString } from '../url-encoding.js'; + +describe('encodeURIValue', () => { + it('should encode reserved chars when allowReserved is false (default)', () => { + expect(encodeURIValue("a!b'c(d)e*f/g")).toBe('a%21b%27c%28d%29e%2Af%2Fg'); + }); + + it('should leave RFC 3986 reserved chars unencoded when allowReserved is true', () => { + const reserved = ":/?#[]@!$&'()*+,;="; + expect(encodeURIValue(reserved, true)).toBe(reserved); + }); + + it("should leave chars that encodeURIComponent does not encode (!'()*) unencoded when allowReserved true", () => { + const encoded = "a!b'c(d)e*f"; + expect(encodeURIValue(encoded, true)).toBe(encoded); + }); + + it('should encode non-reserved chars even when allowReserved is true', () => { + expect(encodeURIValue('a b', true)).toBe('a%20b'); + }); +}); + +describe('buildQueryString', () => { + it('should encode reserved chars depending on allowReserved', () => { + const raw = 'https://example.com/path/to;x,y(z)a*b.c[1]@v'; + const query = buildQueryString([ + { name: 'raw', value: raw, allowReserved: true }, + { name: 'encoded', value: raw }, + ]); + expect(query).toContain(`raw=${raw}`); + expect(query).toContain(`encoded=${encodeURIValue(raw, false)}`); + }); +}); diff --git a/packages/respect-core/src/utils/api-fetcher.ts b/packages/respect-core/src/utils/api-fetcher.ts index 4376b9102f..df6201e583 100644 --- a/packages/respect-core/src/utils/api-fetcher.ts +++ b/packages/respect-core/src/utils/api-fetcher.ts @@ -23,6 +23,7 @@ import { isBinaryContentType } from './binary-content-type-checker.js'; import { generateDigestAuthHeader } from './digest-auth/generate-digest-auth-header.js'; import { parseWwwAuthenticateHeader } from './digest-auth/parse-www-authenticate-header.js'; import { isEmpty } from './is-empty.js'; +import { buildQueryString } from './url-encoding.js'; interface IFetcher { verboseLogs?: VerboseLog; @@ -142,7 +143,7 @@ export class ApiFetcher implements IFetcher { } const headers: Record = {}; - const searchParams = new URLSearchParams(); + const searchParams = []; const pathParams: Record = {}; const cookies: Record = {}; @@ -152,7 +153,11 @@ export class ApiFetcher implements IFetcher { headers[param.name.toLowerCase()] = String(param.value); break; case 'query': - searchParams.set(param.name, String(param.value)); + searchParams.push({ + name: param.name, + value: String(param.value), + allowReserved: param.allowReserved, + }); break; case 'path': pathParams[param.name] = String(param.value); @@ -163,6 +168,8 @@ export class ApiFetcher implements IFetcher { } } + const queryString = buildQueryString(searchParams); + if ((isPlainObject(requestBody) || Array.isArray(requestBody)) && !headers['content-type']) { headers['content-type'] = 'application/json'; } @@ -174,9 +181,7 @@ export class ApiFetcher implements IFetcher { } const resolvedPath = resolvePath(path, pathParams) || ''; - const pathWithSearchParams = `${resolvedPath}${ - searchParams.toString() ? '?' + searchParams.toString() : '' - }`; + const pathWithSearchParams = `${resolvedPath}${queryString ? `?${queryString}` : ''}`; const resolvedServerUrl = resolvePath(serverUrl.url, pathParams) || ''; const urlToFetch = `${trimTrailingSlash(resolvedServerUrl)}${pathWithSearchParams}`; diff --git a/packages/respect-core/src/utils/url-encoding.ts b/packages/respect-core/src/utils/url-encoding.ts new file mode 100644 index 0000000000..1ffeb3514c --- /dev/null +++ b/packages/respect-core/src/utils/url-encoding.ts @@ -0,0 +1,41 @@ +const RESERVED_CHARS = ":/?#[]@!$&'()*+,;="; +const EXPLICITLY_ENCODED_CHARS = new Set(['!', "'", '(', ')', '*']); + +function toPercentEncoded(char: string): string { + if (EXPLICITLY_ENCODED_CHARS.has(char)) { + return `%${char.charCodeAt(0).toString(16).toUpperCase()}`; + } + return encodeURIComponent(char); +} + +/** + * Encodes value for query string. + * @param allowReserved – when true, reserved chars (:/?#[]@!$&'()*+,;=) stay unencoded (RFC 3986). + */ +export function encodeURIValue(value: string, allowReserved = false): string { + let encodedValue = encodeURIComponent(value).replace( + /[!'()*]/g, + (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}` + ); + if (!allowReserved) return encodedValue; + + const RESERVED_PERCENT_MAP = [...RESERVED_CHARS].map((char) => ({ + char, + encoded: toPercentEncoded(char), + })); + + for (const { char, encoded } of RESERVED_PERCENT_MAP) { + encodedValue = encodedValue.split(encoded).join(char); + } + return encodedValue; +} + +export function buildQueryString( + params: Array<{ name: string; value: string; allowReserved?: boolean }> +): string { + return params + .map( + (p) => `${encodeURIComponent(p.name)}=${encodeURIValue(p.value, p.allowReserved === true)}` + ) + .join('&'); +} diff --git a/tests/e2e/respect/allow-reserved-query-param/__snapshots__/allow-reserved-query-param.test.ts.snap b/tests/e2e/respect/allow-reserved-query-param/__snapshots__/allow-reserved-query-param.test.ts.snap new file mode 100644 index 0000000000..09e979f24c --- /dev/null +++ b/tests/e2e/respect/allow-reserved-query-param/__snapshots__/allow-reserved-query-param.test.ts.snap @@ -0,0 +1,55 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`should leave reserved chars unencoded in query when allowReserved is true 1`] = ` +"──────────────────────────────────────────────────────────────────────────────── + + Running workflow allow-reserved-query-param.arazzo.yaml / allow-reserved + + ✓ GET https://httpbin.org/get - step get-with-allow-reserved + +    Request URL: https://httpbin.org/get/?filter=https://example.com/path/to;x,y(z)a*b.c[1]@v + + +    Response status code: 200 +    Response time: ms +    Response Headers: +    Response Size: bytes +    Response Body: +      { +       "args": { +       "filter": "https://example.com/path/to;x,y(z)a*b.c[1]@v" +       }, +       "headers": { +       "Accept": "*/*", +       "Accept-Encoding": "br, gzip, deflate", +       "Accept-Language": "*", +       "Host": "httpbin.org", +       "Sec-Fetch-Mode": "cors", +       "User-Agent": "undici", +       "X-Amzn-Trace-Id": "" +       }, +       "origin": "", +       "url": "https://httpbin.org/get?filter=https:%2F%2Fexample.com%2Fpath%2Fto%3Bx,y(z)a*b.c[1]%40v" +      } + +    ✓ success criteria check - $statusCode == 200 +    ✓ success criteria check - $response.body#/args/filter == "https://example.co... + + +  Summary for allow-reserved-query-param.arazzo.yaml +   +  Workflows: 1 passed, 1 total +  Steps: 1 passed, 1 total +  Checks: 2 passed, 2 total +  Time: ms + + +┌────────────────────────────────────────────────────────────────────────────────┬────────────┬─────────┬─────────┬──────────┐ +│ Filename │ Workflows │ Passed │ Failed │ Warnings │ +├────────────────────────────────────────────────────────────────────────────────┼────────────┼─────────┼─────────┼──────────┤ +│ ✓ allow-reserved-query-param.arazzo.yaml │ 1 │ 1 │ - │ - │ +└────────────────────────────────────────────────────────────────────────────────┴────────────┴─────────┴─────────┴──────────┘ + + +" +`; diff --git a/tests/e2e/respect/allow-reserved-query-param/allow-reserved-query-param.arazzo.yaml b/tests/e2e/respect/allow-reserved-query-param/allow-reserved-query-param.arazzo.yaml new file mode 100644 index 0000000000..aaffb8b292 --- /dev/null +++ b/tests/e2e/respect/allow-reserved-query-param/allow-reserved-query-param.arazzo.yaml @@ -0,0 +1,23 @@ +arazzo: 1.0.1 +info: + title: Allow reserved query params (OpenAPI allowReserved) + version: 1.0.0 +sourceDescriptions: + - name: museum-api + type: openapi + url: ../museum-api.yaml +workflows: + - workflowId: allow-reserved + steps: + - stepId: get-with-allow-reserved + x-operation: + url: https://httpbin.org/get + method: get + parameters: + - in: query + name: filter + value: "https://example.com/path/to;x,y(z)a*b.c[1]@v" + allowReserved: true + successCriteria: + - condition: $statusCode == 200 + - condition: $response.body#/args/filter == "https://example.com/path/to;x,y(z)a*b.c[1]@v" diff --git a/tests/e2e/respect/allow-reserved-query-param/allow-reserved-query-param.test.ts b/tests/e2e/respect/allow-reserved-query-param/allow-reserved-query-param.test.ts new file mode 100644 index 0000000000..5020016533 --- /dev/null +++ b/tests/e2e/respect/allow-reserved-query-param/allow-reserved-query-param.test.ts @@ -0,0 +1,21 @@ +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { getCommandOutput, getParams } from '../../helpers.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +function normalizeHttpbinOutput(output: string): string { + return output + .replace(/"X-Amzn-Trace-Id": "Root=[^"]+"/g, '"X-Amzn-Trace-Id": ""') + .replace(/"origin": "\d+\.\d+\.\d+\.\d+"/g, '"origin": ""'); +} + +test('should leave reserved chars unencoded in query when allowReserved is true', () => { + const indexEntryPoint = join(process.cwd(), 'packages/cli/lib/index.js'); + const fixturesPath = join(__dirname, 'allow-reserved-query-param.arazzo.yaml'); + const args = getParams(indexEntryPoint, ['respect', fixturesPath, '--verbose']); + + const result = getCommandOutput(args); + expect(normalizeHttpbinOutput(result)).toMatchSnapshot(); +}, 60_000);