feat(oas): new medusa-oas docs for Redocly and circular references (#3745)
## What New `medusa-oas docs` to improve OAS compatibility with for Redocly API documentation viewer and handle deep circular references. ## Why We wish to share the tooling we have been using to generate our public API documentation. ## How * Move `/docs-utils/redocly` under `medusa-oas-cli` package. * Move some of the operations from `oas-github-ci/scripts/build-openapi.js` under the `medusa-oas docs` command. * Improve DevX when dealing with circular references by outputting precise troubleshooting recommendations in the error message. * Extract some common operations in utility methods. ## Tests ### Step 1 * Run `yarn install` * Run `yarn build` * Run `yarn openapi:generate --dry-run` * Expect same behaviour as before where OAS is validated and circular references are checked. ### Step 2 * Run `yarn openapi:generate` * Expect same behaviour as before where API documentation is generated in `/docs` ### Step 3 * Move to the `packages/oas/medusa-oas-cli` * Run `yarn medusa-oas oas --type store --out-dir ~/tmp/oas` to generate the raw OAS file. * Run `yarn medusa-oas docs --src-file ~/tmp/oas/store.oas.json --preview` * Open url from the console output in a browser * Expect a preview of the API documentation using Redocly. ### Step 4 * Run `yarn medusa-oas docs --src-file ~/tmp/oas/store.oas.json --out-dir ~/tmp/docs/store --clean --split` * Expect a similiar output as `yarn openapi:generate` ### Step 5 * Run `yarn medusa-oas docs --src-file ~/tmp/oas/store.oas.json --out-dir ~/tmp/docs/store --clean --html` * Expect `index.html` to have been created. * Run `npx http-server ~/tmp/docs/store -p 8000` * Open http://127.0.0.1:8000 in a browser. * Expect a zero-dependency static rendering of the API documentation using Redocly. ### Step 6 * To emulate an unhandled circular reference, edit [packages/oas/medusa-oas-cli/redocly/redocly-config.yaml](https://github.com/medusajs/medusa/blob/d180f47e1609e74f46f72cdeef6265a654da3e7e/packages/oas/medusa-oas-cli/redocly/redocly-config.yaml#L9-L10) and comment out "Address: - Customer" * Run `yarn medusa-oas docs --src-file ~/tmp/oas/store.oas.json --dry-run` * Expect an error message with a hint on how to resolve the issue. * Create a file `~/tmp/redocly-config.yaml` and paste in the recommendation from the error message. * Run `yarn medusa-oas docs --src-file ~/tmp/oas/store.oas.json --dry-run --config ~/tmp/redocly-config.yaml` * Expect Dry run to succeed.
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@medusajs/medusa-oas-cli": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
feat(oas): new medusa-oas docs for Redocly and circular references
|
||||||
@@ -93,40 +93,127 @@ Will generate API client files from a given OAS file.
|
|||||||
|
|
||||||
Specify the path to the OAS JSON file.
|
Specify the path to the OAS JSON file.
|
||||||
|
|
||||||
`yarm medusa-oas client --src-file ./store.oas.json`
|
```bash
|
||||||
|
yarn medusa-oas client --src-file ./store.oas.json`
|
||||||
|
```
|
||||||
|
|
||||||
#### `--name <name>`
|
#### `--name <name>`
|
||||||
|
|
||||||
Namespace for the generated client. Usually `admin` or `store`.
|
Namespace for the generated client. Usually `admin` or `store`.
|
||||||
|
|
||||||
`yarm medusa-oas client --name admin`
|
```bash
|
||||||
|
yarn medusa-oas client --name admin`
|
||||||
|
```
|
||||||
|
|
||||||
#### `--out-dir <path>`
|
#### `--out-dir <path>`
|
||||||
|
|
||||||
Specify in which directory should the files be outputted. Accepts relative and absolute path. It the directory doesn't
|
Specify in which directory should the files be outputted. Accepts relative and absolute path.
|
||||||
exist, it will be created. Defaults to `./`.
|
If the directory doesn't exist, it will be created. Defaults to `./`.
|
||||||
|
|
||||||
`yarm medusa-oas client --out-dir ./client`
|
```bash
|
||||||
|
yarn medusa-oas client --out-dir ./client`
|
||||||
|
```
|
||||||
|
|
||||||
#### `--type <type>`
|
#### `--type <type>`
|
||||||
|
|
||||||
Client component types to generate. Accepts `all`, `types`, `client`, `hooks`.
|
Client component types to generate. Accepts `all`, `types`, `client`, `hooks`.
|
||||||
Defaults to `all`.
|
Defaults to `all`.
|
||||||
|
|
||||||
`yarn medusa-oas client --type types`
|
```bash
|
||||||
|
yarn medusa-oas client --type types`
|
||||||
|
```
|
||||||
|
|
||||||
#### `--types-packages <name>`
|
#### `--types-packages <name>`
|
||||||
|
|
||||||
Replace relative import statements by types package name. Mandatory when using `--type client` or `--type hooks`.
|
Replace relative import statements by types package name. Mandatory when using `--type client` or `--type hooks`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn medusa-oas client --types-packages @medusajs/client-types`
|
||||||
|
```
|
||||||
|
|
||||||
#### `--client-packages <name>`
|
#### `--client-packages <name>`
|
||||||
|
|
||||||
Replace relative import statements by client package name. Mandatory when using `--type hooks`.
|
Replace relative import statements by client package name. Mandatory when using `--type hooks`.
|
||||||
|
|
||||||
`yarn medusa-oas client --type types`
|
```bash
|
||||||
|
yarn medusa-oas client --client-packages @medusajs/medusa-js`
|
||||||
|
```
|
||||||
|
|
||||||
#### `--clean`
|
#### `--clean`
|
||||||
|
|
||||||
Delete destination directory content before generating client.
|
Delete destination directory content before generating client.
|
||||||
|
|
||||||
`yarn medusa-oas --clean`
|
```bash
|
||||||
|
yarn medusa-oas client --clean
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Command - `docs`
|
||||||
|
|
||||||
|
Will sanitize OAS for use with Redocly's API documentation viewer.
|
||||||
|
|
||||||
|
#### `--src-file <path>`
|
||||||
|
|
||||||
|
Specify the path to the OAS JSON file.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarm medusa-oas docs --src-file ./store.oas.json
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `--out-dir <path>`
|
||||||
|
|
||||||
|
Specify in which directory should the files be outputted. Accepts relative and absolute path.
|
||||||
|
If the directory doesn't exist, it will be created. Defaults to `./`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn medusa-oas docs --out-dir ./docs`
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `--config <path>`
|
||||||
|
|
||||||
|
Specify the path to a Redocly config file.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn medusa-oas --config ./redocly-config.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `--dry-run`
|
||||||
|
|
||||||
|
Will sanitize the OAS but will not output file. Useful for troubleshooting circular reference issues.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn medusa-oas docs --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `--clean`
|
||||||
|
|
||||||
|
Delete destination directory content before generating client.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn medusa-oas docs --clean
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `--split`
|
||||||
|
|
||||||
|
Creates a multi-file structure output. Uses `redocly split` internally.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn medusa-oas docs --split
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `--preview`
|
||||||
|
|
||||||
|
Generate a preview of the API documentation in a browser. Does not output files. Uses `redocly preview-docs` internally.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn medusa-oas docs --preview
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `--html`
|
||||||
|
|
||||||
|
Generate a zero-dependency static HTML file. Uses `redocly build-docs` internally.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn medusa-oas docs --html
|
||||||
|
```
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
globals: {
|
|
||||||
"ts-jest": {
|
|
||||||
tsConfig: "tsconfig.json",
|
|
||||||
isolatedModules: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
transform: {
|
transform: {
|
||||||
"^.+\\.[jt]s?$": "ts-jest",
|
"^.+\\.[jt]s?$": [
|
||||||
|
"ts-jest",
|
||||||
|
{
|
||||||
|
tsconfig: "tsconfig.json",
|
||||||
|
isolatedModules: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
testEnvironment: `node`,
|
testEnvironment: `node`,
|
||||||
moduleFileExtensions: [`js`, `ts`],
|
moduleFileExtensions: [`js`, `ts`],
|
||||||
testTimeout: 60000,
|
testTimeout: 100000,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,9 +21,9 @@
|
|||||||
"author": "Medusa",
|
"author": "Medusa",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"jest": "^25.5.4",
|
"jest": "^29.1.0",
|
||||||
"js-yaml": "^4.1.0",
|
"js-yaml": "^4.1.0",
|
||||||
"ts-jest": "^25.5.1",
|
"ts-jest": "^29.1.0",
|
||||||
"ts-node": "^10.9.1",
|
"ts-node": "^10.9.1",
|
||||||
"typescript": "4.9.5",
|
"typescript": "4.9.5",
|
||||||
"uuid": "^9.0.0"
|
"uuid": "^9.0.0"
|
||||||
@@ -32,12 +32,14 @@
|
|||||||
"prepare": "cross-env NODE_ENV=production yarn run build",
|
"prepare": "cross-env NODE_ENV=production yarn run build",
|
||||||
"build": "tsc --build",
|
"build": "tsc --build",
|
||||||
"medusa-oas": "ts-node src/index.ts",
|
"medusa-oas": "ts-node src/index.ts",
|
||||||
"test": "jest src"
|
"test": "NODE_OPTIONS='--unhandled-rejections=strict' jest src"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@medusajs/medusa": "1.8.1",
|
"@medusajs/medusa": "1.8.1",
|
||||||
"@medusajs/openapi-typescript-codegen": "0.2.1",
|
"@medusajs/openapi-typescript-codegen": "0.2.1",
|
||||||
|
"@readme/json-schema-ref-parser": "^1.2.0",
|
||||||
"@readme/openapi-parser": "^2.4.0",
|
"@readme/openapi-parser": "^2.4.0",
|
||||||
|
"@redocly/cli": "1.0.0-beta.123",
|
||||||
"@types/lodash": "^4.14.191",
|
"@types/lodash": "^4.14.191",
|
||||||
"commander": "^10.0.0",
|
"commander": "^10.0.0",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
const CircularPatch = require("./decorators/circular-patch")
|
const CircularPatch = require("./decorators/circular-patch")
|
||||||
|
|
||||||
const id = "plugin"
|
const id = "medusa"
|
||||||
|
|
||||||
const decorators = {
|
const decorators = {
|
||||||
oas3: {
|
oas3: {
|
||||||
+3
-4
@@ -1,11 +1,10 @@
|
|||||||
plugins:
|
plugins:
|
||||||
- "./plugins/plugin.js"
|
- "./plugins/medusa/index.js"
|
||||||
|
|
||||||
# Allows to replace a $ref with `type: object` in order to avoid infinite loops
|
# Allows to replace a $ref with `type: object` in order to avoid infinite loops
|
||||||
# when Redocly attempts to render circular references.
|
# when Redocly attempts to render circular references.
|
||||||
decorators:
|
decorators:
|
||||||
plugin/circular-patch:
|
medusa/circular-patch:
|
||||||
verbose: false
|
|
||||||
schemas:
|
schemas:
|
||||||
Address:
|
Address:
|
||||||
- Customer
|
- Customer
|
||||||
@@ -143,7 +142,7 @@ theme:
|
|||||||
theme:
|
theme:
|
||||||
colors:
|
colors:
|
||||||
primary:
|
primary:
|
||||||
dart: "#242526"
|
dark: "#242526"
|
||||||
sidebar:
|
sidebar:
|
||||||
width: "250px"
|
width: "250px"
|
||||||
disableSearch: true
|
disableSearch: true
|
||||||
@@ -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
|
||||||
|
`)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,26 +1,19 @@
|
|||||||
import execa from "execa"
|
|
||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
import * as yaml from "js-yaml"
|
|
||||||
import { OpenAPIObject, SchemaObject } from "openapi3-ts"
|
import { OpenAPIObject, SchemaObject } from "openapi3-ts"
|
||||||
import { OperationObject } from "openapi3-ts/src/model/OpenApi"
|
import { OperationObject } from "openapi3-ts/src/model/OpenApi"
|
||||||
import os from "os"
|
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { v4 as uid } from "uuid"
|
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"
|
||||||
|
|
||||||
const medusaPackagePath = path.dirname(
|
const medusaPackagePath = path.dirname(
|
||||||
require.resolve("@medusajs/medusa/package.json")
|
require.resolve("@medusajs/medusa/package.json")
|
||||||
)
|
)
|
||||||
const basePath = path.resolve(__dirname, `../../`)
|
const basePath = path.resolve(__dirname, `../../`)
|
||||||
|
|
||||||
const getTmpDirectory = async () => {
|
export const runCLI = async (command: string, options: string[] = []) => {
|
||||||
/**
|
|
||||||
* 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", "medusa-oas", command, ...options]
|
const params = ["run", "medusa-oas", command, ...options]
|
||||||
try {
|
try {
|
||||||
const { all: logs } = await execa("yarn", params, {
|
const { all: logs } = await execa("yarn", params, {
|
||||||
@@ -28,25 +21,10 @@ const runCLI = async (command: string, options: string[] = []) => {
|
|||||||
all: true,
|
all: true,
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
throw new Error(err.message + err.all)
|
||||||
throw err
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isFile = async (filePath): Promise<boolean> => {
|
|
||||||
return (await fs.lstat(path.resolve(filePath))).isFile()
|
|
||||||
}
|
|
||||||
|
|
||||||
const readJsonFile = async (filePath): Promise<unknown> => {
|
|
||||||
const jsonString = await fs.readFile(filePath, "utf8")
|
|
||||||
return JSON.parse(jsonString)
|
|
||||||
}
|
|
||||||
|
|
||||||
const readYamlFile = async (filePath): Promise<unknown> => {
|
|
||||||
const yamlString = await fs.readFile(filePath, "utf8")
|
|
||||||
return yaml.load(yamlString)
|
|
||||||
}
|
|
||||||
|
|
||||||
const listOperations = (oas: OpenAPIObject): OperationObject[] => {
|
const listOperations = (oas: OpenAPIObject): OperationObject[] => {
|
||||||
const operations: OperationObject[] = []
|
const operations: OperationObject[] = []
|
||||||
for (const url in oas.paths) {
|
for (const url in oas.paths) {
|
||||||
@@ -91,7 +69,7 @@ describe("command oas", () => {
|
|||||||
const outDir = path.resolve(tmpDir, uid())
|
const outDir = path.resolve(tmpDir, uid())
|
||||||
await runCLI("oas", ["--type", "admin", "--out-dir", outDir])
|
await runCLI("oas", ["--type", "admin", "--out-dir", outDir])
|
||||||
const generatedFilePath = path.resolve(outDir, "admin.oas.json")
|
const generatedFilePath = path.resolve(outDir, "admin.oas.json")
|
||||||
oas = (await readJsonFile(generatedFilePath)) as OpenAPIObject
|
oas = (await readJson(generatedFilePath)) as OpenAPIObject
|
||||||
})
|
})
|
||||||
|
|
||||||
it("generates oas with admin routes only", async () => {
|
it("generates oas with admin routes only", async () => {
|
||||||
@@ -106,7 +84,7 @@ describe("command oas", () => {
|
|||||||
"oas",
|
"oas",
|
||||||
"admin.oas.base.yaml"
|
"admin.oas.base.yaml"
|
||||||
)
|
)
|
||||||
const oasBase = (await readYamlFile(yamlFilePath)) as OpenAPIObject
|
const oasBase = (await readYaml(yamlFilePath)) as OpenAPIObject
|
||||||
expect(oas.info.title).toEqual(oasBase.info.title)
|
expect(oas.info.title).toEqual(oasBase.info.title)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -118,7 +96,7 @@ describe("command oas", () => {
|
|||||||
const outDir = path.resolve(tmpDir, uid())
|
const outDir = path.resolve(tmpDir, uid())
|
||||||
await runCLI("oas", ["--type", "store", "--out-dir", outDir])
|
await runCLI("oas", ["--type", "store", "--out-dir", outDir])
|
||||||
const generatedFilePath = path.resolve(outDir, "store.oas.json")
|
const generatedFilePath = path.resolve(outDir, "store.oas.json")
|
||||||
oas = (await readJsonFile(generatedFilePath)) as OpenAPIObject
|
oas = (await readJson(generatedFilePath)) as OpenAPIObject
|
||||||
})
|
})
|
||||||
|
|
||||||
it("generates oas with store routes only", async () => {
|
it("generates oas with store routes only", async () => {
|
||||||
@@ -133,7 +111,7 @@ describe("command oas", () => {
|
|||||||
"oas",
|
"oas",
|
||||||
"store.oas.base.yaml"
|
"store.oas.base.yaml"
|
||||||
)
|
)
|
||||||
const oasBase = (await readYamlFile(yamlFilePath)) as OpenAPIObject
|
const oasBase = (await readYaml(yamlFilePath)) as OpenAPIObject
|
||||||
expect(oas.info.title).toEqual(oasBase.info.title)
|
expect(oas.info.title).toEqual(oasBase.info.title)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -145,7 +123,7 @@ describe("command oas", () => {
|
|||||||
const outDir = path.resolve(tmpDir, uid())
|
const outDir = path.resolve(tmpDir, uid())
|
||||||
await runCLI("oas", ["--type", "combined", "--out-dir", outDir])
|
await runCLI("oas", ["--type", "combined", "--out-dir", outDir])
|
||||||
const generatedFilePath = path.resolve(outDir, "combined.oas.json")
|
const generatedFilePath = path.resolve(outDir, "combined.oas.json")
|
||||||
oas = (await readJsonFile(generatedFilePath)) as OpenAPIObject
|
oas = (await readJson(generatedFilePath)) as OpenAPIObject
|
||||||
})
|
})
|
||||||
|
|
||||||
it("generates oas with admin and store routes", async () => {
|
it("generates oas with admin and store routes", async () => {
|
||||||
@@ -160,7 +138,7 @@ describe("command oas", () => {
|
|||||||
"oas",
|
"oas",
|
||||||
"default.oas.base.yaml"
|
"default.oas.base.yaml"
|
||||||
)
|
)
|
||||||
const oasBase = (await readYamlFile(yamlFilePath)) as OpenAPIObject
|
const oasBase = (await readYaml(yamlFilePath)) as OpenAPIObject
|
||||||
expect(oas.info.title).toEqual(oasBase.info.title)
|
expect(oas.info.title).toEqual(oasBase.info.title)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -244,7 +222,7 @@ describe("command oas", () => {
|
|||||||
additionalPath,
|
additionalPath,
|
||||||
])
|
])
|
||||||
const generatedFilePath = path.resolve(outDir, "store.oas.json")
|
const generatedFilePath = path.resolve(outDir, "store.oas.json")
|
||||||
oas = (await readJsonFile(generatedFilePath)) as OpenAPIObject
|
oas = (await readJson(generatedFilePath)) as OpenAPIObject
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should add new path to existing paths", async () => {
|
it("should add new path to existing paths", async () => {
|
||||||
@@ -379,7 +357,7 @@ components:
|
|||||||
filePath,
|
filePath,
|
||||||
])
|
])
|
||||||
const generatedFilePath = path.resolve(outDir, "store.oas.json")
|
const generatedFilePath = path.resolve(outDir, "store.oas.json")
|
||||||
oas = (await readJsonFile(generatedFilePath)) as OpenAPIObject
|
oas = (await readJson(generatedFilePath)) as OpenAPIObject
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should add new path to existing paths", async () => {
|
it("should add new path to existing paths", async () => {
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ export async function execute(cliParams: OptionValues) {
|
|||||||
await generateClientSDK(oas, outDir, apiName, exportComponent, packageNames)
|
await generateClientSDK(oas, outDir, apiName, exportComponent, packageNames)
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`🟢 Client generated - ${apiName} - ${exportComponent} - ${outDir}`
|
`⚫️ Client generated - ${apiName} - ${exportComponent} - ${outDir}`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,280 @@
|
|||||||
|
import { Command, Option, OptionValues } from "commander"
|
||||||
|
import fs, { mkdir } from "fs/promises"
|
||||||
|
import * as path from "path"
|
||||||
|
import execa from "execa"
|
||||||
|
import { jsonFileToYamlFile, readYaml, writeYaml } from "./utils/yaml-utils"
|
||||||
|
import { isArray, mergeWith } from "lodash"
|
||||||
|
import { readJson } from "./utils/json-utils"
|
||||||
|
import { getTmpDirectory, isFile } from "./utils/fs-utils"
|
||||||
|
import {
|
||||||
|
formatHintRecommendation,
|
||||||
|
getCircularPatchRecommendation,
|
||||||
|
getCircularReferences,
|
||||||
|
} from "./utils/circular-patch-utils"
|
||||||
|
import { previewDocs } from "@redocly/cli/lib/commands/preview-docs"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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."
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
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))) {
|
||||||
|
console.log(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 = path.resolve(outDir, "openapi.yaml")
|
||||||
|
|
||||||
|
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.json")
|
||||||
|
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 jsonFileToYamlFile(srcFileSanitized, finalOASFile)
|
||||||
|
}
|
||||||
|
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 writeYaml(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 writeYaml(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 previewDocs({
|
||||||
|
port: 8080,
|
||||||
|
host: "127.0.0.1",
|
||||||
|
api: oasFile,
|
||||||
|
config: configFile,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
mergeBaseIntoOAS,
|
mergeBaseIntoOAS,
|
||||||
mergePathsAndSchemasIntoOAS,
|
mergePathsAndSchemasIntoOAS,
|
||||||
} from "./utils/merge-oas"
|
} from "./utils/merge-oas"
|
||||||
|
import { isFile } from "./utils/fs-utils"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constants
|
* Constants
|
||||||
@@ -124,9 +125,11 @@ export async function execute(cliParams: OptionValues) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await validateOAS(oas, apiType, force)
|
await validateOAS(oas, apiType, force)
|
||||||
if (!dryRun) {
|
if (dryRun) {
|
||||||
await exportOASToJSON(oas, apiType, outDir)
|
console.log(`⚫️ Dry run - no files generated`)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
await exportOASToJSON(oas, apiType, outDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -152,7 +155,7 @@ async function getOASFromCodebase(
|
|||||||
format: ".json",
|
format: ".json",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return await OpenAPIParser.parse(JSON.parse(gen))
|
return (await OpenAPIParser.parse(JSON.parse(gen))) as OpenAPIObject
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getOASFromPaths(
|
async function getOASFromPaths(
|
||||||
@@ -168,7 +171,7 @@ async function getOASFromPaths(
|
|||||||
console.log(log)
|
console.log(log)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return await OpenAPIParser.parse(JSON.parse(gen))
|
return (await OpenAPIParser.parse(JSON.parse(gen))) as OpenAPIObject
|
||||||
}
|
}
|
||||||
|
|
||||||
async function validateOAS(
|
async function validateOAS(
|
||||||
@@ -195,7 +198,7 @@ async function exportOASToJSON(
|
|||||||
const json = JSON.stringify(oas, null, 2)
|
const json = JSON.stringify(oas, null, 2)
|
||||||
const filePath = path.resolve(targetDir, `${apiType}.oas.json`)
|
const filePath = path.resolve(targetDir, `${apiType}.oas.json`)
|
||||||
await writeFile(filePath, json)
|
await writeFile(filePath, json)
|
||||||
console.log(`🔵 Exported OAS - ${apiType} - ${filePath}`)
|
console.log(`⚫️ Exported OAS - ${apiType} - ${filePath}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function isDirectory(dirPath: string): Promise<boolean> {
|
async function isDirectory(dirPath: string): Promise<boolean> {
|
||||||
@@ -206,12 +209,3 @@ async function isDirectory(dirPath: string): Promise<boolean> {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function isFile(filePath: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
return (await lstat(path.resolve(filePath))).isFile()
|
|
||||||
} catch (err) {
|
|
||||||
console.log(err)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { Command } from "commander"
|
import { Command } from "commander"
|
||||||
import { getCommand as oasGetCommand } from "./command-oas"
|
import { getCommand as oasGetCommand } from "./command-oas"
|
||||||
import { getCommand as clientGetCommand } from "./command-client"
|
import { getCommand as clientGetCommand } from "./command-client"
|
||||||
|
import { getCommand as docsGetCommand } from "./command-docs"
|
||||||
|
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
const program = getBaseCommand()
|
const program = getBaseCommand()
|
||||||
@@ -17,6 +18,11 @@ const run = async () => {
|
|||||||
*/
|
*/
|
||||||
program.addCommand(clientGetCommand())
|
program.addCommand(clientGetCommand())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alias to command-docs.ts
|
||||||
|
*/
|
||||||
|
program.addCommand(docsGetCommand())
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run CLI
|
* Run CLI
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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,29 @@
|
|||||||
|
import { access, lstat, mkdtemp } from "fs/promises"
|
||||||
|
import path 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)
|
||||||
|
}
|
||||||
@@ -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,23 @@
|
|||||||
|
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, 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")
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@medusajs/oas-github-ci",
|
"name": "@medusajs/oas-github-ci",
|
||||||
"version": "1.0.2",
|
"version": "1.0.1",
|
||||||
"description": "OAS Github CI",
|
"description": "OAS Github CI",
|
||||||
"main": "scripts/build-openapi.js",
|
"main": "scripts/build-openapi.js",
|
||||||
"files": [
|
"files": [
|
||||||
@@ -16,15 +16,12 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ci": "node scripts/build-openapi.js",
|
"ci": "node scripts/build-openapi.js",
|
||||||
"preview:admin": "yarn redocly preview-docs ../../../docs/api/admin/openapi.yaml --config=../../../docs-util/redocly/config.yaml",
|
"preview:admin": "yarn medusa-oas docs --src-file ../../../docs/api/admin/openapi.yaml --preview",
|
||||||
"preview:store": "yarn redocly preview-docs ../../../docs/api/store/openapi.yaml --config=../../../docs-util/redocly/config.yaml",
|
"preview:store": "yarn medusa-oas docs --src-file ../../../docs/api/store/openapi.yaml --preview",
|
||||||
"test": "jest --passWithNoTests"
|
"test": "jest --passWithNoTests"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@medusajs/medusa-oas-cli": "0.2.1",
|
"@medusajs/medusa-oas-cli": "0.2.1",
|
||||||
"@readme/openapi-parser": "^2.4.0",
|
"execa": "^5.1.1"
|
||||||
"@redocly/cli": "1.0.0-beta.123",
|
|
||||||
"execa": "^5.1.1",
|
|
||||||
"js-yaml": "^4.1.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,37 +4,42 @@ const fs = require("fs/promises")
|
|||||||
const os = require("os")
|
const os = require("os")
|
||||||
const path = require("path")
|
const path = require("path")
|
||||||
const execa = require("execa")
|
const execa = require("execa")
|
||||||
const yaml = require("js-yaml")
|
|
||||||
const OpenAPIParser = require("@readme/openapi-parser")
|
|
||||||
|
|
||||||
const isDryRun = process.argv.indexOf("--dry-run") !== -1
|
const isDryRun = process.argv.indexOf("--dry-run") !== -1
|
||||||
const basePath = path.resolve(__dirname, `../`)
|
const basePath = path.resolve(__dirname, `../`)
|
||||||
const repoRootPath = path.resolve(basePath, `../../../`)
|
const repoRootPath = path.resolve(basePath, `../../../`)
|
||||||
const docsApiPath = path.resolve(repoRootPath, "docs/api/")
|
const docsApiPath = path.resolve(repoRootPath, "docs/api/")
|
||||||
const redoclyConfigPath = path.resolve(
|
|
||||||
repoRootPath,
|
|
||||||
"docs-util/redocly/config.yaml"
|
|
||||||
)
|
|
||||||
|
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
const outputPath = isDryRun ? await getTmpDirectory() : docsApiPath
|
const oasOutDir = isDryRun ? await getTmpDirectory() : docsApiPath
|
||||||
|
|
||||||
for (const apiType of ["store", "admin"]) {
|
for (const apiType of ["store", "admin"]) {
|
||||||
await generateOASSource(outputPath, apiType)
|
await generateOASSource(oasOutDir, apiType)
|
||||||
const inputJsonFile = path.resolve(outputPath, `${apiType}.oas.json`)
|
const oasSrcFile = path.resolve(oasOutDir, `${apiType}.oas.json`)
|
||||||
const outputYamlFile = path.resolve(outputPath, `${apiType}.oas.yaml`)
|
const docsOutDir = path.resolve(oasOutDir, apiType)
|
||||||
|
await generateDocs(oasSrcFile, docsOutDir, isDryRun)
|
||||||
await jsonFileToYamlFile(inputJsonFile, outputYamlFile)
|
|
||||||
await sanitizeOAS(outputYamlFile)
|
|
||||||
await circularReferenceCheck(outputYamlFile)
|
|
||||||
if (!isDryRun) {
|
|
||||||
await generateReference(outputYamlFile, apiType)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const generateOASSource = async (outDir, apiType) => {
|
const generateOASSource = async (outDir, apiType) => {
|
||||||
const params = ["oas", `--type=${apiType}`, `--out-dir=${outDir}`]
|
const { all: logs } = await execa(
|
||||||
|
"medusa-oas",
|
||||||
|
["oas", `--type=${apiType}`, `--out-dir=${outDir}`],
|
||||||
|
{ cwd: basePath, all: true }
|
||||||
|
)
|
||||||
|
console.log(logs)
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateDocs = async (srcFile, outDir, isDryRun) => {
|
||||||
|
const params = [
|
||||||
|
"docs",
|
||||||
|
`--src-file=${srcFile}`,
|
||||||
|
`--out-dir=${outDir}`,
|
||||||
|
`--clean`,
|
||||||
|
`--split`,
|
||||||
|
]
|
||||||
|
if (isDryRun) {
|
||||||
|
params.push("--dry-run")
|
||||||
|
}
|
||||||
const { all: logs } = await execa("medusa-oas", params, {
|
const { all: logs } = await execa("medusa-oas", params, {
|
||||||
cwd: basePath,
|
cwd: basePath,
|
||||||
all: true,
|
all: true,
|
||||||
@@ -42,52 +47,6 @@ const generateOASSource = async (outDir, apiType) => {
|
|||||||
console.log(logs)
|
console.log(logs)
|
||||||
}
|
}
|
||||||
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
||||||
const sanitizeOAS = async (srcFile) => {
|
|
||||||
const { all: logs } = await execa(
|
|
||||||
"redocly",
|
|
||||||
["bundle", srcFile, `--output=${srcFile}`, `--config=${redoclyConfigPath}`],
|
|
||||||
{ cwd: basePath, all: true }
|
|
||||||
)
|
|
||||||
console.log(logs)
|
|
||||||
}
|
|
||||||
|
|
||||||
const circularReferenceCheck = async (srcFile) => {
|
|
||||||
const parser = new OpenAPIParser()
|
|
||||||
await parser.validate(srcFile, {
|
|
||||||
dereference: {
|
|
||||||
circular: "ignore",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if (parser.$refs.circular) {
|
|
||||||
const fileName = path.basename(srcFile)
|
|
||||||
const circularRefs = [...parser.$refs.circularRefs]
|
|
||||||
circularRefs.sort()
|
|
||||||
console.log(circularRefs)
|
|
||||||
throw new Error(
|
|
||||||
`🔴 Unhandled circular references - ${fileName} - Please patch in docs-util/redocly/config.yaml`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
console.log(`🟢 All circular references handled`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const generateReference = async (srcFile, apiType) => {
|
|
||||||
const outDir = path.resolve(docsApiPath, `${apiType}`)
|
|
||||||
await fs.rm(outDir, { recursive: true, force: true })
|
|
||||||
const { all: logs } = await execa(
|
|
||||||
"redocly",
|
|
||||||
["split", srcFile, `--outDir=${outDir}`],
|
|
||||||
{ cwd: basePath, all: true }
|
|
||||||
)
|
|
||||||
console.log(logs)
|
|
||||||
}
|
|
||||||
|
|
||||||
const getTmpDirectory = async () => {
|
const getTmpDirectory = async () => {
|
||||||
/**
|
/**
|
||||||
* RUNNER_TEMP: GitHub action, the path to a temporary directory on the runner.
|
* RUNNER_TEMP: GitHub action, the path to a temporary directory on the runner.
|
||||||
|
|||||||
Reference in New Issue
Block a user