From 966aea65c221403bf316ae7665cc8f73bccd9c38 Mon Sep 17 00:00:00 2001 From: Patrick <116003638+patrick-medusajs@users.noreply.github.com> Date: Fri, 10 Mar 2023 04:37:59 -0500 Subject: [PATCH] feat(oas-cli): combine admin + store + custom OAS (#3411) --- .changeset/witty-ads-matter.md | 6 + ...in-spec3-base.yaml => admin.oas.base.yaml} | 0 ...re-spec3-base.yaml => store.oas.base.yaml} | 0 packages/medusa/package.json | 3 +- packages/oas/medusa-oas-cli/README.md | 18 +- packages/oas/medusa-oas-cli/jest.config.js | 14 + .../medusa-oas-cli/oas/default.oas.base.yaml | 5 + packages/oas/medusa-oas-cli/package.json | 13 +- .../src/__tests__/command-oas.test.ts | 465 ++++++++++++++++++ .../oas/medusa-oas-cli/src/command-oas.ts | 99 +++- packages/oas/medusa-oas-cli/src/types.ts | 1 + .../medusa-oas-cli/src/utils/combine-oas.ts | 118 +++++ .../oas/medusa-oas-cli/src/utils/merge-oas.ts | 93 ++++ packages/oas/medusa-oas-cli/tsconfig.json | 7 +- .../oas-github-ci/scripts/build-openapi.js | 11 +- yarn.lock | 4 + 16 files changed, 818 insertions(+), 39 deletions(-) create mode 100644 .changeset/witty-ads-matter.md rename packages/medusa/oas/{admin-spec3-base.yaml => admin.oas.base.yaml} (100%) rename packages/medusa/oas/{store-spec3-base.yaml => store.oas.base.yaml} (100%) create mode 100644 packages/oas/medusa-oas-cli/jest.config.js create mode 100644 packages/oas/medusa-oas-cli/oas/default.oas.base.yaml create mode 100644 packages/oas/medusa-oas-cli/src/__tests__/command-oas.test.ts create mode 100644 packages/oas/medusa-oas-cli/src/types.ts create mode 100644 packages/oas/medusa-oas-cli/src/utils/combine-oas.ts create mode 100644 packages/oas/medusa-oas-cli/src/utils/merge-oas.ts diff --git a/.changeset/witty-ads-matter.md b/.changeset/witty-ads-matter.md new file mode 100644 index 0000000000..91bfebf283 --- /dev/null +++ b/.changeset/witty-ads-matter.md @@ -0,0 +1,6 @@ +--- +"@medusajs/medusa-oas-cli": minor +"@medusajs/medusa": patch +--- + +feat(oas-cli): combine admin + store + custom OAS diff --git a/packages/medusa/oas/admin-spec3-base.yaml b/packages/medusa/oas/admin.oas.base.yaml similarity index 100% rename from packages/medusa/oas/admin-spec3-base.yaml rename to packages/medusa/oas/admin.oas.base.yaml diff --git a/packages/medusa/oas/store-spec3-base.yaml b/packages/medusa/oas/store.oas.base.yaml similarity index 100% rename from packages/medusa/oas/store-spec3-base.yaml rename to packages/medusa/oas/store.oas.base.yaml diff --git a/packages/medusa/package.json b/packages/medusa/package.json index 0b8a64b44e..b2a10232af 100644 --- a/packages/medusa/package.json +++ b/packages/medusa/package.json @@ -13,7 +13,8 @@ "access": "public" }, "files": [ - "dist" + "dist", + "oas" ], "author": "Sebastian Rindom", "license": "MIT", diff --git a/packages/oas/medusa-oas-cli/README.md b/packages/oas/medusa-oas-cli/README.md index 9b772f5189..103fd4756f 100644 --- a/packages/oas/medusa-oas-cli/README.md +++ b/packages/oas/medusa-oas-cli/README.md @@ -25,15 +25,16 @@ yarn medusa-oas This command will scan the `@medusajs/medusa` package in order to extract JSDoc OAS into a json file. -By default, the command will output the two files `admin.oas.json` and `store.oas.json` in the same directory that the -command was run. +The command will output one of three the files `admin.oas.json`, `store.oas.json` or `combined.oas.json` in the same +directory that the command was run. Invalid OAS with throw an error and will prevent the files from being outputted. #### `--type ` -Specify which API OAS to create. Accepts `all`, `admin`, `store`. -Defaults to `all`. +Specify which API OAS to create. Accepts `admin`, `store`, `combined`. + +The `combined` option will merge both the admin and the store APIs into a single OAS file. ```bash yarn medusa-oas oas --type admin @@ -57,6 +58,15 @@ It accepts multiple entries. yarn medusa-oas oas --paths ~/medusa-server/src ``` +#### `--base ` + +Allows overwriting the content the API's base.yaml OAS that is fed to swagger-inline. +Paths, tags, and components will be merged together. Other OAS properties will be overwritten. + +```bash +yarn medusa-oas oas --base ~/medusa-server/oas/custom.oas.base.yaml +``` + #### `--dry-run` Will package the OAS but will not output file. Useful for validating OAS. diff --git a/packages/oas/medusa-oas-cli/jest.config.js b/packages/oas/medusa-oas-cli/jest.config.js new file mode 100644 index 0000000000..5e9ddf76a1 --- /dev/null +++ b/packages/oas/medusa-oas-cli/jest.config.js @@ -0,0 +1,14 @@ +module.exports = { + globals: { + "ts-jest": { + tsConfig: "tsconfig.json", + isolatedModules: false, + }, + }, + transform: { + "^.+\\.[jt]s?$": "ts-jest", + }, + testEnvironment: `node`, + moduleFileExtensions: [`js`, `ts`], + testTimeout: 30000, +} diff --git a/packages/oas/medusa-oas-cli/oas/default.oas.base.yaml b/packages/oas/medusa-oas-cli/oas/default.oas.base.yaml new file mode 100644 index 0000000000..4316ee1169 --- /dev/null +++ b/packages/oas/medusa-oas-cli/oas/default.oas.base.yaml @@ -0,0 +1,5 @@ +openapi: 3.0.0 +info: + version: 1.0.0 + title: Medusa API +paths: { } \ No newline at end of file diff --git a/packages/oas/medusa-oas-cli/package.json b/packages/oas/medusa-oas-cli/package.json index 4aba185a59..9a02005d32 100644 --- a/packages/oas/medusa-oas-cli/package.json +++ b/packages/oas/medusa-oas-cli/package.json @@ -7,7 +7,8 @@ "medusa-oas": "./dist/index.js" }, "files": [ - "dist" + "dist", + "oas" ], "repository": { "type": "git", @@ -20,15 +21,19 @@ "author": "Medusa", "license": "MIT", "devDependencies": { + "jest": "^25.5.4", + "js-yaml": "^4.1.0", + "ts-jest": "^25.5.1", "ts-node": "^10.9.1", - "typescript": "4.9.5" + "typescript": "4.9.5", + "uuid": "^9.0.0" }, "scripts": { "prepare": "cross-env NODE_ENV=production yarn run build", "build": "tsc --build", "cli": "ts-node src/index.ts", - "test": "jest --passWithNoTests", - "test:unit": "jest --passWithNoTests" + "test": "jest src", + "test:unit": "jest src" }, "dependencies": { "@medusajs/medusa": "*", diff --git a/packages/oas/medusa-oas-cli/src/__tests__/command-oas.test.ts b/packages/oas/medusa-oas-cli/src/__tests__/command-oas.test.ts new file mode 100644 index 0000000000..f9836230a8 --- /dev/null +++ b/packages/oas/medusa-oas-cli/src/__tests__/command-oas.test.ts @@ -0,0 +1,465 @@ +import os from "os" +import fs from "fs/promises" +import execa from "execa" +import path from "path" +import { v4 as uid } from "uuid" +import { OpenAPIObject, SchemaObject } from "openapi3-ts" +import * as yaml from "js-yaml" +import { OperationObject } from "openapi3-ts/src/model/OpenApi" + +const medusaPackagePath = path.dirname( + require.resolve("@medusajs/medusa/package.json") +) +const basePath = path.resolve(__dirname, `../../`) + +const getTmpDirectory = async () => { + /** + * RUNNER_TEMP: GitHub action, the path to a temporary directory on the runner. + */ + const tmpDir = process.env["RUNNER_TEMP"] ?? os.tmpdir() + return await fs.mkdtemp(tmpDir) +} + +const runCLI = async (command: string, options: string[] = []) => { + const params = ["run", "cli", command, ...options] + try { + const { all: logs } = await execa("yarn", params, { + cwd: basePath, + all: true, + }) + } catch (err) { + console.error(err) + throw err + } +} + +const isFile = async (filePath): Promise => { + return (await fs.lstat(path.resolve(filePath))).isFile() +} + +const readJsonFile = async (filePath): Promise => { + const jsonString = await fs.readFile(filePath, "utf8") + return JSON.parse(jsonString) +} + +const readYamlFile = async (filePath): Promise => { + const yamlString = await fs.readFile(filePath, "utf8") + return yaml.load(yamlString) +} + +const listOperations = (oas: OpenAPIObject): OperationObject[] => { + const operations: OperationObject[] = [] + for (const url in oas.paths) { + if (oas.paths.hasOwnProperty(url)) { + const path = oas.paths[url] + for (const method in path) { + if (path.hasOwnProperty(method)) { + switch (method) { + case "get": + case "put": + case "post": + case "delete": + case "options": + case "head": + case "patch": + operations.push(path[method]) + break + } + } + } + } + } + return operations +} + +describe("command oas", () => { + let tmpDir: string + + beforeAll(async () => { + tmpDir = await getTmpDirectory() + }) + + describe("--type admin", () => { + let oas: OpenAPIObject + + beforeAll(async () => { + const outDir = path.resolve(tmpDir, uid()) + await runCLI("oas", ["--type", "admin", "--out-dir", outDir]) + const generatedFilePath = path.resolve(outDir, "admin.oas.json") + oas = (await readJsonFile(generatedFilePath)) as OpenAPIObject + }) + + it("generates oas with admin routes only", async () => { + const routes = Object.keys(oas.paths) + expect(routes.includes("/admin/products")).toBeTruthy() + expect(routes.includes("/store/products")).toBeFalsy() + }) + + it("generates oas using admin.oas.base.yaml", async () => { + const yamlFilePath = path.resolve( + medusaPackagePath, + "oas", + "admin.oas.base.yaml" + ) + const oasBase = (await readYamlFile(yamlFilePath)) as OpenAPIObject + expect(oas.info.title).toEqual(oasBase.info.title) + }) + }) + + describe("--type store", () => { + let oas: OpenAPIObject + + beforeAll(async () => { + const outDir = path.resolve(tmpDir, uid()) + await runCLI("oas", ["--type", "store", "--out-dir", outDir]) + const generatedFilePath = path.resolve(outDir, "store.oas.json") + oas = (await readJsonFile(generatedFilePath)) as OpenAPIObject + }) + + it("generates oas with store routes only", async () => { + const routes = Object.keys(oas.paths) + expect(routes.includes("/admin/products")).toBeFalsy() + expect(routes.includes("/store/products")).toBeTruthy() + }) + + it("generates oas using store.oas.base.yaml", async () => { + const yamlFilePath = path.resolve( + medusaPackagePath, + "oas", + "store.oas.base.yaml" + ) + const oasBase = (await readYamlFile(yamlFilePath)) as OpenAPIObject + expect(oas.info.title).toEqual(oasBase.info.title) + }) + }) + + describe("--type combined", () => { + let oas: OpenAPIObject + + beforeAll(async () => { + const outDir = path.resolve(tmpDir, uid()) + await runCLI("oas", ["--type", "combined", "--out-dir", outDir]) + const generatedFilePath = path.resolve(outDir, "combined.oas.json") + oas = (await readJsonFile(generatedFilePath)) as OpenAPIObject + }) + + it("generates oas with admin and store routes", async () => { + const routes = Object.keys(oas.paths) + expect(routes.includes("/admin/products")).toBeTruthy() + expect(routes.includes("/store/products")).toBeTruthy() + }) + + it("generates oas using default.oas.base.yaml", async () => { + const yamlFilePath = path.resolve( + basePath, + "oas", + "default.oas.base.yaml" + ) + const oasBase = (await readYamlFile(yamlFilePath)) as OpenAPIObject + expect(oas.info.title).toEqual(oasBase.info.title) + }) + + it("prefixes tags with api type", async () => { + const found = (oas.tags ?? []).filter((tag) => { + return !(tag.name.startsWith("Admin") || tag.name.startsWith("Store")) + }) + expect(found).toEqual([]) + }) + + it("prefixes route's tags with api type", async () => { + const tags: string[] = listOperations(oas) + .map((operation) => { + return operation.tags ?? [] + }) + .flat() + const found = tags.filter((tag) => { + return !(tag.startsWith("Admin") || tag.startsWith("Store")) + }) + expect(found).toEqual([]) + }) + + it("prefixes route's operationId with api type", async () => { + const operationIds: string[] = listOperations(oas) + .map((operation) => operation.operationId) + .filter((operationId): operationId is string => !!operationId) + const found = operationIds.filter((tag) => { + return !(tag.startsWith("Admin") || tag.startsWith("Store")) + }) + expect(found).toEqual([]) + }) + + it("combines components.schemas from admin and store", async () => { + const schemas = Object.keys(oas.components?.schemas ?? {}) + expect(schemas.includes("AdminProductsListRes")).toBeTruthy() + expect(schemas.includes("StoreProductsListRes")).toBeTruthy() + }) + }) + + /** + * to optimize test suite time, we only test --paths with the store api + */ + describe("--paths", () => { + let oas: OpenAPIObject + + beforeAll(async () => { + const fileContent = ` +/** @oas [get] /foobar/tests + * operationId: GetFoobarTests + */ +/** @oas [get] /store/regions + * operationId: OverwrittenOperation + */ +/** + * @schema FoobarTestSchema + * type: object + * properties: + * foo: + * type: string + */ +/** + * @schema StoreRegionsListRes + * type: object + * properties: + * foo: + * type: string + */ +` + const additionalPath = path.resolve(tmpDir, uid()) + const filePath = path.resolve(additionalPath, "foobar.ts") + await fs.mkdir(additionalPath, { recursive: true }) + await fs.writeFile(filePath, fileContent, "utf8") + + const outDir = path.resolve(tmpDir, uid()) + await runCLI("oas", [ + "--type", + "store", + "--out-dir", + outDir, + "--paths", + additionalPath, + ]) + const generatedFilePath = path.resolve(outDir, "store.oas.json") + oas = (await readJsonFile(generatedFilePath)) as OpenAPIObject + }) + + it("should add new path to existing paths", async () => { + const routes = Object.keys(oas.paths) + expect(routes.includes("/store/products")).toBeTruthy() + expect(routes.includes("/foobar/tests")).toBeTruthy() + }) + + it("should overwrite existing path", async () => { + expect(oas.paths["/store/regions"]["get"].operationId).toBe( + "OverwrittenOperation" + ) + }) + + it("should add new schema to existing schemas", async () => { + const schemas = Object.keys(oas.components?.schemas ?? {}) + expect(schemas.includes("StoreProductsListRes")).toBeTruthy() + expect(schemas.includes("FoobarTestSchema")).toBeTruthy() + }) + + it("should overwrite existing schema", async () => { + const schema = oas.components?.schemas?.StoreRegionsListRes as + | SchemaObject + | undefined + expect(schema?.properties?.foo).toBeDefined() + }) + }) + + /** + * to optimize test suite time, we only test --base with the store api + */ + describe("--base", () => { + let oas: OpenAPIObject + + beforeAll(async () => { + const fileContent = ` +openapi: 3.1.0 +info: + version: 1.0.1 + title: Custom API +servers: + - url: https://foobar.com +security: + - api_key: [] +externalDocs: + url: https://docs.com +webhooks: + "foo-hook": + get: + responses: + "200": + description: OK +tags: + - name: Products + description: Overwritten tag + - name: FoobarTag + description: Foobar tag description +paths: + "/foobar/tests": + get: + operationId: GetFoobarTests + responses: + "200": + description: OK + "/store/regions": + get: + operationId: OverwrittenOperation + responses: + "200": + description: OK +components: + schemas: + FoobarTestSchema: + type: object + properties: + foo: + type: string + StoreRegionsListRes: + type: object + properties: + foo: + type: string + callbacks: + fooCallback: + get: + description: foo callback + examples: + fooExample: + description: foo example + headers: + fooHeader: + description: foo header + links: + fooLink: + description: foo link + operationRef: GetFoobarTests + parameters: + fooParameter: + description: foo parameter + name: foobar + in: path + required: true + schema: + type: string + requestBodies: + fooRequestBody: + description: foo requestBody + content: + "application/octet-stream": { } + responses: + fooResponse: + description: foo response + securitySchemes: + fooSecurity: + description: foo security + type: apiKey + name: foo-api-key + in: header +` + const targetDir = path.resolve(tmpDir, uid()) + const filePath = path.resolve(targetDir, "custom.oas.base.yaml") + await fs.mkdir(targetDir, { recursive: true }) + await fs.writeFile(filePath, fileContent, "utf8") + + const outDir = path.resolve(tmpDir, uid()) + await runCLI("oas", [ + "--type", + "store", + "--out-dir", + outDir, + "--base", + filePath, + ]) + const generatedFilePath = path.resolve(outDir, "store.oas.json") + oas = (await readJsonFile(generatedFilePath)) as OpenAPIObject + }) + + it("should add new path to existing paths", async () => { + const routes = Object.keys(oas.paths) + expect(routes.includes("/store/products")).toBeTruthy() + expect(routes.includes("/foobar/tests")).toBeTruthy() + }) + + it("should overwrite existing path", async () => { + expect(oas.paths["/store/regions"]["get"].operationId).toBe( + "OverwrittenOperation" + ) + }) + + it("should add new schema to existing schemas", async () => { + const schemas = Object.keys(oas.components?.schemas ?? {}) + expect(schemas.includes("StoreProductsListRes")).toBeTruthy() + expect(schemas.includes("FoobarTestSchema")).toBeTruthy() + }) + + it("should overwrite existing schema", async () => { + const schema = oas.components?.schemas?.StoreRegionsListRes as + | SchemaObject + | undefined + expect(schema?.properties?.foo).toBeDefined() + }) + + it("should replace base properties", async () => { + expect(oas.openapi).toBe("3.1.0") + expect(oas.info).toEqual({ version: "1.0.1", title: "Custom API" }) + expect(oas.servers).toEqual([{ url: "https://foobar.com" }]) + expect(oas.security).toEqual([{ api_key: [] }]) + expect(oas.externalDocs).toEqual({ url: "https://docs.com" }) + expect(oas.webhooks).toEqual({ + "foo-hook": { get: { responses: { "200": { description: "OK" } } } }, + }) + }) + + it("should add new tag", async () => { + expect(oas.tags).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "FoobarTag", + }), + ]) + ) + }) + + it("should overwrite existing tag", async () => { + expect(oas.tags).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "Products", + description: "Overwritten tag", + }), + ]) + ) + }) + + it("should add new components", async () => { + const components = oas.components ?? {} + expect( + Object.keys(components.callbacks ?? {}).includes("fooCallback") + ).toBeTruthy() + expect( + Object.keys(components.examples ?? {}).includes("fooExample") + ).toBeTruthy() + expect( + Object.keys(components.headers ?? {}).includes("fooHeader") + ).toBeTruthy() + expect( + Object.keys(components.links ?? {}).includes("fooLink") + ).toBeTruthy() + expect( + Object.keys(components.parameters ?? {}).includes("fooParameter") + ).toBeTruthy() + expect( + Object.keys(components.requestBodies ?? {}).includes("fooRequestBody") + ).toBeTruthy() + expect( + Object.keys(components.responses ?? {}).includes("fooResponse") + ).toBeTruthy() + expect( + Object.keys(components.securitySchemes ?? {}).includes("fooSecurity") + ).toBeTruthy() + }) + }) +}) diff --git a/packages/oas/medusa-oas-cli/src/command-oas.ts b/packages/oas/medusa-oas-cli/src/command-oas.ts index bbeeaceafc..218345a152 100644 --- a/packages/oas/medusa-oas-cli/src/command-oas.ts +++ b/packages/oas/medusa-oas-cli/src/command-oas.ts @@ -4,6 +4,11 @@ import swaggerInline from "swagger-inline" import OpenAPIParser from "@readme/openapi-parser" import { OpenAPIObject } from "openapi3-ts" import { Command, Option, OptionValues } from "commander" +import { combineOAS } from "./utils/combine-oas" +import { + mergeBaseIntoOAS, + mergePathsAndSchemasIntoOAS, +} from "./utils/merge-oas" /** * Constants @@ -12,8 +17,7 @@ import { Command, Option, OptionValues } from "commander" const medusaPackagePath = path.dirname( require.resolve("@medusajs/medusa/package.json") ) - -type ApiType = "store" | "admin" +const basePath = path.resolve(__dirname, "../") /** * CLI Command declaration @@ -23,9 +27,9 @@ export const commandDescription = "Compile full OAS from swagger-inline compliant JSDoc." export const commandOptions: Option[] = [ - new Option("-t, --type ", "API type to compile []") - .choices(["all", "admin", "store"]) - .default("all"), + new Option("-t, --type ", "API type to compile.") + .choices(["admin", "store", "combined"]) + .makeOptionMandatory(), new Option( "-o, --out-dir ", "Destination directory to output generated OAS files." @@ -35,6 +39,10 @@ export const commandOptions: Option[] = [ "-p, --paths ", "Additional paths to crawl for OAS JSDoc." ), + new Option( + "-b, --base ", + "Custom base OAS file to use for swagger-inline." + ), new Option("-F, --force", "Ignore OAS validation and output OAS files."), ] @@ -58,8 +66,7 @@ export async function execute(cliParams: OptionValues) { const dryRun = !!cliParams.dryRun const force = !!cliParams.force - const apiTypesToExport = - cliParams.type === "all" ? ["store", "admin"] : [cliParams.type] + const apiType: ApiType = cliParams.type const outDir = path.resolve(cliParams.outDir) @@ -72,6 +79,13 @@ export async function execute(cliParams: OptionValues) { } } + const baseFile = cliParams.base ? path.resolve(cliParams.base) : undefined + if (baseFile) { + if (!(await isFile(cliParams.base))) { + throw new Error(`--base must be a file - ${baseFile}`) + } + } + /** * Command execution */ @@ -79,13 +93,30 @@ export async function execute(cliParams: OptionValues) { await mkdir(outDir, { recursive: true }) } - for (const apiType of apiTypesToExport) { - console.log(`🟣 Generating OAS - ${apiType}`) - const oas = await getOASFromCodebase(apiType as ApiType, additionalPaths) - await validateOAS(oas, apiType as ApiType, force) - if (!dryRun) { - await exportOASToJSON(oas, apiType as ApiType, outDir) + let oas: OpenAPIObject + console.log(`🟣 Generating OAS - ${apiType}`) + + if (apiType === "combined") { + const adminOAS = await getOASFromCodebase("admin") + const storeOAS = await getOASFromCodebase("store") + oas = await combineOAS(adminOAS, storeOAS) + } else { + oas = await getOASFromCodebase(apiType) + } + + if (additionalPaths.length || baseFile) { + const customOAS = await getOASFromPaths(additionalPaths, baseFile) + if (baseFile) { + mergeBaseIntoOAS(oas, customOAS) } + if (additionalPaths.length) { + mergePathsAndSchemasIntoOAS(oas, customOAS) + } + } + + await validateOAS(oas, apiType, force) + if (!dryRun) { + await exportOASToJSON(oas, apiType, outDir) } } @@ -94,7 +125,7 @@ export async function execute(cliParams: OptionValues) { */ async function getOASFromCodebase( apiType: ApiType, - additionalPaths: string[] = [] + customBaseFile?: string ): Promise { const gen = await swaggerInline( [ @@ -102,18 +133,30 @@ async function getOASFromCodebase( path.resolve(medusaPackagePath, "dist", "types"), path.resolve(medusaPackagePath, "dist", "api/middlewares"), path.resolve(medusaPackagePath, "dist", `api/routes/${apiType}`), - ...additionalPaths, ], { - base: path.resolve( - medusaPackagePath, - "oas", - `${apiType}-spec3-base.yaml` - ), + base: + customBaseFile ?? + path.resolve(medusaPackagePath, "oas", `${apiType}.oas.base.yaml`), format: ".json", } ) + return await OpenAPIParser.parse(JSON.parse(gen)) +} +async function getOASFromPaths( + additionalPaths: string[] = [], + customBaseFile?: string +): Promise { + console.log(`🔵 Gathering custom OAS`) + const gen = await swaggerInline(additionalPaths, { + base: + customBaseFile ?? path.resolve(basePath, "oas", "default.oas.base.yaml"), + format: ".json", + logger: (log) => { + console.log(log) + }, + }) return await OpenAPIParser.parse(JSON.parse(gen)) } @@ -145,5 +188,19 @@ async function exportOASToJSON( } async function isDirectory(dirPath: string): Promise { - return (await lstat(path.resolve(dirPath))).isDirectory() + try { + return (await lstat(path.resolve(dirPath))).isDirectory() + } catch (err) { + console.log(err) + return false + } +} + +async function isFile(filePath: string): Promise { + try { + return (await lstat(path.resolve(filePath))).isFile() + } catch (err) { + console.log(err) + return false + } } diff --git a/packages/oas/medusa-oas-cli/src/types.ts b/packages/oas/medusa-oas-cli/src/types.ts new file mode 100644 index 0000000000..fca1bf297e --- /dev/null +++ b/packages/oas/medusa-oas-cli/src/types.ts @@ -0,0 +1 @@ +type ApiType = "store" | "admin" | "combined" diff --git a/packages/oas/medusa-oas-cli/src/utils/combine-oas.ts b/packages/oas/medusa-oas-cli/src/utils/combine-oas.ts new file mode 100644 index 0000000000..a4e8175864 --- /dev/null +++ b/packages/oas/medusa-oas-cli/src/utils/combine-oas.ts @@ -0,0 +1,118 @@ +import { OpenAPIObject } from "openapi3-ts" +import { upperFirst } from "lodash" + +export async function combineOAS( + adminOAS: OpenAPIObject, + storeOAS: OpenAPIObject +): Promise { + prepareOASForCombine(adminOAS, "admin") + prepareOASForCombine(storeOAS, "store") + + const combinedOAS: OpenAPIObject = { + openapi: "3.0.0", + info: { title: "Medusa API", version: "1.0.0" }, + servers: [], + paths: {}, + tags: [], + components: { + callbacks: {}, + examples: {}, + headers: {}, + links: {}, + parameters: {}, + requestBodies: {}, + responses: {}, + schemas: {}, + securitySchemes: {}, + }, + } + + for (const oas of [adminOAS, storeOAS]) { + /** + * Combine paths + */ + Object.assign(combinedOAS.paths, oas.paths) + /** + * Combine tags + */ + if (oas.tags) { + combinedOAS.tags = [...combinedOAS.tags!, ...oas.tags] + } + /** + * Combine components + */ + if (oas.components) { + for (const componentGroup of [ + "callbacks", + "examples", + "headers", + "links", + "parameters", + "requestBodies", + "responses", + "schemas", + "securitySchemes", + ]) { + if (Object.keys(oas.components).includes(componentGroup)) { + Object.assign( + combinedOAS.components![componentGroup], + oas.components[componentGroup] + ) + } + } + } + } + + return combinedOAS +} + +function prepareOASForCombine( + oas: OpenAPIObject, + apiType: ApiType +): OpenAPIObject { + console.log( + `🔵 Prefixing ${apiType} tags and operationId with ${upperFirst(apiType)}` + ) + for (const pathKey in oas.paths) { + for (const operationKey in oas.paths[pathKey]) { + /** + * Prefix tags declared on routes + * e.g.: Admin Customer, Store Customer + */ + if (oas.paths[pathKey][operationKey].tags) { + oas.paths[pathKey][operationKey].tags = oas.paths[pathKey][ + operationKey + ].tags.map((tag) => getPrefixedTagName(tag, apiType)) + } + /** + * Prefix operationId + * e.g.: AdminGetCustomers, StoreGetCustomers + */ + if (oas.paths[pathKey][operationKey].operationId) { + oas.paths[pathKey][operationKey].operationId = getPrefixedOperationId( + oas.paths[pathKey][operationKey].operationId, + apiType + ) + } + } + } + + /** + * Prefix tags globally + * e.g.: Admin Customer, Store Customer + */ + if (oas.tags) { + for (const tag of oas.tags) { + tag.name = getPrefixedTagName(tag.name, apiType) + } + } + return oas +} + +function getPrefixedTagName(tagName: string, apiType: ApiType): string { + return `${upperFirst(apiType)} ${tagName}` +} + +function getPrefixedOperationId(operationId: string, apiType: ApiType): string { + return `${upperFirst(apiType)}${operationId}` +} diff --git a/packages/oas/medusa-oas-cli/src/utils/merge-oas.ts b/packages/oas/medusa-oas-cli/src/utils/merge-oas.ts new file mode 100644 index 0000000000..ffdc48cd0c --- /dev/null +++ b/packages/oas/medusa-oas-cli/src/utils/merge-oas.ts @@ -0,0 +1,93 @@ +import { OpenAPIObject, TagObject } from "openapi3-ts" + +export function mergeBaseIntoOAS( + targetOAS: OpenAPIObject, + sourceOAS: OpenAPIObject +): void { + /** + * replace strategy for OpenAPIObject properties + */ + targetOAS.openapi = sourceOAS.openapi ?? targetOAS.openapi + targetOAS.info = sourceOAS.info ?? targetOAS.info + targetOAS.servers = sourceOAS.servers ?? targetOAS.servers + targetOAS.security = sourceOAS.security ?? targetOAS.security + targetOAS.externalDocs = sourceOAS.externalDocs ?? targetOAS.externalDocs + targetOAS.webhooks = sourceOAS.webhooks ?? targetOAS.webhooks + /** + * merge + concat strategy for tags + */ + const targetTags = targetOAS.tags ?? [] + const sourceTags = sourceOAS.tags ?? [] + const combinedTags: TagObject[] = [] + const sourceIndexes: number[] = [] + for (const targetTag of targetTags) { + for (const [sourceTagIndex, sourceTag] of sourceTags.entries()) { + if (targetTag.name === sourceTag.name) { + combinedTags.push(sourceTag) + sourceIndexes.push(sourceTagIndex) + continue + } + combinedTags.push(targetTag) + } + } + for (const [sourceTagIndex, sourceTag] of sourceTags.entries()) { + if (!sourceIndexes.includes(sourceTagIndex)) { + combinedTags.push(sourceTag) + } + } + targetOAS.tags = combinedTags + /** + * merge strategy for paths + */ + targetOAS.paths = Object.assign(targetOAS.paths ?? {}, sourceOAS.paths ?? {}) + /** + * merge strategy for components + */ + if (!sourceOAS.components) { + return + } + if (!targetOAS.components) { + targetOAS.components = {} + } + for (const componentGroup of [ + "callbacks", + "examples", + "headers", + "links", + "parameters", + "requestBodies", + "responses", + "schemas", + "securitySchemes", + ]) { + if (Object.keys(sourceOAS.components).includes(componentGroup)) { + targetOAS.components[componentGroup] = Object.assign( + targetOAS.components[componentGroup] ?? {}, + sourceOAS.components[componentGroup] + ) + } + } +} + +export function mergePathsAndSchemasIntoOAS( + targetOAS: OpenAPIObject, + sourceOAS: OpenAPIObject +): void { + /** + * merge paths + */ + Object.assign(targetOAS.paths, sourceOAS.paths) + + /** + * merge components.schemas + */ + if (sourceOAS.components?.schemas) { + if (!targetOAS.components) { + targetOAS.components = {} + } + if (!targetOAS.components.schemas) { + targetOAS.components.schemas = {} + } + Object.assign(targetOAS.components.schemas, sourceOAS.components.schemas) + } +} diff --git a/packages/oas/medusa-oas-cli/tsconfig.json b/packages/oas/medusa-oas-cli/tsconfig.json index c9a3f431cf..70378f47f6 100644 --- a/packages/oas/medusa-oas-cli/tsconfig.json +++ b/packages/oas/medusa-oas-cli/tsconfig.json @@ -24,8 +24,11 @@ "src" ], "exclude": [ - "node_modules", - "**/tests/*" + "dist", + "src/**/__tests__", + "src/**/__mocks__", + "src/**/__fixtures__", + "node_modules" ], "ts-node": { "transpileOnly": true diff --git a/packages/oas/oas-github-ci/scripts/build-openapi.js b/packages/oas/oas-github-ci/scripts/build-openapi.js index fdcdba9eb5..f3f51e3214 100755 --- a/packages/oas/oas-github-ci/scripts/build-openapi.js +++ b/packages/oas/oas-github-ci/scripts/build-openapi.js @@ -19,9 +19,8 @@ const redoclyConfigPath = path.resolve( const run = async () => { const outputPath = isDryRun ? await getTmpDirectory() : docsApiPath - await generateOASSources(outputPath) - for (const apiType of ["store", "admin"]) { + await generateOASSource(outputPath, apiType) const inputJsonFile = path.resolve(outputPath, `${apiType}.oas.json`) const outputYamlFile = path.resolve(outputPath, `${apiType}.oas.yaml`) @@ -34,11 +33,8 @@ const run = async () => { } } -const generateOASSources = async (outDir, isDryRun) => { - const params = ["oas", `--out-dir=${outDir}`] - if (isDryRun) { - params.push("--dry-run") - } +const generateOASSource = async (outDir, apiType) => { + const params = ["oas", `--type=${apiType}`, `--out-dir=${outDir}`] const { all: logs } = await execa("medusa-oas", params, { cwd: basePath, all: true, @@ -78,6 +74,7 @@ const circularReferenceCheck = async (srcFile) => { `🔴 Unhandled circular references - ${fileName} - Please patch in docs-util/redocly/config.yaml` ) } + console.log(`🟢 All circular references handled`) } const generateReference = async (srcFile, apiType) => { diff --git a/yarn.lock b/yarn.lock index 7d5f73c8b5..0945d9f107 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5913,11 +5913,15 @@ __metadata: "@readme/openapi-parser": ^2.4.0 "@types/lodash": ^4.14.191 commander: ^10.0.0 + jest: ^25.5.4 + js-yaml: ^4.1.0 lodash: ^4.17.21 openapi3-ts: ^3.1.2 swagger-inline: ^6.1.0 + ts-jest: ^25.5.1 ts-node: ^10.9.1 typescript: 4.9.5 + uuid: ^9.0.0 bin: medusa-oas: ./dist/index.js languageName: unknown