-
-
Notifications
You must be signed in to change notification settings - Fork 127
Expand file tree
/
Copy pathdocumentStructure.ts
More file actions
173 lines (147 loc) · 6.79 KB
/
documentStructure.ts
File metadata and controls
173 lines (147 loc) · 6.79 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
/* eslint-disable sonarjs/no-duplicate-string */
import specs from '@asyncapi/specs';
import { createRulesetFunction } from '@stoplight/spectral-core';
import { schema as schemaFn } from '@stoplight/spectral-functions';
import { AsyncAPIFormats } from '../formats';
import { getSemver } from '../../utils';
import type { ErrorObject } from 'ajv';
import type { IFunctionResult, Format } from '@stoplight/spectral-core';
type AsyncAPIVersions = keyof typeof specs.schemas;
type RawSchema = Record<string, unknown>;
function shouldIgnoreError(error: ErrorObject): boolean {
return (
// oneOf is a fairly error as we have 2 options to choose from for most of the time.
error.keyword === 'oneOf' ||
// the required $ref is entirely useless, since aas-schema rules operate on resolved content, so there won't be any $refs in the document
(error.keyword === 'required' && error.params.missingProperty === '$ref')
);
}
// ajv throws a lot of errors that have no understandable context, e.g. errors related to the fact that a value doesn't meet the conditions of some sub-schema in `oneOf`, `anyOf` etc.
// for this reason, we filter these unnecessary errors and leave only the most important ones (usually the first occurring in the list of errors).
function prepareResults(errors: ErrorObject[]): void {
// Update additionalProperties errors to make them more precise and prevent them from being treated as duplicates
for (let i = 0; i < errors.length; i++) {
const error = errors[i];
if (error.keyword === 'additionalProperties') {
error.instancePath = `${error.instancePath}/${String(error.params['additionalProperty'])}`;
} else if (error.keyword === 'required' && error.params.missingProperty === '$ref') {
errors.splice(i, 1);
i--;
}
}
for (let i = 0; i < errors.length; i++) {
const error = errors[i];
if (i + 1 < errors.length && errors[i + 1].instancePath === error.instancePath) {
errors.splice(i + 1, 1);
i--;
} else if (i > 0 && shouldIgnoreError(error) && errors[i - 1].instancePath.startsWith(error.instancePath)) {
errors.splice(i, 1);
i--;
}
}
}
// this is needed because some v3 object fields are expected to be only `$ref` to other objects.
// In order to validate resolved references, we modify those schemas and instead allow the definition of the object
function prepareV3ResolvedSchema(copied: any, version: string): any {
// channel object
const channelObject = copied.definitions[`http://asyncapi.com/definitions/${version}/channel.json`];
if (channelObject?.properties?.servers?.items) {
channelObject.properties.servers.items.$ref = `http://asyncapi.com/definitions/${version}/server.json`;
}
// operation object
const operationSchema = copied.definitions[`http://asyncapi.com/definitions/${version}/operation.json`];
if (operationSchema?.properties?.channel) {
operationSchema.properties.channel.$ref = `http://asyncapi.com/definitions/${version}/channel.json`;
}
if (operationSchema?.properties?.messages?.items) {
operationSchema.properties.messages.items.$ref = `http://asyncapi.com/definitions/${version}/messageObject.json`;
}
// operation reply object
const operationReplySchema = copied.definitions[`http://asyncapi.com/definitions/${version}/operationReply.json`];
if (operationReplySchema?.properties?.channel) {
operationReplySchema.properties.channel.$ref = `http://asyncapi.com/definitions/${version}/channel.json`;
}
if (operationReplySchema?.properties?.messages?.items) {
operationReplySchema.properties.messages.items.$ref = `http://asyncapi.com/definitions/${version}/messageObject.json`;
}
return copied;
}
function getCopyOfSchema(version: AsyncAPIVersions): RawSchema {
return JSON.parse(JSON.stringify(specs.schemas[version])) as RawSchema;
}
const serializedSchemas = new Map<AsyncAPIVersions, RawSchema>();
function getSerializedSchema(version: AsyncAPIVersions, resolved: boolean): RawSchema {
const serializedSchemaKey = resolved ? `${version}-resolved` : `${version}-unresolved`;
const schema = serializedSchemas.get(serializedSchemaKey as AsyncAPIVersions);
if (schema) {
return schema;
}
// Copy to not operate on the original json schema - between imports (in different modules) we operate on this same schema.
let copied = getCopyOfSchema(version) as { '$id': string, definitions: RawSchema };
// Remove the meta schemas because they are already present within Ajv, and it's not possible to add duplicated schemas.
delete copied.definitions['http://json-schema.org/draft-07/schema'];
delete copied.definitions['http://json-schema.org/draft-04/schema'];
// Spectral caches the schemas using '$id' property
copied['$id'] = copied['$id'].replace('asyncapi.json', `asyncapi-${resolved ? 'resolved' : 'unresolved'}.json`);
// Remove format: "uri-reference" from ReferenceObject so that $ref values with special chars
// like `[`, `]` (valid JSON Pointer segments but not RFC 3986 URIs) pass validation.
const referenceObject = copied.definitions[`http://asyncapi.com/definitions/${version}/ReferenceObject.json`] as any;
if (referenceObject?.format === 'uri-reference') {
delete referenceObject.format;
}
const { major } = getSemver(version);
if (resolved && major === 3) {
copied = prepareV3ResolvedSchema(copied, version);
}
serializedSchemas.set(serializedSchemaKey as AsyncAPIVersions, copied);
return copied;
}
const refErrorMessage = 'Property "$ref" is not expected to be here';
function filterRefErrors(errors: IFunctionResult[], resolved: boolean) {
if (resolved) {
return errors.filter(err => err.message !== refErrorMessage);
}
return errors
.filter(err => err.message === refErrorMessage)
.map(err => {
err.message = 'Referencing in this place is not allowed';
return err;
});
}
export function getSchema(docFormats: Set<Format>, resolved: boolean): Record<string, any> | void {
for (const [version, format] of AsyncAPIFormats) {
if (docFormats.has(format)) {
return getSerializedSchema(version as AsyncAPIVersions, resolved);
}
}
}
export const documentStructure = createRulesetFunction<unknown, { resolved: boolean }>(
{
input: null,
options: {
type: 'object',
properties: {
resolved: {
type: 'boolean',
},
},
required: ['resolved'],
},
},
(targetVal, options, context) => {
const formats = context.document?.formats;
if (!formats) {
return;
}
const resolved = options.resolved;
const schema = getSchema(formats, resolved);
if (!schema) {
return;
}
const errors = schemaFn(targetVal, { allErrors: true, schema, prepareResults: resolved ? prepareResults : undefined }, context);
if (!Array.isArray(errors)) {
return;
}
return filterRefErrors(errors, resolved);
},
);