chore(): start moving some packages to the core directory (#7215)
This commit is contained in:
1 parent
fdee748eed
commit
bbccd6481d
1436 files changed
+275
-756
No files matched your search
@@ -0,0 +1,376 @@
|
||||
import { exists, getTmpDirectory } from "../utils/fs-utils"
|
||||
import { writeJson } from "../utils/json-utils"
|
||||
import { OpenAPIObject } from "openapi3-ts"
|
||||
import path from "path"
|
||||
import { v4 as uid } from "uuid"
|
||||
import { readYaml, writeYaml } from "../utils/yaml-utils"
|
||||
import { mkdir, readdir } from "fs/promises"
|
||||
import {
|
||||
formatHintRecommendation,
|
||||
getCircularPatchRecommendation,
|
||||
getCircularReferences,
|
||||
} from "../utils/circular-patch-utils"
|
||||
import execa from "execa"
|
||||
|
||||
const basePath = path.resolve(__dirname, `../../../`)
|
||||
|
||||
export const runCLI = async (command: string, options: string[] = []) => {
|
||||
const params = ["run", "medusa-oas", command, ...options]
|
||||
try {
|
||||
const { all: logs } = await execa("yarn", params, {
|
||||
cwd: basePath,
|
||||
all: true,
|
||||
})
|
||||
} catch (err) {
|
||||
throw new Error(err.message + err.all)
|
||||
}
|
||||
}
|
||||
|
||||
export const getBaseOpenApi = (): OpenAPIObject => {
|
||||
return {
|
||||
openapi: "3.0.0",
|
||||
info: {
|
||||
title: "Test",
|
||||
version: "1.0.0",
|
||||
},
|
||||
paths: {},
|
||||
components: {},
|
||||
}
|
||||
}
|
||||
|
||||
describe("command docs", () => {
|
||||
let tmpDir: string
|
||||
let openApi: OpenAPIObject
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await getTmpDirectory()
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
openApi = getBaseOpenApi()
|
||||
})
|
||||
|
||||
describe("basic usage", () => {
|
||||
let srcFile: string
|
||||
|
||||
beforeAll(async () => {
|
||||
openApi = getBaseOpenApi()
|
||||
openApi.components = {
|
||||
schemas: {
|
||||
Order: {
|
||||
type: "object",
|
||||
},
|
||||
},
|
||||
}
|
||||
const outDir = path.resolve(tmpDir, uid())
|
||||
await mkdir(outDir, { recursive: true })
|
||||
srcFile = path.resolve(outDir, "store.oas.json")
|
||||
await writeJson(srcFile, openApi)
|
||||
})
|
||||
|
||||
it("should generate docs", async () => {
|
||||
const outDir = path.resolve(tmpDir, uid())
|
||||
await runCLI("docs", ["--src-file", srcFile, "--out-dir", outDir])
|
||||
const generatedFilePath = path.resolve(outDir, "openapi.yaml")
|
||||
const oas = (await readYaml(generatedFilePath)) as OpenAPIObject
|
||||
const files = await readdir(outDir)
|
||||
expect(oas.components?.schemas?.Order).toBeDefined()
|
||||
expect(files.length).toBe(1)
|
||||
})
|
||||
|
||||
it("should clean output directory", async () => {
|
||||
const outDir = path.resolve(tmpDir, uid())
|
||||
await mkdir(outDir, { recursive: true })
|
||||
await writeJson(path.resolve(outDir, "test.json"), { foo: "bar" })
|
||||
await runCLI("docs", [
|
||||
"--src-file",
|
||||
srcFile,
|
||||
"--out-dir",
|
||||
outDir,
|
||||
"--clean",
|
||||
])
|
||||
const files = await readdir(outDir)
|
||||
expect(files.includes("test.json")).toBeFalsy()
|
||||
expect(files.length).toBe(1)
|
||||
})
|
||||
|
||||
it("should not output any files", async () => {
|
||||
const outDir = path.resolve(tmpDir, uid())
|
||||
await runCLI("docs", [
|
||||
"--src-file",
|
||||
srcFile,
|
||||
"--out-dir",
|
||||
outDir,
|
||||
"--dry-run",
|
||||
])
|
||||
const outDirExists = await exists(outDir)
|
||||
expect(outDirExists).toBeFalsy()
|
||||
})
|
||||
|
||||
it("should split output into multiple files and directories", async () => {
|
||||
const outDir = path.resolve(tmpDir, uid())
|
||||
/**
|
||||
* For split to output files, we need to not be in test mode
|
||||
* See https://github.com/Redocly/redocly-cli/blob/v1.0.0-beta.125/packages/cli/src/utils.ts#L206-L215
|
||||
*/
|
||||
const previousEnv = process.env.NODE_ENV
|
||||
process.env.NODE_ENV = "development"
|
||||
await runCLI("docs", [
|
||||
"--src-file",
|
||||
srcFile,
|
||||
"--out-dir",
|
||||
outDir,
|
||||
"--split",
|
||||
"--clean",
|
||||
])
|
||||
process.env.NODE_ENV = previousEnv
|
||||
const oasFileExists = await exists(path.resolve(outDir, "openapi.yaml"))
|
||||
const outFileExists = await exists(
|
||||
path.resolve(outDir, "components/schemas/Order.yaml")
|
||||
)
|
||||
expect(oasFileExists).toBeTruthy()
|
||||
expect(outFileExists).toBeTruthy()
|
||||
})
|
||||
|
||||
it("should generate static HTML docs", async () => {
|
||||
const outDir = path.resolve(tmpDir, uid())
|
||||
await runCLI("docs", [
|
||||
"--src-file",
|
||||
srcFile,
|
||||
"--out-dir",
|
||||
outDir,
|
||||
"--html",
|
||||
])
|
||||
const generatedFilePath = path.resolve(outDir, "index.html")
|
||||
const htmlFileExists = await exists(generatedFilePath)
|
||||
expect(htmlFileExists).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe("--config", () => {
|
||||
let srcFile: string
|
||||
let configFile: string
|
||||
let configYamlFile: string
|
||||
|
||||
beforeAll(async () => {
|
||||
openApi = getBaseOpenApi()
|
||||
openApi.components = {
|
||||
schemas: {
|
||||
Customer: {
|
||||
type: "object",
|
||||
properties: {
|
||||
address: {
|
||||
$ref: "#/components/schemas/Address",
|
||||
},
|
||||
},
|
||||
},
|
||||
TestOrder: {
|
||||
type: "object",
|
||||
properties: {
|
||||
address: {
|
||||
$ref: "#/components/schemas/Address",
|
||||
},
|
||||
},
|
||||
},
|
||||
Address: {
|
||||
type: "object",
|
||||
properties: {
|
||||
/**
|
||||
* We expect this circular reference to already be handled by
|
||||
* our CLI's default redocly-config.yaml
|
||||
*/
|
||||
customer: {
|
||||
$ref: "#/components/schemas/Customer",
|
||||
},
|
||||
/**
|
||||
* We expect this circular reference to not be handled.
|
||||
*/
|
||||
test_order: {
|
||||
$ref: "#/components/schemas/TestOrder",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
const config = {
|
||||
decorators: {
|
||||
"medusa/circular-patch": {
|
||||
schemas: {
|
||||
Address: ["TestOrder"],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
const outDir = path.resolve(tmpDir, uid())
|
||||
await mkdir(outDir, { recursive: true })
|
||||
srcFile = path.resolve(outDir, "store.oas.json")
|
||||
await writeJson(srcFile, openApi)
|
||||
configFile = path.resolve(outDir, "redocly-config.json")
|
||||
await writeJson(configFile, config)
|
||||
configYamlFile = path.resolve(outDir, "redocly-config.yaml")
|
||||
await writeYaml(configYamlFile, config)
|
||||
})
|
||||
|
||||
it("should fail with unhandled circular reference", async () => {
|
||||
const outDir = path.resolve(tmpDir, uid())
|
||||
await expect(
|
||||
runCLI("docs", [
|
||||
"--src-file",
|
||||
srcFile,
|
||||
"--out-dir",
|
||||
outDir,
|
||||
"--dry-run",
|
||||
])
|
||||
).rejects.toThrow("Unhandled circular references")
|
||||
})
|
||||
|
||||
it("should succeed with patched circular reference", async () => {
|
||||
const outDir = path.resolve(tmpDir, uid())
|
||||
await expect(
|
||||
runCLI("docs", [
|
||||
"--src-file",
|
||||
srcFile,
|
||||
"--out-dir",
|
||||
outDir,
|
||||
"--config",
|
||||
configFile,
|
||||
"--dry-run",
|
||||
])
|
||||
).resolves.not.toThrow()
|
||||
})
|
||||
|
||||
it("should succeed when config is of type yaml", async () => {
|
||||
const outDir = path.resolve(tmpDir, uid())
|
||||
await expect(
|
||||
runCLI("docs", [
|
||||
"--src-file",
|
||||
srcFile,
|
||||
"--out-dir",
|
||||
outDir,
|
||||
"--config",
|
||||
configYamlFile,
|
||||
"--dry-run",
|
||||
])
|
||||
).resolves.not.toThrow()
|
||||
})
|
||||
|
||||
it("should fail when config is not a file", async () => {
|
||||
const outDir = path.resolve(tmpDir, uid())
|
||||
await expect(
|
||||
runCLI("docs", [
|
||||
"--src-file",
|
||||
srcFile,
|
||||
"--out-dir",
|
||||
outDir,
|
||||
"--dry-run",
|
||||
"--config",
|
||||
outDir,
|
||||
])
|
||||
).rejects.toThrow("--config must be a file")
|
||||
})
|
||||
|
||||
it("should fail when config is not of supported file type", async () => {
|
||||
const outDir = path.resolve(tmpDir, uid())
|
||||
await mkdir(outDir, { recursive: true })
|
||||
const tmpFile = path.resolve(outDir, "tmp.txt")
|
||||
await writeJson(tmpFile, { foo: "bar" })
|
||||
await expect(
|
||||
runCLI("docs", [
|
||||
"--src-file",
|
||||
srcFile,
|
||||
"--out-dir",
|
||||
outDir,
|
||||
"--dry-run",
|
||||
"--config",
|
||||
tmpFile,
|
||||
])
|
||||
).rejects.toThrow("--config file must be of type .json or .yaml")
|
||||
})
|
||||
})
|
||||
|
||||
describe("circular references", () => {
|
||||
let srcFile: string
|
||||
|
||||
beforeAll(async () => {
|
||||
openApi = getBaseOpenApi()
|
||||
openApi.components = {
|
||||
schemas: {
|
||||
Customer: {
|
||||
type: "object",
|
||||
properties: {
|
||||
address: {
|
||||
$ref: "#/components/schemas/Address",
|
||||
},
|
||||
},
|
||||
},
|
||||
TestOrder: {
|
||||
type: "object",
|
||||
properties: {
|
||||
address: {
|
||||
$ref: "#/components/schemas/Address",
|
||||
},
|
||||
},
|
||||
},
|
||||
Address: {
|
||||
type: "object",
|
||||
properties: {
|
||||
customer: {
|
||||
$ref: "#/components/schemas/Customer",
|
||||
},
|
||||
test_order: {
|
||||
$ref: "#/components/schemas/TestOrder",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
const outDir = path.resolve(tmpDir, uid())
|
||||
await mkdir(outDir, { recursive: true })
|
||||
srcFile = path.resolve(outDir, "store.oas.json")
|
||||
await writeJson(srcFile, openApi)
|
||||
})
|
||||
|
||||
it("should find circular references", async () => {
|
||||
const { circularRefs, oas } = await getCircularReferences(srcFile)
|
||||
expect(circularRefs.length).toBe(2)
|
||||
expect(circularRefs).toEqual(
|
||||
expect.arrayContaining([
|
||||
"#/components/schemas/Address/properties/customer",
|
||||
"#/components/schemas/TestOrder/properties/address",
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it("should recommend which schemas to patch to resolve circular references", async () => {
|
||||
/**
|
||||
* The recommendation is heavily influenced but the dereference operation
|
||||
* from @readme/json-schema-ref-parser. It's not an exact science and the
|
||||
* results may vary between versions.
|
||||
*/
|
||||
const { circularRefs, oas } = await getCircularReferences(srcFile)
|
||||
const recommendation = getCircularPatchRecommendation(circularRefs, oas)
|
||||
expect(recommendation).toEqual(
|
||||
expect.objectContaining({
|
||||
Address: expect.arrayContaining(["Customer"]),
|
||||
TestOrder: expect.arrayContaining(["Address"]),
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("should format hint", async () => {
|
||||
const { circularRefs, oas } = await getCircularReferences(srcFile)
|
||||
const recommendation = getCircularPatchRecommendation(circularRefs, oas)
|
||||
const hint = formatHintRecommendation(recommendation)
|
||||
expect(hint).toEqual(
|
||||
expect.stringContaining(`decorators:
|
||||
medusa/circular-patch:
|
||||
schemas:
|
||||
Address:
|
||||
- Customer
|
||||
TestOrder:
|
||||
- Address
|
||||
`)
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,669 @@
|
||||
import fs from "fs/promises"
|
||||
import { OpenAPIObject, SchemaObject } from "openapi3-ts"
|
||||
import { OperationObject } from "openapi3-ts/src/model/OpenApi"
|
||||
import path from "path"
|
||||
import { v4 as uid } from "uuid"
|
||||
import { getTmpDirectory } from "../utils/fs-utils"
|
||||
import { readYaml } from "../utils/yaml-utils"
|
||||
import { readJson } from "../utils/json-utils"
|
||||
import execa from "execa"
|
||||
|
||||
/**
|
||||
* 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, `../../`)
|
||||
|
||||
export const runCLI = async (command: string, options: string[] = []) => {
|
||||
const params = ["run", "medusa-oas", command, ...options]
|
||||
try {
|
||||
const { all: logs } = await execa("yarn", params, {
|
||||
cwd: basePath,
|
||||
all: true,
|
||||
})
|
||||
} catch (err) {
|
||||
throw new Error(err.message + err.all)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
/**
|
||||
* 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, "--local"])
|
||||
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()
|
||||
})
|
||||
|
||||
it("generates oas using admin.oas.base.yaml", async () => {
|
||||
const yamlFilePath = path.resolve(
|
||||
oasOutputPath,
|
||||
"base",
|
||||
"admin.oas.base.yaml"
|
||||
)
|
||||
const oasBase = (await readYaml(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, "--local"])
|
||||
const generatedFilePath = path.resolve(outDir, "store.oas.json")
|
||||
oas = (await readJson(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(
|
||||
oasOutputPath,
|
||||
"base",
|
||||
"store.oas.base.yaml"
|
||||
)
|
||||
const oasBase = (await readYaml(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, "--local"])
|
||||
const generatedFilePath = path.resolve(outDir, "combined.oas.json")
|
||||
oas = (await readJson(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 readYaml(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,
|
||||
"--local"
|
||||
])
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* 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,
|
||||
"--local"
|
||||
])
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,176 @@
|
||||
import path from "path"
|
||||
import {
|
||||
generate,
|
||||
HttpClient,
|
||||
Indent,
|
||||
PackageNames,
|
||||
} from "@medusajs/openapi-typescript-codegen"
|
||||
import { upperFirst } from "lodash"
|
||||
import fs, { mkdir, readFile } from "fs/promises"
|
||||
import { OpenAPIObject } from "openapi3-ts"
|
||||
import { Command, Option, OptionValues } from "commander"
|
||||
|
||||
/**
|
||||
* CLI Command declaration
|
||||
*/
|
||||
export const commandName = "client"
|
||||
export const commandDescription = "Generate API clients from OAS."
|
||||
export const commandOptions: Option[] = [
|
||||
new Option(
|
||||
"-t, --type <type>",
|
||||
"Namespace for the generated client. Usually `admin` or `store`."
|
||||
).makeOptionMandatory(),
|
||||
|
||||
new Option(
|
||||
"-s, --src-file <srcFile>",
|
||||
"Path to source OAS JSON file."
|
||||
).makeOptionMandatory(),
|
||||
|
||||
new Option(
|
||||
"-o, --out-dir <outDir>",
|
||||
"Output directory for generated client files."
|
||||
).default(path.resolve(process.cwd(), "client")),
|
||||
|
||||
new Option(
|
||||
"-c, --component <component>",
|
||||
"Client component types to generate."
|
||||
)
|
||||
.choices(["all", "types", "client", "hooks"])
|
||||
.default("all"),
|
||||
|
||||
new Option(
|
||||
"--types-package <name>",
|
||||
"Replace relative import statements by types package name."
|
||||
),
|
||||
|
||||
new Option(
|
||||
"--client-package <name>",
|
||||
"Replace relative import statements by client package name."
|
||||
),
|
||||
|
||||
new Option(
|
||||
"--clean",
|
||||
"Delete destination directory content before generating client."
|
||||
),
|
||||
]
|
||||
|
||||
export function getCommand() {
|
||||
const command = new Command(commandName)
|
||||
command.description(commandDescription)
|
||||
for (const opt of commandOptions) {
|
||||
command.addOption(opt)
|
||||
}
|
||||
command.action(async (options) => await execute(options))
|
||||
command.showHelpAfterError(true)
|
||||
return command
|
||||
}
|
||||
|
||||
/**
|
||||
* Main
|
||||
*/
|
||||
export async function execute(cliParams: OptionValues) {
|
||||
/**
|
||||
* Process CLI options
|
||||
*/
|
||||
if (
|
||||
["client", "hooks"].includes(cliParams.component) &&
|
||||
!cliParams.typesPackage
|
||||
) {
|
||||
throw new Error(
|
||||
`--types-package must be declared when using --component=${cliParams.component}`
|
||||
)
|
||||
}
|
||||
if (cliParams.component === "hooks" && !cliParams.clientPackage) {
|
||||
throw new Error(
|
||||
`--client-package must be declared when using --component=${cliParams.component}`
|
||||
)
|
||||
}
|
||||
|
||||
const shouldClean = !!cliParams.clean
|
||||
const srcFile = path.resolve(cliParams.srcFile)
|
||||
const outDir = path.resolve(cliParams.outDir)
|
||||
const apiName = cliParams.type
|
||||
const packageNames: PackageNames = {
|
||||
models: cliParams.typesPackage,
|
||||
client: cliParams.clientPackage,
|
||||
}
|
||||
const exportComponent = cliParams.component
|
||||
|
||||
/**
|
||||
* Command execution
|
||||
*/
|
||||
console.log(`🟣 Generating client - ${apiName} - ${exportComponent}`)
|
||||
|
||||
if (shouldClean) {
|
||||
console.log(`🟠 Cleaning output directory`)
|
||||
await fs.rm(outDir, { recursive: true, force: true })
|
||||
}
|
||||
await mkdir(outDir, { recursive: true })
|
||||
|
||||
const oas = await getOASFromFile(srcFile)
|
||||
await generateClientSDK(oas, outDir, apiName, exportComponent, packageNames)
|
||||
|
||||
console.log(
|
||||
`⚫️ Client generated - ${apiName} - ${exportComponent} - ${outDir}`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Methods
|
||||
*/
|
||||
const getOASFromFile = async (jsonFile: string): Promise<OpenAPIObject> => {
|
||||
const jsonString = await readFile(jsonFile, "utf8")
|
||||
return JSON.parse(jsonString)
|
||||
}
|
||||
|
||||
const generateClientSDK = async (
|
||||
oas: OpenAPIObject,
|
||||
targetDir: string,
|
||||
apiName: string,
|
||||
exportComponent: "all" | "types" | "client" | "hooks",
|
||||
packageNames: PackageNames = {}
|
||||
) => {
|
||||
const exports = {
|
||||
exportCore: false,
|
||||
exportServices: false,
|
||||
exportModels: false,
|
||||
exportHooks: false,
|
||||
}
|
||||
|
||||
switch (exportComponent) {
|
||||
case "types":
|
||||
exports.exportModels = true
|
||||
break
|
||||
case "client":
|
||||
exports.exportCore = true
|
||||
exports.exportServices = true
|
||||
break
|
||||
case "hooks":
|
||||
exports.exportHooks = true
|
||||
break
|
||||
default:
|
||||
exports.exportCore = true
|
||||
exports.exportServices = true
|
||||
exports.exportModels = true
|
||||
exports.exportHooks = true
|
||||
}
|
||||
|
||||
await generate({
|
||||
input: oas,
|
||||
output: targetDir,
|
||||
httpClient: HttpClient.AXIOS,
|
||||
useOptions: true,
|
||||
useUnionTypes: true,
|
||||
exportCore: exports.exportCore,
|
||||
exportServices: exports.exportServices,
|
||||
exportModels: exports.exportModels,
|
||||
exportHooks: exports.exportHooks,
|
||||
exportSchemas: false,
|
||||
indent: Indent.SPACE_2,
|
||||
postfixServices: "Service",
|
||||
postfixModels: "",
|
||||
clientName: `Medusa${upperFirst(apiName)}`,
|
||||
request: undefined,
|
||||
packageNames,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import { PreviewDocsOptions, previewDocs } from "@redocly/cli/lib/commands/preview-docs"
|
||||
import { commandWrapper } from "@redocly/cli/lib/wrapper"
|
||||
import { Command, Option, OptionValues } from "commander"
|
||||
import execa from "execa"
|
||||
import fs, { mkdir } from "fs/promises"
|
||||
import { isArray, mergeWith } from "lodash"
|
||||
import * as path from "path"
|
||||
import {
|
||||
formatHintRecommendation,
|
||||
getCircularPatchRecommendation,
|
||||
getCircularReferences,
|
||||
} from "./utils/circular-patch-utils"
|
||||
import { getTmpDirectory, isFile } from "./utils/fs-utils"
|
||||
import { readJson } from "./utils/json-utils"
|
||||
import { jsonFileToYamlFile, readYaml, writeYaml, writeYamlFromJson } from "./utils/yaml-utils"
|
||||
import yargs from "yargs"
|
||||
|
||||
/**
|
||||
* Constants
|
||||
*/
|
||||
const basePath = path.resolve(__dirname, "../")
|
||||
|
||||
const medusaPluginRelativePath = "./plugins/medusa/index.js"
|
||||
const medusaPluginAbsolutePath = path.resolve(
|
||||
basePath,
|
||||
"redocly/plugins/medusa/index.js"
|
||||
)
|
||||
const configFileDefault = path.resolve(basePath, "redocly/redocly-config.yaml")
|
||||
|
||||
/**
|
||||
* CLI Command declaration
|
||||
*/
|
||||
export const commandName = "docs"
|
||||
export const commandDescription =
|
||||
"Sanitize OAS for use with Redocly's API documentation viewer."
|
||||
|
||||
export const commandOptions: Option[] = [
|
||||
new Option(
|
||||
"-s, --src-file <srcFile>",
|
||||
"Path to source OAS JSON file."
|
||||
).makeOptionMandatory(),
|
||||
new Option(
|
||||
"-o, --out-dir <outDir>",
|
||||
"Destination directory to output the sanitized OAS files."
|
||||
).default(process.cwd()),
|
||||
new Option(
|
||||
"--config <config>",
|
||||
"Configuration file to merge with default configuration before passing to Redocly's CLI."
|
||||
),
|
||||
new Option("-D, --dry-run", "Do not output files."),
|
||||
new Option(
|
||||
"--clean",
|
||||
"Delete destination directory content before generating documentation."
|
||||
),
|
||||
new Option("--split", "Creates a multi-file structure output."),
|
||||
new Option(
|
||||
"--preview",
|
||||
"Open a preview of the documentation. Does not output files."
|
||||
),
|
||||
new Option(
|
||||
"--html",
|
||||
"Generate a static HTML using Redocly's build-docs command."
|
||||
),
|
||||
new Option(
|
||||
"--main-file-name <mainFileName>",
|
||||
"The name of the main YAML file."
|
||||
).default("openapi.yaml")
|
||||
]
|
||||
|
||||
export function getCommand(): Command {
|
||||
const command = new Command(commandName)
|
||||
command.description(commandDescription)
|
||||
for (const opt of commandOptions) {
|
||||
command.addOption(opt)
|
||||
}
|
||||
command.action(async (options) => await execute(options))
|
||||
command.showHelpAfterError(true)
|
||||
return command
|
||||
}
|
||||
|
||||
/**
|
||||
* Main
|
||||
*/
|
||||
export async function execute(cliParams: OptionValues): Promise<void> {
|
||||
/**
|
||||
* Process CLI options
|
||||
*/
|
||||
const shouldClean = !!cliParams.clean
|
||||
const shouldSplit = !!cliParams.split
|
||||
const shouldPreview = !!cliParams.preview
|
||||
const shouldBuildHTML = !!cliParams.html
|
||||
const dryRun = !!cliParams.dryRun
|
||||
const srcFile = path.resolve(cliParams.srcFile)
|
||||
const outDir = path.resolve(cliParams.outDir)
|
||||
|
||||
const configFileCustom = cliParams.config
|
||||
? path.resolve(cliParams.config)
|
||||
: undefined
|
||||
if (configFileCustom) {
|
||||
if (!(await isFile(configFileCustom))) {
|
||||
throw new Error(`--config must be a file - ${configFileCustom}`)
|
||||
}
|
||||
if (![".json", ".yaml"].includes(path.extname(configFileCustom))) {
|
||||
throw new Error(
|
||||
`--config file must be of type .json or .yaml - ${configFileCustom}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Command execution
|
||||
*/
|
||||
console.log(`🟣 Generating API documentation`)
|
||||
|
||||
const tmpDir = await getTmpDirectory()
|
||||
const configTmpFile = path.resolve(tmpDir, "redocly-config.yaml")
|
||||
/** matches naming convention from `redocly split` */
|
||||
const finalOASFile = cliParams.mainFileName
|
||||
|
||||
await createTmpConfig(configFileDefault, configTmpFile)
|
||||
if (configFileCustom) {
|
||||
console.log(
|
||||
`🔵 Merging configuration file - ${configFileCustom} > ${configTmpFile}`
|
||||
)
|
||||
await mergeConfig(configTmpFile, configFileCustom, configTmpFile)
|
||||
}
|
||||
|
||||
if (!dryRun) {
|
||||
if (shouldClean) {
|
||||
console.log(`🟠 Cleaning output directory`)
|
||||
await fs.rm(outDir, { recursive: true, force: true })
|
||||
}
|
||||
await mkdir(outDir, { recursive: true })
|
||||
}
|
||||
|
||||
const srcFileSanitized = path.resolve(tmpDir, "tmp.oas.yaml")
|
||||
await sanitizeOAS(srcFile, srcFileSanitized, configTmpFile)
|
||||
await circularReferenceCheck(srcFileSanitized)
|
||||
|
||||
if (dryRun) {
|
||||
console.log(`⚫️ Dry run - no files generated`)
|
||||
return
|
||||
}
|
||||
if (shouldPreview) {
|
||||
await preview(srcFileSanitized, configTmpFile)
|
||||
return
|
||||
}
|
||||
if (shouldSplit) {
|
||||
await generateReference(srcFileSanitized, outDir)
|
||||
} else {
|
||||
await writeYaml(path.join(outDir, finalOASFile), await fs.readFile(srcFileSanitized, "utf8"))
|
||||
}
|
||||
if (shouldBuildHTML) {
|
||||
const outHTMLFile = path.resolve(outDir, "index.html")
|
||||
await buildHTML(finalOASFile, outHTMLFile, configTmpFile)
|
||||
}
|
||||
console.log(`⚫️ API documentation generated - ${outDir}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Methods
|
||||
*/
|
||||
type RedoclyConfig = {
|
||||
apis?: Record<string, unknown>
|
||||
decorators?: Record<string, unknown>
|
||||
extends?: string[]
|
||||
organization?: string
|
||||
plugins?: string[]
|
||||
preprocessors?: Record<string, unknown>
|
||||
region?: string
|
||||
resolve?: Record<string, unknown>
|
||||
rules?: Record<string, unknown>
|
||||
theme?: Record<string, unknown>
|
||||
}
|
||||
|
||||
const mergeConfig = async (
|
||||
configFileDefault: string,
|
||||
configFileCustom: string,
|
||||
configFileOut: string
|
||||
): Promise<void> => {
|
||||
const configDefault = await readYaml(configFileDefault)
|
||||
const configCustom =
|
||||
path.extname(configFileCustom) === ".yaml"
|
||||
? await readYaml(configFileCustom)
|
||||
: await readJson(configFileCustom)
|
||||
|
||||
const config = mergeWith(configDefault, configCustom, (objValue, srcValue) =>
|
||||
isArray(objValue) ? objValue.concat(srcValue) : undefined
|
||||
) as RedoclyConfig
|
||||
|
||||
await writeYamlFromJson(configFileOut, config)
|
||||
}
|
||||
|
||||
const createTmpConfig = async (
|
||||
configFileDefault: string,
|
||||
configFileOut: string
|
||||
): Promise<void> => {
|
||||
const config = (await readYaml(configFileDefault)) as RedoclyConfig
|
||||
config.plugins = (config.plugins ?? []).filter(
|
||||
(plugin) => plugin !== medusaPluginRelativePath
|
||||
)
|
||||
config.plugins.push(medusaPluginAbsolutePath)
|
||||
|
||||
await writeYamlFromJson(configFileOut, config)
|
||||
}
|
||||
|
||||
const sanitizeOAS = async (
|
||||
srcFile: string,
|
||||
outFile: string,
|
||||
configFile: string
|
||||
): Promise<void> => {
|
||||
const { all: logs } = await execa(
|
||||
"yarn",
|
||||
[
|
||||
"redocly",
|
||||
"bundle",
|
||||
srcFile,
|
||||
`--output=${outFile}`,
|
||||
`--config=${configFile}`,
|
||||
],
|
||||
{ cwd: basePath, all: true }
|
||||
)
|
||||
console.log(logs)
|
||||
}
|
||||
|
||||
const circularReferenceCheck = async (srcFile: string): Promise<void> => {
|
||||
const { circularRefs, oas } = await getCircularReferences(srcFile)
|
||||
if (circularRefs.length) {
|
||||
console.log(circularRefs)
|
||||
let errorMessage = `🔴 Unhandled circular references - Please manually patch using --config ./redocly-config.yaml`
|
||||
const recommendation = getCircularPatchRecommendation(circularRefs, oas)
|
||||
if (Object.keys(recommendation).length) {
|
||||
const hint = formatHintRecommendation(recommendation)
|
||||
errorMessage += `
|
||||
Within redocly-config.yaml, try adding the following:
|
||||
###
|
||||
${hint}
|
||||
###
|
||||
`
|
||||
}
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
console.log(`🟢 All circular references are handled`)
|
||||
}
|
||||
|
||||
const generateReference = async (
|
||||
srcFile: string,
|
||||
outDir: string
|
||||
): Promise<void> => {
|
||||
const { all: logs } = await execa(
|
||||
"yarn",
|
||||
["redocly", "split", srcFile, `--outDir=${outDir}`],
|
||||
{ cwd: basePath, all: true }
|
||||
)
|
||||
console.log(logs)
|
||||
}
|
||||
|
||||
const preview = async (oasFile: string, configFile: string): Promise<void> => {
|
||||
await commandWrapper(previewDocs)({
|
||||
port: 8080,
|
||||
host: "127.0.0.1",
|
||||
api: oasFile,
|
||||
config: configFile,
|
||||
} as yargs.Arguments<PreviewDocsOptions>)
|
||||
}
|
||||
|
||||
const buildHTML = async (
|
||||
srcFile: string,
|
||||
outFile: string,
|
||||
configFile: string
|
||||
): Promise<void> => {
|
||||
const { all: logs } = await execa(
|
||||
"yarn",
|
||||
[
|
||||
"redocly",
|
||||
"build-docs",
|
||||
srcFile,
|
||||
`--output=${outFile}`,
|
||||
`--config=${configFile}`,
|
||||
`--cdn=true`,
|
||||
],
|
||||
{ cwd: basePath, all: true }
|
||||
)
|
||||
console.log(logs)
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import OpenAPIParser from "@readme/openapi-parser"
|
||||
import { Command, Option, OptionValues } from "commander"
|
||||
import { lstat, mkdir, writeFile } from "fs/promises"
|
||||
import { OpenAPIObject } from "openapi3-ts"
|
||||
import * as path from "path"
|
||||
import swaggerInline from "swagger-inline"
|
||||
import { combineOAS } from "./utils/combine-oas"
|
||||
import {
|
||||
mergeBaseIntoOAS,
|
||||
mergePathsAndSchemasIntoOAS,
|
||||
} from "./utils/merge-oas"
|
||||
import { isFile } from "./utils/fs-utils"
|
||||
|
||||
/**
|
||||
* Constants
|
||||
*/
|
||||
// Medusa core package directory
|
||||
const medusaPackagePath = path.dirname(
|
||||
require.resolve("@medusajs/medusa/package.json")
|
||||
)
|
||||
// Types package directory
|
||||
const medusaTypesPath = path.dirname(
|
||||
require.resolve("@medusajs/types/package.json")
|
||||
)
|
||||
// Utils package directory
|
||||
const medusaUtilsPath = path.dirname(
|
||||
require.resolve("@medusajs/utils/package.json")
|
||||
)
|
||||
const basePath = path.resolve(__dirname, "../")
|
||||
|
||||
/**
|
||||
* CLI Command declaration
|
||||
*/
|
||||
export const commandName = "oas"
|
||||
export const commandDescription =
|
||||
"Compile full OAS from swagger-inline compliant JSDoc."
|
||||
|
||||
export const commandOptions: Option[] = [
|
||||
new Option("-t, --type <type>", "API type to compile.")
|
||||
.choices(["admin", "store", "combined"])
|
||||
.makeOptionMandatory(),
|
||||
new Option(
|
||||
"-o, --out-dir <outDir>",
|
||||
"Destination directory to output generated OAS files."
|
||||
).default(process.cwd()),
|
||||
new Option("-D, --dry-run", "Do not output files."),
|
||||
new Option(
|
||||
"-p, --paths <paths...>",
|
||||
"Additional paths to crawl for OAS JSDoc."
|
||||
),
|
||||
new Option(
|
||||
"-b, --base <base>",
|
||||
"Custom base OAS file to use for swagger-inline."
|
||||
),
|
||||
new Option("-F, --force", "Ignore OAS validation and output OAS files."),
|
||||
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."
|
||||
)
|
||||
]
|
||||
|
||||
export function getCommand() {
|
||||
const command = new Command(commandName)
|
||||
command.description(commandDescription)
|
||||
for (const opt of commandOptions) {
|
||||
command.addOption(opt)
|
||||
}
|
||||
command.action(async (options) => await execute(options))
|
||||
command.showHelpAfterError(true)
|
||||
return command
|
||||
}
|
||||
|
||||
/**
|
||||
* Main
|
||||
*/
|
||||
export async function execute(cliParams: OptionValues) {
|
||||
/**
|
||||
* Process CLI options
|
||||
*/
|
||||
const dryRun = !!cliParams.dryRun
|
||||
const force = !!cliParams.force
|
||||
const v2 = !!cliParams.v2
|
||||
const local = !!cliParams.local
|
||||
|
||||
const apiType: ApiType = cliParams.type
|
||||
|
||||
const outDir = path.resolve(cliParams.outDir)
|
||||
|
||||
const additionalPaths = (cliParams.paths ?? []).map((additionalPath) =>
|
||||
path.resolve(additionalPath)
|
||||
)
|
||||
for (const additionalPath of additionalPaths) {
|
||||
if (!(await isDirectory(additionalPath))) {
|
||||
throw new Error(`--paths must be a directory - ${additionalPath}`)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
*/
|
||||
if (!dryRun) {
|
||||
await mkdir(outDir, { recursive: true })
|
||||
}
|
||||
|
||||
let oas: OpenAPIObject
|
||||
console.log(`🟣 Generating OAS - ${apiType}`)
|
||||
|
||||
if (apiType === "combined") {
|
||||
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 = !local ? await getPublicOas(apiType, v2) : await getOASFromCodebase(apiType, v2)
|
||||
}
|
||||
|
||||
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) {
|
||||
console.log(`⚫️ Dry run - no files generated`)
|
||||
return
|
||||
}
|
||||
await exportOASToJSON(oas, apiType, outDir)
|
||||
}
|
||||
|
||||
/**
|
||||
* Methods
|
||||
*/
|
||||
async function getOASFromCodebase(
|
||||
apiType: ApiType,
|
||||
v2?: boolean
|
||||
): Promise<OpenAPIObject> {
|
||||
/**
|
||||
* 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),
|
||||
path.resolve(oasOutputPath, "schemas"),
|
||||
// We currently load error schemas from here. If we change
|
||||
// that in the future, we should change the path.
|
||||
path.resolve(medusaPackagePath, "dist", "api/middlewares"),
|
||||
] : [
|
||||
path.resolve(medusaTypesPath, "dist"),
|
||||
path.resolve(medusaUtilsPath, "dist"),
|
||||
path.resolve(medusaPackagePath, "dist", "models"),
|
||||
path.resolve(medusaPackagePath, "dist", "types"),
|
||||
path.resolve(medusaPackagePath, "dist", "api/middlewares"),
|
||||
path.resolve(medusaPackagePath, "dist", `api/routes/${apiType}`),
|
||||
],
|
||||
{
|
||||
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
|
||||
): Promise<OpenAPIObject> {
|
||||
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))) as OpenAPIObject
|
||||
}
|
||||
|
||||
async function validateOAS(
|
||||
oas: OpenAPIObject,
|
||||
apiType: ApiType,
|
||||
force = false
|
||||
): Promise<void> {
|
||||
try {
|
||||
await OpenAPIParser.validate(JSON.parse(JSON.stringify(oas)))
|
||||
console.log(`🟢 Valid OAS - ${apiType}`)
|
||||
} catch (err) {
|
||||
console.error(`🔴 Invalid OAS - ${apiType}`, err)
|
||||
if (!force) {
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function exportOASToJSON(
|
||||
oas: OpenAPIObject,
|
||||
apiType: ApiType,
|
||||
targetDir: string
|
||||
): Promise<void> {
|
||||
const json = JSON.stringify(oas, null, 2)
|
||||
const filePath = path.resolve(targetDir, `${apiType}.oas.json`)
|
||||
await writeFile(filePath, json)
|
||||
console.log(`⚫️ Exported OAS - ${apiType} - ${filePath}`)
|
||||
}
|
||||
|
||||
async function isDirectory(dirPath: string): Promise<boolean> {
|
||||
try {
|
||||
return (await lstat(path.resolve(dirPath))).isDirectory()
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
#! /usr/bin/env node
|
||||
|
||||
import { Command } from "commander"
|
||||
import { getCommand as oasGetCommand } from "./command-oas"
|
||||
import { getCommand as clientGetCommand } from "./command-client"
|
||||
import { getCommand as docsGetCommand } from "./command-docs"
|
||||
|
||||
const run = async () => {
|
||||
const program = getBaseCommand()
|
||||
|
||||
/**
|
||||
* Alias to command-oas.ts
|
||||
*/
|
||||
program.addCommand(oasGetCommand())
|
||||
|
||||
/**
|
||||
* Alias to command-client.ts
|
||||
*/
|
||||
program.addCommand(clientGetCommand())
|
||||
|
||||
/**
|
||||
* Alias to command-docs.ts
|
||||
*/
|
||||
program.addCommand(docsGetCommand())
|
||||
|
||||
/**
|
||||
* Run CLI
|
||||
*/
|
||||
await program.parseAsync()
|
||||
}
|
||||
|
||||
export function getBaseCommand() {
|
||||
const command = new Command()
|
||||
command.name("medusa-oas")
|
||||
command.action(async () => {
|
||||
console.log("No command provided.")
|
||||
command.outputHelp({ error: true })
|
||||
})
|
||||
command.showHelpAfterError(true)
|
||||
command.helpOption(false)
|
||||
return command
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
await run()
|
||||
})()
|
||||
@@ -0,0 +1 @@
|
||||
type ApiType = "store" | "admin" | "combined"
|
||||
@@ -0,0 +1,85 @@
|
||||
import { OpenAPIObject, SchemaObject } from "openapi3-ts"
|
||||
import OpenAPIParser from "@readme/openapi-parser"
|
||||
import { $Refs } from "@readme/json-schema-ref-parser"
|
||||
import { jsonObjectToYamlString } from "./yaml-utils"
|
||||
|
||||
export const getCircularReferences = async (
|
||||
srcFile: string
|
||||
): Promise<{ circularRefs: string[]; oas: OpenAPIObject }> => {
|
||||
const parser = new OpenAPIParser()
|
||||
const oas = (await parser.validate(srcFile, {
|
||||
dereference: {
|
||||
circular: "ignore",
|
||||
},
|
||||
})) as OpenAPIObject
|
||||
if (parser.$refs.circular) {
|
||||
const $refs = parser.$refs as $Refs
|
||||
let circularRefs = $refs.circularRefs.map(
|
||||
(ref) => ref.match(/#\/components\/schemas\/.*/)![0]
|
||||
)
|
||||
circularRefs = [...new Set(circularRefs)]
|
||||
circularRefs.sort()
|
||||
return { circularRefs, oas }
|
||||
}
|
||||
return { circularRefs: [], oas }
|
||||
}
|
||||
|
||||
export const getCircularPatchRecommendation = (
|
||||
circularRefs: string[],
|
||||
oas: OpenAPIObject
|
||||
): Record<string, string[]> => {
|
||||
type circularReferenceMatch = {
|
||||
schema: string
|
||||
property: string
|
||||
isArray: boolean
|
||||
referencedSchema: string
|
||||
}
|
||||
const matches: circularReferenceMatch[] = circularRefs
|
||||
.map((ref) => {
|
||||
let match =
|
||||
ref.match(
|
||||
/(?:.*)(?:#\/components\/schemas\/)(.*)(?:\/properties\/?)(.*)/
|
||||
) ?? []
|
||||
let schema = match[1]
|
||||
let property = match[2]
|
||||
let isArray = false
|
||||
if (property.endsWith("/items")) {
|
||||
property = property.replace("/items", "")
|
||||
isArray = true
|
||||
}
|
||||
return { schema, property, isArray }
|
||||
})
|
||||
.filter((match) => match.property !== "")
|
||||
.map((match) => {
|
||||
const baseSchema = oas.components!.schemas![match.schema] as SchemaObject
|
||||
const propertySpec = match.isArray
|
||||
? (baseSchema.properties![match.property] as SchemaObject).items!
|
||||
: baseSchema.properties![match.property]
|
||||
const referencedSchema = propertySpec["$ref"].match(
|
||||
/(?:#\/components\/schemas\/)(.*)/
|
||||
)![1]
|
||||
return {
|
||||
schema: match.schema,
|
||||
property: match.property,
|
||||
isArray: match.isArray,
|
||||
referencedSchema,
|
||||
}
|
||||
})
|
||||
|
||||
const schemas = {}
|
||||
for (const match of matches) {
|
||||
if (!schemas.hasOwnProperty(match.schema)) {
|
||||
schemas[match.schema] = []
|
||||
}
|
||||
schemas[match.schema].push(match.referencedSchema)
|
||||
}
|
||||
return schemas
|
||||
}
|
||||
|
||||
export const formatHintRecommendation = (
|
||||
recommendation: Record<string, string[]>
|
||||
) => {
|
||||
return jsonObjectToYamlString({
|
||||
decorators: { "medusa/circular-patch": { schemas: recommendation } },
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { OpenAPIObject } from "openapi3-ts"
|
||||
import { upperFirst } from "lodash"
|
||||
|
||||
export async function combineOAS(
|
||||
adminOAS: OpenAPIObject,
|
||||
storeOAS: OpenAPIObject
|
||||
): Promise<OpenAPIObject> {
|
||||
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}`
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { access, lstat, mkdtemp } from "fs/promises"
|
||||
import path from "path"
|
||||
import { sep } from "path"
|
||||
import { tmpdir } from "os"
|
||||
|
||||
export async function isFile(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
return (await lstat(path.resolve(filePath))).isFile()
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function exists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await access(path.resolve(filePath))
|
||||
return true
|
||||
} catch (err) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const getTmpDirectory = async () => {
|
||||
/**
|
||||
* RUNNER_TEMP: GitHub action, the path to a temporary directory on the runner.
|
||||
*/
|
||||
const tmpDir = process.env["RUNNER_TEMP"] ?? tmpdir()
|
||||
return await mkdtemp(`${tmpDir}${sep}`)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import fs from "fs/promises"
|
||||
|
||||
export const readJson = async (filePath: string): Promise<unknown> => {
|
||||
const jsonString = await fs.readFile(filePath, "utf8")
|
||||
return JSON.parse(jsonString)
|
||||
}
|
||||
|
||||
export const writeJson = async (
|
||||
filePath: string,
|
||||
jsonObject: unknown
|
||||
): Promise<void> => {
|
||||
const jsonString = JSON.stringify(jsonObject)
|
||||
await fs.writeFile(filePath, jsonString, "utf8")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import fs from "fs/promises"
|
||||
import * as yaml from "js-yaml"
|
||||
|
||||
export const readYaml = async (filePath): Promise<unknown> => {
|
||||
const yamlString = await fs.readFile(filePath, "utf8")
|
||||
return yaml.load(yamlString)
|
||||
}
|
||||
|
||||
export const writeYaml = async (filePath: string, yamlContent: string): Promise<void> => {
|
||||
await fs.writeFile(filePath, yamlContent, "utf8")
|
||||
}
|
||||
|
||||
export const writeYamlFromJson = async (filePath, jsonObject): Promise<void> => {
|
||||
const yamlString = yaml.dump(jsonObject)
|
||||
await fs.writeFile(filePath, yamlString, "utf8")
|
||||
}
|
||||
|
||||
export const jsonObjectToYamlString = (jsonObject): string => {
|
||||
return yaml.dump(jsonObject)
|
||||
}
|
||||
|
||||
export const jsonFileToYamlFile = async (inputJsonFile, outputYamlFile) => {
|
||||
const jsonString = await fs.readFile(inputJsonFile, "utf8")
|
||||
const jsonObject = JSON.parse(jsonString)
|
||||
const yamlString = yaml.dump(jsonObject)
|
||||
await fs.writeFile(outputYamlFile, yamlString, "utf8")
|
||||
}
|
||||
Reference in new issue
Block a user