Skip to content

Commit

Permalink
feat(ls): app option for apidom-reference based ref validation
Browse files Browse the repository at this point in the history
  • Loading branch information
frantuma committed Sep 1, 2023
1 parent d55a846 commit f97349b
Show file tree
Hide file tree
Showing 11 changed files with 926 additions and 12 deletions.
9 changes: 9 additions & 0 deletions packages/apidom-ls/src/apidom-language-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ export interface NamespaceVersion {
}

export interface ContentLanguage {
mediaType: string;
namespace: string;
format?: 'JSON' | 'YAML';
version?: string;
Expand Down Expand Up @@ -271,10 +272,18 @@ export interface LanguageSettings {

// export type SeverityLevel = 'error' | 'warning' | 'ignore';

export enum ReferenceValidationMode {
LEGACY,
APIDOM_INDIRECT,
APIDOM_INDIRECT_EXTERNAL,
}

export interface ValidationContext {
comments?: DiagnosticSeverity;
relatedInformation?: boolean;
maxNumberOfProblems?: number;
baseURI?: string;
referenceValidationMode?: ReferenceValidationMode;
}

export interface CompletionContext {
Expand Down
1 change: 1 addition & 0 deletions packages/apidom-ls/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export {
SupportedLanguages,
Format,
CompletionType,
ReferenceValidationMode,
CompletionFormat,
LogLevel,
MergeStrategy,
Expand Down
155 changes: 152 additions & 3 deletions packages/apidom-ls/src/services/validation/validation-service.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { CodeAction, Diagnostic, DiagnosticSeverity, Range } from 'vscode-languageserver-types';
import { TextDocument } from 'vscode-languageserver-textdocument';
import { Element, findAtOffset, traverse } from '@swagger-api/apidom-core';
import { Element, findAtOffset, traverse, ObjectElement } from '@swagger-api/apidom-core';
import { CodeActionKind, CodeActionParams } from 'vscode-languageserver-protocol';
import { evaluate, evaluateMulti } from '@swagger-api/apidom-json-path';
import { dereferenceApiDOM, Reference, ReferenceSet } from '@swagger-api/apidom-reference';

import {
APIDOM_LINTER,
Expand All @@ -17,6 +18,8 @@ import {
QuickFixData,
ValidationContext,
ValidationProvider,
ContentLanguage,
ReferenceValidationMode,
} from '../../apidom-language-types';
import {
checkConditions,
Expand Down Expand Up @@ -132,13 +135,133 @@ export class DefaultValidationService implements ValidationService {
return meta;
}

private static buildReferenceErrorMessage(
result: PromiseSettledResult<Element | { error: Error; refEl: Element }>,
): string | boolean {
// @ts-ignore
if (!result.value) {
return false;
}
// @ts-ignore
let errorCause = result.value?.error.cause;
while (errorCause?.cause) {
errorCause = errorCause.cause;
}
if (errorCause.message) {
return `${errorCause.name}: ${errorCause.message}`;
}
return errorCause.name;
}

private async validateReferences(
refElements: Element[],
result: Element,
doc: Element,
textDocument: TextDocument,
nameSpace: ContentLanguage,
validationContext?: ValidationContext,
): Promise<Diagnostic[]> {
const diagnostics: Diagnostic[] = [];
const pointersMap: Record<string, Pointer[]> = {};

const baseURI = validationContext?.baseURI
? validationContext?.baseURI
: 'https://smartbear.com/';

const derefPromises: Promise<Element | { error: Error; refEl: Element }>[] = [];
const apiReference = Reference({ uri: baseURI, value: result });
for (const refEl of refElements) {
const referenceElementReference = Reference({ uri: `${baseURI}#reference1`, value: refEl });
const refSet = ReferenceSet({ refs: [referenceElementReference, apiReference] });
try {
const promise = dereferenceApiDOM(refEl, {
resolve: {
baseURI: `${baseURI}#reference1`,
external: !(refEl as ObjectElement).get('$ref').toValue().startsWith('#'),
},
parse: {
mediaType: nameSpace.mediaType,
},
dereference: { refSet },
}).catch((e: Error) => {
return { error: e, refEl };
});
derefPromises.push(promise);
} catch (ex) {
console.error('error preparing dereferencing', ex);
}
}
try {
const derefResults = await Promise.allSettled(derefPromises);
for (const derefResult of derefResults) {
const message = DefaultValidationService.buildReferenceErrorMessage(derefResult);
if (message) {
// @ts-ignore
const refElement = derefResult.value?.refEl;
if (refElement as Element) {
const refValueElement = refElement.get('$ref');
const referencedElement = refElement
.getMetaProperty('referenced-element', '')
.toValue();
let pointers = pointersMap[referencedElement];
if (!pointers) {
pointers = localReferencePointers(doc, referencedElement, true);
// eslint-disable-next-line no-param-reassign
pointersMap[referencedElement] = pointers;
}
const lintSm = getSourceMap(refValueElement);
const location = { offset: lintSm.offset, length: lintSm.length };
const range = Range.create(
textDocument.positionAt(location.offset),
textDocument.positionAt(location.offset + location.length),
);
const code = `${location.offset.toString()}-${location.length.toString()}-${Date.now()}`;
const diagnostic = Diagnostic.create(
range,
`Reference Error - ${message}`,
DiagnosticSeverity.Error,
code,
'apilint',
);

diagnostic.source = 'apilint';
diagnostic.data = {
quickFix: [],
} as LinterMetaData;
for (const p of pointers) {
// @ts-ignore
if (refValueElement !== p.ref && !p.isRef) {
diagnostic.data.quickFix.push({
message: `update to ${p.ref}`,
action: 'updateValue',
functionParams: [p.ref],
});
}
}
// @ts-ignore
this.quickFixesMap[code] = diagnostic.data.quickFix;
diagnostics.push(diagnostic);
}
}
}
} catch (ex) {
console.error('error dereferencing', ex);
}
return diagnostics;
}

public async doValidation(
textDocument: TextDocument,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
validationContext?: ValidationContext,
): Promise<Diagnostic[]> {
perfStart(PerfLabels.START);
const context = !validationContext ? this.settings?.validationContext : validationContext;
const refValidationMode =
!context || !context.referenceValidationMode
? ReferenceValidationMode.LEGACY
: // eslint-disable-next-line no-bitwise
context.referenceValidationMode | ReferenceValidationMode.LEGACY;
const text: string = textDocument.getText();
const diagnostics: Diagnostic[] = [];
this.quickFixesMap = {};
Expand Down Expand Up @@ -235,7 +358,10 @@ export class DefaultValidationService implements ValidationService {
refValueElement: Element,
): Diagnostic[] => {
const refDiagnostics: Diagnostic[] = [];
if (refValueElement.toValue().startsWith('#')) {
if (
refValidationMode === ReferenceValidationMode.LEGACY &&
refValueElement.toValue().startsWith('#')
) {
let pointers = pointersMap[referencedElement];
if (!pointers) {
pointers = localReferencePointers(doc, referencedElement, true);
Expand Down Expand Up @@ -336,11 +462,22 @@ export class DefaultValidationService implements ValidationService {
return refDiagnostics;
};

const refElements: Element[] = [];

const lint = (element: Element) => {
if (
element.getMetaProperty('referenced-element', '').toValue().length > 0 &&
isObject(element) &&
element.hasKey('$ref') &&
(refValidationMode === ReferenceValidationMode.APIDOM_INDIRECT_EXTERNAL ||
element.get('$ref').toValue().startsWith('#'))
) {
refElements.push(element);
}
const sm = getSourceMap(element);
const referencedElement = element.getMetaProperty('referenced-element', '').toValue();
if (referencedElement.length > 0) {
// lint local references
// legacy lint local references
if (isObject(element) && element.hasKey('$ref')) {
// TODO get ref value from metadata or in adapter
diagnostics.push(...lintReference(api, referencedElement, element.get('$ref')));
Expand Down Expand Up @@ -380,6 +517,18 @@ export class DefaultValidationService implements ValidationService {
}
};
traverse(lint, api);
if (refValidationMode !== ReferenceValidationMode.LEGACY) {
diagnostics.push(
...(await this.validateReferences(
refElements,
result,
api,
textDocument,
nameSpace,
context,
)),
);
}
try {
const rules = this.settings?.metadata?.rules;
if (rules && rules[docNs]?.lint) {
Expand Down
52 changes: 44 additions & 8 deletions packages/apidom-ls/src/utils/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -784,64 +784,94 @@ export async function findNamespace(
const json = await isJsonDoc(text);
if (await asyncapi2AdapterJson.detect(text)) {
const versionMatch = text.match(asyncapi2AdapterJson.detectionRegExp);
const version = versionMatch?.groups?.version_json
? versionMatch?.groups?.version_json
: '2.6.0';
return {
namespace: 'asyncapi',
version: versionMatch?.groups?.version_json,
version,
format: 'JSON',
mediaType: `application/vnd.aai.asyncapi+json;version=${version}`,
};
}
if (await asyncapi2AdapterYaml.detect(text)) {
const versionMatch = text.match(asyncapi2AdapterYaml.detectionRegExp);
const version = versionMatch?.groups?.version_yaml
? versionMatch?.groups?.version_yaml
: '2.6.0';
return {
namespace: 'asyncapi',
version: versionMatch?.groups?.version_yaml || versionMatch?.groups?.version_json,
version,
format: 'YAML',
mediaType: `application/vnd.aai.asyncapi+yaml;version=${version}`,
};
}
if (await openapi3_0AdapterJson.detect(text)) {
const versionMatch = text.match(openapi3_0AdapterYaml.detectionRegExp);
const versionMatch = text.match(openapi3_0AdapterJson.detectionRegExp);
const version = versionMatch?.groups?.version_json
? versionMatch?.groups?.version_json
: '3.0.3';
return {
namespace: 'openapi',
version: versionMatch?.groups?.version_json,
version,
format: 'JSON',
mediaType: `application/vnd.oai.openapi+json;version=${version}`,
};
}
if (await openapi3_0AdapterYaml.detect(text)) {
const versionMatch = text.match(openapi3_0AdapterYaml.detectionRegExp);
const version = versionMatch?.groups?.version_yaml
? versionMatch?.groups?.version_yaml
: versionMatch?.groups?.version_json
? versionMatch?.groups?.version_json
: '3.0.3';
return {
namespace: 'openapi',
version: versionMatch?.groups?.version_yaml || versionMatch?.groups?.version_json,
version,
format: 'YAML',
mediaType: `application/vnd.oai.openapi+yaml;version=${version}`,
};
}
if (await openapi3_1AdapterJson.detect(text)) {
const versionMatch = text.match(openapi3_1AdapterYaml.detectionRegExp);
const versionMatch = text.match(openapi3_1AdapterJson.detectionRegExp);
const version = versionMatch?.groups?.version_json
? versionMatch?.groups?.version_json
: '3.1.0';
return {
namespace: 'openapi',
version: versionMatch?.groups?.version_json,
version,
format: 'JSON',
admitsRefsSiblings: true,
mediaType: `application/vnd.oai.openapi+json;version=${version}`,
};
}
if (await openapi3_1AdapterYaml.detect(text)) {
const versionMatch = text.match(openapi3_1AdapterYaml.detectionRegExp);
const version = versionMatch?.groups?.version_yaml
? versionMatch?.groups?.version_yaml
: versionMatch?.groups?.version_json
? versionMatch?.groups?.version_json
: '3.1.0';
return {
namespace: 'openapi',
version: versionMatch?.groups?.version_yaml || versionMatch?.groups?.version_json,
version,
format: 'YAML',
admitsRefsSiblings: true,
mediaType: `application/vnd.oai.openapi+yaml;version=${version}`,
};
}
if (await adsAdapterJson.detect(text)) {
return {
namespace: 'ads',
format: 'JSON',
mediaType: 'application/vnd.aai.apidesignsystems+json;version=2021-05-07',
};
}
if (await adsAdapterYaml.detect(text)) {
return {
namespace: 'ads',
format: 'YAML',
mediaType: 'application/vnd.aai.apidesignsystems+yaml;version=2021-05-07',
};
}
if (await adapterJson.detect(text)) {
Expand All @@ -851,10 +881,12 @@ export async function findNamespace(
version: defaultContentLanguage.version,
format: 'JSON',
admitsRefsSiblings: defaultContentLanguage.admitsRefsSiblings,
mediaType: defaultContentLanguage.mediaType,
}
: {
namespace: 'apidom',
format: 'JSON',
mediaType: 'application/json',
};
}
if (await adapterYaml.detect(text)) {
Expand All @@ -864,10 +896,12 @@ export async function findNamespace(
version: defaultContentLanguage.version,
format: 'YAML',
admitsRefsSiblings: defaultContentLanguage.admitsRefsSiblings,
mediaType: defaultContentLanguage.mediaType,
}
: {
namespace: 'apidom',
format: 'YAML',
mediaType: 'application/yaml',
};
}
return defaultContentLanguage
Expand All @@ -876,9 +910,11 @@ export async function findNamespace(
version: defaultContentLanguage.version,
format: json ? 'JSON' : 'YAML',
admitsRefsSiblings: defaultContentLanguage.admitsRefsSiblings,
mediaType: defaultContentLanguage.mediaType,
}
: {
namespace: 'apidom',
format: json ? 'JSON' : 'YAML',
mediaType: json ? 'application/json' : 'application/yaml',
};
}

0 comments on commit f97349b

Please sign in to comment.