diff --git a/.changeset/serious-panthers-allow.md b/.changeset/serious-panthers-allow.md new file mode 100644 index 0000000000..5cb9409e5e --- /dev/null +++ b/.changeset/serious-panthers-allow.md @@ -0,0 +1,7 @@ +--- +"@medusajs/client-types": patch +"@medusajs/medusa-oas-cli": patch +"@medusajs/oas-github-ci": patch +--- + +fix(medusa-oas-cli): fix tool not working in Medusa backends diff --git a/packages/generated/client-types/scripts/build.ts b/packages/generated/client-types/scripts/build.ts index 30b4435560..857c9ba7b4 100644 --- a/packages/generated/client-types/scripts/build.ts +++ b/packages/generated/client-types/scripts/build.ts @@ -16,7 +16,7 @@ async function run() { } const generateOASSources = async (outDir: string) => { - const params = ["oas", `--out-dir=${outDir}`, "--type=combined"] + const params = ["oas", `--out-dir=${outDir}`, "--type=combined", "--local"] const { all: logs } = await execa("medusa-oas", params, { cwd: basePath, all: true, 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 index f0b0a68e66..0538b0202e 100644 --- a/packages/oas/medusa-oas-cli/src/__tests__/command-oas.test.ts +++ b/packages/oas/medusa-oas-cli/src/__tests__/command-oas.test.ts @@ -8,9 +8,6 @@ import { readYaml } from "../utils/yaml-utils" import { readJson } from "../utils/json-utils" import execa from "execa" -const medusaPackagePath = path.dirname( - require.resolve("@medusajs/medusa/package.json") -) /** * OAS output directory * @@ -77,7 +74,7 @@ describe("command oas", () => { */ beforeAll(async () => { const outDir = path.resolve(tmpDir, uid()) - await runCLI("oas", ["--type", "admin", "--out-dir", outDir]) + await runCLI("oas", ["--type", "admin", "--out-dir", outDir, "--local"]) const generatedFilePath = path.resolve(outDir, "admin.oas.json") oas = (await readJson(generatedFilePath)) as OpenAPIObject }) @@ -104,7 +101,7 @@ describe("command oas", () => { beforeAll(async () => { const outDir = path.resolve(tmpDir, uid()) - await runCLI("oas", ["--type", "store", "--out-dir", outDir]) + await runCLI("oas", ["--type", "store", "--out-dir", outDir, "--local"]) const generatedFilePath = path.resolve(outDir, "store.oas.json") oas = (await readJson(generatedFilePath)) as OpenAPIObject }) @@ -131,7 +128,7 @@ describe("command oas", () => { beforeAll(async () => { const outDir = path.resolve(tmpDir, uid()) - await runCLI("oas", ["--type", "combined", "--out-dir", outDir]) + await runCLI("oas", ["--type", "combined", "--out-dir", outDir, "--local"]) const generatedFilePath = path.resolve(outDir, "combined.oas.json") oas = (await readJson(generatedFilePath)) as OpenAPIObject }) @@ -230,6 +227,7 @@ describe("command oas", () => { outDir, "--paths", additionalPath, + "--local" ]) const generatedFilePath = path.resolve(outDir, "store.oas.json") oas = (await readJson(generatedFilePath)) as OpenAPIObject @@ -365,6 +363,7 @@ components: outDir, "--base", filePath, + "--local" ]) const generatedFilePath = path.resolve(outDir, "store.oas.json") oas = (await readJson(generatedFilePath)) as OpenAPIObject @@ -455,4 +454,216 @@ components: ).toBeTruthy() }) }) + + describe("public OAS", () => { + let oas: OpenAPIObject + /** + * In a CI context, beforeAll might exceed the configured jest timeout. + * Until we upgrade our jest version, the timeout error will be swallowed + * and the test will fail in unexpected ways. + */ + 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 readJson(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() + }) + }) + + describe("public OAS with 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 readJson(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 881bdbd4a5..8ee47e8c3a 100644 --- a/packages/oas/medusa-oas-cli/src/command-oas.ts +++ b/packages/oas/medusa-oas-cli/src/command-oas.ts @@ -26,16 +26,6 @@ const medusaTypesPath = path.dirname( const medusaUtilsPath = path.dirname( require.resolve("@medusajs/utils/package.json") ) -/** - * OAS output directory - * - * @privateRemark - * This should be the only directory OAS is loaded from for Medusa V2. - * For now, we only use it if the --v2 flag it passed to the CLI tool. - */ -const oasOutputPath = path.resolve( - __dirname, "..", "..", "..", "..", "docs-util", "oas-output" -) const basePath = path.resolve(__dirname, "../") /** @@ -66,6 +56,10 @@ export const commandOptions: Option[] = [ new Option( "--v2", "Generate OAS files for V2 endpoints. This loads OAS from docs-util/oas-output/operations directory" + ), + new Option( + "--local", + "Generate OAS from local files rather than public OAS. This is useful for generating references in the Medusa monorepo." ) ] @@ -90,6 +84,7 @@ export async function execute(cliParams: OptionValues) { const dryRun = !!cliParams.dryRun const force = !!cliParams.force const v2 = !!cliParams.v2 + const local = !!cliParams.local const apiType: ApiType = cliParams.type @@ -122,11 +117,11 @@ export async function execute(cliParams: OptionValues) { console.log(`🟣 Generating OAS - ${apiType}`) if (apiType === "combined") { - const adminOAS = await getOASFromCodebase("admin", undefined, v2) - const storeOAS = await getOASFromCodebase("store", undefined, v2) + const adminOAS = !local ? await getPublicOas("admin", v2) : await getOASFromCodebase("admin", v2) + const storeOAS = !local ? await getPublicOas("store", v2) : await getOASFromCodebase("store", v2) oas = await combineOAS(adminOAS, storeOAS) } else { - oas = await getOASFromCodebase(apiType, undefined, v2) + oas = !local ? await getPublicOas(apiType, v2) : await getOASFromCodebase(apiType, v2) } if (additionalPaths.length || baseFile) { @@ -152,9 +147,18 @@ export async function execute(cliParams: OptionValues) { */ async function getOASFromCodebase( apiType: ApiType, - customBaseFile?: string, v2?: boolean ): Promise { + /** + * OAS output directory + * + * @privateRemark + * This should be the only directory OAS is loaded from for Medusa V2. + * For now, we only use it if the --v2 flag it passed to the CLI tool. + */ + const oasOutputPath = path.resolve( + __dirname, "..", "..", "..", "..", "docs-util", "oas-output" + ) const gen = await swaggerInline( v2 ? [ path.resolve(oasOutputPath, "operations", apiType), @@ -171,15 +175,21 @@ async function getOASFromCodebase( path.resolve(medusaPackagePath, "dist", `api/routes/${apiType}`), ], { - base: - customBaseFile ?? - path.resolve(oasOutputPath, v2 ? "base-v2" : "base", `${apiType}.oas.base.yaml`), + base: path.resolve(oasOutputPath, v2 ? "base-v2" : "base", `${apiType}.oas.base.yaml`), format: ".json", } ) return (await OpenAPIParser.parse(JSON.parse(gen))) as OpenAPIObject } +async function getPublicOas( + apiType: ApiType, + v2?: boolean +) { + const url = `https://docs.medusajs.com/api/download/${apiType}?version=${v2 ? "2" : "1"}` + return await OpenAPIParser.parse(url) as OpenAPIObject +} + async function getOASFromPaths( additionalPaths: string[] = [], customBaseFile?: string diff --git a/packages/oas/oas-github-ci/scripts/build-openapi.js b/packages/oas/oas-github-ci/scripts/build-openapi.js index 1c97cef9ad..9976e1c5b5 100755 --- a/packages/oas/oas-github-ci/scripts/build-openapi.js +++ b/packages/oas/oas-github-ci/scripts/build-openapi.js @@ -24,7 +24,7 @@ const run = async () => { } const generateOASSource = async (outDir, apiType) => { - const commandParams = ["oas", `--type=${apiType}`, `--out-dir=${outDir}`] + const commandParams = ["oas", `--type=${apiType}`, `--out-dir=${outDir}`, "--local"] if (v2) { commandParams.push(`--v2`) } diff --git a/www/apps/api-reference/app/api/download/[area]/route.ts b/www/apps/api-reference/app/api/download/[area]/route.ts index 1c2b2fd6f1..2f8eccda34 100644 --- a/www/apps/api-reference/app/api/download/[area]/route.ts +++ b/www/apps/api-reference/app/api/download/[area]/route.ts @@ -1,6 +1,7 @@ import { existsSync, readFileSync } from "fs" import { NextResponse } from "next/server" import path from "path" +import { Version } from "../../../../types/openapi" type DownloadParams = { params: { @@ -9,8 +10,16 @@ type DownloadParams = { } export function GET(request: Request, { params }: DownloadParams) { + const { searchParams } = new URL(request.url) const { area } = params - const filePath = path.join(process.cwd(), `specs/${area}/openapi.full.yaml`) + const version = + process.env.NEXT_PUBLIC_VERSIONING === "true" + ? (searchParams.get("version") as Version) || "1" + : "1" + const filePath = path.join( + process.cwd(), + `${version === "1" ? "specs" : "specs-v2"}/${area}/openapi.full.yaml` + ) if (!existsSync(filePath)) { return new NextResponse(null, {