feat(oas): medusa-oas-cli as OAS build tool (#3213)

## What

Introduce a CLI for extracting OAS from the core `medusa` package.

## Why

We need to decouple OAS tooling from documentation tooling in order to allow packages and external systems to leverage our OAS has a dependency.

## How

Introduce a new OAS workspace within packages in order to organize current and future OAS related package. Only 1 OAS package for now.

Introduce a new CLI only package to act as the main gateway for all upcoming OAS tooling. Only 1 command for now.

Update documentation tooling pertaining to OAS to use the CLI instead.

## Test

### Prerequisite
From the monorepo root:
* `yarn install`
* `yarn build`

### Documentation

#### Case - validation only - success
* Run `yarn openapi:generate --dry-run`
* Expect console output `🟢 Valid OAS` but no mention of `🔵 Exported OAS`

#### Case - validation only - invalid
* Introduce a bug by renaming `@schema Cart` to `@schema Kart` in [models/cart.ts](https://github.com/medusajs/medusa/blob/0adb0d9ff96087613b7a634cbc97a7e301b01121/packages/medusa/src/models/cart.ts#L2)
* Run `yarn build` to update `@medusajs/medusa` package with the bug.
* Run `yarn openapi:generate --dry-run`
* Expect console output `🔴 Invalid OAS` with a stack trace of the issue.

#### Case - docs generation
* Run `yarn openapi:generate`
* Expect `docs/api/` directory to contain:
  * `admin.oas.json` (raw OAS)
  * `store.oas.json` (raw OAS)
  * `admin.oas.yaml` (sanitized OAS)
  * `store.oas.yaml` (sanitized OAS)
  * `admin/` (updated redocly split output)
  * `store/` (updated redocly split output)

### CLI

#### Case - crawl additional paths
* From a local medusa server (`medusa-starter-default`), add an `index.ts` file in `src/models/`
* In the `index.ts`, add dummy OAS JSDoc like `/** @schema Foobar */`
* From the root of the monorepo, run `yarn medusa-oas --type store --paths path-to-medusa-server/src`
* Expect a `store.oas.json` to be created at the root of the monorepo.
* The `store.oas.json` should contain an additional `Foobar` entry in `components.schemas`.
This commit is contained in:
Patrick authored and GitHub committed 2023-02-15 14:55:58 +00:00
1 parent 1bfbe27b9b
commit 8137061908
20 files changed
+613 -82708

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
/dist
node_modules
.DS_store
.env*
.env
*.sql
+3
View File
@@ -0,0 +1,3 @@
# @medusajs/oas-cli
## 0.1.0
+72
View File
@@ -0,0 +1,72 @@
# medusa-oas-cli - 0.1.0 - experimental
A command-line tool for all OpenAPI Specifications (OAS) related tooling.
## Install
`yarn add --dev @medusajs/medusa-oas-cli`
Install in the global namespace is not yet supported.
~~`npm install -g @medusajs/medusa-oas-cli`~~
## Configuration / First time setup
N/A
## How to use
```bash
yarn medusa-oas <command>
```
### Command - `oas`
This command will scan the `@medusajs/medusa` package in order to extract JSDoc OAS into a json file.
By default, the command will output the two files `admin.oas.json` and `store.oas.json` in the same directory that the
command was run.
Invalid OAS with throw an error and will prevent the files from being outputted.
#### `--type <string>`
Specify which API OAS to create. Accepts `all`, `admin`, `store`.
Defaults to `all`.
```bash
yarn medusa-oas oas --type admin
```
#### `--out-dir <path>`
Specify in which directory should the files be outputted. It accepts a relative or absolute path.
If the directory doesn't exist, it will be created. Defaults to `./`.
```bash
yarm medusa-oas oas --out-dir
```
#### `--paths <paths...>`
Allows passing additional directory paths to crawl for JSDoc OAS and include in the generated OAS.
It accepts multiple entries.
```bash
yarn medusa-oas oas --paths ~/medusa-server/src
```
#### `--dry-run`
Will package the OAS but will not output file. Useful for validating OAS.
```bash
yarn medusa-oas oas --dry-run
```
#### `--force`
Ignore OAS errors and attempt to output generated OAS files.
```bash
yarn medusa-oas oas --force
```
+40
View File
@@ -0,0 +1,40 @@
{
"name": "@medusajs/medusa-oas-cli",
"version": "0.1.0",
"description": "OAS CLI",
"main": "dist/index.js",
"bin": {
"medusa-oas": "./dist/index.js"
},
"files": [
"dist"
],
"repository": {
"type": "git",
"url": "https://github.com/medusajs/medusa",
"directory": "packages/oas/medusa-oas-cli"
},
"publishConfig": {
"access": "public"
},
"author": "Medusa",
"license": "MIT",
"devDependencies": {
"ts-node": "^10.9.1",
"typescript": "4.9.5"
},
"scripts": {
"prepare": "cross-env NODE_ENV=production yarn run build",
"build": "tsc --build",
"cli": "ts-node src/index.ts",
"test": "jest --passWithNoTests",
"test:unit": "jest --passWithNoTests"
},
"dependencies": {
"@medusajs/medusa": "*",
"@readme/openapi-parser": "^2.4.0",
"commander": "^10.0.0",
"openapi3-ts": "^3.1.2",
"swagger-inline": "^6.1.0"
}
}
@@ -0,0 +1,149 @@
import * as path from "path"
import { lstat, mkdir, writeFile } from "fs/promises"
import swaggerInline from "swagger-inline"
import OpenAPIParser from "@readme/openapi-parser"
import { OpenAPIObject } from "openapi3-ts"
import { Command, Option, OptionValues } from "commander"
/**
* Constants
*/
// Medusa core package directory
const medusaPackagePath = path.dirname(
require.resolve("@medusajs/medusa/package.json")
)
type ApiType = "store" | "admin"
/**
* 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(["all", "admin", "store"])
.default("all"),
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("-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))
return command
}
/**
* Main
*/
export async function execute(cliParams: OptionValues) {
/**
* Process CLI options
*/
const dryRun = !!cliParams.dryRun
const force = !!cliParams.force
const apiTypesToExport =
cliParams.type === "all" ? ["store", "admin"] : [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}`)
}
}
/**
* Command execution
*/
if (!dryRun) {
await mkdir(outDir, { recursive: true })
}
for (const apiType of apiTypesToExport) {
console.log(`🟣 Generating OAS - ${apiType}`)
const oas = await getOASFromCodebase(apiType as ApiType, additionalPaths)
await validateOAS(oas, apiType as ApiType, force)
if (!dryRun) {
await exportOASToJSON(oas, apiType as ApiType, outDir)
}
}
}
/**
* Methods
*/
async function getOASFromCodebase(
apiType: ApiType,
additionalPaths: string[] = []
): Promise<OpenAPIObject> {
const gen = await swaggerInline(
[
path.resolve(medusaPackagePath, "dist", "models"),
path.resolve(medusaPackagePath, "dist", "types"),
path.resolve(medusaPackagePath, "dist", "api/middlewares"),
path.resolve(medusaPackagePath, "dist", `api/routes/${apiType}`),
...additionalPaths,
],
{
base: path.resolve(
medusaPackagePath,
"oas",
`${apiType}-spec3-base.yaml`
),
format: ".json",
}
)
return await OpenAPIParser.parse(JSON.parse(gen))
}
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> {
return (await lstat(path.resolve(dirPath))).isDirectory()
}
+22
View File
@@ -0,0 +1,22 @@
#! /usr/bin/env node
import { Command } from "commander"
import { getCommand as oasGetCommand } from "./command-oas"
const run = async () => {
const program = new Command()
/**
* Alias to command-oas.ts
*/
program.addCommand(oasGetCommand())
/**
* Run CLI
*/
await program.parseAsync()
}
void (async () => {
await run()
})()
+33
View File
@@ -0,0 +1,33 @@
{
"compilerOptions": {
"lib": [
"es2019"
],
"target": "es2019",
"outDir": "./dist",
"esModuleInterop": true,
"declaration": true,
"module": "commonjs",
"moduleResolution": "node",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"sourceMap": true,
"noImplicitReturns": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"noImplicitThis": true,
"allowJs": true,
"skipLibCheck": true,
"downlevelIteration": true // to use ES5 specific tooling
},
"include": [
"src"
],
"exclude": [
"node_modules",
"**/tests/*"
],
"ts-node": {
"transpileOnly": true
}
}