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:
Patrick
2023-04-12 17:16:15 +00:00
committed by GitHub
parent 1d50a5e5c1
commit 1456056e8f
20 changed files with 1744 additions and 152 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@medusajs/medusa-oas-cli": patch
---
feat(oas): new medusa-oas docs for Redocly and circular references
+95 -8
View File
@@ -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
```
+8 -8
View File
@@ -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,
} }
+5 -3
View File
@@ -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,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: {
@@ -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)
}
+8 -14
View File
@@ -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
}
}
+6
View File
@@ -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")
}
+4 -7
View File
@@ -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.
+763 -5
View File
File diff suppressed because it is too large Load Diff