Skip to content

Commit 0d14fa0

Browse files
committed
Add support for OpenAPI 3.2 schema dialect
1 parent cfe6c40 commit 0d14fa0

13 files changed

+572
-0
lines changed

lib/openapi.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { addMediaTypePlugin } from "@hyperjump/browser";
33
import { buildSchemaDocument } from "./schema.js";
44

55

6+
const is32 = RegExp.prototype.test.bind(/^3\.2\.\d+(-.+)?$/);
67
const is31 = RegExp.prototype.test.bind(/^3\.1\.\d+(-.+)?$/);
78
const is30 = RegExp.prototype.test.bind(/^3\.0\.\d+(-.+)?$/);
89

@@ -34,6 +35,22 @@ addMediaTypePlugin("application/openapi+json", {
3435
} else {
3536
defaultDialect = `https://spec.openapis.org/oas/3.1/schema?${encodeURIComponent(doc.jsonSchemaDialect)}`;
3637
}
38+
} else if (is32(version)) {
39+
if (!("jsonSchemaDialect" in doc) || doc.jsonSchemaDialect === "https://spec.openapis.org/oas/3.2/dialect/base") {
40+
defaultDialect = "https://spec.openapis.org/oas/3.2/schema-base";
41+
} else if (doc.jsonSchemaDialect === "https://json-schema.org/draft/2020-12/schema") {
42+
defaultDialect = `https://spec.openapis.org/oas/3.2/schema-draft-2020-12`;
43+
} else if (doc.jsonSchemaDialect === "https://json-schema.org/draft/2019-09/schema") {
44+
defaultDialect = `https://spec.openapis.org/oas/3.2/schema-draft-2019-09`;
45+
} else if (doc.jsonSchemaDialect === "http://json-schema.org/draft-07/schema#") {
46+
defaultDialect = `https://spec.openapis.org/oas/3.2/schema-draft-07`;
47+
} else if (doc.jsonSchemaDialect === "http://json-schema.org/draft-06/schema#") {
48+
defaultDialect = `https://spec.openapis.org/oas/3.2/schema-draft-06`;
49+
} else if (doc.jsonSchemaDialect === "http://json-schema.org/draft-04/schema#") {
50+
defaultDialect = `https://spec.openapis.org/oas/3.2/schema-draft-04`;
51+
} else {
52+
defaultDialect = `https://spec.openapis.org/oas/3.2/schema?${encodeURIComponent(doc.jsonSchemaDialect)}`;
53+
}
3754
} else {
3855
throw Error(`Encountered unsupported OpenAPI version '${version}' in ${response.url}`);
3956
}

openapi-3-2/dialect/base.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
export default {
2+
"$schema": "https://json-schema.org/draft/2020-12/schema",
3+
"$vocabulary": {
4+
"https://json-schema.org/draft/2020-12/vocab/core": true,
5+
"https://json-schema.org/draft/2020-12/vocab/applicator": true,
6+
"https://json-schema.org/draft/2020-12/vocab/unevaluated": true,
7+
"https://json-schema.org/draft/2020-12/vocab/validation": true,
8+
"https://json-schema.org/draft/2020-12/vocab/meta-data": true,
9+
"https://json-schema.org/draft/2020-12/vocab/format-annotation": true,
10+
"https://json-schema.org/draft/2020-12/vocab/content": true,
11+
"https://spec.openapis.org/oas/3.2/vocab/base": false
12+
},
13+
"$dynamicAnchor": "meta",
14+
15+
"title": "OpenAPI 3.2 Schema Object Dialect",
16+
"description": "A JSON Schema dialect describing schemas found in OpenAPI v3.2 Descriptions",
17+
18+
"allOf": [
19+
{ "$ref": "https://json-schema.org/draft/2020-12/schema" },
20+
{ "$ref": "https://spec.openapis.org/oas/3.2/meta/base" }
21+
]
22+
};

openapi-3-2/index.d.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import type { Json } from "@hyperjump/json-pointer";
2+
import type { JsonSchemaType } from "../lib/common.js";
3+
4+
5+
export type OasSchema32 = boolean | {
6+
$schema?: "https://spec.openapis.org/oas/3.2/dialect/base";
7+
$id?: string;
8+
$anchor?: string;
9+
$ref?: string;
10+
$dynamicRef?: string;
11+
$dynamicAnchor?: string;
12+
$vocabulary?: Record<string, boolean>;
13+
$comment?: string;
14+
$defs?: Record<string, OasSchema32>;
15+
additionalItems?: OasSchema32;
16+
unevaluatedItems?: OasSchema32;
17+
prefixItems?: OasSchema32[];
18+
items?: OasSchema32;
19+
contains?: OasSchema32;
20+
additionalProperties?: OasSchema32;
21+
unevaluatedProperties?: OasSchema32;
22+
properties?: Record<string, OasSchema32>;
23+
patternProperties?: Record<string, OasSchema32>;
24+
dependentSchemas?: Record<string, OasSchema32>;
25+
propertyNames?: OasSchema32;
26+
if?: OasSchema32;
27+
then?: OasSchema32;
28+
else?: OasSchema32;
29+
allOf?: OasSchema32[];
30+
anyOf?: OasSchema32[];
31+
oneOf?: OasSchema32[];
32+
not?: OasSchema32;
33+
multipleOf?: number;
34+
maximum?: number;
35+
exclusiveMaximum?: number;
36+
minimum?: number;
37+
exclusiveMinimum?: number;
38+
maxLength?: number;
39+
minLength?: number;
40+
pattern?: string;
41+
maxItems?: number;
42+
minItems?: number;
43+
uniqueItems?: boolean;
44+
maxContains?: number;
45+
minContains?: number;
46+
maxProperties?: number;
47+
minProperties?: number;
48+
required?: string[];
49+
dependentRequired?: Record<string, string[]>;
50+
const?: Json;
51+
enum?: Json[];
52+
type?: JsonSchemaType | JsonSchemaType[];
53+
title?: string;
54+
description?: string;
55+
default?: Json;
56+
deprecated?: boolean;
57+
readOnly?: boolean;
58+
writeOnly?: boolean;
59+
examples?: Json[];
60+
format?: "date-time" | "date" | "time" | "duration" | "email" | "idn-email" | "hostname" | "idn-hostname" | "ipv4" | "ipv6" | "uri" | "uri-reference" | "iri" | "iri-reference" | "uuid" | "uri-template" | "json-pointer" | "relative-json-pointer" | "regex";
61+
contentMediaType?: string;
62+
contentEncoding?: string;
63+
contentSchema?: OasSchema32;
64+
example?: Json;
65+
discriminator?: Discriminator;
66+
externalDocs?: ExternalDocs;
67+
xml?: Xml;
68+
};
69+
70+
type Discriminator = {
71+
propertyName: string;
72+
mappings?: Record<string, string>;
73+
};
74+
75+
type ExternalDocs = {
76+
url: string;
77+
description?: string;
78+
};
79+
80+
type Xml = {
81+
name?: string;
82+
namespace?: string;
83+
prefix?: string;
84+
attribute?: boolean;
85+
wrapped?: boolean;
86+
};
87+
88+
// TODO: Fill in this type when 3.2 is published
89+
export type OpenApi32 = unknown;
90+
91+
export * from "../lib/index.js";

openapi-3-2/index.js

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { addKeyword, defineVocabulary } from "../lib/keywords.js";
2+
import { registerSchema } from "../lib/index.js";
3+
import "../lib/openapi.js";
4+
5+
import dialectSchema from "./dialect/base.js";
6+
import vocabularySchema from "./meta/base.js";
7+
import schema from "./schema.js";
8+
import schemaBase from "./schema-base.js";
9+
import schemaDraft2020 from "./schema-draft-2020-12.js";
10+
import schemaDraft2019 from "./schema-draft-2019-09.js";
11+
import schemaDraft07 from "./schema-draft-07.js";
12+
import schemaDraft06 from "./schema-draft-06.js";
13+
import schemaDraft04 from "./schema-draft-04.js";
14+
15+
import discriminator from "../openapi-3-0/discriminator.js";
16+
import example from "../openapi-3-0/example.js";
17+
import externalDocs from "../openapi-3-0/externalDocs.js";
18+
import xml from "../openapi-3-0/xml.js";
19+
20+
21+
export * from "../draft-2020-12/index.js";
22+
23+
addKeyword(discriminator);
24+
addKeyword(example);
25+
addKeyword(externalDocs);
26+
addKeyword(xml);
27+
28+
defineVocabulary("https://spec.openapis.org/oas/3.2/vocab/base", {
29+
"discriminator": "https://spec.openapis.org/oas/3.0/keyword/discriminator",
30+
"example": "https://spec.openapis.org/oas/3.0/keyword/example",
31+
"externalDocs": "https://spec.openapis.org/oas/3.0/keyword/externalDocs",
32+
"xml": "https://spec.openapis.org/oas/3.0/keyword/xml"
33+
});
34+
35+
registerSchema(vocabularySchema, "https://spec.openapis.org/oas/3.2/meta/base");
36+
registerSchema(dialectSchema, "https://spec.openapis.org/oas/3.2/dialect/base");
37+
38+
// Current Schemas
39+
registerSchema(schema, "https://spec.openapis.org/oas/3.2/schema");
40+
registerSchema(schema, "https://spec.openapis.org/oas/3.2/schema/latest");
41+
registerSchema(schemaBase, "https://spec.openapis.org/oas/3.2/schema-base");
42+
registerSchema(schemaBase, "https://spec.openapis.org/oas/3.2/schema-base/latest");
43+
44+
// Alternative dialect schemas
45+
registerSchema(schemaDraft2020, "https://spec.openapis.org/oas/3.2/schema-draft-2020-12");
46+
registerSchema(schemaDraft2019, "https://spec.openapis.org/oas/3.2/schema-draft-2019-09");
47+
registerSchema(schemaDraft07, "https://spec.openapis.org/oas/3.2/schema-draft-07");
48+
registerSchema(schemaDraft06, "https://spec.openapis.org/oas/3.2/schema-draft-06");
49+
registerSchema(schemaDraft04, "https://spec.openapis.org/oas/3.2/schema-draft-04");
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import fs from "node:fs";
2+
import { describe, it, expect, beforeAll, afterAll } from "vitest";
3+
import { toAbsoluteIri } from "@hyperjump/uri";
4+
import { registerSchema, unregisterSchema, validate } from "./index.js";
5+
6+
import type { Json } from "@hyperjump/json-pointer";
7+
import type { OasSchema32, Validator } from "./index.js";
8+
9+
10+
type Suite = {
11+
description: string;
12+
schema: OasSchema32;
13+
tests: Test[];
14+
};
15+
16+
type Test = {
17+
description: string;
18+
data: Json;
19+
valid: boolean;
20+
};
21+
22+
// This package is indended to be a compatibility mode from stable JSON Schema.
23+
// Some edge cases might not work exactly as specified, but it should work for
24+
// any real-life schema.
25+
const skip = new Set<string>([
26+
// Self-identifying with a `file:` URI is not allowed for security reasons.
27+
"|draft2020-12|ref.json|$id with file URI still resolves pointers - *nix",
28+
"|draft2020-12|ref.json|$id with file URI still resolves pointers - windows"
29+
]);
30+
31+
const shouldSkip = (path: string[]): boolean => {
32+
let key = "";
33+
for (const segment of path) {
34+
key = `${key}|${segment}`;
35+
if (skip.has(key)) {
36+
return true;
37+
}
38+
}
39+
return false;
40+
};
41+
42+
const testSuitePath = "./node_modules/json-schema-test-suite";
43+
44+
const addRemotes = (dialectId: string, filePath = `${testSuitePath}/remotes`, url = "") => {
45+
fs.readdirSync(filePath, { withFileTypes: true })
46+
.forEach((entry) => {
47+
if (entry.isFile() && entry.name.endsWith(".json")) {
48+
const remote = JSON.parse(fs.readFileSync(`${filePath}/${entry.name}`, "utf8")) as Exclude<OasSchema32, boolean>;
49+
if (!remote.$schema || toAbsoluteIri(remote.$schema as string) === "https://json-schema.org/draft/2020-12/schema") {
50+
registerSchema(remote, `http://localhost:1234${url}/${entry.name}`, dialectId);
51+
}
52+
} else if (entry.isDirectory()) {
53+
addRemotes(dialectId, `${filePath}/${entry.name}`, `${url}/${entry.name}`);
54+
}
55+
});
56+
};
57+
58+
const runTestSuite = (draft: string, dialectId: string) => {
59+
const testSuiteFilePath = `${testSuitePath}/tests/${draft}`;
60+
61+
describe(`${draft} ${dialectId}`, () => {
62+
beforeAll(() => {
63+
addRemotes(dialectId);
64+
});
65+
66+
fs.readdirSync(testSuiteFilePath, { withFileTypes: true })
67+
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
68+
.forEach((entry) => {
69+
const file = `${testSuiteFilePath}/${entry.name}`;
70+
71+
describe(entry.name, () => {
72+
const suites = JSON.parse(fs.readFileSync(file, "utf8")) as Suite[];
73+
74+
suites.forEach((suite) => {
75+
describe(suite.description, () => {
76+
let _validate: Validator;
77+
let url: string;
78+
79+
beforeAll(async () => {
80+
if (shouldSkip([draft, entry.name, suite.description])) {
81+
return;
82+
}
83+
url = `http://${draft}-test-suite.json-schema.org/${entry.name}/${encodeURIComponent(suite.description)}`;
84+
if (typeof suite.schema === "object" && suite.schema.$schema === "https://json-schema.org/draft/2020-12/schema") {
85+
delete suite.schema.$schema;
86+
}
87+
registerSchema(suite.schema, url, dialectId);
88+
89+
_validate = await validate(url);
90+
});
91+
92+
afterAll(() => {
93+
unregisterSchema(url);
94+
});
95+
96+
suite.tests.forEach((test) => {
97+
if (shouldSkip([draft, entry.name, suite.description, test.description])) {
98+
it.skip(test.description, () => { /* empty */ });
99+
} else {
100+
it(test.description, () => {
101+
const output = _validate(test.data);
102+
expect(output.valid).to.equal(test.valid);
103+
});
104+
}
105+
});
106+
});
107+
});
108+
});
109+
});
110+
});
111+
};
112+
113+
runTestSuite("draft2020-12", "https://spec.openapis.org/oas/3.2/dialect/base");

openapi-3-2/meta/base.js

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
export default {
2+
"$schema": "https://json-schema.org/draft/2020-12/schema",
3+
"$dynamicAnchor": "meta",
4+
5+
"title": "OAS Base Vocabulary",
6+
"description": "A JSON Schema Vocabulary used in the OpenAPI Schema Dialect",
7+
8+
"type": ["object", "boolean"],
9+
"properties": {
10+
"example": true,
11+
"discriminator": { "$ref": "#/$defs/discriminator" },
12+
"externalDocs": { "$ref": "#/$defs/external-docs" },
13+
"xml": { "$ref": "#/$defs/xml" }
14+
},
15+
"$defs": {
16+
"extensible": {
17+
"patternProperties": {
18+
"^x-": true
19+
}
20+
},
21+
"discriminator": {
22+
"$ref": "#/$defs/extensible",
23+
"type": "object",
24+
"properties": {
25+
"propertyName": {
26+
"type": "string"
27+
},
28+
"mapping": {
29+
"type": "object",
30+
"additionalProperties": {
31+
"type": "string"
32+
}
33+
}
34+
},
35+
"required": ["propertyName"],
36+
"unevaluatedProperties": false
37+
},
38+
"external-docs": {
39+
"$ref": "#/$defs/extensible",
40+
"type": "object",
41+
"properties": {
42+
"url": {
43+
"type": "string",
44+
"format": "uri-reference"
45+
},
46+
"description": {
47+
"type": "string"
48+
}
49+
},
50+
"required": ["url"],
51+
"unevaluatedProperties": false
52+
},
53+
"xml": {
54+
"$ref": "#/$defs/extensible",
55+
"type": "object",
56+
"properties": {
57+
"name": {
58+
"type": "string"
59+
},
60+
"namespace": {
61+
"type": "string",
62+
"format": "uri"
63+
},
64+
"prefix": {
65+
"type": "string"
66+
},
67+
"attribute": {
68+
"type": "boolean"
69+
},
70+
"wrapped": {
71+
"type": "boolean"
72+
}
73+
},
74+
"unevaluatedProperties": false
75+
}
76+
}
77+
};

0 commit comments

Comments
 (0)