Files
medusa-store/packages/oas/medusa-oas-cli/src/command-oas.ts
Patrick 1456056e8f 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](d180f47e16/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.
2023-04-12 17:16:15 +00:00

212 lines
5.6 KiB
TypeScript

import OpenAPIParser from "@readme/openapi-parser"
import { Command, Option, OptionValues } from "commander"
import { lstat, mkdir, writeFile } from "fs/promises"
import { OpenAPIObject } from "openapi3-ts"
import * as path from "path"
import swaggerInline from "swagger-inline"
import { combineOAS } from "./utils/combine-oas"
import {
mergeBaseIntoOAS,
mergePathsAndSchemasIntoOAS,
} from "./utils/merge-oas"
import { isFile } from "./utils/fs-utils"
/**
* Constants
*/
// Medusa core package directory
const medusaPackagePath = path.dirname(
require.resolve("@medusajs/medusa/package.json")
)
// Types package directory
const medusaTypesPath = path.dirname(
require.resolve("@medusajs/types/package.json")
)
// Utils package directory
const medusaUtilsPath = path.dirname(
require.resolve("@medusajs/utils/package.json")
)
const basePath = path.resolve(__dirname, "../")
/**
* CLI Command declaration
*/
export const commandName = "oas"
export const commandDescription =
"Compile full OAS from swagger-inline compliant JSDoc."
export const commandOptions: Option[] = [
new Option("-t, --type <type>", "API type to compile.")
.choices(["admin", "store", "combined"])
.makeOptionMandatory(),
new Option(
"-o, --out-dir <outDir>",
"Destination directory to output generated OAS files."
).default(process.cwd()),
new Option("-D, --dry-run", "Do not output files."),
new Option(
"-p, --paths <paths...>",
"Additional paths to crawl for OAS JSDoc."
),
new Option(
"-b, --base <base>",
"Custom base OAS file to use for swagger-inline."
),
new Option("-F, --force", "Ignore OAS validation and output OAS files."),
]
export function getCommand() {
const command = new Command(commandName)
command.description(commandDescription)
for (const opt of commandOptions) {
command.addOption(opt)
}
command.action(async (options) => await execute(options))
command.showHelpAfterError(true)
return command
}
/**
* Main
*/
export async function execute(cliParams: OptionValues) {
/**
* Process CLI options
*/
const dryRun = !!cliParams.dryRun
const force = !!cliParams.force
const apiType: ApiType = cliParams.type
const outDir = path.resolve(cliParams.outDir)
const additionalPaths = (cliParams.paths ?? []).map((additionalPath) =>
path.resolve(additionalPath)
)
for (const additionalPath of additionalPaths) {
if (!(await isDirectory(additionalPath))) {
throw new Error(`--paths must be a directory - ${additionalPath}`)
}
}
const baseFile = cliParams.base ? path.resolve(cliParams.base) : undefined
if (baseFile) {
if (!(await isFile(cliParams.base))) {
throw new Error(`--base must be a file - ${baseFile}`)
}
}
/**
* Command execution
*/
if (!dryRun) {
await mkdir(outDir, { recursive: true })
}
let oas: OpenAPIObject
console.log(`🟣 Generating OAS - ${apiType}`)
if (apiType === "combined") {
const adminOAS = await getOASFromCodebase("admin")
const storeOAS = await getOASFromCodebase("store")
oas = await combineOAS(adminOAS, storeOAS)
} else {
oas = await getOASFromCodebase(apiType)
}
if (additionalPaths.length || baseFile) {
const customOAS = await getOASFromPaths(additionalPaths, baseFile)
if (baseFile) {
mergeBaseIntoOAS(oas, customOAS)
}
if (additionalPaths.length) {
mergePathsAndSchemasIntoOAS(oas, customOAS)
}
}
await validateOAS(oas, apiType, force)
if (dryRun) {
console.log(`⚫️ Dry run - no files generated`)
return
}
await exportOASToJSON(oas, apiType, outDir)
}
/**
* Methods
*/
async function getOASFromCodebase(
apiType: ApiType,
customBaseFile?: string
): Promise<OpenAPIObject> {
const gen = await swaggerInline(
[
path.resolve(medusaTypesPath, "dist"),
path.resolve(medusaUtilsPath, "dist"),
path.resolve(medusaPackagePath, "dist", "models"),
path.resolve(medusaPackagePath, "dist", "types"),
path.resolve(medusaPackagePath, "dist", "api/middlewares"),
path.resolve(medusaPackagePath, "dist", `api/routes/${apiType}`),
],
{
base:
customBaseFile ??
path.resolve(medusaPackagePath, "oas", `${apiType}.oas.base.yaml`),
format: ".json",
}
)
return (await OpenAPIParser.parse(JSON.parse(gen))) as OpenAPIObject
}
async function getOASFromPaths(
additionalPaths: string[] = [],
customBaseFile?: string
): Promise<OpenAPIObject> {
console.log(`🔵 Gathering custom OAS`)
const gen = await swaggerInline(additionalPaths, {
base:
customBaseFile ?? path.resolve(basePath, "oas", "default.oas.base.yaml"),
format: ".json",
logger: (log) => {
console.log(log)
},
})
return (await OpenAPIParser.parse(JSON.parse(gen))) as OpenAPIObject
}
async function validateOAS(
oas: OpenAPIObject,
apiType: ApiType,
force = false
): Promise<void> {
try {
await OpenAPIParser.validate(JSON.parse(JSON.stringify(oas)))
console.log(`🟢 Valid OAS - ${apiType}`)
} catch (err) {
console.error(`🔴 Invalid OAS - ${apiType}`, err)
if (!force) {
process.exit(1)
}
}
}
async function exportOASToJSON(
oas: OpenAPIObject,
apiType: ApiType,
targetDir: string
): Promise<void> {
const json = JSON.stringify(oas, null, 2)
const filePath = path.resolve(targetDir, `${apiType}.oas.json`)
await writeFile(filePath, json)
console.log(`⚫️ Exported OAS - ${apiType} - ${filePath}`)
}
async function isDirectory(dirPath: string): Promise<boolean> {
try {
return (await lstat(path.resolve(dirPath))).isDirectory()
} catch (err) {
console.log(err)
return false
}
}