@mittwald/api-code-generator 0.0.0-development-860f5d6-20261009 → 0.0.0-development-fd38fc6-20261012

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.
Files changed (30) hide show
  1. package/dist/esm/generation/compileJsonSchema.test.js +32 -0
  2. package/dist/esm/generation/format.test.js +46 -0
  3. package/dist/esm/generation/model/CodeGenerationModel.test.js +155 -0
  4. package/dist/esm/generation/model/__fixtures__/petstore.js +152 -0
  5. package/dist/esm/generation/model/tags/Tag.test.js +13 -0
  6. package/dist/esm/generation/populateNullableTypes.test.js +153 -0
  7. package/dist/esm/generation/refs/assertNoRefs.test.js +10 -0
  8. package/dist/esm/generation/refs/extractComponentRefName.test.js +19 -0
  9. package/dist/esm/generation/refs/isRef.test.js +19 -0
  10. package/dist/esm/generation/tsNamespaceName.test.js +13 -0
  11. package/dist/esm/lib/makeError.test.js +36 -0
  12. package/dist/esm/lib/relativePath.test.js +16 -0
  13. package/dist/esm/lib/writeIfChangedAsync.test.js +37 -0
  14. package/dist/esm/loading/UniversalFileLoader.test.js +50 -0
  15. package/dist/esm/openapi/OpenAPISchemaValidationError.test.js +27 -0
  16. package/dist/esm/openapi/OpenApiSpec.test.js +66 -0
  17. package/dist/types/generation/format.test.d.ts +1 -0
  18. package/dist/types/generation/model/__fixtures__/petstore.d.ts +12 -0
  19. package/dist/types/generation/model/tags/Tag.test.d.ts +1 -0
  20. package/dist/types/generation/populateNullableTypes.test.d.ts +1 -0
  21. package/dist/types/generation/refs/assertNoRefs.test.d.ts +1 -0
  22. package/dist/types/generation/refs/extractComponentRefName.test.d.ts +1 -0
  23. package/dist/types/generation/refs/isRef.test.d.ts +1 -0
  24. package/dist/types/generation/tsNamespaceName.test.d.ts +1 -0
  25. package/dist/types/lib/makeError.test.d.ts +1 -0
  26. package/dist/types/lib/relativePath.test.d.ts +1 -0
  27. package/dist/types/lib/writeIfChangedAsync.test.d.ts +1 -0
  28. package/dist/types/loading/UniversalFileLoader.test.d.ts +1 -0
  29. package/dist/types/openapi/OpenAPISchemaValidationError.test.d.ts +1 -0
  30. package/package.json +4 -3
@@ -64,3 +64,35 @@ test("const-tagged members compile to a discriminated union", async () => {
64
64
  };
65
65
  `));
66
66
  });
67
+ test("compiles an object schema into an interface", async () => {
68
+ const output = await compileJsonSchema({
69
+ type: "object",
70
+ required: ["id"],
71
+ properties: { id: { type: "string" }, name: { type: "string" } },
72
+ }, "Pet");
73
+ expect(output).toContain("export interface Pet {");
74
+ expect(output).toContain("id: string;");
75
+ expect(output).toContain("name?: string;");
76
+ });
77
+ test("emits no banner comment", async () => {
78
+ const output = await compileJsonSchema({ type: "string" }, "Name");
79
+ expect(output).not.toContain("/* eslint-disable */");
80
+ expect(output.trimStart().startsWith("export")).toBe(true);
81
+ });
82
+ test("disallows additional properties by default", async () => {
83
+ const output = await compileJsonSchema({ type: "object", properties: { id: { type: "string" } } }, "Pet");
84
+ expect(output).not.toContain("[k: string]: unknown");
85
+ });
86
+ test("lets callers override the defaults", async () => {
87
+ const output = await compileJsonSchema({ type: "object", properties: { id: { type: "string" } } }, "Pet", { additionalProperties: true });
88
+ expect(output).toContain("[k: string]: unknown");
89
+ });
90
+ test("compiles a scalar schema into a type alias", async () => {
91
+ const output = await compileJsonSchema({ type: "string" }, "Name");
92
+ expect(output).toContain("export type Name = string;");
93
+ });
94
+ test("compiles an enum into a union", async () => {
95
+ const output = await compileJsonSchema({ type: "string", enum: ["asc", "desc"] }, "Order");
96
+ expect(output).toContain('"asc"');
97
+ expect(output).toContain('"desc"');
98
+ });
@@ -0,0 +1,46 @@
1
+ import { format } from "./format.js";
2
+ import { prepareTypeScriptOutput } from "./prepareTypeScriptOutput.js";
3
+ describe("format", () => {
4
+ test("formats TypeScript source", async () => {
5
+ await expect(format("const a={b:1}")).resolves.toBe("const a = { b: 1 };\n");
6
+ });
7
+ test("keeps already formatted source stable", async () => {
8
+ const formatted = await format("const a = 1;\n");
9
+ expect(await format(formatted)).toBe(formatted);
10
+ });
11
+ test("formats type declarations", async () => {
12
+ await expect(format("type A={b:string}")).resolves.toBe("type A = { b: string };\n");
13
+ });
14
+ test("reports a syntax error as a CodeFormattingError", async () => {
15
+ await expect(format("const a = {")).rejects.toMatchObject({
16
+ name: "CodeFormattingError",
17
+ });
18
+ });
19
+ test("points at the likely cause in the error message", async () => {
20
+ await expect(format("const a = {")).rejects.toThrow("Failed to format the generated code");
21
+ });
22
+ test("keeps the underlying prettier error as cause", async () => {
23
+ const error = await format("const a = {").catch((e) => e);
24
+ expect(error.cause()).toBeInstanceOf(Error);
25
+ });
26
+ });
27
+ describe("prepareTypeScriptOutput", () => {
28
+ test("prepends the generated-file header", async () => {
29
+ const output = await prepareTypeScriptOutput("const a=1");
30
+ expect(output).toBe([
31
+ "/* eslint-disable */",
32
+ "/* prettier-ignore */",
33
+ "/* This file is auto-generated with acg (@mittwald/api-code-generator) */",
34
+ "const a = 1;",
35
+ "",
36
+ ].join("\n"));
37
+ });
38
+ test("marks the output so lint and prettier skip it", async () => {
39
+ const output = await prepareTypeScriptOutput("const a=1");
40
+ expect(output).toContain("/* eslint-disable */");
41
+ expect(output).toContain("/* prettier-ignore */");
42
+ });
43
+ test("propagates formatting failures", async () => {
44
+ await expect(prepareTypeScriptOutput("const a = {")).rejects.toThrow("Failed to format the generated code");
45
+ });
46
+ });
@@ -1,5 +1,6 @@
1
1
  import { OpenApiSpec } from "../../openapi/OpenApiSpec.js";
2
2
  import { CodeGenerationModel } from "./CodeGenerationModel.js";
3
+ import { makePetstoreDoc } from "./__fixtures__/petstore.js";
3
4
  const normalize = (typeScript) => typeScript.replace(/\s+/g, " ").trim();
4
5
  /**
5
6
  * End-to-end check for the `const` keyword: an OpenAPI document describing a
@@ -64,3 +65,157 @@ test("const keywords survive from the spec into the generated types", async () =
64
65
  | Api.Components.Schemas.DoneEvent;
65
66
  `));
66
67
  });
68
+ const buildModel = (doc = makePetstoreDoc()) => CodeGenerationModel.fromDoc("PetstoreAPI", doc);
69
+ const compile = (doc = makePetstoreDoc()) => buildModel(doc).compileTypes({ rootNamespace: "PetstoreAPI" });
70
+ /** Collapses whitespace so assertions do not depend on generated indentation. */
71
+ const flat = (s) => s.replace(/\s+/g, " ");
72
+ describe("model structure", () => {
73
+ test("names the root namespace", () => {
74
+ expect(buildModel().rootNamespace.tsType).toBe("PetstoreAPI");
75
+ });
76
+ test("keeps the source document", () => {
77
+ const doc = makePetstoreDoc();
78
+ expect(buildModel(doc).doc).toBe(doc);
79
+ });
80
+ test("builds a Tag per documented tag", () => {
81
+ const { tags } = buildModel();
82
+ expect(tags.map((t) => t.name.raw)).toEqual(["pet", "store"]);
83
+ expect(tags[0].description).toBe("Everything about pets");
84
+ expect(tags[1].description).toBeUndefined();
85
+ });
86
+ test("tolerates a document without tags", () => {
87
+ const model = buildModel({ ...makePetstoreDoc(), tags: undefined });
88
+ expect(model.tags).toEqual([]);
89
+ });
90
+ });
91
+ describe("compiled types", () => {
92
+ let types;
93
+ beforeAll(async () => {
94
+ types = await compile();
95
+ });
96
+ test("wraps everything in the root namespace", () => {
97
+ expect(types).toContain("export declare module PetstoreAPI {");
98
+ });
99
+ test("imports the descriptors and the commons helper types", () => {
100
+ expect(types).toContain('import * as descriptors from "./descriptors.js"');
101
+ expect(flat(types)).toContain('InferredRequestData, InferredResponseData, HttpStatus } from "@mittwald/api-client-commons"');
102
+ });
103
+ test("declares an Operations namespace entry per operation", () => {
104
+ for (const operation of ["ListPets", "CreatePet", "GetPet", "DeletePet"]) {
105
+ expect(types).toContain(`namespace ${operation} {`);
106
+ }
107
+ });
108
+ test("wires operation request and response data to the descriptors", () => {
109
+ expect(flat(types)).toContain("type RequestData = InferredRequestData<typeof descriptors.listPets>;");
110
+ expect(flat(types)).toContain("type ResponseData<TStatus extends HttpStatus = 200> = InferredResponseData<typeof descriptors.listPets, TStatus>;");
111
+ });
112
+ test("compiles component schemas into interfaces", () => {
113
+ expect(flat(types)).toContain("export interface Pet { id: string; name: string; nickname?: string | null; tags?: PetstoreAPI.Components.Schemas.Tag[]; }");
114
+ });
115
+ test("turns schema refs into fully namespaced type references", () => {
116
+ expect(types).toContain("PetstoreAPI.Components.Schemas.Tag[]");
117
+ });
118
+ test("marks a nullable property as nullable", () => {
119
+ expect(flat(types)).toContain("nickname?: string | null;");
120
+ });
121
+ test("keeps required properties required", () => {
122
+ expect(flat(types)).toContain("export interface Tag { label: string; }");
123
+ });
124
+ test("compiles component parameters", () => {
125
+ expect(flat(types)).toContain("export type PetId = string;");
126
+ });
127
+ test("compiles component responses through to the referenced schema", () => {
128
+ expect(flat(types)).toContain("export type ApplicationJson = PetstoreAPI.Components.Schemas.Error;");
129
+ });
130
+ test("compiles the security scheme into a required header", () => {
131
+ expect(flat(types)).toContain('export interface AccessToken { "x-access-token": string; }');
132
+ });
133
+ test("derives a path namespace name from the path template", () => {
134
+ expect(types).toContain("namespace Pets {");
135
+ expect(types).toContain("namespace PetsPetId {");
136
+ });
137
+ test("declares a namespace per HTTP method of a path", () => {
138
+ expect(types).toContain("namespace Get {");
139
+ expect(types).toContain("namespace Post {");
140
+ expect(types).toContain("namespace Delete {");
141
+ });
142
+ test("splits operation parameters by location", () => {
143
+ expect(flat(types)).toContain("export type Query = { limit?: number; };");
144
+ expect(flat(types)).toContain('export type Header = { "x-access-token": string; };');
145
+ expect(flat(types)).toContain("export type Path = { petId: PetstoreAPI.Components.Parameters.PetId; };");
146
+ });
147
+ test("emits an empty parameter type where a location has no parameters", () => {
148
+ expect(flat(types)).toContain("export type Path = {};");
149
+ });
150
+ test("declares a namespace per response status", () => {
151
+ expect(types).toContain("namespace $200 {");
152
+ expect(types).toContain("namespace $201 {");
153
+ expect(types).toContain("namespace $204 {");
154
+ expect(types).toContain("namespace Default {");
155
+ });
156
+ test("types an inline array response through to the item schema", () => {
157
+ expect(flat(types)).toContain("export type ApplicationJson = PetstoreAPI.Components.Schemas.Pet[];");
158
+ });
159
+ test("resolves a referenced request body to its component type", () => {
160
+ expect(flat(types)).toContain("export type RequestBody = PetstoreAPI.Components.RequestBodies.PetBody;");
161
+ });
162
+ /**
163
+ * `ResponseContentTypes.buildContentTypesFromReferenceObject` passes
164
+ * `c.schema` — a `JSONSchema` _model instance_ — into `ResponseContent`,
165
+ * whose parameter is a raw JSON schema object. The instance is then wrapped
166
+ * in another `JSONSchema`, so nothing usable reaches
167
+ * `json-schema-to-typescript` and it falls back to an index signature.
168
+ * `c.schema.schemaObject` is what it should pass.
169
+ *
170
+ * Every error response in the real mittwald spec is a `$ref` to a component
171
+ * response, so every error payload in the published client is untyped even
172
+ * though `Components.Responses.*.ApplicationJson` is generated correctly.
173
+ */
174
+ test("loses the type of a response that references a component response", () => {
175
+ expect(flat(types)).toContain("export interface ApplicationJson { [k: string]: unknown; }");
176
+ expect(flat(types)).not.toContain("export type ApplicationJson = PetstoreAPI.Components.Responses.NotFound.ApplicationJson;");
177
+ });
178
+ /** Same root cause, reached through the request body component. */
179
+ test("loses the type of a request body component that references a schema", () => {
180
+ expect(flat(types)).toContain("export interface PetBody { [k: string]: unknown; }");
181
+ });
182
+ });
183
+ describe("compiled descriptors", () => {
184
+ let descriptors;
185
+ beforeAll(() => {
186
+ descriptors = buildModel().paths.compileDescriptors();
187
+ });
188
+ test("exports a descriptor per operation", () => {
189
+ for (const operationId of [
190
+ "listPets",
191
+ "createPet",
192
+ "getPet",
193
+ "deletePet",
194
+ ]) {
195
+ expect(descriptors).toContain(operationId);
196
+ }
197
+ });
198
+ test("records the path and method of each operation", () => {
199
+ expect(flat(descriptors)).toContain('path: "/pets/{petId}"');
200
+ expect(flat(descriptors)).toContain('method: "DELETE"');
201
+ expect(flat(descriptors)).toContain('method: "GET"');
202
+ expect(flat(descriptors)).toContain('method: "POST"');
203
+ });
204
+ test("records the response statuses and media types", () => {
205
+ expect(flat(descriptors)).toContain('"application/json"');
206
+ expect(flat(descriptors)).toContain('"default"');
207
+ });
208
+ });
209
+ describe("optional headers", () => {
210
+ test("makes a named header optional in the generated parameter type", async () => {
211
+ const types = await buildModel().compileTypes({
212
+ rootNamespace: "PetstoreAPI",
213
+ optionalHeaders: ["x-access-token"],
214
+ });
215
+ expect(flat(types)).toContain('export type Header = { "x-access-token"?: string; };');
216
+ });
217
+ test("leaves the header required when it is not listed", async () => {
218
+ const types = await compile();
219
+ expect(flat(types)).toContain('export type Header = { "x-access-token": string; };');
220
+ });
221
+ });
@@ -0,0 +1,152 @@
1
+ /**
2
+ * A deliberately small but structurally complete OpenAPI v3 document: refs
3
+ * between components, both safe and unsafe operations, path/query/header
4
+ * parameters, several response statuses, and a security scheme. It exercises
5
+ * the code generation model without the bulk of a real spec.
6
+ *
7
+ * Returned fresh on every call: building a `CodeGenerationModel` runs
8
+ * `populateNullableTypes` over the document and mutates it in place, so a
9
+ * shared instance would accumulate changes between tests.
10
+ */
11
+ export const makePetstoreDoc = () => ({
12
+ openapi: "3.0.3",
13
+ info: { title: "Petstore", version: "1.0.0" },
14
+ tags: [
15
+ { name: "pet", description: "Everything about pets" },
16
+ { name: "store" },
17
+ ],
18
+ components: {
19
+ securitySchemes: {
20
+ accessToken: {
21
+ type: "apiKey",
22
+ in: "header",
23
+ name: "x-access-token",
24
+ },
25
+ },
26
+ schemas: {
27
+ Pet: {
28
+ type: "object",
29
+ required: ["id", "name"],
30
+ properties: {
31
+ id: { type: "string" },
32
+ name: { type: "string" },
33
+ nickname: { type: "string", nullable: true },
34
+ tags: { type: "array", items: { $ref: "#/components/schemas/Tag" } },
35
+ },
36
+ },
37
+ Tag: {
38
+ type: "object",
39
+ required: ["label"],
40
+ properties: { label: { type: "string" } },
41
+ },
42
+ Error: {
43
+ type: "object",
44
+ required: ["message"],
45
+ properties: { message: { type: "string" } },
46
+ },
47
+ },
48
+ parameters: {
49
+ PetId: {
50
+ name: "petId",
51
+ in: "path",
52
+ required: true,
53
+ schema: { type: "string" },
54
+ },
55
+ },
56
+ requestBodies: {
57
+ PetBody: {
58
+ required: true,
59
+ content: {
60
+ "application/json": { schema: { $ref: "#/components/schemas/Pet" } },
61
+ },
62
+ },
63
+ },
64
+ responses: {
65
+ NotFound: {
66
+ description: "Not found",
67
+ content: {
68
+ "application/json": {
69
+ schema: { $ref: "#/components/schemas/Error" },
70
+ },
71
+ },
72
+ },
73
+ },
74
+ },
75
+ paths: {
76
+ "/pets": {
77
+ get: {
78
+ operationId: "listPets",
79
+ tags: ["pet"],
80
+ parameters: [
81
+ {
82
+ name: "limit",
83
+ in: "query",
84
+ required: false,
85
+ schema: { type: "integer" },
86
+ },
87
+ {
88
+ name: "x-access-token",
89
+ in: "header",
90
+ required: true,
91
+ schema: { type: "string" },
92
+ },
93
+ ],
94
+ responses: {
95
+ "200": {
96
+ description: "A list of pets",
97
+ content: {
98
+ "application/json": {
99
+ schema: {
100
+ type: "array",
101
+ items: { $ref: "#/components/schemas/Pet" },
102
+ },
103
+ },
104
+ },
105
+ },
106
+ default: { $ref: "#/components/responses/NotFound" },
107
+ },
108
+ },
109
+ post: {
110
+ operationId: "createPet",
111
+ tags: ["pet"],
112
+ requestBody: { $ref: "#/components/requestBodies/PetBody" },
113
+ responses: {
114
+ "201": {
115
+ description: "The created pet",
116
+ content: {
117
+ "application/json": {
118
+ schema: { $ref: "#/components/schemas/Pet" },
119
+ },
120
+ },
121
+ },
122
+ "400": { $ref: "#/components/responses/NotFound" },
123
+ },
124
+ },
125
+ },
126
+ "/pets/{petId}": {
127
+ get: {
128
+ operationId: "getPet",
129
+ tags: ["pet"],
130
+ parameters: [{ $ref: "#/components/parameters/PetId" }],
131
+ responses: {
132
+ "200": {
133
+ description: "The pet",
134
+ content: {
135
+ "application/json": {
136
+ schema: { $ref: "#/components/schemas/Pet" },
137
+ },
138
+ },
139
+ },
140
+ "404": { $ref: "#/components/responses/NotFound" },
141
+ },
142
+ },
143
+ delete: {
144
+ operationId: "deletePet",
145
+ parameters: [{ $ref: "#/components/parameters/PetId" }],
146
+ responses: {
147
+ "204": { description: "Deleted" },
148
+ },
149
+ },
150
+ },
151
+ },
152
+ });
@@ -0,0 +1,13 @@
1
+ import { Tag } from "./Tag.js";
2
+ test("keeps the raw tag name", () => {
3
+ expect(Tag.fromDoc({ name: "pet" }).name.raw).toBe("pet");
4
+ });
5
+ test("derives a TypeScript type name from the tag name", () => {
6
+ expect(Tag.fromDoc({ name: "pet-store" }).name.tsType).toBe("PetStore");
7
+ });
8
+ test("keeps the description when present", () => {
9
+ expect(Tag.fromDoc({ name: "pet", description: "About pets" }).description).toBe("About pets");
10
+ });
11
+ test("leaves the description undefined when absent", () => {
12
+ expect(Tag.fromDoc({ name: "pet" }).description).toBeUndefined();
13
+ });
@@ -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
+ });
@@ -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
+ });
@@ -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,67 @@ 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
+ describe("parsing a valid v3 document", () => {
53
+ test("keeps the document as-is", async () => {
54
+ const doc = makePetstoreDoc();
55
+ const spec = await OpenApiSpec.parse(doc);
56
+ expect(spec.document).toBe(doc);
57
+ });
58
+ test("exposes the document through the instance", async () => {
59
+ const spec = await OpenApiSpec.parse(makePetstoreDoc());
60
+ expect(spec.document.info.title).toBe("Petstore");
61
+ expect(Object.keys(spec.document.paths)).toEqual([
62
+ "/pets",
63
+ "/pets/{petId}",
64
+ ]);
65
+ });
66
+ test("accepts a document constructed directly", () => {
67
+ const doc = makePetstoreDoc();
68
+ expect(new OpenApiSpec(doc).document).toBe(doc);
69
+ });
70
+ });
71
+ describe("validation", () => {
72
+ const invalidDoc = { openapi: "3.0.3", info: { title: "Broken" } };
73
+ test("rejects a document that does not satisfy the OpenAPI schema", async () => {
74
+ await expect(OpenApiSpec.parse(invalidDoc)).rejects.toThrow("Failed parsing OpenAPISpec");
75
+ });
76
+ test("names the failure OpenAPIParserError", async () => {
77
+ await expect(OpenApiSpec.parse(invalidDoc)).rejects.toMatchObject({
78
+ name: "OpenAPIParserError",
79
+ });
80
+ });
81
+ test("keeps the validation error as the cause", async () => {
82
+ const error = await OpenApiSpec.parse(invalidDoc).catch((e) => e);
83
+ expect(error.cause()).toBeInstanceOf(OpenAPISchemaValidationError);
84
+ });
85
+ test("accepts the same document when validation is skipped", async () => {
86
+ const spec = await OpenApiSpec.parse(invalidDoc, { skipValidation: true });
87
+ expect(spec.document.info.title).toBe("Broken");
88
+ });
89
+ });
90
+ describe("swagger 2 conversion", () => {
91
+ const swaggerDoc = {
92
+ swagger: "2.0",
93
+ info: { title: "Legacy", version: "1.0.0" },
94
+ paths: {
95
+ "/pets": {
96
+ get: {
97
+ operationId: "listPets",
98
+ responses: {
99
+ "200": { description: "ok", schema: { type: "array", items: {} } },
100
+ },
101
+ },
102
+ },
103
+ },
104
+ };
105
+ test("converts a swagger 2 document to OpenAPI 3", async () => {
106
+ const spec = await OpenApiSpec.parse(swaggerDoc);
107
+ expect(spec.document.openapi).toMatch(/^3\./);
108
+ expect(spec.document.paths["/pets"]?.get?.operationId).toBe("listPets");
109
+ });
110
+ });
111
+ describe("malformed input", () => {
112
+ test("rejects a document without a version field", async () => {
113
+ await expect(OpenApiSpec.parse({ info: {} })).rejects.toThrow("Failed parsing OpenAPISpec");
114
+ });
115
+ });
@@ -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 {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mittwald/api-code-generator",
3
- "version": "0.0.0-development-860f5d6-20261009",
3
+ "version": "0.0.0-development-fd38fc6-20261012",
4
4
  "type": "module",
5
5
  "repository": "https://github.com/mittwald/api-client-js.git",
6
6
  "license": "MIT",
@@ -24,7 +24,8 @@
24
24
  "lint": "run eslint .",
25
25
  "test": "",
26
26
  "test:compile": "run tsc --noEmit",
27
- "test:unit": "node --experimental-vm-modules $(yarn bin jest)"
27
+ "test:unit": "node --experimental-vm-modules $(yarn bin jest)",
28
+ "test:unit:coverage": "run test:unit --coverage"
28
29
  },
29
30
  "dependencies": {
30
31
  "@oclif/core": "^3.27.0",
@@ -84,5 +85,5 @@
84
85
  "@oclif/plugin-plugins"
85
86
  ]
86
87
  },
87
- "gitHead": "f97d7936d6b673d95cc2574c929c71a29b7db732"
88
+ "gitHead": "b4a8cf280ff804dee2c67e6c5919020cd0067c59"
88
89
  }