oav/lib/xMsExampleExtractor.ts

339 строки
12 KiB
TypeScript

// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License. See License.txt in the project root for license information.
import * as fs from "fs";
import * as pathlib from "path";
import { URL } from "url";
import {
MutableStringMap,
StringMap,
entries,
mapEntries,
keys,
values,
} from "@azure-tools/openapi-tools-common";
import swaggerParser from "@apidevtools/swagger-parser";
import { log } from "./util/logging";
import { kvPairsToObject } from "./util/utils";
export interface Options {
output?: string;
shouldResolveXmsExamples?: unknown;
matchApiVersion?: unknown;
}
const mkdirRecursiveSync = (dir: string) => {
if (!fs.existsSync(dir)) {
const parent = pathlib.dirname(dir);
if (parent !== dir) {
mkdirRecursiveSync(parent);
}
fs.mkdirSync(dir);
}
};
/**
* @class
*/
export class XMsExampleExtractor {
private readonly specPath: string;
private readonly recordings: string;
private readonly options: Options;
/**
* @constructor
* Initializes a new instance of the xMsExampleExtractor class.
*
* @param {string} specPath the swagger spec path
*
* @param {object} recordings the folder for recordings
*
* @param {object} [options] The options object
*
* @param {object} [options.matchApiVersion] Only generate examples if api-version matches.
* Default: false
*
* @param {object} [options.output] Output folder for the generated examples.
*/
public constructor(specPath: string, recordings: string, options: Options) {
if (
specPath === null ||
specPath === undefined ||
typeof specPath.valueOf() !== "string" ||
!specPath.trim().length
) {
throw new Error(
"specPath is a required property of type string and it cannot be an empty string."
);
}
if (
recordings === null ||
recordings === undefined ||
typeof recordings.valueOf() !== "string" ||
!recordings.trim().length
) {
throw new Error(
"recordings is a required property of type string and it cannot be an empty string."
);
}
this.specPath = specPath;
this.recordings = recordings;
if (!options) {
options = {};
}
if (options.output === null || options.output === undefined) {
options.output = process.cwd() + "/output";
}
if (
options.shouldResolveXmsExamples === null ||
options.shouldResolveXmsExamples === undefined
) {
options.shouldResolveXmsExamples = true;
}
if (options.matchApiVersion === null || options.matchApiVersion === undefined) {
options.matchApiVersion = false;
}
this.options = options;
log.debug(`specPath : ${this.specPath}`);
log.debug(`recordings : ${this.recordings}`);
log.debug(`options.output : ${this.options.output}`);
log.debug(`options.matchApiVersion : ${this.options.matchApiVersion}`);
}
public extractOne(
relativeExamplesPath: string,
outputExamples: string,
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
api: any,
recordingFileName: string
): void {
const recording = JSON.parse(fs.readFileSync(recordingFileName).toString());
const paths = api.paths;
let pathIndex = 0;
let pathParams: MutableStringMap<number> = {};
for (const path of keys(paths)) {
pathIndex++;
const searchResult = path.match(/\/{\w*\}/g);
const pathParts = path.split("/");
let pathToMatch = path;
pathParams = {};
if (searchResult !== null) {
for (const match of searchResult) {
const splitRegEx = /[{}]/;
const pathParam = match.split(splitRegEx)[1];
for (const [part, value] of entries(pathParts)) {
const pathPart = "/" + value;
if (pathPart.localeCompare(match) === 0) {
pathParams[pathParam] = part;
}
}
pathToMatch = pathToMatch.replace(match, "/[^/]+");
}
}
let newPathToMatch = pathToMatch.replace(/\//g, "\\/");
newPathToMatch = newPathToMatch + "$";
// for this API path (and method), try to find it in the recording file, and get
// the data
const recordingEntries: StringMap<any> = recording.Entries;
let entryIndex = 0;
let queryParams: any = {};
for (const recordingEntry of values(recordingEntries)) {
entryIndex++;
const parsedUrl = new URL(recordingEntry.RequestUri, "https://management.azure.com");
let recordingPath = parsedUrl.href || "";
queryParams = kvPairsToObject(parsedUrl.searchParams) || {};
const hostUrl = parsedUrl ? parsedUrl.protocol! + "//" + parsedUrl.hostname! : undefined;
const headerParams = recordingEntry.RequestHeaders;
// if command-line included check for API version, validate api-version from URI in
// recordings matches the api-version of the spec
if (
!this.options.matchApiVersion ||
("api-version" in queryParams && queryParams["api-version"] === api.info.version)
) {
recordingPath = recordingPath.replace(/\?.*/, "");
const recordingPathParts = recordingPath.split("/");
// eslint-disable-next-line @typescript-eslint/prefer-regexp-exec
const match = recordingPath.match(newPathToMatch);
if (match !== null) {
log.silly("path: " + path);
log.silly("recording path: " + recordingPath);
const pathParamsValues: MutableStringMap<unknown> = {};
for (const [p, v] of mapEntries(pathParams)) {
const index = v;
pathParamsValues[p] = recordingPathParts[index];
}
if (hostUrl !== undefined) {
pathParamsValues.url = hostUrl;
}
// found a match in the recording
const requestMethodFromRecording = recordingEntry.RequestMethod;
const infoFromOperation = paths[path][requestMethodFromRecording.toLowerCase()];
if (typeof infoFromOperation !== "undefined") {
// need to consider each method in operation
const fileNameArray = recordingFileName.split("/");
let fileName = fileNameArray[fileNameArray.length - 1];
fileName = fileName.split(".json")[0];
fileName = fileName.replace(/\//g, "-");
const exampleFileName = `${fileName}-${requestMethodFromRecording}-example-${pathIndex}${entryIndex}.json`;
const ref = {
$ref: relativeExamplesPath + exampleFileName,
};
const exampleFriendlyName = `${fileName}${requestMethodFromRecording}${pathIndex}${entryIndex}`;
log.debug(`exampleFriendlyName: ${exampleFriendlyName}`);
if (!("x-ms-examples" in infoFromOperation)) {
infoFromOperation["x-ms-examples"] = {};
}
infoFromOperation["x-ms-examples"][exampleFriendlyName] = ref;
const exampleL: {
parameters: MutableStringMap<unknown>;
responses: MutableStringMap<{
body?: unknown;
}>;
} = {
parameters: {},
responses: {},
};
const paramsToProcess = [
...mapEntries(pathParamsValues),
...mapEntries(queryParams),
...mapEntries(headerParams),
];
for (const paramEntry of paramsToProcess) {
const param = paramEntry[0];
const v = paramEntry[1];
exampleL.parameters[param] = v;
}
const params = infoFromOperation.parameters;
for (const param of keys(infoFromOperation.parameters)) {
if (params[param].in === "body") {
const bodyParamName = params[param].name;
const bodyParamValue = recordingEntry.RequestBody;
const bodyParamExample: MutableStringMap<unknown> = {};
bodyParamExample[bodyParamName] = bodyParamValue;
exampleL.parameters[bodyParamName] =
bodyParamValue !== "" ? JSON.parse(bodyParamValue) : "";
}
}
const parseResponseBody = (body: any) => {
try {
return JSON.parse(body);
} catch (err) {
return body;
}
};
for (const _v of keys(infoFromOperation.responses)) {
const statusCodeFromRecording = recordingEntry.StatusCode;
let responseBody = recordingEntry.ResponseBody;
if (typeof responseBody === "string" && responseBody !== "") {
responseBody = parseResponseBody(responseBody);
}
exampleL.responses[statusCodeFromRecording] = {
body: responseBody,
};
}
log.info(
`Writing x-ms-examples at ${pathlib.resolve(outputExamples, exampleFileName)}`
);
const examplePath = pathlib.join(outputExamples, exampleFileName);
const dir = pathlib.dirname(examplePath);
mkdirRecursiveSync(dir);
fs.writeFileSync(examplePath, JSON.stringify(exampleL, null, 2));
}
}
}
}
}
}
/**
* Extracts x-ms-examples from the recordings
*/
public async extract(): Promise<StringMap<unknown>> {
if (this.options.output === undefined) {
throw new Error("this.options.output === undefined");
}
this.mkdirSync(this.options.output);
this.mkdirSync(this.options.output + "/examples");
this.mkdirSync(this.options.output + "/swagger");
const outputExamples = pathlib.join(this.options.output, "examples");
const relativeExamplesPath = "../examples/";
const specName = this.specPath.split("/");
const outputSwagger = pathlib.join(
this.options.output,
"swagger",
specName[specName.length - 1].split(".")[0] + ".json"
);
const accErrors: MutableStringMap<unknown> = {};
const filesArray: string[] = [];
this.getFileList(this.recordings, filesArray);
const recordingFiles = filesArray;
try {
const api = await swaggerParser.parse(this.specPath);
for (const recordingFileName of recordingFiles) {
log.debug(`Processing recording file: ${recordingFileName}`);
try {
this.extractOne(relativeExamplesPath, outputExamples, api, recordingFileName);
log.info(`Writing updated swagger with x-ms-examples at ${outputSwagger}`);
fs.writeFileSync(outputSwagger, JSON.stringify(api, null, 2));
} catch (err) {
accErrors[recordingFileName] = err.toString();
log.warn(`Error processing recording file: "${recordingFileName}"`);
log.warn(`Error: "${err.toString()} "`);
}
}
if (JSON.stringify(accErrors) !== "{}") {
log.error(`Errors loading/parsing recording files.`);
log.error(`${JSON.stringify(accErrors)}`);
}
} catch (err) {
process.exitCode = 1;
log.error(err);
}
return accErrors;
}
private mkdirSync(dir: string): void {
try {
fs.mkdirSync(dir);
} catch (e) {
if (e.code !== "EEXIST") {
throw e;
}
}
}
private getFileList(dir: string, fileList: string[]): string[] {
const files = fs.readdirSync(dir);
fileList = fileList || [];
files.forEach((file) => {
if (fs.statSync(pathlib.join(dir, file)).isDirectory()) {
fileList = this.getFileList(pathlib.join(dir, file), fileList);
} else {
fileList.push(pathlib.join(dir, file));
}
});
return fileList;
}
}