diff --git a/eslint.config.mjs b/eslint.config.mjs index 726de46d3c..12eee0bc1a 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -36,7 +36,7 @@ export default [ }, }, { - ignores: ['node-modules'], + ignores: ['node_modules'], }, { files: ['**/*.test.js'], diff --git a/package.json b/package.json index 8808e59d11..3f221d00f3 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "scripts": { "format": "npx prettier . --write", "format-check": "npx prettier . --check", - "lint-js": "npx eslint **/*.js", + "lint-js": "npx eslint .", "gen-ipa-docs": "node tools/spectral/ipa/scripts/generateRulesetReadme.js", "ipa-validation": "spectral lint ./openapi/.raw/v2.yaml --ruleset=./tools/spectral/ipa/ipa-spectral.yaml", "ipa-filter-violations": "node tools/spectral/ipa/scripts/filter-ipa-violations.js", diff --git a/tools/spectral/.spectral.yaml b/tools/spectral/.spectral.yaml index 8e5a012fb9..28bf2b216f 100644 --- a/tools/spectral/.spectral.yaml +++ b/tools/spectral/.spectral.yaml @@ -15,7 +15,18 @@ aliases: - "#OperationObject.parameters[?(@ && @.in)]" - "$.components.schemas[*]..properties[?(@ && @.type)]" +functions: + - acceptHeaderUpcomingVersionLimit + rules: + accept-header-upcoming-version-limit: + description: Ensure that each operation has at most one upcoming API Accept header. + message: "An operation must not have more than one upcoming API Accept header (format: application/vnd.atlas.YYYY-MM-DD.upcoming+format)." + severity: error + given: $.paths[*][*] + then: + function: "acceptHeaderUpcomingVersionLimit" + xgen-schema-name-pascal-case: description: OpenAPI Schema names should use PascalCase. PascalCase ensures consistency with OpenAPI generated code. message: "`{{property}}` name must follow PascalCase. Please verify if you have provided valid @Schema(name='') annotation" diff --git a/tools/spectral/.tool-versions b/tools/spectral/.tool-versions new file mode 100644 index 0000000000..119cdc35fc --- /dev/null +++ b/tools/spectral/.tool-versions @@ -0,0 +1 @@ +nodejs 24.0.2 diff --git a/tools/spectral/__tests__/acceptHeaderUpcomingVersionLimit.test.js b/tools/spectral/__tests__/acceptHeaderUpcomingVersionLimit.test.js new file mode 100644 index 0000000000..2271a6cc94 --- /dev/null +++ b/tools/spectral/__tests__/acceptHeaderUpcomingVersionLimit.test.js @@ -0,0 +1,99 @@ +import { DiagnosticSeverity } from '@stoplight/types'; +import acceptHeaderUpcomingVersionLimit from '../functions/acceptHeaderUpcomingVersionLimit'; + +describe('accept-header-upcoming-version-limit', () => { + it('valid: no upcoming Accept headers', () => { + const operation = { + operationId: 'getTest', + responses: { + 200: { + content: { + 'application/json': {}, + }, + }, + }, + requestBody: { + content: { + 'application/json': {}, + }, + }, + }; + + const result = acceptHeaderUpcomingVersionLimit(operation); + expect(result).toBeUndefined(); + }); + + it('valid: one upcoming Accept header in response', () => { + const operation = { + operationId: 'getTest', + responses: { + 200: { + content: { + 'application/vnd.atlas.2024-06-01.upcoming+json': {}, + 'application/json': {}, + }, + }, + }, + }; + + const result = acceptHeaderUpcomingVersionLimit(operation); + expect(result).toBeUndefined(); + }); + + it('invalid: two upcoming Accept headers in response', () => { + const operation = { + operationId: 'getTest', + responses: { + 200: { + content: { + 'application/vnd.atlas.2024-06-01.upcoming+json': {}, + 'application/vnd.atlas.2024-07-01.upcoming+json': {}, + 'application/json': {}, + }, + }, + }, + }; + + const result = acceptHeaderUpcomingVersionLimit(operation); + expect(result).toEqual([ + { + message: expect.stringMatching(/Found 2 upcoming API Accept headers/), + }, + ]); + }); + + it('invalid: two upcoming Accept headers in request', () => { + const operation = { + operationId: 'postTest', + requestBody: { + content: { + 'application/vnd.atlas.2024-06-01.upcoming+json': {}, + 'application/vnd.atlas.2024-07-01.upcoming+json': {}, + 'application/json': {}, + }, + }, + }; + + const result = acceptHeaderUpcomingVersionLimit(operation); + expect(result).toEqual([ + { + message: expect.stringMatching(/Found 2 upcoming API Accept headers/), + }, + ]); + }); + + it('invalid: missing operationId', () => { + const operation = { + responses: { + 200: { + content: { + 'application/vnd.atlas.2024-06-01.upcoming+json': {}, + }, + }, + }, + }; + + const result = acceptHeaderUpcomingVersionLimit(operation); + expect(result).toBeUndefined(); + }); +}); diff --git a/tools/spectral/functions/acceptHeaderUpcomingVersionLimit.js b/tools/spectral/functions/acceptHeaderUpcomingVersionLimit.js new file mode 100644 index 0000000000..ffe59b0699 --- /dev/null +++ b/tools/spectral/functions/acceptHeaderUpcomingVersionLimit.js @@ -0,0 +1,41 @@ +module.exports = function (input) { + // Get operationId from context, if this fails return an error + const operationId = input.operationId; + if (!operationId) { + return; + } + + // List of errors + const errors = []; + + // Validate versions in 200 responses + const responseErr = validateContent(operationId, 'response', input?.responses?.[200]?.content); + if (responseErr != null) errors.push(responseErr); + + // Validate versions in requests + const requestErr = validateContent(operationId, 'request', input?.requestBody?.content); + if (requestErr != null) errors.push(requestErr); + + return errors.length > 0 ? errors : undefined; +}; + +// Check for upcoming API Accept headers +const upcomingRegex = /^application\/vnd\.atlas\.\d{4}-\d{2}-\d{2}\.upcoming\+.+$/; + +function validateContent(operationId, section, content) { + if (content == null) { + return null; + } + + const contentTypes = Object.keys(content); + const upcomingContentTypes = contentTypes.filter((k) => upcomingRegex.test(k)); + // If there's less than or equal to one upcoming header then the operation is valid + if (upcomingContentTypes.length <= 1) { + return null; + } + + // Return an error message + return { + message: `OperationId: ${operationId} - Found ${upcomingContentTypes.length} upcoming API Accept headers (section: ${section}): ${upcomingContentTypes.join(', ')}`, + }; +}