Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/apidom-ls/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions packages/apidom-ls/src/config/codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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;
4 changes: 4 additions & 0 deletions packages/apidom-ls/src/config/common/schema/lint/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -192,6 +194,8 @@ const schemaLints = [
uniqueItemsTypeLint,
writeOnlyTypeLint,
exampleDeprecatedLint,
exampleDateTimeLint,
examplesDateTimeLint,
];

export default schemaLints;
4 changes: 4 additions & 0 deletions packages/apidom-ls/src/config/openapi/schema/lint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -184,6 +186,8 @@ const schemaLints = [
uniqueItemsTypeLint,
writeOnlyTypeLint,
exampleDeprecatedLint,
exampleDateTimeLint,
examplesDateTimeLint,
$refNotUsedLint,
$refNoSiblingsLint,
$refValidLint,
Expand Down
75 changes: 75 additions & 0 deletions packages/apidom-ls/src/services/validation/linter-functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 {
Expand Down Expand Up @@ -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[] => {
Expand Down
128 changes: 128 additions & 0 deletions packages/apidom-ls/test/fixtures/validation/oas/example-date-time.yaml
Original file line number Diff line number Diff line change
@@ -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'
Loading
Loading