diff --git a/package-lock.json b/package-lock.json index 7c82e78b0..742d71e6c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23359,6 +23359,21 @@ "node": ">=4" } }, + "node_modules/temporal-polyfill": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/temporal-polyfill/-/temporal-polyfill-0.3.2.tgz", + "integrity": "sha512-TzHthD/heRK947GNiSu3Y5gSPpeUDH34+LESnfsq8bqpFhsB79HFBX8+Z834IVX68P3EUyRPZK5bL/1fh437Eg==", + "license": "MIT", + "dependencies": { + "temporal-spec": "0.3.1" + } + }, + "node_modules/temporal-spec": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/temporal-spec/-/temporal-spec-0.3.1.tgz", + "integrity": "sha512-B4TUhezh9knfSIMwt7RVggApDRJZo73uZdj8AacL2mZ8RP5KtLianh2MXxL06GN9ESYiIsiuoLQhgVfwe55Yhw==", + "license": "ISC" + }, "node_modules/terser": { "version": "5.40.0", "resolved": "https://registry.npmjs.org/terser/-/terser-5.40.0.tgz", @@ -25234,6 +25249,7 @@ "openapi-path-templating": "^2.0.1", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0", + "temporal-polyfill": "^0.3.2", "vscode-languageserver-protocol": "^3.17.2", "vscode-languageserver-textdocument": "^1.0.5", "vscode-languageserver-types": "^3.17.2" diff --git a/packages/apidom-ls/package.json b/packages/apidom-ls/package.json index 266591b1e..2b4d1a45a 100644 --- a/packages/apidom-ls/package.json +++ b/packages/apidom-ls/package.json @@ -129,6 +129,7 @@ "openapi-path-templating": "^2.0.1", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0", + "temporal-polyfill": "^0.3.2", "vscode-languageserver-protocol": "^3.17.2", "vscode-languageserver-textdocument": "^1.0.5", "vscode-languageserver-types": "^3.17.2" diff --git a/packages/apidom-ls/src/config/codes.ts b/packages/apidom-ls/src/config/codes.ts index 09210a788..d0e8cafbf 100644 --- a/packages/apidom-ls/src/config/codes.ts +++ b/packages/apidom-ls/src/config/codes.ts @@ -79,6 +79,8 @@ enum ApilintCodes { SCHEMA_READONLY_REQUIRED, SCHEMA_PATTERN_REG_EXP_ANCHORS, SCHEMA_ITEMS_REQUIRED, + SCHEMA_EXAMPLE_DATE_TIME, + SCHEMA_EXAMPLES_DATE_TIME, SECURITY_REQUIREMENT_ARRAY = 14997, SECURITY_SCHEME_USED = 14998, diff --git a/packages/apidom-ls/src/config/common/schema/lint/example--date-time.ts b/packages/apidom-ls/src/config/common/schema/lint/example--date-time.ts new file mode 100644 index 000000000..2983f8ac5 --- /dev/null +++ b/packages/apidom-ls/src/config/common/schema/lint/example--date-time.ts @@ -0,0 +1,27 @@ +import { DiagnosticSeverity } from 'vscode-languageserver-types'; + +import ApilintCodes from '../../../codes.ts'; +import { LinterMeta } from '../../../../apidom-language-types.ts'; +import { OpenAPI2, OpenAPI3 } from '../../../openapi/target-specs.ts'; + +const exampleDateTimeLint: LinterMeta = { + code: ApilintCodes.SCHEMA_EXAMPLE_DATE_TIME, + source: 'apilint', + message: 'example value must be a valid date-time string (RFC 3339)', + severity: DiagnosticSeverity.Error, + linterFunction: 'apilintValidDateTimeExample', + linterParams: [], + marker: 'value', + target: 'example', + data: {}, + targetSpecs: [...OpenAPI2, ...OpenAPI3], + conditions: [ + { + targets: [{ path: 'format' }], + function: 'apilintValueOrArray', + params: [['date-time']], + }, + ], +}; + +export default exampleDateTimeLint; diff --git a/packages/apidom-ls/src/config/common/schema/lint/examples--date-time.ts b/packages/apidom-ls/src/config/common/schema/lint/examples--date-time.ts new file mode 100644 index 000000000..8ec99b7ae --- /dev/null +++ b/packages/apidom-ls/src/config/common/schema/lint/examples--date-time.ts @@ -0,0 +1,27 @@ +import { DiagnosticSeverity } from 'vscode-languageserver-types'; + +import ApilintCodes from '../../../codes.ts'; +import { LinterMeta } from '../../../../apidom-language-types.ts'; +import { OpenAPI31, OpenAPI32 } from '../../../openapi/target-specs.ts'; + +const examplesDateTimeLint: LinterMeta = { + code: ApilintCodes.SCHEMA_EXAMPLES_DATE_TIME, + source: 'apilint', + message: 'examples values must be valid date-time strings (RFC 3339)', + severity: DiagnosticSeverity.Error, + linterFunction: 'apilintValidDateTimeExample', + linterParams: [true], + marker: 'value', + target: 'examples', + data: {}, + targetSpecs: [...OpenAPI31, ...OpenAPI32], + conditions: [ + { + targets: [{ path: 'format' }], + function: 'apilintValueOrArray', + params: [['date-time']], + }, + ], +}; + +export default examplesDateTimeLint; diff --git a/packages/apidom-ls/src/config/common/schema/lint/index.ts b/packages/apidom-ls/src/config/common/schema/lint/index.ts index 370d1969d..e3dab586c 100644 --- a/packages/apidom-ls/src/config/common/schema/lint/index.ts +++ b/packages/apidom-ls/src/config/common/schema/lint/index.ts @@ -85,6 +85,8 @@ import uniqueItemsNonArrayLint from './unique-items--non-array.ts'; import uniqueItemsTypeLint from './unique-items--type.ts'; import writeOnlyTypeLint from './write-only--type.ts'; import exampleDeprecatedLint from './example--deprecated.ts'; +import exampleDateTimeLint from './example--date-time.ts'; +import examplesDateTimeLint from './examples--date-time.ts'; import enumTypeLint from './enum--type.ts'; import enumDefaultValueLint from './enum--default-value.ts'; import minimumValueLint from './minimum-maximum--value.ts'; @@ -192,6 +194,8 @@ const schemaLints = [ uniqueItemsTypeLint, writeOnlyTypeLint, exampleDeprecatedLint, + exampleDateTimeLint, + examplesDateTimeLint, ]; export default schemaLints; diff --git a/packages/apidom-ls/src/config/openapi/schema/lint.ts b/packages/apidom-ls/src/config/openapi/schema/lint.ts index 30d7f105e..d2f424f27 100644 --- a/packages/apidom-ls/src/config/openapi/schema/lint.ts +++ b/packages/apidom-ls/src/config/openapi/schema/lint.ts @@ -80,6 +80,8 @@ import uniqueItemsNonArrayLint from '../../common/schema/lint/unique-items--non- import uniqueItemsTypeLint from '../../common/schema/lint/unique-items--type.ts'; import writeOnlyTypeLint from '../../common/schema/lint/write-only--type.ts'; import exampleDeprecatedLint from '../../common/schema/lint/example--deprecated.ts'; +import exampleDateTimeLint from '../../common/schema/lint/example--date-time.ts'; +import examplesDateTimeLint from '../../common/schema/lint/examples--date-time.ts'; import $refNotUsedLint from '../../common/schema/lint/$ref--not-used.ts'; import $ref3RequestBodiesLint from '../../common/schema/lint/$ref-3-0--request-bodies.ts'; import $refNoSiblingsLint from '../../common/schema/lint/$ref--no-siblings.ts'; @@ -184,6 +186,8 @@ const schemaLints = [ uniqueItemsTypeLint, writeOnlyTypeLint, exampleDeprecatedLint, + exampleDateTimeLint, + examplesDateTimeLint, $refNotUsedLint, $refNoSiblingsLint, $refValidLint, diff --git a/packages/apidom-ls/src/services/validation/linter-functions.ts b/packages/apidom-ls/src/services/validation/linter-functions.ts index d2429f922..455f9f1b6 100644 --- a/packages/apidom-ls/src/services/validation/linter-functions.ts +++ b/packages/apidom-ls/src/services/validation/linter-functions.ts @@ -12,6 +12,7 @@ import { includesClasses, isObjectElement, traverse, + isNullElement, } from '@swagger-api/apidom-core'; import { URIFragmentIdentifier, evaluate } from '@swagger-api/apidom-json-pointer/modern'; import { CompletionItem } from 'vscode-languageserver-types'; @@ -21,6 +22,7 @@ import { parse as parsePathTemplate, isIdentical, } from 'openapi-path-templating'; +import { Temporal } from 'temporal-polyfill'; // eslint-disable-next-line import/no-cycle import { @@ -743,6 +745,79 @@ export const standardLinterfunctions: FunctionItem[] = [ return true; }, }, + { + functionName: 'apilintValidDateTimeExample', + function: (element: Element, examples: boolean = false): boolean => { + if (!element) { + return true; + } + + const isValidDateTime = (el: Element): boolean => { + if (!isString(el)) { + return false; + } + + const value = toValue(el); + + // RFC 3339 requires 'T' (or 't') as the date-time separator; + // Temporal.Instant.from() also accepts a space (ISO 8601), so we enforce it explicitly. + // We also need to filter out timezone and calendar annotations (RFC 9557) + if (!/^\d{4}-\d{2}-\d{2}[Tt][^[]+$/.test(value)) { + return false; + } + + try { + Temporal.Instant.from(value); + return true; + } catch { + return false; + } + }; + + const elementParent = element?.parent?.parent; + const schemaType = + elementParent && isObject(elementParent) ? toValue(elementParent.get('type')) : undefined; + const isArraySchemaType = Array.isArray(schemaType) && schemaType.includes('string'); + const nonStringPredicates = isArraySchemaType + ? ([ + (schemaType.includes('number') || schemaType.includes('integer')) && isNumber, + schemaType.includes('boolean') && isBoolean, + schemaType.includes('object') && isObject, + schemaType.includes('array') && isArray, + schemaType.includes('null') && isNullElement, + ].filter(Boolean) as ((el: Element) => boolean)[]) + : []; + + const isValid = (el: Element): boolean => { + if (!schemaType || schemaType === 'string') { + return isValidDateTime(el); + } + + if (isArraySchemaType) { + return nonStringPredicates.some((p) => p(el)) || isValidDateTime(el); + } + + return true; + }; + + if (!examples) { + const nullable = + elementParent && isObject(elementParent) ? toValue(elementParent.get('nullable')) : false; + + if (nullable && isNullElement(element)) { + return true; + } + + return isValid(element); + } + + if (!isArray(element)) { + return true; + } + + return element.findElements((e) => !isValid(e), { recursive: false }).length === 0; + }, + }, { functionName: 'apicompleteDiscriminator', function: (element: Element): CompletionItem[] => { diff --git a/packages/apidom-ls/test/fixtures/validation/oas/example-date-time.yaml b/packages/apidom-ls/test/fixtures/validation/oas/example-date-time.yaml new file mode 100644 index 000000000..9c79f0c14 --- /dev/null +++ b/packages/apidom-ls/test/fixtures/validation/oas/example-date-time.yaml @@ -0,0 +1,128 @@ +openapi: 3.0.4 +info: + title: Example API + version: 1.0.0 +paths: + /example: + get: + responses: + '200': + description: OK + content: + application/json: + schema: + properties: + # basic date-time + dateTimeValid1: + type: string + format: date-time + example: '2025-12-18T06:43:17.913Z' + + # date-time with timezone offset + dateTimeValid2: + type: string + format: date-time + example: '2025-12-18T06:43:17.913+00:00' + + # date-time with lower case "t" and timezone offset + dateTimeValid3: + type: string + format: date-time + example: '2025-12-18t06:43:17.913+07:00' + + # date-time with negative timezone offset + dateTimeValid4: + type: string + format: date-time + example: '2025-12-18T06:43:17.913-07:00' + + # leap year + dateTimeValid5: + type: string + format: date-time + example: '2024-02-29T06:43:17.913Z' + + # date-time without type defined + dateTimeValid6: + format: date-time + example: '2025-12-18T06:43:17.913Z' + + # non-string type with date-time format + dateTimeValid7: + type: object + format: date-time + example: + test: 123 + + # non-string type with date-time format and string example + dateTimeValid8: + type: object + format: date-time + example: 'not a date-time' + + # nullable date-time + dateTimeValid9: + type: string + format: date-time + nullable: true + example: null + + # date-time without "T" separator + dateTimeInvalid1: + type: string + format: date-time + example: '2025-12-18 06:43:17.913Z' + + # date with invalid month + dateTimeInvalid2: + type: string + format: date-time + example: '2025-20-18T06:43:17.913Z' + + # date with invalid day + dateTimeInvalid3: + type: string + format: date-time + example: '2025-11-31T06:43:17.913Z' + + # date-time without "Z" or timezone offset + dateTimeInvalid4: + type: string + format: date-time + example: '2025-12-30T06:43:17.913' + + # date-time with extra text + dateTimeInvalid5: + type: string + format: date-time + example: '2025-12-18T06:43:17.913Z UTC' + + # date-time with completely invalid string + dateTimeInvalid6: + type: string + format: date-time + example: 'invalid' + + # date-time with numeric value + dateTimeInvalid7: + type: string + format: date-time + example: 123 + + # date-time with invalid leap year + dateTimeInvalid8: + type: string + format: date-time + example: '2025-02-29T06:43:17.913Z' + + # date-time without type defined and with non-string example value + dateTimeInvalid9: + format: date-time + example: 123 + + # nullable date-time with string null example + dateTimeInvalid10: + type: string + format: date-time + nullable: true + example: 'null' diff --git a/packages/apidom-ls/test/fixtures/validation/oas/examples-date-time.yaml b/packages/apidom-ls/test/fixtures/validation/oas/examples-date-time.yaml new file mode 100644 index 000000000..5cf5fdb0b --- /dev/null +++ b/packages/apidom-ls/test/fixtures/validation/oas/examples-date-time.yaml @@ -0,0 +1,176 @@ +openapi: 3.1.0 +info: + title: Example API + version: 1.0.0 +paths: + /example: + get: + responses: + '200': + description: OK + content: + application/json: + schema: + properties: + # basic date-time + dateTimeValid1: + type: string + format: date-time + examples: + - '2025-12-18T06:43:17.913Z' + + # date-time with timezone offset + dateTimeValid2: + type: string + format: date-time + examples: + - '2025-12-18T06:43:17.913+00:00' + + # date-time with lower case "t" and timezone offset + dateTimeValid3: + type: string + format: date-time + examples: + - '2025-12-18t06:43:17.913+07:00' + + # date-time with negative timezone offset + dateTimeValid4: + type: string + format: date-time + examples: + - '2025-12-18T06:43:17.913-07:00' + + # leap year + dateTimeValid5: + type: string + format: date-time + examples: + - '2024-02-29T06:43:17.913Z' + + # date-time without type defined + dateTimeValid6: + format: date-time + examples: + - '2025-12-18T06:43:17.913Z' + + # non-string type with date-time format + dateTimeValid7: + type: object + format: date-time + examples: + - test: 123 + + # non-string type with date-time format and string example + dateTimeValid8: + type: object + format: date-time + examples: + - 'not a date-time' + + # date-time with multiple types + dateTimeValid9: + type: + - object + - string + format: date-time + examples: + - test: 123 + - '2025-12-18T06:43:17.913Z' + + # date-time with null type + dateTimeValid10: + type: + - 'null' + - string + format: date-time + examples: + - null + + # date-time with null type and string null example + dateTimeValid11: + type: 'null' + format: date-time + examples: + - 'null' + + # date-time without "T" separator + dateTimeInvalid1: + type: string + format: date-time + examples: + - '2025-12-18 06:43:17.913Z' + + # date with invalid month + dateTimeInvalid2: + type: string + format: date-time + examples: + - '2025-20-18T06:43:17.913Z' + + # date with invalid day + dateTimeInvalid3: + type: string + format: date-time + examples: + - '2025-11-31T06:43:17.913Z' + + # date-time without "Z" or timezone offset + dateTimeInvalid4: + type: string + format: date-time + examples: + - '2025-12-30T06:43:17.913' + + # date-time with extra text + dateTimeInvalid5: + type: string + format: date-time + examples: + - '2025-12-18T06:43:17.913Z UTC' + + # date-time with completely invalid string + dateTimeInvalid6: + type: string + format: date-time + examples: + - 'invalid' + + # date-time with numeric value + dateTimeInvalid7: + type: string + format: date-time + examples: + - 123 + + # date-time with invalid leap year + dateTimeInvalid8: + type: string + format: date-time + examples: + - '2025-02-29T06:43:17.913Z' + + # date-time without type defined and with non-string example value + dateTimeInvalid9: + format: date-time + examples: + - 123 + + # date-time with multiple types and invalid type in examples + dateTimeInvalid10: + type: + - object + - string + format: date-time + examples: + - test: 123 + - '2025-12-18T06:43:17.913Z' + - 123 + + # date-time with null and string type and string null example + dateTimeInvalid11: + type: + - 'null' + - string + format: date-time + examples: + - 'null' diff --git a/packages/apidom-ls/test/validate.ts b/packages/apidom-ls/test/validate.ts index e64ff7d68..6d8bb46c0 100644 --- a/packages/apidom-ls/test/validate.ts +++ b/packages/apidom-ls/test/validate.ts @@ -12055,4 +12055,295 @@ describe('apidom-ls-validate', function () { languageService.terminate(); }); + + it('oas - should validate example field when format is date-time', async function () { + const validationContext: ValidationContext = { + comments: DiagnosticSeverity.Error, + maxNumberOfProblems: 100, + relatedInformation: false, + }; + + const spec = fs + .readFileSync(path.join(__dirname, 'fixtures', 'validation', 'oas', 'example-date-time.yaml')) + .toString(); + + const doc: TextDocument = TextDocument.create( + 'foo://bar/example-date-time.yaml', + 'yaml', + 0, + spec, + ); + + const languageService: LanguageService = getLanguageService(context); + + const result = await languageService.doValidation(doc, validationContext); + + const expected: Diagnostic[] = [ + { + range: { + start: { line: 73, character: 29 }, + end: { line: 73, character: 55 }, + }, + message: 'example value must be a valid date-time string (RFC 3339)', + severity: 1, + code: 10085, + source: 'apilint', + data: {}, + }, + { + range: { + start: { line: 79, character: 29 }, + end: { line: 79, character: 55 }, + }, + message: 'example value must be a valid date-time string (RFC 3339)', + severity: 1, + code: 10085, + source: 'apilint', + data: {}, + }, + { + range: { + start: { line: 85, character: 29 }, + end: { line: 85, character: 55 }, + }, + message: 'example value must be a valid date-time string (RFC 3339)', + severity: 1, + code: 10085, + source: 'apilint', + data: {}, + }, + { + range: { + start: { line: 91, character: 29 }, + end: { line: 91, character: 54 }, + }, + message: 'example value must be a valid date-time string (RFC 3339)', + severity: 1, + code: 10085, + source: 'apilint', + data: {}, + }, + { + range: { + start: { line: 97, character: 29 }, + end: { line: 97, character: 59 }, + }, + message: 'example value must be a valid date-time string (RFC 3339)', + severity: 1, + code: 10085, + source: 'apilint', + data: {}, + }, + { + range: { + start: { line: 103, character: 29 }, + end: { line: 103, character: 38 }, + }, + message: 'example value must be a valid date-time string (RFC 3339)', + severity: 1, + code: 10085, + source: 'apilint', + data: {}, + }, + { + range: { + start: { line: 109, character: 29 }, + end: { line: 109, character: 32 }, + }, + message: 'example value must be a valid date-time string (RFC 3339)', + severity: 1, + code: 10085, + source: 'apilint', + data: {}, + }, + { + range: { + start: { line: 115, character: 29 }, + end: { line: 115, character: 55 }, + }, + message: 'example value must be a valid date-time string (RFC 3339)', + severity: 1, + code: 10085, + source: 'apilint', + data: {}, + }, + { + range: { + start: { line: 120, character: 29 }, + end: { line: 120, character: 32 }, + }, + message: 'example value must be a valid date-time string (RFC 3339)', + severity: 1, + code: 10085, + source: 'apilint', + data: {}, + }, + { + range: { + start: { line: 127, character: 29 }, + end: { line: 127, character: 35 }, + }, + message: 'example value must be a valid date-time string (RFC 3339)', + severity: 1, + code: 10085, + source: 'apilint', + data: {}, + }, + ]; + assert.deepEqual(result, expected); + + languageService.terminate(); + }); + + it('oas - should validate examples field when format is date-time', async function () { + const validationContext: ValidationContext = { + comments: DiagnosticSeverity.Error, + maxNumberOfProblems: 100, + relatedInformation: false, + }; + + const spec = fs + .readFileSync( + path.join(__dirname, 'fixtures', 'validation', 'oas', 'examples-date-time.yaml'), + ) + .toString(); + + const doc: TextDocument = TextDocument.create( + 'foo://bar/examples-date-time.yaml', + 'yaml', + 0, + spec, + ); + + const languageService: LanguageService = getLanguageService(contextNoSchema); + + const result = await languageService.doValidation(doc, validationContext); + + const expected: Diagnostic[] = [ + { + range: { + start: { line: 100, character: 22 }, + end: { line: 102, character: 43 }, + }, + message: 'examples values must be valid date-time strings (RFC 3339)', + severity: 1, + code: 10086, + source: 'apilint', + data: {}, + }, + { + range: { + start: { line: 107, character: 22 }, + end: { line: 109, character: 41 }, + }, + message: 'examples values must be valid date-time strings (RFC 3339)', + severity: 1, + code: 10086, + source: 'apilint', + data: {}, + }, + { + range: { + start: { line: 114, character: 22 }, + end: { line: 116, character: 60 }, + }, + message: 'examples values must be valid date-time strings (RFC 3339)', + severity: 1, + code: 10086, + source: 'apilint', + data: {}, + }, + { + range: { + start: { line: 121, character: 22 }, + end: { line: 123, character: 45 }, + }, + message: 'examples values must be valid date-time strings (RFC 3339)', + severity: 1, + code: 10086, + source: 'apilint', + data: {}, + }, + { + range: { + start: { line: 128, character: 22 }, + end: { line: 130, character: 60 }, + }, + message: 'examples values must be valid date-time strings (RFC 3339)', + severity: 1, + code: 10086, + source: 'apilint', + data: {}, + }, + { + range: { + start: { line: 135, character: 22 }, + end: { line: 137, character: 48 }, + }, + message: 'examples values must be valid date-time strings (RFC 3339)', + severity: 1, + code: 10086, + source: 'apilint', + data: {}, + }, + { + range: { + start: { line: 142, character: 22 }, + end: { line: 144, character: 52 }, + }, + message: 'examples values must be valid date-time strings (RFC 3339)', + severity: 1, + code: 10086, + source: 'apilint', + data: {}, + }, + { + range: { + start: { line: 149, character: 22 }, + end: { line: 151, character: 84 }, + }, + message: 'examples values must be valid date-time strings (RFC 3339)', + severity: 1, + code: 10086, + source: 'apilint', + data: {}, + }, + { + range: { + start: { line: 155, character: 22 }, + end: { line: 157, character: 78 }, + }, + message: 'examples values must be valid date-time strings (RFC 3339)', + severity: 1, + code: 10086, + source: 'apilint', + data: {}, + }, + { + range: { + start: { line: 164, character: 22 }, + end: { line: 168, character: 79 }, + }, + message: 'examples values must be valid date-time strings (RFC 3339)', + severity: 1, + code: 10086, + source: 'apilint', + data: {}, + }, + { + range: { + start: { line: 175, character: 22 }, + end: { line: 176, character: 0 }, + }, + message: 'examples values must be valid date-time strings (RFC 3339)', + severity: 1, + code: 10086, + source: 'apilint', + data: {}, + }, + ]; + assert.deepEqual(result, expected); + + languageService.terminate(); + }); });