Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/funny-dingos-spend.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions packages/core/src/types/arazzo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-',
Expand Down
1 change: 1 addition & 0 deletions packages/respect-core/src/arazzo-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ export const parameter = {
value: {
oneOf: [{ type: 'string' }, { type: 'number' }, { type: 'boolean' }],
},
allowReserved: { type: 'boolean' },
},
required: ['name', 'value'],
additionalProperties: false,
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/respect-core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export type AdditionalParameterProperties = {
schema?: Record<string, any>;
example?: unknown;
examples?: Record<string, any> | unknown;
allowReserved?: boolean;
};
type ExtendedParameter<T> = T & AdditionalParameterProperties;
export type Parameter = ExtendedParameter<ArazzoParameter>;
Expand Down
33 changes: 33 additions & 0 deletions packages/respect-core/src/utils/__tests__/url-encoding.test.ts
Original file line number Diff line number Diff line change
@@ -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)}`);
});
});
15 changes: 10 additions & 5 deletions packages/respect-core/src/utils/api-fetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -142,7 +143,7 @@ export class ApiFetcher implements IFetcher {
}

const headers: Record<string, string> = {};
const searchParams = new URLSearchParams();
const searchParams = [];
const pathParams: Record<string, string | number | boolean> = {};
const cookies: Record<string, string> = {};

Expand All @@ -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);
Expand All @@ -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';
}
Expand All @@ -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}`;

Expand Down
41 changes: 41 additions & 0 deletions packages/respect-core/src/utils/url-encoding.ts
Original file line number Diff line number Diff line change
@@ -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('&');
}
Original file line number Diff line number Diff line change
@@ -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: <test> ms
    Response Headers: <response headers test>
    Response Size: <test> 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": "<trace-id>"
       },
       "origin": "<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: <test>ms


┌────────────────────────────────────────────────────────────────────────────────┬────────────┬─────────┬─────────┬──────────┐
│ Filename │ Workflows │ Passed │ Failed │ Warnings │
├────────────────────────────────────────────────────────────────────────────────┼────────────┼─────────┼─────────┼──────────┤
│ ✓ allow-reserved-query-param.arazzo.yaml │ 1 │ 1 │ - │ - │
└────────────────────────────────────────────────────────────────────────────────┴────────────┴─────────┴─────────┴──────────┘


"
`;
Original file line number Diff line number Diff line change
@@ -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"
Original file line number Diff line number Diff line change
@@ -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": "<trace-id>"')
.replace(/"origin": "\d+\.\d+\.\d+\.\d+"/g, '"origin": "<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);
Loading