Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(rulesets): validate oas3 runtime expressions #1608

Open
wants to merge 6 commits into
base: develop
Choose a base branch
from
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { DiagnosticSeverity } from '@stoplight/types';
import testRule from '../../__tests__/__helpers__/tester';

testRule('oas3-links-parameters-expression', [
{
name: 'all link objects are validated and correct error object produced',
document: {
openapi: '3.0.3',
info: {
title: 'response example',
version: '1.0',
},
paths: {
'/user': {
get: {
responses: {
200: {
description: 'dummy description',
links: {
link1: {
parameters: '$invalidkeyword',
},
link2: {
parameters: '$invalidkeyword',
},
},
},
},
},
},
},
},
errors: [
{
message: 'Expressions must start with one of: `$url`, `$method`, `$statusCode`, `$request.`,`$response.`',
path: ['paths', '/user', 'get', 'responses', '200', 'links', 'link1', 'parameters'],
severity: DiagnosticSeverity.Error,
},
{
message: 'Expressions must start with one of: `$url`, `$method`, `$statusCode`, `$request.`,`$response.`',
path: ['paths', '/user', 'get', 'responses', '200', 'links', 'link2', 'parameters'],
severity: DiagnosticSeverity.Error,
},
],
},
]);
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import type { DeepPartial } from '@stoplight/types';
import type { RulesetFunctionContext } from '@stoplight/spectral-core';
import runtimeExpression from '../runtimeExpression';

function runRuntimeExpression(targetVal: unknown, context?: DeepPartial<RulesetFunctionContext>) {
// @ts-expect-error: string is expected
return runtimeExpression(targetVal, null, {
path: ['paths', '/path', 'get', 'responses', '200', 'links', 'link', 'parameters', 'param'],
documentInventory: {},
...context,
} as RulesetFunctionContext);
}

describe('runtimeExpression', () => {
describe('valid expressions, negative tests', () => {
test.each(['$url', '$method', '$statusCode'])('no messages for valid expressions', expr => {
expect(runRuntimeExpression(expr)).toBeUndefined();
});

test.each([{ obj: 'object' }, ['1'], 1])('no messages for non-strings', expr => {
expect(runRuntimeExpression(expr)).toBeUndefined();
});

test.each(['$request.body', '$response.body'])('no messages for valid expressions', expr => {
expect(runRuntimeExpression(expr)).toBeUndefined();
});

test.each(['$request.body#/chars/in/range/0x00-0x2E/0x30-0x7D/0x7F-0x10FFFF', '$response.body#/simple/path'])(
'no messages for valid expressions',
expr => {
expect(runRuntimeExpression(expr)).toBeUndefined();
},
);

test.each(['$request.body#/~0', '$response.body#/~1'])('no messages for valid expressions', expr => {
expect(runRuntimeExpression(expr)).toBeUndefined();
});

test.each([
'$request.query.query-name',
'$response.query.QUERY-NAME',
'$request.path.path-name',
'$response.path.PATH-NAME',
])('no messages for valid expressions', expr => {
expect(runRuntimeExpression(expr)).toBeUndefined();
});

test.each(["$request.header.a-zA-Z0-9!#$%&'*+-.^_`|~"])('no messages for valid expressions', expr => {
expect(runRuntimeExpression(expr)).toBeUndefined();
});
});

describe('invalid expressions, positive tests', () => {
test.each(['$invalidkeyword'])('error for invalid base keyword', expr => {
const results = runRuntimeExpression(expr);
expect(results).toEqual([
expect.objectContaining({
message: 'Expressions must start with one of: `$url`, `$method`, `$statusCode`, `$request.`,`$response.`',
}),
]);
});

test.each(['$request.invalidkeyword', '$response.invalidkeyword'])('second key invalid', expr => {
const results = runRuntimeExpression(expr);
expect(results).toEqual([
expect.objectContaining({
message: '`$request.` and `$response.` must be followed by one of: `header.`, `query.`, `body`, `body#`',
}),
]);
});

test.each(['$request.body#.uses.dots.as.delimiters', '$response.body#.uses.dots.as.delimiters'])(
'should error for using `.` as delimiter in json pointer',
expr => {
const results = runRuntimeExpression(expr);
expect(results).toEqual([expect.objectContaining({ message: '`body#` must be followed by `/`' })]);
},
);

test.each(['$request.body#/no/tilde/tokens/in/unescaped~', '$response.body#/invalid/escaped/~01'])(
'errors for incorrect reference tokens',
expr => {
const results = runRuntimeExpression(expr);
expect(results).toEqual([
expect.objectContaining({
message:
'String following `body#` is not a valid JSON pointer, see https://spec.openapis.org/oas/v3.1.0#runtime-expressions for more information',
}),
]);
},
);

test.each(['$request.query.', '$response.query.'])('error for invalid name', expr => {
const invalidString = String.fromCodePoint(0x80);
const results = runRuntimeExpression(expr + invalidString);
expect(results).toEqual([
expect.objectContaining({
message: 'String following `query.` and `path.` must only include ascii characters 0x01-0x7F.',
}),
]);
});

test.each(['$request.header.', '$request.header.(invalid-parentheses)', '$response.header.no,commas'])(
'error for invalid tokens',
expr => {
const results = runRuntimeExpression(expr);
expect(results).toEqual([
expect.objectContaining({ message: 'Must provide valid header name after `header.`' }),
]);
},
);
});
});
110 changes: 110 additions & 0 deletions packages/rulesets/src/oas/functions/runtimeExpression.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { createRulesetFunction } from '@stoplight/spectral-core';
import type { IFunctionResult } from '@stoplight/spectral-core';

export default createRulesetFunction<string, null>(
{
input: {
type: 'string',
},
options: null,
},
function runtimeExpressions(exp) {
if (['$url', '$method', '$statusCode'].includes(exp)) {
// valid expression
return;
} else if (exp.startsWith('$request.') || exp.startsWith('$response.')) {
return validateSource(exp.replace(/^\$(request\.|response\.)/, ''));
}

return [
{
message: 'Expressions must start with one of: `$url`, `$method`, `$statusCode`, `$request.`,`$response.`',
},
];
},
);

function validateSource(source: string): void | IFunctionResult[] {
if (source === 'body') {
// valid expression
return;
} else if (source.startsWith('body#')) {
return validateJsonPointer(source.replace(/^body#/, ''));
} else if (source.startsWith('query.') || source.startsWith('path.')) {
return validateName(source.replace(/^(query\.|path\.)/, ''));
} else if (source.startsWith('header.')) {
return validateToken(source.replace(/^header\./, ''));
}

return [
{
message: '`$request.` and `$response.` must be followed by one of: `header.`, `query.`, `body`, `body#`',
},
];
}

function validateJsonPointer(jsonPointer: string): void | IFunctionResult[] {
if (!jsonPointer.startsWith('/')) {
return [
{
message: '`body#` must be followed by `/`',
},
];
}

while (jsonPointer.includes('/')) {
// remove everything up to and including the first `/`
jsonPointer = jsonPointer.replace(/[^/]*\//, '');
// get substring before the next `/`
const referenceToken: string = jsonPointer.includes('/')
? jsonPointer.slice(0, jsonPointer.indexOf('/'))
: jsonPointer;
if (!isValidReferenceToken(referenceToken)) {
return [
{
message:
'String following `body#` is not a valid JSON pointer, see https://spec.openapis.org/oas/v3.1.0#runtime-expressions for more information',
},
];
}
}
}

function validateName(name: string): void | IFunctionResult[] {
// zero or more of characters in the ASCII range 0x01-0x7F
// eslint-disable-next-line no-control-regex
const validName = /^[\x01-\x7F]*$/;
if (!validName.test(name)) {
return [
{
message: 'String following `query.` and `path.` must only include ascii characters 0x01-0x7F.',
},
];
}
}

function validateToken(token: string): void | IFunctionResult[] {
// one or more of the given tchar characters
const validTCharString = /^[a-zA-Z0-9!#$%&'*+\-.^_`|~]+$/;
if (!validTCharString.test(token)) {
return [
{
message: 'Must provide valid header name after `header.`',
},
];
}
}

function isValidReferenceToken(referenceToken: string): boolean {
return isValidEscaped(referenceToken) || isValidUnescaped(referenceToken);
}

function isValidEscaped(escaped: string): boolean {
// escaped must be empty/null or match the given pattern
return /^(~[01])?$/.test(escaped);
}

function isValidUnescaped(unescaped: string): boolean {
// unescaped may be empty/null, expect no `/` and no `~` chars
return !/[/~]/.test(unescaped);
}
12 changes: 12 additions & 0 deletions packages/rulesets/src/oas/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
oasDiscriminator,
} from './functions';
import { uniquenessTags } from '../shared/functions';
import runtimeExpression from './functions/runtimeExpression';
import serverVariables from '../shared/functions/serverVariables';

export { ruleset as default };
Expand Down Expand Up @@ -727,6 +728,17 @@ const ruleset = {
function: oasUnusedComponent,
},
},
'oas3-links-parameters-expression': {
description: "The links.parameters object's values should be valid runtime expressions.",
message: '{{error}}',
severity: 0,
formats: [oas3],
recommended: true,
given: '#ResponseObject.links[*].parameters',
then: {
function: runtimeExpression,
},
},
'oas3-server-variables': {
description: 'Server variables must be defined and valid and there must be no unused variables.',
message: '{{error}}',
Expand Down