diff --git a/tools/spectral/ipa/__tests__/eachEnumValueMustBeUpperSnakeCase.test.js b/tools/spectral/ipa/__tests__/eachEnumValueMustBeUpperSnakeCase.test.js new file mode 100644 index 0000000000..14854718a2 --- /dev/null +++ b/tools/spectral/ipa/__tests__/eachEnumValueMustBeUpperSnakeCase.test.js @@ -0,0 +1,151 @@ +import testRule from './__helpers__/testRule'; +import { DiagnosticSeverity } from '@stoplight/types'; + +testRule('xgen-IPA-123-enum-values-must-be-upper-snake-case', [ + { + name: 'valid schema - components.schemas', + document: { + components: { + schemas: { + SchemaName: { + properties: { + exampleProperty: { + enum: ['EXAMPLE_A', 'EXAMPLE_B'], + type: 'string', + }, + }, + }, + }, + }, + }, + errors: [], + }, + { + name: 'invalid schema with exception - components.schemas', + document: { + components: { + schemas: { + SchemaName: { + 'x-xgen-IPA-exception': { + 'xgen-IPA-123-enum-values-must-be-upper-snake-case': 'reason', + }, + properties: { + exampleProperty: { + enum: ['exampleA', 'exampleB'], + type: 'string', + }, + }, + }, + }, + }, + }, + errors: [], + }, + { + name: 'invalid schema - components.schemas', + document: { + components: { + schemas: { + SchemaName: { + properties: { + exampleProperty: { + enum: ['exampleA', 'exampleB'], + type: 'string', + }, + }, + }, + }, + }, + }, + errors: [ + { + code: 'xgen-IPA-123-enum-values-must-be-upper-snake-case', + message: 'exampleA enum value must be UPPER_SNAKE_CASE. http://go/ipa/123', + path: ['components', 'schemas', 'SchemaName', 'properties', 'exampleProperty', 'enum', '0'], + severity: DiagnosticSeverity.Warning, + }, + { + code: 'xgen-IPA-123-enum-values-must-be-upper-snake-case', + message: 'exampleB enum value must be UPPER_SNAKE_CASE. http://go/ipa/123', + path: ['components', 'schemas', 'SchemaName', 'properties', 'exampleProperty', 'enum', '1'], + severity: DiagnosticSeverity.Warning, + }, + ], + }, + { + name: 'valid schema - paths.*', + document: { + paths: { + '/a/{exampleId}': { + get: { + parameters: [ + { + schema: { + type: 'string', + enum: ['EXAMPLE_A', 'EXAMPLE_B'], + }, + }, + ], + }, + }, + }, + }, + errors: [], + }, + { + name: 'invalid schema with exception - paths.*', + document: { + paths: { + '/a/{exampleId}': { + get: { + parameters: [ + { + schema: { + 'x-xgen-IPA-exception': { + 'xgen-IPA-123-enum-values-must-be-upper-snake-case': 'reason', + }, + type: 'string', + enum: ['exampleA', 'exampleB'], + }, + }, + ], + }, + }, + }, + }, + errors: [], + }, + { + name: 'invalid schema - paths.*', + document: { + paths: { + '/a/{exampleId}': { + get: { + parameters: [ + { + schema: { + type: 'string', + enum: ['exampleA', 'exampleB'], + }, + }, + ], + }, + }, + }, + }, + errors: [ + { + code: 'xgen-IPA-123-enum-values-must-be-upper-snake-case', + message: 'exampleA enum value must be UPPER_SNAKE_CASE. http://go/ipa/123', + path: ['paths', '/a/{exampleId}', 'get', 'parameters', '0', 'schema', 'enum', '0'], + severity: DiagnosticSeverity.Warning, + }, + { + code: 'xgen-IPA-123-enum-values-must-be-upper-snake-case', + message: 'exampleB enum value must be UPPER_SNAKE_CASE. http://go/ipa/123', + path: ['paths', '/a/{exampleId}', 'get', 'parameters', '0', 'schema', 'enum', '1'], + severity: DiagnosticSeverity.Warning, + }, + ], + }, +]); diff --git a/tools/spectral/ipa/ipa-spectral.yaml b/tools/spectral/ipa/ipa-spectral.yaml index 789367fc96..204b0e2db0 100644 --- a/tools/spectral/ipa/ipa-spectral.yaml +++ b/tools/spectral/ipa/ipa-spectral.yaml @@ -3,3 +3,4 @@ extends: - ./rulesets/IPA-102.yaml - ./rulesets/IPA-104.yaml - ./rulesets/IPA-109.yaml + - ./rulesets/IPA-123.yaml diff --git a/tools/spectral/ipa/rulesets/IPA-123.yaml b/tools/spectral/ipa/rulesets/IPA-123.yaml new file mode 100644 index 0000000000..23b9c66223 --- /dev/null +++ b/tools/spectral/ipa/rulesets/IPA-123.yaml @@ -0,0 +1,14 @@ +# IPA-123: Enums +# http://go/ipa/123 + +functions: + - eachEnumValueMustBeUpperSnakeCase + +rules: + xgen-IPA-123-enum-values-must-be-upper-snake-case: + description: 'Enum values must be UPPER_SNAKE_CASE. http://go/ipa/123' + message: '{{error}} http://go/ipa/123' + severity: warn + given: '$..enum' + then: + function: 'eachEnumValueMustBeUpperSnakeCase' diff --git a/tools/spectral/ipa/rulesets/functions/eachEnumValueMustBeUpperSnakeCase.js b/tools/spectral/ipa/rulesets/functions/eachEnumValueMustBeUpperSnakeCase.js new file mode 100644 index 0000000000..1850a44ca2 --- /dev/null +++ b/tools/spectral/ipa/rulesets/functions/eachEnumValueMustBeUpperSnakeCase.js @@ -0,0 +1,29 @@ +import { hasException } from './utils/exceptions.js'; +import { getSchemaPath, resolveObject } from './utils/componentUtils.js'; +import { casing } from '@stoplight/spectral-functions'; + +const RULE_NAME = 'xgen-IPA-123-enum-values-must-be-upper-snake-case'; +const ERROR_MESSAGE = 'enum value must be UPPER_SNAKE_CASE.'; + +export default (input, _, { path, documentInventory }) => { + const oas = documentInventory.resolved; + const schemaPath = getSchemaPath(path); + const schemaObject = resolveObject(oas, schemaPath); + if (hasException(schemaObject, RULE_NAME)) { + return; + } + + const errors = []; + input.forEach((enumValue, index) => { + const isUpperSnakeCase = casing(enumValue, { type: 'macro' }); + + if (isUpperSnakeCase) { + errors.push({ + path: [...path, index], + message: `${enumValue} ${ERROR_MESSAGE} `, + }); + } + }); + + return errors; +}; diff --git a/tools/spectral/ipa/rulesets/functions/eachPathAlternatesBetweenResourceNameAndPathParam.js b/tools/spectral/ipa/rulesets/functions/eachPathAlternatesBetweenResourceNameAndPathParam.js index 234cb2c5a4..eb418aae61 100644 --- a/tools/spectral/ipa/rulesets/functions/eachPathAlternatesBetweenResourceNameAndPathParam.js +++ b/tools/spectral/ipa/rulesets/functions/eachPathAlternatesBetweenResourceNameAndPathParam.js @@ -1,4 +1,4 @@ -import { isPathParam } from './utils/pathUtils.js'; +import { isPathParam } from './utils/componentUtils.js'; import { hasException } from './utils/exceptions.js'; const RULE_NAME = 'xgen-IPA-102-path-alternate-resource-name-path-param'; diff --git a/tools/spectral/ipa/rulesets/functions/utils/componentUtils.js b/tools/spectral/ipa/rulesets/functions/utils/componentUtils.js new file mode 100644 index 0000000000..2eb2f86d9e --- /dev/null +++ b/tools/spectral/ipa/rulesets/functions/utils/componentUtils.js @@ -0,0 +1,75 @@ +/** + * Checks if a string belongs to a path parameter or a path parameter with a custom method. + * + * A path parameter has the format: `{paramName}` + * A path parameter with a custom method has the format: `{paramName}:customMethod` + * + * @param {string} str - A string extracted from a path split by slashes. + * @returns {boolean} True if the string matches the expected formats, false otherwise. + */ +export function isPathParam(str) { + const pathParamRegEx = new RegExp(`^{[a-z][a-zA-Z0-9]*}$`); + const pathParamWithCustomMethodRegEx = new RegExp(`^{[a-z][a-zA-Z0-9]*}:[a-z][a-zA-Z0-9]*$`); + return pathParamRegEx.test(str) || pathParamWithCustomMethodRegEx.test(str); +} + +/** + * Extracts the schema path from the given JSONPath array. + * + * This function is designed to handle two types of paths commonly encountered in OpenAPI definitions: + * + * 1. **Component Schema Paths**: + * - Represented as: `components.schemas.schemaName.*.enum` + * - This path indicates that the enum is defined within a schema under `components.schemas`. + * - The function returns the first three elements (`["components", "schemas", "schemaName"]`). + * + * 2. **Parameter Schema Paths**: + * - Represented as: `paths.*.method.parameters[*].schema.enum` + * - This path indicates that the enum is part of a parameter's schema in an operation. + * - The function identifies the location of `schema` in the path and returns everything up to (and including) it. + * + * @param {string[]} path - An array representing the JSONPath structure of the OpenAPI definition. + * @returns {string[]} The truncated path pointing to the schema object. + */ +export function getSchemaPath(path) { + if (path.includes('components')) { + return path.slice(0, 3); + } else if (path.includes('paths')) { + const index = path.findIndex((item) => item === 'schema'); + return path.slice(0, index + 1); + } +} + +/** + * Resolves the value of a nested property within an OpenAPI structure using a given path. + * + * This function traverses an OpenAPI object based on a specified path (array of keys) + * and retrieves the value at the end of the path. If any key in the path is not found, + * or the value is undefined at any point, the function will return `undefined`. + * + * @param {Object} oas - The entire OpenAPI Specification object. + * @param {string[]} objectPath - An array of strings representing the path to the desired value. + * For example, `['components', 'schemas', 'MySchema', 'properties']`. + * @returns {*} The value at the specified path within the OpenAPI object, or `undefined` if the path is invalid. + * + * @example + * const oas = { + * components: { + * schemas: { + * MySchema: { + * properties: { + * fieldName: { type: 'string' } + * } + * } + * } + * } + * }; + * + * const result = resolveObject(oas, ['components', 'schemas', 'MySchema', 'properties']); + * console.log(result); // Output: { fieldName: { type: 'string' } } + */ +export function resolveObject(oas, objectPath) { + return objectPath.reduce((current, key) => { + return current && current[key] ? current[key] : undefined; + }, oas); +} diff --git a/tools/spectral/ipa/rulesets/functions/utils/pathUtils.js b/tools/spectral/ipa/rulesets/functions/utils/pathUtils.js deleted file mode 100644 index 34d1910175..0000000000 --- a/tools/spectral/ipa/rulesets/functions/utils/pathUtils.js +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Checks if a string belongs to a path parameter or a path parameter with a custom method. - * - * A path parameter has the format: `{paramName}` - * A path parameter with a custom method has the format: `{paramName}:customMethod` - * - * @param {string} str - A string extracted from a path split by slashes. - * @returns {boolean} True if the string matches the expected formats, false otherwise. - */ -export function isPathParam(str) { - const pathParamRegEx = new RegExp(`^{[a-z][a-zA-Z0-9]*}$`); - const pathParamWithCustomMethodRegEx = new RegExp(`^{[a-z][a-zA-Z0-9]*}:[a-z][a-zA-Z0-9]*$`); - return pathParamRegEx.test(str) || pathParamWithCustomMethodRegEx.test(str); -}