Skip to content

Commit 9e2287b

Browse files
authored
[spec-model] Improve error if input-files do not match schema (#37827)
- Fixes #37825
1 parent 72cee8d commit 9e2287b

File tree

11 files changed

+216
-70
lines changed

11 files changed

+216
-70
lines changed

.github/shared/cmd/spec-model.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { debugLogger } from "../src/logger.js";
44
import { SpecModel } from "../src/spec-model.js";
55

66
const USAGE =
7-
"Usage: npx spec-model path/to/spec [--debug] [--include-refs] [--relative-paths]\n" +
7+
"Usage: npx spec-model path/to/spec [--debug] [--include-refs] [--relative-paths] [--no-embed-errors]\n" +
88
"Example: npx spec-model specfication/contosowidgetmanager";
99

1010
// Exclude first two args (node, script file)
@@ -19,6 +19,9 @@ args = args.filter((a) => a != "--include-refs");
1919
const relativePaths = args.includes("--relative-paths");
2020
args = args.filter((a) => a != "--relative-paths");
2121

22+
const noEmbedErrors = args.includes("--no-embed-errors");
23+
args = args.filter((a) => a != "--no-embed-errors");
24+
2225
if (args.length < 1) {
2326
console.error(USAGE);
2427
process.exit(1);
@@ -40,7 +43,7 @@ const specModel = new SpecModel(specPath, { logger });
4043
console.log(
4144
JSON.stringify(
4245
// Always embed errors, since we always want to return a valid JSON object instead of throwing
43-
await specModel.toJSONAsync({ embedErrors: true, includeRefs, relativePaths }),
46+
await specModel.toJSONAsync({ embedErrors: !noEmbedErrors, includeRefs, relativePaths }),
4447
null,
4548
2,
4649
),

.github/shared/package-lock.json

Lines changed: 11 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.github/shared/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@
3838
"debug": "^4.4.3",
3939
"js-yaml": "^4.1.0",
4040
"marked": "^16.3.0",
41-
"simple-git": "^3.27.0"
41+
"simple-git": "^3.27.0",
42+
"zod": "^4.1.11"
4243
},
4344
"devDependencies": {
4445
"@eslint/js": "^9.22.0",

.github/shared/src/readme.js

Lines changed: 51 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import { readFile } from "fs/promises";
44
import yaml from "js-yaml";
55
import { marked } from "marked";
66
import { dirname, normalize, relative, resolve } from "path";
7+
import * as z from "zod";
78
import { mapAsync } from "./array.js";
9+
import { SpecModelError } from "./spec-model-error.js";
810
import { embedError } from "./spec-model.js";
911
import { Tag } from "./tag.js";
1012

@@ -18,6 +20,21 @@ import { Tag } from "./tag.js";
1820
*/
1921
export const TagMatchRegex = /yaml.*\$\(tag\) ?== ?(["'])(.*?)\1/;
2022

23+
// Example: "foo.json"
24+
const jsonFileSchema = z.string().regex(/\.json$/i);
25+
26+
// Examples:
27+
// {}
28+
// {"input-file": "foo.json"}
29+
// {"input-file": ["foo.json", "bar.json"]}
30+
const inputFileSchema = z.object({
31+
"input-file": z
32+
// May be undefined, a single json filename, or an array of json filenames
33+
.optional(z.union([jsonFileSchema, z.array(jsonFileSchema)]))
34+
// Normalize single string to array of string. Don't change 'undefined'.
35+
.transform((value) => (typeof value === "string" ? [value] : value)),
36+
});
37+
2138
export class Readme {
2239
/**
2340
* Content of `readme.md`, either loaded from `#path` or passed in via `options`.
@@ -53,12 +70,14 @@ export class Readme {
5370
* @param {import('./logger.js').ILogger} [options.logger]
5471
* @param {SpecModel} [options.specModel]
5572
*/
56-
constructor(path, options) {
57-
this.#path = resolve(options?.specModel?.folder ?? "", path);
73+
constructor(path, options = {}) {
74+
const { content, logger, specModel } = options;
75+
76+
this.#path = resolve(specModel?.folder ?? "", path);
5877

59-
this.#content = options?.content;
60-
this.#logger = options?.logger;
61-
this.#specModel = options?.specModel;
78+
this.#content = content;
79+
this.#logger = logger;
80+
this.#specModel = specModel;
6281
}
6382

6483
/**
@@ -127,11 +146,31 @@ export class Readme {
127146
const obj = /** @type {any} */ (yaml.load(block.text, { schema: yaml.FAILSAFE_SCHEMA }));
128147

129148
if (!obj) {
130-
this.#logger?.debug(`No yaml object found for tag ${tagName} in ${this.#path}`);
149+
this.#logger?.debug(`No YAML object found for tag ${tagName} in ${this.#path}`);
131150
continue;
132151
}
133152

134-
if (!obj["input-file"]) {
153+
let parsedObj;
154+
try {
155+
parsedObj = inputFileSchema.parse(obj);
156+
} catch (error) {
157+
if (error instanceof z.ZodError) {
158+
throw new SpecModelError(
159+
`Unable to parse input-file YAML for tag ${tagName} in ${this.#path}`,
160+
{
161+
source: this.#path,
162+
readme: this.#path,
163+
tag: tagName,
164+
cause: error,
165+
},
166+
);
167+
} /* v8 ignore start: defensive rethrow */ else {
168+
throw error;
169+
}
170+
/* v8 ignore end */
171+
}
172+
173+
if (!parsedObj["input-file"]) {
135174
// The yaml block does not contain an input-file key
136175
continue;
137176
}
@@ -149,10 +188,7 @@ export class Readme {
149188
throw new Error(message);
150189
}
151190

152-
// It's possible for input-file to be a string or an array
153-
const inputFilePaths = Array.isArray(obj["input-file"])
154-
? obj["input-file"]
155-
: [obj["input-file"]];
191+
const inputFilePaths = parsedObj["input-file"];
156192

157193
const swaggerPathsResolved = inputFilePaths
158194
.map((p) => Readme.#normalizeSwaggerPath(p))
@@ -207,7 +243,9 @@ export class Readme {
207243
* @param {ToJSONOptions} [options]
208244
* @returns {Promise<Object>}
209245
*/
210-
async toJSONAsync(options) {
246+
async toJSONAsync(options = {}) {
247+
const { relativePaths } = options;
248+
211249
return await embedError(async () => {
212250
const tags = await mapAsync(
213251
[...(await this.getTags()).values()].sort((a, b) => a.name.localeCompare(b.name)),
@@ -216,7 +254,7 @@ export class Readme {
216254

217255
return {
218256
path:
219-
options?.relativePaths && this.#specModel
257+
relativePaths && this.#specModel
220258
? relative(this.#specModel.folder, this.#path)
221259
: this.#path,
222260
globalConfig: await this.getGlobalConfig(),

.github/shared/src/spec-model-error.js

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,19 @@ export class SpecModelError extends Error {
66
*
77
* @type {string|undefined}
88
*/
9-
source;
9+
#source;
1010

1111
/**
1212
* Path to readme that caused the error (if known)
1313
* @type {string|undefined}
1414
*/
15-
readme;
15+
#readme;
1616

1717
/**
1818
* Name of tag that caused the error (if known)
1919
* @type {string|undefined}
2020
*/
21-
tag;
21+
#tag;
2222

2323
/**
2424
* @param {string} message
@@ -28,22 +28,34 @@ export class SpecModelError extends Error {
2828
* @param {string} [options.readme] Path to readme that caused the error (if known)
2929
* @param {string} [options.tag] Name of tag that caused the error (if known)
3030
*/
31-
constructor(message, options) {
32-
super(message, { cause: options?.cause });
31+
constructor(message, options = {}) {
32+
const { cause, source, readme, tag } = options;
33+
34+
const fullMessage =
35+
message +
36+
(source ? `\n Problem File: ${source}` : "") +
37+
(readme ? `\n Readme: ${readme}` : "") +
38+
(tag ? `\n Tag: ${tag}` : "") +
39+
(cause ? `\n Cause: ${cause}` : "");
40+
41+
super(fullMessage, { cause });
3342

3443
this.name = this.constructor.name;
3544

36-
this.source = options?.source;
37-
this.readme = options?.readme;
38-
this.tag = options?.tag;
45+
this.#source = source;
46+
this.#readme = readme;
47+
this.#tag = tag;
48+
}
49+
50+
get source() {
51+
return this.#source;
52+
}
53+
54+
get readme() {
55+
return this.#readme;
3956
}
4057

41-
toString() {
42-
return (
43-
`SpecModelError: ${this.message}` +
44-
`${this.source ? `\n\tProblem File: ${this.source}` : ""}` +
45-
`${this.readme ? `\n\tReadme: ${this.readme}` : ""}` +
46-
`${this.tag ? `\n\tTag: ${this.tag}` : ""}`
47-
);
58+
get tag() {
59+
return this.#tag;
4860
}
4961
}

.github/shared/src/spec-model.js

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,9 @@ export class SpecModel {
3636
* @param {Object} [options]
3737
* @param {import('./logger.js').ILogger} [options.logger]
3838
*/
39-
constructor(folder, options) {
39+
constructor(folder, options = {}) {
40+
const { logger } = options;
41+
4042
const resolvedFolder = resolve(folder);
4143

4244
const cachedSpecModel = specModelCache.get(resolvedFolder);
@@ -45,7 +47,7 @@ export class SpecModel {
4547
}
4648

4749
this.#folder = resolvedFolder;
48-
this.#logger = options?.logger;
50+
this.#logger = logger;
4951

5052
specModelCache.set(resolvedFolder, this);
5153
}
@@ -216,7 +218,7 @@ export class SpecModel {
216218
* @param {ToJSONOptions} [options]
217219
* @returns {Promise<Object>}
218220
*/
219-
async toJSONAsync(options) {
221+
async toJSONAsync(options = {}) {
220222
return await embedError(async () => {
221223
const readmes = await mapAsync(
222224
[...(await this.getReadmes()).values()].sort((a, b) => a.path.localeCompare(b.path)),
@@ -240,14 +242,17 @@ export class SpecModel {
240242
/**
241243
* @template T
242244
* @param {() => Promise<T>} fn
243-
* @param {{embedErrors?: boolean}} [options]
245+
* @param {Object} [options]
246+
* @param {boolean} [options.embedErrors]
244247
* @returns {Promise<T | {error: string}>}
245248
*/
246-
export async function embedError(fn, options) {
249+
export async function embedError(fn, options = {}) {
250+
const { embedErrors } = options;
251+
247252
try {
248253
return await fn();
249254
} catch (error) {
250-
if (options?.embedErrors && error instanceof Error) {
255+
if (embedErrors && error instanceof Error) {
251256
return { error: error.message };
252257
} else {
253258
throw error;

.github/shared/src/swagger.js

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -60,11 +60,13 @@ export class Swagger {
6060
* @param {import('./logger.js').ILogger} [options.logger]
6161
* @param {Tag} [options.tag]
6262
*/
63-
constructor(path, options) {
64-
const rootDir = dirname(options?.tag?.readme?.path ?? "");
63+
constructor(path, options = {}) {
64+
const { logger, tag } = options;
65+
66+
const rootDir = dirname(tag?.readme?.path ?? "");
6567
this.#path = resolve(rootDir, path);
66-
this.#logger = options?.logger;
67-
this.#tag = options?.tag;
68+
this.#logger = logger;
69+
this.#tag = tag;
6870
}
6971

7072
/**
@@ -202,14 +204,16 @@ export class Swagger {
202204
* @param {ToJSONOptions} [options]
203205
* @returns {Promise<Object>}
204206
*/
205-
async toJSONAsync(options) {
207+
async toJSONAsync(options = {}) {
208+
const { includeRefs, relativePaths } = options;
209+
206210
return await embedError(
207211
async () => ({
208212
path:
209-
options?.relativePaths && this.#tag?.readme?.specModel
213+
relativePaths && this.#tag?.readme?.specModel
210214
? relative(this.#tag?.readme?.specModel.folder, this.#path)
211215
: this.#path,
212-
refs: options?.includeRefs
216+
refs: includeRefs
213217
? await mapAsync(
214218
[...(await this.getRefs()).values()].sort((a, b) => a.path.localeCompare(b.path)),
215219
async (s) =>

.github/shared/src/tag.js

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,12 @@ export class Tag {
3232
* @param {import('./logger.js').ILogger} [options.logger]
3333
* @param {Readme} [options.readme]
3434
*/
35-
constructor(name, inputFilePaths, options) {
35+
constructor(name, inputFilePaths, options = {}) {
36+
const { logger, readme } = options;
37+
3638
this.#name = name;
37-
this.#logger = options?.logger;
38-
this.#readme = options?.readme;
39+
this.#logger = logger;
40+
this.#readme = readme;
3941

4042
this.#inputFiles = new Map(
4143
inputFilePaths.map((p) => {
@@ -70,7 +72,7 @@ export class Tag {
7072
* @param {ToJSONOptions} [options]
7173
* @returns {Promise<Object>}
7274
*/
73-
async toJSONAsync(options) {
75+
async toJSONAsync(options = {}) {
7476
return await embedError(
7577
async () => ({
7678
name: this.#name,

0 commit comments

Comments
 (0)