diff --git a/README.md b/README.md index 6af029f36..6b3e0c19b 100644 --- a/README.md +++ b/README.md @@ -10,11 +10,15 @@ 🚀 Convert [OpenAPI 3.0][openapi3] and [2.0 (Swagger)][openapi2] schemas to TypeScript interfaces using Node.js. -💅 The output is prettified with [Prettier][prettier] (and can be customized!). +**Features** -👉 Works for both local and remote resources (filesystem and HTTP). +- Convert [Open API 3.x][openapi3] and [Swagger 2.x][openapi2] to TypeScript types +- Load schemas either from local `.yaml` or `.json` files, or from a remote URL (simple authentication supported with the `--auth` flag) +- Supports remote `$ref`s using [json-schema-ref-parser][json-schema-ref-parser] +- Formats output using [Prettier][prettier] +- Uses the latest TypeScript 4.0 syntax -View examples: +**Examples** - [Stripe, OpenAPI 2.0](./examples/stripe-openapi2.ts) - [Stripe, OpenAPI 3.0](./examples/stripe-openapi3.ts) @@ -106,19 +110,24 @@ npm i --save-dev openapi-typescript ``` ```js -const { readFileSync } = require("fs"); +const fs = require("fs"); const openapiTS = require("openapi-typescript").default; -const input = JSON.parse(readFileSync("spec.json", "utf8")); // Input can be any JS object (OpenAPI format) -const output = openapiTS(input); // Outputs TypeScript defs as a string (to be parsed, or written to a file) +// option 1: load [object] as schema (JSON only) +const schema = await fs.promises.readFile("spec.json", "utf8") // must be OpenAPI JSON +const output = await openapiTS(JSON.parse(schema)); + +// option 2: load [string] as local file (YAML or JSON; released in v3.3) +const localPath = path.join(__dirname, 'spec.yaml'); // may be YAML or JSON format +const output = await openapiTS(localPath); + +// option 3: load [string] as remote URL (YAML or JSON; released in v3.3) +const output = await openapiTS('https://myurl.com/v1/openapi.yaml'); ``` -The Node API is a bit more flexible: it will only take a JS object as input (OpenAPI format), and return a string of TS -definitions. This lets you pull from any source (a Swagger server, local files, etc.), and similarly lets you parse, -post-process, and save the output anywhere. +The Node API may be useful if dealing with dynamically-created schemas, or you’re using within context of a larger application. Pass in either a JSON-friendly object to load a schema from memory, or a string to load a schema from a local file or remote URL (it will load the file quickly using built-in Node methods). Note that a YAML string isn’t supported in the Node.js API; either use the CLI or convert to JSON using [js-yaml][js-yaml] first. -If your specs are in YAML, you’ll have to convert them to JS objects using a library such as [js-yaml][js-yaml]. If -you’re batching large folders of specs, [glob][glob] may also come in handy. +⚠️ As of `v3.3`, this is an async function. #### Custom Formatter @@ -164,6 +173,7 @@ encouraged but not required. [glob]: https://www.npmjs.com/package/glob [js-yaml]: https://www.npmjs.com/package/js-yaml +[json-schema-ref-parser]: https://github.com/APIDevTools/json-schema-ref-parser [namespace]: https://www.typescriptlang.org/docs/handbook/namespaces.html [npm-run-all]: https://www.npmjs.com/package/npm-run-all [openapi-format]: https://swagger.io/specification/#data-types diff --git a/bin/cli.js b/bin/cli.js index f3ed4c08a..33be91a70 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -6,7 +6,6 @@ const path = require("path"); const meow = require("meow"); const glob = require("tiny-glob"); const { default: openapiTS } = require("../dist/cjs/index.js"); -const { loadSpec } = require("./loaders"); const cli = meow( `Usage @@ -70,25 +69,15 @@ function errorAndExit(errorMessage) { async function generateSchema(pathToSpec) { const output = cli.flags.output ? OUTPUT_FILE : OUTPUT_STDOUT; // FILE or STDOUT - // load spec - let spec = undefined; - try { - spec = await loadSpec(pathToSpec, { - auth: cli.flags.auth, - log: output !== OUTPUT_STDOUT, - }); - } catch (err) { - errorAndExit(`❌ ${err}`); - } - // generate schema - const result = openapiTS(spec, { - auth: cli.flags.auth, + const result = await openapiTS(pathToSpec, { additionalProperties: cli.flags.additionalProperties, - immutableTypes: cli.flags.immutableTypes, + auth: cli.flags.auth, defaultNonNullable: cli.flags.defaultNonNullable, + immutableTypes: cli.flags.immutableTypes, prettierConfig: cli.flags.prettierConfig, rawSchema: cli.flags.rawSchema, + silent: output === OUTPUT_STDOUT, version: cli.flags.version, }); @@ -108,13 +97,14 @@ async function generateSchema(pathToSpec) { console.log(green(`🚀 ${pathToSpec} -> ${bold(outputFile)} [${time}ms]`)); } else { process.stdout.write(result); + // if stdout, (still) don’t log anything to console! } return result; } async function main() { - const output = cli.flags.output ? OUTPUT_FILE : OUTPUT_STDOUT; // FILE or STDOUT + let output = cli.flags.output ? OUTPUT_FILE : OUTPUT_STDOUT; // FILE or STDOUT const pathToSpec = cli.input[0]; if (output === OUTPUT_FILE) { @@ -148,7 +138,7 @@ async function main() { errorAndExit(`❌ Expected directory for --output if using glob patterns. Received "${cli.flags.output}".`); } - // generate schema(s) + // generate schema(s) in parallel await Promise.all( inputSpecPaths.map(async (specPath) => { if (cli.flags.output !== "." && output === OUTPUT_FILE) { diff --git a/bin/loaders/index.js b/bin/loaders/index.js deleted file mode 100644 index ef6a64308..000000000 --- a/bin/loaders/index.js +++ /dev/null @@ -1,69 +0,0 @@ -const mime = require("mime"); -const yaml = require("js-yaml"); -const { bold, yellow } = require("kleur"); - -const loadFromFs = require("./loadFromFs"); -const loadFromHttp = require("./loadFromHttp"); - -async function load(pathToSpec, { auth }) { - // option 1: remote URL - if (/^https?:\/\//.test(pathToSpec)) { - try { - return loadFromHttp(pathToSpec, { auth }); - } catch (e) { - if (e.code === "ENOTFOUND") { - throw new Error( - `The URL ${pathToSpec} could not be reached. Ensure the URL is correct, that you're connected to the internet and that the URL is reachable via a browser.` - ); - } - throw e; - } - } - - // option 2: local file - return { - body: loadFromFs(pathToSpec), - contentType: mime.getType(pathToSpec), - }; -} - -async function loadSpec(pathToSpec, { auth, log = true }) { - if (log === true) { - console.log(yellow(`🔭 Loading spec from ${bold(pathToSpec)}…`)); // only log if not writing to stdout - } - - const { body, contentType } = await load(pathToSpec, { auth }); - - switch (contentType) { - case "application/openapi+yaml": - case "text/yaml": { - try { - return yaml.load(body); - } catch (err) { - throw new Error(`YAML: ${err.toString()}`); - } - } - case "application/json": - case "application/json5": - case "application/openapi+json": { - try { - return JSON.parse(body); - } catch (err) { - throw new Error(`JSON: ${err.toString()}`); - } - } - default: { - try { - return JSON.parse(body); // unknown attempt 1: JSON - } catch (err1) { - try { - return yaml.load(body); // unknown attempt 2: YAML - } catch (err2) { - // give up: unknown type - throw new Error(`Unknown format${contentType ? `: "${contentType}"` : ""}. Only YAML or JSON supported.`); - } - } - } - } -} -exports.loadSpec = loadSpec; diff --git a/bin/loaders/loadFromFs.js b/bin/loaders/loadFromFs.js deleted file mode 100644 index 0ac58ef4b..000000000 --- a/bin/loaders/loadFromFs.js +++ /dev/null @@ -1,14 +0,0 @@ -const fs = require("fs"); -const path = require("path"); - -function loadFromFs(pathToSpec) { - const pathname = path.resolve(process.cwd(), pathToSpec); - const pathExists = fs.existsSync(pathname); - - if (!pathExists) { - throw new Error(`Cannot find spec under the following path: ${pathname}`); - } - - return fs.readFileSync(pathname, "utf8"); -} -module.exports = loadFromFs; diff --git a/bin/loaders/loadFromHttp.js b/bin/loaders/loadFromHttp.js deleted file mode 100644 index e0be1b3ca..000000000 --- a/bin/loaders/loadFromHttp.js +++ /dev/null @@ -1,57 +0,0 @@ -const http = require("http"); -const https = require("https"); -const { parse } = require("url"); - -// config -const MAX_REDIRECT_COUNT = 10; - -function fetch(url, opts, { redirectCount = 0 } = {}) { - return new Promise((resolve, reject) => { - const { protocol } = parse(url); - - if (protocol !== "http:" && protocol !== "https:") { - throw new Error(`Unsupported protocol: "${protocol}". URL must start with "http://" or "https://".`); - } - - const fetchMethod = protocol === "https:" ? https : http; - const req = fetchMethod.request(url, opts, (res) => { - let rawData = ""; - res.setEncoding("utf8"); - res.on("data", (chunk) => { - rawData += chunk; - }); - res.on("end", () => { - // 2xx: OK - if (res.statusCode >= 200 && res.statusCode < 300) { - return resolve({ - body: rawData, - contentType: res.headers["content-type"].split(";")[0].trim(), - }); - } - - // 3xx: follow redirect (if given) - if (res.statusCode >= 300 && res.headers.location) { - redirectCount += 1; - if (redirectCount >= MAX_REDIRECT_COUNT) { - reject(`Max redirects exceeded`); - return; - } - console.log(`🚥 Redirecting to ${res.headers.location}…`); - return fetch(res.headers.location, opts).then(resolve); - } - - // everything else: throw - return reject(rawData || `${res.statusCode} ${res.statusMessage}`); - }); - }); - req.on("error", (err) => { - reject(err); - }); - req.end(); - }); -} - -function loadFromHttp(pathToSpec, { auth }) { - return fetch(pathToSpec, { method: "GET", auth }); -} -module.exports = loadFromHttp; diff --git a/package-lock.json b/package-lock.json index 52bb8e37d..b94debb13 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "kleur": "^4.1.4", "meow": "^9.0.0", "mime": "^2.5.2", + "node-fetch": "^2.6.1", "prettier": "^2.3.0", "tiny-glob": "^0.2.9" }, @@ -20,12 +21,14 @@ "openapi-typescript": "bin/cli.js" }, "devDependencies": { - "@types/jest": "^26.0.14", - "@types/js-yaml": "^4.0.0", - "@typescript-eslint/eslint-plugin": "^4.26.0", - "@typescript-eslint/parser": "^4.26.0", - "codecov": "^3.8.1", - "eslint": "^7.26.0", + "@types/jest": "^26.0.23", + "@types/js-yaml": "^4.0.1", + "@types/mime": "^2.0.3", + "@types/node-fetch": "^2.5.10", + "@typescript-eslint/eslint-plugin": "^4.25.0", + "@typescript-eslint/parser": "^4.25.0", + "codecov": "^3.8.2", + "eslint": "^7.27.0", "eslint-config-prettier": "^8.3.0", "eslint-plugin-prettier": "^3.4.0", "jest": "^27.0.3", @@ -1241,6 +1244,12 @@ "integrity": "sha512-cxWFQVseBm6O9Gbw1IWb8r6OS4OhSt3hPZLkFApLjM8TEXROBuQGLAH2i2gZpcXdLBIrpXuTDhH7Vbm1iXmNGA==", "dev": true }, + "node_modules/@types/mime": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-2.0.3.tgz", + "integrity": "sha512-Jus9s4CDbqwocc5pOAnh8ShfrnMcPHuJYzVcSUU7lrh8Ni5HuIqX3oilL86p3dlTrk0LzHRCgA/GQ7uNCw6l2Q==", + "dev": true + }, "node_modules/@types/minimist": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.1.tgz", @@ -1252,6 +1261,16 @@ "integrity": "sha512-z/5Yd59dCKI5kbxauAJgw6dLPzW+TNOItNE00PkpzNwUIEwdj/Lsqwq94H5DdYBX7C13aRA0CY32BK76+neEUA==", "dev": true }, + "node_modules/@types/node-fetch": { + "version": "2.5.10", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.5.10.tgz", + "integrity": "sha512-IpkX0AasN44hgEad0gEF/V6EgR5n69VEqPEgnmoM8GsIGro3PowbWs4tR6IhxUTyPLpOn+fiGG6nrQhcmoCuIQ==", + "dev": true, + "dependencies": { + "@types/node": "*", + "form-data": "^3.0.0" + } + }, "node_modules/@types/normalize-package-data": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz", @@ -2393,9 +2412,9 @@ } }, "node_modules/eslint": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.26.0.tgz", - "integrity": "sha512-4R1ieRf52/izcZE7AlLy56uIHHDLT74Yzz2Iv2l6kDaYvEu9x+wMB5dZArVL8SYGXSYV2YAg70FcW5Y5nGGNIg==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.27.0.tgz", + "integrity": "sha512-JZuR6La2ZF0UD384lcbnd0Cgg6QJjiCwhMD6eU4h/VGPcVGwawNNzKU41tgokGXnfjOOyI6QIffthhJTPzzuRA==", "dev": true, "dependencies": { "@babel/code-frame": "7.12.11", @@ -2406,12 +2425,14 @@ "debug": "^4.0.1", "doctrine": "^3.0.0", "enquirer": "^2.3.5", + "escape-string-regexp": "^4.0.0", "eslint-scope": "^5.1.1", "eslint-utils": "^2.1.0", "eslint-visitor-keys": "^2.0.0", "espree": "^7.3.1", "esquery": "^1.4.0", "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", "file-entry-cache": "^6.0.1", "functional-red-black-tree": "^1.0.1", "glob-parent": "^5.0.0", @@ -2423,7 +2444,7 @@ "js-yaml": "^3.13.1", "json-stable-stringify-without-jsonify": "^1.0.1", "levn": "^0.4.1", - "lodash": "^4.17.21", + "lodash.merge": "^4.6.2", "minimatch": "^3.0.4", "natural-compare": "^1.4.0", "optionator": "^0.9.1", @@ -2432,7 +2453,7 @@ "semver": "^7.2.1", "strip-ansi": "^6.0.0", "strip-json-comments": "^3.1.0", - "table": "^6.0.4", + "table": "^6.0.9", "text-table": "^0.2.0", "v8-compile-cache": "^2.0.3" }, @@ -2531,6 +2552,18 @@ "@babel/highlight": "^7.10.4" } }, + "node_modules/eslint/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/eslint/node_modules/globals": { "version": "13.6.0", "resolved": "https://registry.npmjs.org/globals/-/globals-13.6.0.tgz", @@ -5115,6 +5148,24 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "dev": true }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=", + "dev": true + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "node_modules/lodash.truncate": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", + "integrity": "sha1-WjUNoLERO4N+z//VgSy+WNbq4ZM=", + "dev": true + }, "node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -5327,7 +5378,6 @@ "version": "2.6.1", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz", "integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==", - "dev": true, "engines": { "node": "4.x || >=6.0.0" } @@ -5987,6 +6037,23 @@ "node": ">=8" } }, + "node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -6179,30 +6246,36 @@ "dev": true }, "node_modules/table": { - "version": "6.0.7", - "resolved": "https://registry.npmjs.org/table/-/table-6.0.7.tgz", - "integrity": "sha512-rxZevLGTUzWna/qBLObOe16kB2RTnnbhciwgPbMMlazz1yZGVEgnZK762xyVdVznhqxrfCeBMmMkgOOaPwjH7g==", + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/table/-/table-6.7.1.tgz", + "integrity": "sha512-ZGum47Yi6KOOFDE8m223td53ath2enHcYLgOCjGr5ngu8bdIARQk6mN/wRMv4yMRcHnCSnHbCEha4sobQx5yWg==", "dev": true, "dependencies": { - "ajv": "^7.0.2", - "lodash": "^4.17.20", + "ajv": "^8.0.1", + "lodash.clonedeep": "^4.5.0", + "lodash.truncate": "^4.4.2", "slice-ansi": "^4.0.0", - "string-width": "^4.2.0" + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0" }, "engines": { "node": ">=10.0.0" } }, "node_modules/table/node_modules/ajv": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-7.1.1.tgz", - "integrity": "sha512-ga/aqDYnUy/o7vbsRTFhhTsNeXiYb5JWDIcRIeZfwRNCefwjNTVYCGdGSUrEmiu3yDK3vFvNbgJxvrQW4JXrYQ==", + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.5.0.tgz", + "integrity": "sha512-Y2l399Tt1AguU3BPRP9Fn4eN+Or+StUGWCUpbnFyXSo8NZ9S4uj+AG2pjs5apK+ZMOwYOz1+a+VKvKH7CudXgQ==", "dev": true, "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, "node_modules/table/node_modules/json-schema-traverse": { @@ -6211,20 +6284,6 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true }, - "node_modules/table/node_modules/slice-ansi": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", - "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "astral-regex": "^2.0.0", - "is-fullwidth-code-point": "^3.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/teeny-request": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-7.0.1.tgz", @@ -7799,6 +7858,12 @@ "integrity": "sha512-cxWFQVseBm6O9Gbw1IWb8r6OS4OhSt3hPZLkFApLjM8TEXROBuQGLAH2i2gZpcXdLBIrpXuTDhH7Vbm1iXmNGA==", "dev": true }, + "@types/mime": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-2.0.3.tgz", + "integrity": "sha512-Jus9s4CDbqwocc5pOAnh8ShfrnMcPHuJYzVcSUU7lrh8Ni5HuIqX3oilL86p3dlTrk0LzHRCgA/GQ7uNCw6l2Q==", + "dev": true + }, "@types/minimist": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.1.tgz", @@ -7810,6 +7875,16 @@ "integrity": "sha512-z/5Yd59dCKI5kbxauAJgw6dLPzW+TNOItNE00PkpzNwUIEwdj/Lsqwq94H5DdYBX7C13aRA0CY32BK76+neEUA==", "dev": true }, + "@types/node-fetch": { + "version": "2.5.10", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.5.10.tgz", + "integrity": "sha512-IpkX0AasN44hgEad0gEF/V6EgR5n69VEqPEgnmoM8GsIGro3PowbWs4tR6IhxUTyPLpOn+fiGG6nrQhcmoCuIQ==", + "dev": true, + "requires": { + "@types/node": "*", + "form-data": "^3.0.0" + } + }, "@types/normalize-package-data": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz", @@ -8663,9 +8738,9 @@ } }, "eslint": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.26.0.tgz", - "integrity": "sha512-4R1ieRf52/izcZE7AlLy56uIHHDLT74Yzz2Iv2l6kDaYvEu9x+wMB5dZArVL8SYGXSYV2YAg70FcW5Y5nGGNIg==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.27.0.tgz", + "integrity": "sha512-JZuR6La2ZF0UD384lcbnd0Cgg6QJjiCwhMD6eU4h/VGPcVGwawNNzKU41tgokGXnfjOOyI6QIffthhJTPzzuRA==", "dev": true, "requires": { "@babel/code-frame": "7.12.11", @@ -8676,12 +8751,14 @@ "debug": "^4.0.1", "doctrine": "^3.0.0", "enquirer": "^2.3.5", + "escape-string-regexp": "^4.0.0", "eslint-scope": "^5.1.1", "eslint-utils": "^2.1.0", "eslint-visitor-keys": "^2.0.0", "espree": "^7.3.1", "esquery": "^1.4.0", "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", "file-entry-cache": "^6.0.1", "functional-red-black-tree": "^1.0.1", "glob-parent": "^5.0.0", @@ -8693,7 +8770,7 @@ "js-yaml": "^3.13.1", "json-stable-stringify-without-jsonify": "^1.0.1", "levn": "^0.4.1", - "lodash": "^4.17.21", + "lodash.merge": "^4.6.2", "minimatch": "^3.0.4", "natural-compare": "^1.4.0", "optionator": "^0.9.1", @@ -8702,7 +8779,7 @@ "semver": "^7.2.1", "strip-ansi": "^6.0.0", "strip-json-comments": "^3.1.0", - "table": "^6.0.4", + "table": "^6.0.9", "text-table": "^0.2.0", "v8-compile-cache": "^2.0.3" }, @@ -8716,6 +8793,12 @@ "@babel/highlight": "^7.10.4" } }, + "escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true + }, "globals": { "version": "13.6.0", "resolved": "https://registry.npmjs.org/globals/-/globals-13.6.0.tgz", @@ -10821,6 +10904,24 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "dev": true }, + "lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=", + "dev": true + }, + "lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "lodash.truncate": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", + "integrity": "sha1-WjUNoLERO4N+z//VgSy+WNbq4ZM=", + "dev": true + }, "lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -10985,8 +11086,7 @@ "node-fetch": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz", - "integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==", - "dev": true + "integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==" }, "node-int64": { "version": "0.4.0", @@ -11462,6 +11562,17 @@ "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", "dev": true }, + "slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dev": true, + "requires": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + } + }, "source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -11618,21 +11729,23 @@ "dev": true }, "table": { - "version": "6.0.7", - "resolved": "https://registry.npmjs.org/table/-/table-6.0.7.tgz", - "integrity": "sha512-rxZevLGTUzWna/qBLObOe16kB2RTnnbhciwgPbMMlazz1yZGVEgnZK762xyVdVznhqxrfCeBMmMkgOOaPwjH7g==", + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/table/-/table-6.7.1.tgz", + "integrity": "sha512-ZGum47Yi6KOOFDE8m223td53ath2enHcYLgOCjGr5ngu8bdIARQk6mN/wRMv4yMRcHnCSnHbCEha4sobQx5yWg==", "dev": true, "requires": { - "ajv": "^7.0.2", - "lodash": "^4.17.20", + "ajv": "^8.0.1", + "lodash.clonedeep": "^4.5.0", + "lodash.truncate": "^4.4.2", "slice-ansi": "^4.0.0", - "string-width": "^4.2.0" + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0" }, "dependencies": { "ajv": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-7.1.1.tgz", - "integrity": "sha512-ga/aqDYnUy/o7vbsRTFhhTsNeXiYb5JWDIcRIeZfwRNCefwjNTVYCGdGSUrEmiu3yDK3vFvNbgJxvrQW4JXrYQ==", + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.5.0.tgz", + "integrity": "sha512-Y2l399Tt1AguU3BPRP9Fn4eN+Or+StUGWCUpbnFyXSo8NZ9S4uj+AG2pjs5apK+ZMOwYOz1+a+VKvKH7CudXgQ==", "dev": true, "requires": { "fast-deep-equal": "^3.1.1", @@ -11646,17 +11759,6 @@ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true - }, - "slice-ansi": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", - "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", - "dev": true, - "requires": { - "ansi-styles": "^4.0.0", - "astral-regex": "^2.0.0", - "is-fullwidth-code-point": "^3.0.0" - } } } }, diff --git a/package.json b/package.json index bcd052bda..af70ceb83 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "prepare": "npm run build", "pregenerate": "npm run build", "test": "npm run build && jest --no-cache --test-timeout=10000", - "test:coverage": "npm run build && jest --no-cache --coverage && codecov", + "test:coverage": "npm run build && jest --no-cache --test-timeout=10000 --coverage && codecov", "typecheck": "tsc --noEmit", "version": "npm run build" }, @@ -59,16 +59,19 @@ "kleur": "^4.1.4", "meow": "^9.0.0", "mime": "^2.5.2", + "node-fetch": "^2.6.1", "prettier": "^2.3.0", "tiny-glob": "^0.2.9" }, "devDependencies": { - "@types/jest": "^26.0.14", - "@types/js-yaml": "^4.0.0", - "@typescript-eslint/eslint-plugin": "^4.26.0", - "@typescript-eslint/parser": "^4.26.0", - "codecov": "^3.8.1", - "eslint": "^7.26.0", + "@types/jest": "^26.0.23", + "@types/js-yaml": "^4.0.1", + "@types/mime": "^2.0.3", + "@types/node-fetch": "^2.5.10", + "@typescript-eslint/eslint-plugin": "^4.25.0", + "@typescript-eslint/parser": "^4.25.0", + "codecov": "^3.8.2", + "eslint": "^7.27.0", "eslint-config-prettier": "^8.3.0", "eslint-plugin-prettier": "^3.4.0", "jest": "^27.0.3", diff --git a/src/index.ts b/src/index.ts index c5bbb15c1..b3d83c7cf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,8 @@ import path from "path"; +import { bold, yellow } from "kleur"; import prettier from "prettier"; import parserTypescript from "prettier/parser-typescript"; +import load, { resolveSchema } from "./load"; import { swaggerVersion } from "./utils"; import { transformAll } from "./transform/index"; import { GlobalContext, OpenAPI2, OpenAPI3, SchemaObject, SwaggerToTSOptions } from "./types"; @@ -14,28 +16,70 @@ export const WARNING_MESSAGE = `/** `; -export default function openapiTS( - schema: OpenAPI2 | OpenAPI3 | Record, - options: SwaggerToTSOptions = {} -): string { - // 1. set up context +export default async function openapiTS( + schema: string | OpenAPI2 | OpenAPI3 | Record, + options: SwaggerToTSOptions = {} as any +): Promise { const ctx: GlobalContext = { additionalProperties: options.additionalProperties || false, auth: options.auth, defaultNonNullable: options.defaultNonNullable || false, - formatter: typeof options.formatter === "function" ? options.formatter : undefined, + formatter: options && typeof options.formatter === "function" ? options.formatter : undefined, immutableTypes: options.immutableTypes || false, rawSchema: options.rawSchema || false, - version: options.version || swaggerVersion(schema as OpenAPI2 | OpenAPI3), + version: options.version || 3, } as any; - // 2. generate output + // note: we may be loading many large schemas into memory at once; take care to reuse references without cloning + + // 1. load schema + let rootSchema: Record = {}; + let external: Record> = {}; + if (typeof schema === "string") { + const schemaURL = resolveSchema(schema); + if (options.silent === false) console.log(yellow(`🔭 Loading spec from ${bold(schemaURL.href)}…`)); + const schemas: Record> = {}; + await load(schemaURL, { + ...ctx, + schemas, + rootURL: schemaURL, // as it crawls schemas recursively, it needs to know which is the root to resolve everything relative to + }); + for (const k of Object.keys(schemas)) { + if (k === schemaURL.href) { + rootSchema = schemas[k]; + } else { + external[k] = schemas[k]; + } + } + } else { + rootSchema = schema; + } + + // 2. generate raw output let output = WARNING_MESSAGE; - const rootTypes = transformAll(schema, { ...ctx }); + + // 2a. root schema + if (!options?.version && !ctx.rawSchema) ctx.version = swaggerVersion(rootSchema as any); // note: root version cascades down to all subschemas + const rootTypes = transformAll(rootSchema, { ...ctx }); for (const k of Object.keys(rootTypes)) { - if (typeof rootTypes[k] !== "string") continue; - output += `export interface ${k} {\n ${rootTypes[k]}\n}\n\n`; + if (typeof rootTypes[k] === "string") { + output += `export interface ${k} {\n ${rootTypes[k]}\n}\n\n`; + } + } + + // 2b. external schemas (subschemas) + output += `export interface external {\n`; + const externalKeys = Object.keys(external); + externalKeys.sort((a, b) => a.localeCompare(b, "en", { numeric: true })); // sort external keys because they may have resolved in a different order each time + for (const subschemaURL of externalKeys) { + output += ` "${subschemaURL}": {\n`; + const subschemaTypes = transformAll(external[subschemaURL], { ...ctx, namespace: subschemaURL }); + for (const k of Object.keys(subschemaTypes)) { + output += ` "${k}": {\n ${subschemaTypes[k]}\n }\n`; + } + output += ` }\n`; } + output += `}\n\n`; // 3. Prettify let prettierOptions: prettier.Options = { @@ -44,7 +88,7 @@ export default function openapiTS( }; if (options && options.prettierConfig) { try { - const userOptions = prettier.resolveConfig.sync(path.resolve(process.cwd(), options.prettierConfig)); + const userOptions = await prettier.resolveConfig(path.resolve(process.cwd(), options.prettierConfig)); prettierOptions = { ...(userOptions || {}), ...prettierOptions, diff --git a/src/load.ts b/src/load.ts new file mode 100644 index 000000000..8db8f0a05 --- /dev/null +++ b/src/load.ts @@ -0,0 +1,170 @@ +import fs from "fs"; +import path from "path"; +import { URL } from "url"; +import fetch, { Headers } from "node-fetch"; +import mime from "mime"; +import yaml from "js-yaml"; +import { GlobalContext } from "./types"; +import { parseRef } from "./utils"; + +type PartialSchema = Record; // not a very accurate type, but this is easier to deal with before we know we’re dealing with a valid spec + +function parseSchema(schema: any, type: "YAML" | "JSON") { + if (type === "YAML") { + try { + return yaml.load(schema); + } catch (err) { + throw new Error(`YAML: ${err.toString()}`); + } + } else { + try { + return JSON.parse(schema); + } catch (err) { + throw new Error(`JSON: ${err.toString()}`); + } + } +} + +function isFile(url: URL): boolean { + return url.protocol === "file:"; +} + +export function resolveSchema(url: string): URL { + // option 1: remote + if (url.startsWith("http://") || url.startsWith("https://")) { + return new URL(url); + } + + // option 2: local + const localPath = path.isAbsolute(url) ? new URL("", `file://${url}`) : new URL(url, `file://${process.cwd()}/`); // if absolute path is provided use that; otherwise search cwd\ + if (!fs.existsSync(localPath)) { + throw new Error(`Could not locate ${url}`); + } else if (fs.statSync(localPath).isDirectory()) { + throw new Error(`${localPath} is a directory not a file`); + } + return localPath; +} + +interface LoadOptions extends GlobalContext { + rootURL: URL; + schemas: { [url: string]: PartialSchema }; +} + +// temporary cache for load() +let urlCache = new Set(); // URL cache (prevent URLs from being loaded over and over) + +/** Load a schema from local path or remote URL */ +export default async function load(schemaURL: URL, options: LoadOptions): Promise<{ [url: string]: PartialSchema }> { + if (urlCache.has(schemaURL.href)) return options.schemas; // exit early if this has already been scanned + urlCache.add(schemaURL.href); // add URL to cache + + const schemas = options.schemas; + + let contents = ""; + let contentType = ""; + + if (isFile(schemaURL)) { + // load local + contents = await fs.promises.readFile(schemaURL, "utf8"); + contentType = mime.getType(schemaURL.href) || ""; + } else { + // load remote + const headers = new Headers(); + if (options.auth) headers.set("Authorization", options.auth); + const res = await fetch(schemaURL.href, { method: "GET", headers }); + contentType = res.headers.get("Content-Type") || ""; + contents = await res.text(); + } + + const isYAML = contentType === "application/openapi+yaml" || contentType === "text/yaml"; + const isJSON = + contentType === "application/json" || + contentType === "application/json5" || + contentType === "application/openapi+json"; + if (isYAML) { + schemas[schemaURL.href] = parseSchema(contents, "YAML"); + } else if (isJSON) { + schemas[schemaURL.href] = parseSchema(contents, "JSON"); + } else { + // if contentType is unknown, guess + try { + schemas[schemaURL.href] = parseSchema(contents, "JSON"); + } catch (err1) { + try { + schemas[schemaURL.href] = parseSchema(contents, "YAML"); + } catch (err2) { + throw new Error(`Unknown format${contentType ? `: "${contentType}"` : ""}. Only YAML or JSON supported.`); // give up: unknown type + } + } + } + + // scan $refs, but don’t transform (load everything in parallel) + const refPromises: Promise[] = []; + schemas[schemaURL.href] = JSON.parse(JSON.stringify(schemas[schemaURL.href]), (k, v) => { + if (k !== "$ref" || typeof v !== "string") return v; + + const { url: refURL } = parseRef(v); + if (refURL) { + // load $refs (only if new) and merge subschemas with top-level schema + const nextURL = + refURL.startsWith("http://") || refURL.startsWith("https://") ? new URL(refURL) : new URL(refURL, schemaURL); + refPromises.push( + load(nextURL, options).then((subschemas) => { + for (const subschemaURL of Object.keys(subschemas)) { + schemas[subschemaURL] = subschemas[subschemaURL]; + } + }) + ); + return v.replace(refURL, nextURL.href); // resolve relative URLs to absolute URLs so the schema can be flattened + } + return v; + }); + await Promise.all(refPromises); + + // transform $refs once, at the root schema, after all have been scanned & downloaded (much easier to do here when we have the context) + if (schemaURL.href === options.rootURL.href) { + for (const subschemaURL of Object.keys(schemas)) { + // transform $refs in schema + schemas[subschemaURL] = JSON.parse(JSON.stringify(schemas[subschemaURL]), (k, v) => { + if (k !== "$ref" || typeof v !== "string") return v; + if (!v.includes("#")) return v; // already transformed; skip + + const { url, parts } = parseRef(v); + // scenario 1: resolve all external URLs so long as they don’t point back to root schema + if (url && new URL(url).href !== options.rootURL.href) { + const relativeURL = + isFile(new URL(url)) && isFile(options.rootURL) + ? path.posix.relative(path.posix.dirname(options.rootURL.href), url) + : url; + return `external["${relativeURL}"]["${parts.join('"]["')}"]`; // export external ref + } + // scenario 2: treat all $refs in external schemas as external + if (!url && subschemaURL !== options.rootURL.href) { + const relativeURL = + isFile(new URL(subschemaURL)) && isFile(options.rootURL) + ? path.posix.relative(path.posix.dirname(options.rootURL.href), subschemaURL) + : subschemaURL; + return `external["${relativeURL}"]["${parts.join('"]["')}"]`; // export external ref + } + + // scenario 3: transform all $refs pointing back to root schema + const [base, ...rest] = parts; + return `${base}["${rest.join('"]["')}"]`; // transform other $refs to the root schema (including external refs that point back to the root schema) + }); + + // use relative keys for external schemas (schemas generated on different machines should have the same namespace) + if (subschemaURL !== options.rootURL.href) { + const relativeURL = + isFile(new URL(subschemaURL)) && isFile(options.rootURL) + ? path.posix.relative(path.posix.dirname(options.rootURL.href), subschemaURL) + : subschemaURL; + if (relativeURL !== subschemaURL) { + schemas[relativeURL] = schemas[subschemaURL]; + delete schemas[subschemaURL]; + } + } + } + } + + return schemas; +} diff --git a/src/transform/operation.ts b/src/transform/operation.ts index bb8b10136..f72db95cb 100644 --- a/src/transform/operation.ts +++ b/src/transform/operation.ts @@ -1,5 +1,5 @@ import { GlobalContext, OperationObject, ParameterObject, PathItemObject } from "../types"; -import { comment, isRef, transformRef, tsReadonly } from "../utils"; +import { comment, isRef, tsReadonly } from "../utils"; import { transformParametersArray } from "./parameters"; import { transformRequestBodyObj } from "./request"; import { transformResponsesObj } from "./responses"; @@ -29,7 +29,7 @@ export function transformOperationObj(operation: OperationObject, options: Trans if (operation.requestBody) { if (isRef(operation.requestBody)) { - output += ` ${readonly}requestBody: ${transformRef(operation.requestBody.$ref)};\n`; + output += ` ${readonly}requestBody: ${operation.requestBody.$ref};\n`; } else { if (operation.requestBody.description) output += comment(operation.requestBody.description); output += ` ${readonly}requestBody: {\n ${transformRequestBodyObj(operation.requestBody, ctx)} }\n`; diff --git a/src/transform/parameters.ts b/src/transform/parameters.ts index 01a9a1348..57ee2ad9a 100644 --- a/src/transform/parameters.ts +++ b/src/transform/parameters.ts @@ -19,7 +19,7 @@ export function transformParametersArray( let mappedParams: Record> = {}; for (const paramObj of parameters as any[]) { if (paramObj.$ref && globalParameters) { - const paramName = paramObj.$ref.split("/").pop(); // take last segment + const paramName = paramObj.$ref.split('["').pop().replace(/"\]$/, ""); // take last segment if (globalParameters[paramName]) { const reference = globalParameters[paramName] as any; if (!mappedParams[reference.in]) mappedParams[reference.in] = {}; diff --git a/src/transform/paths.ts b/src/transform/paths.ts index e40ab6a7e..1cc429e10 100644 --- a/src/transform/paths.ts +++ b/src/transform/paths.ts @@ -1,5 +1,5 @@ import { GlobalContext, OperationObject, ParameterObject, PathItemObject } from "../types"; -import { comment, transformRef, tsReadonly } from "../utils"; +import { comment, tsReadonly } from "../utils"; import { transformOperationObj } from "./operation"; import { transformParametersArray } from "./parameters"; @@ -19,7 +19,7 @@ export function transformPathsObj(paths: Record, options if (pathItem.description) output += comment(pathItem.description); // add comment if (pathItem.$ref) { - output += ` ${readonly}"${url}": ${transformRef(pathItem.$ref)};\n`; + output += ` ${readonly}"${url}": ${pathItem.$ref};\n`; continue; } @@ -33,7 +33,8 @@ export function transformPathsObj(paths: Record, options if (operation.operationId) { // if operation has operationId, abstract into top-level operations object operations[operation.operationId] = { operation, pathItem }; - output += ` ${readonly}"${method}": operations["${operation.operationId}"];\n`; + const namespace = ctx.namespace ? `external["${ctx.namespace}"]["operations"]` : `operations`; + output += ` ${readonly}"${method}": ${namespace}["${operation.operationId}"];\n`; } else { // otherwise, inline operation output += ` ${readonly}"${method}": {\n ${transformOperationObj(operation, { diff --git a/src/transform/request.ts b/src/transform/request.ts index 74eb15169..ead72cc98 100644 --- a/src/transform/request.ts +++ b/src/transform/request.ts @@ -5,8 +5,7 @@ import { transformSchemaObj } from "./schema"; export function transformRequestBodies(requestBodies: Record, ctx: GlobalContext) { let output = ""; - for (const name of Object.keys(requestBodies)) { - const requestBody = requestBodies[name]; + for (const [name, requestBody] of Object.entries(requestBodies)) { if (requestBody && requestBody.description) output += ` ${comment(requestBody.description)}`; output += ` "${name}": {\n ${transformRequestBodyObj(requestBody, ctx)}\n }\n`; } @@ -21,8 +20,7 @@ export function transformRequestBodyObj(requestBody: RequestBody, ctx: GlobalCon if (requestBody.content && Object.keys(requestBody.content).length) { output += ` ${readonly}content: {\n`; // open content - for (const k of Object.keys(requestBody.content)) { - const v = requestBody.content[k]; + for (const [k, v] of Object.entries(requestBody.content)) { output += ` ${readonly}"${k}": ${transformSchemaObj(v.schema, { ...ctx, required: new Set() })};\n`; } output += ` }\n`; // close content diff --git a/src/transform/responses.ts b/src/transform/responses.ts index 90d231254..f0f1adf96 100644 --- a/src/transform/responses.ts +++ b/src/transform/responses.ts @@ -1,5 +1,5 @@ import { GlobalContext } from "../types"; -import { comment, transformRef, tsReadonly } from "../utils"; +import { comment, tsReadonly } from "../utils"; import { transformHeaderObjMap } from "./headers"; import { transformSchemaObj } from "./schema"; @@ -16,7 +16,7 @@ export function transformResponsesObj(responsesObj: Record, ctx: Gl if (response.description) output += comment(response.description); if (response.$ref) { - output += ` ${readonly}${statusCode}: ${transformRef(response.$ref)};\n`; // reference + output += ` ${readonly}${statusCode}: ${response.$ref};\n`; // reference continue; } @@ -30,7 +30,7 @@ export function transformResponsesObj(responsesObj: Record, ctx: Gl // headers if (response.headers && Object.keys(response.headers).length) { if (response.headers.$ref) { - output += ` ${readonly}headers: ${transformRef(response.headers.$ref)};\n`; + output += ` ${readonly}headers: ${response.headers.$ref};\n`; } else { output += ` ${readonly}headers: {\n ${transformHeaderObjMap(response.headers, { ...ctx, diff --git a/src/transform/schema.ts b/src/transform/schema.ts index d7dfe2f7b..eb7d8d407 100644 --- a/src/transform/schema.ts +++ b/src/transform/schema.ts @@ -1,15 +1,5 @@ import { GlobalContext } from "../types"; -import { - comment, - nodeType, - transformRef, - tsArrayOf, - tsIntersectionOf, - tsPartial, - tsReadonly, - tsTupleOf, - tsUnionOf, -} from "../utils"; +import { comment, nodeType, tsArrayOf, tsIntersectionOf, tsPartial, tsReadonly, tsTupleOf, tsUnionOf } from "../utils"; interface TransformSchemaObjOptions extends GlobalContext { required: Set; @@ -27,6 +17,7 @@ export function transformSchemaObjMap(obj: Record, options: Transfo for (const k of Object.keys(obj)) { const v = obj[k]; + // 1. JSDoc comment (goes above property) if (v.description) output += comment(v.description); @@ -75,7 +66,7 @@ export function transformSchemaObj(node: any, options: TransformSchemaObjOptions // transform core type switch (nodeType(node)) { case "ref": { - output += transformRef(node.$ref); + output += node.$ref; // these were transformed at load time when remote schemas were resolved; return as-is break; } case "string": diff --git a/src/types.ts b/src/types.ts index ce3a273ef..681d53eff 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,4 @@ -import { URL } from "url"; +import type { URL } from "url"; export interface OpenAPI2 { swagger: string; // required @@ -130,6 +130,8 @@ export interface SwaggerToTSOptions { prettierConfig?: string; /** (optional) Parsing input document as raw schema rather than OpenAPI document */ rawSchema?: boolean; + /** (optional) Should logging be suppressed? (necessary for STDOUT) */ + silent?: boolean; /** (optional) OpenAPI version. Must be present if parsing raw schema */ version?: number; } diff --git a/src/utils.ts b/src/utils.ts index 4d17f3f85..027633b9d 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -14,6 +14,18 @@ export function comment(text: string): string { */\n`; } +export function parseRef(ref: string): { url?: string; parts: string[] } { + if (typeof ref !== "string" || !ref.includes("#")) return { parts: [] }; + const [url, parts] = ref.split("#"); + return { + url: url || undefined, + parts: parts + .split("/") // split by special character + .filter((p) => !!p) // remove empty parts + .map(decodeRef), // decode encoded chars + }; +} + /** Is this a ReferenceObject? (note: this is just a TypeScript helper for nodeType() below) */ export function isRef(obj: any): obj is ReferenceObject { return !!obj.$ref; @@ -85,14 +97,14 @@ export function swaggerVersion(definition: OpenAPI2 | OpenAPI3): 2 | 3 { ); } -/** Convert $ref to TS ref */ -export function transformRef(ref: string, root = ""): string { - // TODO: load external file - const isExternalRef = !ref.startsWith("#"); // if # isn’t first character, we can assume this is a remote schema - if (isExternalRef) return "any"; +/** Decode $ref (https://swagger.io/docs/specification/using-ref/#escape) */ +export function decodeRef(ref: string): string { + return ref.replace(/\~0/g, "~").replace(/\~1/g, "/").replace(/"/g, '\\"'); +} - const parts = ref.replace(/^#\//, root).split("/"); - return `${parts[0]}["${parts.slice(1).join('"]["')}"]`; +/** Encode $ref (https://swagger.io/docs/specification/using-ref/#escape) */ +export function encodeRef(ref: string): string { + return ref.replace(/\~/g, "~0").replace(/\//g, "~1"); } /** Convert T into T[]; */ @@ -125,9 +137,3 @@ export function tsUnionOf(types: Array): string { if (types.length === 1) return `${types[0]}`; // don’t add parentheses around one thing return `(${types.join(") | (")})`; } - -/** Convert the components object and a 'components["parameters"]["param"]' string into the `param` object **/ -export function unrefComponent(components: any, ref: string): any { - const [type, object] = ref.match(/(?<=\[")([^"]+)/g) as string[]; - return components[type][object]; -} diff --git a/tests/bin/cli.test.ts b/tests/bin/cli.test.ts index 1517e261e..61f633288 100644 --- a/tests/bin/cli.test.ts +++ b/tests/bin/cli.test.ts @@ -37,7 +37,7 @@ describe("cli", () => { }); it("supports glob paths", async () => { - execSync(`../../bin/cli.js \"specs/*.yaml\" -o generated/`, { cwd: __dirname }); // Quotes are necessary because shells like zsh treats glob weirdly + execSync(`../../bin/cli.js "specs/*.yaml" -o generated/`, { cwd: __dirname }); // Quotes are necessary because shells like zsh treats glob weirdly const [generatedPetstore, expectedPetstore, generatedManifold, expectedManifold] = await Promise.all([ fs.promises.readFile(path.join(__dirname, "generated", "specs", "petstore.ts"), "utf8"), fs.promises.readFile(path.join(__dirname, "expected", "petstore.ts"), "utf8"), diff --git a/tests/bin/expected/manifold.ts b/tests/bin/expected/manifold.ts index 821bda220..41eb3b0d0 100644 --- a/tests/bin/expected/manifold.ts +++ b/tests/bin/expected/manifold.ts @@ -1008,3 +1008,5 @@ export interface parameters { } export interface operations {} + +export interface external {} diff --git a/tests/bin/expected/petstore.ts b/tests/bin/expected/petstore.ts index 756d358ac..152806e25 100644 --- a/tests/bin/expected/petstore.ts +++ b/tests/bin/expected/petstore.ts @@ -463,3 +463,5 @@ export interface operations { }; }; } + +export interface external {} diff --git a/tests/bin/expected/prettier-js.ts b/tests/bin/expected/prettier-js.ts index a7f99b44e..3f89d5a70 100644 --- a/tests/bin/expected/prettier-js.ts +++ b/tests/bin/expected/prettier-js.ts @@ -463,3 +463,5 @@ export interface operations { } } } + +export interface external {} diff --git a/tests/bin/expected/prettier-json.ts b/tests/bin/expected/prettier-json.ts index a7f99b44e..3f89d5a70 100644 --- a/tests/bin/expected/prettier-json.ts +++ b/tests/bin/expected/prettier-json.ts @@ -463,3 +463,5 @@ export interface operations { } } } + +export interface external {} diff --git a/tests/bin/expected/stdout.ts b/tests/bin/expected/stdout.ts index 756d358ac..152806e25 100644 --- a/tests/bin/expected/stdout.ts +++ b/tests/bin/expected/stdout.ts @@ -463,3 +463,5 @@ export interface operations { }; }; } + +export interface external {} diff --git a/tests/empty-definitions.test.ts b/tests/empty-definitions.test.ts index 38907a810..b33a9478b 100644 --- a/tests/empty-definitions.test.ts +++ b/tests/empty-definitions.test.ts @@ -1,7 +1,7 @@ import openapiTS from "../src/index"; describe("allow empty definitions", () => { - it("allow empty definitions", () => { + it("allow empty definitions", async () => { const schema = { swagger: "2.0", paths: { @@ -16,7 +16,7 @@ describe("allow empty definitions", () => { description: "Pet object that needs to be added to the store", required: true, schema: { - $ref: "#/definitions/Pet", + $ref: 'definitions["Pet"]', }, }, ], @@ -30,11 +30,7 @@ describe("allow empty definitions", () => { }, }; - expect( - openapiTS(schema as any, { - version: 2, - }) - ).toBe(`/** + expect(await openapiTS(schema as any, { version: 2 })).toBe(`/** * This file was auto-generated by openapi-typescript. * Do not make direct changes to the file. */ @@ -59,14 +55,11 @@ export interface operations { }; }; } + +export interface external {} `); - expect( - openapiTS(schema as any, { - immutableTypes: true, - version: 2, - }) - ).toBe(`/** + expect(await openapiTS(schema as any, { immutableTypes: true, version: 2 })).toBe(`/** * This file was auto-generated by openapi-typescript. * Do not make direct changes to the file. */ @@ -91,6 +84,8 @@ export interface operations { }; }; } + +export interface external {} `); }); }); diff --git a/tests/formatter.test.ts b/tests/formatter.test.ts index f7882b427..41bfe7d01 100644 --- a/tests/formatter.test.ts +++ b/tests/formatter.test.ts @@ -1,7 +1,7 @@ import { default as openapiTS } from "../src/index"; describe("formatter", () => { - it("basic", () => { + it("basic", async () => { const schema = { openapi: "3.0.1", components: { @@ -14,7 +14,7 @@ describe("formatter", () => { }, }; expect( - openapiTS(schema, { + await openapiTS(schema, { formatter(schemaObj) { if (schemaObj.format === "date-time") { return "Date"; @@ -37,10 +37,12 @@ export interface components { } export interface operations {} + +export interface external {} `); }); - it("hasObject", () => { + it("hasObject", async () => { const schemaHasObject = { openapi: "3.0.1", components: { @@ -64,7 +66,7 @@ export interface operations {} }; expect( - openapiTS(schemaHasObject, { + await openapiTS(schemaHasObject, { formatter(schemaObj) { if (schemaObj.format === "date-time") { return "Date"; @@ -91,6 +93,8 @@ export interface components { } export interface operations {} + +export interface external {} `); }); }); diff --git a/tests/operation.test.ts b/tests/operation.test.ts index 21736931e..a161becdf 100644 --- a/tests/operation.test.ts +++ b/tests/operation.test.ts @@ -12,10 +12,10 @@ describe("requestBody", () => { requestBody: { content: { "application/json": { - schema: { $ref: "#/components/schemas/Pet" }, + schema: { $ref: 'components["schemas"]["Pet"]' }, }, "application/xml": { - schema: { $ref: "#/components/schemas/Pet" }, + schema: { $ref: 'components["schemas"]["Pet"]' }, }, }, }, @@ -40,6 +40,7 @@ describe("requestBody", () => { transformOperationObj(basicSchema, { ...defaults, immutableTypes: true, + rawSchema: false, version: 3, }).trim() ).toBe(`readonly requestBody: { @@ -51,7 +52,7 @@ describe("requestBody", () => { }); const refSchema = { - requestBody: { $ref: "#/components/requestBodies/Request" }, + requestBody: { $ref: 'components["requestBodies"]["Request"]' }, }; it("$ref", () => { @@ -68,6 +69,7 @@ describe("requestBody", () => { transformOperationObj(refSchema, { ...defaults, immutableTypes: true, + rawSchema: false, version: 3, }).trim() ).toBe(`readonly requestBody: components["requestBodies"]["Request"];`); diff --git a/tests/parameters.test.ts b/tests/parameters.test.ts index 4d2c3f77e..3d96b7df8 100644 --- a/tests/parameters.test.ts +++ b/tests/parameters.test.ts @@ -49,6 +49,7 @@ describe("transformParametersArray()", () => { transformParametersArray(basicSchema as any, { ...defaults, immutableTypes: true, + rawSchema: false, version: 2, }).trim() ).toBe( @@ -66,9 +67,9 @@ describe("transformParametersArray()", () => { }); const refSchema = [ - { $ref: "#/parameters/per_page" }, - { $ref: "#/parameters/page" }, - { $ref: "#/parameters/since" }, + { $ref: 'parameters["per_page"]' }, + { $ref: 'parameters["page"]' }, + { $ref: 'parameters["since"]' }, ]; it("$ref", () => { @@ -155,6 +156,7 @@ describe("transformParametersArray()", () => { transformParametersArray(basicSchema as any, { ...defaults, immutableTypes: true, + rawSchema: false, version: 3, }).trim() ).toBe( @@ -169,9 +171,9 @@ describe("transformParametersArray()", () => { }); const refSchema = [ - { $ref: "#/components/parameters/per_page" }, - { $ref: "#/components/parameters/page" }, - { $ref: "#/components/parameters/since" }, + { $ref: 'components["parameters"]["per_page"]' }, + { $ref: 'components["parameters"]["page"]' }, + { $ref: 'components["parameters"]["since"]' }, ]; it("$ref", () => { @@ -212,7 +214,7 @@ describe("transformParametersArray()", () => { }); it("nullable", () => { - const schema = [ + const schema: any = [ { in: "query", name: "nullableString", schema: { type: "string", nullable: true } }, { in: "query", name: "nullableNum", schema: { type: "number", nullable: true } }, ]; diff --git a/tests/paths.test.ts b/tests/paths.test.ts index 288af88f2..f42c16579 100644 --- a/tests/paths.test.ts +++ b/tests/paths.test.ts @@ -48,7 +48,7 @@ describe("transformPathsObj", () => { schema: { type: "object", properties: { - results: { type: "array", items: { $ref: "#/components/schemas/SearchResult" } }, + results: { type: "array", items: { $ref: 'components["schemas"]["SearchResult"]' } }, total: { type: "integer" }, }, additionalProperties: false, @@ -60,7 +60,7 @@ describe("transformPathsObj", () => { 404: { content: { "application/json": { - schema: { $ref: "#/components/schemas/ErrorResponse" }, + schema: { $ref: 'components["schemas"]["ErrorResponse"]' }, }, }, }, @@ -276,7 +276,7 @@ describe("transformPathsObj", () => { }, delete: { operationId: "testsDelete", - requestBody: { $ref: "#/components/schemas/Pet" }, + requestBody: { $ref: 'components["schemas"]["Pet"]' }, }, }, }; @@ -344,8 +344,11 @@ describe("transformPathsObj", () => { { "/some/path": { get: { - parameters: [{ $ref: "#/components/parameters/param1" }, { $ref: "#/components/parameters/param2" }], - responses: { 400: { $ref: "#/components/responses/400BadRequest" } }, + parameters: [ + { $ref: 'components["parameters"]["param1"]' }, + { $ref: 'components["parameters"]["param2"]' }, + ], + responses: { 400: { $ref: 'components["responses"]["400BadRequest"]' } }, }, }, }, diff --git a/tests/raw-schema.test.ts b/tests/raw-schema.test.ts index 0f4c19db8..e31d6a4b9 100644 --- a/tests/raw-schema.test.ts +++ b/tests/raw-schema.test.ts @@ -1,7 +1,7 @@ import openapiTS from "../src/index"; describe("rawSchema", () => { - it("v2", () => { + it("v2", async () => { const v2schema = { User: { type: "object", @@ -13,12 +13,7 @@ describe("rawSchema", () => { }, }; - expect( - openapiTS(v2schema, { - rawSchema: true, - version: 2, - }) - ).toBe(`/** + expect(await openapiTS(v2schema, { rawSchema: true, version: 2 })).toBe(`/** * This file was auto-generated by openapi-typescript. * Do not make direct changes to the file. */ @@ -30,10 +25,13 @@ export interface definitions { /** user email */ email: string; }; -}\n`); +} + +export interface external {} +`); }); - it("v3", () => { + it("v3", async () => { const v3schema = { User: { type: "object", @@ -46,12 +44,7 @@ export interface definitions { }, }; - expect( - openapiTS(v3schema, { - rawSchema: true, - version: 3, - }) - ).toBe(`/** + expect(await openapiTS(v3schema, { rawSchema: true, version: 3 })).toBe(`/** * This file was auto-generated by openapi-typescript. * Do not make direct changes to the file. */ @@ -63,6 +56,9 @@ export interface schemas { /** user email */ email: string; }; -}\n`); +} + +export interface external {} +`); }); }); diff --git a/tests/remote-schema/remote-schema.test.ts b/tests/remote-schema/remote-schema.test.ts new file mode 100644 index 000000000..07ccf3a20 --- /dev/null +++ b/tests/remote-schema/remote-schema.test.ts @@ -0,0 +1,510 @@ +import path from "path"; +import openapiTS from "../../src/index"; + +describe("remote $refs", () => { + it("resolves remote $refs", async () => { + const types = await openapiTS(path.join(__dirname, "spec", "spec.yml")); + expect(types).toEqual(`/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths {} + +export interface components { + schemas: { + /** this is a duplicate of subschema/remote1.yml */ + Circular: string; + Remote1: external["subschema/remote1.yml"]["components"]["schemas"]["Remote1"]; + Pet: external["https://raw.githubusercontent.com/drwpow/openapi-typescript/main/tests/v3/specs/petstore.yaml"]["components"]["schemas"]["Pet"]; + }; +} + +export interface operations {} + +export interface external { + "https://raw.githubusercontent.com/drwpow/openapi-typescript/main/tests/v3/specs/petstore.yaml": { + paths: { + "/pet": { + put: external["https://raw.githubusercontent.com/drwpow/openapi-typescript/main/tests/v3/specs/petstore.yaml"]["operations"]["updatePet"]; + post: external["https://raw.githubusercontent.com/drwpow/openapi-typescript/main/tests/v3/specs/petstore.yaml"]["operations"]["addPet"]; + }; + "/pet/findByStatus": { + /** Multiple status values can be provided with comma separated strings */ + get: external["https://raw.githubusercontent.com/drwpow/openapi-typescript/main/tests/v3/specs/petstore.yaml"]["operations"]["findPetsByStatus"]; + }; + "/pet/findByTags": { + /** Muliple tags can be provided with comma separated strings. Use tag1, tag2, tag3 for testing. */ + get: external["https://raw.githubusercontent.com/drwpow/openapi-typescript/main/tests/v3/specs/petstore.yaml"]["operations"]["findPetsByTags"]; + }; + "/pet/{petId}": { + /** Returns a single pet */ + get: external["https://raw.githubusercontent.com/drwpow/openapi-typescript/main/tests/v3/specs/petstore.yaml"]["operations"]["getPetById"]; + post: external["https://raw.githubusercontent.com/drwpow/openapi-typescript/main/tests/v3/specs/petstore.yaml"]["operations"]["updatePetWithForm"]; + delete: external["https://raw.githubusercontent.com/drwpow/openapi-typescript/main/tests/v3/specs/petstore.yaml"]["operations"]["deletePet"]; + }; + "/pet/{petId}/uploadImage": { + post: external["https://raw.githubusercontent.com/drwpow/openapi-typescript/main/tests/v3/specs/petstore.yaml"]["operations"]["uploadFile"]; + }; + "/store/inventory": { + /** Returns a map of status codes to quantities */ + get: external["https://raw.githubusercontent.com/drwpow/openapi-typescript/main/tests/v3/specs/petstore.yaml"]["operations"]["getInventory"]; + }; + "/store/order": { + post: external["https://raw.githubusercontent.com/drwpow/openapi-typescript/main/tests/v3/specs/petstore.yaml"]["operations"]["placeOrder"]; + }; + "/store/order/{orderId}": { + /** For valid response try integer IDs with value >= 1 and <= 10. Other values will generated exceptions */ + get: external["https://raw.githubusercontent.com/drwpow/openapi-typescript/main/tests/v3/specs/petstore.yaml"]["operations"]["getOrderById"]; + /** For valid response try integer IDs with positive integer value. Negative or non-integer values will generate API errors */ + delete: external["https://raw.githubusercontent.com/drwpow/openapi-typescript/main/tests/v3/specs/petstore.yaml"]["operations"]["deleteOrder"]; + }; + "/user": { + /** This can only be done by the logged in user. */ + post: external["https://raw.githubusercontent.com/drwpow/openapi-typescript/main/tests/v3/specs/petstore.yaml"]["operations"]["createUser"]; + }; + "/user/createWithArray": { + post: external["https://raw.githubusercontent.com/drwpow/openapi-typescript/main/tests/v3/specs/petstore.yaml"]["operations"]["createUsersWithArrayInput"]; + }; + "/user/createWithList": { + post: external["https://raw.githubusercontent.com/drwpow/openapi-typescript/main/tests/v3/specs/petstore.yaml"]["operations"]["createUsersWithListInput"]; + }; + "/user/login": { + get: external["https://raw.githubusercontent.com/drwpow/openapi-typescript/main/tests/v3/specs/petstore.yaml"]["operations"]["loginUser"]; + }; + "/user/logout": { + get: external["https://raw.githubusercontent.com/drwpow/openapi-typescript/main/tests/v3/specs/petstore.yaml"]["operations"]["logoutUser"]; + }; + "/user/{username}": { + get: external["https://raw.githubusercontent.com/drwpow/openapi-typescript/main/tests/v3/specs/petstore.yaml"]["operations"]["getUserByName"]; + /** This can only be done by the logged in user. */ + put: external["https://raw.githubusercontent.com/drwpow/openapi-typescript/main/tests/v3/specs/petstore.yaml"]["operations"]["updateUser"]; + /** This can only be done by the logged in user. */ + delete: external["https://raw.githubusercontent.com/drwpow/openapi-typescript/main/tests/v3/specs/petstore.yaml"]["operations"]["deleteUser"]; + }; + }; + components: { + schemas: { + Order: { + id?: number; + petId?: number; + quantity?: number; + shipDate?: string; + /** Order Status */ + status?: "placed" | "approved" | "delivered"; + complete?: boolean; + }; + Category: { + id?: number; + name?: string; + }; + User: { + id?: number; + username?: string; + firstName?: string; + lastName?: string; + email?: string; + password?: string; + phone?: string; + /** User Status */ + userStatus?: number; + }; + Tag: { + id?: number; + name?: string; + }; + Pet: { + id?: number; + category?: external["https://raw.githubusercontent.com/drwpow/openapi-typescript/main/tests/v3/specs/petstore.yaml"]["components"]["schemas"]["Category"]; + name: string; + photoUrls: string[]; + tags?: external["https://raw.githubusercontent.com/drwpow/openapi-typescript/main/tests/v3/specs/petstore.yaml"]["components"]["schemas"]["Tag"][]; + /** pet status in the store */ + status?: "available" | "pending" | "sold"; + }; + ApiResponse: { + code?: number; + type?: string; + message?: string; + }; + }; + }; + operations: { + updatePet: { + responses: { + /** Invalid ID supplied */ + 400: unknown; + /** Pet not found */ + 404: unknown; + /** Validation exception */ + 405: unknown; + }; + /** Pet object that needs to be added to the store */ + requestBody: { + content: { + "application/json": external["https://raw.githubusercontent.com/drwpow/openapi-typescript/main/tests/v3/specs/petstore.yaml"]["components"]["schemas"]["Pet"]; + "application/xml": external["https://raw.githubusercontent.com/drwpow/openapi-typescript/main/tests/v3/specs/petstore.yaml"]["components"]["schemas"]["Pet"]; + }; + }; + }; + addPet: { + responses: { + /** Invalid input */ + 405: unknown; + }; + /** Pet object that needs to be added to the store */ + requestBody: { + content: { + "application/json": external["https://raw.githubusercontent.com/drwpow/openapi-typescript/main/tests/v3/specs/petstore.yaml"]["components"]["schemas"]["Pet"]; + "application/xml": external["https://raw.githubusercontent.com/drwpow/openapi-typescript/main/tests/v3/specs/petstore.yaml"]["components"]["schemas"]["Pet"]; + }; + }; + }; + /** Multiple status values can be provided with comma separated strings */ + findPetsByStatus: { + parameters: { + query: { + /** Status values that need to be considered for filter */ + status: ("available" | "pending" | "sold")[]; + }; + }; + responses: { + /** successful operation */ + 200: { + content: { + "application/xml": external["https://raw.githubusercontent.com/drwpow/openapi-typescript/main/tests/v3/specs/petstore.yaml"]["components"]["schemas"]["Pet"][]; + "application/json": external["https://raw.githubusercontent.com/drwpow/openapi-typescript/main/tests/v3/specs/petstore.yaml"]["components"]["schemas"]["Pet"][]; + }; + }; + /** Invalid status value */ + 400: unknown; + }; + }; + /** Muliple tags can be provided with comma separated strings. Use tag1, tag2, tag3 for testing. */ + findPetsByTags: { + parameters: { + query: { + /** Tags to filter by */ + tags: string[]; + }; + }; + responses: { + /** successful operation */ + 200: { + content: { + "application/xml": external["https://raw.githubusercontent.com/drwpow/openapi-typescript/main/tests/v3/specs/petstore.yaml"]["components"]["schemas"]["Pet"][]; + "application/json": external["https://raw.githubusercontent.com/drwpow/openapi-typescript/main/tests/v3/specs/petstore.yaml"]["components"]["schemas"]["Pet"][]; + }; + }; + /** Invalid tag value */ + 400: unknown; + }; + }; + /** Returns a single pet */ + getPetById: { + parameters: { + path: { + /** ID of pet to return */ + petId: number; + }; + }; + responses: { + /** successful operation */ + 200: { + content: { + "application/xml": external["https://raw.githubusercontent.com/drwpow/openapi-typescript/main/tests/v3/specs/petstore.yaml"]["components"]["schemas"]["Pet"]; + "application/json": external["https://raw.githubusercontent.com/drwpow/openapi-typescript/main/tests/v3/specs/petstore.yaml"]["components"]["schemas"]["Pet"]; + }; + }; + /** Invalid ID supplied */ + 400: unknown; + /** Pet not found */ + 404: unknown; + }; + }; + updatePetWithForm: { + parameters: { + path: { + /** ID of pet that needs to be updated */ + petId: number; + }; + }; + responses: { + /** Invalid input */ + 405: unknown; + }; + requestBody: { + content: { + "application/x-www-form-urlencoded": { + /** Updated name of the pet */ + name?: string; + /** Updated status of the pet */ + status?: string; + }; + }; + }; + }; + deletePet: { + parameters: { + header: { + api_key?: string; + }; + path: { + /** Pet id to delete */ + petId: number; + }; + }; + responses: { + /** Invalid ID supplied */ + 400: unknown; + /** Pet not found */ + 404: unknown; + }; + }; + uploadFile: { + parameters: { + path: { + /** ID of pet to update */ + petId: number; + }; + }; + responses: { + /** successful operation */ + 200: { + content: { + "application/json": external["https://raw.githubusercontent.com/drwpow/openapi-typescript/main/tests/v3/specs/petstore.yaml"]["components"]["schemas"]["ApiResponse"]; + }; + }; + }; + requestBody: { + content: { + "multipart/form-data": { + /** Additional data to pass to server */ + additionalMetadata?: string; + /** file to upload */ + file?: string; + }; + }; + }; + }; + /** Returns a map of status codes to quantities */ + getInventory: { + responses: { + /** successful operation */ + 200: { + content: { + "application/json": { [key: string]: number }; + }; + }; + }; + }; + placeOrder: { + responses: { + /** successful operation */ + 200: { + content: { + "application/xml": external["https://raw.githubusercontent.com/drwpow/openapi-typescript/main/tests/v3/specs/petstore.yaml"]["components"]["schemas"]["Order"]; + "application/json": external["https://raw.githubusercontent.com/drwpow/openapi-typescript/main/tests/v3/specs/petstore.yaml"]["components"]["schemas"]["Order"]; + }; + }; + /** Invalid Order */ + 400: unknown; + }; + /** order placed for purchasing the pet */ + requestBody: { + content: { + "*/*": external["https://raw.githubusercontent.com/drwpow/openapi-typescript/main/tests/v3/specs/petstore.yaml"]["components"]["schemas"]["Order"]; + }; + }; + }; + /** For valid response try integer IDs with value >= 1 and <= 10. Other values will generated exceptions */ + getOrderById: { + parameters: { + path: { + /** ID of pet that needs to be fetched */ + orderId: number; + }; + }; + responses: { + /** successful operation */ + 200: { + content: { + "application/xml": external["https://raw.githubusercontent.com/drwpow/openapi-typescript/main/tests/v3/specs/petstore.yaml"]["components"]["schemas"]["Order"]; + "application/json": external["https://raw.githubusercontent.com/drwpow/openapi-typescript/main/tests/v3/specs/petstore.yaml"]["components"]["schemas"]["Order"]; + }; + }; + /** Invalid ID supplied */ + 400: unknown; + /** Order not found */ + 404: unknown; + }; + }; + /** For valid response try integer IDs with positive integer value. Negative or non-integer values will generate API errors */ + deleteOrder: { + parameters: { + path: { + /** ID of the order that needs to be deleted */ + orderId: number; + }; + }; + responses: { + /** Invalid ID supplied */ + 400: unknown; + /** Order not found */ + 404: unknown; + }; + }; + /** This can only be done by the logged in user. */ + createUser: { + responses: { + /** successful operation */ + default: unknown; + }; + /** Created user object */ + requestBody: { + content: { + "*/*": external["https://raw.githubusercontent.com/drwpow/openapi-typescript/main/tests/v3/specs/petstore.yaml"]["components"]["schemas"]["User"]; + }; + }; + }; + createUsersWithArrayInput: { + responses: { + /** successful operation */ + default: unknown; + }; + /** List of user object */ + requestBody: { + content: { + "*/*": external["https://raw.githubusercontent.com/drwpow/openapi-typescript/main/tests/v3/specs/petstore.yaml"]["components"]["schemas"]["User"][]; + }; + }; + }; + createUsersWithListInput: { + responses: { + /** successful operation */ + default: unknown; + }; + /** List of user object */ + requestBody: { + content: { + "*/*": external["https://raw.githubusercontent.com/drwpow/openapi-typescript/main/tests/v3/specs/petstore.yaml"]["components"]["schemas"]["User"][]; + }; + }; + }; + loginUser: { + parameters: { + query: { + /** The user name for login */ + username: string; + /** The password for login in clear text */ + password: string; + }; + }; + responses: { + /** successful operation */ + 200: { + headers: { + /** calls per hour allowed by the user */ + "X-Rate-Limit"?: number; + /** date in UTC when token expires */ + "X-Expires-After"?: string; + }; + content: { + "application/xml": string; + "application/json": string; + }; + }; + /** Invalid username/password supplied */ + 400: unknown; + }; + }; + logoutUser: { + responses: { + /** successful operation */ + default: unknown; + }; + }; + getUserByName: { + parameters: { + path: { + /** The name that needs to be fetched. Use user1 for testing. */ + username: string; + }; + }; + responses: { + /** successful operation */ + 200: { + content: { + "application/xml": external["https://raw.githubusercontent.com/drwpow/openapi-typescript/main/tests/v3/specs/petstore.yaml"]["components"]["schemas"]["User"]; + "application/json": external["https://raw.githubusercontent.com/drwpow/openapi-typescript/main/tests/v3/specs/petstore.yaml"]["components"]["schemas"]["User"]; + }; + }; + /** Invalid username supplied */ + 400: unknown; + /** User not found */ + 404: unknown; + }; + }; + /** This can only be done by the logged in user. */ + updateUser: { + parameters: { + path: { + /** name that need to be updated */ + username: string; + }; + }; + responses: { + /** Invalid user supplied */ + 400: unknown; + /** User not found */ + 404: unknown; + }; + /** Updated user object */ + requestBody: { + content: { + "*/*": external["https://raw.githubusercontent.com/drwpow/openapi-typescript/main/tests/v3/specs/petstore.yaml"]["components"]["schemas"]["User"]; + }; + }; + }; + /** This can only be done by the logged in user. */ + deleteUser: { + parameters: { + path: { + /** The name that needs to be deleted */ + username: string; + }; + }; + responses: { + /** Invalid username supplied */ + 400: unknown; + /** User not found */ + 404: unknown; + }; + }; + }; + }; + "subschema/remote1.yml": { + paths: {}; + components: { + schemas: { + /** this is a duplicate of spec.yml#components/schemas/Remote1 */ + Remote1: string; + Remote2: external["subschema/remote2.yml"]["components"]["schemas"]["Remote2"]; + Circular: components["schemas"]["Circular"]; + }; + }; + operations: {}; + }; + "subschema/remote2.yml": { + paths: {}; + components: { + schemas: { + Remote2: string; + }; + }; + operations: {}; + }; +} +`); + }); +}); diff --git a/tests/remote-schema/spec/spec.yml b/tests/remote-schema/spec/spec.yml new file mode 100644 index 000000000..20b960c88 --- /dev/null +++ b/tests/remote-schema/spec/spec.yml @@ -0,0 +1,10 @@ +openapi: 3.1.0 +components: + schemas: + Circular: + description: this is a duplicate of subschema/remote1.yml + type: string + Remote1: + $ref: "./subschema/remote1.yml#components/schemas/Remote1" + Pet: + $ref: "https://raw.githubusercontent.com/drwpow/openapi-typescript/main/tests/v3/specs/petstore.yaml#components/schemas/Pet" diff --git a/tests/remote-schema/spec/subschema/remote1.yml b/tests/remote-schema/spec/subschema/remote1.yml new file mode 100644 index 000000000..af31b6087 --- /dev/null +++ b/tests/remote-schema/spec/subschema/remote1.yml @@ -0,0 +1,10 @@ +openapi: 3.1.0 +components: + schemas: + Remote1: + description: this is a duplicate of spec.yml#components/schemas/Remote1 + type: string + Remote2: + $ref: "./remote2.yml#components/schemas/Remote2" + Circular: + $ref: "../spec.yml#components/schemas/Circular" diff --git a/tests/remote-schema/spec/subschema/remote2.yml b/tests/remote-schema/spec/subschema/remote2.yml new file mode 100644 index 000000000..2b7a00843 --- /dev/null +++ b/tests/remote-schema/spec/subschema/remote2.yml @@ -0,0 +1,5 @@ +openapi: 3.1.0 +components: + schemas: + Remote2: + type: string diff --git a/tests/schema.test.ts b/tests/schema.test.ts index b0a0c7506..80d7b9140 100644 --- a/tests/schema.test.ts +++ b/tests/schema.test.ts @@ -35,7 +35,7 @@ describe("SchemaObject", () => { type: "object", properties: { object: { - properties: { string: { type: "string" }, number: { $ref: "#/components/schemas/object_ref" } }, + properties: { string: { type: "string" }, number: { $ref: 'components["schemas"]["object_ref"]' } }, type: "object", }, }, @@ -99,12 +99,12 @@ describe("SchemaObject", () => { ); // $ref - expect(transform({ type: "array", items: { $ref: "#/components/schemas/ArrayItem" } }, { ...defaults })).toBe( + expect(transform({ type: "array", items: { $ref: 'components["schemas"]["ArrayItem"]' } }, { ...defaults })).toBe( `(components["schemas"]["ArrayItem"])[]` ); // inferred - expect(transform({ items: { $ref: "#/components/schemas/ArrayItem" } }, { ...defaults })).toBe( + expect(transform({ items: { $ref: 'components["schemas"]["ArrayItem"]' } }, { ...defaults })).toBe( `(components["schemas"]["ArrayItem"])[]` ); @@ -134,10 +134,10 @@ describe("SchemaObject", () => { expect(transform({ type: "array", items: { enum: ["chocolate", "vanilla"] } }, opts)).toBe( `readonly (('chocolate') | ('vanilla'))[]` ); - expect(transform({ type: "array", items: { $ref: "#/components/schemas/ArrayItem" } }, opts)).toBe( + expect(transform({ type: "array", items: { $ref: 'components["schemas"]["ArrayItem"]' } }, opts)).toBe( `readonly (components["schemas"]["ArrayItem"])[]` ); - expect(transform({ items: { $ref: "#/components/schemas/ArrayItem" } }, opts)).toBe( + expect(transform({ items: { $ref: 'components["schemas"]["ArrayItem"]' } }, opts)).toBe( `readonly (components["schemas"]["ArrayItem"])[]` ); expect(transform({ type: "array", items: { type: "string" }, nullable: true }, opts)).toBe( @@ -205,15 +205,10 @@ describe("SchemaObject", () => { }); it("$ref", () => { - expect(transform({ $ref: "#/components/parameters/ReferenceObject" }, { ...defaults })).toBe( + expect(transform({ $ref: 'components["parameters"]["ReferenceObject"]' }, { ...defaults })).toBe( `components["parameters"]["ReferenceObject"]` ); }); - - // TODO: allow import later - it("$ref (external)", () => { - expect(transform({ $ref: "./external.yaml" }, { ...defaults })).toBe(`any`); - }); }); describe("advanced", () => { @@ -230,7 +225,7 @@ describe("SchemaObject", () => { ); // $ref - expect(transform({ additionalProperties: { $ref: "#/definitions/Message" } }, { ...defaults })).toBe( + expect(transform({ additionalProperties: { $ref: 'definitions["Message"]' } }, { ...defaults })).toBe( `{ [key: string]: definitions["Message"]; }` ); }); @@ -240,7 +235,7 @@ describe("SchemaObject", () => { transform( { allOf: [ - { $ref: "#/components/schemas/base" }, + { $ref: 'components["schemas"]["base"]' }, { properties: { string: { type: "string" } }, type: "object" }, ], properties: { password: { type: "string" } }, @@ -256,9 +251,9 @@ describe("SchemaObject", () => { transform( { anyOf: [ - { $ref: "#/components/schemas/StringType" }, - { $ref: "#/components/schemas/NumberType" }, - { $ref: "#/components/schemas/BooleanType" }, + { $ref: 'components["schemas"]["StringType"]' }, + { $ref: 'components["schemas"]["NumberType"]' }, + { $ref: 'components["schemas"]["BooleanType"]' }, ], }, { ...defaults } @@ -272,7 +267,7 @@ describe("SchemaObject", () => { // standard expect( transform( - { oneOf: [{ type: "string" }, { type: "number" }, { $ref: "#/components/schemas/one_of_ref" }] }, + { oneOf: [{ type: "string" }, { type: "number" }, { $ref: 'components["schemas"]["one_of_ref"]' }] }, { ...defaults } ) ).toBe(`(string) | (number) | (components["schemas"]["one_of_ref"])`); diff --git a/tests/v2/expected/http.ts b/tests/v2/expected/http.ts index 821bda220..41eb3b0d0 100644 --- a/tests/v2/expected/http.ts +++ b/tests/v2/expected/http.ts @@ -1008,3 +1008,5 @@ export interface parameters { } export interface operations {} + +export interface external {} diff --git a/tests/v2/expected/manifold.immutable.ts b/tests/v2/expected/manifold.immutable.ts index d1861c25c..e212726af 100644 --- a/tests/v2/expected/manifold.immutable.ts +++ b/tests/v2/expected/manifold.immutable.ts @@ -1008,3 +1008,5 @@ export interface parameters { } export interface operations {} + +export interface external {} diff --git a/tests/v2/expected/manifold.ts b/tests/v2/expected/manifold.ts index 821bda220..41eb3b0d0 100644 --- a/tests/v2/expected/manifold.ts +++ b/tests/v2/expected/manifold.ts @@ -1008,3 +1008,5 @@ export interface parameters { } export interface operations {} + +export interface external {} diff --git a/tests/v2/expected/null-in-enum.immutable.ts b/tests/v2/expected/null-in-enum.immutable.ts index fa8369306..90e19f228 100644 --- a/tests/v2/expected/null-in-enum.immutable.ts +++ b/tests/v2/expected/null-in-enum.immutable.ts @@ -36,3 +36,5 @@ export interface operations { }; }; } + +export interface external {} diff --git a/tests/v2/expected/null-in-enum.ts b/tests/v2/expected/null-in-enum.ts index c45e97102..c68291f5e 100644 --- a/tests/v2/expected/null-in-enum.ts +++ b/tests/v2/expected/null-in-enum.ts @@ -36,3 +36,5 @@ export interface operations { }; }; } + +export interface external {} diff --git a/tests/v2/expected/petstore.immutable.ts b/tests/v2/expected/petstore.immutable.ts index 27b5d502c..e15f6a629 100644 --- a/tests/v2/expected/petstore.immutable.ts +++ b/tests/v2/expected/petstore.immutable.ts @@ -421,3 +421,5 @@ export interface operations { }; }; } + +export interface external {} diff --git a/tests/v2/expected/petstore.ts b/tests/v2/expected/petstore.ts index 25907ad8a..da09e750f 100644 --- a/tests/v2/expected/petstore.ts +++ b/tests/v2/expected/petstore.ts @@ -421,3 +421,5 @@ export interface operations { }; }; } + +export interface external {} diff --git a/tests/v2/expected/stripe.immutable.ts b/tests/v2/expected/stripe.immutable.ts index 90e6a68e1..e7795258f 100644 --- a/tests/v2/expected/stripe.immutable.ts +++ b/tests/v2/expected/stripe.immutable.ts @@ -27435,3 +27435,5 @@ export interface operations { }; }; } + +export interface external {} diff --git a/tests/v2/expected/stripe.ts b/tests/v2/expected/stripe.ts index e7306c4be..7d1d87ce0 100644 --- a/tests/v2/expected/stripe.ts +++ b/tests/v2/expected/stripe.ts @@ -27402,3 +27402,5 @@ export interface operations { }; }; } + +export interface external {} diff --git a/tests/v3/expected/github.additional.ts b/tests/v3/expected/github.additional.ts index db97aa21d..43fc5518c 100644 --- a/tests/v3/expected/github.additional.ts +++ b/tests/v3/expected/github.additional.ts @@ -31282,3 +31282,5 @@ export interface operations { }; }; } + +export interface external {} diff --git a/tests/v3/expected/github.immutable.ts b/tests/v3/expected/github.immutable.ts index 5d18a1e34..27d3d2fa8 100644 --- a/tests/v3/expected/github.immutable.ts +++ b/tests/v3/expected/github.immutable.ts @@ -31221,3 +31221,5 @@ export interface operations { }; }; } + +export interface external {} diff --git a/tests/v3/expected/github.ts b/tests/v3/expected/github.ts index 1e8e3892d..61798a50e 100644 --- a/tests/v3/expected/github.ts +++ b/tests/v3/expected/github.ts @@ -31216,3 +31216,5 @@ export interface operations { }; }; } + +export interface external {} diff --git a/tests/v3/expected/http.ts b/tests/v3/expected/http.ts index d133f154c..fc73734c7 100644 --- a/tests/v3/expected/http.ts +++ b/tests/v3/expected/http.ts @@ -1145,3 +1145,5 @@ export interface components { } export interface operations {} + +export interface external {} diff --git a/tests/v3/expected/manifold.additional.ts b/tests/v3/expected/manifold.additional.ts index 135358b63..174ab51f3 100644 --- a/tests/v3/expected/manifold.additional.ts +++ b/tests/v3/expected/manifold.additional.ts @@ -1151,3 +1151,5 @@ export interface components { } export interface operations {} + +export interface external {} diff --git a/tests/v3/expected/manifold.immutable.ts b/tests/v3/expected/manifold.immutable.ts index f4a54add4..9a6af9045 100644 --- a/tests/v3/expected/manifold.immutable.ts +++ b/tests/v3/expected/manifold.immutable.ts @@ -1145,3 +1145,5 @@ export interface components { } export interface operations {} + +export interface external {} diff --git a/tests/v3/expected/manifold.ts b/tests/v3/expected/manifold.ts index d133f154c..fc73734c7 100644 --- a/tests/v3/expected/manifold.ts +++ b/tests/v3/expected/manifold.ts @@ -1145,3 +1145,5 @@ export interface components { } export interface operations {} + +export interface external {} diff --git a/tests/v3/expected/null-in-enum.additional.ts b/tests/v3/expected/null-in-enum.additional.ts index 1f18e072b..9d5f87fc5 100644 --- a/tests/v3/expected/null-in-enum.additional.ts +++ b/tests/v3/expected/null-in-enum.additional.ts @@ -36,3 +36,5 @@ export interface components { } export interface operations {} + +export interface external {} diff --git a/tests/v3/expected/null-in-enum.immutable.ts b/tests/v3/expected/null-in-enum.immutable.ts index cce6a886e..929c981d0 100644 --- a/tests/v3/expected/null-in-enum.immutable.ts +++ b/tests/v3/expected/null-in-enum.immutable.ts @@ -36,3 +36,5 @@ export interface components { } export interface operations {} + +export interface external {} diff --git a/tests/v3/expected/null-in-enum.ts b/tests/v3/expected/null-in-enum.ts index 5e162ed60..2ecf336bf 100644 --- a/tests/v3/expected/null-in-enum.ts +++ b/tests/v3/expected/null-in-enum.ts @@ -36,3 +36,5 @@ export interface components { } export interface operations {} + +export interface external {} diff --git a/tests/v3/expected/petstore-openapitools.additional.ts b/tests/v3/expected/petstore-openapitools.additional.ts index c4050a457..0c41353d5 100644 --- a/tests/v3/expected/petstore-openapitools.additional.ts +++ b/tests/v3/expected/petstore-openapitools.additional.ts @@ -476,3 +476,5 @@ export interface operations { }; }; } + +export interface external {} diff --git a/tests/v3/expected/petstore-openapitools.immutable.ts b/tests/v3/expected/petstore-openapitools.immutable.ts index a3af49f30..9a7133970 100644 --- a/tests/v3/expected/petstore-openapitools.immutable.ts +++ b/tests/v3/expected/petstore-openapitools.immutable.ts @@ -476,3 +476,5 @@ export interface operations { }; }; } + +export interface external {} diff --git a/tests/v3/expected/petstore-openapitools.ts b/tests/v3/expected/petstore-openapitools.ts index 26fc4794b..4c18dac6f 100644 --- a/tests/v3/expected/petstore-openapitools.ts +++ b/tests/v3/expected/petstore-openapitools.ts @@ -476,3 +476,5 @@ export interface operations { }; }; } + +export interface external {} diff --git a/tests/v3/expected/petstore.additional.ts b/tests/v3/expected/petstore.additional.ts index 59460406a..c0635c287 100644 --- a/tests/v3/expected/petstore.additional.ts +++ b/tests/v3/expected/petstore.additional.ts @@ -463,3 +463,5 @@ export interface operations { }; }; } + +export interface external {} diff --git a/tests/v3/expected/petstore.immutable.ts b/tests/v3/expected/petstore.immutable.ts index b4367c609..5e095a3fa 100644 --- a/tests/v3/expected/petstore.immutable.ts +++ b/tests/v3/expected/petstore.immutable.ts @@ -463,3 +463,5 @@ export interface operations { }; }; } + +export interface external {} diff --git a/tests/v3/expected/petstore.ts b/tests/v3/expected/petstore.ts index 756d358ac..152806e25 100644 --- a/tests/v3/expected/petstore.ts +++ b/tests/v3/expected/petstore.ts @@ -463,3 +463,5 @@ export interface operations { }; }; } + +export interface external {} diff --git a/tests/v3/expected/stripe.additional.ts b/tests/v3/expected/stripe.additional.ts index 68cf15715..78c565ea9 100644 --- a/tests/v3/expected/stripe.additional.ts +++ b/tests/v3/expected/stripe.additional.ts @@ -31339,3 +31339,5 @@ export interface operations { }; }; } + +export interface external {} diff --git a/tests/v3/expected/stripe.immutable.ts b/tests/v3/expected/stripe.immutable.ts index 9ea5ab877..1d4653648 100644 --- a/tests/v3/expected/stripe.immutable.ts +++ b/tests/v3/expected/stripe.immutable.ts @@ -30969,3 +30969,5 @@ export interface operations { }; }; } + +export interface external {} diff --git a/tests/v3/expected/stripe.ts b/tests/v3/expected/stripe.ts index 8461aba6e..93186a7bd 100644 --- a/tests/v3/expected/stripe.ts +++ b/tests/v3/expected/stripe.ts @@ -30920,3 +30920,5 @@ export interface operations { }; }; } + +export interface external {}