Skip to content

Commit 93fb500

Browse files
authored
[spec-model] Add Swagger.getTypeSpecGenerated() (Azure#38866)
- Ensure all data is analyzed lazily - Preparing to enable tseslint.configs.recommendedTypeChecked
1 parent 4a98ab7 commit 93fb500

File tree

3 files changed

+152
-57
lines changed

3 files changed

+152
-57
lines changed

.github/shared/eslint.config.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,12 @@ export default defineConfig(
1414
// we only run in node, not browser
1515
globals: globals.node,
1616
// required to use tseslint.configs.recommendedTypeChecked
17-
parserOptions: { projectService: true },
17+
parserOptions: {
18+
projectService: true,
19+
// ensures the tsconfig path resolves relative to this file
20+
// default is process.cwd() when running eslint, which may be incorrect
21+
tsconfigRootDir: import.meta.dirname,
22+
},
1823
},
1924
},
2025
{

.github/shared/src/swagger.js

Lines changed: 119 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@ import { embedError } from "./spec-model.js";
2828
* @property {Object[]} [refs]
2929
*/
3030

31+
const infoSchema = z.object({
32+
"x-typespec-generated": z.array(z.object({ emitter: z.string().optional() })).optional(),
33+
});
34+
/**
35+
* @typedef {import("zod").infer<typeof infoSchema>} InfoObject
36+
*/
37+
3138
// https://swagger.io/specification/v2/#operation-object
3239
const operationSchema = z.object({ operationId: z.string().optional() });
3340
/**
@@ -53,6 +60,7 @@ const pathsSchema = z.record(z.string(), pathSchema);
5360

5461
// https://swagger.io/specification/v2/#swagger-object
5562
const swaggerSchema = z.object({
63+
info: infoSchema.optional(),
5664
paths: pathsSchema.optional(),
5765
"x-ms-paths": pathsSchema.optional(),
5866
});
@@ -100,22 +108,42 @@ export class Swagger {
100108
/**
101109
* Content of swagger file, either loaded from `#path` or passed in via `options`.
102110
*
103-
* Reset to `undefined` after `#data` is loaded to save memory.
104-
*
105111
* @type {string | undefined}
106112
*/
107113
#content;
108114

109-
// operations: Map of the operations in this swagger, using `operationId` as key
110-
/** @type {{operations: Map<string, Operation>, refs: Map<string, Swagger>} | undefined} */
111-
#data;
115+
/**
116+
* Content of swagger file, represented as an untyped JSON object
117+
*
118+
* @type {unknown | undefined}
119+
*/
120+
#contentJSON;
121+
122+
/**
123+
* Content of swagger file, represented as a typed object
124+
*
125+
* @type {SwaggerObject | undefined}
126+
* */
127+
#contentObject;
112128

113129
/** @type {import('./logger.js').ILogger | undefined} */
114130
#logger;
115131

132+
/**
133+
* Map of the operations in this swagger, using `operationId` as key
134+
*
135+
* @type {Map<string, Operation> | undefined}
136+
*/
137+
#operations;
138+
116139
/** @type {string} absolute path */
117140
#path;
118141

142+
/**
143+
* @type {Map<string, Swagger> | undefined}
144+
*/
145+
#refs;
146+
119147
/** @type {Tag | undefined} Tag that contains this Swagger */
120148
#tag;
121149

@@ -137,48 +165,77 @@ export class Swagger {
137165
this.#tag = tag;
138166
}
139167

140-
async #getData() {
141-
if (!this.#data) {
168+
/**
169+
* @returns {Promise<string>} Content of swagger file, represented as a string, either loaded from `#path` or passed in via `options`
170+
* @throws {SpecModelError}
171+
*/
172+
async #getContent() {
173+
if (this.#content === undefined) {
142174
const path = this.#path;
143175

144-
const content =
145-
this.#content ??
146-
(await this.#wrapError(
147-
async () => await readFile(path, { encoding: "utf8" }),
148-
"Failed to read file for swagger",
149-
));
176+
this.#content = await this.#wrapError(
177+
async () => await readFile(path, { encoding: "utf8" }),
178+
"Failed to read file for swagger",
179+
);
180+
}
181+
182+
return this.#content;
183+
}
150184

151-
/** @type {Map<string, Operation>} */
152-
const operations = new Map();
185+
/**
186+
* @returns {Promise<unknown>} Content of swagger file, represented as an untyped JSON object
187+
* @throws {SpecModelError}
188+
*/
189+
async #getContentJSON() {
190+
if (this.#contentJSON === undefined) {
191+
const content = await this.#getContent();
153192

154-
const swaggerJson = await this.#wrapError(
193+
this.#contentJSON = await this.#wrapError(
155194
() => /** @type {unknown} */ (JSON.parse(content)),
156195
"Failed to parse JSON for swagger",
157196
);
197+
}
198+
199+
return this.#contentJSON;
200+
}
201+
202+
/**
203+
* @returns {Promise<SwaggerObject>} Content of swagger file, represented as a typed object
204+
* @throws {SpecModelError}
205+
*/
206+
async #getContentObject() {
207+
if (this.#contentObject === undefined) {
208+
const contentJSON = await this.#getContentJSON();
158209

159-
/** @type {SwaggerObject} */
160-
const swagger = await this.#wrapError(
161-
() => swaggerSchema.parse(swaggerJson),
210+
this.#contentObject = await this.#wrapError(
211+
() => swaggerSchema.parse(contentJSON),
162212
"Failed to parse schema for swagger",
163213
);
214+
}
164215

165-
// Process regular paths
166-
if (swagger.paths) {
167-
for (const [path, pathObject] of Object.entries(swagger.paths)) {
168-
this.#addOperations(operations, path, pathObject);
169-
}
170-
}
216+
return this.#contentObject;
217+
}
171218

172-
// Process x-ms-paths (Azure extension)
173-
if (swagger["x-ms-paths"]) {
174-
for (const [path, pathObject] of Object.entries(swagger["x-ms-paths"])) {
175-
this.#addOperations(operations, path, pathObject);
176-
}
177-
}
219+
/**
220+
* @returns {Promise<Map<string, Swagger>>} Map of swaggers referenced from this swagger, using `path` as key
221+
*/
222+
async getRefs() {
223+
const allRefs = await this.#getRefs();
224+
225+
// filter out any paths that are examples
226+
const filtered = new Map([...allRefs].filter(([path]) => !example(path)));
227+
228+
return filtered;
229+
}
230+
231+
async #getRefs() {
232+
if (this.#refs === undefined) {
233+
const path = this.#path;
234+
const contentJSON = await this.#getContentJSON();
178235

179236
const schema = await this.#wrapError(
180237
async () =>
181-
await $RefParser.resolve(this.#path, swaggerJson, {
238+
await $RefParser.resolve(path, contentJSON, {
182239
resolve: { file: excludeExamples, http: false },
183240
}),
184241
"Failed to resolve file for swagger",
@@ -189,7 +246,7 @@ export class Swagger {
189246
// Exclude ourself
190247
.filter((p) => resolve(p) !== resolve(this.#path));
191248

192-
const refs = new Map(
249+
this.#refs = new Map(
193250
refPaths.map((p) => {
194251
const swagger = new Swagger(p, {
195252
logger: this.#logger,
@@ -198,49 +255,56 @@ export class Swagger {
198255
return [swagger.path, swagger];
199256
}),
200257
);
201-
202-
this.#data = { operations, refs };
203-
204-
// Clear #content to save memory, since it's no longer needed after #data is loaded
205-
this.#content = undefined;
206258
}
207259

208-
return this.#data;
260+
return this.#refs;
209261
}
210262

211263
/**
212-
* @returns {Promise<Map<string, Swagger>>} Map of swaggers referenced from this swagger, using `path` as key
264+
* @returns {Promise<Map<string, Swagger>>} Map of examples referenced from this swagger, using `path` as key
213265
*/
214-
async getRefs() {
266+
async getExamples() {
215267
const allRefs = await this.#getRefs();
216268

217269
// filter out any paths that are examples
218-
const filtered = new Map([...allRefs].filter(([path]) => !example(path)));
270+
const filtered = new Map([...allRefs].filter(([path]) => example(path)));
219271

220272
return filtered;
221273
}
222274

223-
async #getRefs() {
224-
return (await this.#getData()).refs;
225-
}
226-
227275
/**
228-
* @returns {Promise<Map<string, Swagger>>} Map of examples referenced from this swagger, using `path` as key
276+
* @returns {Promise<Map<string, Operation>>} Map of the operations in this swagger, using `operationId` as key
229277
*/
230-
async getExamples() {
231-
const allRefs = await this.#getRefs();
278+
async getOperations() {
279+
if (this.#operations === undefined) {
280+
const contentObject = await this.#getContentObject();
232281

233-
// filter out any paths that are examples
234-
const filtered = new Map([...allRefs].filter(([path]) => example(path)));
282+
this.#operations = new Map();
235283

236-
return filtered;
284+
// Process regular paths
285+
if (contentObject.paths) {
286+
for (const [path, pathObject] of Object.entries(contentObject.paths)) {
287+
this.#addOperations(this.#operations, path, pathObject);
288+
}
289+
}
290+
291+
// Process x-ms-paths (Azure extension)
292+
if (contentObject["x-ms-paths"]) {
293+
for (const [path, pathObject] of Object.entries(contentObject["x-ms-paths"])) {
294+
this.#addOperations(this.#operations, path, pathObject);
295+
}
296+
}
297+
}
298+
299+
return this.#operations;
237300
}
238301

239302
/**
240-
* @returns {Promise<Map<string, Operation>>} Map of the operations in this swagger, using `operationId` as key
303+
* @returns {Promise<boolean>} True if the spec was generated from TypeSpec
241304
*/
242-
async getOperations() {
243-
return (await this.#getData()).operations;
305+
async getTypeSpecGenerated() {
306+
const contentObject = await this.#getContentObject();
307+
return contentObject.info?.["x-typespec-generated"] !== undefined;
244308
}
245309

246310
/**

.github/shared/test/swagger.test.js

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { ConsoleLogger } from "../src/logger.js";
77
import { Readme } from "../src/readme.js";
88
import { SpecModel } from "../src/spec-model.js";
99
import { Tag } from "../src/tag.js";
10+
import { swaggerTypeSpecGenerated } from "./examples.js";
1011

1112
const __dirname = dirname(fileURLToPath(import.meta.url));
1213

@@ -41,6 +42,26 @@ describe("Swagger", () => {
4142

4243
const refs = await swagger.getRefs();
4344
expect(new Set(refs.keys())).toEqual(new Set());
45+
46+
expect(await swagger.getTypeSpecGenerated()).toEqual(false);
47+
});
48+
49+
it("can be created with typespec-generated string content", async () => {
50+
const folder = "/fake";
51+
const swagger = new Swagger(resolve(folder, "empty.json"), {
52+
content: swaggerTypeSpecGenerated,
53+
});
54+
55+
const operations = await swagger.getOperations();
56+
expect(new Set(operations.keys())).toEqual(new Set());
57+
58+
const examples = await swagger.getExamples();
59+
expect(new Set(examples.keys())).toEqual(new Set());
60+
61+
const refs = await swagger.getRefs();
62+
expect(new Set(refs.keys())).toEqual(new Set());
63+
64+
expect(await swagger.getTypeSpecGenerated()).toEqual(true);
4465
});
4566

4667
it("can be created with sample string content", async () => {
@@ -112,7 +133,12 @@ describe("Swagger", () => {
112133
tag: new Tag("test-tag", [], { readme: new Readme("/fake/readme.md") }),
113134
});
114135

115-
await expect(swagger.getRefs()).rejects.toThrowErrorMatchingInlineSnapshot(`
136+
// getRefs() shouldn't throw, since it doesn't care about the zod schema
137+
// ensures we are evaluating each data method lazily
138+
await expect(swagger.getRefs().then((m) => new Set(m.keys()))).resolves.toEqual(new Set());
139+
140+
// getOperations() should throw, since the input wasn't valid per the zod schema
141+
await expect(swagger.getOperations()).rejects.toThrowErrorMatchingInlineSnapshot(`
116142
[SpecModelError: Failed to parse schema for swagger: ${resolve("/fake/invalid.json")}
117143
Problem File: ${resolve("/fake/invalid.json")}
118144
Readme: ${resolve("/fake/readme.md")}

0 commit comments

Comments
 (0)