openapi-typesafe 1.0.0-rc.5 → 1.0.0-rc.6

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 (148) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/dist/adapters/redocly-loader/query-document-names.cjs +50 -0
  3. package/dist/adapters/redocly-loader/query-document-names.cjs.map +1 -0
  4. package/dist/adapters/redocly-loader/query-document-names.mjs +48 -0
  5. package/dist/adapters/redocly-loader/query-document-names.mjs.map +1 -0
  6. package/dist/adapters/redocly-loader/query-named-components.cjs +29 -37
  7. package/dist/adapters/redocly-loader/query-named-components.cjs.map +1 -1
  8. package/dist/adapters/redocly-loader/query-named-components.mjs +29 -37
  9. package/dist/adapters/redocly-loader/query-named-components.mjs.map +1 -1
  10. package/dist/adapters/redocly-loader/redocly-config-source.cjs +3 -0
  11. package/dist/adapters/redocly-loader/redocly-config-source.cjs.map +1 -1
  12. package/dist/adapters/redocly-loader/redocly-config-source.mjs +4 -1
  13. package/dist/adapters/redocly-loader/redocly-config-source.mjs.map +1 -1
  14. package/dist/adapters/redocly-loader/redocly-schema-loader.cjs +16 -7
  15. package/dist/adapters/redocly-loader/redocly-schema-loader.cjs.map +1 -1
  16. package/dist/adapters/redocly-loader/redocly-schema-loader.mjs +17 -8
  17. package/dist/adapters/redocly-loader/redocly-schema-loader.mjs.map +1 -1
  18. package/dist/adapters/redocly-loader/ref-access.cjs +9 -1
  19. package/dist/adapters/redocly-loader/ref-access.cjs.map +1 -1
  20. package/dist/adapters/redocly-loader/ref-access.mjs +10 -2
  21. package/dist/adapters/redocly-loader/ref-access.mjs.map +1 -1
  22. package/dist/adapters/redocly-loader/remote-fetch.cjs +16 -1
  23. package/dist/adapters/redocly-loader/remote-fetch.cjs.map +1 -1
  24. package/dist/adapters/redocly-loader/remote-fetch.mjs +16 -2
  25. package/dist/adapters/redocly-loader/remote-fetch.mjs.map +1 -1
  26. package/dist/api/cwd-option.cjs +58 -0
  27. package/dist/api/cwd-option.cjs.map +1 -0
  28. package/dist/api/cwd-option.mjs +52 -0
  29. package/dist/api/cwd-option.mjs.map +1 -0
  30. package/dist/api/default-ports.cjs +15 -7
  31. package/dist/api/default-ports.cjs.map +1 -1
  32. package/dist/api/default-ports.mjs +14 -8
  33. package/dist/api/default-ports.mjs.map +1 -1
  34. package/dist/api/document-run.cjs +2 -16
  35. package/dist/api/document-run.cjs.map +1 -1
  36. package/dist/api/document-run.mjs +2 -12
  37. package/dist/api/document-run.mjs.map +1 -1
  38. package/dist/api/legacy-utils.cjs +1 -1
  39. package/dist/api/legacy-utils.cjs.map +1 -1
  40. package/dist/api/legacy-utils.mjs +1 -1
  41. package/dist/api/legacy-utils.mjs.map +1 -1
  42. package/dist/api/openapi-ts.cjs.map +1 -1
  43. package/dist/api/openapi-ts.mjs.map +1 -1
  44. package/dist/api/option-errors.cjs +24 -0
  45. package/dist/api/option-errors.cjs.map +1 -0
  46. package/dist/api/option-errors.mjs +21 -0
  47. package/dist/api/option-errors.mjs.map +1 -0
  48. package/dist/api/ref-limit-options.cjs +19 -24
  49. package/dist/api/ref-limit-options.cjs.map +1 -1
  50. package/dist/api/ref-limit-options.mjs +15 -20
  51. package/dist/api/ref-limit-options.mjs.map +1 -1
  52. package/dist/application/transform/document/path-template-refs.cjs +12 -2
  53. package/dist/application/transform/document/path-template-refs.cjs.map +1 -1
  54. package/dist/application/transform/document/path-template-refs.mjs +12 -2
  55. package/dist/application/transform/document/path-template-refs.mjs.map +1 -1
  56. package/dist/application/transform/ref-pointer/generated-shape.cjs +28 -10
  57. package/dist/application/transform/ref-pointer/generated-shape.cjs.map +1 -1
  58. package/dist/application/transform/ref-pointer/generated-shape.mjs +29 -11
  59. package/dist/application/transform/ref-pointer/generated-shape.mjs.map +1 -1
  60. package/dist/application/transform/ref-pointer/original-index.cjs +1 -1
  61. package/dist/application/transform/ref-pointer/original-index.cjs.map +1 -1
  62. package/dist/application/transform/ref-pointer/original-index.mjs +1 -1
  63. package/dist/application/transform/ref-pointer/original-index.mjs.map +1 -1
  64. package/dist/application/transform/ref-pointer/property-shape.cjs +5 -0
  65. package/dist/application/transform/ref-pointer/property-shape.cjs.map +1 -1
  66. package/dist/application/transform/ref-pointer/property-shape.mjs +5 -1
  67. package/dist/application/transform/ref-pointer/property-shape.mjs.map +1 -1
  68. package/dist/application/transform/ref-pointer/typed-walk.cjs +11 -1
  69. package/dist/application/transform/ref-pointer/typed-walk.cjs.map +1 -1
  70. package/dist/application/transform/ref-pointer/typed-walk.mjs +12 -2
  71. package/dist/application/transform/ref-pointer/typed-walk.mjs.map +1 -1
  72. package/dist/cli/api-settings.cjs +3 -2
  73. package/dist/cli/api-settings.cjs.map +1 -1
  74. package/dist/cli/api-settings.mjs +2 -1
  75. package/dist/cli/api-settings.mjs.map +1 -1
  76. package/dist/cli/api-writes.cjs +7 -1
  77. package/dist/cli/api-writes.cjs.map +1 -1
  78. package/dist/cli/api-writes.mjs +7 -1
  79. package/dist/cli/api-writes.mjs.map +1 -1
  80. package/dist/cli/cli-arguments.cjs +25 -107
  81. package/dist/cli/cli-arguments.cjs.map +1 -1
  82. package/dist/cli/cli-arguments.mjs +18 -100
  83. package/dist/cli/cli-arguments.mjs.map +1 -1
  84. package/dist/cli/cli-errors.cjs +1 -0
  85. package/dist/cli/cli-errors.cjs.map +1 -1
  86. package/dist/cli/cli-errors.mjs +1 -0
  87. package/dist/cli/cli-errors.mjs.map +1 -1
  88. package/dist/cli/cli-flag-names.cjs +77 -0
  89. package/dist/cli/cli-flag-names.cjs.map +1 -0
  90. package/dist/cli/cli-flag-names.mjs +64 -0
  91. package/dist/cli/cli-flag-names.mjs.map +1 -0
  92. package/dist/cli/cli-output.cjs +7 -2
  93. package/dist/cli/cli-output.cjs.map +1 -1
  94. package/dist/cli/cli-output.mjs +7 -2
  95. package/dist/cli/cli-output.mjs.map +1 -1
  96. package/dist/cli/main.cjs +1 -1
  97. package/dist/cli/main.cjs.map +1 -1
  98. package/dist/cli/main.mjs +1 -1
  99. package/dist/cli/main.mjs.map +1 -1
  100. package/dist/cli/redocly-apis.cjs +14 -4
  101. package/dist/cli/redocly-apis.cjs.map +1 -1
  102. package/dist/cli/redocly-apis.mjs +14 -4
  103. package/dist/cli/redocly-apis.mjs.map +1 -1
  104. package/dist/cli/written-flags.cjs +83 -0
  105. package/dist/cli/written-flags.cjs.map +1 -0
  106. package/dist/cli/written-flags.mjs +77 -0
  107. package/dist/cli/written-flags.mjs.map +1 -0
  108. package/dist/domain/errors.cjs +1 -0
  109. package/dist/domain/errors.cjs.map +1 -1
  110. package/dist/domain/errors.mjs +1 -0
  111. package/dist/domain/errors.mjs.map +1 -1
  112. package/dist/domain/location-text.cjs +7 -1
  113. package/dist/domain/location-text.cjs.map +1 -1
  114. package/dist/domain/location-text.mjs +7 -1
  115. package/dist/domain/location-text.mjs.map +1 -1
  116. package/dist/index.d.cts +3 -0
  117. package/dist/index.d.mts +3 -0
  118. package/dist/index.d.ts +3 -0
  119. package/package.json +1 -1
  120. package/src/adapters/redocly-loader/query-document-names.ts +79 -0
  121. package/src/adapters/redocly-loader/query-named-components.ts +48 -49
  122. package/src/adapters/redocly-loader/redocly-config-source.ts +11 -1
  123. package/src/adapters/redocly-loader/redocly-schema-loader.ts +20 -6
  124. package/src/adapters/redocly-loader/ref-access.ts +16 -2
  125. package/src/adapters/redocly-loader/remote-fetch.ts +26 -0
  126. package/src/api/cwd-option.ts +76 -0
  127. package/src/api/default-ports.ts +33 -10
  128. package/src/api/document-run.ts +2 -17
  129. package/src/api/legacy-utils.ts +1 -1
  130. package/src/api/openapi-ts.ts +14 -1
  131. package/src/api/option-errors.ts +22 -0
  132. package/src/api/ref-limit-options.ts +17 -24
  133. package/src/application/ports/redocly-config-source.ts +5 -0
  134. package/src/application/transform/document/path-template-refs.ts +28 -7
  135. package/src/application/transform/ref-pointer/generated-shape.ts +57 -14
  136. package/src/application/transform/ref-pointer/original-index.ts +6 -3
  137. package/src/application/transform/ref-pointer/property-shape.ts +9 -0
  138. package/src/application/transform/ref-pointer/typed-walk.ts +26 -4
  139. package/src/cli/api-writes.ts +11 -3
  140. package/src/cli/cli-arguments.ts +42 -153
  141. package/src/cli/cli-errors.ts +1 -0
  142. package/src/cli/cli-flag-names.ts +99 -0
  143. package/src/cli/cli-output.ts +20 -6
  144. package/src/cli/main.ts +2 -1
  145. package/src/cli/redocly-apis.ts +21 -5
  146. package/src/cli/written-flags.ts +131 -0
  147. package/src/domain/errors.ts +1 -0
  148. package/src/domain/location-text.ts +17 -2
@@ -0,0 +1,79 @@
1
+ import type { LoadedDocument } from "./unsupported-keys.js";
2
+
3
+ /**
4
+ * Which loaded document with a query each node comes from, and the names Redocly and the loader
5
+ * give that document. Redocly names a component after the text behind the last `/` of the URL,
6
+ * query included, so `pet.json?ref=feature/new-api` and `owner.yaml?ref=feature/new-api` both give
7
+ * `new-api`: the name alone cannot tell the documents apart, the node can.
8
+ */
9
+
10
+ const REMOTE_URL_RE = /^https?:\/\//;
11
+ const PATH_SEPARATOR_RE = /[/\\]/;
12
+ const EXTENSION_RE = /\.[^.]+$/;
13
+
14
+ /** The base name Redocly gives a document and the one without the query. */
15
+ export interface DocumentNames {
16
+ readonly named: string;
17
+ readonly clean: string;
18
+ }
19
+
20
+ /** The base name Redocly 1.34 gives a document (`refBaseName`): the last segment, no extension. */
21
+ function redoclyBaseName(ref: string): string {
22
+ return (ref.split(PATH_SEPARATOR_RE).at(-1) ?? "").replace(EXTENSION_RE, "");
23
+ }
24
+
25
+ /**
26
+ * The base name of the URL without its query and fragment. A URL with no last segment
27
+ * (`https://host/?key=…`) is named after its host, so the name is never empty.
28
+ */
29
+ function queryFreeBaseName(url: URL): string {
30
+ const withoutQuery = new URL(url.pathname, url.origin).href;
31
+ return redoclyBaseName(withoutQuery) || url.hostname;
32
+ }
33
+
34
+ /** The names of a remote document with a query whose Redocly name keeps it; else undefined. */
35
+ function documentNamesOf(ref: string): DocumentNames | undefined {
36
+ const url = REMOTE_URL_RE.test(ref) && URL.canParse(ref) ? new URL(ref) : undefined;
37
+ if (url === undefined || url.search === "") {
38
+ return undefined;
39
+ }
40
+ const names = { named: redoclyBaseName(ref), clean: queryFreeBaseName(url) };
41
+ return names.named === names.clean ? undefined : names;
42
+ }
43
+
44
+ function isNode(value: unknown): value is object {
45
+ return typeof value === "object" && value !== null;
46
+ }
47
+
48
+ /** Every object and array of a parsed document, each once (a YAML alias can repeat one). */
49
+ function nodesOf(parsed: unknown): object[] {
50
+ const visited = new Set<object>();
51
+ const pending = isNode(parsed) ? [parsed] : [];
52
+ for (let node = pending.pop(); node !== undefined; node = pending.pop()) {
53
+ if (!visited.has(node)) {
54
+ visited.add(node);
55
+ pending.push(...Object.values(node).filter(isNode));
56
+ }
57
+ }
58
+ return [...visited];
59
+ }
60
+
61
+ /**
62
+ * The names of the document each node of a loaded document with a query belongs to. The bundle
63
+ * keeps the nodes of the loaded documents as its components, so a component finds its document.
64
+ */
65
+ export function queryDocumentNames(
66
+ documents: readonly LoadedDocument[],
67
+ ): ReadonlyMap<object, DocumentNames> {
68
+ const byNode = new Map<object, DocumentNames>();
69
+ for (const { source, parsed } of documents) {
70
+ const names = documentNamesOf(source);
71
+ if (names === undefined) {
72
+ continue;
73
+ }
74
+ for (const node of nodesOf(parsed)) {
75
+ byNode.set(node, names);
76
+ }
77
+ }
78
+ return byNode;
79
+ }
@@ -4,7 +4,9 @@ import {
4
4
  parseRefPointer,
5
5
  ROOT_POINTER,
6
6
  } from "../../domain/openapi/json-pointer.js";
7
+ import { type DocumentNames, queryDocumentNames } from "./query-document-names.js";
7
8
  import { findMappings, findRefHolders } from "./ref-holders.js";
9
+ import type { LoadedDocument } from "./unsupported-keys.js";
8
10
 
9
11
  /**
10
12
  * Redocly 1.x bundles an external `$ref` into a component named after the last segment of the
@@ -13,6 +15,7 @@ import { findMappings, findRefHolders } from "./ref-holders.js";
13
15
  * API keys and signatures, so `https://host/schemas/pet?token=…` became the component
14
16
  * `pet?token=…` in the generated file and in messages (threat model T4b). This renames such a
15
17
  * component after the URL without its query: `pet`, with a `-2` suffix when that name is taken.
18
+ * Each component is matched to its document by its node, since two URLs can give one name.
16
19
  */
17
20
 
18
21
  type Components = Record<string, Record<string, unknown>>;
@@ -24,12 +27,14 @@ export type ComponentKeys = ReadonlyMap<string, ReadonlySet<string>>;
24
27
  type Renames = Map<string, Map<string, string>>;
25
28
 
26
29
  const COMPONENTS_KEY = "components";
27
- const REMOTE_URL_RE = /^https?:\/\//;
28
- const PATH_SEPARATOR_RE = /[/\\]/;
29
- const EXTENSION_RE = /\.[^.]+$/;
30
30
  /** What Redocly puts between the base name and the rest of a component name. */
31
31
  const NAME_SUFFIX_STARTS = ["_", "-"];
32
32
  const FIRST_SERIAL = 2;
33
+ /**
34
+ * The serial Redocly adds when a name is taken (`pet-2`). Two URLs that differ only in the query
35
+ * took one name; without the query they may not, so the serial is chosen again (`freeName`).
36
+ */
37
+ const REDOCLY_SERIAL_RE = /-\d+$/;
33
38
 
34
39
  function isContainer(value: unknown): value is Record<string, unknown> {
35
40
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -58,46 +63,17 @@ export function componentKeysOf(document: unknown): ComponentKeys {
58
63
  return keys;
59
64
  }
60
65
 
61
- /** The base name Redocly 1.34 gives a document (`refBaseName`): the last segment, no extension. */
62
- function redoclyBaseName(ref: string): string {
63
- return (ref.split(PATH_SEPARATOR_RE).at(-1) ?? "").replace(EXTENSION_RE, "");
64
- }
65
-
66
- /**
67
- * The base name of the URL without its query and fragment. A URL with no last segment
68
- * (`https://host/?key=…`) is named after its host, so the name is never empty.
69
- */
70
- function queryFreeBaseName(url: URL): string {
71
- const withoutQuery = new URL(url.pathname, url.origin).href;
72
- return redoclyBaseName(withoutQuery) || url.hostname;
73
- }
74
-
75
- /** Each base name Redocly takes from a remote URL with a query, with the name to use instead. */
76
- function queryBaseNames(documentRefs: Iterable<string>): Map<string, string> {
77
- const names = new Map<string, string>();
78
- for (const ref of documentRefs) {
79
- const url = REMOTE_URL_RE.test(ref) && URL.canParse(ref) ? new URL(ref) : undefined;
80
- if (url === undefined || url.search === "") {
81
- continue;
82
- }
83
- const named = redoclyBaseName(ref);
84
- const clean = queryFreeBaseName(url);
85
- if (named !== clean) {
86
- names.set(named, clean);
87
- }
66
+ /** The name a component of a document with a query gets; undefined for any other component. */
67
+ function queryFreeName(key: string, names: DocumentNames | undefined): string | undefined {
68
+ if (names === undefined) {
69
+ return undefined;
88
70
  }
89
- return names;
90
- }
91
-
92
- /** The name `key` gets, or undefined when no query base name starts it. */
93
- function queryFreeName(key: string, baseNames: ReadonlyMap<string, string>): string | undefined {
94
- // The longest base name first: `pet?a=1` must not match as `pet?a` when both were loaded.
95
- const matches = [...baseNames.keys()].filter(
96
- (named) =>
97
- key === named || NAME_SUFFIX_STARTS.some((start) => key.startsWith(`${named}${start}`)),
98
- );
99
- const named = matches.sort((a, b) => b.length - a.length)[0];
100
- return named === undefined ? undefined : `${baseNames.get(named)}${key.slice(named.length)}`;
71
+ const { named, clean } = names;
72
+ const hasBaseName =
73
+ key === named || NAME_SUFFIX_STARTS.some((start) => key.startsWith(`${named}${start}`));
74
+ return hasBaseName
75
+ ? `${clean}${key.slice(named.length).replace(REDOCLY_SERIAL_RE, "")}`
76
+ : undefined;
101
77
  }
102
78
 
103
79
  function freeName(name: string, taken: ReadonlySet<string>): string {
@@ -112,12 +88,14 @@ function freeName(name: string, taken: ReadonlySet<string>): string {
112
88
  function renameGroup(
113
89
  entries: Record<string, unknown>,
114
90
  keysBefore: ReadonlySet<string>,
115
- baseNames: ReadonlyMap<string, string>,
91
+ documentNames: ReadonlyMap<object, DocumentNames>,
116
92
  ): Map<string, string> {
117
93
  const renames = new Map<string, string>();
118
94
  const taken = new Set(Object.keys(entries));
119
95
  for (const key of Object.keys(entries)) {
120
- const name = keysBefore.has(key) ? undefined : queryFreeName(key, baseNames);
96
+ const node = entries[key];
97
+ const names = typeof node === "object" && node !== null ? documentNames.get(node) : undefined;
98
+ const name = keysBefore.has(key) ? undefined : queryFreeName(key, names);
121
99
  if (name !== undefined) {
122
100
  taken.delete(key);
123
101
  const free = freeName(name, taken);
@@ -138,6 +116,26 @@ export interface ComponentRenaming {
138
116
  readonly pointer: (pointer: string) => string;
139
117
  /** The text with each renamed component's old name replaced by its new name. */
140
118
  readonly text: (text: string) => string;
119
+ /**
120
+ * Whether the problem is Redocly's report of two components with one name that the new names
121
+ * tell apart: it renamed `new-api` to `new-api-2`, and the documents are now `pet` and `owner`.
122
+ */
123
+ readonly isResolvedCollision: (message: string) => boolean;
124
+ }
125
+
126
+ /** The warning Redocly 1.34 gives when it adds a serial to a component name. */
127
+ const COLLISION_RE =
128
+ /^Two schemas are referenced with the same name but different content\. Renamed (.+) to (.+)\.$/;
129
+ const SERIAL_RE = /^-\d+$/;
130
+
131
+ function resolvesCollision(message: string, renames: Renames): boolean {
132
+ const [, first, second] = COLLISION_RE.exec(message) ?? [];
133
+ if (first === undefined || second === undefined) {
134
+ return false;
135
+ }
136
+ const renamed = new Map([...renames.values()].flatMap((group) => [...group]));
137
+ const [newFirst, newSecond] = [renamed.get(first) ?? first, renamed.get(second) ?? second];
138
+ return !(newSecond.startsWith(newFirst) && SERIAL_RE.test(newSecond.slice(newFirst.length)));
141
139
  }
142
140
 
143
141
  function renamedPointer(pointer: string, renames: Renames): string {
@@ -173,18 +171,18 @@ function rewriteRefs(document: unknown, renaming: ComponentRenaming): void {
173
171
 
174
172
  /**
175
173
  * Rename, in place, the components the bundle named after a URL with a query, and every local
176
- * `$ref` and `mapping` value that names them. `documentRefs` are the refs of the loaded documents.
174
+ * `$ref` and `mapping` value that names them. `documents` are the documents the load read.
177
175
  */
178
176
  export function renameQueryNamedComponents(
179
177
  bundled: unknown,
180
178
  keysBefore: ComponentKeys,
181
- documentRefs: Iterable<string>,
179
+ documents: readonly LoadedDocument[],
182
180
  ): ComponentRenaming {
183
- const baseNames = queryBaseNames(documentRefs);
181
+ const documentNames = queryDocumentNames(documents);
184
182
  const renames: Renames = new Map();
185
- if (baseNames.size > 0) {
183
+ if (documentNames.size > 0) {
186
184
  for (const [group, entries] of Object.entries(componentsOf(bundled))) {
187
- const groupRenames = renameGroup(entries, keysBefore.get(group) ?? new Set(), baseNames);
185
+ const groupRenames = renameGroup(entries, keysBefore.get(group) ?? new Set(), documentNames);
188
186
  if (groupRenames.size > 0) {
189
187
  renames.set(group, groupRenames);
190
188
  }
@@ -193,6 +191,7 @@ export function renameQueryNamedComponents(
193
191
  const renaming: ComponentRenaming = {
194
192
  pointer: (pointer) => renamedPointer(pointer, renames),
195
193
  text: (text) => renamedText(text, renames),
194
+ isResolvedCollision: (message) => resolvesCollision(message, renames),
196
195
  };
197
196
  if (renames.size > 0) {
198
197
  rewriteRefs(bundled, renaming);
@@ -1,6 +1,12 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { createConfig, findConfig, IGNORE_FILE, loadConfig } from "@redocly/openapi-core";
3
+ import {
4
+ createConfig,
5
+ findConfig,
6
+ getMergedConfig,
7
+ IGNORE_FILE,
8
+ loadConfig,
9
+ } from "@redocly/openapi-core";
4
10
  import type {
5
11
  RedoclyConfig,
6
12
  RedoclyConfigSource,
@@ -70,6 +76,10 @@ class RedoclyConfigFiles implements RedoclyConfigSource {
70
76
  );
71
77
  return loadedConfig(create, undefined);
72
78
  }
79
+
80
+ forApi(config: RedoclyConfig, apiName: string): RedoclyConfig {
81
+ return getMergedConfig(config, apiName);
82
+ }
73
83
  }
74
84
 
75
85
  /** Find and load `redocly.yaml` relative to the directory `cwd`. */
@@ -31,7 +31,11 @@ import {
31
31
  import { RefAccessResolver } from "./ref-access.js";
32
32
  import { checkRefsResolve } from "./ref-resolution-check.js";
33
33
  import { REF_CYCLE_MESSAGE } from "./ref-resolution-steps.js";
34
- import { type RemoteDocumentLimits, resolveConfigWithAccess } from "./remote-fetch.js";
34
+ import {
35
+ RedirectedUrls,
36
+ type RemoteDocumentLimits,
37
+ resolveConfigWithAccess,
38
+ } from "./remote-fetch.js";
35
39
  import {
36
40
  loadFailedError,
37
41
  refCycleError,
@@ -177,17 +181,21 @@ class RedoclySchemaLoader implements SchemaLoader {
177
181
 
178
182
  #resolver(request: SchemaLoadRequest): RefAccessResolver {
179
183
  const { redoc, remoteRefTimeoutMs, remoteDocumentLimits } = this.#options;
184
+ // One record per load, as the resolver and its cache are.
185
+ const redirects = new RedirectedUrls();
180
186
  const config = resolveConfigWithAccess(redoc.resolve, {
181
187
  timeoutMs: remoteRefTimeoutMs,
182
188
  access: request.refAccess,
183
189
  rootUrl: request.source instanceof URL ? request.source.href : undefined,
184
190
  maxParallelFetches: remoteDocumentLimits.maxParallelFetches,
185
191
  bodyLimits: remoteDocumentLimits,
192
+ redirects,
186
193
  });
187
194
  return new RefAccessResolver(config, {
188
195
  access: request.refAccess,
189
196
  maxRemoteDocuments: remoteDocumentLimits.maxDocuments,
190
197
  maxRemoteNodes: remoteDocumentLimits.maxNodes,
198
+ redirects,
191
199
  });
192
200
  }
193
201
 
@@ -229,8 +237,8 @@ class RedoclySchemaLoader implements SchemaLoader {
229
237
  });
230
238
  removeBundledMappingCopies(bundled.bundle.parsed, schemaKeys);
231
239
  repairBundledRefs(bundled.bundle.parsed);
232
- const documentRefs = resolver.getFiles();
233
- const renaming = renameQueryNamedComponents(bundled.bundle.parsed, componentKeys, documentRefs);
240
+ const documents = await loadedDocuments(document, resolver);
241
+ const renaming = renameQueryNamedComponents(bundled.bundle.parsed, componentKeys, documents);
234
242
  // The problems name the components the bundle made, so they are shown by their new names.
235
243
  this.#report(bundled.problems, document.source.absoluteRef, renaming);
236
244
  this.#options.logger.debug("Bundled schema", "bundle", this.#options.now() - startedAt);
@@ -240,11 +248,13 @@ class RedoclySchemaLoader implements SchemaLoader {
240
248
  /**
241
249
  * Report every problem; then throw if any of them is an error. A problem that the Redocly
242
250
  * config's `.redocly.lint-ignore.yaml` lists is left out, as `redocly lint` leaves it out
243
- * (upstream #2189).
251
+ * (upstream #2189). A name collision that the renamed components no longer have is dropped.
244
252
  */
245
253
  #report(problems: readonly NormalizedProblem[], rootRef: string, renaming = UNRENAMED): void {
246
254
  const errorMessages: string[] = [];
247
- for (const problem of problems) {
255
+ for (const problem of problems.filter(
256
+ (found) => !renaming.isResolvedCollision(found.message),
257
+ )) {
248
258
  if (problem.ignored === true) {
249
259
  this.#options.logger.debug(
250
260
  `Ignored by the lint ignore file: ${problemMessage(problem, renaming, rootRef)}`,
@@ -272,7 +282,11 @@ function lintWarningText(message: string, ruleId: string): string {
272
282
  }
273
283
 
274
284
  /** Problems found before the bundle named any component. */
275
- const UNRENAMED: ComponentRenaming = { pointer: (pointer) => pointer, text: (text) => text };
285
+ const UNRENAMED: ComponentRenaming = {
286
+ pointer: (pointer) => pointer,
287
+ text: (text) => text,
288
+ isResolvedCollision: () => false,
289
+ };
276
290
 
277
291
  /** A problem location that names a JSON Pointer, not a line and column. */
278
292
  type PointerLocation = Extract<LocationObject, { pointer: string }>;
@@ -5,7 +5,7 @@ import {
5
5
  BaseResolver,
6
6
  type Document,
7
7
  type Config as RedoclyConfig,
8
- type Source,
8
+ Source,
9
9
  YamlParseError,
10
10
  } from "@redocly/openapi-core";
11
11
  import type { RefAccess } from "../../application/ports/schema-loader.js";
@@ -14,6 +14,7 @@ import { redactUrlSecrets } from "../../domain/url-secrets.js";
14
14
  import { aliasExpansionFailure } from "./alias-limit.js";
15
15
  import { isHostAllowed } from "./allowed-hosts.js";
16
16
  import { pointMappingNamesAtSchemas } from "./mapping-schema-names.js";
17
+ import type { RedirectedUrls } from "./remote-fetch.js";
17
18
  import { isParsedSource, RemoteNodeLimit } from "./remote-node-limit.js";
18
19
  import { refForbiddenError } from "./schema-load-errors.js";
19
20
 
@@ -108,6 +109,8 @@ export interface RefAccessLimits {
108
109
  readonly maxRemoteDocuments: number;
109
110
  /** Nodes the remote documents of one load may parse into together (threat model T2). */
110
111
  readonly maxRemoteNodes: number;
112
+ /** The final URLs of redirected documents, which their relative `$ref`s resolve against. */
113
+ readonly redirects: RedirectedUrls;
111
114
  }
112
115
 
113
116
  /**
@@ -207,7 +210,7 @@ export class RefAccessResolver extends BaseResolver {
207
210
  throw refusal;
208
211
  }
209
212
  try {
210
- return await super.loadExternalRef(absoluteRef);
213
+ return this.#atFinalUrl(await super.loadExternalRef(absoluteRef));
211
214
  } catch (error) {
212
215
  // A refused redirect throws inside the fetch, and Redocly wraps it in a ResolveError.
213
216
  const cause = (error as { originalError?: unknown }).originalError;
@@ -218,6 +221,17 @@ export class RefAccessResolver extends BaseResolver {
218
221
  }
219
222
  }
220
223
 
224
+ /**
225
+ * A redirected document is named by the URL it came from, so its relative `$ref`s, its
226
+ * component names and its problems use that URL. Redocly still finds it by the requested one.
227
+ */
228
+ #atFinalUrl(source: Source): Source {
229
+ const finalUrl = this.#limits.redirects.finalUrlOf(source.absoluteRef);
230
+ return finalUrl === source.absoluteRef
231
+ ? source
232
+ : new Source(finalUrl, source.body, source.mimeType);
233
+ }
234
+
221
235
  #accessRefusal(absoluteRef: string): Promise<OpenAPITypesafeError | undefined> {
222
236
  return absoluteRef === this.#rootRef
223
237
  ? Promise.resolve(undefined)
@@ -109,6 +109,27 @@ function fetchWithBodyLimit(fetchImpl: RemoteFetch, bodyLimit: RemoteBodyLimit):
109
109
  return async (input, init) => bufferedResponse(await fetchImpl(input, init), input, bodyLimit);
110
110
  }
111
111
 
112
+ /**
113
+ * The URL each remote document was read from in the end, when redirects moved it. A relative
114
+ * `$ref` resolves against the URL the document was retrieved from (RFC 3986, section 5.1.3), so
115
+ * `/latest` that redirects to `/static/v1/root.yaml` loads `./pet.yaml` from `/static/v1/`.
116
+ * Redocly reads only the body of the response, so the fetch records the URL here.
117
+ */
118
+ export class RedirectedUrls {
119
+ readonly #finalUrls = new Map<string, string>();
120
+
121
+ record(requested: string, final: string): void {
122
+ if (requested !== final) {
123
+ this.#finalUrls.set(requested, final);
124
+ }
125
+ }
126
+
127
+ /** The URL the document of `requested` came from; `requested` when it was not redirected. */
128
+ finalUrlOf(requested: string): string {
129
+ return this.#finalUrls.get(requested) ?? requested;
130
+ }
131
+ }
132
+
112
133
  /** What every hop of a remote fetch is checked against. */
113
134
  export interface RedirectPolicy {
114
135
  /** `resolve.http.headers` of the Redocly config; chosen again for the URL of every hop. */
@@ -120,6 +141,8 @@ export interface RedirectPolicy {
120
141
  * redirects are limited to the hosts (ADR-017): a spec URL that redirects to a CDN loads.
121
142
  */
122
143
  readonly rootUrl: string | undefined;
144
+ /** Where the final URL of each redirected document is recorded. */
145
+ readonly redirects: RedirectedUrls;
123
146
  }
124
147
 
125
148
  /**
@@ -148,6 +171,7 @@ export function fetchWithCheckedRedirects(
148
171
  const response = await fetchImpl(url.href, { ...init, headers, redirect: "manual" });
149
172
  const location = response.headers.get("location");
150
173
  if (!REDIRECT_STATUSES.has(response.status) || location === null) {
174
+ policy.redirects.record(input, url.href);
151
175
  return response;
152
176
  }
153
177
  url = new URL(location, url);
@@ -200,6 +224,7 @@ export interface RemoteFetchOptions {
200
224
  readonly rootUrl: string | undefined;
201
225
  readonly maxParallelFetches: number;
202
226
  readonly bodyLimits: RemoteBodyLimits;
227
+ readonly redirects: RedirectedUrls;
203
228
  }
204
229
 
205
230
  /**
@@ -225,6 +250,7 @@ export function resolveConfigWithAccess(
225
250
  headers: resolve.http.headers,
226
251
  hosts: options.access.remoteHosts,
227
252
  rootUrl: options.rootUrl,
253
+ redirects: options.redirects,
228
254
  });
229
255
  // The limit is outside the redirects, so all hops of one document share one slot.
230
256
  const customFetch = fetchWithConcurrencyLimit(checked, options.maxParallelFetches);
@@ -0,0 +1,76 @@
1
+ import path from "node:path";
2
+ import { fileURLToPath, pathToFileURL } from "node:url";
3
+ import { hasUrlScheme } from "../domain/location-text.js";
4
+ import type { GeneratorOptions } from "../types.js";
5
+ import { processCwd } from "./default-ports.js";
6
+ import { kindOf, optionInvalid } from "./option-errors.js";
7
+
8
+ /**
9
+ * The `cwd` option: the directory that relative `$ref`s of an inline source resolve against
10
+ * (ADR-023). A string is read as `localRefRoot` is: a URL when it has a scheme, else a path.
11
+ */
12
+
13
+ const FILE_PROTOCOL = "file:";
14
+
15
+ /** A `file:` cwd is a local directory; an `http:` or `https:` one holds remote documents. */
16
+ const CWD_PROTOCOLS: ReadonlySet<string> = new Set([FILE_PROTOCOL, "http:", "https:"]);
17
+
18
+ const CWD_EXPECTED = "a directory path, or a file:, http: or https: URL";
19
+
20
+ /** A path, resolved against the working directory, as a directory URL; `#` and `%` are encoded. */
21
+ function pathDirectoryUrl(directory: string): URL {
22
+ return pathToFileURL(path.join(path.resolve(directory), path.sep));
23
+ }
24
+
25
+ /** The URL the value names, before its scheme is checked; undefined for another type. */
26
+ function cwdUrlOf(value: unknown): URL | undefined {
27
+ if (value === undefined) {
28
+ return pathDirectoryUrl(processCwd());
29
+ }
30
+ if (typeof value === "string") {
31
+ if (!hasUrlScheme(value)) {
32
+ return pathDirectoryUrl(value);
33
+ }
34
+ return URL.parse(value, pathDirectoryUrl(processCwd()).href) ?? undefined;
35
+ }
36
+ if (Buffer.isBuffer(value)) {
37
+ return pathDirectoryUrl(value.toString());
38
+ }
39
+ return value instanceof URL ? value : undefined;
40
+ }
41
+
42
+ /** A `file:` URL with a host, or with an encoded slash, names no path on this platform. */
43
+ function isLocalPath(url: URL): boolean {
44
+ try {
45
+ fileURLToPath(url);
46
+ return true;
47
+ } catch {
48
+ return false;
49
+ }
50
+ }
51
+
52
+ /** What the cwd is instead of a directory, for the error. */
53
+ function cwdKindOf(value: unknown, url: URL | undefined): string {
54
+ if (url?.protocol === FILE_PROTOCOL) {
55
+ return "a file: URL that names no local path";
56
+ }
57
+ return url === undefined && typeof value === "string"
58
+ ? "a string that is no valid URL"
59
+ : kindOf(url ?? value);
60
+ }
61
+
62
+ /**
63
+ * The cwd as a URL that ends in a slash. Fails with `OPTION_INVALID` for another type, a URL of
64
+ * another scheme or a `file:` URL that names no local path.
65
+ */
66
+ export function sourceCwdOf(options: GeneratorOptions): URL {
67
+ const url = cwdUrlOf(options.cwd);
68
+ const isUsable =
69
+ url !== undefined &&
70
+ CWD_PROTOCOLS.has(url.protocol) &&
71
+ (url.protocol !== FILE_PROTOCOL || isLocalPath(url));
72
+ if (!isUsable) {
73
+ throw optionInvalid("cwd", CWD_EXPECTED, cwdKindOf(options.cwd, url));
74
+ }
75
+ return url.pathname.endsWith("/") ? url : new URL(`${url.pathname}/`, url);
76
+ }
@@ -1,10 +1,11 @@
1
1
  import { performance } from "node:perf_hooks";
2
2
  import { createConfig, type Config as RedoclyConfig } from "@redocly/openapi-core";
3
- import supportsColor from "supports-color";
3
+ import supportsColor, { type ColorInfo } from "supports-color";
4
4
  import { createNodeDeprecationReporter } from "../adapters/node-deprecations/node-deprecations.js";
5
5
  import type { RemoteDocumentLimits } from "../adapters/redocly-loader/remote-fetch.js";
6
6
  import {
7
7
  createTerminalLogger,
8
+ type TerminalLoggerOptions,
8
9
  type TerminalSink,
9
10
  } from "../adapters/terminal-logger/terminal-logger.js";
10
11
  import type { DeprecationReporter } from "../application/ports/deprecation-reporter.js";
@@ -15,10 +16,24 @@ import type { Logger } from "../application/ports/logger.js";
15
16
  * composition root are where the process environment is read.
16
17
  */
17
18
 
18
- /** Colors when the terminal supports them, as the original decided. */
19
- export function terminalSupportsColor(): boolean {
20
- const stdout = supportsColor.stdout;
21
- return Boolean(stdout) && stdout !== false && stdout.hasBasic !== false;
19
+ /** The output streams of the process, which can each be a terminal or redirected. */
20
+ export type TerminalStream = "stdout" | "stderr";
21
+
22
+ /** The color support of each stream, as `supports-color` detects it. */
23
+ export type StreamColorSupport = Readonly<Record<TerminalStream, ColorInfo>>;
24
+
25
+ /**
26
+ * Whether `stream` shows colors. Each stream is asked for itself: with stdout on a terminal and
27
+ * stderr redirected to a log, the log gets no escape sequences.
28
+ */
29
+ export function streamSupportsColor(stream: TerminalStream, support: StreamColorSupport): boolean {
30
+ const info = support[stream];
31
+ return info !== false && info.hasBasic;
32
+ }
33
+
34
+ /** Colors when the terminal of `stream` supports them, as the original decided. */
35
+ export function terminalSupportsColor(stream: TerminalStream): boolean {
36
+ return streamSupportsColor(stream, supportsColor);
22
37
  }
23
38
 
24
39
  /**
@@ -34,14 +49,22 @@ const STDERR_SINK: TerminalSink = {
34
49
  error: (line) => console.error(line),
35
50
  };
36
51
 
37
- /** The terminal logger with `DEBUG` from the environment, writing to stderr. */
38
- export function defaultLogger(silent: boolean): Logger {
39
- return createTerminalLogger({
52
+ /** The options of the default logger: it writes to stderr, so the colors follow stderr. */
53
+ export function defaultLoggerOptions(
54
+ silent: boolean,
55
+ support: StreamColorSupport,
56
+ ): TerminalLoggerOptions {
57
+ return {
40
58
  silent,
41
59
  debugFilter: process.env.DEBUG,
42
- useColors: terminalSupportsColor(),
60
+ useColors: streamSupportsColor("stderr", support),
43
61
  sink: STDERR_SINK,
44
- });
62
+ };
63
+ }
64
+
65
+ /** The terminal logger with `DEBUG` from the environment, writing to stderr. */
66
+ export function defaultLogger(silent: boolean): Logger {
67
+ return createTerminalLogger(defaultLoggerOptions(silent, supportsColor));
45
68
  }
46
69
 
47
70
  /** Deprecations as Node.js `DeprecationWarning`s, each code once per process. */
@@ -1,5 +1,3 @@
1
- import path from "node:path";
2
- import { pathToFileURL } from "node:url";
3
1
  import type { Config as RedoclyConfig } from "@redocly/openapi-core";
4
2
  import { sourceEmptyError } from "../adapters/redocly-loader/schema-load-errors.js";
5
3
  import type { DeprecationReporter } from "../application/ports/deprecation-reporter.js";
@@ -14,7 +12,7 @@ import {
14
12
  } from "../domain/output-fixes.js";
15
13
  import { WARNING_CODES, warningText } from "../domain/warnings.js";
16
14
  import type { GeneratorOptions, OpenAPI3, SchemaSource } from "../types.js";
17
- import { processCwd } from "./default-ports.js";
15
+ import { sourceCwdOf } from "./cwd-option.js";
18
16
  import { warnDiscriminatorValueConflicts } from "./discriminator-conflict-warnings.js";
19
17
  import { findDiscriminators } from "./discriminator-scan.js";
20
18
  import { resolveDocumentRef } from "./document-refs.js";
@@ -56,19 +54,6 @@ type ContextFlags = Omit<
56
54
  | "fix"
57
55
  >;
58
56
 
59
- /**
60
- * The directory that relative `$ref`s of an inline source resolve against, as a URL that ends in
61
- * a slash. A path is resolved and encoded (`#`, `%`, spaces); a URL without the slash gets one.
62
- */
63
- function sourceCwd(options: GeneratorOptions): URL {
64
- const { cwd } = options;
65
- if (cwd instanceof URL) {
66
- return cwd.pathname.endsWith("/") ? cwd : new URL(`${cwd.pathname}/`, cwd);
67
- }
68
- const directory = path.resolve(cwd === undefined ? processCwd() : cwd.toString());
69
- return pathToFileURL(path.join(directory, path.sep));
70
- }
71
-
72
57
  function callbackOptions(options: GeneratorOptions) {
73
58
  return {
74
59
  irCallbacks: options.irCallbacks === true,
@@ -146,7 +131,7 @@ export async function createDocumentRun(
146
131
  const { logger } = ports;
147
132
  const fix = outputFixesOf(options, logger);
148
133
  const silent = options.silent ?? false;
149
- const cwd = sourceCwd(options);
134
+ const cwd = sourceCwdOf(options);
150
135
  const { document: schema, localFiles } = await ports.loader.load({
151
136
  source,
152
137
  cwd,
@@ -15,7 +15,7 @@ import { type ResolveRefOptions, resolveDocumentRef, walkDocument } from "./docu
15
15
 
16
16
  // The exported `c` is the shared ansi-colors instance, switched off at import when the terminal has
17
17
  // no colors, as the original did. The generator's own logger uses a separate instance.
18
- if (!terminalSupportsColor()) {
18
+ if (!terminalSupportsColor("stdout")) {
19
19
  colors.enabled = false;
20
20
  }
21
21