Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
63 changes: 35 additions & 28 deletions packages/jentic-openapi-validator-speclynx/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,38 +196,45 @@ if "dict" in validator.accepts():

## Custom Plugins

Create custom validation plugins as ES modules (`.mjs` files). Plugins use the ApiDOM visitor pattern:
Create custom validation plugins as ES modules (`.mjs` files). Plugins use the ApiDOM visitor pattern and receive
a toolbox with dependencies and diagnostics array:

```javascript
// custom-plugin.mjs
import {toValue} from '@speclynx/apidom-core';
import {DiagnosticSeverity} from 'vscode-languageserver-types';

export default ({diagnostics}) => () => ({
pre() {
},
visitor: {
InfoElement(path) {
const info = path.node;
const version = info.get('version');

if (version && typeof toValue(version) !== 'string') {
diagnostics.push({
severity: DiagnosticSeverity.Error,
message: 'info.version must be a string',
code: 'invalid-info-version-type',
range: {
start: {line: 0, character: 0},
end: {line: 0, character: 0}
},
data: {path: ['info', 'version']}
});

export default (toolbox) => {
const {diagnostics, deps} = toolbox;
const {DiagnosticSeverity} = deps['vscode-languageserver-types'];
const {toValue} = deps['@speclynx/apidom-core'];

return {
pre() {
// Called before traversal starts
},
visitor: {
InfoElement(path) {
const info = path.node;
const version = info.get('version');

if (version && typeof toValue(version) !== 'string') {
diagnostics.push({
severity: DiagnosticSeverity.Error,
message: 'info.version must be a string',
code: 'invalid-info-version-type',
range: {
start: {line: 0, character: 0},
end: {line: 0, character: 0}
},
data: {path: path.getPathKeys()}
});
}
}
}
},
post() {
},
});
},
post() {
// Called after traversal completes
},
};
};
```

Use it with the validator:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ def __init__(
Plugins are loaded automatically and used to validate the OpenAPI document.
When specified, custom plugins are merged with the built-in plugins (both are loaded).
If None (default), only the built-in plugins directory is used (which is empty by default).
Plugins must export a function that receives a toolbox object with:
- deps: External dependencies (vscode-languageserver-types, @speclynx/apidom-reference)
- diagnostics: Array to collect validation diagnostics
See resources/plugins/example-plugin.mjs.sample for plugin format.
"""
self.speclynx_path = speclynx_path
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,96 +5,101 @@
* using the ApiDOM visitor pattern.
*
* Plugin structure:
* - Must export a default function that accepts a context object with:
* - Must export a default function that accepts a toolbox object with:
* - diagnostics: Array to collect validation diagnostics
* - deps: External dependencies (vscode-languageserver-types, @speclynx/apidom-reference)
* - Returns an object with pre/post hooks and a visitor property
* - The visitor contains methods named after ApiDOM element types
* - Each visitor method receives a path object with information about the current element
* - Each visitor method receives the element being visited
*
* @param {Object} context - Plugin context
* @param {Array} context.diagnostics - Array to collect validation diagnostics
* @param {Object} toolbox - Plugin toolbox
* @param {Array} toolbox.diagnostics - Array to collect validation diagnostics
* @param {Object} toolbox.deps - External dependencies
*/

import { toValue } from '@speclynx/apidom-core';
import { DiagnosticSeverity } from 'vscode-languageserver-types';
export default (toolbox) => {
const {diagnostics, deps} = toolbox;
const {DiagnosticSeverity} = deps['vscode-languageserver-types'];
const {toValue} = deps['@speclynx/apidom-core'];

export default ({diagnostics}) => () => ({
pre() {
console.log('Pre-validation hook');
},
post() {
console.log('Post-validation hook');
},
visitor: {
/**
* Visit all InfoElement nodes in the OpenAPI document
* @param {Object} path - Contains information about the current element
*/
InfoElement(path) {
const info = path.node;
return {
pre() {
console.log('Pre-validation hook');
},
post() {
console.log('Post-validation hook');
},
visitor: {
/**
* Visit all InfoElement nodes in the OpenAPI document
* @param {Object} path - Contains information about the current element
*/
InfoElement(path) {
const info = path.node;

// Example validation: Check if info.title exists
if (!info.get('title')) {
diagnostics.push({
severity: DiagnosticSeverity.Error,
message: 'OpenAPI document is missing info.title',
code: 'missing-info-title',
range: getRange(info),
data: {path: path.getPathKeys()}
});
}
// Example validation: Check if info.title exists
if (!info.get('title')) {
diagnostics.push({
severity: DiagnosticSeverity.Error,
message: 'OpenAPI document is missing info.title',
code: 'missing-info-title',
range: getRange(info),
data: {path: path.getPathKeys()}
});
}

// Example validation: Check if info.version exists
if (!info.get('version')) {
diagnostics.push({
severity: DiagnosticSeverity.Warning,
message: 'OpenAPI document is missing info.version',
code: 'missing-info-version',
range: getRange(info),
data: {path: path.getPathKeys()}
});
}
},
// Example validation: Check if info.version exists
if (!info.get('version')) {
diagnostics.push({
severity: DiagnosticSeverity.Warning,
message: 'OpenAPI document is missing info.version',
code: 'missing-info-version',
range: getRange(info),
data: {path: path.getPathKeys()}
});
}
},

/**
* Visit all OperationElement nodes (HTTP operations like GET, POST, etc.)
* @param {Object} path - Contains information about the current element
*/
OperationElement(path) {
const operation = path.node;
/**
* Visit all OperationElement nodes (HTTP operations like GET, POST, etc.)
* @param {Object} path - Contains information about the current element
*/
OperationElement(path) {
const operation = path.node;

// Example validation: Check if operation has a summary
if (!operation.get('summary')) {
diagnostics.push({
severity: DiagnosticSeverity.Warning,
message: 'Operation is missing a summary',
code: 'missing-operation-summary',
range: getRange(operation),
data: {path: path.getPathKeys()}
});
}
},
// Example validation: Check if operation has a summary
if (!operation.get('summary')) {
diagnostics.push({
severity: DiagnosticSeverity.Warning,
message: 'Operation is missing a summary',
code: 'missing-operation-summary',
range: getRange(operation),
data: {path: path.getPathKeys()}
});
}
},

/**
* Visit all SchemaElement nodes (JSON Schema definitions)
* @param {Object} path - Contains information about the current element
*/
SchemaElement(path) {
const schema = path.node;
/**
* Visit all SchemaElement nodes (JSON Schema definitions)
* @param {Object} path - Contains information about the current element
*/
SchemaElement(path) {
const schema = path.node;

// Example validation: Check for schemas without descriptions
if (!schema.get('description') && schema.get('type')) {
diagnostics.push({
severity: DiagnosticSeverity.Information,
message: `Schema of type "${toValue(schema.get('type'))}" has no description`,
code: 'schema-missing-description',
range: getRange(schema),
data: {path: path.getPathKeys()}
});
// Example validation: Check for schemas without descriptions
if (!schema.get('description') && schema.get('type')) {
diagnostics.push({
severity: DiagnosticSeverity.Information,
message: `Schema of type "${toValue(schema.get('type'))}" has no description`,
code: 'schema-missing-description',
range: getRange(schema),
data: {path: path.getPathKeys()}
});
}
}
}
}
});
};
};

/**
* Extract LSP range from ApiDOM element
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,28 +7,32 @@
* Note: This visitor is only called when traversing parseResult (invalid documents).
* When traversing parseResult.api (valid documents), ParseResultElement is not visited.
*
* @param {Object} context - Plugin context
* @param {Array} context.diagnostics - Array to collect validation diagnostics
* Plugin receives toolbox with:
* - deps: External dependencies (vscode-languageserver-types, @speclynx/apidom-reference)
* - diagnostics: Array to collect validation diagnostics
*/

import {DiagnosticSeverity, Diagnostic, Range} from 'vscode-languageserver-types';
export default (toolbox) => {
const {diagnostics, deps} = toolbox;
const {DiagnosticSeverity, Diagnostic, Range} = deps['vscode-languageserver-types'];

export default ({diagnostics}) => () => ({
visitor: {
ParseResultElement(path) {
const parseResult = path.node;
return {
visitor: {
ParseResultElement(path) {
const parseResult = path.node;

if (!parseResult.api) {
const diagnostic = Diagnostic.create(
Range.create(0, 0, 0, 0),
'Document is not recognized as a valid OpenAPI 3.x document',
DiagnosticSeverity.Error,
'invalid-openapi-document',
'speclynx-validator'
);
diagnostic.data = {path: path.getPathKeys()};
diagnostics.push(diagnostic);
if (!parseResult.api) {
const diagnostic = Diagnostic.create(
Range.create(0, 0, 0, 0),
'Document is not recognized as a valid OpenAPI 3.x document',
DiagnosticSeverity.Error,
'invalid-openapi-document',
'speclynx-validator'
);
diagnostic.data = {path: path.getPathKeys()};
diagnostics.push(diagnostic);
}
}
}
}
});
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ import {pathToFileURL, fileURLToPath} from 'node:url';
import {Command} from 'commander';
import * as vscodeLanguageServerTypes from 'vscode-languageserver-types';
import {Diagnostic, DiagnosticSeverity, Range} from 'vscode-languageserver-types';
import * as apidomCore from '@speclynx/apidom-core';
import {dispatchRefractorPlugins, createToolbox as createToolboxBase} from '@speclynx/apidom-core';
import * as apidomDatamodel from '@speclynx/apidom-datamodel';
import * as apidomJsonPath from '@speclynx/apidom-json-path';
import * as apidomJsonPointer from '@speclynx/apidom-json-pointer';
import * as apidomTraverse from '@speclynx/apidom-traverse';
import * as apidomReference from '@speclynx/apidom-reference';
import {parse, options} from '@speclynx/apidom-reference';
import FileResolver from '@speclynx/apidom-reference/resolve/resolvers/file';
Expand Down Expand Up @@ -135,12 +140,18 @@ async function validate(document, cliOptions) {
return {valid: false, diagnostics};
}

// Toolbox creation
// Toolbox creation - provides deps and diagnostics to plugins
const createToolbox = () => ({
deps: {
'vscode-languageserver-types': vscodeLanguageServerTypes,
'@speclynx/apidom-core': apidomCore,
'@speclynx/apidom-datamodel': apidomDatamodel,
'@speclynx/apidom-json-path': apidomJsonPath,
'@speclynx/apidom-json-pointer': apidomJsonPointer,
'@speclynx/apidom-traverse': apidomTraverse,
'@speclynx/apidom-reference': apidomReference,
},
diagnostics,
...createToolboxBase()
});

Expand All @@ -149,7 +160,7 @@ async function validate(document, cliOptions) {
// When it doesn't exist (e.g., Swagger 2.0), traverse parseResult so ParseResultElement visitor runs
const elementToTraverse = parseResult.api ?? parseResult;
const dispatchRefractorPluginsAsync = promisify(dispatchRefractorPlugins);
await dispatchRefractorPluginsAsync(elementToTraverse, plugins.map(plugin => plugin({diagnostics})), {
await dispatchRefractorPluginsAsync(elementToTraverse, plugins, {
toolboxCreator: createToolbox,
});

Expand Down
Loading