diff --git a/aep/0126.yaml b/aep/0126.yaml new file mode 100644 index 0000000..b8edd8e --- /dev/null +++ b/aep/0126.yaml @@ -0,0 +1,73 @@ +functionsDir: ../functions +functions: + - aep-126-enum-case-consistent + +aliases: + EnumProperty: + description: An enumerated property. + targets: + - formats: ['oas2', 'oas3'] + given: + - $..[?(@property === 'enum')]^ + +rules: + aep-126-enum-type-string: + description: Enumerated fields should use type string, not integer or other types. + message: Enum field "{{property}}" should have type "string", not "{{error}}". + severity: error + formats: ['oas2', 'oas3'] + given: '#EnumProperty' + then: + field: type + function: schema + functionOptions: + schema: + oneOf: + - const: string + - type: array + contains: + const: string + # Exclude everything else but "null" + not: + anyOf: + - const: boolean + - const: integer + - const: number + - const: object + - const: array + + aep-126-enum-case-consistent: + description: All enum values in a field should use consistent case format. + message: '{{error}}' + severity: warn + formats: ['oas2', 'oas3'] + given: '#EnumProperty' + then: + function: aep-126-enum-case-consistent + + aep-126-no-standard-value-enums: + description: Fields should not enumerate standard codes (language, country, currency, media types). + severity: warn + formats: ['oas2', 'oas3'] + given: + - $..properties[[?(@property == 'language' || @property == 'language_code')]] + - $..properties[[?(@property == 'country' || @property == 'country_code' || @property == 'region_code')]] + - $..properties[[?(@property == 'currency' || @property == 'currency_code')]] + - $..properties[[?(@property == 'media_type' || @property == 'content_type')]] + then: + function: schema + functionOptions: + schema: + type: object + not: + required: ['enum'] + + aep-126-enum-has-description: + description: Enum fields should include a description explaining their purpose. + message: Enum field "{{property}}" should have a description. + severity: info + formats: ['oas2', 'oas3'] + given: '#EnumProperty' + then: + field: description + function: truthy diff --git a/docs/0126.md b/docs/0126.md new file mode 100644 index 0000000..b009e19 --- /dev/null +++ b/docs/0126.md @@ -0,0 +1,231 @@ +# Rules for AEP-126: Enumerations + +[aep-126]: https://aep.dev/126 + +## aep-126-enum-type-string + +**Rule**: Enumerated fields should use `type: string`, not `type: integer` or +other types. + +This rule enforces that all enum fields use string types for better readability +and maintainability. + +### Details + +This rule checks that fields with an `enum` property have `type: string`. +Integer or number enums are less readable and harder to maintain than string +enums. + +### Examples + +**Incorrect** code for this rule: + +```yaml +components: + schemas: + Book: + type: object + properties: + status: + type: integer + enum: [0, 1, 2] +``` + +**Correct** code for this rule: + +```yaml +components: + schemas: + Book: + type: object + properties: + status: + type: string + enum: ['DRAFT', 'PUBLISHED', 'ARCHIVED'] +``` + +### Disabling + +If you need to violate this rule, add an override: + +```yaml +overrides: + - files: + - 'openapi.json#/components/schemas/Book/properties/status' + rules: + aep-126-enum-type-string: 'off' +``` + +## aep-126-enum-case-consistent + +**Rule**: All enum values in a field should use consistent case formatting. + +This rule ensures that all values within a single enum use the same case style +(e.g., all UPPERCASE, all lowercase, or all kebab-case). It does not enforce a +specific case format, only consistency. + +### Details + +This rule checks that enum values don't mix different case styles like +UPPERCASE, lowercase, camelCase, or kebab-case within the same enum. + +### Examples + +**Incorrect** code for this rule: + +```yaml +components: + schemas: + Order: + type: object + properties: + status: + type: string + enum: ['active', 'PENDING', 'In_Progress'] # Mixed case styles +``` + +**Correct** code for this rule: + +```yaml +components: + schemas: + Order: + type: object + properties: + status: + type: string + enum: ['ACTIVE', 'PENDING', 'IN_PROGRESS'] # Consistent UPPERCASE +``` + +```yaml +components: + schemas: + Order: + type: object + properties: + status: + type: string + enum: ['active', 'pending', 'in-progress'] # Consistent lowercase/kebab-case +``` + +### Disabling + +```yaml +overrides: + - files: + - 'openapi.json#/components/schemas/Order/properties/status' + rules: + aep-126-enum-case-consistent: 'off' +``` + +## aep-126-no-standard-value-enums + +**Rule**: Fields should not enumerate standard codes (language, country, +currency, media types). + +This rule warns when field names suggest they contain standard codes that +should reference existing standards rather than creating limited enums. + +### Details + +Standard codes like language codes (ISO 639), country codes (ISO 3166), +currency codes (ISO 4217), and media types (IANA) should not be enumerated. +Using enums for these values can lead to lookup tables and integration issues. + +Field names that trigger this warning: + +- `language`, `language_code` → Use ISO 639 +- `country`, `country_code`, `region_code` → Use ISO 3166 +- `currency`, `currency_code` → Use ISO 4217 +- `media_type`, `content_type` → Use IANA media types + +### Examples + +**Incorrect** code for this rule: + +```yaml +components: + schemas: + Document: + type: object + properties: + language: + type: string + enum: ['EN', 'FR', 'ES'] # Should use ISO 639 standard +``` + +**Correct** code for this rule: + +```yaml +components: + schemas: + Document: + type: object + properties: + language_code: + type: string + description: 'ISO 639-1 language code' + pattern: '^[a-z]{2}(-[A-Z]{2})?$' + example: 'en-US' +``` + +### Disabling + +```yaml +overrides: + - files: + - 'openapi.json#/components/schemas/Document/properties/language' + rules: + aep-126-no-standard-value-enums: 'off' +``` + +## aep-126-enum-has-description + +**Rule**: Enum fields should include a `description` property. + +This rule encourages documentation of enum fields to help API consumers +understand their purpose and usage. + +### Details + +Enum fields should include a description explaining what the enum represents +and how it should be used. + +### Examples + +**Incorrect** code for this rule: + +```yaml +components: + schemas: + Book: + type: object + properties: + format: + type: string + enum: ['HARDCOVER', 'PAPERBACK', 'EBOOK'] # Missing description +``` + +**Correct** code for this rule: + +```yaml +components: + schemas: + Book: + type: object + properties: + format: + type: string + description: 'The format in which the book is published' + enum: ['HARDCOVER', 'PAPERBACK', 'EBOOK', 'AUDIOBOOK'] +``` + +### Disabling + +```yaml +overrides: + - files: + - 'openapi.json#/components/schemas/Book/properties/format' + rules: + aep-126-enum-has-description: 'off' +``` diff --git a/docs/rules.md b/docs/rules.md index 2916310..5a8ce11 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -2,6 +2,7 @@ - [Rules for AEP-4](./0004.md) - [Rules for AEP-122](./0122.md) +- [Rules for AEP-126](./0126.md) - [Rules for AEP-131](./0131.md) - [Rules for AEP-132](./0132.md) - [Rules for AEP-133](./0133.md) diff --git a/functions/aep-126-enum-case-consistent.js b/functions/aep-126-enum-case-consistent.js new file mode 100644 index 0000000..15842b1 --- /dev/null +++ b/functions/aep-126-enum-case-consistent.js @@ -0,0 +1,130 @@ +/** + * Validates that all enum values in a field use consistent case formatting. + * + * Based on AEP-126 specification (https://aep.dev/126). + * + * AEP-126 states: "All enum values should use a consistent case format across + * an organization." This rule checks that all values within a single enum use + * the same case style, without enforcing a specific case format. + * + * @param {object} field - The field object containing the enum + * @param {object} _opts - Options (unused) + * @param {object} context - Spectral context containing the path + * @returns {Array} Array of error objects, or empty array if valid + */ +module.exports = (field, _opts, context) => { + if (!field || typeof field !== 'object') { + return []; + } + + // Only check string enums + if (field.type !== 'string') { + return []; + } + + // Get enum array + const enumValues = field.enum; + if (!Array.isArray(enumValues) || enumValues.length === 0) { + return []; + } + + /** + * Detect the case style of a string value. + * Returns one of: + * - 'UPPER', 'UPPER_SNAKE', 'UPPER-KEBAB', + * - 'lower', 'snake_case', 'kebab-case', + * - 'PascalCase', + * - 'camelCase', + * - 'unknown' (not a string or empty or unrecognized format) + */ + const detectCase = (str) => { + if (!str || typeof str !== 'string') return 'unknown'; + + // Check for all uppercase (UPPER, UPPER_SNAKE, or UPPER-KEBAB) + if (str === str.toUpperCase()) { + if (str.includes('_')) return 'UPPER_SNAKE'; + if (str.includes('-')) return 'UPPER-KEBAB'; + return 'UPPER'; + } + + // Check for all lowercase (lower, snake_case, or kebab-case) + if (str === str.toLowerCase()) { + if (str.includes('_')) return 'snake_case'; + if (str.includes('-')) return 'kebab-case'; + return 'lower'; + } + + // Check for PascalCase (starts with uppercase, has mixed case, no separators) + if (/^[A-Z]([a-z0-9]+[A-Z]?)*[a-z0-9]*$/.test(str)) { + return 'PascalCase'; + } + + // Check for camelCase (starts with lowercase, has mixed case, no separators) + if (/^[a-z]([a-z0-9]+[A-Z]?)*[A-Z][a-zA-Z0-9]*$/.test(str)) { + return 'camelCase'; + } + + return 'unknown'; + }; + + const cases = enumValues + .filter((v) => v !== null) + .map((v) => ({ + value: v, + case: detectCase(v), + })); + const caseStyles = [...new Set(cases.map((c) => c.case))]; + + // Check for valid combinations explicitly + let isValid = false; + + if (caseStyles.length === 1 && !caseStyles.includes('unknown')) { + // Single known case style is valid + isValid = true; + // Two case styles - check valid combinations + } else if (caseStyles.every((c) => ['UPPER', 'UPPER_SNAKE'].includes(c))) { + isValid = true; + } else if (caseStyles.every((c) => ['UPPER', 'UPPER-KEBAB'].includes(c))) { + isValid = true; + } else if (caseStyles.every((c) => ['lower', 'snake_case'].includes(c))) { + isValid = true; + } else if (caseStyles.every((c) => ['lower', 'kebab-case'].includes(c))) { + isValid = true; + } else if (caseStyles.every((c) => ['lower', 'camelCase'].includes(c))) { + isValid = true; + } + + if (!isValid) { + const fieldName = context.path[context.path.length - 1]; + + if (caseStyles.includes('unknown')) { + const unknownValues = cases + .filter((c) => c.case === 'unknown') + .map((c) => `"${c.value}"`) + .slice(0, 3) + .join(', '); + const suffix = cases.filter((c) => c.case === 'unknown').length > 3 ? ', ...' : ''; + return [ + { + message: `Enum field "${fieldName}" contains values with unknown case styles: ${unknownValues}${suffix}`, + }, + ]; + } + + const caseExamples = cases + .map((c) => `"${c.value}" (${c.case})`) + .slice(0, 3) + .join(', '); + const suffix = cases.length > 3 ? ', ...' : ''; + + return [ + { + message: + `Enum field "${fieldName}" contains values with inconsistent case styles. ` + + `Found: ${caseExamples}${suffix}. `, + }, + ]; + } + + return []; +}; diff --git a/spectral.yaml b/spectral.yaml index 428b8b5..ad6c5f1 100644 --- a/spectral.yaml +++ b/spectral.yaml @@ -2,6 +2,7 @@ extends: - spectral:oas - ./aep/0004.yaml - ./aep/0122.yaml + - ./aep/0126.yaml - ./aep/0131.yaml - ./aep/0132.yaml - ./aep/0133.yaml diff --git a/test/0126/enum-case-consistent.test.js b/test/0126/enum-case-consistent.test.js new file mode 100644 index 0000000..8623a44 --- /dev/null +++ b/test/0126/enum-case-consistent.test.js @@ -0,0 +1,993 @@ +const { linterForAepRule } = require('../utils'); +require('../matchers'); + +let linter; + +beforeAll(async () => { + linter = await linterForAepRule('0126', 'aep-126-enum-case-consistent'); + return linter; +}); + +test('aep-126-enum-case-consistent should pass for all UPPER case values', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Status: { + type: 'object', + properties: { + state: { + type: 'string', + enum: ['OPEN', 'CLOSED', 'PENDING'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(0); + }); +}); + +test('aep-126-enum-case-consistent should pass for all UPPER_SNAKE_CASE values', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Status: { + type: 'object', + properties: { + state: { + type: 'string', + enum: ['OPEN_NOW', 'CLOSED_TODAY', 'PENDING_REVIEW'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(0); + }); +}); + +test('aep-126-enum-case-consistent should pass for all UPPER-KEBAB-CASE values', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Status: { + type: 'object', + properties: { + state: { + type: 'string', + enum: ['OPEN-NOW', 'CLOSED-TODAY', 'PENDING-REVIEW'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(0); + }); +}); + +test('aep-126-enum-case-consistent should pass for all lower case values', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Status: { + type: 'object', + properties: { + state: { + type: 'string', + enum: ['open', 'closed', 'pending'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(0); + }); +}); + +test('aep-126-enum-case-consistent should pass for all snake_case values', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Status: { + type: 'object', + properties: { + state: { + type: 'string', + enum: ['open_now', 'closed_today', 'pending_review'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(0); + }); +}); + +test('aep-126-enum-case-consistent should pass for all kebab-case values', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Status: { + type: 'object', + properties: { + state: { + type: 'string', + enum: ['open-now', 'closed-today', 'pending-review'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(0); + }); +}); + +test('aep-126-enum-case-consistent should pass for all PascalCase values', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Status: { + type: 'object', + properties: { + state: { + type: 'string', + enum: ['OpenNow', 'ClosedToday', 'PendingReview'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(0); + }); +}); + +test('aep-126-enum-case-consistent should pass for all camelCase values', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Status: { + type: 'object', + properties: { + state: { + type: 'string', + enum: ['openNow', 'closedToday', 'pendingReview'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(0); + }); +}); + +test('aep-126-enum-case-consistent should pass for UPPER + UPPER_SNAKE combination', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Status: { + type: 'object', + properties: { + state: { + type: 'string', + enum: ['OPEN', 'CLOSED_NOW', 'PENDING_REVIEW'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(0); + }); +}); + +test('aep-126-enum-case-consistent should pass for UPPER + UPPER-KEBAB combination', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Status: { + type: 'object', + properties: { + state: { + type: 'string', + enum: ['OPEN', 'CLOSED-NOW', 'PENDING-REVIEW'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(0); + }); +}); + +test('aep-126-enum-case-consistent should pass for lower + snake_case combination', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Status: { + type: 'object', + properties: { + state: { + type: 'string', + enum: ['open', 'closed_now', 'pending_review'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(0); + }); +}); + +test('aep-126-enum-case-consistent should pass for lower + kebab-case combination', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Status: { + type: 'object', + properties: { + state: { + type: 'string', + enum: ['open', 'closed-now', 'pending-review'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(0); + }); +}); + +test('aep-126-enum-case-consistent should pass for lower + camelCase combination', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Status: { + type: 'object', + properties: { + state: { + type: 'string', + enum: ['open', 'closedNow', 'pendingReview'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(0); + }); +}); + +test('aep-126-enum-case-consistent should pass with null at start', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Status: { + type: 'object', + properties: { + state: { + type: 'string', + nullable: true, + enum: [null, 'OPEN', 'CLOSED'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(0); + }); +}); + +test('aep-126-enum-case-consistent should pass with null in middle', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Status: { + type: 'object', + properties: { + state: { + type: 'string', + nullable: true, + enum: ['OPEN', null, 'CLOSED'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(0); + }); +}); + +test('aep-126-enum-case-consistent should pass with null at end', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Status: { + type: 'object', + properties: { + state: { + type: 'string', + nullable: true, + enum: ['OPEN', 'CLOSED', null], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(0); + }); +}); + +test('aep-126-enum-case-consistent should pass with multiple nulls', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Status: { + type: 'object', + properties: { + state: { + type: 'string', + nullable: true, + enum: [null, 'OPEN', null, 'CLOSED', null], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(0); + }); +}); + +test('aep-126-enum-case-consistent should pass for single value enum', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Status: { + type: 'object', + properties: { + state: { + type: 'string', + enum: ['OPEN'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(0); + }); +}); + +test('aep-126-enum-case-consistent should pass for non-string type field', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Status: { + type: 'object', + properties: { + priority: { + type: 'integer', + enum: [1, 2, 3], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(0); + }); +}); + +test('aep-126-enum-case-consistent should pass for empty enum array', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Status: { + type: 'object', + properties: { + state: { + type: 'string', + enum: [], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(0); + }); +}); + +test('aep-126-enum-case-consistent should pass when only one string value with nulls', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Status: { + type: 'object', + properties: { + state: { + type: 'string', + nullable: true, + enum: [null, 'OPEN', null], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(0); + }); +}); + +test('aep-126-enum-case-consistent should pass for field without enum property', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Status: { + type: 'object', + properties: { + state: { + type: 'string', + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(0); + }); +}); + +test('aep-126-enum-case-consistent should fail for UPPER + lower combination', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Status: { + type: 'object', + properties: { + state: { + type: 'string', + enum: ['OPEN', 'closed', 'PENDING'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBeGreaterThan(0); + expect(results).toContainMatch({ + message: expect.stringMatching(/inconsistent case styles/i), + }); + }); +}); + +test('aep-126-enum-case-consistent should fail for UPPER + PascalCase combination', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Status: { + type: 'object', + properties: { + state: { + type: 'string', + enum: ['OPEN', 'Closed', 'Pending'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBeGreaterThan(0); + expect(results).toContainMatch({ + message: expect.stringMatching(/inconsistent case styles/i), + }); + }); +}); + +test('aep-126-enum-case-consistent should fail for UPPER + camelCase combination', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Status: { + type: 'object', + properties: { + state: { + type: 'string', + enum: ['OPEN', 'closedNow', 'pending'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBeGreaterThan(0); + expect(results).toContainMatch({ + message: expect.stringMatching(/inconsistent case styles/i), + }); + }); +}); + +test('aep-126-enum-case-consistent should fail for UPPER_SNAKE + snake_case combination', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Status: { + type: 'object', + properties: { + state: { + type: 'string', + enum: ['OPEN_NOW', 'closed_now', 'PENDING'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBeGreaterThan(0); + expect(results).toContainMatch({ + message: expect.stringMatching(/inconsistent case styles/i), + }); + }); +}); + +test('aep-126-enum-case-consistent should fail for UPPER-KEBAB + kebab-case combination', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Status: { + type: 'object', + properties: { + state: { + type: 'string', + enum: ['OPEN-NOW', 'closed-now', 'PENDING'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBeGreaterThan(0); + expect(results).toContainMatch({ + message: expect.stringMatching(/inconsistent case styles/i), + }); + }); +}); + +test('aep-126-enum-case-consistent should fail for PascalCase + camelCase combination', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Status: { + type: 'object', + properties: { + state: { + type: 'string', + enum: ['OpenNow', 'closedNow', 'PendingReview'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBeGreaterThan(0); + expect(results).toContainMatch({ + message: expect.stringMatching(/inconsistent case styles/i), + }); + }); +}); + +test('aep-126-enum-case-consistent should fail for snake_case + kebab-case combination', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Status: { + type: 'object', + properties: { + state: { + type: 'string', + enum: ['open_now', 'closed-now', 'pending'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBeGreaterThan(0); + expect(results).toContainMatch({ + message: expect.stringMatching(/inconsistent case styles/i), + }); + }); +}); + +test('aep-126-enum-case-consistent should fail for snake_case + camelCase combination', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Status: { + type: 'object', + properties: { + state: { + type: 'string', + enum: ['open_now', 'closedNow', 'pending'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBeGreaterThan(0); + expect(results).toContainMatch({ + message: expect.stringMatching(/inconsistent case styles/i), + }); + }); +}); + +test('aep-126-enum-case-consistent should fail for UPPER_SNAKE + kebab-case combination', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Status: { + type: 'object', + properties: { + state: { + type: 'string', + enum: ['OPEN_NOW', 'closed-now'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBeGreaterThan(0); + expect(results).toContainMatch({ + message: expect.stringMatching(/inconsistent case styles/i), + }); + }); +}); + +test('aep-126-enum-case-consistent should fail for UPPER-KEBAB + snake_case combination', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Status: { + type: 'object', + properties: { + state: { + type: 'string', + enum: ['OPEN-NOW', 'closed_now'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBeGreaterThan(0); + expect(results).toContainMatch({ + message: expect.stringMatching(/inconsistent case styles/i), + }); + }); +}); + +test('aep-126-enum-case-consistent should fail for PascalCase + snake_case combination', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Status: { + type: 'object', + properties: { + state: { + type: 'string', + enum: ['OpenNow', 'closed_now'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBeGreaterThan(0); + expect(results).toContainMatch({ + message: expect.stringMatching(/inconsistent case styles/i), + }); + }); +}); + +test('aep-126-enum-case-consistent should fail for three or more inconsistent styles', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Status: { + type: 'object', + properties: { + state: { + type: 'string', + enum: ['OPEN', 'closed', 'PendingReview', 'in_progress'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBeGreaterThan(0); + expect(results).toContainMatch({ + message: expect.stringMatching(/inconsistent case styles/i), + }); + }); +}); + +test('aep-126-enum-case-consistent should include field name in error message', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Status: { + type: 'object', + properties: { + state: { + type: 'string', + enum: ['OPEN', 'closed'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBeGreaterThan(0); + expect(results[0].message).toMatch(/field "state"/i); + }); +}); + +test('aep-126-enum-case-consistent should show case styles for each value', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Status: { + type: 'object', + properties: { + state: { + type: 'string', + enum: ['OPEN', 'closed', 'Pending'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBeGreaterThan(0); + expect(results[0].message).toMatch(/"OPEN"/); + expect(results[0].message).toMatch(/"closed"/); + expect(results[0].message).toMatch(/\(UPPER\)/); + expect(results[0].message).toMatch(/\(lower\)/); + }); +}); + +test('aep-126-enum-case-consistent should truncate to first 3 examples with ellipsis', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Status: { + type: 'object', + properties: { + state: { + type: 'string', + enum: ['OPEN', 'closed', 'Pending', 'in_review', 'Done'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBeGreaterThan(0); + expect(results[0].message).toMatch(/\.\.\./); + const quoteCount = (results[0].message.match(/"/g) || []).length; + expect(quoteCount).toBe(8); // field name + 3 values × 2 quotes each + }); +}); + +test('aep-126-enum-case-consistent should handle single character values consistently', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Grade: { + type: 'object', + properties: { + letter: { + type: 'string', + enum: ['A', 'B', 'C'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(0); + }); +}); + +test('aep-126-enum-case-consistent should handle numeric strings consistently', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Priority: { + type: 'object', + properties: { + level: { + type: 'string', + enum: ['1', '2', '3'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(0); + }); +}); + +test('aep-126-enum-case-consistent should fail for empty string in enum', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Status: { + type: 'object', + properties: { + state: { + type: 'string', + enum: ['OPEN', '', 'CLOSED'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBeGreaterThan(0); + expect(results[0].message).toMatch(/unknown case styles/i); + expect(results[0].message).toMatch(/""/); + }); +}); + +test('aep-126-enum-case-consistent should fail for enum with non-string values', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Status: { + type: 'object', + properties: { + state: { + type: 'string', + enum: ['OPEN', 123, 'CLOSED'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBeGreaterThan(0); + expect(results[0].message).toMatch(/unknown case styles/i); + expect(results[0].message).toMatch(/"123"/); + }); +}); + +test('aep-126-enum-case-consistent should not fail for operation parameter with enum', () => { + const oasDoc = { + openapi: '3.0.3', + paths: { + '/books': { + get: { + parameters: [ + { + name: 'status', + in: 'query', + schema: { + type: 'string', + enum: ['PUBLISHED', 'DRAFT', 'ARCHIVED'], + }, + }, + ], + responses: { + 200: { + description: 'Success', + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(0); + }); +}); diff --git a/test/0126/enum-has-description.test.js b/test/0126/enum-has-description.test.js new file mode 100644 index 0000000..751676c --- /dev/null +++ b/test/0126/enum-has-description.test.js @@ -0,0 +1,154 @@ +const { linterForAepRule } = require('../utils'); +require('../matchers'); + +let linter; + +beforeAll(async () => { + linter = await linterForAepRule('0126', 'aep-126-enum-has-description'); + return linter; +}); + +test('aep-126-enum-has-description should find info messages for enums without description', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Book: { + type: 'object', + properties: { + format: { + type: 'string', + enum: ['HARDCOVER', 'PAPERBACK', 'EBOOK'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(1); + expect(results).toContainMatch({ + message: expect.stringMatching(/description/i), + }); + }); +}); + +test('aep-126-enum-has-description should find info for multiple enums without descriptions', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Order: { + type: 'object', + properties: { + status: { + type: 'string', + enum: ['PENDING', 'SHIPPED', 'DELIVERED'], + }, + priority: { + type: 'string', + enum: ['LOW', 'MEDIUM', 'HIGH'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(2); + }); +}); + +test('aep-126-enum-has-description should find no issues for enums with description', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Book: { + type: 'object', + properties: { + format: { + type: 'string', + description: 'The format in which the book is published', + enum: ['HARDCOVER', 'PAPERBACK', 'EBOOK', 'AUDIOBOOK'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(0); + }); +}); + +test('aep-126-enum-has-description should accept empty string as description', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Product: { + type: 'object', + properties: { + category: { + type: 'string', + description: '', + enum: ['ELECTRONICS', 'BOOKS', 'CLOTHING'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + // Empty string is falsy, so it should still trigger + expect(results.length).toBe(1); + }); +}); + +test('aep-126-enum-has-description should work with nullable enums', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Book: { + type: 'object', + properties: { + format: { + type: 'string', + nullable: true, + description: 'Optional format of the book', + enum: [null, 'HARDCOVER', 'PAPERBACK'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(0); + }); +}); + +test('aep-126-enum-has-description should flag nullable enums without description', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Order: { + type: 'object', + properties: { + priority: { + type: 'string', + nullable: true, + enum: [null, 'LOW', 'HIGH'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(1); + }); +}); diff --git a/test/0126/enum-type-string.test.js b/test/0126/enum-type-string.test.js new file mode 100644 index 0000000..a0d25c5 --- /dev/null +++ b/test/0126/enum-type-string.test.js @@ -0,0 +1,155 @@ +const { linterForAepRule } = require('../utils'); +require('../matchers'); + +let linter; + +beforeAll(async () => { + linter = await linterForAepRule('0126', 'aep-126-enum-type-string'); + return linter; +}); + +test('aep-126-enum-type-string should find errors for integer enums', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Book: { + type: 'object', + properties: { + status: { + type: 'integer', + enum: [0, 1, 2], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBeGreaterThan(0); + expect(results).toContainMatch({ + message: expect.stringMatching(/type.*string/i), + }); + }); +}); + +test('aep-126-enum-type-string should find errors for number enums', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Product: { + type: 'object', + properties: { + rating: { + type: 'number', + enum: [1.0, 2.0, 3.0, 4.0, 5.0], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBeGreaterThan(0); + expect(results).toContainMatch({ + message: expect.stringMatching(/type.*string/i), + }); + }); +}); + +test('aep-126-enum-type-string should find no errors for string enums', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Book: { + type: 'object', + properties: { + format: { + type: 'string', + enum: ['HARDCOVER', 'PAPERBACK', 'EBOOK', 'AUDIOBOOK'], + }, + status: { + type: 'string', + enum: ['DRAFT', 'PUBLISHED', 'ARCHIVED'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(0); + }); +}); + +test('aep-126-enum-type-string should allow nullable string enums', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Book: { + type: 'object', + properties: { + format: { + type: 'string', + nullable: true, + enum: [null, 'HARDCOVER', 'PAPERBACK'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(0); + }); +}); + +test('aep-126-enum-type-string should allow OAS 3.1 type array with string and null', () => { + const oasDoc = { + openapi: '3.1.0', + components: { + schemas: { + Book: { + type: 'object', + properties: { + format: { + type: ['string', 'null'], + enum: [null, 'HARDCOVER', 'PAPERBACK', 'EBOOK'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(0); + }); +}); + +test('aep-126-enum-type-string should reject OAS 3.1 type array with integer', () => { + const oasDoc = { + openapi: '3.1.0', + components: { + schemas: { + Product: { + type: 'object', + properties: { + status: { + type: ['integer', 'null'], + enum: [null, 0, 1, 2], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBeGreaterThan(0); + expect(results).toContainMatch({ + message: expect.stringMatching(/type.*string/i), + }); + }); +}); diff --git a/test/0126/no-standard-value-enums.test.js b/test/0126/no-standard-value-enums.test.js new file mode 100644 index 0000000..895723e --- /dev/null +++ b/test/0126/no-standard-value-enums.test.js @@ -0,0 +1,246 @@ +const { linterForAepRule } = require('../utils'); +require('../matchers'); + +let linter; + +beforeAll(async () => { + linter = await linterForAepRule('0126', 'aep-126-no-standard-value-enums'); + return linter; +}); + +test('aep-126-no-standard-value-enums should warn for language field', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Book: { + type: 'object', + properties: { + language: { + type: 'string', + enum: ['EN', 'FR', 'ES', 'DE'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(1); + expect(results).toContainMatch({ + message: expect.stringMatching(/standard codes/i), + }); + }); +}); + +test('aep-126-no-standard-value-enums should warn for language_code field', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Document: { + type: 'object', + properties: { + language_code: { + type: 'string', + enum: ['en', 'fr', 'es'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(1); + expect(results).toContainMatch({ + message: expect.stringMatching(/standard/i), + }); + }); +}); + +test('aep-126-no-standard-value-enums should warn for country field', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Address: { + type: 'object', + properties: { + country: { + type: 'string', + enum: ['US', 'CA', 'MX'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(1); + expect(results).toContainMatch({ + message: expect.stringMatching(/standard/i), + }); + }); +}); + +test('aep-126-no-standard-value-enums should warn for country_code field', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Location: { + type: 'object', + properties: { + country_code: { + type: 'string', + enum: ['USA', 'CAN', 'MEX'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(1); + }); +}); + +test('aep-126-no-standard-value-enums should warn for region_code field', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Service: { + type: 'object', + properties: { + region_code: { + type: 'string', + enum: ['us-east', 'us-west', 'eu-central'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(1); + }); +}); + +test('aep-126-no-standard-value-enums should warn for currency field', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Payment: { + type: 'object', + properties: { + currency: { + type: 'string', + enum: ['USD', 'EUR', 'GBP'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(1); + }); +}); + +test('aep-126-no-standard-value-enums should warn for currency_code field', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Transaction: { + type: 'object', + properties: { + currency_code: { + type: 'string', + enum: ['USD', 'EUR'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(1); + }); +}); + +test('aep-126-no-standard-value-enums should warn for media_type field', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + File: { + type: 'object', + properties: { + media_type: { + type: 'string', + enum: ['image/jpeg', 'image/png', 'application/pdf'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(1); + }); +}); + +test('aep-126-no-standard-value-enums should warn for content_type field', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Document: { + type: 'object', + properties: { + content_type: { + type: 'string', + enum: ['text/html', 'application/json'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(1); + }); +}); + +test('aep-126-no-standard-value-enums should not warn for other enum fields', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Book: { + type: 'object', + properties: { + status: { + type: 'string', + enum: ['DRAFT', 'PUBLISHED', 'ARCHIVED'], + }, + format: { + type: 'string', + enum: ['HARDCOVER', 'PAPERBACK', 'EBOOK'], + }, + priority: { + type: 'string', + enum: ['LOW', 'MEDIUM', 'HIGH'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(0); + }); +});