docs-util: support generating OAS in docblock generator (#6338)
## What
This PR adds support for generating OAS in the docblock generator tool.
## How
As OAS are generated in a different manner/location than regular TSDocs, it requires a new type of generator within the tool. As such, the existing docblock generator now only handles files that aren't under the `packages/medusa/src/api` and `packages/medusa/src/api-v2` directories. The new generator handles files under these directories. However, it only considers a node to be an API route if it's a function having two parameters of types `MedusaRequest` and `MedusaResponse` respectively. So, only new API Routes are considered.
The new generator runs the same way as the existing docblock generator with the same method. The generators will detect whether they can run on the file or not and the docblocks/oas are generated based on that. I've also added a `--type` option to the CLI commands of the docblock generator tool to further filter and choose which generator to use.
When the OAS generator finds an API route, it will generate its OAS under the `docs-util/oas-output/operations` directory in a TypeScript file. I chose to generate in TS files rather than YAML files to maintain the functionality of `medusa-oas` without major changes.
Schemas detected in the OAS operation, such as the request and response schemas, are generated as OAS schemas under the `docs-util/oas-output/schemas` directory and referenced in operations and other resources.
The OAS generator also handles updating OAS. When you run the same command on a file/directory and an API route already has OAS associated with it, its information and associated schemas are updated instead of generating new schemas/operations. However, summaries and descriptions aren't updated unless they're not available or their values are the default value SUMMARY.
## API Route Handling
### Request and Response Types
The tool extracts the type of request/response schemas from the type arguments passed to the `MedusaRequest` and `MedusaResponse` respectively. For example:
```ts
export const POST = async (
req: MedusaRequest<{
id: string
}>,
res: MedusaResponse<ResponseType>
) => {
// ...
}
```
If these types aren't provided, the request/response is considered empty.
### Path Parameters
Path parameters are extracted from the file's path name. For example, for `packages/medusa/src/api-v2/admin/campaigns/[id]/route.ts` the `id` path parameter is extracted.
### Query Parameters
The tool extracts the query parameters of an API route based on the type of `request.validatedQuery`. Once we narrow down how we're typing query parameters, we can revisit this implementation.
## Changes to Medusa Oas CLI
I added a `--v2` option to the Medusa OAS CLI to support loading OAS from `docs-util/oas-output` directory rather than the `medusa` package. This will output the OAS in `www/apps/api-reference/specs`, wiping out old OAS. This is only helpful for testing purposes to check how the new OAS looks like in the API reference. It also allows us to slowly start adapting the new OAS.
## Other Notes and Changes
- I've added a GitHub action that creates a PR for generated OAS when Version Packages is merged (similar to regular TSDocs). However, this will only generate the OAS in the `docs-util/oas-output` directory and will not affect the existing OAS in the API reference. Once we're ready to include it those OAS, we can talk about next steps.
- I've moved the base YAML from the `medusa` package to the `docs-util/oas-output/base` directory and changed the `medusa-oas` tool to load them from there.
- I added a `clean:oas` command to the docblock generator CLI tool that removes unused OAS operations, schemas, and tags from `docs-util/oas-output`. The tool also supports updating OAS operations and their associated schemas. However, I didn't add a specific mechanism to update schemas on their own as that's a bit tricky and would require the help of typedoc. I believe with the process of running the tool on the `api-v2` directory whenever there's a new release should be enough to update associated schemas, but if we find that not enough, we can revisit updating schemas individually.
- Because of the `clean:oas` command which makes changes to tags (removing the existing ones, more details on this one later), I've added new base YAML under `docs-util/oas-output/base-v2`. This is used by the tool when generating/cleaning OAS, and the Medusa OAS CLI when the `--v2` option is used.
## Testing
### Prerequisites
To test with request/response types, I recommend minimally modifying `packages/medusa/src/types/routing.ts` to allow type arguments of `MedusaRequest` and `MedusaResponse`:
```ts
import type { NextFunction, Request, Response } from "express"
import type { Customer, User } from "../models"
import type { MedusaContainer } from "./global"
export interface MedusaRequest<T = unknown> extends Request {
user?: (User | Customer) & { customer_id?: string; userId?: string }
scope: MedusaContainer
}
export type MedusaResponse<T = unknown> = Response
export type MedusaNextFunction = NextFunction
export type MedusaRequestHandler = (
req: MedusaRequest,
res: MedusaResponse,
next: MedusaNextFunction
) => Promise<void> | void
```
You can then add type arguments to the routes in `packages/medusa/src/api-v2/admin/campaigns/[id]/route.ts`. For example:
```ts
import {
deleteCampaignsWorkflow,
updateCampaignsWorkflow,
} from "@medusajs/core-flows"
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { CampaignDTO, IPromotionModuleService } from "@medusajs/types"
import { MedusaRequest, MedusaResponse } from "../../../../types/routing"
interface ResponseType {
campaign: CampaignDTO
}
export const GET = async (
req: MedusaRequest,
res: MedusaResponse<ResponseType>
) => {
const promotionModuleService: IPromotionModuleService = req.scope.resolve(
ModuleRegistrationName.PROMOTION
)
const campaign = await promotionModuleService.retrieveCampaign(
req.params.id,
{
select: req.retrieveConfig.select,
relations: req.retrieveConfig.relations,
}
)
res.status(200).json({ campaign })
}
export const POST = async (
req: MedusaRequest<{
id: string
}>,
res: MedusaResponse<ResponseType>
) => {
const updateCampaigns = updateCampaignsWorkflow(req.scope)
const campaignsData = [
{
id: req.params.id,
...(req.validatedBody || {}),
},
]
const { result, errors } = await updateCampaigns.run({
input: { campaignsData },
throwOnError: false,
})
if (Array.isArray(errors) && errors[0]) {
throw errors[0].error
}
res.status(200).json({ campaign: result[0] })
}
export const DELETE = async (
req: MedusaRequest,
res: MedusaResponse<{
id: string
object: string
deleted: boolean
}>
) => {
const id = req.params.id
const manager = req.scope.resolve("manager")
const deleteCampaigns = deleteCampaignsWorkflow(req.scope)
const { errors } = await deleteCampaigns.run({
input: { ids: [id] },
context: { manager },
throwOnError: false,
})
if (Array.isArray(errors) && errors[0]) {
throw errors[0].error
}
res.status(200).json({
id,
object: "campaign",
deleted: true,
})
}
```
### Generate OAS
- Install dependencies in the `docs-util` directory
- Run the following command in the `docs-util/packages/docblock-generator` directory:
```bash
yarn dev run "../../../packages/medusa/src/api-v2/admin/campaigns/[id]/route.ts"
```
This will generate the OAS operation and schemas and necessary and update the base YAML to include the new tags.
### Generate OAS with Examples
By default, the tool will only generate cURL examples for OAS operations. To generate templated JS Client and (placeholder) Medusa React examples, add the `--generate-examples` option to the command:
```bash
yarn dev run "../../../packages/medusa/src/api-v2/admin/campaigns/[id]/route.ts" --generate-examples
```
> Note: the command will update the existing OAS you generated in the previous test.
### Testing Updates
To test updating OAS, you can try updating request/response types, then running the command, and the associated OAS/schemas will be updated.
### Clean OAS
The `clean:oas` command will remove any unused operation, tags, or schemas. To test it out you can try:
- Remove an API Route => this removes its associated operation and schemas (if not referenced anywhere else).
- Remove all references to a schema => this removes the schema.
- Remove all operations in `docs-util/oas-output/operations` associated with a tag => this removes the tag from the base YAML.
```bash
yarn dev clean:oas
```
> Note: when running this command, existing tags in the base YAML (such as Products) will be removed since there are no operations using it. As it's running on the base YAML under `base-v2`, this doesn't affect base YAML used for the API reference.
### Medusa Oas CLI
- Install and build dependencies in the root of the monorepo
- Run the following command to generate reference OAS for v2 API Routes (must have generated OAS previously using the docblock generator tool):
```bash
yarn openapi:generate --v2
```
- This wipes out existing OAS in `www/apps/api-reference/specs` and replaces them with the new ones. At this point, you can view the new API routes in the API reference by running the `yarn dev` command in `www/apps/api-reference` (although not necessary for testing here).
- Run the command again without the `--v2` option:
```bash
yarn openapi:generate
```
The specs in `www/apps/api-reference/specs` are reverted back to the old routes.
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
import getMonorepoRoot from "../../utils/get-monorepo-root.js"
|
||||
import { ESLint, Linter } from "eslint"
|
||||
import path from "path"
|
||||
import dirname from "../../utils/dirname.js"
|
||||
import { minimatch } from "minimatch"
|
||||
import { existsSync } from "fs"
|
||||
import * as prettier from "prettier"
|
||||
import getRelativePaths from "../../utils/get-relative-paths.js"
|
||||
|
||||
/**
|
||||
* A class used to apply formatting to files using ESLint and other formatting options.
|
||||
*/
|
||||
class Formatter {
|
||||
protected cwd: string
|
||||
protected eslintConfig?: Linter.Config
|
||||
protected generalESLintConfig?: Linter.ConfigOverride<Linter.RulesRecord>
|
||||
protected configForFile: Map<
|
||||
string,
|
||||
Linter.ConfigOverride<Linter.RulesRecord>
|
||||
>
|
||||
|
||||
constructor() {
|
||||
this.cwd = getMonorepoRoot()
|
||||
this.configForFile = new Map()
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds new lines before and after a comment if it's preceeded/followed immediately by a word (not by an empty line).
|
||||
*
|
||||
* @param {string} content - The content to format.
|
||||
* @returns {string} The returned formatted content.
|
||||
*/
|
||||
normalizeCommentNewLine(content: string): string {
|
||||
return content
|
||||
.replaceAll(/(.)\n(\s*)\/\*\*/g, "$1\n\n$2/**")
|
||||
.replaceAll(/\*\/\s*(.)/g, "*/\n$1")
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes an ESLint overrides configuration object. If a file name is specified, the configuration are normalized to
|
||||
* include the `tsconfig` related to the file. If a file name isn't specified, the tsconfig file path names
|
||||
* in the `parserConfig.project` array are normalized to have a full relative path (as that is required by ESLint).
|
||||
*
|
||||
* @param {Linter.ConfigOverride<Linter.RulesRecord>} config - The original configuration object.
|
||||
* @param {string} fileName - The file name that
|
||||
* @returns {Linter.ConfigOverride<Linter.RulesRecord>} The normalized and cloned configuration object.
|
||||
*/
|
||||
normalizeOverridesConfigObject(
|
||||
config: Linter.ConfigOverride<Linter.RulesRecord>,
|
||||
fileName?: string
|
||||
): Linter.ConfigOverride<Linter.RulesRecord> {
|
||||
// clone config
|
||||
const newConfig = structuredClone(config)
|
||||
if (!newConfig.parserOptions) {
|
||||
return newConfig
|
||||
}
|
||||
|
||||
if (fileName) {
|
||||
const packagePattern = /^(?<packagePath>.*\/packages\/[^/]*).*$/
|
||||
// try to manually set the project of the parser options
|
||||
const matchFilePackage = packagePattern.exec(fileName)
|
||||
|
||||
if (matchFilePackage?.groups?.packagePath) {
|
||||
const tsConfigPath = path.join(
|
||||
matchFilePackage.groups.packagePath,
|
||||
"tsconfig.json"
|
||||
)
|
||||
const tsConfigSpecPath = path.join(
|
||||
matchFilePackage.groups.packagePath,
|
||||
"tsconfig.spec.json"
|
||||
)
|
||||
|
||||
newConfig.parserOptions.project = [
|
||||
existsSync(tsConfigPath)
|
||||
? tsConfigPath
|
||||
: existsSync(tsConfigSpecPath)
|
||||
? tsConfigSpecPath
|
||||
: [
|
||||
...getRelativePaths(
|
||||
newConfig.parserOptions.project || [],
|
||||
this.cwd
|
||||
),
|
||||
],
|
||||
]
|
||||
}
|
||||
} else if (newConfig.parserOptions.project?.length) {
|
||||
// fix parser projects paths to be relative to this script
|
||||
newConfig.parserOptions.project = getRelativePaths(
|
||||
newConfig.parserOptions.project as string[],
|
||||
this.cwd
|
||||
)
|
||||
}
|
||||
|
||||
return newConfig
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the general ESLint configuration and sets it to the `eslintConfig` class property, if it's not already set.
|
||||
* It also tries to set the `generalESLintConfig` class property to the override configuration in the `eslintConfig`
|
||||
* whose `files` array includes `*.ts`.
|
||||
*/
|
||||
async getESLintConfig() {
|
||||
if (this.eslintConfig) {
|
||||
return
|
||||
}
|
||||
|
||||
this.eslintConfig = (
|
||||
await import(
|
||||
path.relative(dirname(), path.join(this.cwd, ".eslintrc.js"))
|
||||
)
|
||||
).default as Linter.Config
|
||||
|
||||
this.generalESLintConfig = this.eslintConfig!.overrides?.find((item) =>
|
||||
item.files.includes("*.ts")
|
||||
)
|
||||
|
||||
if (this.generalESLintConfig) {
|
||||
this.generalESLintConfig = this.normalizeOverridesConfigObject(
|
||||
this.generalESLintConfig
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the normalized ESLint overrides configuration for a specific file.
|
||||
*
|
||||
* @param {string} filePath - The file's path.
|
||||
* @returns {Promise<Linter.ConfigOverride<Linter.RulesRecord> | undefined>} The normalized configuration object or `undefined` if not found.
|
||||
*/
|
||||
async getESLintOverridesConfigForFile(
|
||||
filePath: string
|
||||
): Promise<Linter.ConfigOverride<Linter.RulesRecord> | undefined> {
|
||||
await this.getESLintConfig()
|
||||
|
||||
if (this.configForFile.has(filePath)) {
|
||||
return this.configForFile.get(filePath)!
|
||||
}
|
||||
|
||||
let relevantConfig = this.eslintConfig!.overrides?.find((item) => {
|
||||
if (typeof item.files === "string") {
|
||||
return minimatch(filePath, item.files)
|
||||
}
|
||||
|
||||
return item.files.some((file) => minimatch(filePath, file))
|
||||
})
|
||||
|
||||
if (!relevantConfig && !this.generalESLintConfig) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
relevantConfig = this.normalizeOverridesConfigObject(
|
||||
structuredClone(relevantConfig || this.generalESLintConfig!),
|
||||
filePath
|
||||
)
|
||||
|
||||
relevantConfig!.files = [path.relative(this.cwd, filePath)]
|
||||
|
||||
this.configForFile.set(filePath, relevantConfig)
|
||||
|
||||
return relevantConfig
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a string with ESLint.
|
||||
*
|
||||
* @param {string} content - The content to format.
|
||||
* @param {string} fileName - The path to the file that the content belongs to.
|
||||
* @returns {Promise<string>} The formatted content.
|
||||
*/
|
||||
async formatStrWithEslint(
|
||||
content: string,
|
||||
fileName: string
|
||||
): Promise<string> {
|
||||
const prettifiedContent = await this.formatStrWithPrettier(
|
||||
content,
|
||||
fileName
|
||||
)
|
||||
const relevantConfig = await this.getESLintOverridesConfigForFile(fileName)
|
||||
|
||||
const eslint = new ESLint({
|
||||
overrideConfig: {
|
||||
...this.eslintConfig,
|
||||
overrides: relevantConfig ? [relevantConfig] : undefined,
|
||||
},
|
||||
cwd: this.cwd,
|
||||
resolvePluginsRelativeTo: this.cwd,
|
||||
fix: true,
|
||||
ignore: false,
|
||||
})
|
||||
|
||||
let newContent = prettifiedContent
|
||||
const result = await eslint.lintText(prettifiedContent, {
|
||||
filePath: fileName,
|
||||
})
|
||||
|
||||
if (result.length) {
|
||||
newContent = result[0].output || newContent
|
||||
}
|
||||
|
||||
return newContent
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a file's content with prettier.
|
||||
*
|
||||
* @param content - The content to format.
|
||||
* @param fileName - The name of the file the content belongs to.
|
||||
* @returns The formatted content
|
||||
*/
|
||||
async formatStrWithPrettier(
|
||||
content: string,
|
||||
fileName: string
|
||||
): Promise<string> {
|
||||
// load config of the file
|
||||
const prettierConfig = (await prettier.resolveConfig(fileName)) || undefined
|
||||
|
||||
if (prettierConfig && !prettierConfig.parser) {
|
||||
prettierConfig.parser = "babel-ts"
|
||||
}
|
||||
|
||||
return await prettier.format(content, prettierConfig)
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies all formatting types to a string.
|
||||
*
|
||||
* @param {string} content - The content to format.
|
||||
* @param {string} fileName - The path to the file that holds the content.
|
||||
* @returns {Promise<string>} The formatted content.
|
||||
*/
|
||||
async formatStr(content: string, fileName: string): Promise<string> {
|
||||
const newContent = await this.formatStrWithEslint(content, fileName)
|
||||
|
||||
let normalizedContent = this.normalizeCommentNewLine(newContent)
|
||||
|
||||
if (normalizedContent !== newContent) {
|
||||
/**
|
||||
* Since adding the new lines after comments as done in {@link normalizeCommentNewLine} method may lead to linting errors,
|
||||
* we have to rerun the {@link formatStrWithEslint}. It's not possible to run {@link normalizeCommentNewLine} the first time
|
||||
* and provide the expected result.
|
||||
*/
|
||||
normalizedContent = await this.formatStrWithEslint(
|
||||
normalizedContent,
|
||||
fileName
|
||||
)
|
||||
}
|
||||
|
||||
return normalizedContent
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds comments of a source file to the top of the file's content. It should have additional extra line after the comment.
|
||||
* If the comment's length is 0, the `content` is returned as is.
|
||||
*
|
||||
* @param {string} comment - The comments of the source file.
|
||||
* @param {string} content - The source file's comments.
|
||||
* @returns {string} The full content with the comments.
|
||||
*/
|
||||
addCommentsToSourceFile(comment: string, content: string): string {
|
||||
return comment.length ? `/**\n ${comment}*/\n\n${content}` : content
|
||||
}
|
||||
}
|
||||
|
||||
export default Formatter
|
||||
@@ -0,0 +1,37 @@
|
||||
import EventEmitter from "events"
|
||||
|
||||
export enum GeneratorEvent {
|
||||
FINISHED_GENERATE_EVENT = "finished_generate",
|
||||
}
|
||||
|
||||
/**
|
||||
* A class used to emit events during the lifecycle of the generator.
|
||||
*/
|
||||
class GeneratorEventManager {
|
||||
private eventEmitter: EventEmitter
|
||||
|
||||
constructor() {
|
||||
this.eventEmitter = new EventEmitter()
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an event to listeners.
|
||||
*
|
||||
* @param event - The event to emit.
|
||||
*/
|
||||
emit(event: GeneratorEvent) {
|
||||
this.eventEmitter.emit(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a listener to an event.
|
||||
*
|
||||
* @param event - The event to add a listener for.
|
||||
* @param handler - The handler of the event.
|
||||
*/
|
||||
listen(event: GeneratorEvent, handler: () => void) {
|
||||
this.eventEmitter.on(event, handler)
|
||||
}
|
||||
}
|
||||
|
||||
export default GeneratorEventManager
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Octokit } from "octokit"
|
||||
import promiseExec from "../utils/promise-exec.js"
|
||||
import getMonorepoRoot from "../utils/get-monorepo-root.js"
|
||||
import filterFiles from "../utils/filter-files.js"
|
||||
|
||||
type Options = {
|
||||
owner?: string
|
||||
repo?: string
|
||||
authToken?: string
|
||||
}
|
||||
|
||||
export class GitManager {
|
||||
private owner: string
|
||||
private repo: string
|
||||
private authToken: string
|
||||
private octokit: Octokit
|
||||
private gitApiVersion = "2022-11-28"
|
||||
|
||||
constructor(options?: Options) {
|
||||
this.owner = options?.owner || process.env.GIT_OWNER || ""
|
||||
this.repo = options?.repo || process.env.GIT_REPO || ""
|
||||
this.authToken = options?.authToken || process.env.GITHUB_TOKEN || ""
|
||||
|
||||
this.octokit = new Octokit({
|
||||
auth: this.authToken,
|
||||
})
|
||||
}
|
||||
|
||||
async getCommitFilesSinceRelease(tagName: string) {
|
||||
const { data: release } = await this.octokit.request(
|
||||
"GET /repos/{owner}/{repo}/releases/tags/{tag}",
|
||||
{
|
||||
owner: this.owner,
|
||||
repo: this.repo,
|
||||
tag: tagName,
|
||||
headers: {
|
||||
"X-GitHub-Api-Version": this.gitApiVersion,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return this.getCommitsFiles(release.published_at)
|
||||
}
|
||||
|
||||
async getCommitFilesSinceLastRelease() {
|
||||
// list releases to get the latest two releases
|
||||
const { data: release } = await this.octokit.request(
|
||||
"GET /repos/{owner}/{repo}/releases/latest",
|
||||
{
|
||||
owner: this.owner,
|
||||
repo: this.repo,
|
||||
headers: {
|
||||
"X-GitHub-Api-Version": this.gitApiVersion,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return this.getCommitsFiles(release.published_at)
|
||||
}
|
||||
|
||||
async getCommitsFiles(date?: string | null) {
|
||||
// get commits between the last two releases
|
||||
const commits = await this.octokit.paginate(
|
||||
"GET /repos/{owner}/{repo}/commits",
|
||||
{
|
||||
owner: this.owner,
|
||||
repo: this.repo,
|
||||
since: date || undefined,
|
||||
per_page: 100,
|
||||
}
|
||||
)
|
||||
|
||||
// get files of each of the commits
|
||||
const files = new Set<string>()
|
||||
|
||||
await Promise.all(
|
||||
commits.map(async (commit) => {
|
||||
const commitFiles = await this.getCommitFiles(commit.sha)
|
||||
|
||||
commitFiles?.forEach((commitFile) => files.add(commitFile.filename))
|
||||
})
|
||||
)
|
||||
|
||||
return [...files]
|
||||
}
|
||||
|
||||
async getDiffFiles(): Promise<string[]> {
|
||||
const childProcess = await promiseExec(
|
||||
`git diff --name-only -- "packages/**/**.ts" "packages/**/*.js" "packages/**/*.tsx" "packages/**/*.jsx"`,
|
||||
{
|
||||
cwd: getMonorepoRoot(),
|
||||
}
|
||||
)
|
||||
|
||||
return filterFiles(
|
||||
childProcess.stdout.toString().split("\n").filter(Boolean)
|
||||
)
|
||||
}
|
||||
|
||||
async getCommitFiles(commitSha: string) {
|
||||
const {
|
||||
data: { files },
|
||||
} = await this.octokit.request("GET /repos/{owner}/{repo}/commits/{ref}", {
|
||||
owner: "medusajs",
|
||||
repo: "medusa",
|
||||
ref: commitSha,
|
||||
headers: {
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
per_page: 3000,
|
||||
})
|
||||
|
||||
return files
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
import ts from "typescript"
|
||||
import { DOCBLOCK_DOUBLE_LINES, DOCBLOCK_NEW_LINE } from "../../constants.js"
|
||||
import {
|
||||
camelToTitle,
|
||||
camelToWords,
|
||||
normalizeName,
|
||||
snakeToWords,
|
||||
} from "../../utils/str-formatting.js"
|
||||
import pluralize from "pluralize"
|
||||
|
||||
type TemplateOptions = {
|
||||
parentName?: string
|
||||
rawParentName?: string
|
||||
returnTypeName?: string
|
||||
}
|
||||
|
||||
type KnowledgeBase = {
|
||||
startsWith?: string
|
||||
endsWith?: string
|
||||
exact?: string
|
||||
pattern?: RegExp
|
||||
template:
|
||||
| string
|
||||
| ((str: string, options?: TemplateOptions) => string | undefined)
|
||||
kind?: ts.SyntaxKind[]
|
||||
}
|
||||
|
||||
export type RetrieveOptions = {
|
||||
/**
|
||||
* A name that can be of a function, type, etc...
|
||||
*/
|
||||
str: string
|
||||
/**
|
||||
* Options to pass to the `template` function of a
|
||||
* knowledge base item.
|
||||
*/
|
||||
templateOptions?: TemplateOptions
|
||||
/**
|
||||
* The kind of the associated node.
|
||||
*/
|
||||
kind?: ts.SyntaxKind
|
||||
}
|
||||
|
||||
type RetrieveSymbolOptions = Omit<RetrieveOptions, "str"> & {
|
||||
/**
|
||||
* The symbol to retrieve the item from the knowledge base.
|
||||
*/
|
||||
symbol: ts.Symbol
|
||||
}
|
||||
|
||||
/**
|
||||
* A class that holds common Medusa patterns and acts as a knowledge base for possible summaries/examples/general templates.
|
||||
*/
|
||||
class KnowledgeBaseFactory {
|
||||
private summaryKnowledgeBase: KnowledgeBase[] = [
|
||||
{
|
||||
startsWith: "FindConfig",
|
||||
template: (str) => {
|
||||
const typeArgs = str
|
||||
.replace("FindConfig<", "")
|
||||
.replace(/>$/, "")
|
||||
.split(",")
|
||||
.map((part) => camelToWords(normalizeName(part.trim())))
|
||||
const typeName =
|
||||
typeArgs.length > 0 && typeArgs[0].length > 0
|
||||
? typeArgs[0]
|
||||
: `{type name}`
|
||||
return `The configurations determining how the ${typeName} is retrieved. Its properties, such as \`select\` or \`relations\`, accept the ${DOCBLOCK_NEW_LINE}attributes or relations associated with a ${typeName}.`
|
||||
},
|
||||
},
|
||||
{
|
||||
startsWith: "Filterable",
|
||||
endsWith: "Props",
|
||||
template: (str) => {
|
||||
return `The filters to apply on the retrieved ${camelToTitle(
|
||||
normalizeName(str)
|
||||
)}.`
|
||||
},
|
||||
},
|
||||
{
|
||||
startsWith: "Create",
|
||||
endsWith: "DTO",
|
||||
template: (str) => {
|
||||
return `The ${camelToTitle(normalizeName(str))} to be created.`
|
||||
},
|
||||
},
|
||||
{
|
||||
startsWith: "Update",
|
||||
endsWith: "DTO",
|
||||
template: (str) => {
|
||||
return `The attributes to update in the ${camelToTitle(
|
||||
normalizeName(str)
|
||||
)}.`
|
||||
},
|
||||
},
|
||||
{
|
||||
startsWith: "RestoreReturn",
|
||||
template: `Configurations determining which relations to restore along with each of the {type name}. You can pass to its \`returnLinkableKeys\` ${DOCBLOCK_NEW_LINE}property any of the {type name}'s relation attribute names, such as \`{type relation name}\`.`,
|
||||
},
|
||||
{
|
||||
endsWith: "DTO",
|
||||
template: (str: string): string => {
|
||||
return `The ${camelToTitle(normalizeName(str))} details.`
|
||||
},
|
||||
},
|
||||
{
|
||||
endsWith: "_id",
|
||||
template: (str: string): string => {
|
||||
const formatted = str.replace(/_id$/, "").split("_").join(" ")
|
||||
|
||||
return `The associated ${formatted}'s ID.`
|
||||
},
|
||||
kind: [ts.SyntaxKind.PropertySignature],
|
||||
},
|
||||
{
|
||||
endsWith: "Id",
|
||||
template: (str: string): string => {
|
||||
const formatted = camelToWords(str.replace(/Id$/, ""))
|
||||
|
||||
return `The ${formatted}'s ID.`
|
||||
},
|
||||
kind: [
|
||||
ts.SyntaxKind.PropertySignature,
|
||||
ts.SyntaxKind.PropertyDeclaration,
|
||||
ts.SyntaxKind.Parameter,
|
||||
],
|
||||
},
|
||||
{
|
||||
exact: "id",
|
||||
template: (str, options) => {
|
||||
if (options?.rawParentName?.startsWith("Filterable")) {
|
||||
return `The IDs to filter the ${options?.parentName || `{name}`} by.`
|
||||
}
|
||||
return `The ID of the ${options?.parentName || `{name}`}.`
|
||||
},
|
||||
kind: [ts.SyntaxKind.PropertySignature],
|
||||
},
|
||||
{
|
||||
exact: "metadata",
|
||||
template: "Holds custom data in key-value pairs.",
|
||||
kind: [ts.SyntaxKind.PropertySignature],
|
||||
},
|
||||
{
|
||||
exact: "customHeaders",
|
||||
template: "Custom headers to attach to the request.",
|
||||
},
|
||||
]
|
||||
private functionSummaryKnowledgeBase: KnowledgeBase[] = [
|
||||
{
|
||||
startsWith: "listAndCount",
|
||||
template:
|
||||
"retrieves a paginated list of {return type} along with the total count of available {return type}(s) satisfying the provided filters.",
|
||||
},
|
||||
{
|
||||
startsWith: "list",
|
||||
template:
|
||||
"retrieves a paginated list of {return type}(s) based on optional filters and configuration.",
|
||||
},
|
||||
{
|
||||
startsWith: "retrieve",
|
||||
template: "retrieves a {return type} by its ID.",
|
||||
},
|
||||
{
|
||||
startsWith: "create",
|
||||
template: "creates {return type}(s)",
|
||||
},
|
||||
{
|
||||
startsWith: "delete",
|
||||
template: "deletes {return type} by its ID.",
|
||||
},
|
||||
{
|
||||
startsWith: "update",
|
||||
template: "updates existing {return type}(s).",
|
||||
},
|
||||
{
|
||||
startsWith: "softDelete",
|
||||
template: "soft deletes {return type}(s) by their IDs.",
|
||||
},
|
||||
{
|
||||
startsWith: "restore",
|
||||
template: "restores soft deleted {return type}(s) by their IDs.",
|
||||
},
|
||||
]
|
||||
private exampleCodeBlockLine = `${DOCBLOCK_DOUBLE_LINES}\`\`\`ts${DOCBLOCK_NEW_LINE}{example-code}${DOCBLOCK_NEW_LINE}\`\`\`${DOCBLOCK_DOUBLE_LINES}`
|
||||
private examplesKnowledgeBase: KnowledgeBase[] = [
|
||||
{
|
||||
startsWith: "list",
|
||||
template: `To retrieve a list of {type name} using their IDs: ${this.exampleCodeBlockLine}To specify relations that should be retrieved within the {type name}: ${this.exampleCodeBlockLine}By default, only the first \`{default limit}\` records are retrieved. You can control pagination by specifying the \`skip\` and \`take\` properties of the \`config\` parameter: ${this.exampleCodeBlockLine}`,
|
||||
},
|
||||
{
|
||||
startsWith: "retrieve",
|
||||
template: `A simple example that retrieves a {type name} by its ID: ${this.exampleCodeBlockLine}To specify relations that should be retrieved: ${this.exampleCodeBlockLine}`,
|
||||
},
|
||||
]
|
||||
private functionReturnKnowledgeBase: KnowledgeBase[] = [
|
||||
{
|
||||
startsWith: "listAndCount",
|
||||
template: "The list of {return type}(s) along with their total count.",
|
||||
},
|
||||
{
|
||||
startsWith: "list",
|
||||
template: "The list of {return type}(s).",
|
||||
},
|
||||
{
|
||||
startsWith: "retrieve",
|
||||
template: "The retrieved {return type}(s).",
|
||||
},
|
||||
{
|
||||
startsWith: "create",
|
||||
template: "The created {return type}(s).",
|
||||
},
|
||||
{
|
||||
startsWith: "update",
|
||||
template: "The updated {return type}(s).",
|
||||
},
|
||||
{
|
||||
startsWith: "restore",
|
||||
template: `An object that includes the IDs of related records that were restored, such as the ID of associated {relation name}. ${DOCBLOCK_NEW_LINE}The object's keys are the ID attribute names of the {type name} entity's relations, such as \`{relation ID field name}\`, ${DOCBLOCK_NEW_LINE}and its value is an array of strings, each being the ID of the record associated with the money amount through this relation, ${DOCBLOCK_NEW_LINE}such as the IDs of associated {relation name}.`,
|
||||
},
|
||||
]
|
||||
private oasDescriptionKnowledgeBase: KnowledgeBase[] = [
|
||||
{
|
||||
pattern: /.*/,
|
||||
template(str, options) {
|
||||
if (!options?.parentName) {
|
||||
return
|
||||
}
|
||||
|
||||
const formattedName = str === "id" ? "ID" : snakeToWords(str)
|
||||
const formattedParentName = pluralize.singular(
|
||||
snakeToWords(options.parentName)
|
||||
)
|
||||
|
||||
if (formattedName === formattedParentName) {
|
||||
return `The ${formattedParentName}'s details.`
|
||||
}
|
||||
|
||||
return `The ${formattedParentName}'s ${formattedName}.`
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Tries to find in a specified knowledge base a template relevant to the specified name.
|
||||
*
|
||||
* @returns {string | undefined} The matching knowledge base template, if found.
|
||||
*/
|
||||
private tryToFindInKnowledgeBase({
|
||||
str,
|
||||
knowledgeBase,
|
||||
templateOptions,
|
||||
kind,
|
||||
}: RetrieveOptions & {
|
||||
/**
|
||||
* A knowledge base to search in.
|
||||
*/
|
||||
knowledgeBase: KnowledgeBase[]
|
||||
}): string | undefined {
|
||||
const foundItem = knowledgeBase.find((item) => {
|
||||
if (item.exact) {
|
||||
return str === item.exact
|
||||
}
|
||||
|
||||
if (item.pattern) {
|
||||
return item.pattern.test(str)
|
||||
}
|
||||
|
||||
if (item.kind?.length && (!kind || !item.kind.includes(kind))) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (item.startsWith && item.endsWith) {
|
||||
return str.startsWith(item.startsWith) && str.endsWith(item.endsWith)
|
||||
}
|
||||
|
||||
if (item.startsWith) {
|
||||
return str.startsWith(item.startsWith)
|
||||
}
|
||||
|
||||
return item.endsWith ? str.endsWith(item.endsWith) : false
|
||||
})
|
||||
|
||||
if (!foundItem) {
|
||||
return
|
||||
}
|
||||
|
||||
return typeof foundItem.template === "string"
|
||||
? foundItem?.template
|
||||
: foundItem?.template(str, templateOptions)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to retrieve the summary template of a specified type from the {@link summaryKnowledgeBase}.
|
||||
*
|
||||
* @returns {string | undefined} The matching knowledge base template, if found.
|
||||
*/
|
||||
tryToGetSummary({ str, ...options }: RetrieveOptions): string | undefined {
|
||||
const normalizedTypeStr = str.replaceAll("[]", "")
|
||||
return this.tryToFindInKnowledgeBase({
|
||||
...options,
|
||||
str: normalizedTypeStr,
|
||||
knowledgeBase: this.summaryKnowledgeBase,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to retrieve the summary template of a function's symbol from the {@link functionSummaryKnowledgeBase}.
|
||||
*
|
||||
* @returns {string | undefined} The matching knowledge base template, if found.
|
||||
*/
|
||||
tryToGetFunctionSummary({
|
||||
symbol,
|
||||
...options
|
||||
}: RetrieveSymbolOptions): string | undefined {
|
||||
return this.tryToFindInKnowledgeBase({
|
||||
...options,
|
||||
str: symbol.getName(),
|
||||
knowledgeBase: this.functionSummaryKnowledgeBase,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to retrieve the example template of a function's symbol from the {@link examplesKnowledgeBase}.
|
||||
*
|
||||
* @returns {string | undefined} The matching knowledge base template, if found.
|
||||
*/
|
||||
tryToGetFunctionExamples({
|
||||
symbol,
|
||||
...options
|
||||
}: RetrieveSymbolOptions): string | undefined {
|
||||
return this.tryToFindInKnowledgeBase({
|
||||
...options,
|
||||
str: symbol.getName(),
|
||||
knowledgeBase: this.examplesKnowledgeBase,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to retrieve the return template of a function's symbol from the {@link functionReturnKnowledgeBase}.
|
||||
*
|
||||
* @returns {string | undefined} The matching knowledge base template, if found.
|
||||
*/
|
||||
tryToGetFunctionReturns({
|
||||
symbol,
|
||||
...options
|
||||
}: RetrieveSymbolOptions): string | undefined {
|
||||
return this.tryToFindInKnowledgeBase({
|
||||
...options,
|
||||
str: symbol.getName(),
|
||||
knowledgeBase: this.functionReturnKnowledgeBase,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to retrieve the description template of an OAS property from the {@link oasDescriptionKnowledgeBase}.
|
||||
*
|
||||
* @returns {string | undefined} The matching knowledgebase template, if found.
|
||||
*/
|
||||
tryToGetOasDescription({
|
||||
str,
|
||||
...options
|
||||
}: RetrieveOptions): string | undefined {
|
||||
const normalizedTypeStr = str.replaceAll("[]", "")
|
||||
return this.tryToFindInKnowledgeBase({
|
||||
...options,
|
||||
str: normalizedTypeStr,
|
||||
knowledgeBase: this.oasDescriptionKnowledgeBase,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default KnowledgeBaseFactory
|
||||
@@ -0,0 +1,233 @@
|
||||
import { OpenAPIV3 } from "openapi-types"
|
||||
import { OpenApiSchema } from "../../types/index.js"
|
||||
import Formatter from "./formatter.js"
|
||||
import { join } from "path"
|
||||
import { DOCBLOCK_LINE_ASTRIX } from "../../constants.js"
|
||||
import ts from "typescript"
|
||||
import getOasOutputBasePath from "../../utils/get-oas-output-base-path.js"
|
||||
import { parse } from "yaml"
|
||||
import formatOas from "../../utils/format-oas.js"
|
||||
import pluralize from "pluralize"
|
||||
import { wordsToPascal } from "../../utils/str-formatting.js"
|
||||
|
||||
type ParsedSchema = {
|
||||
schema: OpenApiSchema
|
||||
schemaPrefix: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Class providing helper methods for OAS Schemas
|
||||
*/
|
||||
class OasSchemaHelper {
|
||||
/**
|
||||
* This map collects schemas created while generating the OAS, then, once the generation process
|
||||
* finishes, it checks if it should be added to the base OAS document.
|
||||
*/
|
||||
private schemas: Map<string, OpenApiSchema>
|
||||
protected schemaRefPrefix = "#/components/schemas/"
|
||||
protected formatter: Formatter
|
||||
/**
|
||||
* The path to the directory holding the base YAML files.
|
||||
*/
|
||||
protected baseOutputPath: string
|
||||
|
||||
constructor() {
|
||||
this.schemas = new Map()
|
||||
this.formatter = new Formatter()
|
||||
this.baseOutputPath = getOasOutputBasePath()
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the {@link schemas} property. Helpful when resetting the property.
|
||||
*/
|
||||
init() {
|
||||
this.schemas = new Map()
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve schema as a reference object and add the schema to the {@link schemas} property.
|
||||
*
|
||||
* @param schema - The schema to convert and add to the schemas property.
|
||||
* @returns The schema as a reference. If the schema doesn't have the x-schemaName property set,
|
||||
* the schema isn't converted and `undefined` is returned.
|
||||
*/
|
||||
schemaToReference(
|
||||
schema: OpenApiSchema
|
||||
): OpenAPIV3.ReferenceObject | undefined {
|
||||
if (!schema["x-schemaName"]) {
|
||||
return
|
||||
}
|
||||
schema["x-schemaName"] = this.normalizeSchemaName(schema["x-schemaName"])
|
||||
|
||||
// check if schema has child schemas
|
||||
// and convert those
|
||||
if (schema.properties) {
|
||||
Object.keys(schema.properties).forEach((property) => {
|
||||
if (
|
||||
"$ref" in schema.properties![property] ||
|
||||
!(schema.properties![property] as OpenApiSchema)["x-schemaName"]
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
schema.properties![property] =
|
||||
this.schemaToReference(
|
||||
schema.properties![property] as OpenApiSchema
|
||||
) || schema.properties![property]
|
||||
})
|
||||
}
|
||||
|
||||
this.schemas.set(schema["x-schemaName"], schema)
|
||||
|
||||
return {
|
||||
$ref: this.constructSchemaReference(schema["x-schemaName"]),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the expected file name of the schema.
|
||||
*
|
||||
* @param name - The schema's name
|
||||
* @returns The schema's file name
|
||||
*/
|
||||
getSchemaFileName(name: string): string {
|
||||
return join(
|
||||
this.baseOutputPath,
|
||||
"schemas",
|
||||
`${this.normalizeSchemaName(name)}.ts`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the schema by its name. If the schema is in the {@link schemas} map, it'll be retrieved from
|
||||
* there. Otherwise, the method will try to retrieve it from an outputted schema file, if available.
|
||||
*
|
||||
* @param name - The schema's name.
|
||||
* @returns The parsed schema, if found.
|
||||
*/
|
||||
getSchemaByName(name: string): ParsedSchema | undefined {
|
||||
const schemaName = this.normalizeSchemaName(name)
|
||||
// check if it already exists in the schemas map
|
||||
if (this.schemas.has(schemaName)) {
|
||||
return {
|
||||
schema: this.schemas.get(schemaName)!,
|
||||
schemaPrefix: `@schema ${schemaName}`,
|
||||
}
|
||||
}
|
||||
const schemaFile = this.getSchemaFileName(schemaName)
|
||||
const schemaFileContent = ts.sys.readFile(schemaFile)
|
||||
|
||||
if (!schemaFileContent) {
|
||||
return
|
||||
}
|
||||
|
||||
return this.parseSchema(schemaFileContent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a schema comment string.
|
||||
*
|
||||
* @param content - The schema comment string
|
||||
* @returns If the schema is valid and parsed successfully, the schema and its prefix are retrieved.
|
||||
*/
|
||||
parseSchema(content: string): ParsedSchema | undefined {
|
||||
const schemaFileContent = content
|
||||
.replace(`/**\n`, "")
|
||||
.replaceAll(DOCBLOCK_LINE_ASTRIX, "")
|
||||
.replaceAll("*/", "")
|
||||
.trim()
|
||||
|
||||
if (!schemaFileContent.startsWith("@schema")) {
|
||||
return
|
||||
}
|
||||
|
||||
const splitContent = schemaFileContent.split("\n")
|
||||
const schemaPrefix = splitContent[0]
|
||||
let schema: OpenApiSchema | undefined
|
||||
|
||||
try {
|
||||
schema = parse(splitContent.slice(1).join("\n"))
|
||||
} catch (e) {
|
||||
// couldn't parse the OAS, so consider it
|
||||
// not existent
|
||||
}
|
||||
|
||||
return schema
|
||||
? {
|
||||
schema,
|
||||
schemaPrefix,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the normalized schema name. A schema's name must be normalized before saved.
|
||||
*
|
||||
* @param name - The original name.
|
||||
* @returns The normalized name.
|
||||
*/
|
||||
normalizeSchemaName(name: string): string {
|
||||
return name.replace("DTO", "").replace(this.schemaRefPrefix, "")
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a reference string to a schema.
|
||||
*
|
||||
* @param name - The name of the schema. For cautionary reasons, the name is normalized using the {@link normalizeSchemaName} method.
|
||||
* @returns The schema reference.
|
||||
*/
|
||||
constructSchemaReference(name: string): string {
|
||||
return `${this.schemaRefPrefix}${this.normalizeSchemaName(name)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes schemas in the {@link schemas} property to the file path retrieved using the {@link getSchemaFileName} method.
|
||||
*/
|
||||
writeNewSchemas() {
|
||||
this.schemas.forEach((schema) => {
|
||||
if (!schema["x-schemaName"]) {
|
||||
return
|
||||
}
|
||||
const normalizedName = this.normalizeSchemaName(schema["x-schemaName"])
|
||||
const schemaFileName = this.getSchemaFileName(normalizedName)
|
||||
|
||||
ts.sys.writeFile(
|
||||
schemaFileName,
|
||||
this.formatter.addCommentsToSourceFile(
|
||||
formatOas(schema, `@schema ${normalizedName}`),
|
||||
""
|
||||
)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether an object is a reference object.
|
||||
*
|
||||
* @param schema - The schema object to check.
|
||||
* @returns Whether the object is a reference object.
|
||||
*/
|
||||
isRefObject(
|
||||
schema:
|
||||
| OpenAPIV3.ReferenceObject
|
||||
| OpenApiSchema
|
||||
| OpenAPIV3.RequestBodyObject
|
||||
| OpenAPIV3.ResponseObject
|
||||
| undefined
|
||||
): schema is OpenAPIV3.ReferenceObject {
|
||||
return schema !== undefined && "$ref" in schema
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a tag name to a schema name. Can be used to try and retrieve the schema
|
||||
* associated with a tag.
|
||||
*
|
||||
* @param tagName - The name of the tag.
|
||||
* @returns The possible name of the associated schema.
|
||||
*/
|
||||
tagNameToSchemaName(tagName: string): string {
|
||||
return wordsToPascal(pluralize.singular(tagName))
|
||||
}
|
||||
}
|
||||
|
||||
export default OasSchemaHelper
|
||||
Reference in New Issue
Block a user