Skip to content

Commit f88f7e5

Browse files
committed
Support schema in YAML
1 parent 61a9c38 commit f88f7e5

File tree

7 files changed

+241
-63
lines changed

7 files changed

+241
-63
lines changed

README.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
# Hyperjump - JSON Schema Test Coverage
22

33
This package provides tools for testing JSON Schemas and providing test coverage
4-
for schema files in your code base. Integration is provided for Vitest, but the
5-
component for collecting the coverage data is also exposed if you want to
6-
do some other integration.
4+
for schema files in JSON or YAML in your code base. Integration is provided for
5+
Vitest, but the component for collecting the coverage data is also exposed if
6+
you want to do some other integration.
77

88
Validation is done by `@hyperjump/json-schema`, so you can use any version of
99
JSON Schema supported by that package.
@@ -28,9 +28,10 @@ All files | 81.81 | 66.66 | 80 | 88.88 |
2828

2929
The following are known limitations I'm hopeful can be addressed.
3030

31-
- Coverage can only be reported for `**/*.schema.json` and `**/schema.json`
32-
files.
33-
- Schemas in YAML aren't supported.
31+
- Coverage can only be reported for the following files
32+
- `**/*.schema.json` and `**/schema.json`
33+
- `**/*.schema.yaml` and `**/schema.yaml`
34+
- `**/*.schema.yml` and `**/schema.yml`
3435

3536
## Vitest
3637

package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
},
2929
"devDependencies": {
3030
"@stylistic/eslint-plugin": "*",
31+
"@types/content-type": "^1.1.9",
3132
"@types/istanbul-lib-coverage": "^2.0.6",
3233
"@types/istanbul-reports": "^3.0.4",
3334
"@types/moo": "^0.5.10",
@@ -42,13 +43,16 @@
4243
"dependencies": {
4344
"@hyperjump/json-schema": "^1.16.0",
4445
"@hyperjump/uri": "^1.3.1",
46+
"content-type": "^1.0.5",
4547
"ignore": "^7.0.5",
4648
"istanbul-lib-coverage": "^3.2.2",
4749
"istanbul-lib-report": "^3.0.1",
4850
"istanbul-reports": "^3.1.7",
4951
"moo": "^0.5.2",
5052
"pathe": "^2.0.3",
5153
"tinyglobby": "^0.2.14",
52-
"vfile": "^6.0.3"
54+
"vfile": "^6.0.3",
55+
"yaml": "^2.8.0",
56+
"yaml-unist-parser": "^2.0.5"
5357
}
5458
}

src/json-schema.js

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import "@hyperjump/json-schema/draft-2020-12";
2+
import "@hyperjump/json-schema/draft-2019-09";
3+
import "@hyperjump/json-schema/draft-07";
4+
import "@hyperjump/json-schema/draft-06";
5+
import "@hyperjump/json-schema/draft-04";
6+
import "@hyperjump/json-schema/openapi-3-0";
7+
import "@hyperjump/json-schema/openapi-3-1";
8+
import { buildSchemaDocument } from "@hyperjump/json-schema/experimental";
9+
import { addMediaTypePlugin } from "@hyperjump/browser";
10+
import contentTypeParser from "content-type";
11+
import YAML from "yaml";
12+
13+
/**
14+
* @import { SchemaObject } from "@hyperjump/json-schema"
15+
*/
16+
17+
addMediaTypePlugin("application/schema+json", {
18+
parse: async (response) => {
19+
const contentType = contentTypeParser.parse(response.headers.get("content-type") ?? "");
20+
const contextDialectId = contentType.parameters.schema ?? contentType.parameters.profile;
21+
22+
/** @type SchemaObject */
23+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
24+
const json = await response.json();
25+
return buildSchemaDocument(json, response.url, contextDialectId);
26+
},
27+
// eslint-disable-next-line @typescript-eslint/require-await
28+
fileMatcher: async (path) => /\.json$/i.test(path)
29+
});
30+
31+
addMediaTypePlugin("application/schema+yaml", {
32+
parse: async (response) => {
33+
const contentType = contentTypeParser.parse(response.headers.get("content-type") ?? "");
34+
const contextDialectId = contentType.parameters.schema ?? contentType.parameters.profile;
35+
36+
/** @type SchemaObject */
37+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
38+
const yaml = YAML.parse(await response.text());
39+
return buildSchemaDocument(yaml, response.url, contextDialectId);
40+
},
41+
// eslint-disable-next-line @typescript-eslint/require-await
42+
fileMatcher: async (path) => /\.ya?ml$/i.test(path)
43+
});
44+
45+
export * from "@hyperjump/json-schema/draft-2020-12";

src/json-util.js

Lines changed: 156 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as JsonPointer from "@hyperjump/json-pointer";
22
import { JsonLexer } from "./json-lexer.js";
3+
import { parse as parseYaml } from "yaml-unist-parser";
34

45
/**
56
* @import { Node, Position } from "unist"
@@ -9,52 +10,59 @@ import { JsonLexer } from "./json-lexer.js";
910
* JsonNode,
1011
* JsonObjectNode,
1112
* JsonPropertyNameNode,
12-
* JsonPropertyNode
13+
* JsonPropertyNode,
14+
* JsonStringNode
1315
* } from "./jsonast.d.ts"
16+
* @import {
17+
* YamlUnistNode,
18+
* MappingKey,
19+
* MappingItem,
20+
* FlowMappingItem,
21+
* ContentNode
22+
* } from "yaml-unist-parser"
1423
*/
1524

16-
/** @type (json: string, uri?: string) => JsonNode */
17-
export const fromJson = (json, uri = "") => {
25+
/** @type (json: string) => JsonNode */
26+
export const fromJson = (json) => {
1827
const lexer = new JsonLexer(json);
1928

2029
const token = lexer.nextToken();
21-
const jsonValue = parseValue(token, lexer, undefined, uri, "");
30+
const jsonValue = parseValue(token, lexer, undefined);
2231

2332
lexer.done();
2433

2534
return jsonValue;
2635
};
2736

28-
/** @type (token: JsonToken, lexer: JsonLexer, key: string | undefined, uri: string, pointer: string) => JsonNode */
29-
const parseValue = (token, lexer, _key, uri, pointer) => {
37+
/** @type (token: JsonToken, lexer: JsonLexer, key: string | undefined) => JsonNode */
38+
const parseValue = (token, lexer, _key) => {
3039
switch (token.type) {
3140
case "null":
3241
case "boolean":
3342
case "number":
3443
case "string":
35-
return parseScalar(token, uri, pointer);
44+
return parseScalar(token);
3645
case "[":
37-
return parseArray(token, lexer, uri, pointer);
46+
return parseArray(token, lexer);
3847
case "{":
39-
return parseObject(token, lexer, uri, pointer);
48+
return parseObject(token, lexer);
4049
default:
4150
throw lexer.syntaxError("Expected a JSON value", token);
4251
}
4352
};
4453

45-
/** @type (token: JsonToken<"null" | "boolean" | "number" | "string">, uri: string, pointer: string) => JsonNode */
46-
const parseScalar = (token, uri, pointer) => {
54+
/** @type (token: JsonToken<"null" | "boolean" | "number" | "string">) => JsonNode */
55+
const parseScalar = (token) => {
4756
return {
4857
type: "json",
4958
jsonType: token.type,
5059
value: JSON.parse(token.value), // eslint-disable-line @typescript-eslint/no-unsafe-assignment
51-
location: `${uri}#${encodeURI(pointer)}`,
5260
position: tokenPosition(token)
5361
};
5462
};
5563

56-
/** @type (token: JsonToken, lexer: JsonLexer, key: string, uri: string, pointer: string) => JsonPropertyNode */
57-
const parseProperty = (token, lexer, _key, uri, pointer) => {
64+
/** @type (token: JsonToken, lexer: JsonLexer, key: string) => JsonPropertyNode */
65+
const parseProperty = (token, lexer, _key) => {
5866
if (token.type !== "string") {
5967
throw lexer.syntaxError("Expected a propertry", token);
6068
}
@@ -71,7 +79,7 @@ const parseProperty = (token, lexer, _key, uri, pointer) => {
7179
throw lexer.syntaxError("Expected :", token);
7280
}
7381

74-
const valueNode = parseValue(lexer.nextToken(), lexer, keyNode.value, uri, JsonPointer.append(keyNode.value, pointer));
82+
const valueNode = parseValue(lexer.nextToken(), lexer, keyNode.value);
7583

7684
return {
7785
type: "json-property",
@@ -90,11 +98,11 @@ const parseProperty = (token, lexer, _key, uri, pointer) => {
9098

9199
/**
92100
* @type <P extends ParentNode<C>, C extends JsonNode | JsonPropertyNode>(
93-
* parseChild: (token: JsonToken, lexer: JsonLexer, key: string, uri: string, pointer: string) => C,
101+
* parseChild: (token: JsonToken, lexer: JsonLexer, key: string) => C,
94102
* endToken: string
95-
* ) => (lexer: JsonLexer, node: P, uri: string, pointer: string) => P
103+
* ) => (lexer: JsonLexer, node: P) => P
96104
*/
97-
const parseCommaSeparated = (parseChild, endToken) => (lexer, node, uri, pointer) => {
105+
const parseCommaSeparated = (parseChild, endToken) => (lexer, node) => {
98106
for (let index = 0; true; index++) {
99107
let token = lexer.nextToken();
100108

@@ -111,44 +119,42 @@ const parseCommaSeparated = (parseChild, endToken) => (lexer, node, uri, pointer
111119
}
112120
}
113121

114-
const childNode = parseChild(token, lexer, `${index}`, uri, pointer);
122+
const childNode = parseChild(token, lexer, `${index}`);
115123
if (childNode) {
116124
node.children.push(childNode);
117125
}
118126
}
119127
};
120128

121-
/** @type (openToken: JsonToken, lexer: JsonLexer, uri: string, pointer: string) => JsonArrayNode */
122-
const parseArray = (openToken, lexer, uri, pointer) => {
129+
/** @type (openToken: JsonToken, lexer: JsonLexer) => JsonArrayNode */
130+
const parseArray = (openToken, lexer) => {
123131
return parseItems(lexer, {
124132
type: "json",
125133
jsonType: "array",
126134
children: [],
127-
location: `${uri}#${encodeURI(pointer)}`,
128135
position: tokenPosition(openToken)
129-
}, uri, pointer);
136+
});
130137
};
131138

132-
/** @type (token: JsonToken, lexer: JsonLexer, key: string, uri: string, pointer: string) => JsonNode */
133-
const parseItem = (token, lexer, key, uri, pointer) => {
134-
return parseValue(token, lexer, key, uri, JsonPointer.append(key, pointer));
139+
/** @type (token: JsonToken, lexer: JsonLexer, key: string) => JsonNode */
140+
const parseItem = (token, lexer, key) => {
141+
return parseValue(token, lexer, key);
135142
};
136143

137-
/** @type (lexer: JsonLexer, node: { type: "json" } & JsonArrayNode, uri: string, pointer: string) => JsonArrayNode */
144+
/** @type (lexer: JsonLexer, node: { type: "json" } & JsonArrayNode) => JsonArrayNode */
138145
const parseItems = parseCommaSeparated(parseItem, "]");
139146

140-
/** @type (openToken: JsonToken, lexer: JsonLexer, uri: string, pointer: string) => JsonObjectNode */
141-
const parseObject = (openToken, lexer, uri, pointer) => {
147+
/** @type (openToken: JsonToken, lexer: JsonLexer) => JsonObjectNode */
148+
const parseObject = (openToken, lexer) => {
142149
return parseProperties(lexer, {
143150
type: "json",
144151
jsonType: "object",
145152
children: [],
146-
location: `${uri}#${encodeURI(pointer)}`,
147153
position: tokenPosition(openToken)
148-
}, uri, pointer);
154+
});
149155
};
150156

151-
/** @type (lexer: JsonLexer, node: { type: "json" } & JsonObjectNode, uri: string, pointer: string) => JsonObjectNode */
157+
/** @type (lexer: JsonLexer, node: { type: "json" } & JsonObjectNode) => JsonObjectNode */
152158
const parseProperties = parseCommaSeparated(parseProperty, "}");
153159

154160
/** @type (startToken: JsonToken, endToken?: JsonToken) => Position */
@@ -211,3 +217,121 @@ export const getNodeFromPointer = (tree, pointer, returnProperty) => {
211217

212218
return node.type === "json-property" && !returnProperty ? node.children[1] : node;
213219
};
220+
221+
/** @type (yaml: string) => JsonNode */
222+
export const fromYaml = (yaml) => {
223+
const root = parseYaml(yaml);
224+
return yamlToJson(root);
225+
};
226+
227+
/**
228+
* @overload
229+
* @param {MappingItem | FlowMappingItem} yamlNode
230+
* @returns {JsonPropertyNode}
231+
*
232+
* @overload
233+
* @param {MappingKey} yamlNode
234+
* @returns {JsonPropertyNameNode}
235+
*
236+
* @overload
237+
* @param {ContentNode} yamlNode
238+
* @returns {JsonStringNode}
239+
*
240+
* @overload
241+
* @param {YamlUnistNode} yamlNode
242+
* @returns {JsonNode}
243+
*
244+
* @param {YamlUnistNode} yamlNode
245+
* @returns {JsonNode | JsonPropertyNode | JsonPropertyNameNode}
246+
*/
247+
const yamlToJson = (yamlNode) => {
248+
switch (yamlNode.type) {
249+
case "root":
250+
return yamlToJson(yamlNode.children[0]);
251+
252+
case "document":
253+
return yamlToJson(yamlNode.children[1]);
254+
255+
case "documentHead":
256+
throw Error(`Not Implemented - ${yamlNode.type}`);
257+
258+
case "documentBody":
259+
if (yamlNode.children.length === 0) {
260+
throw Error("YAML documents must contain a value");
261+
}
262+
return yamlToJson(yamlNode.children[0]);
263+
264+
case "plain":
265+
case "quoteDouble":
266+
case "quoteSingle":
267+
case "blockLiteral":
268+
case "blockFolded":
269+
/** @type JsonStringNode */
270+
const stringNode = {
271+
type: "json",
272+
jsonType: "string",
273+
value: yamlNode.value,
274+
position: yamlNode.position
275+
};
276+
return stringNode;
277+
278+
case "mapping":
279+
case "flowMapping":
280+
/** @type JsonObjectNode */
281+
const objectNode = {
282+
type: "json",
283+
jsonType: "object",
284+
children: yamlNode.children.map((mappingItemNode) => yamlToJson(mappingItemNode)),
285+
position: yamlNode.position
286+
};
287+
return objectNode;
288+
289+
case "mappingItem":
290+
case "flowMappingItem":
291+
const [mappingKeyNode, mappingValueNode] = yamlNode.children;
292+
293+
/** @type JsonPropertyNode */
294+
const propertyNode = {
295+
type: "json-property",
296+
children: [
297+
yamlToJson(mappingKeyNode),
298+
yamlToJson(mappingValueNode)
299+
],
300+
position: yamlNode.position
301+
};
302+
303+
return propertyNode;
304+
305+
case "mappingKey":
306+
const contentNode = yamlToJson(/** @type ContentNode */ (yamlNode.children[0]));
307+
/** @type JsonPropertyNameNode */
308+
const propertyNameNode = {
309+
type: "json-property-name",
310+
jsonType: "string",
311+
value: contentNode.value,
312+
position: yamlNode.position
313+
};
314+
return propertyNameNode;
315+
316+
case "mappingValue":
317+
return yamlToJson(/** @type ContentNode */ (yamlNode.children[0]));
318+
319+
case "sequence":
320+
case "flowSequence":
321+
/** @type JsonArrayNode */
322+
const arrayNode = {
323+
type: "json",
324+
jsonType: "array",
325+
children: yamlNode.children.map((sequenceItemNode) => yamlToJson(sequenceItemNode)),
326+
position: yamlNode.position
327+
};
328+
return arrayNode;
329+
330+
case "sequenceItem":
331+
case "flowSequenceItem":
332+
return yamlToJson(/** @type ContentNode */ (yamlNode.children[0]));
333+
334+
default:
335+
throw Error(`YAML error. ${yamlNode.type}`);
336+
}
337+
};

0 commit comments

Comments
 (0)