diff --git a/aep/0126.yaml b/aep/0126.yaml new file mode 100644 index 0000000..302d044 --- /dev/null +++ b/aep/0126.yaml @@ -0,0 +1,71 @@ +functionsDir: ../functions +functions: + - aep-126-enum-case-consistent + - aep-126-enum-null-first + - aep-126-enum-nullable-declaration + - aep-126-no-standard-value-enums + +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: $..[?(@property === 'enum')]^ + then: + field: type + function: schema + functionOptions: + schema: + oneOf: + - const: string + - type: array + contains: + const: string + maxItems: 2 + + 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: $..[?(@property === 'enum')]^ + then: + function: aep-126-enum-case-consistent + + aep-126-enum-null-first: + description: If enum is nullable, null should be the first value in the enum array. + message: '{{error}}' + severity: warn + formats: ['oas2', 'oas3'] + given: $..[?(@property === 'enum')]^ + then: + function: aep-126-enum-null-first + + aep-126-enum-nullable-declaration: + description: If enum contains null, field must declare nullable true. + message: '{{error}}' + severity: error + formats: ['oas2', 'oas3'] + given: $..[?(@property === 'enum')]^ + then: + function: aep-126-enum-nullable-declaration + + aep-126-no-standard-value-enums: + description: Fields should not enumerate standard codes (language, country, currency, media types). + message: '{{error}}' + severity: warn + formats: ['oas2', 'oas3'] + given: $..[?(@property === 'enum')]^ + then: + function: aep-126-no-standard-value-enums + + 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: $..[?(@property === 'enum')]^ + then: + field: description + function: truthy diff --git a/docs/0126.md b/docs/0126.md new file mode 100644 index 0000000..891c02d --- /dev/null +++ b/docs/0126.md @@ -0,0 +1,335 @@ +# 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-enum-null-first + +**Rule**: If enum is nullable, `null` should be the first value in the enum +array. + +This rule enforces a consistent pattern where `null` appears as the first +element in nullable enums for improved API predictability. + +### Details + +For optional enums (fields with `nullable: true`), this rule checks that if +`null` is included in the enum array, it must be the first value. + +### Examples + +**Incorrect** code for this rule: + +```yaml +components: + schemas: + Book: + type: object + properties: + format: + type: string + nullable: true + enum: ['HARDCOVER', null, 'PAPERBACK'] # null not first +``` + +**Correct** code for this rule: + +```yaml +components: + schemas: + Book: + type: object + properties: + format: + type: string + nullable: true + enum: [null, 'HARDCOVER', 'PAPERBACK', 'EBOOK'] # null first +``` + +### Disabling + +```yaml +overrides: + - files: + - 'openapi.json#/components/schemas/Book/properties/format' + rules: + aep-126-enum-null-first: 'off' +``` + +## aep-126-enum-nullable-declaration + +**Rule**: If enum contains `null`, field must declare `nullable: true`. + +This rule enforces the OpenAPI 3.0 requirement that fields with `null` in their +enum array must also declare `nullable: true`. + +### Details + +When an enum array contains `null` as one of the values, the field must +explicitly declare `nullable: true` for proper OpenAPI validation. + +### Examples + +**Incorrect** code for this rule: + +```yaml +components: + schemas: + Book: + type: object + properties: + format: + type: string + enum: [null, 'HARDCOVER', 'PAPERBACK'] # Missing nullable: true +``` + +**Correct** code for this rule: + +```yaml +components: + schemas: + Book: + type: object + properties: + format: + type: string + nullable: true + enum: [null, 'HARDCOVER', 'PAPERBACK', 'EBOOK'] +``` + +### Disabling + +```yaml +overrides: + - files: + - 'openapi.json#/components/schemas/Book/properties/format' + rules: + aep-126-enum-nullable-declaration: '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 248ef4d..986f000 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -1,6 +1,7 @@ # Ruleset for AEP OpenAPI Linter - [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..8eadfe2 --- /dev/null +++ b/functions/aep-126-enum-case-consistent.js @@ -0,0 +1,123 @@ +/** + * 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 []; + } + + // Filter out null values - they don't have case + const stringValues = enumValues.filter((v) => v !== null && typeof v === 'string'); + + // If less than 2 string values, no consistency check needed + if (stringValues.length < 2) { + return []; + } + + /** + * Detect the case style of a string value. + * Returns one of: 'UPPER', 'lower', 'camelCase', 'PascalCase', 'kebab-case', 'snake_case', 'UPPER_SNAKE', 'mixed' + */ + const detectCase = (str) => { + if (!str || typeof str !== 'string') return 'unknown'; + + // Check for all uppercase (UPPERCASE or UPPER_SNAKE_CASE) + if (str === str.toUpperCase()) { + if (str.includes('_')) return 'UPPER_SNAKE'; + if (str.includes('-')) return 'UPPER-KEBAB'; + return 'UPPER'; + } + + // Check for all lowercase + 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-zA-Z0-9]*$/.test(str) && str !== str.toUpperCase()) { + return 'PascalCase'; + } + + // Check for camelCase (starts with lowercase, has mixed case, no separators) + if (/^[a-z][a-zA-Z0-9]*$/.test(str)) { + return 'camelCase'; + } + + // Mixed case with separators + if (str.includes('_')) return 'Mixed_Snake'; + if (str.includes('-')) return 'Mixed-Kebab'; + + return 'mixed'; + }; + + // Detect case for all string values + const cases = stringValues.map((value) => ({ + value, + case: detectCase(value), + })); + + // Get unique case styles + const caseStyles = [...new Set(cases.map((c) => c.case))]; + + // Normalize compatible case styles + // Lowercase variants (lower, kebab-case, snake_case) are compatible + // Uppercase variants (UPPER, UPPER_SNAKE, UPPER-KEBAB) are compatible + const normalizedStyles = caseStyles.map((style) => { + if (style === 'lower' || style === 'kebab-case' || style === 'snake_case') { + return 'lowercase-family'; + } + if (style === 'UPPER' || style === 'UPPER_SNAKE' || style === 'UPPER-KEBAB') { + return 'uppercase-family'; + } + return style; + }); + + const uniqueNormalizedStyles = [...new Set(normalizedStyles)]; + + // If more than one normalized case style, report inconsistency + if (uniqueNormalizedStyles.length > 1) { + const fieldName = context.path[context.path.length - 1]; + const caseExamples = cases + .map((c) => `"${c.value}" (${c.case})`) + .slice(0, 3) + .join(', '); + + const suffix = cases.length > 3 ? ', ...' : ''; + const message = + `Enum field "${fieldName}" has inconsistent case formatting. ` + + `Found: ${caseExamples}${suffix}. ` + + `All values should use the same case style.`; + + return [ + { + message, + }, + ]; + } + + return []; +}; diff --git a/functions/aep-126-enum-null-first.js b/functions/aep-126-enum-null-first.js new file mode 100644 index 0000000..b12134e --- /dev/null +++ b/functions/aep-126-enum-null-first.js @@ -0,0 +1,48 @@ +/** + * Validates that nullable enums have null as the first value. + * + * Based on AEP-126 specification (https://aep.dev/126). + * + * @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 []; + } + + // Check if field is nullable + // OpenAPI 3.0: nullable: true + // OpenAPI 3.1: type: ['string', 'null'] or type: 'null' + const isNullable = field.nullable === true || (Array.isArray(field.type) && field.type.includes('null')); + + if (!isNullable) { + return []; + } + + // Get enum array + const enumValues = field.enum; + if (!Array.isArray(enumValues) || enumValues.length === 0) { + return []; + } + + // Check if null exists in the enum + const hasNull = enumValues.includes(null); + if (!hasNull) { + return []; + } + + // Check if null is the first value + if (enumValues[0] !== null) { + const fieldName = context.path[context.path.length - 1]; + return [ + { + message: `Enum field "${fieldName}" contains "null" and is nullable, but "null" must be the first value.`, + }, + ]; + } + + return []; +}; diff --git a/functions/aep-126-enum-nullable-declaration.js b/functions/aep-126-enum-nullable-declaration.js new file mode 100644 index 0000000..7a8247d --- /dev/null +++ b/functions/aep-126-enum-nullable-declaration.js @@ -0,0 +1,45 @@ +/** + * Validates that enums containing null also declare nullable: true. + * + * Based on AEP-126 specification (https://aep.dev/126). + * + * @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 []; + } + + // Get enum array + const enumValues = field.enum; + if (!Array.isArray(enumValues) || enumValues.length === 0) { + return []; + } + + // Check if null exists in the enum + const hasNull = enumValues.includes(null); + if (!hasNull) { + return []; + } + + // Check if nullable is declared + // OpenAPI 3.0: nullable: true + // OpenAPI 3.1: type: ['string', 'null'] or type: 'null' + const isNullable = field.nullable === true || (Array.isArray(field.type) && field.type.includes('null')); + + if (!isNullable) { + const fieldName = context.path[context.path.length - 1]; + return [ + { + message: + `Enum field "${fieldName}" contains "null" value but does not declare nullable ` + + `(use "nullable: true" in OAS 3.0 or "type: ['string', 'null']" in OAS 3.1).`, + }, + ]; + } + + return []; +}; diff --git a/functions/aep-126-no-standard-value-enums.js b/functions/aep-126-no-standard-value-enums.js new file mode 100644 index 0000000..9cb85fb --- /dev/null +++ b/functions/aep-126-no-standard-value-enums.js @@ -0,0 +1,54 @@ +/** + * Validates that fields don't enumerate standard codes. + * + * Based on AEP-126 specification (https://aep.dev/126). + * + * Standard codes like language, country, currency should not be enumerated. + * + * @param {object} _targetVal - The target value (unused, we check the field name) + * @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) => { + // Only check string enums + if (!field || typeof field !== 'object' || field.type !== 'string') { + return []; + } + + // Extract the field name from the path + const path = context.path || []; + const fieldName = path[path.length - 1]; + + if (typeof fieldName !== 'string') { + return []; + } + + // List of standard field names that should not be enumerated + const standardFields = [ + 'language', + 'language_code', + 'country', + 'country_code', + 'region_code', + 'currency', + 'currency_code', + 'media_type', + 'content_type', + ]; + + if (standardFields.includes(fieldName)) { + const message = + `Field "${fieldName}" appears to enumerate standard codes. ` + + `Use standard formats instead ` + + `(e.g., ISO 639 for languages, ISO 3166 for countries, ` + + `ISO 4217 for currencies, IANA for media types).`; + return [ + { + message, + }, + ]; + } + + return []; +}; diff --git a/spectral.yaml b/spectral.yaml index 1a015de..e13101e 100644 --- a/spectral.yaml +++ b/spectral.yaml @@ -1,6 +1,7 @@ extends: - spectral:oas - ./aep/0122.yaml + - ./aep/0126.yaml - ./aep/0131.yaml - ./aep/0132.yaml - ./aep/0133.yaml @@ -16,6 +17,10 @@ extends: - ./aep/0193.yaml functionsDir: './functions' functions: + - aep-126-enum-case-consistent + - aep-126-enum-null-first + - aep-126-enum-nullable-declaration + - aep-126-no-standard-value-enums - aep-142-time-field-type - parameter-names-unique - operations-endpoint diff --git a/test/0126/enum-case-consistent.test.js b/test/0126/enum-case-consistent.test.js new file mode 100644 index 0000000..21017dd --- /dev/null +++ b/test/0126/enum-case-consistent.test.js @@ -0,0 +1,192 @@ +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 find warnings for mixed case', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Book: { + type: 'object', + properties: { + status: { + type: 'string', + enum: ['active', 'PENDING', 'In_Progress'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(1); + expect(results).toContainMatch({ + message: expect.stringMatching(/inconsistent case/i), + }); + }); +}); + +test('aep-126-enum-case-consistent should find warnings for UPPER and lower mix', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Order: { + type: 'object', + properties: { + state: { + type: 'string', + enum: ['SHIPPED', 'delivered', 'PENDING'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(1); + expect(results).toContainMatch({ + message: expect.stringMatching(/inconsistent/i), + }); + }); +}); + +test('aep-126-enum-case-consistent should find no warnings for consistent UPPERCASE', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Book: { + type: 'object', + properties: { + format: { + type: 'string', + enum: ['HARDCOVER', 'PAPERBACK', 'EBOOK', 'AUDIOBOOK'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(0); + }); +}); + +test('aep-126-enum-case-consistent should find no warnings for consistent lowercase', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Book: { + type: 'object', + properties: { + status: { + type: 'string', + enum: ['draft', 'published', 'archived'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(0); + }); +}); + +test('aep-126-enum-case-consistent should find no warnings for consistent kebab-case', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Resource: { + type: 'object', + properties: { + state: { + type: 'string', + enum: ['in-progress', 'not-started', 'completed'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(0); + }); +}); + +test('aep-126-enum-case-consistent should find no warnings for consistent UPPER_SNAKE_CASE', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Order: { + type: 'object', + properties: { + status: { + type: 'string', + enum: ['ORDER_PENDING', 'ORDER_SHIPPED', 'ORDER_DELIVERED'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(0); + }); +}); + +test('aep-126-enum-case-consistent should ignore null values when checking consistency', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Book: { + type: 'object', + properties: { + format: { + type: 'string', + nullable: true, + enum: [null, 'HARDCOVER', 'PAPERBACK', 'EBOOK'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(0); + }); +}); + +test('aep-126-enum-case-consistent should not flag single-value enums', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Singleton: { + type: 'object', + properties: { + type: { + type: 'string', + enum: ['CONSTANT'], + }, + }, + }, + }, + }, + }; + 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-null-first.test.js b/test/0126/enum-null-first.test.js new file mode 100644 index 0000000..cb70c1e --- /dev/null +++ b/test/0126/enum-null-first.test.js @@ -0,0 +1,129 @@ +const { linterForAepRule } = require('../utils'); +require('../matchers'); + +let linter; + +beforeAll(async () => { + linter = await linterForAepRule('0126', 'aep-126-enum-null-first'); + return linter; +}); + +test('aep-126-enum-null-first should find warnings when null is not first', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Book: { + type: 'object', + properties: { + format: { + type: 'string', + nullable: true, + enum: ['HARDCOVER', null, 'PAPERBACK'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBeGreaterThan(0); + expect(results).toContainMatch({ + message: expect.stringMatching(/null.*first/i), + }); + }); +}); + +test('aep-126-enum-null-first should find warnings when null is last', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Product: { + type: 'object', + properties: { + category: { + type: 'string', + nullable: true, + enum: ['ELECTRONICS', 'BOOKS', 'CLOTHING', null], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBeGreaterThan(0); + expect(results).toContainMatch({ + message: expect.stringMatching(/null.*first/i), + }); + }); +}); + +test('aep-126-enum-null-first should find no warnings when null is first', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Book: { + type: 'object', + properties: { + format: { + type: 'string', + nullable: true, + enum: [null, 'HARDCOVER', 'PAPERBACK', 'EBOOK'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(0); + }); +}); + +test('aep-126-enum-null-first should not flag non-nullable enums without null', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Book: { + type: 'object', + properties: { + status: { + type: 'string', + enum: ['DRAFT', 'PUBLISHED', 'ARCHIVED'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(0); + }); +}); + +test('aep-126-enum-null-first should not flag nullable enums without null in array', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Book: { + type: 'object', + properties: { + format: { + type: 'string', + nullable: true, + enum: ['HARDCOVER', 'PAPERBACK'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(0); + }); +}); diff --git a/test/0126/enum-nullable-declaration.test.js b/test/0126/enum-nullable-declaration.test.js new file mode 100644 index 0000000..32764f4 --- /dev/null +++ b/test/0126/enum-nullable-declaration.test.js @@ -0,0 +1,130 @@ +const { linterForAepRule } = require('../utils'); +require('../matchers'); + +let linter; + +beforeAll(async () => { + linter = await linterForAepRule('0126', 'aep-126-enum-nullable-declaration'); + return linter; +}); + +test('aep-126-enum-nullable-declaration should find errors when null in enum but no nullable', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Book: { + type: 'object', + properties: { + format: { + type: 'string', + enum: [null, 'HARDCOVER', 'PAPERBACK'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(1); + expect(results).toContainMatch({ + message: expect.stringMatching(/nullable.*true/i), + }); + }); +}); + +test('aep-126-enum-nullable-declaration should find errors when nullable is false', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Product: { + type: 'object', + properties: { + category: { + type: 'string', + nullable: false, + enum: [null, 'ELECTRONICS', 'BOOKS'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(1); + expect(results).toContainMatch({ + message: expect.stringMatching(/nullable/i), + }); + }); +}); + +test('aep-126-enum-nullable-declaration should find no errors when nullable true', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Book: { + type: 'object', + properties: { + format: { + type: 'string', + nullable: true, + enum: [null, 'HARDCOVER', 'PAPERBACK', 'EBOOK'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(0); + }); +}); + +test('aep-126-enum-nullable-declaration should find no errors for enums without null', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Book: { + type: 'object', + properties: { + status: { + type: 'string', + enum: ['DRAFT', 'PUBLISHED', 'ARCHIVED'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(0); + }); +}); + +test('aep-126-enum-nullable-declaration should handle null in middle of array', () => { + const oasDoc = { + openapi: '3.0.3', + components: { + schemas: { + Order: { + type: 'object', + properties: { + status: { + type: 'string', + enum: ['PENDING', null, 'SHIPPED'], + }, + }, + }, + }, + }, + }; + return linter.run(oasDoc).then((results) => { + expect(results.length).toBe(1); + expect(results).toContainMatch({ + message: expect.stringMatching(/nullable/i), + }); + }); +}); 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); + }); +});