@mittwald/api-code-generator 0.0.0-development-fd38fc6-20261012 → 0.0.0-development-34bc8ae-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.
@@ -0,0 +1,40 @@
1
+ export const isDeprecated = (doc) => doc?.deprecated === true;
2
+ /**
3
+ * Copies a `deprecated: true` annotation from an OpenAPI object onto the JSON
4
+ * schema that is generated for it. Used where the annotation lives on the
5
+ * OpenAPI object (e.g. a Parameter Object) rather than on its `schema`, because
6
+ * only the schema is handed to the TypeScript compiler.
7
+ */
8
+ export const withDeprecation = (schema, source) => isDeprecated(source) && !isDeprecated(schema)
9
+ ? { ...schema, deprecated: true }
10
+ : schema;
11
+ const jsDocStart = "/**";
12
+ const jsDocEnd = "*/";
13
+ const deprecatedTag = "@deprecated";
14
+ /**
15
+ * `json-schema-to-typescript` emits an `@deprecated` JSDoc tag for interface
16
+ * members and for standalone interfaces, but drops it for standalone type
17
+ * aliases: its `generateStandaloneType` calls the comment generator without the
18
+ * deprecation flag, so `export type Foo = string` ends up with an empty `/**
19
+ * *\/` block. We add the tag back so every generated declaration carries the
20
+ * deprecation.
21
+ */
22
+ export const ensureDeprecatedTypeAliasComment = (compiled, tsTypeName) => {
23
+ const declaration = `export type ${tsTypeName} `;
24
+ const declarationIndex = compiled.indexOf(declaration);
25
+ if (declarationIndex < 0) {
26
+ return compiled;
27
+ }
28
+ const before = compiled.slice(0, declarationIndex);
29
+ const trimmedBefore = before.trimEnd();
30
+ if (!trimmedBefore.endsWith(jsDocEnd)) {
31
+ return `${before}/**\n * ${deprecatedTag}\n */\n${compiled.slice(declarationIndex)}`;
32
+ }
33
+ const commentStart = trimmedBefore.lastIndexOf(jsDocStart);
34
+ if (commentStart < 0 ||
35
+ trimmedBefore.slice(commentStart).includes(deprecatedTag)) {
36
+ return compiled;
37
+ }
38
+ const insertAt = commentStart + jsDocStart.length;
39
+ return `${compiled.slice(0, insertAt)}\n * ${deprecatedTag}${compiled.slice(insertAt)}`;
40
+ };
@@ -0,0 +1,43 @@
1
+ import { ensureDeprecatedTypeAliasComment, isDeprecated, withDeprecation, } from "./deprecation.js";
2
+ test("isDeprecated only reports an explicit true", () => {
3
+ expect(isDeprecated({ deprecated: true })).toBe(true);
4
+ expect(isDeprecated({ deprecated: false })).toBe(false);
5
+ expect(isDeprecated({})).toBe(false);
6
+ expect(isDeprecated(undefined)).toBe(false);
7
+ });
8
+ test("withDeprecation copies the annotation onto the schema", () => {
9
+ const schema = { type: "string" };
10
+ expect(withDeprecation(schema, { deprecated: true })).toEqual({
11
+ type: "string",
12
+ deprecated: true,
13
+ });
14
+ });
15
+ test("withDeprecation leaves the schema untouched when not deprecated", () => {
16
+ const schema = { type: "string" };
17
+ expect(withDeprecation(schema, { deprecated: false })).toBe(schema);
18
+ expect(withDeprecation(schema, undefined)).toBe(schema);
19
+ });
20
+ test("withDeprecation keeps an annotation that is already on the schema", () => {
21
+ const schema = { type: "string", deprecated: true };
22
+ expect(withDeprecation(schema, { deprecated: true })).toBe(schema);
23
+ });
24
+ test("ensureDeprecatedTypeAliasComment adds the tag to an empty comment block", () => {
25
+ const compiled = "/**\n */\nexport type LegacySort = string;\n";
26
+ expect(ensureDeprecatedTypeAliasComment(compiled, "LegacySort")).toBe("/**\n * @deprecated\n */\nexport type LegacySort = string;\n");
27
+ });
28
+ test("ensureDeprecatedTypeAliasComment keeps an existing description", () => {
29
+ const compiled = "/**\n * Old thing\n */\nexport type LegacySort = string;\n";
30
+ expect(ensureDeprecatedTypeAliasComment(compiled, "LegacySort")).toBe("/**\n * @deprecated\n * Old thing\n */\nexport type LegacySort = string;\n");
31
+ });
32
+ test("ensureDeprecatedTypeAliasComment adds a comment block when there is none", () => {
33
+ const compiled = "export type LegacySort = string;\n";
34
+ expect(ensureDeprecatedTypeAliasComment(compiled, "LegacySort")).toBe("/**\n * @deprecated\n */\nexport type LegacySort = string;\n");
35
+ });
36
+ test("ensureDeprecatedTypeAliasComment is idempotent", () => {
37
+ const compiled = "/**\n * @deprecated\n */\nexport type LegacySort = string;\n";
38
+ expect(ensureDeprecatedTypeAliasComment(compiled, "LegacySort")).toBe(compiled);
39
+ });
40
+ test("ensureDeprecatedTypeAliasComment ignores output without a matching alias", () => {
41
+ const compiled = "export interface LegacySort {\n a?: string;\n}\n";
42
+ expect(ensureDeprecatedTypeAliasComment(compiled, "LegacySort")).toBe(compiled);
43
+ });
@@ -65,6 +65,95 @@ test("const keywords survive from the spec into the generated types", async () =
65
65
  | Api.Components.Schemas.DoneEvent;
66
66
  `));
67
67
  });
68
+ const doc = {
69
+ openapi: "3.0.3",
70
+ info: { title: "Test API", version: "1.0.0" },
71
+ paths: {
72
+ "/conversations": {
73
+ get: {
74
+ operationId: "conversation-list",
75
+ tags: ["conversation"],
76
+ parameters: [
77
+ {
78
+ name: "legacyFilter",
79
+ in: "query",
80
+ deprecated: true,
81
+ schema: { type: "string" },
82
+ },
83
+ { $ref: "#/components/parameters/LegacySort" },
84
+ ],
85
+ responses: {
86
+ "200": {
87
+ description: "OK",
88
+ content: {
89
+ "application/json": {
90
+ schema: { $ref: "#/components/schemas/Conversation" },
91
+ },
92
+ },
93
+ },
94
+ },
95
+ },
96
+ },
97
+ },
98
+ components: {
99
+ parameters: {
100
+ LegacySort: {
101
+ name: "legacySort",
102
+ in: "query",
103
+ deprecated: true,
104
+ schema: { type: "string" },
105
+ },
106
+ },
107
+ schemas: {
108
+ Conversation: {
109
+ type: "object",
110
+ properties: {
111
+ lastMessageAt: {
112
+ type: "string",
113
+ format: "date-time",
114
+ deprecated: true,
115
+ },
116
+ },
117
+ },
118
+ LegacyState: {
119
+ type: "string",
120
+ enum: ["enabled", "disabled"],
121
+ deprecated: true,
122
+ },
123
+ },
124
+ },
125
+ };
126
+ const compileTypes = () => CodeGenerationModel.fromDoc("Api", doc).compileTypes({
127
+ rootNamespace: "Api",
128
+ });
129
+ /** Matches an `@deprecated` JSDoc tag directly in front of `declaration`. */
130
+ const deprecatedBefore = (declaration) => new RegExp(`@deprecated[^;{}]*?\\*/\\s*${declaration.replace(/[$?]/g, "\\$&")}`);
131
+ test("a deprecated schema property is emitted with an @deprecated tag", async () => {
132
+ const types = await compileTypes();
133
+ expect(types).toMatch(deprecatedBefore("lastMessageAt?"));
134
+ });
135
+ test("a deprecated standalone type gets an @deprecated tag", async () => {
136
+ const types = await compileTypes();
137
+ expect(types).toMatch(deprecatedBefore("export type LegacyState"));
138
+ });
139
+ test("a deprecated request parameter is emitted with an @deprecated tag", async () => {
140
+ const types = await compileTypes();
141
+ expect(types).toMatch(deprecatedBefore("legacyFilter?"));
142
+ });
143
+ test("a deprecated referenced request parameter is emitted with an @deprecated tag", async () => {
144
+ const types = await compileTypes();
145
+ expect(types).toMatch(deprecatedBefore("legacySort?"));
146
+ });
147
+ /**
148
+ * Component parameters are request-only, so they are compiled through
149
+ * `compileAsRequestInput` rather than `compile`. Both paths have to apply the
150
+ * type alias workaround, otherwise the standalone alias ends up with an empty
151
+ * JSDoc block.
152
+ */
153
+ test("a deprecated component parameter alias gets an @deprecated tag", async () => {
154
+ const types = await compileTypes();
155
+ expect(types).toMatch(deprecatedBefore("export type LegacySort"));
156
+ });
68
157
  const buildModel = (doc = makePetstoreDoc()) => CodeGenerationModel.fromDoc("PetstoreAPI", doc);
69
158
  const compile = (doc = makePetstoreDoc()) => buildModel(doc).compileTypes({ rootNamespace: "PetstoreAPI" });
70
159
  /** Collapses whitespace so assertions do not depend on generated indentation. */
@@ -2,6 +2,7 @@ import { JSONSchema } from "../global/JSONSchema.js";
2
2
  import { Name } from "../global/Name.js";
3
3
  import { asyncStringJoin } from "../../asyncStringJoin.js";
4
4
  import { assertNoRefs } from "../../refs/assertNoRefs.js";
5
+ import { withDeprecation } from "../../deprecation.js";
5
6
  export class Parameters {
6
7
  static ns = "Parameters";
7
8
  components;
@@ -15,7 +16,12 @@ export class Parameters {
15
16
  async compileTypes(opts) {
16
17
  const schemas = Object.entries(this.parameters).map(([name, param]) => {
17
18
  assertNoRefs(param);
18
- return new JSONSchema(new Name(name, this.name), param.schema);
19
+ return new JSONSchema(new Name(name, this.name),
20
+ // `deprecated` lives on the Parameter Object, not on its schema, so it
21
+ // has to be carried over explicitly to end up in the generated JSDoc.
22
+ param.schema === undefined
23
+ ? undefined
24
+ : withDeprecation(param.schema, param));
19
25
  });
20
26
  const t = {
21
27
  ns: Parameters.ns,
@@ -2,6 +2,7 @@ import { compileJsonSchema } from "../../compileJsonSchema.js";
2
2
  import { Name } from "./Name.js";
3
3
  import cloneDeep from "clone-deep";
4
4
  import { componentRefsToCustomTypes } from "../../refs/componentRefsToCustomTypes.js";
5
+ import { ensureDeprecatedTypeAliasComment, isDeprecated, } from "../../deprecation.js";
5
6
  import { widenDateTimeInputs } from "../../dateTime/dateTimeInput.js";
6
7
  import { dateTimeInputRefTSNameResolver } from "../../dateTime/dateTimeInputRefs.js";
7
8
  export class JSONSchema {
@@ -13,7 +14,10 @@ export class JSONSchema {
13
14
  }
14
15
  async compile(opts) {
15
16
  const withCustomRefTypes = componentRefsToCustomTypes(opts.rootNamespace, this.schemaObject);
16
- return compileJsonSchema(withCustomRefTypes, this.name.tsType);
17
+ const compiled = await compileJsonSchema(withCustomRefTypes, this.name.tsType);
18
+ return isDeprecated(this.schemaObject)
19
+ ? ensureDeprecatedTypeAliasComment(compiled, this.name.tsType)
20
+ : compiled;
17
21
  }
18
22
  /**
19
23
  * Compiles the schema for usage in _request_ position.
@@ -29,7 +33,10 @@ export class JSONSchema {
29
33
  async compileAsRequestInput(opts, dateTimeInputSchemaNames) {
30
34
  const widened = widenDateTimeInputs(this.schemaObject);
31
35
  const withCustomRefTypes = componentRefsToCustomTypes(opts.rootNamespace, widened, false, dateTimeInputRefTSNameResolver(dateTimeInputSchemaNames));
32
- return compileJsonSchema(withCustomRefTypes, this.name.tsType);
36
+ const compiled = await compileJsonSchema(withCustomRefTypes, this.name.tsType);
37
+ return isDeprecated(this.schemaObject)
38
+ ? ensureDeprecatedTypeAliasComment(compiled, this.name.tsType)
39
+ : compiled;
33
40
  }
34
41
  clone() {
35
42
  return new JSONSchema(new Name(this.name.raw), cloneDeep(this.schemaObject));
@@ -1,6 +1,7 @@
1
1
  import { JSONSchema } from "../../global/JSONSchema.js";
2
2
  import { Name } from "../../global/Name.js";
3
3
  import { isRef } from "../../../refs/isRef.js";
4
+ import { withDeprecation } from "../../../deprecation.js";
4
5
  import invariant from "invariant";
5
6
  export class RequestParameters {
6
7
  static ns = "Parameters";
@@ -26,7 +27,9 @@ export class RequestParameters {
26
27
  ? parameter
27
28
  : parameter.schema;
28
29
  invariant(!!jsonSchemaObject, `Could not find schema for request parameter ${name.raw}`);
29
- properties[resolvedParameter.name] = jsonSchemaObject;
30
+ // `deprecated` lives on the Parameter Object, not on its schema, so it
31
+ // has to be carried over explicitly to end up in the generated JSDoc.
32
+ properties[resolvedParameter.name] = withDeprecation(jsonSchemaObject, resolvedParameter);
30
33
  if (resolvedParameter.required) {
31
34
  required.push(resolvedParameter.name);
32
35
  }
@@ -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);
@@ -40,12 +40,14 @@ export class UniversalContentLoader {
40
40
  try {
41
41
  return await UniversalContentLoader.parseJson(content);
42
42
  }
43
- catch (error) {
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
  }
@@ -49,6 +49,88 @@ test("unknown schema keywords are still rejected", async () => {
49
49
  Broken: { type: "string", definitelyNotAKeyword: true },
50
50
  }))).rejects.toThrow(/Failed parsing OpenAPISpec/);
51
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
+ });
52
134
  describe("parsing a valid v3 document", () => {
53
135
  test("keeps the document as-is", async () => {
54
136
  const doc = makePetstoreDoc();
@@ -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 {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mittwald/api-code-generator",
3
- "version": "0.0.0-development-fd38fc6-20261012",
3
+ "version": "0.0.0-development-34bc8ae-20261013",
4
4
  "type": "module",
5
5
  "repository": "https://github.com/mittwald/api-client-js.git",
6
6
  "license": "MIT",
@@ -58,15 +58,15 @@
58
58
  "zod-validation-error": "^3.5.3"
59
59
  },
60
60
  "devDependencies": {
61
+ "@eslint/js": "^10.0.1",
61
62
  "@jest/globals": "^29.7.0",
62
63
  "@types/jest": "^29.5.14",
63
64
  "@types/swagger2openapi": "^7.0.4",
64
- "@typescript-eslint/eslint-plugin": "^7.18.0",
65
- "@typescript-eslint/parser": "^7.18.0",
66
- "eslint": "^8.57.1",
67
- "eslint-config-prettier": "^9.1.2",
68
- "eslint-plugin-json": "^3.1.0",
69
- "eslint-plugin-prettier": "^5.5.4",
65
+ "eslint": "^10.9.1",
66
+ "eslint-config-prettier": "^10.1.8",
67
+ "eslint-plugin-json": "^5.0.0",
68
+ "eslint-plugin-prettier": "^5.5.6",
69
+ "globals": "^17.11.0",
70
70
  "jest": "^29.7.0",
71
71
  "prettier": "^3.6.2",
72
72
  "prettier-plugin-jsdoc": "^1.3.3",
@@ -74,7 +74,8 @@
74
74
  "prettier-plugin-sort-json": "^3.1.0",
75
75
  "rimraf": "^5.0.10",
76
76
  "ts-jest": "^29.2.5",
77
- "typescript": "^5.7.2"
77
+ "typescript": "^5.7.2",
78
+ "typescript-eslint": "^8.68.0"
78
79
  },
79
80
  "oclif": {
80
81
  "bin": "acg",
@@ -85,5 +86,5 @@
85
86
  "@oclif/plugin-plugins"
86
87
  ]
87
88
  },
88
- "gitHead": "b4a8cf280ff804dee2c67e6c5919020cd0067c59"
89
+ "gitHead": "b4795a8b89bf28874a654c71cbb70d9f61f81516"
89
90
  }