diff --git a/packages/rulesets/src/oas/__tests__/oas3-links-parameters-expressions.test.ts b/packages/rulesets/src/oas/__tests__/oas3-links-parameters-expressions.test.ts new file mode 100644 index 000000000..971c05214 --- /dev/null +++ b/packages/rulesets/src/oas/__tests__/oas3-links-parameters-expressions.test.ts @@ -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, + }, + ], + }, +]); diff --git a/packages/rulesets/src/oas/functions/__tests__/runtimeExpression.test.ts b/packages/rulesets/src/oas/functions/__tests__/runtimeExpression.test.ts new file mode 100644 index 000000000..bbf65c31d --- /dev/null +++ b/packages/rulesets/src/oas/functions/__tests__/runtimeExpression.test.ts @@ -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) { + // @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.`' }), + ]); + }, + ); + }); +}); diff --git a/packages/rulesets/src/oas/functions/runtimeExpression.ts b/packages/rulesets/src/oas/functions/runtimeExpression.ts new file mode 100644 index 000000000..b5869bf06 --- /dev/null +++ b/packages/rulesets/src/oas/functions/runtimeExpression.ts @@ -0,0 +1,110 @@ +import { createRulesetFunction } from '@stoplight/spectral-core'; +import type { IFunctionResult } from '@stoplight/spectral-core'; + +export default createRulesetFunction( + { + 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); +} diff --git a/packages/rulesets/src/oas/index.ts b/packages/rulesets/src/oas/index.ts index 62057d456..b256e4d30 100644 --- a/packages/rulesets/src/oas/index.ts +++ b/packages/rulesets/src/oas/index.ts @@ -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 }; @@ -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}}',