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:
Shahed Nasser
2024-02-13 10:40:04 +02:00
committed by GitHub
parent a86c87fe14
commit 374a3f4dab
41 changed files with 4677 additions and 312 deletions

View File

@@ -0,0 +1,383 @@
import { faker } from "@faker-js/faker"
import { OpenAPIV3 } from "openapi-types"
import { API_ROUTE_PARAM_REGEX, OasArea } from "../kinds/oas.js"
import {
capitalize,
kebabToCamel,
wordsToCamel,
wordsToKebab,
} from "../../utils/str-formatting.js"
import { CodeSample } from "../../types/index.js"
type CodeSampleData = Omit<CodeSample, "source">
/**
* This class generates examples for OAS.
*/
class OasExamplesGenerator {
static JSCLIENT_CODESAMPLE_DATA: CodeSampleData = {
lang: "JavaScript",
label: "JS Client",
}
static CURL_CODESAMPLE_DATA: CodeSampleData = {
lang: "Shell",
label: "cURL",
}
static MEDUSAREACT_CODESAMPLE_DATA: CodeSampleData = {
lang: "tsx",
label: "Medusa React",
}
/**
* Generate JS client example for an OAS operation.
*
* @param param0 - The operation's details
* @returns The JS client example.
*/
generateJSClientExample({
area,
tag,
oasPath,
httpMethod,
isAdminAuthenticated,
isStoreAuthenticated,
parameters,
requestBody,
responseBody,
}: {
/**
* The area of the operation.
*/
area: OasArea
/**
* The tag this operation belongs to.
*/
tag: string
/**
* The API route's path.
*/
oasPath: string
/**
* The http method of the operation.
*/
httpMethod: string
/**
* Whether the operation requires admin authentication.
*/
isAdminAuthenticated?: boolean
/**
* Whether the operation requires customer authentication.
*/
isStoreAuthenticated?: boolean
/**
* The path parameters that can be sent in the request, if any.
*/
parameters?: OpenAPIV3.ParameterObject[]
/**
* The request body's schema, if any.
*/
requestBody?: OpenAPIV3.SchemaObject
/**
* The response body's schema, if any.
*/
responseBody?: OpenAPIV3.SchemaObject
}) {
const exampleArr = [
`import Medusa from "@medusajs/medusa-js"`,
`const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 })`,
]
if (isAdminAuthenticated) {
exampleArr.push(`// must be previously logged in or use api token`)
} else if (isStoreAuthenticated) {
exampleArr.push(`// must be previously logged in.`)
}
// infer JS method name
// reset regex manually
API_ROUTE_PARAM_REGEX.lastIndex = 0
const isForSingleEntity = API_ROUTE_PARAM_REGEX.test(oasPath)
let jsMethod = `{methodName}`
if (isForSingleEntity) {
const splitOasPath = oasPath
.replaceAll(API_ROUTE_PARAM_REGEX, "")
.replace(/\/(batch)*$/, "")
.split("/")
const isBulk = oasPath.endsWith("/batch")
const isOperationOnDifferentEntity =
wordsToKebab(tag) !== splitOasPath[splitOasPath.length - 1]
if (isBulk || isOperationOnDifferentEntity) {
const endingEntityName = capitalize(
isBulk &&
API_ROUTE_PARAM_REGEX.test(splitOasPath[splitOasPath.length - 1])
? wordsToCamel(tag)
: kebabToCamel(splitOasPath[splitOasPath.length - 1])
)
jsMethod =
httpMethod === "get"
? `list${endingEntityName}`
: httpMethod === "post"
? `add${endingEntityName}`
: `remove${endingEntityName}`
} else {
jsMethod =
httpMethod === "get"
? "retrieve"
: httpMethod === "post"
? "update"
: "delete"
}
} else {
jsMethod =
httpMethod === "get"
? "list"
: httpMethod === "post"
? "create"
: "delete"
}
// collect the path/request parameters to be passed to the request.
const parametersArr: string[] =
parameters?.map((parameter) => parameter.name) || []
const requestData = requestBody
? this.getSchemaRequiredData(requestBody)
: {}
// assemble the method-call line of format `medusa.{admin?}.{methodName}({...parameters,} {requestBodyDataObj})`
exampleArr.push(
`medusa${area === "admin" ? `.${area}` : ""}.${wordsToCamel(
tag
)}.${jsMethod}(${parametersArr.join(", ")}${
Object.keys(requestData).length
? `${parametersArr.length ? ", " : ""}${JSON.stringify(
requestData,
undefined,
2
)}`
: ""
})`
)
// assemble then lines with response data, if any
const responseData = responseBody
? this.getSchemaRequiredData(responseBody)
: {}
const responseRequiredItems = Object.keys(responseData)
const responseRequiredItemsStr = responseRequiredItems.length
? `{ ${responseRequiredItems.join(", ")} }`
: ""
exampleArr.push(
`.then((${responseRequiredItemsStr}) => {\n\t\t${
responseRequiredItemsStr.length
? `console.log(${responseRequiredItemsStr})`
: "// Success"
}\n})`
)
return exampleArr.join("\n")
}
/**
* Generate cURL examples for an OAS operation.
*
* @param param0 - The operation's details.
* @returns The cURL example.
*/
generateCurlExample({
method,
path,
isAdminAuthenticated,
isStoreAuthenticated,
requestSchema,
}: {
/**
* The HTTP method.
*/
method: string
/**
* The API Route's path.
*/
path: string
/**
* Whether the route requires admin authentication.
*/
isAdminAuthenticated?: boolean
/**
* Whether the route requires customer authentication.
*/
isStoreAuthenticated?: boolean
/**
* The schema of the request body, if any.
*/
requestSchema?: OpenAPIV3.SchemaObject
}): string {
const exampleArr = [
`curl${
method.toLowerCase() !== "get" ? ` -X ${method.toUpperCase()}` : ""
} '{backend_url}${path}'`,
]
if (isAdminAuthenticated) {
exampleArr.push(`-H 'x-medusa-access-token: {api_token}'`)
} else if (isStoreAuthenticated) {
exampleArr.push(`-H 'Authorization: Bearer {access_token}'`)
}
if (requestSchema) {
const requestData = this.getSchemaRequiredData(requestSchema)
if (Object.keys(requestData).length > 0) {
exampleArr.push(`-H 'Content-Type: application/json'`)
exampleArr.push(
`--data-raw '${JSON.stringify(requestData, undefined, 2)}'`
)
}
}
return exampleArr.join(` \\\n`)
}
/**
* Retrieves data object from a schema object. Only retrieves the required fields.
*
* @param schema - The schema to retrieve its required data object.
* @returns An object of required data and their fake values.
*/
getSchemaRequiredData(
schema: OpenAPIV3.SchemaObject
): Record<string, unknown> {
const data: Record<string, unknown> = {}
if (schema.required?.length && schema.properties) {
schema.required.forEach((propertyName) => {
// extract property and its type
const property = schema.properties![
propertyName
] as OpenAPIV3.SchemaObject
let value: unknown
if (property.type === "object") {
const typedValue: Record<string, unknown> = {}
// get the fake value of every property in the object
if (property.properties) {
Object.entries(property.properties).forEach(
([childName, childProp]) => {
const typedChildProp = childProp as OpenAPIV3.SchemaObject
if (!typedChildProp.type) {
return
}
// if the property is an object, get its data object
// otherwise, get its fake value
typedValue[childName] =
typedChildProp.type === "object"
? this.getSchemaRequiredData(
typedChildProp as OpenAPIV3.SchemaObject
)
: this.getFakeValue({
name: childName,
type: typedChildProp.type,
format: typedChildProp.format,
})
}
)
}
value = typedValue
} else if (property.type === "array") {
// if the type of the array's items is an object, retrieve
// its data object. Otherwise, retrieve its fake value.
const propertyItems = property.items as OpenAPIV3.SchemaObject
if (!propertyItems.type) {
value = []
} else {
value = [
propertyItems.type === "object"
? this.getSchemaRequiredData(
property.items as OpenAPIV3.SchemaObject
)
: this.getFakeValue({
name: propertyName,
type: propertyItems.type,
format: propertyItems.format,
}),
]
}
} else if (property.type) {
// retrieve fake value for all other types
value = this.getFakeValue({
name: propertyName,
type: property.type,
format: property.format,
})
}
if (value !== undefined) {
data[propertyName] = value
}
})
}
return data
}
/**
* Retrieve the fake value of a property. The value is used in examples.
*
* @param param0 - The property's details
* @returns The fake value
*/
getFakeValue({
name,
type,
format,
}: {
/**
* The name of the property. It can help when generating the fake value.
* For example, if the name is `id`, the fake value generated will be of the format `id_<randomstring>`.
*/
name: string
/**
* The type of the property.
*/
type: OpenAPIV3.NonArraySchemaObjectType | "array"
/**
* The OAS format of the property. For example, `date-time`.
*/
format?: string
}): unknown {
let value: unknown
switch (true) {
case type === "string" && format === "date-time":
value = faker.date.future().toISOString()
break
case type === "boolean":
value = faker.datatype.boolean()
break
case type === "integer" || type === "number":
value = faker.number.int()
break
case type === "array":
value = []
break
case type === "string":
value = faker.helpers
.mustache(`{{${name}}}`, {
id: () =>
`id_${faker.string.alphanumeric({
length: { min: 10, max: 20 },
})}`,
name: () => faker.person.firstName(),
email: () => faker.internet.email(),
password: () => faker.internet.password({ length: 8 }),
currency: () => faker.finance.currencyCode(),
})
.replace(`{{${name}}}`, "{value}")
}
return value !== undefined ? value : "{value}"
}
}
export default OasExamplesGenerator

View File

@@ -1,54 +1,35 @@
/* eslint-disable no-case-declarations */
import ts from "typescript"
import Formatter from "./formatter.js"
import KindsRegistry from "./kinds/registry.js"
import nodeHasComments from "../utils/node-has-comments.js"
export type Options = {
paths: string[]
dryRun?: boolean
}
import { GeneratorEvent } from "../helpers/generator-event-manager.js"
import AbstractGenerator from "./index.js"
import { minimatch } from "minimatch"
/**
* A class used to generate docblock for one or multiple file paths.
*/
class DocblockGenerator {
protected options: Options
protected program?: ts.Program
protected checker?: ts.TypeChecker
protected formatter: Formatter
protected kindsRegistry?: KindsRegistry
constructor(options: Options) {
this.options = options
this.formatter = new Formatter()
}
class DocblockGenerator extends AbstractGenerator {
/**
* Generate the docblock for the paths specified in the {@link options} class property.
* Generate docblocks for the files in the `options`.
*/
async run() {
this.program = ts.createProgram(this.options.paths, {})
this.checker = this.program.getTypeChecker()
this.kindsRegistry = new KindsRegistry(this.checker)
this.init()
const printer = ts.createPrinter({
removeComments: false,
})
await Promise.all(
this.program.getSourceFiles().map(async (file) => {
this.program!.getSourceFiles().map(async (file) => {
// Ignore .d.ts files
if (file.isDeclarationFile || !this.isFileIncluded(file.fileName)) {
return
}
console.log(`Generating for ${file.fileName}...`)
console.log(`[Docblock] Generating for ${file.fileName}...`)
let fileContent = file.getFullText()
let fileComments: string = ""
const commentsToRemove: string[] = []
const documentChild = (node: ts.Node, topLevel = false) => {
const isSourceFile = ts.isSourceFile(node)
@@ -56,7 +37,7 @@ class DocblockGenerator {
const nodeKindGenerator = this.kindsRegistry?.getKindGenerator(node)
let docComment: string | undefined
if (nodeKindGenerator && this.canDocumentNode(node)) {
if (nodeKindGenerator?.canDocumentNode(node)) {
docComment = nodeKindGenerator.getDocBlock(node)
if (docComment.length) {
if (isSourceFile) {
@@ -92,6 +73,9 @@ class DocblockGenerator {
documentChild(file, true)
if (!this.options.dryRun) {
commentsToRemove.forEach((commentToRemove) => {
fileContent = fileContent.replace(commentToRemove, "")
})
ts.sys.writeFile(
file.fileName,
this.formatter.addCommentsToSourceFile(
@@ -101,44 +85,30 @@ class DocblockGenerator {
)
}
console.log(`Finished generating docblock for ${file.fileName}.`)
console.log(
`[Docblock] Finished generating docblock for ${file.fileName}.`
)
})
)
this.generatorEventManager.emit(GeneratorEvent.FINISHED_GENERATE_EVENT)
this.reset()
}
/**
* Checks whether a file is included in the specified files.
* Checks whether the specified file path is included in the program
* and isn't an API file.
*
* @param {string} fileName - The file to check for.
* @returns {boolean} Whether the file can have docblocks generated for it.
* @param fileName - The file path to check
* @returns Whether the docblock generator can run on this file.
*/
isFileIncluded(fileName: string): boolean {
return this.options.paths.some((path) => path.includes(fileName))
}
/**
* Checks whether a node can be documented.
*
* @privateRemark
* I'm leaving this method in case other conditions arise for a node to be documented.
* Otherwise, we can directly use the {@link nodeHasComments} function.
*
* @param {ts.Node} node - The node to check for.
* @returns {boolean} Whether the node can be documented.
*/
canDocumentNode(node: ts.Node): boolean {
// check if node already has docblock
return !nodeHasComments(node)
}
/**
* Reset the generator's properties for new usage.
*/
reset() {
this.program = undefined
this.checker = undefined
return (
super.isFileIncluded(fileName) &&
!minimatch(this.getBasePath(fileName), "packages/medusa/**/api**/**", {
matchBase: true,
})
)
}
}

View File

@@ -0,0 +1,110 @@
import ts from "typescript"
import Formatter from "../helpers/formatter.js"
import KindsRegistry from "../kinds/registry.js"
import GeneratorEventManager from "../helpers/generator-event-manager.js"
import { CommonCliOptions } from "../../types/index.js"
import { existsSync, readdirSync, statSync } from "node:fs"
import path from "node:path"
export type Options = {
paths: string[]
dryRun?: boolean
} & Pick<CommonCliOptions, "generateExamples">
abstract class AbstractGenerator {
protected options: Options
protected program?: ts.Program
protected checker?: ts.TypeChecker
protected formatter: Formatter
protected kindsRegistry?: KindsRegistry
protected generatorEventManager: GeneratorEventManager
constructor(options: Options) {
this.options = options
this.formatter = new Formatter()
this.generatorEventManager = new GeneratorEventManager()
}
init() {
const files: string[] = []
this.options.paths.forEach((optionPath) => {
if (!existsSync(optionPath)) {
return
}
if (!statSync(optionPath).isDirectory()) {
files.push(optionPath)
return
}
// read files recursively from directory
files.push(
...readdirSync(optionPath, {
recursive: true,
encoding: "utf-8",
})
.map((filePath) => path.join(optionPath, filePath))
.filter((filePath) => !statSync(filePath).isDirectory())
)
})
this.program = ts.createProgram(files, {})
this.checker = this.program.getTypeChecker()
const { generateExamples } = this.options
this.kindsRegistry = new KindsRegistry({
checker: this.checker,
generatorEventManager: this.generatorEventManager,
additionalOptions: {
generateExamples,
},
})
}
/**
* Generate the docblock for the paths specified in the {@link options} class property.
*/
abstract run(): void
/**
* Checks whether a file is included in the specified files.
*
* @param {string} fileName - The file to check for.
* @returns {boolean} Whether the file can have docblocks generated for it.
*/
isFileIncluded(fileName: string): boolean {
const baseFilePath = this.getBasePath(fileName)
return this.options.paths.some((path) =>
baseFilePath.startsWith(this.getBasePath(path))
)
}
/**
* Retrieve the pathname of a file without the relative part before `packages/`
*
* @param fileName - The file name/path
* @returns The path without the relative part.
*/
getBasePath(fileName: string) {
let basePath = fileName
const packageIndex = fileName.indexOf("packages/")
if (packageIndex) {
basePath = basePath.substring(packageIndex)
}
return basePath
}
/**
* Reset the generator's properties for new usage.
*/
reset() {
this.program = undefined
this.checker = undefined
}
}
export default AbstractGenerator

View File

@@ -0,0 +1,76 @@
import { minimatch } from "minimatch"
import AbstractGenerator from "./index.js"
import ts from "typescript"
import OasKindGenerator from "../kinds/oas.js"
import { GeneratorEvent } from "../helpers/generator-event-manager.js"
/**
* A class used to generate OAS yaml comments. The comments are written
* in different files than the specified files.
*/
class OasGenerator extends AbstractGenerator {
protected oasKindGenerator?: OasKindGenerator
run() {
this.init()
const { generateExamples } = this.options
this.oasKindGenerator = new OasKindGenerator({
checker: this.checker!,
generatorEventManager: this.generatorEventManager,
additionalOptions: {
generateExamples,
},
})
this.program!.getSourceFiles().map((file) => {
// Ignore .d.ts files
if (file.isDeclarationFile || !this.isFileIncluded(file.fileName)) {
return
}
console.log(`[OAS] Generating for ${file.fileName}...`)
const documentChild = (node: ts.Node) => {
if (
this.oasKindGenerator!.isAllowed(node) &&
this.oasKindGenerator!.canDocumentNode(node)
) {
const oas = this.oasKindGenerator!.getDocBlock(node)
if (!this.options.dryRun) {
const filename = this.oasKindGenerator!.getAssociatedFileName(node)
ts.sys.writeFile(
filename,
this.formatter.addCommentsToSourceFile(oas, "")
)
}
}
}
ts.forEachChild(file, documentChild)
this.generatorEventManager.emit(GeneratorEvent.FINISHED_GENERATE_EVENT)
console.log(`[OAS] Finished generating OAS for ${file.fileName}.`)
})
}
/**
* Checks whether the specified file path is included in the program
* and is an API file.
*
* @param fileName - The file path to check
* @returns Whether the OAS generator can run on this file.
*/
isFileIncluded(fileName: string): boolean {
return (
super.isFileIncluded(fileName) &&
minimatch(this.getBasePath(fileName), "packages/medusa/**/api**/**", {
matchBase: true,
})
)
}
}
export default OasGenerator

View File

@@ -1,10 +1,11 @@
import getMonorepoRoot from "../utils/get-monorepo-root.js"
import getMonorepoRoot from "../../utils/get-monorepo-root.js"
import { ESLint, Linter } from "eslint"
import path from "path"
import dirname from "../utils/dirname.js"
import dirname from "../../utils/dirname.js"
import { minimatch } from "minimatch"
import { existsSync } from "fs"
import getRelativePaths from "../utils/get-relative-paths.js"
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.
@@ -70,10 +71,10 @@ class Formatter {
)
newConfig.parserOptions.project = [
existsSync(tsConfigSpecPath)
? tsConfigSpecPath
: existsSync(tsConfigPath)
? tsConfigPath
existsSync(tsConfigPath)
? tsConfigPath
: existsSync(tsConfigSpecPath)
? tsConfigSpecPath
: [
...getRelativePaths(
newConfig.parserOptions.project || [],
@@ -170,6 +171,10 @@ class Formatter {
content: string,
fileName: string
): Promise<string> {
const prettifiedContent = await this.formatStrWithPrettier(
content,
fileName
)
const relevantConfig = await this.getESLintOverridesConfigForFile(fileName)
const eslint = new ESLint({
@@ -183,8 +188,8 @@ class Formatter {
ignore: false,
})
let newContent = content
const result = await eslint.lintText(content, {
let newContent = prettifiedContent
const result = await eslint.lintText(prettifiedContent, {
filePath: fileName,
})
@@ -195,6 +200,27 @@ class Formatter {
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.
*

View File

@@ -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

View File

@@ -1,10 +1,12 @@
import ts from "typescript"
import { DOCBLOCK_DOUBLE_LINES, DOCBLOCK_NEW_LINE } from "../constants.js"
import { DOCBLOCK_DOUBLE_LINES, DOCBLOCK_NEW_LINE } from "../../constants.js"
import {
camelToTitle,
camelToWords,
normalizeName,
} from "../utils/str-formatting.js"
snakeToWords,
} from "../../utils/str-formatting.js"
import pluralize from "pluralize"
type TemplateOptions = {
parentName?: string
@@ -16,7 +18,10 @@ type KnowledgeBase = {
startsWith?: string
endsWith?: string
exact?: string
template: string | ((str: string, options?: TemplateOptions) => string)
pattern?: RegExp
template:
| string
| ((str: string, options?: TemplateOptions) => string | undefined)
kind?: ts.SyntaxKind[]
}
@@ -213,6 +218,27 @@ class KnowledgeBaseFactory {
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.
@@ -235,6 +261,10 @@ class KnowledgeBaseFactory {
return str === item.exact
}
if (item.pattern) {
return item.pattern.test(str)
}
if (item.kind?.length && (!kind || !item.kind.includes(kind))) {
return false
}
@@ -320,6 +350,23 @@ class KnowledgeBaseFactory {
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

View File

@@ -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

View File

@@ -8,7 +8,7 @@ import {
import getSymbol from "../../utils/get-symbol.js"
import KnowledgeBaseFactory, {
RetrieveOptions,
} from "../knowledge-base-factory.js"
} from "../helpers/knowledge-base-factory.js"
import {
getCustomNamespaceTag,
shouldHaveCustomNamespace,
@@ -18,10 +18,14 @@ import {
capitalize,
normalizeName,
} from "../../utils/str-formatting.js"
import GeneratorEventManager from "../helpers/generator-event-manager.js"
import { CommonCliOptions } from "../../types/index.js"
export type GeneratorOptions = {
checker: ts.TypeChecker
kinds?: ts.SyntaxKind[]
generatorEventManager: GeneratorEventManager
additionalOptions: Pick<CommonCliOptions, "generateExamples">
}
export type GetDocBlockOptions = {
@@ -54,11 +58,20 @@ class DefaultKindGenerator<T extends ts.Node = ts.Node> {
protected checker: ts.TypeChecker
protected defaultSummary = "{summary}"
protected knowledgeBaseFactory: KnowledgeBaseFactory
protected generatorEventManager: GeneratorEventManager
protected options: Pick<CommonCliOptions, "generateExamples">
constructor({ checker, kinds }: GeneratorOptions) {
constructor({
checker,
kinds,
generatorEventManager,
additionalOptions,
}: GeneratorOptions) {
this.allowedKinds = kinds || DefaultKindGenerator.DEFAULT_ALLOWED_NODE_KINDS
this.checker = checker
this.knowledgeBaseFactory = new KnowledgeBaseFactory()
this.generatorEventManager = generatorEventManager
this.options = additionalOptions
}
/**
@@ -136,7 +149,11 @@ class DefaultKindGenerator<T extends ts.Node = ts.Node> {
*/
nodeType?: ts.Type
}): string {
const knowledgeBaseOptions = this.getKnowledgeOptions(node)
const syntheticComments = ts.getSyntheticLeadingComments(node)
if (syntheticComments?.length) {
return syntheticComments.map((comment) => comment.text).join(" ")
}
const knowledgeBaseOptions = this.getKnowledgeBaseOptions(node)
if (!nodeType) {
nodeType =
"type" in node && node.type && ts.isTypeNode(node.type as ts.Node)
@@ -170,7 +187,7 @@ class DefaultKindGenerator<T extends ts.Node = ts.Node> {
* @param {ts.Type} nodeType - The type of a node.
* @returns {string} The summary comment.
*/
private getTypeDocBlock(
protected getTypeDocBlock(
nodeType: ts.Type,
knowledgeBaseOptions?: Partial<RetrieveOptions>
): string {
@@ -226,7 +243,7 @@ class DefaultKindGenerator<T extends ts.Node = ts.Node> {
* @param {ts.Symbol} symbol - The symbol to retrieve its docblock.
* @returns {string} The symbol's docblock.
*/
private getSymbolDocBlock(
protected getSymbolDocBlock(
symbol: ts.Symbol,
knowledgeBaseOptions?: Partial<RetrieveOptions>
): string {
@@ -395,26 +412,9 @@ class DefaultKindGenerator<T extends ts.Node = ts.Node> {
}
// check for default value
if (
"initializer" in node &&
node.initializer &&
ts.isExpression(node.initializer as ts.Node)
) {
const initializer = node.initializer as ts.Expression
// retrieve default value only if the value is numeric, string, or boolean
const defaultValue =
ts.isNumericLiteral(initializer) || ts.isStringLiteral(initializer)
? initializer.getText()
: initializer.kind === ts.SyntaxKind.FalseKeyword
? "false"
: initializer.kind === ts.SyntaxKind.TrueKeyword
? "true"
: ""
if (defaultValue.length) {
tags.add(`@defaultValue ${defaultValue}`)
}
const defaultValue = this.getDefaultValue(node)
if (defaultValue?.length) {
tags.add(`@defaultValue ${defaultValue}`)
}
let str = ""
@@ -481,7 +481,13 @@ class DefaultKindGenerator<T extends ts.Node = ts.Node> {
)
}
getKnowledgeOptions(node: ts.Node): Partial<RetrieveOptions> {
/**
* Get knowledge base options for a specified node.
*
* @param node - The node to retrieve its knowledge base options.
* @returns The knowledge base options.
*/
getKnowledgeBaseOptions(node: ts.Node): Partial<RetrieveOptions> {
const rawParentName =
"name" in node.parent &&
node.parent.name &&
@@ -498,6 +504,88 @@ class DefaultKindGenerator<T extends ts.Node = ts.Node> {
},
}
}
/**
* Get the default value of a node.
*
* @param node - The node to get its default value.
* @returns The default value, if any.
*/
getDefaultValue(node: ts.Node): string | undefined {
if (
"initializer" in node &&
node.initializer &&
ts.isExpression(node.initializer as ts.Node)
) {
const initializer = node.initializer as ts.Expression
// retrieve default value only if the value is numeric, string, or boolean
const defaultValue =
ts.isNumericLiteral(initializer) || ts.isStringLiteral(initializer)
? initializer.getText()
: initializer.kind === ts.SyntaxKind.FalseKeyword
? "false"
: initializer.kind === ts.SyntaxKind.TrueKeyword
? "true"
: ""
if (defaultValue.length) {
return defaultValue
}
}
}
/**
* Checks whether a node can be documented.
*
* @param {ts.Node} node - The node to check for.
* @returns {boolean} Whether the node can be documented.
*/
canDocumentNode(node: ts.Node): boolean {
// check if node already has docblock
return !this.nodeHasComments(node)
}
/**
* Get the comments range of a node.
* @param node - The node to get its comment range.
* @returns The comment range of the node if available.
*/
getNodeCommentsRange(node: ts.Node): ts.CommentRange[] | undefined {
return ts.getLeadingCommentRanges(
node.getSourceFile().getFullText(),
node.getFullStart()
)
}
/**
* Get a node's comment from its range.
*
* @param node - The node to get its comment range.
* @returns The comment if available.
*/
getNodeCommentsFromRange(node: ts.Node): string | undefined {
const commentRange = this.getNodeCommentsRange(node)
if (!commentRange?.length) {
return
}
return node
.getSourceFile()
.getFullText()
.slice(commentRange[0].pos, commentRange[0].end)
}
/**
* Check whether a node has comments.
*
* @param node - The node to check.
* @returns Whether the node has comments.
*/
nodeHasComments(node: ts.Node): boolean {
return this.getNodeCommentsFromRange(node) !== undefined
}
}
export default DefaultKindGenerator

View File

@@ -14,7 +14,7 @@ export type FunctionNode =
| ts.FunctionDeclaration
| ts.ArrowFunction
type VariableNode = ts.VariableDeclaration | ts.VariableStatement
export type VariableNode = ts.VariableDeclaration | ts.VariableStatement
export type FunctionOrVariableNode = FunctionNode | ts.VariableStatement

View File

@@ -9,7 +9,6 @@ import {
DOCBLOCK_START,
DOCBLOCK_DOUBLE_LINES,
} from "../../constants.js"
import nodeHasComments from "../../utils/node-has-comments.js"
import {
CUSTOM_NAMESPACE_TAG,
getCustomNamespaceTag,
@@ -152,7 +151,7 @@ class MedusaReactHooksKindGenerator extends FunctionKindGenerator {
return (
!parameterTypeStr?.startsWith("UseQueryOptionsWrapper") &&
!parameterTypeStr?.startsWith("UseMutationOptions") &&
!nodeHasComments(parameter)
!this.nodeHasComments(parameter)
)
})
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,10 @@
import ts from "typescript"
import FunctionKindGenerator from "./function.js"
import DefaultKindGenerator from "./default.js"
import DefaultKindGenerator, { GeneratorOptions } from "./default.js"
import MedusaReactHooksKindGenerator from "./medusa-react-hooks.js"
import SourceFileKindGenerator from "./source-file.js"
import DTOPropertyGenerator from "./dto-property.js"
import OasKindGenerator from "./oas.js"
/**
* A class that is used as a registry for the kind generators.
@@ -12,14 +13,20 @@ class KindsRegistry {
protected kindInstances: DefaultKindGenerator[]
protected defaultKindGenerator: DefaultKindGenerator
constructor(checker: ts.TypeChecker) {
constructor(
options: Pick<
GeneratorOptions,
"checker" | "generatorEventManager" | "additionalOptions"
>
) {
this.kindInstances = [
new MedusaReactHooksKindGenerator({ checker }),
new FunctionKindGenerator({ checker }),
new SourceFileKindGenerator({ checker }),
new DTOPropertyGenerator({ checker }),
new OasKindGenerator(options),
new MedusaReactHooksKindGenerator(options),
new FunctionKindGenerator(options),
new SourceFileKindGenerator(options),
new DTOPropertyGenerator(options),
]
this.defaultKindGenerator = new DefaultKindGenerator({ checker })
this.defaultKindGenerator = new DefaultKindGenerator(options)
}
/**