@mittwald/api-code-generator 0.0.0-development-860f5d6-20261009 → 0.0.0-development-172be3a-20261013
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/esm/generation/compileJsonSchema.test.js +32 -0
- package/dist/esm/generation/deprecation.js +40 -0
- package/dist/esm/generation/deprecation.test.js +43 -0
- package/dist/esm/generation/format.test.js +46 -0
- package/dist/esm/generation/model/CodeGenerationModel.test.js +244 -0
- package/dist/esm/generation/model/__fixtures__/petstore.js +152 -0
- package/dist/esm/generation/model/components/Parameters.js +7 -1
- package/dist/esm/generation/model/global/JSONSchema.js +9 -2
- package/dist/esm/generation/model/paths/operation/RequestParameters.js +4 -1
- package/dist/esm/generation/model/tags/Tag.test.js +13 -0
- package/dist/esm/generation/populateNullableTypes.test.js +153 -0
- package/dist/esm/generation/refs/assertNoRefs.test.js +10 -0
- package/dist/esm/generation/refs/componentRefsToCustomTypes.js +14 -0
- package/dist/esm/generation/refs/componentRefsToCustomTypes.test.js +11 -0
- package/dist/esm/generation/refs/extractComponentRefName.test.js +19 -0
- package/dist/esm/generation/refs/isRef.test.js +19 -0
- package/dist/esm/generation/tsNamespaceName.test.js +13 -0
- package/dist/esm/lib/makeError.test.js +36 -0
- package/dist/esm/lib/relativePath.test.js +16 -0
- package/dist/esm/lib/writeIfChangedAsync.test.js +37 -0
- package/dist/esm/loading/UniversalContentLoader.js +4 -2
- package/dist/esm/loading/UniversalFileLoader.test.js +50 -0
- package/dist/esm/openapi/OpenAPISchemaValidationError.test.js +27 -0
- package/dist/esm/openapi/OpenApiSpec.test.js +148 -0
- package/dist/types/generation/deprecation.d.ts +27 -0
- package/dist/types/generation/deprecation.test.d.ts +1 -0
- package/dist/types/generation/format.test.d.ts +1 -0
- package/dist/types/generation/model/__fixtures__/petstore.d.ts +12 -0
- package/dist/types/generation/model/tags/Tag.test.d.ts +1 -0
- package/dist/types/generation/populateNullableTypes.test.d.ts +1 -0
- package/dist/types/generation/refs/assertNoRefs.test.d.ts +1 -0
- package/dist/types/generation/refs/extractComponentRefName.test.d.ts +1 -0
- package/dist/types/generation/refs/isRef.test.d.ts +1 -0
- package/dist/types/generation/tsNamespaceName.test.d.ts +1 -0
- package/dist/types/lib/makeError.test.d.ts +1 -0
- package/dist/types/lib/relativePath.test.d.ts +1 -0
- package/dist/types/lib/writeIfChangedAsync.test.d.ts +1 -0
- package/dist/types/loading/UniversalFileLoader.test.d.ts +1 -0
- package/dist/types/openapi/OpenAPISchemaValidationError.test.d.ts +1 -0
- package/package.json +12 -10
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { populateNullableTypes } from "./populateNullableTypes.js";
|
|
2
|
+
const populate = (schema) => populateNullableTypes(schema);
|
|
3
|
+
describe("non-nullable schemas", () => {
|
|
4
|
+
test("are returned unchanged", () => {
|
|
5
|
+
expect(populate({ type: "string" })).toEqual({ type: "string" });
|
|
6
|
+
});
|
|
7
|
+
test("are returned unchanged when nullable is false", () => {
|
|
8
|
+
expect(populate({ type: "string", nullable: false })).toEqual({
|
|
9
|
+
type: "string",
|
|
10
|
+
nullable: false,
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
describe("nullable schemas", () => {
|
|
15
|
+
test("are wrapped in an anyOf with a null branch", () => {
|
|
16
|
+
expect(populate({ type: "string", nullable: true })).toEqual({
|
|
17
|
+
anyOf: [{ type: "string", nullable: true }, { type: "null" }],
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
test("keep sibling keywords inside the anyOf branch", () => {
|
|
21
|
+
expect(populate({ type: "string", nullable: true, maxLength: 5 })).toEqual({
|
|
22
|
+
anyOf: [
|
|
23
|
+
{ type: "string", nullable: true, maxLength: 5 },
|
|
24
|
+
{ type: "null" },
|
|
25
|
+
],
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
describe("references", () => {
|
|
30
|
+
test("are returned unchanged", () => {
|
|
31
|
+
const ref = { $ref: "#/components/schemas/Project" };
|
|
32
|
+
expect(populateNullableTypes(ref)).toBe(ref);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
describe("nested structures", () => {
|
|
36
|
+
test("nullable object properties are wrapped", () => {
|
|
37
|
+
const result = populate({
|
|
38
|
+
type: "object",
|
|
39
|
+
properties: {
|
|
40
|
+
name: { type: "string" },
|
|
41
|
+
description: { type: "string", nullable: true },
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
expect(result.properties?.name).toEqual({ type: "string" });
|
|
45
|
+
expect(result.properties?.description).toEqual({
|
|
46
|
+
anyOf: [{ type: "string", nullable: true }, { type: "null" }],
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
test("nullable array items are wrapped", () => {
|
|
50
|
+
const result = populate({
|
|
51
|
+
type: "array",
|
|
52
|
+
items: { type: "string", nullable: true },
|
|
53
|
+
});
|
|
54
|
+
expect(result).toEqual({
|
|
55
|
+
type: "array",
|
|
56
|
+
items: { anyOf: [{ type: "string", nullable: true }, { type: "null" }] },
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
test("recurses into deeply nested properties", () => {
|
|
60
|
+
const result = populate({
|
|
61
|
+
type: "object",
|
|
62
|
+
properties: {
|
|
63
|
+
outer: {
|
|
64
|
+
type: "object",
|
|
65
|
+
properties: { inner: { type: "number", nullable: true } },
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
const outer = result.properties?.outer;
|
|
70
|
+
expect(outer.properties?.inner).toEqual({
|
|
71
|
+
anyOf: [{ type: "number", nullable: true }, { type: "null" }],
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
test.each(["allOf", "anyOf", "oneOf"])("recurses into %s branches", (key) => {
|
|
75
|
+
const result = populate({
|
|
76
|
+
[key]: [{ type: "string", nullable: true }, { type: "number" }],
|
|
77
|
+
});
|
|
78
|
+
expect(result[key]).toEqual([
|
|
79
|
+
{ anyOf: [{ type: "string", nullable: true }, { type: "null" }] },
|
|
80
|
+
{ type: "number" },
|
|
81
|
+
]);
|
|
82
|
+
});
|
|
83
|
+
test("leaves references inside properties untouched", () => {
|
|
84
|
+
const result = populate({
|
|
85
|
+
type: "object",
|
|
86
|
+
properties: { project: { $ref: "#/components/schemas/Project" } },
|
|
87
|
+
});
|
|
88
|
+
expect(result.properties?.project).toEqual({
|
|
89
|
+
$ref: "#/components/schemas/Project",
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
test("wraps a nullable schema that also has nullable properties", () => {
|
|
93
|
+
const result = populate({
|
|
94
|
+
type: "object",
|
|
95
|
+
nullable: true,
|
|
96
|
+
properties: { name: { type: "string", nullable: true } },
|
|
97
|
+
});
|
|
98
|
+
const branch = result.anyOf?.[0];
|
|
99
|
+
expect(result.anyOf?.[1]).toEqual({ type: "null" });
|
|
100
|
+
expect(branch.properties?.name).toEqual({
|
|
101
|
+
anyOf: [{ type: "string", nullable: true }, { type: "null" }],
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
describe("mutation of the input", () => {
|
|
106
|
+
/**
|
|
107
|
+
* The function rewrites `properties`, `items` and the composition keys on the
|
|
108
|
+
* schema it was given rather than on a copy. Callers that keep the original
|
|
109
|
+
* document around — or that build a second model from it — see the rewritten
|
|
110
|
+
* version.
|
|
111
|
+
*/
|
|
112
|
+
test("rewrites nested properties on the given schema, not a copy", () => {
|
|
113
|
+
const schema = {
|
|
114
|
+
type: "object",
|
|
115
|
+
properties: { name: { type: "string", nullable: true } },
|
|
116
|
+
};
|
|
117
|
+
populate(schema);
|
|
118
|
+
expect(schema.properties?.name).toEqual({
|
|
119
|
+
anyOf: [{ type: "string", nullable: true }, { type: "null" }],
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
/**
|
|
123
|
+
* Because the wrapped branch keeps its `nullable: true`, applying the
|
|
124
|
+
* function twice to the same object nests another `anyOf`. A schema visited n
|
|
125
|
+
* times therefore compiles to n nested `| null` unions — which is why a
|
|
126
|
+
* shared OpenAPI document must not be fed to two models.
|
|
127
|
+
*/
|
|
128
|
+
test("compounds when applied twice to the same schema", () => {
|
|
129
|
+
const schema = {
|
|
130
|
+
type: "object",
|
|
131
|
+
properties: { name: { type: "string", nullable: true } },
|
|
132
|
+
};
|
|
133
|
+
populate(schema);
|
|
134
|
+
populate(schema);
|
|
135
|
+
expect(schema.properties?.name).toEqual({
|
|
136
|
+
anyOf: [
|
|
137
|
+
{
|
|
138
|
+
anyOf: [{ type: "string", nullable: true }, { type: "null" }],
|
|
139
|
+
},
|
|
140
|
+
{ type: "null" },
|
|
141
|
+
],
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
test("is idempotent for a schema with no nullable members", () => {
|
|
145
|
+
const schema = {
|
|
146
|
+
type: "object",
|
|
147
|
+
properties: { name: { type: "string" } },
|
|
148
|
+
};
|
|
149
|
+
populate(schema);
|
|
150
|
+
populate(schema);
|
|
151
|
+
expect(schema.properties?.name).toEqual({ type: "string" });
|
|
152
|
+
});
|
|
153
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { assertNoRefs } from "./assertNoRefs.js";
|
|
2
|
+
test("passes for a plain schema object", () => {
|
|
3
|
+
expect(() => assertNoRefs({ type: "string" })).not.toThrow();
|
|
4
|
+
});
|
|
5
|
+
test("passes for an empty object", () => {
|
|
6
|
+
expect(() => assertNoRefs({})).not.toThrow();
|
|
7
|
+
});
|
|
8
|
+
test("throws for a reference object", () => {
|
|
9
|
+
expect(() => assertNoRefs({ $ref: "#/components/schemas/Project" })).toThrow("$ref's are not supported here (ref: #/components/schemas/Project)");
|
|
10
|
+
});
|
|
@@ -8,6 +8,19 @@ const getComponentRef = (something) => {
|
|
|
8
8
|
return something.$ref;
|
|
9
9
|
}
|
|
10
10
|
};
|
|
11
|
+
/**
|
|
12
|
+
* A `deprecated` annotation next to a `$ref` is put there by our own code
|
|
13
|
+
* model, not by the input document: the OpenAPI 3.0 meta schema rejects any
|
|
14
|
+
* sibling of a `$ref`, so a document that carries one never reaches this point.
|
|
15
|
+
* `RequestParameters` and `Parameters` copy the flag from a Parameter Object
|
|
16
|
+
* onto the referencing schema, because only the schema is handed to the
|
|
17
|
+
* TypeScript compiler. It describes the referencing property rather than the
|
|
18
|
+
* referenced component, so it has to survive the conversion into a custom
|
|
19
|
+
* `tsType` reference.
|
|
20
|
+
*/
|
|
21
|
+
const getRefSiblingAnnotations = (something) => something["deprecated"] === undefined
|
|
22
|
+
? {}
|
|
23
|
+
: { deprecated: something["deprecated"] };
|
|
11
24
|
export const componentRefsToCustomTypes = (rootNamespace, something, clone = true, resolveTSName = refNameToTSName) => {
|
|
12
25
|
if (clone) {
|
|
13
26
|
something = cloneDeep(something);
|
|
@@ -22,6 +35,7 @@ export const componentRefsToCustomTypes = (rootNamespace, something, clone = tru
|
|
|
22
35
|
if (componentRef !== undefined) {
|
|
23
36
|
// see https://github.com/bcherny/json-schema-to-typescript#custom-schema-properties
|
|
24
37
|
return {
|
|
38
|
+
...getRefSiblingAnnotations(something),
|
|
25
39
|
tsType: resolveTSName(rootNamespace, componentRef),
|
|
26
40
|
type: "object",
|
|
27
41
|
};
|
|
@@ -38,6 +38,17 @@ test.each([
|
|
|
38
38
|
},
|
|
39
39
|
},
|
|
40
40
|
],
|
|
41
|
+
[
|
|
42
|
+
{
|
|
43
|
+
$ref: "#/components/foo/bar",
|
|
44
|
+
deprecated: true,
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
type: "object",
|
|
48
|
+
tsType: "Test.Components.Foo.Bar",
|
|
49
|
+
deprecated: true,
|
|
50
|
+
},
|
|
51
|
+
],
|
|
41
52
|
])("componentRefsToCustomTypes works for test %#", (input, expected) => {
|
|
42
53
|
const result = componentRefsToCustomTypes("test", input);
|
|
43
54
|
expect(result).toMatchObject(expected);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { extractComponentRefName } from "./extractComponentRefName.js";
|
|
2
|
+
test("extracts the name of a schema ref", () => {
|
|
3
|
+
expect(extractComponentRefName("#/components/schemas/Project", "schemas")).toBe("Project");
|
|
4
|
+
});
|
|
5
|
+
test("extracts the name of a response ref", () => {
|
|
6
|
+
expect(extractComponentRefName("#/components/responses/NotFound", "responses")).toBe("NotFound");
|
|
7
|
+
});
|
|
8
|
+
test("only looks at the last two segments", () => {
|
|
9
|
+
expect(extractComponentRefName("#/foo/bar/components/schemas/Project", "schemas")).toBe("Project");
|
|
10
|
+
});
|
|
11
|
+
test("rejects a ref that belongs to a different component type", () => {
|
|
12
|
+
expect(() => extractComponentRefName("#/components/schemas/Project", "responses")).toThrow("Could not extract ref name (component: responses, ref: #/components/schemas/Project)");
|
|
13
|
+
});
|
|
14
|
+
test("rejects a ref without a component segment", () => {
|
|
15
|
+
expect(() => extractComponentRefName("#/Project", "schemas")).toThrow("Could not extract ref name");
|
|
16
|
+
});
|
|
17
|
+
test("rejects an empty ref", () => {
|
|
18
|
+
expect(() => extractComponentRefName("#/", "schemas")).toThrow("Could not extract ref name");
|
|
19
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { isRef } from "./isRef.js";
|
|
2
|
+
test("recognizes a reference object", () => {
|
|
3
|
+
expect(isRef({ $ref: "#/components/schemas/Project" })).toBe(true);
|
|
4
|
+
});
|
|
5
|
+
test("rejects an object without $ref", () => {
|
|
6
|
+
expect(isRef({ type: "string" })).toBe(false);
|
|
7
|
+
});
|
|
8
|
+
test("rejects an empty object", () => {
|
|
9
|
+
expect(isRef({})).toBe(false);
|
|
10
|
+
});
|
|
11
|
+
test("rejects a $ref that is not a string", () => {
|
|
12
|
+
expect(isRef({ $ref: 42 })).toBe(false);
|
|
13
|
+
});
|
|
14
|
+
test("accepts an empty $ref string", () => {
|
|
15
|
+
expect(isRef({ $ref: "" })).toBe(true);
|
|
16
|
+
});
|
|
17
|
+
test("recognizes a schema that carries both $ref and other keys", () => {
|
|
18
|
+
expect(isRef({ $ref: "#/components/schemas/Project", description: "a project" })).toBe(true);
|
|
19
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { tsNamespaceName } from "./tsNamespaceName.js";
|
|
2
|
+
test("joins a single part", () => {
|
|
3
|
+
expect(tsNamespaceName("components")).toBe("Components");
|
|
4
|
+
});
|
|
5
|
+
test("joins multiple parts with dots", () => {
|
|
6
|
+
expect(tsNamespaceName("components", "schemas")).toBe("Components.Schemas");
|
|
7
|
+
});
|
|
8
|
+
test("converts each part to a TypeScript type name", () => {
|
|
9
|
+
expect(tsNamespaceName("my-api", "some_schemas")).toBe("MyApi.SomeSchemas");
|
|
10
|
+
});
|
|
11
|
+
test("returns an empty string for no parts", () => {
|
|
12
|
+
expect(tsNamespaceName()).toBe("");
|
|
13
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { makeError } from "./makeError.js";
|
|
2
|
+
test("passes an Error through unchanged", () => {
|
|
3
|
+
const error = new TypeError("boom");
|
|
4
|
+
expect(makeError(error)).toBe(error);
|
|
5
|
+
});
|
|
6
|
+
test("passes a subclass of Error through unchanged", () => {
|
|
7
|
+
class CustomError extends Error {
|
|
8
|
+
}
|
|
9
|
+
const error = new CustomError("boom");
|
|
10
|
+
expect(makeError(error)).toBe(error);
|
|
11
|
+
});
|
|
12
|
+
test("wraps an error-shaped object, keeping name and message", () => {
|
|
13
|
+
const error = makeError({ name: "HttpError", message: "not found" });
|
|
14
|
+
expect(error).toBeInstanceOf(Error);
|
|
15
|
+
expect(error.name).toBe("HttpError");
|
|
16
|
+
expect(error.message).toBe("not found");
|
|
17
|
+
});
|
|
18
|
+
test("falls back to the Error name when none is given", () => {
|
|
19
|
+
expect(makeError({ message: "boom" }).name).toBe("Error");
|
|
20
|
+
});
|
|
21
|
+
test("falls back to an empty message when none is given", () => {
|
|
22
|
+
expect(makeError({ name: "HttpError" }).message).toBe("");
|
|
23
|
+
});
|
|
24
|
+
/**
|
|
25
|
+
* `dot-prop` returns the value itself when the subject is not an object, so a
|
|
26
|
+
* thrown string ends up as both the name and the message of the wrapper.
|
|
27
|
+
*/
|
|
28
|
+
test("wraps a thrown string, using it as name and message", () => {
|
|
29
|
+
const error = makeError("boom");
|
|
30
|
+
expect(error).toBeInstanceOf(Error);
|
|
31
|
+
expect(error.name).toBe("boom");
|
|
32
|
+
expect(error.message).toBe("boom");
|
|
33
|
+
});
|
|
34
|
+
test("wraps undefined into an Error", () => {
|
|
35
|
+
expect(makeError(undefined)).toBeInstanceOf(Error);
|
|
36
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import * as path from "path";
|
|
2
|
+
import * as url from "url";
|
|
3
|
+
import { relativePath } from "./relativePath.js";
|
|
4
|
+
const meta = { url: url.pathToFileURL("/tmp/project/src/lib/thing.ts").href };
|
|
5
|
+
test("resolves a path relative to the importing module's directory", () => {
|
|
6
|
+
expect(relativePath(meta, "template.txt")).toBe(path.join("/tmp/project/src/lib", "template.txt"));
|
|
7
|
+
});
|
|
8
|
+
test("joins multiple segments", () => {
|
|
9
|
+
expect(relativePath(meta, "templates", "type.ts")).toBe(path.join("/tmp/project/src/lib", "templates", "type.ts"));
|
|
10
|
+
});
|
|
11
|
+
test("returns the module directory, trailing separator included, when no segments are given", () => {
|
|
12
|
+
expect(relativePath(meta)).toBe(`${path.sep}tmp${path.sep}project${path.sep}src${path.sep}lib${path.sep}`);
|
|
13
|
+
});
|
|
14
|
+
test("normalizes parent-directory segments", () => {
|
|
15
|
+
expect(relativePath(meta, "..", "other.ts")).toBe(path.join("/tmp/project/src", "other.ts"));
|
|
16
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { jest } from "@jest/globals";
|
|
2
|
+
import jetpack from "fs-jetpack";
|
|
3
|
+
import { writeIfChangedAsync } from "./writeIfChangedAsync.js";
|
|
4
|
+
const readAsync = jest.spyOn(jetpack, "readAsync");
|
|
5
|
+
const writeAsync = jest.spyOn(jetpack, "writeAsync");
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
readAsync.mockReset();
|
|
8
|
+
writeAsync.mockReset().mockResolvedValue(undefined);
|
|
9
|
+
});
|
|
10
|
+
afterAll(() => {
|
|
11
|
+
jest.restoreAllMocks();
|
|
12
|
+
});
|
|
13
|
+
test("writes when the file does not exist yet", async () => {
|
|
14
|
+
readAsync.mockResolvedValue(undefined);
|
|
15
|
+
await writeIfChangedAsync("out.ts", "content");
|
|
16
|
+
expect(writeAsync).toHaveBeenCalledWith("out.ts", "content");
|
|
17
|
+
});
|
|
18
|
+
test("writes when the content changed", async () => {
|
|
19
|
+
readAsync.mockResolvedValue("old content");
|
|
20
|
+
await writeIfChangedAsync("out.ts", "new content");
|
|
21
|
+
expect(writeAsync).toHaveBeenCalledWith("out.ts", "new content");
|
|
22
|
+
});
|
|
23
|
+
test("does not write when the content is unchanged", async () => {
|
|
24
|
+
readAsync.mockResolvedValue("content");
|
|
25
|
+
await writeIfChangedAsync("out.ts", "content");
|
|
26
|
+
expect(writeAsync).not.toHaveBeenCalled();
|
|
27
|
+
});
|
|
28
|
+
test("treats a trailing-whitespace difference as a change", async () => {
|
|
29
|
+
readAsync.mockResolvedValue("content");
|
|
30
|
+
await writeIfChangedAsync("out.ts", "content\n");
|
|
31
|
+
expect(writeAsync).toHaveBeenCalledWith("out.ts", "content\n");
|
|
32
|
+
});
|
|
33
|
+
test("reads the file it is about to write", async () => {
|
|
34
|
+
readAsync.mockResolvedValue("content");
|
|
35
|
+
await writeIfChangedAsync("some/nested/out.ts", "content");
|
|
36
|
+
expect(readAsync).toHaveBeenCalledWith("some/nested/out.ts");
|
|
37
|
+
});
|
|
@@ -40,12 +40,14 @@ export class UniversalContentLoader {
|
|
|
40
40
|
try {
|
|
41
41
|
return await UniversalContentLoader.parseJson(content);
|
|
42
42
|
}
|
|
43
|
-
catch
|
|
43
|
+
catch {
|
|
44
44
|
try {
|
|
45
45
|
return await UniversalContentLoader.parseYaml(content);
|
|
46
46
|
}
|
|
47
47
|
catch (error) {
|
|
48
|
-
throw new Error("Content is not of supported format JSON/YAML."
|
|
48
|
+
throw new Error("Content is not of supported format JSON/YAML.", {
|
|
49
|
+
cause: error,
|
|
50
|
+
});
|
|
49
51
|
}
|
|
50
52
|
}
|
|
51
53
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { jest } from "@jest/globals";
|
|
2
|
+
import jetpack from "fs-jetpack";
|
|
3
|
+
import nock from "nock";
|
|
4
|
+
import { UniversalFileLoader } from "./UniversalFileLoader.js";
|
|
5
|
+
const readAsync = jest.spyOn(jetpack, "readAsync");
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
readAsync.mockReset();
|
|
8
|
+
});
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
nock.cleanAll();
|
|
11
|
+
});
|
|
12
|
+
afterAll(() => {
|
|
13
|
+
jest.restoreAllMocks();
|
|
14
|
+
});
|
|
15
|
+
describe("source", () => {
|
|
16
|
+
test("is kept as given", () => {
|
|
17
|
+
expect(new UniversalFileLoader("spec.json").source).toBe("spec.json");
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
describe("loading from a file", () => {
|
|
21
|
+
test("returns the file content", async () => {
|
|
22
|
+
readAsync.mockResolvedValue("{}");
|
|
23
|
+
await expect(new UniversalFileLoader("spec.json").load()).resolves.toBe("{}");
|
|
24
|
+
expect(readAsync).toHaveBeenCalledWith("spec.json");
|
|
25
|
+
});
|
|
26
|
+
test("rejects when the file does not exist", async () => {
|
|
27
|
+
readAsync.mockResolvedValue(undefined);
|
|
28
|
+
await expect(new UniversalFileLoader("missing.json").load()).rejects.toThrow("File not found: missing.json");
|
|
29
|
+
});
|
|
30
|
+
test("treats a relative path as a file, not a URL", async () => {
|
|
31
|
+
readAsync.mockResolvedValue("{}");
|
|
32
|
+
await new UniversalFileLoader("./spec/openapi.json").load();
|
|
33
|
+
expect(readAsync).toHaveBeenCalledWith("./spec/openapi.json");
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
describe("loading from a URL", () => {
|
|
37
|
+
test("fetches http sources", async () => {
|
|
38
|
+
nock("http://example.com").get("/openapi.json").reply(200, "{}");
|
|
39
|
+
await expect(new UniversalFileLoader("http://example.com/openapi.json").load()).resolves.toBe("{}");
|
|
40
|
+
expect(readAsync).not.toHaveBeenCalled();
|
|
41
|
+
});
|
|
42
|
+
test("fetches https sources", async () => {
|
|
43
|
+
nock("https://example.com").get("/openapi.json").reply(200, "{}");
|
|
44
|
+
await expect(new UniversalFileLoader("https://example.com/openapi.json").load()).resolves.toBe("{}");
|
|
45
|
+
});
|
|
46
|
+
test("rejects a non-200 response", async () => {
|
|
47
|
+
nock("https://example.com").get("/openapi.json").reply(404, "nope");
|
|
48
|
+
await expect(new UniversalFileLoader("https://example.com/openapi.json").load()).rejects.toThrow();
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { OpenAPISchemaValidationError } from "./OpenAPISchemaValidationError.js";
|
|
2
|
+
const errors = (...items) => items;
|
|
3
|
+
test("prefixes the message so the source of the failure is clear", () => {
|
|
4
|
+
const error = new OpenAPISchemaValidationError(errors({ instancePath: "/paths", message: "is required" }));
|
|
5
|
+
expect(error.message).toContain("OpenAPI is invalid:");
|
|
6
|
+
});
|
|
7
|
+
test("numbers each validation error", () => {
|
|
8
|
+
const error = new OpenAPISchemaValidationError(errors({ instancePath: "/paths", message: "is required" }, { instancePath: "/info", message: "is required" }));
|
|
9
|
+
expect(error.message).toContain("#1");
|
|
10
|
+
expect(error.message).toContain("#2");
|
|
11
|
+
});
|
|
12
|
+
test("dumps each error as YAML so nested details stay readable", () => {
|
|
13
|
+
const error = new OpenAPISchemaValidationError(errors({ instancePath: "/paths", message: "is required" }));
|
|
14
|
+
expect(error.message).toContain("instancePath: /paths");
|
|
15
|
+
expect(error.message).toContain("message: is required");
|
|
16
|
+
});
|
|
17
|
+
test("uses its own name instead of the inherited one", () => {
|
|
18
|
+
expect(new OpenAPISchemaValidationError(errors()).name).toBe("OpenAPISchemaValidationError");
|
|
19
|
+
});
|
|
20
|
+
test("is recognizable via instanceof after the prototype fix-up", () => {
|
|
21
|
+
const error = new OpenAPISchemaValidationError(errors());
|
|
22
|
+
expect(error).toBeInstanceOf(OpenAPISchemaValidationError);
|
|
23
|
+
expect(error).toBeInstanceOf(Error);
|
|
24
|
+
});
|
|
25
|
+
test("handles an empty error list", () => {
|
|
26
|
+
expect(new OpenAPISchemaValidationError(errors()).message).toBe("OpenAPI is invalid: ");
|
|
27
|
+
});
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { OpenApiSpec } from "./OpenApiSpec.js";
|
|
2
|
+
import { OpenAPISchemaValidationError } from "./OpenAPISchemaValidationError.js";
|
|
3
|
+
import { makePetstoreDoc } from "../generation/model/__fixtures__/petstore.js";
|
|
2
4
|
const documentWithSchemas = (schemas) => ({
|
|
3
5
|
openapi: "3.0.3",
|
|
4
6
|
info: { title: "Test", version: "1.0.0" },
|
|
@@ -47,3 +49,149 @@ test("unknown schema keywords are still rejected", async () => {
|
|
|
47
49
|
Broken: { type: "string", definitelyNotAKeyword: true },
|
|
48
50
|
}))).rejects.toThrow(/Failed parsing OpenAPISpec/);
|
|
49
51
|
});
|
|
52
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
53
|
+
const buildDoc = (overrides = {}) => ({
|
|
54
|
+
openapi: "3.0.3",
|
|
55
|
+
info: { title: "Test API", version: "1.0.0" },
|
|
56
|
+
paths: {},
|
|
57
|
+
...overrides,
|
|
58
|
+
});
|
|
59
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
60
|
+
const docWithSchema = (schema) => buildDoc({
|
|
61
|
+
components: {
|
|
62
|
+
schemas: {
|
|
63
|
+
Conversation: schema,
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
68
|
+
const docWithOperation = (operation) => buildDoc({
|
|
69
|
+
paths: {
|
|
70
|
+
"/conversations": {
|
|
71
|
+
get: {
|
|
72
|
+
operationId: "conversation-list",
|
|
73
|
+
responses: { "200": { description: "OK" } },
|
|
74
|
+
...operation,
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
test("a deprecated schema property does not invalidate the spec", async () => {
|
|
80
|
+
const doc = docWithSchema({
|
|
81
|
+
type: "object",
|
|
82
|
+
properties: {
|
|
83
|
+
lastMessageAt: {
|
|
84
|
+
type: "string",
|
|
85
|
+
format: "date-time",
|
|
86
|
+
deprecated: true,
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
await expect(OpenApiSpec.parse(doc)).resolves.toBeInstanceOf(OpenApiSpec);
|
|
91
|
+
});
|
|
92
|
+
test("a deprecated schema object does not invalidate the spec", async () => {
|
|
93
|
+
const doc = docWithSchema({
|
|
94
|
+
type: "object",
|
|
95
|
+
deprecated: true,
|
|
96
|
+
properties: { id: { type: "string" } },
|
|
97
|
+
});
|
|
98
|
+
await expect(OpenApiSpec.parse(doc)).resolves.toBeInstanceOf(OpenApiSpec);
|
|
99
|
+
});
|
|
100
|
+
test("a deprecated operation does not invalidate the spec", async () => {
|
|
101
|
+
const doc = docWithOperation({ deprecated: true });
|
|
102
|
+
await expect(OpenApiSpec.parse(doc)).resolves.toBeInstanceOf(OpenApiSpec);
|
|
103
|
+
});
|
|
104
|
+
test("a deprecated parameter does not invalidate the spec", async () => {
|
|
105
|
+
const doc = docWithOperation({
|
|
106
|
+
parameters: [
|
|
107
|
+
{
|
|
108
|
+
name: "legacyFilter",
|
|
109
|
+
in: "query",
|
|
110
|
+
deprecated: true,
|
|
111
|
+
schema: { type: "string" },
|
|
112
|
+
},
|
|
113
|
+
],
|
|
114
|
+
});
|
|
115
|
+
await expect(OpenApiSpec.parse(doc)).resolves.toBeInstanceOf(OpenApiSpec);
|
|
116
|
+
});
|
|
117
|
+
test("a deprecated response header does not invalidate the spec", async () => {
|
|
118
|
+
const doc = docWithOperation({
|
|
119
|
+
responses: {
|
|
120
|
+
"200": {
|
|
121
|
+
description: "OK",
|
|
122
|
+
headers: {
|
|
123
|
+
"X-Legacy": { deprecated: true, schema: { type: "string" } },
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
await expect(OpenApiSpec.parse(doc)).resolves.toBeInstanceOf(OpenApiSpec);
|
|
129
|
+
});
|
|
130
|
+
test("unknown keywords are still rejected", async () => {
|
|
131
|
+
const doc = docWithOperation({ bogusUnknownKey: true });
|
|
132
|
+
await expect(OpenApiSpec.parse(doc)).rejects.toThrow(/OpenAPI is invalid/);
|
|
133
|
+
});
|
|
134
|
+
describe("parsing a valid v3 document", () => {
|
|
135
|
+
test("keeps the document as-is", async () => {
|
|
136
|
+
const doc = makePetstoreDoc();
|
|
137
|
+
const spec = await OpenApiSpec.parse(doc);
|
|
138
|
+
expect(spec.document).toBe(doc);
|
|
139
|
+
});
|
|
140
|
+
test("exposes the document through the instance", async () => {
|
|
141
|
+
const spec = await OpenApiSpec.parse(makePetstoreDoc());
|
|
142
|
+
expect(spec.document.info.title).toBe("Petstore");
|
|
143
|
+
expect(Object.keys(spec.document.paths)).toEqual([
|
|
144
|
+
"/pets",
|
|
145
|
+
"/pets/{petId}",
|
|
146
|
+
]);
|
|
147
|
+
});
|
|
148
|
+
test("accepts a document constructed directly", () => {
|
|
149
|
+
const doc = makePetstoreDoc();
|
|
150
|
+
expect(new OpenApiSpec(doc).document).toBe(doc);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
describe("validation", () => {
|
|
154
|
+
const invalidDoc = { openapi: "3.0.3", info: { title: "Broken" } };
|
|
155
|
+
test("rejects a document that does not satisfy the OpenAPI schema", async () => {
|
|
156
|
+
await expect(OpenApiSpec.parse(invalidDoc)).rejects.toThrow("Failed parsing OpenAPISpec");
|
|
157
|
+
});
|
|
158
|
+
test("names the failure OpenAPIParserError", async () => {
|
|
159
|
+
await expect(OpenApiSpec.parse(invalidDoc)).rejects.toMatchObject({
|
|
160
|
+
name: "OpenAPIParserError",
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
test("keeps the validation error as the cause", async () => {
|
|
164
|
+
const error = await OpenApiSpec.parse(invalidDoc).catch((e) => e);
|
|
165
|
+
expect(error.cause()).toBeInstanceOf(OpenAPISchemaValidationError);
|
|
166
|
+
});
|
|
167
|
+
test("accepts the same document when validation is skipped", async () => {
|
|
168
|
+
const spec = await OpenApiSpec.parse(invalidDoc, { skipValidation: true });
|
|
169
|
+
expect(spec.document.info.title).toBe("Broken");
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
describe("swagger 2 conversion", () => {
|
|
173
|
+
const swaggerDoc = {
|
|
174
|
+
swagger: "2.0",
|
|
175
|
+
info: { title: "Legacy", version: "1.0.0" },
|
|
176
|
+
paths: {
|
|
177
|
+
"/pets": {
|
|
178
|
+
get: {
|
|
179
|
+
operationId: "listPets",
|
|
180
|
+
responses: {
|
|
181
|
+
"200": { description: "ok", schema: { type: "array", items: {} } },
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
test("converts a swagger 2 document to OpenAPI 3", async () => {
|
|
188
|
+
const spec = await OpenApiSpec.parse(swaggerDoc);
|
|
189
|
+
expect(spec.document.openapi).toMatch(/^3\./);
|
|
190
|
+
expect(spec.document.paths["/pets"]?.get?.operationId).toBe("listPets");
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
describe("malformed input", () => {
|
|
194
|
+
test("rejects a document without a version field", async () => {
|
|
195
|
+
await expect(OpenApiSpec.parse({ info: {} })).rejects.toThrow("Failed parsing OpenAPISpec");
|
|
196
|
+
});
|
|
197
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { JSONSchema as JSONSchemaObject } from "json-schema-to-typescript";
|
|
2
|
+
/**
|
|
3
|
+
* Anything that may carry the OpenAPI/JSON Schema `deprecated` annotation:
|
|
4
|
+
* schema objects, parameter objects, header objects and operation objects.
|
|
5
|
+
*/
|
|
6
|
+
export interface MaybeDeprecated {
|
|
7
|
+
deprecated?: boolean;
|
|
8
|
+
}
|
|
9
|
+
export declare const isDeprecated: (doc: MaybeDeprecated | undefined) => boolean;
|
|
10
|
+
/**
|
|
11
|
+
* Copies a `deprecated: true` annotation from an OpenAPI object onto the JSON
|
|
12
|
+
* schema that is generated for it. Used where the annotation lives on the
|
|
13
|
+
* OpenAPI object (e.g. a Parameter Object) rather than on its `schema`, because
|
|
14
|
+
* only the schema is handed to the TypeScript compiler.
|
|
15
|
+
*/
|
|
16
|
+
export declare const withDeprecation: <T extends JSONSchemaObject>(schema: T, source: MaybeDeprecated | undefined) => T | (T & {
|
|
17
|
+
deprecated: true;
|
|
18
|
+
});
|
|
19
|
+
/**
|
|
20
|
+
* `json-schema-to-typescript` emits an `@deprecated` JSDoc tag for interface
|
|
21
|
+
* members and for standalone interfaces, but drops it for standalone type
|
|
22
|
+
* aliases: its `generateStandaloneType` calls the comment generator without the
|
|
23
|
+
* deprecation flag, so `export type Foo = string` ends up with an empty `/**
|
|
24
|
+
* *\/` block. We add the tag back so every generated declaration carries the
|
|
25
|
+
* deprecation.
|
|
26
|
+
*/
|
|
27
|
+
export declare const ensureDeprecatedTypeAliasComment: (compiled: string, tsTypeName: string) => string;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { OpenAPIV3 } from "openapi-types";
|
|
2
|
+
/**
|
|
3
|
+
* A deliberately small but structurally complete OpenAPI v3 document: refs
|
|
4
|
+
* between components, both safe and unsafe operations, path/query/header
|
|
5
|
+
* parameters, several response statuses, and a security scheme. It exercises
|
|
6
|
+
* the code generation model without the bulk of a real spec.
|
|
7
|
+
*
|
|
8
|
+
* Returned fresh on every call: building a `CodeGenerationModel` runs
|
|
9
|
+
* `populateNullableTypes` over the document and mutates it in place, so a
|
|
10
|
+
* shared instance would accumulate changes between tests.
|
|
11
|
+
*/
|
|
12
|
+
export declare const makePetstoreDoc: () => OpenAPIV3.Document;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|