Files
medusa-store/packages/cli/oas/medusa-oas-cli/src/command-docs.ts

296 lines
8.4 KiB
TypeScript

import { PreviewDocsOptions, previewDocs } from "@redocly/cli/lib/commands/preview-docs"
import { commandWrapper } from "@redocly/cli/lib/wrapper"
import { Command, Option, OptionValues } from "commander"
import execa from "execa"
import fs, { mkdir } from "fs/promises"
import { isArray, mergeWith } from "lodash"
import * as path from "path"
import {
formatHintRecommendation,
getCircularPatchRecommendation,
getCircularReferences,
} from "./utils/circular-patch-utils"
import { getTmpDirectory, isFile } from "./utils/fs-utils"
import { readJson } from "./utils/json-utils"
import { jsonObjectToYamlString, readYaml, writeYaml, writeYamlFromJson } from "./utils/yaml-utils"
import yargs from "yargs"
/**
* Constants
*/
const basePath = path.resolve(__dirname, "../")
const medusaPluginRelativePath = "./plugins/medusa/index.js"
const medusaPluginAbsolutePath = path.resolve(
basePath,
"redocly/plugins/medusa/index.js"
)
const configFileDefault = path.resolve(basePath, "redocly/redocly-config.yaml")
/**
* CLI Command declaration
*/
export const commandName = "docs"
export const commandDescription =
"Sanitize OAS for use with Redocly's API documentation viewer."
export const commandOptions: Option[] = [
new Option(
"-s, --src-file <srcFile>",
"Path to source OAS JSON file."
).makeOptionMandatory(),
new Option(
"-o, --out-dir <outDir>",
"Destination directory to output the sanitized OAS files."
).default(process.cwd()),
new Option(
"--config <config>",
"Configuration file to merge with default configuration before passing to Redocly's CLI."
),
new Option("-D, --dry-run", "Do not output files."),
new Option(
"--clean",
"Delete destination directory content before generating documentation."
),
new Option("--split", "Creates a multi-file structure output."),
new Option(
"--preview",
"Open a preview of the documentation. Does not output files."
),
new Option(
"--html",
"Generate a static HTML using Redocly's build-docs command."
),
new Option(
"--main-file-name <mainFileName>",
"The name of the main YAML file."
).default("openapi.yaml")
]
export function getCommand(): Command {
const command = new Command(commandName)
command.description(commandDescription)
for (const opt of commandOptions) {
command.addOption(opt)
}
command.action(async (options) => await execute(options))
command.showHelpAfterError(true)
return command
}
/**
* Main
*/
export async function execute(cliParams: OptionValues): Promise<void> {
/**
* Process CLI options
*/
const shouldClean = !!cliParams.clean
const shouldSplit = !!cliParams.split
const shouldPreview = !!cliParams.preview
const shouldBuildHTML = !!cliParams.html
const dryRun = !!cliParams.dryRun
const srcFile = path.resolve(cliParams.srcFile)
const outDir = path.resolve(cliParams.outDir)
const configFileCustom = cliParams.config
? path.resolve(cliParams.config)
: undefined
if (configFileCustom) {
if (!(await isFile(configFileCustom))) {
throw new Error(`--config must be a file - ${configFileCustom}`)
}
if (![".json", ".yaml"].includes(path.extname(configFileCustom))) {
throw new Error(
`--config file must be of type .json or .yaml - ${configFileCustom}`
)
}
}
/**
* Command execution
*/
console.log(`🟣 Generating API documentation`)
const tmpDir = await getTmpDirectory()
const configTmpFile = path.resolve(tmpDir, "redocly-config.yaml")
/** matches naming convention from `redocly split` */
const finalOASFile = cliParams.mainFileName
await createTmpConfig(configFileDefault, configTmpFile)
if (configFileCustom) {
console.log(
`🔵 Merging configuration file - ${configFileCustom} > ${configTmpFile}`
)
await mergeConfig(configTmpFile, configFileCustom, configTmpFile)
}
if (!dryRun) {
if (shouldClean) {
console.log(`🟠 Cleaning output directory`)
await fs.rm(outDir, { recursive: true, force: true })
}
await mkdir(outDir, { recursive: true })
}
const srcFileSanitized = path.resolve(tmpDir, "tmp.oas.yaml")
await sanitizeOAS(srcFile, srcFileSanitized, configTmpFile)
await fixCirclularReferences(srcFileSanitized, dryRun)
if (dryRun) {
console.log(`⚫️ Dry run - no files generated`)
return
}
if (shouldPreview) {
await preview(srcFileSanitized, configTmpFile)
return
}
if (shouldSplit) {
await generateReference(srcFileSanitized, outDir)
} else {
await writeYaml(path.join(outDir, finalOASFile), await fs.readFile(srcFileSanitized, "utf8"))
}
if (shouldBuildHTML) {
const outHTMLFile = path.resolve(outDir, "index.html")
await buildHTML(finalOASFile, outHTMLFile, configTmpFile)
}
console.log(`⚫️ API documentation generated - ${outDir}`)
}
/**
* Methods
*/
type RedoclyConfig = {
apis?: Record<string, unknown>
decorators?: Record<string, unknown>
extends?: string[]
organization?: string
plugins?: string[]
preprocessors?: Record<string, unknown>
region?: string
resolve?: Record<string, unknown>
rules?: Record<string, unknown>
theme?: Record<string, unknown>
}
const mergeConfig = async (
configFileDefault: string,
configFileCustom: string,
configFileOut: string
): Promise<void> => {
const configDefault = await readYaml(configFileDefault)
const configCustom =
path.extname(configFileCustom) === ".yaml"
? await readYaml(configFileCustom)
: await readJson(configFileCustom)
const config = mergeWith(configDefault, configCustom, (objValue, srcValue) =>
isArray(objValue) ? objValue.concat(srcValue) : undefined
) as RedoclyConfig
await writeYamlFromJson(configFileOut, config)
}
const createTmpConfig = async (
configFileDefault: string,
configFileOut: string
): Promise<void> => {
const config = (await readYaml(configFileDefault)) as RedoclyConfig
config.plugins = (config.plugins ?? []).filter(
(plugin) => plugin !== medusaPluginRelativePath
)
config.plugins.push(medusaPluginAbsolutePath)
await writeYamlFromJson(configFileOut, config)
}
const sanitizeOAS = async (
srcFile: string,
outFile: string,
configFile: string
): Promise<void> => {
const { all: logs } = await execa(
"yarn",
[
"redocly",
"bundle",
srcFile,
`--output=${outFile}`,
`--config=${configFile}`,
],
{ cwd: basePath, all: true }
)
console.log(logs)
}
const fixCirclularReferences = async (srcFile: string, dryRun = false): Promise<void> => {
const { circularRefs, oas } = await getCircularReferences(srcFile)
if (circularRefs.length) {
const recommendation = getCircularPatchRecommendation(circularRefs, oas)
if (Object.keys(recommendation).length) {
const hint = formatHintRecommendation(recommendation)
const hintMessage = `
###
${hint}
###
`
if (dryRun) {
throw new Error(
`🔴 Unhandled circular references - Please manually patch using --config ./redocly-config.yaml
Within redocly-config.yaml, try adding the following:` + hintMessage
)
}
const redoclyConfigPath = path.join(basePath, "redocly", "redocly-config.yaml")
const originalContent = await readYaml(redoclyConfigPath) as CircularReferenceConfig
originalContent.decorators["medusa/circular-patch"].schemas = Object.assign(
originalContent.decorators["medusa/circular-patch"].schemas,
recommendation
)
await writeYaml(redoclyConfigPath, jsonObjectToYamlString(originalContent))
console.log(`🟡 Added the following unhandled circular references to redocly-config.ts:` + hintMessage)
}
}
console.log(`🟢 All circular references are handled`)
}
const generateReference = async (
srcFile: string,
outDir: string
): Promise<void> => {
const { all: logs } = await execa(
"yarn",
["redocly", "split", srcFile, `--outDir=${outDir}`],
{ cwd: basePath, all: true }
)
console.log(logs)
}
const preview = async (oasFile: string, configFile: string): Promise<void> => {
await commandWrapper(previewDocs)({
port: 8080,
host: "127.0.0.1",
api: oasFile,
config: configFile,
} as yargs.Arguments<PreviewDocsOptions>)
}
const buildHTML = async (
srcFile: string,
outFile: string,
configFile: string
): Promise<void> => {
const { all: logs } = await execa(
"yarn",
[
"redocly",
"build-docs",
srcFile,
`--output=${outFile}`,
`--config=${configFile}`,
`--cdn=true`,
],
{ cwd: basePath, all: true }
)
console.log(logs)
}