Skip to content

Commit 0949dbf

Browse files
committed
[Swagger] Add getTypeSpecGenerated()
- Ensure all data is analyzed lazily
1 parent 32425b4 commit 0949dbf

File tree

2 files changed

+148
-56
lines changed

2 files changed

+148
-56
lines changed

.github/shared/src/swagger.js

Lines changed: 121 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,79 @@ export class Swagger {
137165
this.#tag = tag;
138166
}
139167

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

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-
));
178+
this.#content = await this.#wrapError(
179+
async () => await readFile(path, { encoding: "utf8" }),
180+
"Failed to read file for swagger",
181+
);
182+
}
150183

151-
/** @type {Map<string, Operation>} */
152-
const operations = new Map();
184+
return this.#content;
185+
}
153186

154-
const swaggerJson = await this.#wrapError(
187+
/**
188+
* @returns {Promise<unknown>} Content of swagger file, represented as an untyped JSON object
189+
* @throws {SpecModelError}
190+
*/
191+
async #getContentJSON() {
192+
if (this.#contentJSON === undefined) {
193+
const content = await this.#getContent();
194+
195+
this.#contentJSON = await this.#wrapError(
155196
() => /** @type {unknown} */ (JSON.parse(content)),
156197
"Failed to parse JSON for swagger",
157198
);
199+
}
200+
201+
return this.#contentJSON;
202+
}
203+
204+
/**
205+
* @returns {Promise<SwaggerObject>} Content of swagger file, represented as a typed object
206+
* @throws {SpecModelError}
207+
*/
208+
async #getContentObject() {
209+
if (this.#contentObject === undefined) {
210+
const contentJSON = await this.#getContentJSON();
158211

159-
/** @type {SwaggerObject} */
160-
const swagger = await this.#wrapError(
161-
() => swaggerSchema.parse(swaggerJson),
212+
this.#contentObject = await this.#wrapError(
213+
() => swaggerSchema.parse(contentJSON),
162214
"Failed to parse schema for swagger",
163215
);
216+
}
164217

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-
}
218+
return this.#contentObject;
219+
}
171220

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-
}
221+
/**
222+
* @returns {Promise<Map<string, Swagger>>} Map of swaggers referenced from this swagger, using `path` as key
223+
*/
224+
async getRefs() {
225+
const allRefs = await this.#getRefs();
226+
227+
// filter out any paths that are examples
228+
const filtered = new Map([...allRefs].filter(([path]) => !example(path)));
229+
230+
return filtered;
231+
}
232+
233+
async #getRefs() {
234+
if (this.#refs === undefined) {
235+
const path = this.#path;
236+
const contentJSON = await this.#getContentJSON();
178237

179238
const schema = await this.#wrapError(
180239
async () =>
181-
await $RefParser.resolve(this.#path, swaggerJson, {
240+
await $RefParser.resolve(path, contentJSON, {
182241
resolve: { file: excludeExamples, http: false },
183242
}),
184243
"Failed to resolve file for swagger",
@@ -189,7 +248,7 @@ export class Swagger {
189248
// Exclude ourself
190249
.filter((p) => resolve(p) !== resolve(this.#path));
191250

192-
const refs = new Map(
251+
this.#refs = new Map(
193252
refPaths.map((p) => {
194253
const swagger = new Swagger(p, {
195254
logger: this.#logger,
@@ -198,49 +257,56 @@ export class Swagger {
198257
return [swagger.path, swagger];
199258
}),
200259
);
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;
206260
}
207261

208-
return this.#data;
262+
return this.#refs;
209263
}
210264

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

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

220274
return filtered;
221275
}
222276

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

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

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

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

246312
/**

.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)