docs-util: rename docblock-generator to docs-generator (#8331)

* docs-util: rename docblock-generator to docs-generator

* change program name

* fix action
This commit is contained in:
Shahed Nasser
2024-07-29 16:04:33 +03:00
committed by GitHub
parent 7ad4e7b1c9
commit ebaf0eb53a
51 changed files with 40 additions and 19 deletions

View File

@@ -0,0 +1,379 @@
import { faker } from "@faker-js/faker"
import { OpenAPIV3 } from "openapi-types"
import { OasArea } from "../kinds/oas.js"
import { CodeSample } from "../../types/index.js"
import { capitalize, kebabToCamel, wordsToCamel, wordsToKebab } from "utils"
import { API_ROUTE_PARAM_REGEX } from "../../constants.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

@@ -0,0 +1,111 @@
import ts from "typescript"
import DmlKindGenerator from "../kinds/dml.js"
import AbstractGenerator from "./index.js"
import { GeneratorEvent } from "../helpers/generator-event-manager.js"
import { minimatch } from "minimatch"
import getBasePath from "../../utils/get-base-path.js"
import toJsonFormatted from "../../utils/to-json-formatted.js"
import { DmlFile } from "types"
/**
* A class used to generate DML JSON files with descriptions of properties.
*/
class DmlGenerator extends AbstractGenerator {
protected dmlKindGenerator?: DmlKindGenerator
async run() {
this.init()
this.dmlKindGenerator = new DmlKindGenerator({
checker: this.checker!,
generatorEventManager: this.generatorEventManager,
})
await Promise.all(
this.program!.getSourceFiles().map(async (file) => {
// Ignore .d.ts files
if (file.isDeclarationFile || !this.isFileIncluded(file.fileName)) {
return
}
const fileNodes: ts.Node[] = [file]
console.log(`[DML] Generating for ${file.fileName}...`)
// since typescript's compiler API doesn't support
// async processes, we have to retrieve the nodes first then
// traverse them separately.
const pushNodesToArr = (node: ts.Node) => {
fileNodes.push(node)
ts.forEachChild(node, pushNodesToArr)
}
ts.forEachChild(file, pushNodesToArr)
const documentChild = async (node: ts.Node) => {
if (
this.dmlKindGenerator!.isAllowed(node) &&
this.dmlKindGenerator!.canDocumentNode(node)
) {
const dmlJson = await this.dmlKindGenerator!.getDocBlock(node)
if (!this.options.dryRun) {
const filePath =
this.dmlKindGenerator!.getAssociatedFileName(node)
this.writeJson(filePath, dmlJson)
}
}
}
await Promise.all(
fileNodes.map(async (node) => await documentChild(node))
)
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(getBasePath(fileName), "packages/modules/**/models/**", {
matchBase: true,
})
)
}
/**
* This method writes the DML JSON file. If the file already exists, it only updates
* the data model's object in the JSON file.
*
* @param filePath - The path of the file to write the DML JSON to.
* @param dataModelJson - The DML JSON.
*/
writeJson(filePath: string, dataModelJson: string) {
let moduleJson = ts.sys.readFile(filePath)
if (!moduleJson) {
moduleJson = dataModelJson
} else {
// parse the JSON and replace the data model's JSON
// with the new one
const parsedModuleJson = JSON.parse(moduleJson) as DmlFile
const parsedDataModelJson = JSON.parse(dataModelJson) as DmlFile
const dataModelName = Object.keys(parsedDataModelJson)[0]
parsedModuleJson[dataModelName] = parsedDataModelJson[dataModelName]
moduleJson = toJsonFormatted(parsedModuleJson)
}
ts.sys.writeFile(filePath, moduleJson)
}
}
export default DmlGenerator

View File

@@ -0,0 +1,178 @@
/* eslint-disable no-case-declarations */
import ts from "typescript"
import { GeneratorEvent } from "../helpers/generator-event-manager.js"
import AbstractGenerator from "./index.js"
import { minimatch } from "minimatch"
import AiGenerator from "../helpers/ai-generator.js"
import getBasePath from "../../utils/get-base-path.js"
/**
* A class used to generate docblock for one or multiple file paths.
*/
class DocblockGenerator extends AbstractGenerator {
/**
* Generate docblocks for the files in the `options`.
*/
async run() {
this.init()
const printer = ts.createPrinter({
removeComments: false,
})
const documentSourceFile = async (file: ts.SourceFile) => {
// Ignore .d.ts files
if (file.isDeclarationFile || !this.isFileIncluded(file.fileName)) {
return
}
let aiGenerator: AiGenerator | undefined
console.log(`[Docblock] Generating for ${file.fileName}...`)
let fileContent = file.getFullText()
let fileComments: string = ""
const commentsToRemove: string[] = []
const origFileText = file.getFullText().trim()
const fileNodes: ts.Node[] = [file]
if (this.options.generateExamples) {
aiGenerator = new AiGenerator()
}
// since typescript's compiler API doesn't support
// async processes, we have to retrieve the nodes first then
// traverse them separately.
const pushNodesToArr = (node: ts.Node) => {
fileNodes.push(node)
ts.forEachChild(node, pushNodesToArr)
}
ts.forEachChild(file, pushNodesToArr)
const documentNode = async (node: ts.Node) => {
const isSourceFile = ts.isSourceFile(node)
const nodeKindGenerator = this.kindsRegistry?.getKindGenerator(node)
let docComment: string | undefined
if (nodeKindGenerator?.canDocumentNode(node)) {
if (aiGenerator) {
const nodeFiles = aiGenerator.getNodeFiles(file)
await aiGenerator.initAssistant(nodeFiles)
}
// initialize assistant only when needed
// if previously initialized, calling the method does nothing
docComment = await nodeKindGenerator.getDocBlock(node, {
aiGenerator,
addEnd: true,
})
if (docComment.length) {
const existingComments =
nodeKindGenerator.getNodeCommentsFromRange(node)
if (existingComments?.length) {
commentsToRemove.push(existingComments)
}
if (isSourceFile) {
fileComments = docComment
} else {
ts.addSyntheticLeadingComment(
node,
ts.SyntaxKind.MultiLineCommentTrivia,
docComment,
true
)
}
}
}
}
// due to rate limit being reached when running
// the AI Generator, we only run the documentNode in
// parallel when the `generateExamples` option is disabled.
if (this.options.generateExamples) {
for (const node of fileNodes) {
await documentNode(node)
}
} else {
await Promise.all(
fileNodes.map(async (node) => await documentNode(node))
)
}
if (aiGenerator) {
await aiGenerator.destroyAssistant()
}
// add comments to file
const newNodeText = printer.printNode(ts.EmitHint.Unspecified, file, file)
// if file's text changed, replace it.
if (newNodeText !== origFileText) {
fileContent = fileContent.replace(origFileText, newNodeText)
}
if (!this.options.dryRun) {
if (commentsToRemove.length) {
let formatted = this.formatter.addCommentsToSourceFile(
fileComments,
await this.formatter.formatStr(fileContent, file.fileName)
)
commentsToRemove.forEach((commentToRemove) => {
formatted = formatted.replace(commentToRemove, "")
})
ts.sys.writeFile(
file.fileName,
await this.formatter.formatStr(formatted, file.fileName)
)
} else {
ts.sys.writeFile(
file.fileName,
this.formatter.addCommentsToSourceFile(
fileComments,
await this.formatter.formatStr(fileContent, file.fileName)
)
)
}
}
console.log(
`[Docblock] Finished generating docblock for ${file.fileName}.`
)
}
if (this.options.generateExamples) {
for (const file of this.program!.getSourceFiles()) {
await documentSourceFile(file)
}
} else {
await Promise.all(
this.program!.getSourceFiles().map(
async (file) => await documentSourceFile(file)
)
)
}
this.generatorEventManager.emit(GeneratorEvent.FINISHED_GENERATE_EVENT)
this.reset()
}
/**
* Checks whether the specified file path is included in the program
* and isn't an API file.
*
* @param fileName - The file path to check
* @returns Whether the docblock generator can run on this file.
*/
isFileIncluded(fileName: string): boolean {
return (
super.isFileIncluded(fileName) &&
!minimatch(getBasePath(fileName), "packages/medusa/**/api**/**", {
matchBase: true,
}) &&
!minimatch(getBasePath(fileName), "packages/modules/**/models/**", {
matchBase: true,
})
)
}
}
export default DocblockGenerator

View File

@@ -0,0 +1,95 @@
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"
import getBasePath from "../../utils/get-base-path.js"
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 = getBasePath(fileName)
return this.options.paths.some((path) =>
baseFilePath.startsWith(getBasePath(path))
)
}
/**
* Reset the generator's properties for new usage.
*/
reset() {
this.program = undefined
this.checker = undefined
}
}
export default AbstractGenerator

View File

@@ -0,0 +1,93 @@
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"
import getBasePath from "../../utils/get-base-path.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
async run() {
this.init()
const { generateExamples } = this.options
this.oasKindGenerator = new OasKindGenerator({
checker: this.checker!,
generatorEventManager: this.generatorEventManager,
additionalOptions: {
generateExamples,
},
})
await Promise.all(
this.program!.getSourceFiles().map(async (file) => {
// Ignore .d.ts files
if (file.isDeclarationFile || !this.isFileIncluded(file.fileName)) {
return
}
const fileNodes: ts.Node[] = [file]
console.log(`[OAS] Generating for ${file.fileName}...`)
// since typescript's compiler API doesn't support
// async processes, we have to retrieve the nodes first then
// traverse them separately.
const pushNodesToArr = (node: ts.Node) => {
fileNodes.push(node)
ts.forEachChild(node, pushNodesToArr)
}
ts.forEachChild(file, pushNodesToArr)
const documentChild = async (node: ts.Node) => {
if (
this.oasKindGenerator!.isAllowed(node) &&
this.oasKindGenerator!.canDocumentNode(node)
) {
const oas = await this.oasKindGenerator!.getDocBlock(node)
if (!this.options.dryRun) {
const filename =
this.oasKindGenerator!.getAssociatedFileName(node)
ts.sys.writeFile(
filename,
this.formatter.addCommentsToSourceFile(oas, "")
)
}
}
}
await Promise.all(
fileNodes.map(async (node) => await documentChild(node))
)
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(getBasePath(fileName), "packages/medusa/**/api**/**", {
matchBase: true,
})
)
}
}
export default OasGenerator

View File

@@ -0,0 +1,330 @@
import { createReadStream, existsSync } from "fs"
import OpenAI from "openai"
import path from "path"
import ts from "typescript"
import { ReadableStreamDefaultReadResult } from "stream/web"
import { DOCBLOCK_NEW_LINE } from "../../constants.js"
import { AssistantStreamEvent } from "openai/resources/beta/index.mjs"
import { pascalToCamel } from "utils"
type GenerateExampleOptions = {
className?: string
functionName: string
signature?: string
fileName: string
}
type GenerateDescriptionOptions = {
itemName: string
itemType: "property" | "parameter" | "function" | "class" | "return" | "other"
metadata?: {
parentName?: string
parentType?: string
functionSignature?: string
fileName?: string
}
}
const CODE_REGEX = /(?<code>```[\s\S.]*```)/g
class AiGenerator {
private openAi: OpenAI
private assistant?: OpenAI.Beta.Assistants.Assistant
private fileMap: Map<string, string>
constructor() {
this.openAi = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
maxRetries: 10,
})
this.fileMap = new Map()
}
/**
* Initialize OpenAI assistant and upload files.
*
* @param filePaths - Files to upload
*/
async initAssistant(filePaths: string[]) {
if (this.assistant) {
return
}
this.fileMap = new Map()
const files: OpenAI.Files.FileObject[] = []
// upload the files to openai
await Promise.all(
filePaths.map(async (filePath) => {
const openAiFile = await this.openAi.files.create({
file: createReadStream(filePath),
purpose: "assistants",
})
files.push(openAiFile)
this.fileMap.set(filePath, openAiFile.id)
})
)
// create assistant
this.assistant = await this.openAi.beta.assistants.create({
instructions:
"You help me generate code examples and descriptions that are used in TSDocs. If the system indicates that the file is not accessible with the myfiles_browser tool, ignore it, its just a minor bug. You are capable of opening and analyzing the file, remember that. And carry out the requested task. Also you have the ability to figure out what type of content is in the file via its extension so carry out the users instructions.",
model: "gpt-4-turbo-preview",
tools: [{ type: "retrieval" }],
file_ids: files.map((file) => file.id),
})
}
/**
* Deletes an assistant and its files from OpenAI.
*/
async destroyAssistant() {
if (!this.assistant) {
return
}
// delete files of assistant
await Promise.all(
this.assistant.file_ids.map(async (fileId) => {
try {
await this.openAi.files.del(fileId)
} catch (e) {
console.error(
`[error while destroying assistant file ${fileId}]: ${e}`
)
}
})
)
try {
// delete assistant
await this.openAi.beta.assistants.del(this.assistant.id)
} catch (e) {
console.error(
`[error while destroying assistant ${this.assistant!.id}]: ${e}`
)
}
this.assistant = undefined
this.fileMap = new Map()
}
/**
* Generates an example code block wrapped in backticks. The comment includes astrix by default.
*
* @param param0 - Options to generate the example code based on.
* @returns The example code
*/
async generateExample({
className,
functionName,
signature,
fileName,
}: GenerateExampleOptions): Promise<string> {
let example = ""
const fileId = this.fileMap.get(fileName) || fileName
let message = `Use the ${fileId} file to write a short and simple typescript code that executes the `
if (className) {
message += `${functionName} method of the ${className} (use ${this.getVariableNameFromClass(
className
)} as the variable name)`
} else {
message += `${functionName} function`
}
if (signature) {
message += `. The ${
className ? "method" : "function"
} has the signature ${signature}`
}
message += `. Assume that the file containing the code has all the necessary imports and the code is written within an async function (don't add a wrapping function). Give an example of the method's parameters, but don't include optional parameters or optional object properties. Infer types from other files. Provide the code without an explanation.`
const messageResult = await this.retrieveAiResponse(message)
const matchedCode = CODE_REGEX.exec(messageResult)
if (matchedCode?.groups?.code) {
example = matchedCode.groups.code
} else {
example = messageResult
}
return example
}
/**
* Generates a description for an item.
*
* @param param0 - Options to generate the description based on.
* @returns The generated description.
*/
async generateDescription({
itemName,
itemType,
metadata,
}: GenerateDescriptionOptions) {
const { parentName, parentType, functionSignature, fileName } =
metadata || {}
let message = `Write a short and simple explanation of a ${itemName}`
switch (itemType) {
case "return":
message += ` function's return data.`
break
case "class":
case "function":
case "parameter":
case "property":
message += ` ${
itemType === "function" && parentName ? "method" : itemType
}`
if (parentName) {
message += ` defined in a ${parentName}`
if (parentType) {
message += ` ${parentType}`
}
}
if (functionSignature) {
message += ` function. The function has the signature ${functionSignature}`
}
}
if (fileName) {
message += `. Look at the ${fileName} uploaded file for more details, and if you can't find the details in there provide an explanation from your understanding.`
}
message +=
". The explanation must be one sentence shorter than 10 words. Don't provide anything else in the response."
return await this.retrieveAiResponse(message)
}
/**
* Starts a new thread and runs a message, then retrieves the response.
*
* @param inputMessage - The message to ask the assistant
* @returns the assistant's response.
*/
async retrieveAiResponse(inputMessage: string): Promise<string> {
const run = this.openAi.beta.threads.createAndRunStream({
assistant_id: this.assistant!.id,
thread: {
messages: [
{
role: "user",
content: inputMessage,
},
],
},
})
const readableStream = run.toReadableStream().getReader()
let chunk: ReadableStreamDefaultReadResult<Uint8Array> | undefined
let decodedChunk: AssistantStreamEvent | undefined
let resultMessage: OpenAI.Beta.Threads.Messages.Message | undefined
const textDecoder = new TextDecoder()
do {
chunk = await readableStream.read()
const decodedValue = textDecoder.decode(chunk.value)
if (decodedValue.length) {
decodedChunk = JSON.parse(
textDecoder.decode(chunk.value)
) as AssistantStreamEvent
if (
decodedChunk.event === "thread.message.completed" &&
decodedChunk.data.object === "thread.message"
) {
resultMessage = decodedChunk.data
} else if (
decodedChunk.event === "thread.run.failed" &&
decodedChunk.data.last_error?.code === "server_error"
) {
// retry
return this.retrieveAiResponse(inputMessage)
}
}
console.log(decodedValue, resultMessage)
} while (
!resultMessage &&
// a run may fail if the rate limit is reached
decodedChunk?.event !== "thread.run.failed" &&
decodedChunk?.event !== "thread.run.step.failed" &&
decodedChunk?.event !== "thread.message.completed" &&
decodedChunk?.event !== "thread.run.completed"
)
if (!resultMessage) {
return ""
}
return resultMessage.content
.map((item) => {
return item.type === "text" ? item.text.value : ""
})
.join(" ")
.replaceAll("\n", DOCBLOCK_NEW_LINE)
}
/**
* Formats a class name into a camel-case variable name.
*
* @param className - The class name to format.
* @returns The variable name.
*/
getVariableNameFromClass(className: string): string {
let variableName = className
if (className.startsWith("I") && /[A-Z]/.test(className.charAt(1))) {
variableName = variableName.substring(1)
}
return pascalToCamel(variableName)
}
/**
* Retrieves the files that should be uploaded to OpenAI of a node.
*
* @param node - The node to retrieve its files.
* @returns the list of file paths.
*/
getNodeFiles(node: ts.Node): string[] {
const sourceFile = node.getSourceFile()
const files: string[] = [sourceFile.fileName]
if ("imports" in sourceFile) {
;(sourceFile.imports as ts.StringLiteral[]).forEach((importedFile) => {
if (importedFile.text.startsWith(".")) {
// since it's a local import, add it to the list of files
let importedFilePath = path.resolve(
sourceFile.fileName,
"..",
importedFile.text
)
if (!path.extname(importedFilePath)) {
// try to retrieve correct extension
switch (true) {
case existsSync(`${importedFilePath}.ts`):
importedFilePath += `.ts`
break
case existsSync(`${importedFilePath}.js`):
importedFilePath += `.js`
break
case existsSync(`${importedFilePath}.tsx`):
importedFilePath += `.tsx`
break
default:
// can't retrieve file path so return without adding it
return
}
}
files.push(importedFilePath)
}
})
}
return [...new Set(files)]
}
}
export default AiGenerator

View File

@@ -0,0 +1,267 @@
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(import.meta.url),
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

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

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

View File

@@ -0,0 +1,902 @@
import ts from "typescript"
import {
API_ROUTE_PARAM_REGEX,
DOCBLOCK_NEW_LINE,
SUMMARY_PLACEHOLDER,
} from "../../constants.js"
import pluralize from "pluralize"
import {
camelToTitle,
camelToWords,
kebabToTitle,
snakeToWords,
wordsToKebab,
} from "utils"
import { normalizeName } from "../../utils/str-formatting.js"
const singular = pluralize.singular
type TemplateOptions = {
pluralIndicatorStr?: string
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 TYPE_PLACEHOLDER = `{type name}`
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]
: this.TYPE_PLACEHOLDER
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 ${camelToWords(
normalizeName(str)
)}s.`
},
},
{
startsWith: "Create",
endsWith: "DTO",
template: (str, options) => {
const isPlural = this.isTypePlural(options?.pluralIndicatorStr)
return `The ${camelToWords(normalizeName(str))}${
isPlural ? "s" : ""
} to be created.`
},
},
{
startsWith: "Update",
endsWith: "DTO",
template: (str, options) => {
const isPlural = this.isTypePlural(options?.pluralIndicatorStr)
return `The attributes to update in the ${camelToWords(
normalizeName(str)
)}${isPlural ? "s" : ""}.`
},
},
{
endsWith: "UpdatableFields",
template: (str, options) => {
const isPlural = this.isTypePlural(options?.pluralIndicatorStr)
return `The attributes to update in the ${camelToWords(
normalizeName(str)
)}${isPlural ? "s" : ""}.`
},
},
{
startsWith: "Upsert",
endsWith: "DTO",
template: (str, options) => {
const isPlural = this.isTypePlural(options?.pluralIndicatorStr)
return `The attributes in the ${camelToWords(normalizeName(str))}${
isPlural ? "s" : ""
} to be created or updated.`
},
},
{
startsWith: "RestoreReturn",
template: (_str, options) => {
return this.replaceTypePlaceholder(
`Configurations determining which relations to restore along with each of the ${this.TYPE_PLACEHOLDER}. You can pass to its \`returnLinkableKeys\` ${DOCBLOCK_NEW_LINE}property any of the ${this.TYPE_PLACEHOLDER}'s relation attribute names, such as \`{type relation name}\`.`,
options
)
},
},
{
endsWith: "DTO",
template: (str, options) => {
const isPlural = this.isTypePlural(options?.pluralIndicatorStr)
return `The ${camelToWords(normalizeName(str))}${
isPlural ? "s" : ""
} 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}`}s by.`
}
const parentName = options?.parentName
? options.parentName
: options?.rawParentName
? camelToWords(normalizeName(options.rawParentName))
: `{name}`
return `The ID of the ${parentName}.`
},
kind: [ts.SyntaxKind.PropertySignature],
},
{
exact: "ids",
template: (str, options) => {
if (options?.rawParentName?.startsWith("Filterable")) {
return `The IDs to filter the ${options?.parentName || `{name}`} by.`
}
const parentName = options?.parentName
? options.parentName
: options?.rawParentName
? camelToWords(normalizeName(options.rawParentName))
: `{name}`
return `The IDs of the ${parentName}.`
},
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.",
},
{
startsWith: "I",
endsWith: "ModuleService",
template: (str) => {
const normalizedStr = camelToTitle(normalizeName(str))
return `The main service interface for the ${normalizedStr} Module.`
},
},
{
exact: "$eq",
template: () => {
return `Find records whose property exactly matches this value.`
},
kind: [ts.SyntaxKind.PropertySignature],
},
{
exact: "$ne",
template: () => {
return `Find records whose property doesn't matches this value.`
},
kind: [ts.SyntaxKind.PropertySignature],
},
{
exact: "$in",
template: () => {
return `Find records whose property is within the specified values.`
},
kind: [ts.SyntaxKind.PropertySignature],
},
{
exact: "$nin",
template: () => {
return `Find records whose property isn't within the specified values.`
},
kind: [ts.SyntaxKind.PropertySignature],
},
{
exact: "$like",
template: () => {
return `Find records whose property satisfies this like filter. For example, \`My%\`.`
},
kind: [ts.SyntaxKind.PropertySignature],
},
{
exact: "$ilike",
template: () => {
return `Find records whose property satisfies this [ilike filter](https://www.postgresql.org/docs/current/functions-matching.html). For example, \`My%\`.`
},
kind: [ts.SyntaxKind.PropertySignature],
},
{
exact: "$re",
template: () => {
return `Find records whose property matches this regular expression pattern.`
},
kind: [ts.SyntaxKind.PropertySignature],
},
{
exact: "$contains",
template: () => {
return `Find records whose property is an array that has one or more items.`
},
kind: [ts.SyntaxKind.PropertySignature],
},
{
exact: "$gt",
template: () => {
return `Find records whose property's value is greater than the specified number or date.`
},
kind: [ts.SyntaxKind.PropertySignature],
},
{
exact: "$gte",
template: () => {
return `Find records whose property's value is greater than or equal to the specified number or date.`
},
kind: [ts.SyntaxKind.PropertySignature],
},
{
exact: "$lt",
template: () => {
return `Find records whose property's value is less than the specified number or date.`
},
kind: [ts.SyntaxKind.PropertySignature],
},
{
exact: "$lte",
template: () => {
return `Find records whose property's value is less than or equal to the specified number or date.`
},
kind: [ts.SyntaxKind.PropertySignature],
},
]
private functionSummaryKnowledgeBase: KnowledgeBase[] = [
{
startsWith: "listAndCount",
template: (str) => {
const { pluralName } = this.getPluralConfigForFunction({
str,
replacement: "listAndCount",
})
return `retrieves a paginated list of ${pluralName} along with the total count of available ${pluralName} satisfying the provided filters.`
},
},
{
startsWith: "list",
template: (str) => {
const { pluralName } = this.getPluralConfigForFunction({
str,
replacement: "list",
})
return `retrieves a paginated list of ${pluralName} based on optional filters and configuration.`
},
},
{
startsWith: "retrieve",
template: (str) => {
const { singularName } = this.getPluralConfigForFunction({
str,
replacement: "retrieve",
})
return `retrieves a ${singularName} by its ID.`
},
},
{
startsWith: "create",
template: (str, options) => {
const { article, isPlural, singularName, pluralName } =
this.getPluralConfigForFunction({
str,
replacement: "create",
options,
})
return `creates${article} ${isPlural ? pluralName : singularName}.`
},
},
{
startsWith: "delete",
template: (str, options) => {
const { article, isPlural, pronoun, singularName, pluralName } =
this.getPluralConfigForFunction({
str,
replacement: "delete",
options,
})
return `deletes${article} ${
isPlural ? pluralName : singularName
} by ${pronoun} ID${isPlural ? "s" : ""}.`
},
},
{
startsWith: "update",
template: (str, options) => {
const { article, isPlural, singularName, pluralName } =
this.getPluralConfigForFunction({
str,
replacement: "update",
options,
})
return `updates${article} existing ${
isPlural ? pluralName : singularName
}.`
},
},
{
startsWith: "softDelete",
template: (str, options) => {
const { article, pronoun, isPlural, singularName, pluralName } =
this.getPluralConfigForFunction({
str,
replacement: "softDelete",
options,
})
return `soft deletes${article} ${
isPlural ? pluralName : singularName
} by ${pronoun} IDs.`
},
},
{
startsWith: "restore",
template: (str, options) => {
const { article, pronoun, isPlural, singularName, pluralName } =
this.getPluralConfigForFunction({
str,
replacement: "restore",
options,
})
return `restores${article} soft deleted ${
isPlural ? pluralName : singularName
} by ${pronoun} ID${isPlural ? "s" : ""}.`
},
},
{
startsWith: "upsert",
template: (str, options) => {
const { article, isPlural, singularName, pluralName } =
this.getPluralConfigForFunction({
str,
replacement: "upsert",
options,
})
return `updates or creates${article} ${
isPlural ? pluralName : singularName
} if ${isPlural ? "they don't" : "it doesn't"} exist.`
},
},
]
private functionReturnKnowledgeBase: KnowledgeBase[] = [
{
startsWith: "listAndCount",
template: (str, options) => {
const { isPlural, singularName, pluralName } =
this.getPluralConfigForFunction({
str,
replacement: "upsert",
options,
})
return `The list of ${
isPlural ? pluralName : singularName
} along with their total count.`
},
},
{
startsWith: "list",
template: (str, options) => {
const { isPlural, singularName, pluralName } =
this.getPluralConfigForFunction({
str,
replacement: "upsert",
options,
})
return `The list of ${isPlural ? pluralName : singularName}.`
},
},
{
startsWith: "retrieve",
template: (str, options) => {
const { isPlural, singularName, pluralName } =
this.getPluralConfigForFunction({
str,
replacement: "upsert",
options,
})
return `The retrieved ${isPlural ? pluralName : singularName}.`
},
},
{
startsWith: "create",
template: (str, options) => {
const { isPlural, singularName, pluralName } =
this.getPluralConfigForFunction({
str,
replacement: "upsert",
options,
})
return `The created ${isPlural ? pluralName : singularName}.`
},
},
{
startsWith: "update",
template: (str, options) => {
const { isPlural, singularName, pluralName } =
this.getPluralConfigForFunction({
str,
replacement: "upsert",
options,
})
return `The updated ${isPlural ? pluralName : singularName}.`
},
},
{
startsWith: "upsert",
template: (str, options) => {
const { isPlural, singularName, pluralName } =
this.getPluralConfigForFunction({
str,
replacement: "upsert",
options,
})
return `The created or updated ${isPlural ? pluralName : singularName}.`
},
},
{
startsWith: "softDelete",
template: (str, options) => {
const { isPlural, singularName, pluralName } =
this.getPluralConfigForFunction({
str,
replacement: "softDelete",
options,
})
return `An object whose keys are of the format \`{camel_case_data_model}_id\` and values are arrays of IDs of soft-deleted ${
isPlural ? pluralName : singularName
}`
},
},
{
startsWith: "restore",
template: (str, options) => {
const { isPlural, singularName, pluralName } =
this.getPluralConfigForFunction({
str,
replacement: "restore",
options,
})
return `An object whose keys are of the format \`{camel_case_data_model}_id\` and values are arrays of IDs of restored ${
isPlural ? pluralName : singularName
}`
},
},
]
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)
}
/**
* This method replaces uses of {@link TYPE_PLACEHOLDER} with the normalized parent name, if provided.
*
* @param str - The string to normalize
* @param options - The template options
* @returns The normalized string
*/
private replaceTypePlaceholder(
str: string,
options?: TemplateOptions
): string {
const typeName = options?.rawParentName
? camelToWords(normalizeName(options.rawParentName))
: this.TYPE_PLACEHOLDER
return str.replaceAll(this.TYPE_PLACEHOLDER, typeName)
}
/**
* This method retrieves plural configuration for Medusa service's functions.
*
* @param param0 - The function's input.
* @returns The plural configurations.
*/
private getPluralConfigForFunction({
str,
replacement,
options,
}: {
str: string
replacement: string
options?: TemplateOptions
}): {
isPlural: boolean
typeName: string
singularName: string
pluralName: string
article: string
pronoun: string
} {
const isPlural = this.isTypePlural(options?.pluralIndicatorStr)
const typeName = camelToWords(str.replace(replacement, ""))
const singularName = singular(typeName)
const pluralName = pluralize(typeName)
const article = isPlural ? "" : " a"
const pronoun = isPlural ? "their" : "its"
return {
isPlural,
typeName,
singularName,
pluralName,
article,
pronoun,
}
}
/**
* Checks whether a type should be handled as a plural. Typically used with {@link TemplateOptions.pluralIndicatorStr}.
*
* @param str - The type string to check.
* @returns Whether the type is handled as a plural.
*/
private isTypePlural(str: string | undefined): boolean {
return str?.endsWith("[]") || false
}
/**
* 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,
templateOptions: {
pluralIndicatorStr: str,
...options.templateOptions,
},
})
}
/**
* 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 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.
*/
tryToGetOasSchemaDescription({
str,
...options
}: RetrieveOptions): string | undefined {
const normalizedTypeStr = str.replaceAll("[]", "")
return this.tryToFindInKnowledgeBase({
...options,
str: normalizedTypeStr,
knowledgeBase: this.oasDescriptionKnowledgeBase,
})
}
/**
* Retrieve the summary and description of the OAS.
*
* @param param0 - The OAS operation's details.
* @returns The summary and description.
*/
tryToGetOasMethodSummaryAndDescription({
oasPath,
httpMethod,
tag,
}: {
/**
* The OAS path.
*/
oasPath: string
/**
* The HTTP method name.
*/
httpMethod: string
/**
* The OAS tag name.
*/
tag: string
}): {
/**
* The OAS's summary
*/
summary: string
/**
* The OAS's description.
*/
description: string
} {
// reset regex manually
API_ROUTE_PARAM_REGEX.lastIndex = 0
const result = {
summary: SUMMARY_PLACEHOLDER,
description: SUMMARY_PLACEHOLDER,
}
// retrieve different variations of the tag to include in the summary/description
const lowerTag = tag.toLowerCase()
const singularLowerTag = pluralize.singular(lowerTag)
const singularTag = pluralize.singular(tag)
// check if the OAS operation is performed on a single entity or
// general entities. If the operation has a path parameter, then it's
// considered for a single entity.
const isForSingleEntity = API_ROUTE_PARAM_REGEX.test(oasPath)
if (isForSingleEntity) {
// Check whether the OAS operation is applied on a different entity.
// If the OAS path ends with /batch or a different entity
// name than the tag name, then it's performed on an entity other than the
// main entity (the one indicated by the tag), so the summary/description vary
// slightly.
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) {
// if the operation is a bulk operation and it ends with a path parameter (after removing the `/batch` part)
// then the tag name is the targeted entity. Else, it's the last part of the OAS path (after removing the `/batch` part).
const endingEntityName =
isBulk &&
API_ROUTE_PARAM_REGEX.test(splitOasPath[splitOasPath.length - 1])
? tag
: kebabToTitle(splitOasPath[splitOasPath.length - 1])
// retrieve different formatted versions of the entity name for the summary/description
const pluralEndingEntityName = pluralize.plural(endingEntityName)
const lowerEndingEntityName = pluralEndingEntityName.toLowerCase()
const singularLowerEndingEntityName =
pluralize.singular(endingEntityName)
// set the summary/description based on the HTTP method
if (httpMethod === "get") {
result.summary = `List ${pluralEndingEntityName}`
result.description = `Retrieve a list of ${lowerEndingEntityName} in a ${singularLowerTag}. The ${lowerEndingEntityName} can be filtered by fields like FILTER FIELDS. The ${lowerEndingEntityName} can also be paginated.`
} else if (httpMethod === "post") {
result.summary = `Add ${pluralEndingEntityName} to ${singularTag}`
result.description = `Add a list of ${lowerEndingEntityName} to a ${singularLowerTag}.`
} else {
result.summary = `Remove ${pluralEndingEntityName} from ${singularTag}`
result.description = `Remove a list of ${lowerEndingEntityName} from a ${singularLowerTag}. This doesn't delete the ${singularLowerEndingEntityName}, only the association between the ${singularLowerEndingEntityName} and the ${singularLowerTag}.`
}
} else {
// the OAS operation is applied on a single entity that is the main entity (denoted by the tag).
// retrieve the summary/description based on the HTTP method.
if (httpMethod === "get") {
result.summary = `Get a ${singularTag}`
result.description = `Retrieve a ${singularLowerTag} by its ID. You can expand the ${singularLowerTag}'s relations or select the fields that should be returned.`
} else if (httpMethod === "post") {
result.summary = `Update a ${singularTag}`
result.description = `Update a ${singularLowerTag}'s details.`
} else {
result.summary = `Delete a ${singularTag}`
result.description = `Delete a ${singularLowerTag}.`
}
}
} else {
// the OAS operation is applied on all entities of the tag in general.
// retrieve the summary/description based on the HTTP method.
if (httpMethod === "get") {
result.summary = `List ${tag}`
result.description = `Retrieve a list of ${lowerTag}. The ${lowerTag} can be filtered by fields such as \`id\`. The ${lowerTag} can also be sorted or paginated.`
} else if (httpMethod === "post") {
result.summary = `Create ${singularTag}`
result.description = `Create a ${singularLowerTag}.`
} else {
result.summary = `Delete ${tag}`
result.description = `Delete ${tag}`
}
}
return result
}
/**
* Retrieve summary and description for a property of an object, interface, or type.
*
* @param param0 - The property's details.
* @returns The property's summary.
*/
tryToGetObjectPropertySummary({
retrieveOptions,
propertyDetails: { isClassOrInterface, isBoolean, classOrInterfaceName },
}: {
/**
* The options used to retrieve the summary from the knowledge base, if available.
*/
retrieveOptions: RetrieveOptions
/**
* The details of the property.
*/
propertyDetails: {
/**
* Whether the property's value is a class or interface. This applies to all
* object-like types.
*
* If `true`, the property is considered to represent a relationship to another
* class / interface.
*/
isClassOrInterface: boolean
/**
* Whether the property's value is a boolean
*/
isBoolean: boolean
/**
* The name of the class / interface this property's value is associated to.
* This is only used if {@link isClassOrInterface} is `true`.
*/
classOrInterfaceName?: string
}
}): string {
let summary = this.tryToGetSummary(retrieveOptions)
if (summary) {
return summary
}
if (isClassOrInterface) {
summary = `The associated ${classOrInterfaceName}.`
} else if (isBoolean) {
summary = `Whether the ${retrieveOptions.templateOptions
?.parentName} ${snakeToWords(retrieveOptions.str)}.`
} else {
summary = `The ${snakeToWords(
retrieveOptions.str
)} of the ${retrieveOptions.templateOptions?.parentName}`
}
return summary
}
}
export default KnowledgeBaseFactory

View File

@@ -0,0 +1,339 @@
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-output-base-paths.js"
import { parse } from "yaml"
import formatOas from "../../utils/format-oas.js"
import pluralize from "pluralize"
import { capitalize, wordsToPascal } from "utils"
import { OasArea } from "../kinds/oas.js"
export 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
private MAX_LEVEL = 4
/**
* 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.
* @param level - The current depth level. Used to avoid maximum call stack size exceeded.
* @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.
*/
namedSchemaToReference(
schema: OpenApiSchema,
level = 0
): OpenAPIV3.ReferenceObject | undefined {
if (level > this.MAX_LEVEL) {
return
}
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) => {
const propertySchema = schema.properties![property]
if ("$ref" in propertySchema) {
return
}
// if the property is an array, possibly convert its items schema
// to a reference.
if (
propertySchema.type === "array" &&
propertySchema.items &&
!("$ref" in propertySchema.items)
) {
propertySchema.items =
this.namedSchemaToReference(propertySchema.items, level + 1) ||
propertySchema.items
} else if (
propertySchema.oneOf ||
propertySchema.allOf ||
propertySchema.anyOf
) {
// if the property is a combination of types, go through each of
// the types and try to convert them to references.
const schemaTarget =
propertySchema.oneOf || propertySchema.allOf || propertySchema.anyOf
schemaTarget!.forEach((item, index) => {
if ("$ref" in item) {
return
}
schemaTarget![index] =
this.namedSchemaToReference(item, level + 1) || item
})
}
schema.properties![property] =
this.namedSchemaToReference(
propertySchema as OpenApiSchema,
level + 1
) || propertySchema
})
}
this.schemas.set(schema["x-schemaName"], schema)
return {
$ref: this.constructSchemaReference(schema["x-schemaName"]),
}
}
schemaChildrenToRefs(schema: OpenApiSchema, level = 0): OpenApiSchema {
if (level > this.MAX_LEVEL) {
return schema
}
const clonedSchema = Object.assign({}, schema)
if (clonedSchema.allOf) {
clonedSchema.allOf = clonedSchema.allOf.map((item) => {
if (this.isRefObject(item)) {
return item
}
const transformChildItems = this.schemaChildrenToRefs(item, level + 1)
return (
this.namedSchemaToReference(transformChildItems) ||
transformChildItems
)
})
} else if (clonedSchema.oneOf) {
clonedSchema.oneOf = clonedSchema.oneOf.map((item) => {
if (this.isRefObject(item)) {
return item
}
const transformChildItems = this.schemaChildrenToRefs(item, level + 1)
return (
this.namedSchemaToReference(transformChildItems) ||
transformChildItems
)
})
} else if (
clonedSchema.type === "array" &&
!this.isRefObject(clonedSchema.items)
) {
const transformedChildItems = this.schemaChildrenToRefs(
clonedSchema.items,
level
)
clonedSchema.items =
this.namedSchemaToReference(transformedChildItems) ||
transformedChildItems
} else if (clonedSchema.properties && !clonedSchema["x-schemaName"]) {
Object.entries(clonedSchema.properties).forEach(([key, property]) => {
if (this.isRefObject(property)) {
return
}
const transformedProperty = this.schemaChildrenToRefs(
property,
level + 1
)
schema.properties![key] =
this.namedSchemaToReference(transformedProperty) ||
transformedProperty
})
}
return clonedSchema
}
/**
* Retrieve the expected file name of the schema.
*
* @param name - The schema's name
* @returns The schema's file name
*/
getSchemaFileName(name: string, shouldNormalizeName = true): string {
return join(
this.baseOutputPath,
"schemas",
`${shouldNormalizeName ? this.normalizeSchemaName(name) : 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,
shouldNormalizeName = true
): ParsedSchema | undefined {
const schemaName = shouldNormalizeName
? this.normalizeSchemaName(name)
: 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, shouldNormalizeName)
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, "")
.replace(/(?<!Type)Type$/, "")
}
/**
* 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 names of the associated schema.
*/
tagNameToSchemaName(tagName: string, area: OasArea): string {
const mainSchemaName = wordsToPascal(pluralize.singular(tagName))
return `${capitalize(area)}${mainSchemaName}`
}
}
export default OasSchemaHelper

View File

@@ -0,0 +1,57 @@
import { OpenApiSchema } from "../../types/index.js"
/**
* This class has predefined OAS schemas for some types. It's used to bypass
* the logic of creating a schema for certain types.
*/
class SchemaFactory {
/**
* The pre-defined schemas.
*/
private schemas: Record<string, OpenApiSchema> = {
BigNumberInput: {
type: "string",
},
BigNumber: {
type: "string",
},
created_at: {
type: "string",
format: "date-time",
},
updated_at: {
type: "string",
format: "date-time",
},
deleted_at: {
type: "string",
format: "date-time",
},
}
/**
* Try to retrieve the pre-defined schema of a type name.
*
* @param name - the name of the type.
* @param additionalData - Additional data to pass along/override in the predefined schema. For example, a description.
* @returns The schema, if found.
*/
public tryGetSchema(
name: string,
additionalData?: Partial<OpenApiSchema>
): OpenApiSchema | undefined {
if (!Object.hasOwn(this.schemas, name)) {
return
}
let schema = Object.assign({}, this.schemas[name])
if (additionalData) {
schema = Object.assign(schema, additionalData)
}
return schema
}
}
export default SchemaFactory

View File

@@ -0,0 +1,600 @@
import ts from "typescript"
import {
DOCBLOCK_START,
DOCBLOCK_END_LINE,
DOCBLOCK_DOUBLE_LINES,
DOCBLOCK_NEW_LINE,
SUMMARY_PLACEHOLDER,
} from "../../constants.js"
import getSymbol from "../../utils/get-symbol.js"
import KnowledgeBaseFactory, {
RetrieveOptions,
} from "../helpers/knowledge-base-factory.js"
import {
getCustomNamespaceTag,
shouldHaveCustomNamespace,
} from "../../utils/medusa-react-utils.js"
import GeneratorEventManager from "../helpers/generator-event-manager.js"
import { CommonCliOptions } from "../../types/index.js"
import AiGenerator from "../helpers/ai-generator.js"
import { camelToWords, capitalize } from "utils"
import { normalizeName } from "../../utils/str-formatting.js"
export type GeneratorOptions = {
checker: ts.TypeChecker
kinds?: ts.SyntaxKind[]
generatorEventManager: GeneratorEventManager
additionalOptions?: Pick<CommonCliOptions, "generateExamples">
}
export type GetDocBlockOptions = {
addEnd?: boolean
summaryPrefix?: string
aiGenerator?: AiGenerator
}
type CommonDocsOptions = {
addDefaultSummary?: boolean
prefixWithLineBreaks?: boolean
}
/**
* Class used to generate docblocks for basic kinds. It can be
* extended for kinds requiring more elaborate TSDocs.
*/
class DefaultKindGenerator<T extends ts.Node = ts.Node> {
static DEFAULT_ALLOWED_NODE_KINDS = [
ts.SyntaxKind.SourceFile,
ts.SyntaxKind.ClassDeclaration,
ts.SyntaxKind.EnumDeclaration,
ts.SyntaxKind.EnumMember,
ts.SyntaxKind.ModuleDeclaration,
ts.SyntaxKind.PropertyDeclaration,
ts.SyntaxKind.InterfaceDeclaration,
ts.SyntaxKind.TypeAliasDeclaration,
ts.SyntaxKind.PropertySignature,
]
public name = "default"
protected allowedKinds: ts.SyntaxKind[]
protected checker: ts.TypeChecker
protected knowledgeBaseFactory: KnowledgeBaseFactory
protected generatorEventManager: GeneratorEventManager
protected options: Pick<CommonCliOptions, "generateExamples">
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
}
/**
* @returns the kinds that are handled by this generator.
*/
getAllowedKinds(): ts.SyntaxKind[] {
return this.allowedKinds
}
/**
* Check whether this generator can be used for a node based on the node's kind.
*
* @param {ts.Node} node - The node to check for.
* @returns {boolean} Whether this generator can be used with the specified node.
*/
isAllowed(node: ts.Node): node is T {
return this.allowedKinds.includes(node.kind)
}
/**
* Retrieve the doc block for the passed node.
*
* @param {T | ts.Node} node - The node to retrieve the docblock for.
* @param {GetDocBlockOptions} options - Options useful for children classes of this class to specify the formatting of the docblock.
* @returns {string} The node's docblock.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async getDocBlock(
node: T | ts.Node,
options: GetDocBlockOptions = { addEnd: true }
): Promise<string> {
let str = DOCBLOCK_START
const summary = this.getNodeSummary({ node })
switch (node.kind) {
case ts.SyntaxKind.EnumDeclaration:
str += `@enum${DOCBLOCK_DOUBLE_LINES}${summary}`
break
case ts.SyntaxKind.TypeAliasDeclaration:
str += `@interface${DOCBLOCK_DOUBLE_LINES}${summary}`
break
default:
str += summary
}
str += this.getCommonDocs(node, {
prefixWithLineBreaks: true,
})
return `${str}${options.addEnd ? DOCBLOCK_END_LINE : ""}`
}
/**
* Retrieves the summary comment of a node. It gives precedense to the node's symbol if it's provided/retrieved and if it's available using the {@link getSymbolDocBlock}.
* Otherwise, it retrieves the comments of the type using the {@link getTypeDocBlock}
* @returns {string} The summary comment.
*/
getNodeSummary({
node,
symbol,
nodeType,
knowledgeBaseOptions: overrideOptions,
}: {
/**
* The node to retrieve the summary comment for.
*/
node: T | ts.Node
/**
* Optionally provide the node's symbol. If not provided, the
* method will try to retrieve it.
*/
symbol?: ts.Symbol
/**
* Optionally provide the node's type. If not provided, the method
* will try to retrieve it.
*/
nodeType?: ts.Type
/**
* Override any of the default knowledge base options
* inferred using the {@link getKnowledgeBaseOptions} method
*/
knowledgeBaseOptions?: Partial<RetrieveOptions>
}): string {
const syntheticComments = ts.getSyntheticLeadingComments(node)
if (syntheticComments?.length) {
return syntheticComments.map((comment) => comment.text).join(" ")
}
const knowledgeBaseOptions = {
...this.getKnowledgeBaseOptions(node),
...overrideOptions,
}
if (!nodeType) {
nodeType =
"type" in node && node.type && ts.isTypeNode(node.type as ts.Node)
? this.checker.getTypeFromTypeNode(node.type as ts.TypeNode)
: symbol
? this.checker.getTypeOfSymbolAtLocation(symbol, node)
: this.checker.getTypeAtLocation(node)
}
if (!symbol) {
symbol = getSymbol(node, this.checker)
}
let summary = ""
if (symbol) {
summary = this.getSymbolDocBlock(symbol, knowledgeBaseOptions)
}
if (!summary.length) {
summary = this.getTypeDocBlock(nodeType, knowledgeBaseOptions)
}
return summary.length > 0 ? summary : SUMMARY_PLACEHOLDER
}
/**
* Retrieves the summary comment of a type. It tries to retrieve from the alias symbol, type arguments, or {@link KnowledgeBaseFactory}.
* If no summary comments are found, the {@link defaultSummary} is used.
*
* @param {ts.Type} nodeType - The type of a node.
* @returns {string} The summary comment.
*/
protected getTypeDocBlock(
nodeType: ts.Type,
knowledgeBaseOptions?: Partial<RetrieveOptions>
): string {
if (nodeType.aliasSymbol || nodeType.symbol) {
const symbolDoc = this.getSymbolDocBlock(
nodeType.aliasSymbol || nodeType.symbol
)
if (symbolDoc.length) {
return symbolDoc
}
}
const typeArguments = this.checker.getTypeArguments(
nodeType as ts.TypeReference
)
if (typeArguments.length) {
// take only the first type argument to account
const typeArgumentDoc = this.getTypeDocBlock(typeArguments[0])
if (!typeArgumentDoc.length) {
const tryKnowledgeSummary = this.knowledgeBaseFactory.tryToGetSummary({
...knowledgeBaseOptions,
str: this.checker.typeToString(nodeType),
})
if (tryKnowledgeSummary?.length) {
return tryKnowledgeSummary
}
}
if (!this.checker.isArrayType(nodeType)) {
return typeArgumentDoc
}
// do some formatting if the encapsulating type is an array
return `The list of ${capitalize(typeArgumentDoc) || SUMMARY_PLACEHOLDER}`
}
return (
this.knowledgeBaseFactory.tryToGetSummary({
...knowledgeBaseOptions,
str: this.checker.typeToString(nodeType),
}) || ""
)
}
/**
* Retrieves the docblock of a symbol. It tries to retrieve it using the symbol's `getDocumentationComment` and `getJsDocTags`
* methods. If both methods don't return any comments, it tries to get the comments from the {@link KnowledgeBaseFactory}.
*
* @param {ts.Symbol} symbol - The symbol to retrieve its docblock.
* @returns {string} The symbol's docblock.
*/
protected getSymbolDocBlock(
symbol: ts.Symbol,
knowledgeBaseOptions?: Partial<RetrieveOptions>
): string {
const commentDisplayParts = symbol.getDocumentationComment(this.checker)
if (!commentDisplayParts.length) {
// try to get description from the first JSDoc comment
const jsdocComments = symbol.getJsDocTags(this.checker)
if (jsdocComments.length) {
jsdocComments
.find((tag) => (tag.text?.length || 0) > 0)
?.text!.forEach((tagText) => {
commentDisplayParts.push(tagText)
})
}
}
if (!commentDisplayParts.length) {
return (
this.knowledgeBaseFactory.tryToGetSummary({
...knowledgeBaseOptions,
str: this.checker.typeToString(this.checker.getTypeOfSymbol(symbol)),
}) ||
this.knowledgeBaseFactory.tryToGetSummary({
...knowledgeBaseOptions,
str: symbol.name,
}) ||
""
)
}
return ts
.displayPartsToString(commentDisplayParts)
.replaceAll("\n", DOCBLOCK_NEW_LINE)
}
/**
* Retrieves docblocks based on decorators used on a symbol.
*
* @param {ts.Symbol} symbol - The symbol to retrieve its decorators docblock.
* @returns {string} The symbol's decorators docblock.
*/
getDecoratorDocs(symbol: ts.Symbol): string {
let str = ""
symbol.declarations?.forEach((declaration) => {
const modifiers =
"modifiers" in declaration && declaration.modifiers
? (declaration.modifiers as ts.NodeArray<ts.Modifier>)
: []
modifiers.forEach((modifier) => {
if (!ts.isDecorator(modifier)) {
return
}
// check for decorator text
;(modifier as ts.Decorator).forEachChild((childNode) => {
if (ts.isCallExpression(childNode)) {
const childNodeExpression = (childNode as ts.CallExpression)
.expression
if (ts.isIdentifier(childNodeExpression)) {
switch (childNodeExpression.escapedText) {
case "FeatureFlagEntity":
// add the `@featureFlag` tag.
str += `${DOCBLOCK_DOUBLE_LINES}@featureFlag [flag_name]`
break
case "BeforeInsert":
case "BeforeLoad":
case "AfterLoad":
// add `@apiIgnore` tag
str += `${DOCBLOCK_DOUBLE_LINES}@apiIgnore`
}
}
}
})
})
})
return str
}
/**
* Retrieve docblocks that are common to all nodes, despite their kind.
*
* @param {T | ts.Node} node - The node to retrieve its common doc blocks.
* @param {CommonDocsOptions} options - Formatting options.
* @returns {string} The common docblocks.
*/
getCommonDocs(
node: T | ts.Node,
options: CommonDocsOptions = { addDefaultSummary: false }
): string {
const tags = new Set<string>()
const symbol = getSymbol(node, this.checker)
if (!symbol) {
return ""
}
if (ts.isSourceFile(node)) {
// comments for source files must start with this tag
tags.add(`@packageDocumentation`)
}
if (options.addDefaultSummary) {
tags.add(SUMMARY_PLACEHOLDER)
}
// check for private or protected modifiers
// and if found, add the `@ignore` tag.
symbol.declarations?.some((declaration) => {
if (!("modifiers" in declaration) || !declaration.modifiers) {
return false
}
const hasPrivateOrProtected = (
declaration.modifiers as ts.NodeArray<ts.Modifier>
).find((modifier) => {
modifier.kind === ts.SyntaxKind.PrivateKeyword ||
modifier.kind === ts.SyntaxKind.ProtectedKeyword
})
if (!hasPrivateOrProtected) {
return false
}
tags.add("@ignore")
return true
})
// if a symbol's name starts with `_` then we
// should add the `@ignore` tag
if (symbol.getName().startsWith("_")) {
tags.add("@ignore")
}
// check if any docs can be added for the symbol's
// decorators
this.getDecoratorDocs(symbol)
.split(`${DOCBLOCK_DOUBLE_LINES}`)
.filter((docItem) => docItem.length > 0)
.forEach((docItem) => tags.add(docItem))
// add `@expandable` tag if the resource is
if (ts.isPropertyDeclaration(node)) {
const symbolType = this.checker.getTypeOfSymbol(symbol)
if (
symbolType.symbol?.declarations?.length &&
ts.isClassDeclaration(symbolType.symbol?.declarations[0]) &&
this.isEntity({
heritageClauses: (
symbolType.symbol?.declarations[0] as ts.ClassDeclaration
).heritageClauses,
node: symbolType.symbol?.declarations[0],
})
) {
tags.add(`@expandable`)
}
}
// check if custom namespace should be added
if (shouldHaveCustomNamespace(node)) {
tags.add(getCustomNamespaceTag(node))
}
// check for default value
const defaultValue = this.getDefaultValue(node)
if (defaultValue?.length) {
tags.add(`@defaultValue ${defaultValue}`)
}
let str = ""
tags.forEach((tag) => {
if (str.length > 0) {
str += `${DOCBLOCK_DOUBLE_LINES}`
}
str += `${tag}`
})
if (str.length && options.prefixWithLineBreaks) {
str = `${DOCBLOCK_DOUBLE_LINES}${str}`
}
return str
}
/**
* Check if a node is a Medusa entity.
* @returns {boolean} Whether the node is a Medusa entity.
*/
isEntity({
/**
* The inherit/extend keywords of the node.
*/
heritageClauses,
/**
* Optionally provide the node to accurately retrieve its type name.
*/
node,
}: {
heritageClauses?: ts.NodeArray<ts.HeritageClause>
node?: ts.Node
}): boolean {
return (
heritageClauses?.some((heritageClause) => {
return heritageClause.types.some((heritageClauseType) => {
const symbolType = this.checker.getTypeAtLocation(
heritageClauseType.expression
)
if (
this.checker
.typeToString(symbolType, node, undefined)
.includes("BaseEntity")
) {
return true
}
if (
symbolType.symbol?.valueDeclaration &&
"heritageClauses" in symbolType.symbol.valueDeclaration
) {
return this.isEntity({
heritageClauses: symbolType.symbol.valueDeclaration
.heritageClauses as ts.NodeArray<ts.HeritageClause>,
node,
})
}
return false
})
}) || false
)
}
/**
* 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 &&
ts.isIdentifier(node.parent.name as ts.Node)
? (node.parent.name as ts.Identifier).getText()
: undefined
return {
kind: node.kind,
templateOptions: {
rawParentName,
parentName: rawParentName
? camelToWords(normalizeName(rawParentName))
: undefined,
},
}
}
/**
* 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

@@ -0,0 +1,277 @@
import ts from "typescript"
import DefaultKindGenerator, { GetDocBlockOptions } from "./default.js"
import getBasePath from "../../utils/get-base-path.js"
import { getDmlOutputBasePath } from "../../utils/get-output-base-paths.js"
import path from "path"
import { camelToWords, RELATION_NAMES, snakeToPascal } from "utils"
import toJsonFormatted from "../../utils/to-json-formatted.js"
import { DmlFile, DmlObject } from "types"
/**
* DML generator for data models created with DML.
*/
class DmlKindGenerator extends DefaultKindGenerator<ts.CallExpression> {
protected allowedKinds: ts.SyntaxKind[] = [ts.SyntaxKind.CallExpression]
public name = "dml"
/**
* Checks whether the node is a call expression, is `model.define`, and has at least
* two arguments.
*
* @param node - The node to check
* @returns Whether this kind generator can be used for this node.
*/
isAllowed(node: ts.Node): node is ts.CallExpression {
if (!super.isAllowed(node)) {
return false
}
const isModelUtility =
"expression" in node.expression &&
(node.expression.expression as ts.Node).getText() === "model"
const isDefineMethod =
"name" in node.expression &&
(node.expression.name as ts.Identifier).getText() === "define"
const hasAllArguments = node.arguments.length >= 2
return isModelUtility && isDefineMethod && hasAllArguments
}
/**
* Retrieve the DML object from the node's associated file, if it exists.
*
* @param node - The node to retrieve its DML object.
* @param dataModelName - The data model's name
* @returns The DML object, if available.
*/
getExistingDmlObjectFromFile(
node: ts.CallExpression,
dataModelName: string
): DmlObject | undefined {
const filePath = this.getAssociatedFileName(node)
const existingJson = ts.sys.readFile(filePath)
if (!existingJson) {
return
}
const parsedJson = JSON.parse(existingJson) as DmlFile
if (!Object.hasOwn(parsedJson, dataModelName)) {
return
}
return parsedJson[dataModelName].properties
}
/**
* Get new or updated JSON dml object of the data model.
*
* @param node - The node to get its JSON DML object.
* @param options - General options
* @returns The JSON dml object.
*/
async getDocBlock(
node: ts.CallExpression | ts.Node,
options?: GetDocBlockOptions
): Promise<string> {
if (!this.isAllowed(node)) {
return await super.getDocBlock(node, options)
}
const dataModelName = this.getDataModelName(node.arguments[0])
const existingDmlObject = this.getExistingDmlObjectFromFile(
node,
dataModelName
)
const properties = existingDmlObject
? this.updateExistingDmlObject({
node,
dmlObject: existingDmlObject,
dataModelName,
})
: this.getNewDmlObject({
node,
dataModelName,
})
const dmlFile: DmlFile = {
[dataModelName]: {
filePath: getBasePath(node.getSourceFile().fileName),
properties,
},
}
return toJsonFormatted(dmlFile)
}
/**
* Get a new DML object for a node.
*
* @param param0 - The node and data model's details
* @returns The DML object.
*/
getNewDmlObject({
node,
dataModelName,
}: {
node: ts.CallExpression
dataModelName: string
}): DmlObject {
return this.getPropertiesFromNode(node, dataModelName)
}
/**
* Update existing dml object by removing properties no longer
* available and adding new ones.
*
* @param param0 - The node and data model's details.
* @returns The updated DML object.
*/
updateExistingDmlObject({
node,
dmlObject,
dataModelName,
}: {
node: ts.CallExpression
dmlObject: DmlObject
dataModelName: string
}): DmlObject {
const newDmlObject = Object.assign({}, dmlObject)
const newProperties = this.getPropertiesFromNode(node, dataModelName)
const newPropertyNames = Object.keys(newProperties)
const oldPropertyNames = Object.keys(newDmlObject)
// delete properties not available anymore
oldPropertyNames.forEach((oldPropertyName) => {
if (!newPropertyNames.includes(oldPropertyName)) {
delete newDmlObject[oldPropertyName]
}
})
// add new properties
newPropertyNames.forEach((newPropertyName) => {
if (oldPropertyNames.includes(newPropertyName)) {
return
}
newDmlObject[newPropertyName] = newProperties[newPropertyName]
})
return newDmlObject
}
/**
* Get the data model's name. It's either a string passed to `model.define`, or
* in the `name` property of the object passed as a parameter to `model.define`.
*
* @param node - The node to retrieve its data model name.
* @returns The data model's name.
*/
getDataModelName(node: ts.Node): string {
let name = node.getText()
if (ts.isObjectLiteralExpression(node)) {
const nameProperty = node.properties.find((propertyNode) => {
return (
propertyNode.name?.getText() === "name" &&
"initializer" in propertyNode
)
}) as ts.ObjectLiteralElementLike & {
initializer: ts.Node
}
if (nameProperty) {
name = nameProperty.initializer.getText()
}
}
return snakeToPascal(name.replace(/^"/, "").replace(/"$/, ""))
}
/**
* Get the properties of a node.
*
* @param node - The node to get its properties.
* @param dataModelName - The data model's name.
* @returns The properties and their description.
*/
getPropertiesFromNode(
node: ts.CallExpression,
dataModelName: string
): DmlObject {
const formattedDataModelName = this.formatDataModelName(dataModelName)
const propertyNodes =
"properties" in node.arguments[1]
? (node.arguments[1].properties as ts.Node[])
: []
const properties: DmlObject = {}
propertyNodes.forEach((propertyNode) => {
const propertyName =
"name" in propertyNode
? (propertyNode.name as ts.Identifier).getText()
: propertyNode.getText()
const propertyType = this.checker.getTypeAtLocation(propertyNode)
const propertyTypeStr = this.checker.typeToString(propertyType)
const isRelation = RELATION_NAMES.some((relationName) =>
propertyTypeStr.includes(relationName)
)
const isBoolean = propertyTypeStr.includes("BooleanProperty")
const relationName = isRelation ? camelToWords(propertyName) : undefined
let propertyDescription =
this.knowledgeBaseFactory.tryToGetObjectPropertySummary({
retrieveOptions: {
str: propertyName,
kind: propertyNode.kind,
templateOptions: {
parentName: formattedDataModelName,
rawParentName: dataModelName,
},
},
propertyDetails: {
isClassOrInterface: isRelation,
isBoolean,
classOrInterfaceName: relationName,
},
})
if (isRelation) {
propertyDescription += `\n\n@expandable`
}
properties[propertyName] = propertyDescription
})
return properties
}
/**
* Get the name of the output JSON file associated with a node.
*
* @param node - The node to get its file name.
* @returns The file name.
*/
getAssociatedFileName(node: ts.Node): string {
const filePath = getBasePath(node.getSourceFile().fileName).split("/")
// since modules are at packages/modules/<name>, the name should be at index 2
const moduleName = filePath[2]
return path.join(getDmlOutputBasePath(), `${moduleName}.json`)
}
/**
* Format the data model name for presentation purposes.
*
* @param name - The raw data model's name.
* @returns The formatted name.
*/
formatDataModelName(name: string): string {
return camelToWords(name)
}
}
export default DmlKindGenerator

View File

@@ -0,0 +1,98 @@
import ts from "typescript"
import DefaultKindGenerator, { GetDocBlockOptions } from "./default.js"
import { DOCBLOCK_END_LINE, DOCBLOCK_START } from "../../constants.js"
import { camelToWords } from "utils"
import { normalizeName } from "../../utils/str-formatting.js"
/**
* A class that generates doc blocks for properties in a DTO interface/type.
*/
class DTOPropertyGenerator extends DefaultKindGenerator<ts.PropertySignature> {
protected allowedKinds: ts.SyntaxKind[] = [ts.SyntaxKind.PropertySignature]
public name = "dto-property"
/**
* Check that the generator can handle generating for the node.
*
* @param {ts.Node} node - The node to check.
* @returns {boolean} Whether the generator can handle generating for the node.
*/
isAllowed(node: ts.Node): node is ts.PropertySignature {
if (!super.isAllowed(node)) {
return false
}
return (
this.getParentName((node as ts.PropertySignature).parent).endsWith(
"DTO"
) || false
)
}
async getDocBlock(
node: ts.PropertyDeclaration | ts.Node,
options?: GetDocBlockOptions
): Promise<string> {
if (!this.isAllowed(node)) {
return await super.getDocBlock(node, options)
}
let str = DOCBLOCK_START
const rawParentName = this.getParentName(node.parent)
const parentName = this.formatInterfaceName(rawParentName)
// check if the property's type is interface/type/class
const propertyType = this.checker.getTypeAtLocation(node)
const isPropertyClassOrInterface = propertyType.isClassOrInterface()
// try first to retrieve the summary from the knowledge base if it exists.
const summary = this.knowledgeBaseFactory.tryToGetObjectPropertySummary({
retrieveOptions: {
str: node.name.getText(),
kind: node.kind,
templateOptions: {
rawParentName,
parentName,
},
},
propertyDetails: {
isClassOrInterface: isPropertyClassOrInterface,
isBoolean:
"intrinsicName" in propertyType &&
propertyType.intrinsicName === "boolean",
classOrInterfaceName: isPropertyClassOrInterface
? this.formatInterfaceName(this.checker.typeToString(propertyType))
: undefined,
},
})
str += summary
return `${str}${DOCBLOCK_END_LINE}`
}
/**
* Format the name of the interface/type.
*
* @param {string} name - The name to format.
* @returns {string} The formatted name.
*/
formatInterfaceName(name: string): string {
return camelToWords(normalizeName(name))
}
/**
* Get the name of the parent interface/type.
*
* @param {ts.InterfaceDeclaration | ts.TypeLiteralNode} parent - The parent node.
* @returns {string} The name of the parent.
*/
getParentName(parent: ts.InterfaceDeclaration | ts.TypeLiteralNode): string {
if (ts.isInterfaceDeclaration(parent)) {
return parent.name.getText()
}
return this.checker.typeToString(this.checker.getTypeFromTypeNode(parent))
}
}
export default DTOPropertyGenerator

View File

@@ -0,0 +1,386 @@
import ts from "typescript"
import DefaultKindGenerator, { GetDocBlockOptions } from "./default.js"
import {
DOCBLOCK_NEW_LINE,
DOCBLOCK_END_LINE,
DOCBLOCK_START,
DOCBLOCK_DOUBLE_LINES,
SUMMARY_PLACEHOLDER,
} from "../../constants.js"
import getSymbol from "../../utils/get-symbol.js"
import AiGenerator from "../helpers/ai-generator.js"
import path from "path"
export type FunctionNode =
| ts.MethodDeclaration
| ts.MethodSignature
| ts.FunctionDeclaration
| ts.ArrowFunction
export type VariableNode = ts.VariableDeclaration | ts.VariableStatement
export type FunctionOrVariableNode = FunctionNode | ts.VariableStatement
/**
* Docblock generator for functions.
*/
// eslint-disable-next-line max-len
class FunctionKindGenerator extends DefaultKindGenerator<FunctionOrVariableNode> {
protected methodKinds: ts.SyntaxKind[] = [
ts.SyntaxKind.MethodDeclaration,
ts.SyntaxKind.MethodSignature,
]
protected functionKinds: ts.SyntaxKind[] = [ts.SyntaxKind.FunctionDeclaration]
protected allowedKinds: ts.SyntaxKind[] = [
...this.methodKinds,
...this.functionKinds,
]
public name = "function"
static EXAMPLE_PLACEHOLDER = `{example-code}`
protected aiParameterExceptions = ["sharedContext"]
/**
* Checks whether a node is considered a function node. A node is considered a function node if:
*
* 1. It is a method declaration (typically in classes), a method signature (typically in interfaces), or a function declaration.
* 2. An arrow function. However, for better docblock placement and formatting, we detect the variable statement surrounding the arrow function
* rather than the arrow function itself.
*
* @param {ts.Node} node - The node to check.
* @returns {boolean} Whether the node is a function node and can be handled by this generator.
*/
isAllowed(node: ts.Node): node is FunctionOrVariableNode {
if (!super.isAllowed(node)) {
return ts.isVariableStatement(node) && this.isFunctionVariable(node)
}
return true
}
/**
* Checks whether a node is a variable statement/declaration with underlying node function
* using the {@link extractFunctionNode} method.
*
* @param {ts.Node} node - The node to check.
* @returns {boolean} Whether the node is a variable statement/declaration with underlying node function.
*/
isFunctionVariable(node: ts.Node): node is VariableNode {
if (ts.isVariableStatement(node)) {
return node.declarationList.declarations.some((declaration) => {
return this.isFunctionVariable(declaration)
})
} else if (ts.isVariableDeclaration(node)) {
return this.extractFunctionNode(node) !== undefined
}
return false
}
/**
* Retrieves the underlying function/method/arrow function of a variable statement or declaration.
*
* @param {ts.Node} node - The variable statement/declaration to retrieve the function/method from.
* @returns The function/method if found.
*/
extractFunctionNode(node: VariableNode): FunctionNode | undefined {
if (ts.isVariableStatement(node)) {
const variableDeclaration = node.declarationList.declarations.find(
(declaration) => ts.isVariableDeclaration(declaration)
)
return variableDeclaration
? this.extractFunctionNode(variableDeclaration)
: undefined
} else if (
node.initializer &&
(this.isAllowed(node.initializer) || ts.isArrowFunction(node.initializer))
) {
return node.initializer
}
}
/**
* Check whether a node refers to a method.
*
* @param {FunctionNode} node - The node to check.
* @returns {boolean} Whether the node is a method.
*/
isMethod(
node: FunctionNode
): node is ts.MethodDeclaration | ts.MethodSignature {
return this.methodKinds.includes(node.kind)
}
/**
* Checks whether a type, typically the type of a function's signature, has return data.
*
* @param {string} typeStr - The type's string representation.
* @returns {boolean} Whether the type has return data.
*/
hasReturnData(typeStr: string): boolean {
return (
typeStr !== "void" &&
typeStr !== "never" &&
typeStr !== "Promise<void>" &&
typeStr !== "Promise<never>"
)
}
/**
* Retrieves the return type of a function.
*
* @param {FunctionNode} node - The function's node.
* @returns {ts.Type} The function's return type.
*/
getReturnType(node: FunctionNode): ts.Type {
return node.type
? this.checker.getTypeFromTypeNode(node.type)
: this.checker.getTypeAtLocation(node)
}
/**
* Retrieves the summary comment of a function.
*
* @param {FunctionNode} node - The function's options.
* @returns {string} The function's summary comment.
*/
getFunctionSummary({
node,
symbol,
parentSymbol,
returnType,
}: {
/**
* The node's function.
*/
node: FunctionNode
/**
* The node's symbol. If provided, the method will try to retrieve the summary from the {@link KnowledgeBaseFactory}.
*/
symbol?: ts.Symbol
/**
* The node's parent symbol. This is useful to pass along the parent name to the knowledge base.
*/
parentSymbol?: ts.Symbol
/**
* The node's return type. Useful for the {@link KnowledgeBaseFactory}
*/
returnType?: string
}): string {
return symbol
? this.knowledgeBaseFactory.tryToGetFunctionSummary({
symbol: symbol,
kind: node.kind,
templateOptions: {
rawParentName: parentSymbol?.getName(),
pluralIndicatorStr: returnType,
},
}) || this.getNodeSummary({ node, symbol })
: this.getNodeSummary({ node, symbol })
}
/**
* Retrieve the function's example comment.
*
* @param {ts.Symbol} symbol - The function's symbol. If provided, the method will try to retrieve the example from the {@link KnowledgeBaseFactory}.
* @returns {string} The function's example comment.
*/
getFunctionPlaceholderExample(): string {
return this.formatExample(FunctionKindGenerator.EXAMPLE_PLACEHOLDER)
}
/**
* Retrieves a function's example using the AiGenerator
*
* @param node - The function's node.
* @param aiGenerator - An instance of the AiGenerator
* @returns the example code
*/
async getFunctionExampleAi(
node: FunctionOrVariableNode,
aiGenerator: AiGenerator,
withTag = true
): Promise<string> {
const actualNode = ts.isVariableStatement(node)
? this.extractFunctionNode(node)
: node
if (!actualNode) {
return ""
}
const symbol = getSymbol(node, this.checker)
const example = await aiGenerator.generateExample({
className: this.isMethod(actualNode)
? getSymbol(node.parent, this.checker)?.name
: undefined,
functionName: symbol?.name || "",
signature: node.getText(),
fileName: path.basename(node.getSourceFile().fileName),
})
return this.formatExample(
example.length
? `${example}${DOCBLOCK_NEW_LINE}`
: FunctionKindGenerator.EXAMPLE_PLACEHOLDER,
withTag
)
}
formatExample(example: string, withTag = true): string {
return `${
withTag ? `${DOCBLOCK_DOUBLE_LINES}@example${DOCBLOCK_NEW_LINE}` : ""
}${example}`
}
/**
* Retrieve the full docblock of a function.
*
* @param {FunctionOrVariableNode | ts.Node} node - The function node. If a variable statement is provided, the underlying function is retrieved.
* If a different node type is provided, the parent generator is used to retrieve the docblock comment.
* @param {GetDocBlockOptions} options - Formatting options.
* @returns {string} The function's docblock.
*/
async getDocBlock(
node: FunctionOrVariableNode | ts.Node,
options: GetDocBlockOptions = { addEnd: true }
): Promise<string> {
if (!this.isAllowed(node)) {
return await super.getDocBlock(node, options)
}
const actualNode = ts.isVariableStatement(node)
? this.extractFunctionNode(node)
: node
if (!actualNode) {
return await super.getDocBlock(node, options)
}
let existingComments = this.getNodeCommentsFromRange(node)
if (existingComments?.includes(FunctionKindGenerator.EXAMPLE_PLACEHOLDER)) {
// just replace the existing comment and return it
if (options.aiGenerator) {
existingComments = existingComments.replace(
FunctionKindGenerator.EXAMPLE_PLACEHOLDER,
await this.getFunctionExampleAi(
actualNode,
options.aiGenerator,
false
)
)
}
return existingComments.replace("/*", "").replace("*/", "")
}
const nodeSymbol = getSymbol(node, this.checker)
const nodeParentSymbol = getSymbol(node.parent, this.checker)
const nodeType = this.getReturnType(actualNode)
const returnTypeStr = this.checker.typeToString(nodeType)
const normalizedTypeStr = returnTypeStr.startsWith("Promise<")
? returnTypeStr.replace(/^Promise</, "").replace(/>$/, "")
: returnTypeStr
let str = DOCBLOCK_START
// add summary
str += `${
options.summaryPrefix ||
(this.isMethod(actualNode) ? `This method` : `This function`)
} ${this.getFunctionSummary({
node: actualNode,
symbol: nodeSymbol,
parentSymbol: nodeParentSymbol,
returnType: normalizedTypeStr,
})}${DOCBLOCK_NEW_LINE}`
actualNode.parameters.map((parameterNode) => {
const symbol = getSymbol(parameterNode, this.checker)
if (!symbol) {
return
}
const symbolType = this.checker.getTypeOfSymbolAtLocation(
symbol,
parameterNode
)
const parameterName = symbol.getName()
const parameterSummary = this.getNodeSummary({
node: parameterNode,
symbol,
nodeType: symbolType,
knowledgeBaseOptions: {
templateOptions: {
rawParentName: nodeParentSymbol?.getName(),
pluralIndicatorStr: this.checker.typeToString(symbolType),
},
},
})
str += `${DOCBLOCK_NEW_LINE}@param {${this.checker.typeToString(
symbolType
)}} ${parameterName} - ${parameterSummary}`
})
// add returns
const possibleReturnSummary = !this.hasReturnData(returnTypeStr)
? `Resolves when ${SUMMARY_PLACEHOLDER}`
: this.getNodeSummary({
node: actualNode,
nodeType,
})
str += `${DOCBLOCK_NEW_LINE}@returns {${returnTypeStr}} ${
nodeSymbol
? this.knowledgeBaseFactory.tryToGetFunctionReturns({
symbol: nodeSymbol,
kind: actualNode.kind,
templateOptions: {
rawParentName: nodeParentSymbol?.getName(),
pluralIndicatorStr: normalizedTypeStr,
},
}) || possibleReturnSummary
: possibleReturnSummary
}`
// add example
if (!options.aiGenerator) {
str += this.getFunctionPlaceholderExample()
} else {
str += await this.getFunctionExampleAi(actualNode, options.aiGenerator)
}
// add common docs
str += this.getCommonDocs(node, {
prefixWithLineBreaks: true,
})
if (options.addEnd) {
str += DOCBLOCK_END_LINE
}
return str
}
/**
* Allows documenting (updating) a node if it has the example placeholder.
*
* @param node - The node to document.
* @returns Whether the node can be documented.
*/
canDocumentNode(node: ts.Node): boolean {
const comments = this.getNodeCommentsFromRange(node)
return (
!comments ||
comments?.includes(FunctionKindGenerator.EXAMPLE_PLACEHOLDER) ||
false
)
}
}
export default FunctionKindGenerator

View File

@@ -0,0 +1,202 @@
import ts from "typescript"
import FunctionKindGenerator, {
FunctionNode,
FunctionOrVariableNode,
} from "./function.js"
import {
DOCBLOCK_NEW_LINE,
DOCBLOCK_END_LINE,
DOCBLOCK_START,
DOCBLOCK_DOUBLE_LINES,
} from "../../constants.js"
import {
CUSTOM_NAMESPACE_TAG,
getCustomNamespaceTag,
} from "../../utils/medusa-react-utils.js"
/**
* Docblock generate for medusa-react hooks. Since hooks are essentially functions,
* it extends the {@link FunctionKindGenerator} class.
*/
class MedusaReactHooksKindGenerator extends FunctionKindGenerator {
public name = "medusa-react"
/**
* Checks whether the generator can retrieve the docblock of the specified node. It uses the parent generator
* to check that the node is a function, then checks if the function is a mutation using the {@link isMutation} method,
* or a query using the {@link isQuery} method.
*
* @param {ts.Node} node - The node to check.
* @returns {boolean} Whether this generator can be used on this node.
*/
isAllowed(node: ts.Node): node is FunctionOrVariableNode {
if (!super.isAllowed(node)) {
return false
}
const actualNode = ts.isVariableStatement(node)
? this.extractFunctionNode(node)
: node
return (
actualNode !== undefined &&
(this.isMutation(actualNode) || this.isQuery(actualNode))
)
}
/**
* Checks whether a function node is a mutation.
*
* @param {FunctionNode} node - The function node to check.
* @returns {boolean} Whether the node is a mutation.
*/
isMutation(node: FunctionNode): boolean {
const nodeType = this.getReturnType(node)
const callSignatures = nodeType.getCallSignatures()
return (
callSignatures.length > 0 &&
this.checker
.typeToString(this.checker.getReturnTypeOfSignature(callSignatures[0]))
.startsWith("UseMutationResult")
)
}
/**
* Checks whether a function node is a query.
*
* @param {FunctionNode} node - The function node to check.
* @returns {boolean} Whether the node is a query.
*/
isQuery(node: FunctionNode): boolean {
return node.parameters.some(
(parameter) =>
parameter.type?.getText().startsWith("UseQueryOptionsWrapper")
)
}
/**
* Retrieves the docblock of the medusa-react hook or mutation.
*
* @param {FunctionNode & ts.VariableDeclaration} node - The node to retrieve its docblock.
* @returns {string} The node's docblock.
*/
async getDocBlock(
node: FunctionNode & ts.VariableDeclaration
): Promise<string> {
// TODO use the AiGenerator to generate summary + examples
if (!this.isAllowed(node)) {
return await super.getDocBlock(node)
}
const actualNode = ts.isVariableStatement(node)
? this.extractFunctionNode(node)
: node
if (!actualNode) {
return await super.getDocBlock(node)
}
const isMutation = this.isMutation(actualNode)
let str = `${DOCBLOCK_START}This hook ${this.getFunctionSummary({
node,
})}`
// add example
str += this.getFunctionPlaceholderExample()
// loop over parameters that aren't query/mutation parameters
// and add docblock to them
await Promise.all(
this.getActualParameters(actualNode).map(async (parameter) => {
ts.addSyntheticLeadingComment(
parameter,
ts.SyntaxKind.MultiLineCommentTrivia,
await super.getDocBlock(parameter),
true
)
})
)
// check if mutation parameter is an intrinsic type and, if so, add the `@typeParamDefinition`
// tag to the hook
if (isMutation) {
const typeArg = this.getMutationRequestTypeArg(actualNode)
if (typeArg) {
str += `${DOCBLOCK_DOUBLE_LINES}@typeParamDefinition ${this.checker.typeToString(
typeArg
)} - {summary}`
}
}
// add common docs
str += this.getCommonDocs(node, {
prefixWithLineBreaks: true,
})
// add namespace in case it's not added
if (!str.includes(CUSTOM_NAMESPACE_TAG)) {
str += `${DOCBLOCK_DOUBLE_LINES}${getCustomNamespaceTag(actualNode)}`
}
// add the category
str += `${DOCBLOCK_NEW_LINE}@category ${
isMutation ? "Mutations" : "Queries"
}`
return `${str}${DOCBLOCK_END_LINE}`
}
/**
* Retrieves the parameters of a function node that aren't query/mutation options.
*
* @param {FunctionNode} node - The function node to retrieve its parameters.
* @returns {ts.ParameterDeclaration[]} - The function's actual parameters.
*/
getActualParameters(node: FunctionNode): ts.ParameterDeclaration[] {
return node.parameters.filter((parameter) => {
const parameterTypeStr = parameter.type?.getText()
return (
!parameterTypeStr?.startsWith("UseQueryOptionsWrapper") &&
!parameterTypeStr?.startsWith("UseMutationOptions") &&
!this.nodeHasComments(parameter)
)
})
}
/**
* Retreives a mutation's intrinsic request type, if available, which is specified as the third type argument of `UseMutationOptions`.
*
* @param {FunctionNode} node - The function node to retrieve its request type.
* @returns {ts.Type | undefined} The mutation's request type, if available.
*/
getMutationRequestTypeArg(node: FunctionNode): ts.Type | undefined {
const parameter = node.parameters.find(
(parameter) => parameter.type?.getText().startsWith("UseMutationOptions")
)
if (!parameter) {
return
}
const parameterType = this.checker.getTypeFromTypeNode(parameter.type!)
const typeArgs =
parameterType.aliasTypeArguments ||
("resolvedTypeArguments" in parameterType
? (parameterType.resolvedTypeArguments as ts.Type[])
: [])
if (
!typeArgs ||
typeArgs.length < 3 ||
!("intrinsicName" in typeArgs[2]) ||
["void", "unknown"].includes(typeArgs[2].intrinsicName as string)
) {
return
}
// find request in third type argument
return typeArgs[2]
}
}
export default MedusaReactHooksKindGenerator

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,70 @@
import ts from "typescript"
import FunctionKindGenerator from "./function.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"
import DmlKindGenerator from "./dml.js"
/**
* A class that is used as a registry for the kind generators.
*/
class KindsRegistry {
protected kindInstances: DefaultKindGenerator[]
protected defaultKindGenerator: DefaultKindGenerator
constructor(
options: Pick<
GeneratorOptions,
"checker" | "generatorEventManager" | "additionalOptions"
>
) {
this.kindInstances = [
new DmlKindGenerator(options),
new OasKindGenerator(options),
new MedusaReactHooksKindGenerator(options),
new FunctionKindGenerator(options),
new SourceFileKindGenerator(options),
new DTOPropertyGenerator(options),
]
this.defaultKindGenerator = new DefaultKindGenerator(options)
}
/**
* Retrieve the generator for a node based on its kind, if any.
*
* @param {ts.Node} node - The node to retrieve its docblock generator.
* @returns {DefaultKindGenerator | undefined} The generator that can handle the node's kind, if any.
*/
getKindGenerator(node: ts.Node): DefaultKindGenerator | undefined {
return (
this.kindInstances.find((generator) => generator.isAllowed(node)) ||
(this.defaultKindGenerator.isAllowed(node)
? this.defaultKindGenerator
: undefined)
)
}
/**
* Checks whether a node has a kind generator.
*
* @param {ts.Node} node - The node to check for.
* @returns {boolean} Whether the node has a kind generator.
*/
hasGenerator(node: ts.Node): boolean {
return this.getKindGenerator(node) !== undefined
}
/**
* Retrieves a generator by its name attribute.
*
* @param name - The name of the generator to retrieve.
* @returns The generator, if exists.
*/
getKindGeneratorByName(name: string): DefaultKindGenerator | undefined {
return this.kindInstances.find((generator) => generator.name === name)
}
}
export default KindsRegistry

View File

@@ -0,0 +1,38 @@
import ts from "typescript"
import DefaultKindGenerator, { GetDocBlockOptions } from "./default.js"
import { DOCBLOCK_END_LINE, DOCBLOCK_START } from "../../constants.js"
import { shouldHaveCustomNamespace } from "../../utils/medusa-react-utils.js"
/**
* A generator used to retrieve doc blocks for a source file.
*/
class SourceFileKindGenerator extends DefaultKindGenerator<ts.SourceFile> {
protected allowedKinds: ts.SyntaxKind[] = [ts.SyntaxKind.SourceFile]
public name = "source-file"
/**
* Retrieve the docblock of a source file.
*
* @param {ts.SourceFile | ts.Node} node - The node to retrieve its docblocks.
* @param {GetDocBlockOptions} options - The formatting options.
* @returns {string} The node's docblock.
*/
async getDocBlock(
node: ts.SourceFile | ts.Node,
options?: GetDocBlockOptions
): Promise<string> {
if (!this.isAllowed(node)) {
return await super.getDocBlock(node, options)
}
if (shouldHaveCustomNamespace(node)) {
return `${DOCBLOCK_START}${this.getCommonDocs(node, {
addDefaultSummary: true,
})}${DOCBLOCK_END_LINE}`
}
return ""
}
}
export default SourceFileKindGenerator

View File

@@ -0,0 +1,43 @@
import { readdirSync, existsSync, readFileSync, writeFileSync } from "fs"
import { getDmlOutputBasePath } from "../utils/get-output-base-paths.js"
import path from "path"
import getMonorepoRoot from "../utils/get-monorepo-root.js"
import { DmlFile } from "types"
import toJsonFormatted from "../utils/to-json-formatted.js"
export default async function () {
console.log("Cleaning DML JSON files...")
const dmlOutputPath = getDmlOutputBasePath()
const monorepoRoot = getMonorepoRoot()
const jsonFiles = readdirSync(dmlOutputPath)
jsonFiles.forEach((jsonFile) => {
const jsonFilePath = path.join(dmlOutputPath, jsonFile)
const parsedJson = JSON.parse(
readFileSync(jsonFilePath, "utf-8")
) as DmlFile
const dataModelKeys = Object.keys(parsedJson)
let dataUpdated = false
dataModelKeys.forEach((dataModelName) => {
const { filePath } = parsedJson[dataModelName]
const fullFilePath = path.join(monorepoRoot, filePath)
if (existsSync(fullFilePath)) {
return
}
// delete data model from json object
delete parsedJson[dataModelName]
dataUpdated = true
})
if (dataUpdated) {
writeFileSync(jsonFilePath, toJsonFormatted(parsedJson))
}
})
}

View File

@@ -0,0 +1,284 @@
import {
existsSync,
readFileSync,
readdirSync,
rmSync,
writeFileSync,
} from "fs"
import { OpenAPIV3 } from "openapi-types"
import path from "path"
import ts from "typescript"
import { parse, stringify } from "yaml"
import GeneratorEventManager from "../classes/helpers/generator-event-manager.js"
import OasSchemaHelper from "../classes/helpers/oas-schema.js"
import OasKindGenerator, { OasArea } from "../classes/kinds/oas.js"
import { DEFAULT_OAS_RESPONSES } from "../constants.js"
import { OpenApiDocument, OpenApiSchema } from "../types/index.js"
import getMonorepoRoot from "../utils/get-monorepo-root.js"
import { getOasOutputBasePath } from "../utils/get-output-base-paths.js"
import parseOas from "../utils/parse-oas.js"
const OAS_PREFIX_REGEX = /@oas \[(?<method>(get|post|delete))\] (?<path>.+)/
export default async function () {
const oasOutputBasePath = getOasOutputBasePath()
const oasOperationsPath = path.join(oasOutputBasePath, "operations")
const apiRoutesPath = path.join(
getMonorepoRoot(),
"packages",
"medusa",
"src",
"api"
)
const areas: OasArea[] = ["admin", "store"]
const tags: Map<OasArea, Set<string>> = new Map()
const oasSchemaHelper = new OasSchemaHelper()
const referencedSchemas: Set<string> = new Set()
const allSchemas: Set<string> = new Set()
areas.forEach((area) => {
tags.set(area, new Set<string>())
})
const testAndFindReferenceSchema = (
nestedSchema: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject
) => {
if (oasSchemaHelper.isRefObject(nestedSchema)) {
referencedSchemas.add(
oasSchemaHelper.normalizeSchemaName(nestedSchema.$ref)
)
} else {
findReferencedSchemas(nestedSchema)
}
}
const findReferencedSchemas = (schema: OpenApiSchema) => {
if (schema.properties) {
Object.values(schema.properties).forEach(testAndFindReferenceSchema)
} else if (schema.oneOf || schema.allOf || schema.anyOf) {
Object.values((schema.oneOf || schema.allOf || schema.anyOf)!).forEach(
testAndFindReferenceSchema
)
} else if (schema.type === "array") {
testAndFindReferenceSchema(schema.items)
}
}
console.log("Cleaning OAS files...")
// read files under the operations/{area} directory
areas.forEach((area) => {
const areaPath = path.join(oasOperationsPath, area)
if (!existsSync(areaPath)) {
return
}
readdirSync(areaPath, {
recursive: true,
encoding: "utf-8",
}).forEach((oasFile) => {
const filePath = path.join(areaPath, oasFile)
const { oas, oasPrefix } = parseOas(readFileSync(filePath, "utf-8")) || {}
if (!oas || !oasPrefix) {
return
}
// decode oasPrefix
const matchOasPrefix = OAS_PREFIX_REGEX.exec(oasPrefix)
if (!matchOasPrefix?.groups?.method || !matchOasPrefix.groups.path) {
return
}
const splitPath = matchOasPrefix.groups.path.substring(1).split("/")
// normalize path by replacing {paramName} with [paramName]
const normalizedOasPrefix = splitPath
.map((item) => item.replace(/^\{(.+)\}$/, "[$1]"))
.join("/")
const sourceFilePath = path.join(
apiRoutesPath,
normalizedOasPrefix,
"route.ts"
)
// check if a route exists for the path
if (!existsSync(sourceFilePath)) {
// remove OAS file
rmSync(filePath, {
force: true,
})
return
}
// check if method exists in the file
let exists = false
const program = ts.createProgram([sourceFilePath], {})
const oasKindGenerator = new OasKindGenerator({
checker: program.getTypeChecker(),
generatorEventManager: new GeneratorEventManager(),
additionalOptions: {},
})
const sourceFile = program.getSourceFile(sourceFilePath)
if (!sourceFile) {
// remove file
rmSync(filePath, {
force: true,
})
return
}
const visitChildren = (node: ts.Node) => {
if (
!exists &&
oasKindGenerator.isAllowed(node) &&
oasKindGenerator.canDocumentNode(node) &&
oasKindGenerator.getHTTPMethodName(node) ===
matchOasPrefix.groups!.method
) {
exists = true
} else if (!exists) {
ts.forEachChild(node, visitChildren)
}
}
ts.forEachChild(sourceFile, visitChildren)
if (!exists) {
// remove OAS file
rmSync(filePath, {
force: true,
})
return
}
// collect tags
oas.tags?.forEach((tag) => {
const areaTags = tags.get(area as OasArea)
areaTags?.add(tag)
})
// collect schemas
if (oas.requestBody) {
if (oasSchemaHelper.isRefObject(oas.requestBody)) {
referencedSchemas.add(
oasSchemaHelper.normalizeSchemaName(oas.requestBody.$ref)
)
} else {
const requestBodySchema =
oas.requestBody.content[Object.keys(oas.requestBody.content)[0]]
.schema
if (requestBodySchema) {
testAndFindReferenceSchema(requestBodySchema)
}
}
}
if (oas.responses) {
const successResponseKey = Object.keys(oas.responses)[0]
if (!Object.keys(DEFAULT_OAS_RESPONSES).includes(successResponseKey)) {
const responseObj = oas.responses[successResponseKey]
if (oasSchemaHelper.isRefObject(responseObj)) {
referencedSchemas.add(
oasSchemaHelper.normalizeSchemaName(responseObj.$ref)
)
} else if (responseObj.content) {
const responseBodySchema =
responseObj.content[Object.keys(responseObj.content)[0]].schema
if (responseBodySchema) {
testAndFindReferenceSchema(responseBodySchema)
}
}
}
}
})
})
console.log("Clean tags...")
// check if any tags should be removed
const oasBasePath = path.join(oasOutputBasePath, "base")
readdirSync(oasBasePath, {
recursive: true,
encoding: "utf-8",
}).forEach((baseYaml) => {
const baseYamlPath = path.join(oasBasePath, baseYaml)
const parsedBaseYaml = parse(
readFileSync(baseYamlPath, "utf-8")
) as OpenApiDocument
const area = path.basename(baseYaml).split(".")[0] as OasArea
const areaTags = tags.get(area)
if (!areaTags) {
return
}
const lengthBefore = parsedBaseYaml.tags?.length || 0
parsedBaseYaml.tags = parsedBaseYaml.tags?.filter((tag) =>
areaTags.has(tag.name)
)
if (lengthBefore !== (parsedBaseYaml.tags?.length || 0)) {
// sort alphabetically
parsedBaseYaml.tags?.sort((tagA, tagB) => {
return tagA.name.localeCompare(tagB.name)
})
// write to the file
writeFileSync(baseYamlPath, stringify(parsedBaseYaml))
}
// collect referenced schemas
parsedBaseYaml.tags?.forEach((tag) => {
if (tag["x-associatedSchema"]) {
referencedSchemas.add(
oasSchemaHelper.normalizeSchemaName(tag["x-associatedSchema"].$ref)
)
}
})
})
console.log("Clean schemas...")
// check if any schemas should be removed
// a schema is removed if no other schemas/operations reference it
const oasSchemasPath = path.join(oasOutputBasePath, "schemas")
readdirSync(oasSchemasPath, {
recursive: true,
encoding: "utf-8",
}).forEach((schemaYaml) => {
const schemaPath = path.join(oasSchemasPath, schemaYaml)
const parsedSchema = oasSchemaHelper.parseSchema(
readFileSync(schemaPath, "utf-8")
)
if (!parsedSchema) {
// remove file
rmSync(schemaPath, {
force: true,
})
return
}
// add schema to all schemas
if (parsedSchema.schema["x-schemaName"]) {
allSchemas.add(parsedSchema.schema["x-schemaName"])
}
// collect referenced schemas
findReferencedSchemas(parsedSchema.schema)
})
// clean up schemas
allSchemas.forEach((schemaName) => {
if (referencedSchemas.has(schemaName)) {
return
}
// schema isn't referenced anywhere, so remove it
rmSync(path.join(oasSchemasPath, `${schemaName}.ts`), {
force: true,
})
})
console.log("Finished clean up")
}

View File

@@ -0,0 +1,57 @@
import path from "path"
import DocblockGenerator from "../classes/generators/docblock.js"
import getMonorepoRoot from "../utils/get-monorepo-root.js"
import { GitManager } from "../classes/helpers/git-manager.js"
import { CommonCliOptions } from "../types/index.js"
import OasGenerator from "../classes/generators/oas.js"
import DmlGenerator from "../classes/generators/dml.js"
export default async function runGitChanges({
type,
...options
}: CommonCliOptions) {
const monorepoPath = getMonorepoRoot()
// retrieve the changed files under `packages` in the monorepo root.
const gitManager = new GitManager()
let files = await gitManager.getDiffFiles()
if (!files.length) {
console.log(`No file changes detected.`)
return
}
console.log(
`${files.length} files have changed. Running generator on them...`
)
files = files.map((filePath) => path.resolve(monorepoPath, filePath))
if (type === "all" || type === "docs") {
const docblockGenerator = new DocblockGenerator({
paths: files,
...options,
})
await docblockGenerator.run()
}
if (type === "all" || type === "oas") {
const oasGenerator = new OasGenerator({
paths: files,
...options,
})
await oasGenerator.run()
}
if (type === "all" || type === "dml") {
const dmlGenerator = new DmlGenerator({
paths: files,
...options,
})
await dmlGenerator.run()
}
console.log(`Finished generating docs for ${files.length} files.`)
}

View File

@@ -0,0 +1,65 @@
import filterFiles from "../utils/filter-files.js"
import path from "path"
import getMonorepoRoot from "../utils/get-monorepo-root.js"
import DocblockGenerator from "../classes/generators/docblock.js"
import OasGenerator from "../classes/generators/oas.js"
import { CommonCliOptions } from "../types/index.js"
import { GitManager } from "../classes/helpers/git-manager.js"
import DmlGenerator from "../classes/generators/dml.js"
export default async function (
commitSha: string,
{ type, ...options }: CommonCliOptions
) {
const monorepoPath = getMonorepoRoot()
// retrieve the files changed in the commit
const gitManager = new GitManager()
const files = await gitManager.getCommitFiles(commitSha)
// filter changed files
let filteredFiles = filterFiles(files?.map((file) => file.filename) || [])
if (!filteredFiles.length) {
console.log("No applicable files changed. Canceling...")
return
}
console.log(
`${filteredFiles.length} files have changed. Running generator on them...`
)
filteredFiles = filteredFiles.map((filePath) =>
path.resolve(monorepoPath, filePath)
)
// generate docblocks for each of the files.
if (type === "all" || type === "docs") {
const docblockGenerator = new DocblockGenerator({
paths: filteredFiles,
...options,
})
await docblockGenerator.run()
}
if (type === "all" || type === "oas") {
const oasGenerator = new OasGenerator({
paths: filteredFiles,
...options,
})
await oasGenerator.run()
}
if (type === "all" || type === "dml") {
const dmlGenerator = new DmlGenerator({
paths: filteredFiles,
...options,
})
await dmlGenerator.run()
}
console.log(`Finished generating docs for ${filteredFiles.length} files.`)
}

View File

@@ -0,0 +1,63 @@
import filterFiles from "../utils/filter-files.js"
import path from "path"
import DocblockGenerator from "../classes/generators/docblock.js"
import getMonorepoRoot from "../utils/get-monorepo-root.js"
import { GitManager } from "../classes/helpers/git-manager.js"
import OasGenerator from "../classes/generators/oas.js"
import { CommonCliOptions } from "../types/index.js"
import DmlGenerator from "../classes/generators/dml.js"
export default async function ({ type, tag, ...options }: CommonCliOptions) {
const gitManager = new GitManager()
console.log(`Get files in commits since ${tag || "last release"}`)
const files = tag
? await gitManager.getCommitFilesSinceRelease(tag)
: await gitManager.getCommitFilesSinceLastRelease()
// filter changed files
let filteredFiles = filterFiles(files)
if (!filteredFiles.length) {
console.log("No applicable files changed. Canceling...")
return
}
console.log(
`${filteredFiles.length} files have changed. Running generator on them...`
)
filteredFiles = filteredFiles.map((filePath) =>
path.resolve(getMonorepoRoot(), filePath)
)
if (type === "all" || type === "docs") {
const docblockGenerator = new DocblockGenerator({
paths: filteredFiles,
...options,
})
await docblockGenerator.run()
}
if (type === "all" || type === "oas") {
const oasGenerator = new OasGenerator({
paths: filteredFiles,
...options,
})
await oasGenerator.run()
}
if (type === "all" || type === "dml") {
const dmlGenerator = new DmlGenerator({
paths: filteredFiles,
...options,
})
await dmlGenerator.run()
}
console.log(`Finished generating docs for ${filteredFiles.length} files.`)
}

View File

@@ -0,0 +1,41 @@
import DmlGenerator from "../classes/generators/dml.js"
import DocblockGenerator from "../classes/generators/docblock.js"
import { Options } from "../classes/generators/index.js"
import OasGenerator from "../classes/generators/oas.js"
import { CommonCliOptions } from "../types/index.js"
export default async function run(
paths: string[],
{ type, ...options }: Omit<Options, "paths"> & CommonCliOptions
) {
console.log("Running...")
if (type === "all" || type === "docs") {
const docblockGenerator = new DocblockGenerator({
paths,
...options,
})
await docblockGenerator.run()
}
if (type === "all" || type === "oas") {
const oasGenerator = new OasGenerator({
paths,
...options,
})
await oasGenerator.run()
}
if (type === "all" || type === "dml") {
const dmlGenerator = new DmlGenerator({
paths,
...options,
})
await dmlGenerator.run()
}
console.log(`Finished running.`)
}

View File

@@ -0,0 +1,35 @@
import { OpenAPIV3 } from "openapi-types"
export const DOCBLOCK_LINE_ASTRIX = " * "
export const DOCBLOCK_NEW_LINE = `\n${DOCBLOCK_LINE_ASTRIX}`
export const DOCBLOCK_START = `*${DOCBLOCK_NEW_LINE}`
export const DOCBLOCK_END_LINE = "\n"
export const DOCBLOCK_DOUBLE_LINES = `${DOCBLOCK_NEW_LINE}${DOCBLOCK_NEW_LINE}`
export const DEFAULT_OAS_RESPONSES: {
[k: string]: OpenAPIV3.ReferenceObject
} = {
"400": {
$ref: "#/components/responses/400_error",
},
"401": {
$ref: "#/components/responses/unauthorized",
},
"404": {
$ref: "#/components/responses/not_found_error",
},
"409": {
$ref: "#/components/responses/invalid_state_error",
},
"422": {
$ref: "#/components/responses/invalid_request_error",
},
"500": {
$ref: "#/components/responses/500_error",
},
}
export const API_ROUTE_PARAM_REGEX = /\[(.+?)\]/g
// we can't use `{summary}` because it causes an MDX error
// when we finally render the summary. We can alternatively
// use `\{summary\}` but it wouldn't look pretty in the OAS,
// so doing this for now.
export const SUMMARY_PLACEHOLDER = "SUMMARY"

View File

@@ -0,0 +1,79 @@
#!/usr/bin/env node
import "dotenv/config"
import { Command, Option } from "commander"
import run from "./commands/run.js"
import runGitChanges from "./commands/run-git-changes.js"
import runGitCommit from "./commands/run-git-commit.js"
import runRelease from "./commands/run-release.js"
import cleanOas from "./commands/clean-oas.js"
import cleanDml from "./commands/clean-dml.js"
const program = new Command()
program.name("docs-generator").description("Generate TSDoc doc-blocks")
// define common options
const typeOption = new Option("--type <type>", "The type of docs to generate.")
.choices(["all", "docs", "oas", "dml"])
.default("all")
const generateExamplesOption = new Option(
"--generate-examples",
"Whether to generate examples"
).default(false)
program
.command("run")
.description("Generate TSDoc doc-blocks for specified files.")
.argument("<files...>", "One or more TypeScript file or directory paths.")
.option(
"--dry-run",
"Whether to run the command without writing the changes."
)
.addOption(typeOption)
.addOption(generateExamplesOption)
.action(run)
program
.command("run:changes")
.description("Generate TSDoc doc-blocks for changed files in git.")
.addOption(typeOption)
.addOption(generateExamplesOption)
.action(runGitChanges)
program
.command("run:commit")
.description("Generate TSDoc doc-blocks for changed files in a commit.")
.argument("<commitSha>", "The SHA of a commit.")
.addOption(typeOption)
.addOption(generateExamplesOption)
.action(runGitCommit)
program
.command("run:release")
.description(
"Generate TSDoc doc-blocks for files part of the latest release. It will retrieve the files of commits between the latest two releases."
)
.addOption(typeOption)
.addOption(generateExamplesOption)
.option(
"--tag <tag>",
"Specify a release tag to use rather than the latest release."
)
.action(runRelease)
program
.command("clean:oas")
.description(
"Check generated OAS under the `oas-output/operations` directory and remove any OAS that no longer exists."
)
.action(cleanOas)
program
.command("clean:dml")
.description(
"Check generated DML files under the `dml-output` directory and remove any data models that no longer exists."
)
.action(cleanDml)
program.parse()

View File

@@ -0,0 +1,39 @@
import { OpenAPIV3 } from "openapi-types"
declare type CodeSample = {
lang: string
label: string
source: string
}
export declare type OpenApiOperation = Partial<OpenAPIV3.OperationObject> & {
"x-authenticated"?: boolean
"x-codeSamples"?: CodeSample[]
}
export declare type CommonCliOptions = {
type: "all" | "oas" | "docs" | "dml"
generateExamples?: boolean
tag?: string
}
export declare type OpenApiSchema = OpenAPIV3.SchemaObject & {
"x-schemaName"?: string
}
export declare interface OpenApiTagObject extends OpenAPIV3.TagObject {
"x-associatedSchema"?: OpenAPIV3.ReferenceObject
}
export declare interface OpenApiDocument extends OpenAPIV3.Document {
tags?: OpenApiTagObject[]
}
export declare type DmlObject = Record<string, string>
export declare type DmlFile = {
[k: string]: {
filePath: string
properties: DmlObject
}
}

View File

@@ -0,0 +1,8 @@
import path from "path"
import { fileURLToPath } from "url"
export default function dirname(fileUrl: string) {
const __filename = fileURLToPath(fileUrl)
return path.dirname(__filename)
}

View File

@@ -0,0 +1,13 @@
import { minimatch } from "minimatch"
export default function (files: string[]): string[] {
return files.filter((file) =>
minimatch(
file,
"**/packages/@(medusa|core/types|medusa-js|medusa-react)/src/**/*.@(ts|tsx|js|jsx)",
{
matchBase: true,
}
)
)
}

View File

@@ -0,0 +1,20 @@
import { stringify } from "yaml"
import { DOCBLOCK_END_LINE, DOCBLOCK_NEW_LINE } from "../constants.js"
import { OpenApiOperation, OpenApiSchema } from "../types/index.js"
/**
* Retrieve the OAS as a formatted string that can be used as a comment.
*
* @param oas - The OAS operation to format.
* @param oasPrefix - The OAS prefix that's used before the OAS operation.
* @returns The formatted OAS comment.
*/
export default function formatOas(
oas: OpenApiOperation | OpenApiSchema,
oasPrefix: string
) {
return `* ${oasPrefix}${DOCBLOCK_NEW_LINE}${stringify(oas).replaceAll(
"\n",
DOCBLOCK_NEW_LINE
)}${DOCBLOCK_END_LINE}`
}

View File

@@ -0,0 +1,15 @@
/**
* 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.
*/
export default function getBasePath(fileName: string) {
let basePath = fileName
const packageIndex = fileName.indexOf("packages/")
if (packageIndex) {
basePath = basePath.substring(packageIndex)
}
return basePath
}

View File

@@ -0,0 +1,23 @@
// Due to some types using Zod in their declaration
// The type name isn't picked properly by typescript
import ts from "typescript"
import isZodObject from "./is-zod-object.js"
// this ensures that the correct type name is used.
export default function getCorrectZodTypeName({
typeReferenceNode,
itemType,
}: {
typeReferenceNode: ts.TypeReferenceNode
itemType: ts.Type
}): string | undefined {
if (!isZodObject(itemType)) {
return
}
return typeReferenceNode.typeArguments?.[0] &&
"typeName" in typeReferenceNode.typeArguments[0]
? (typeReferenceNode.typeArguments?.[0].typeName as ts.Identifier).getText()
: undefined
}

View File

@@ -0,0 +1,15 @@
import path from "path"
import dirname from "./dirname.js"
/**
* Retrieves the monorepo root either from the `MONOREPO_ROOT_PATH` environment
* variable, or inferring it from the path.
*
* @returns {string} The absolute path to the monorepository.
*/
export default function getMonorepoRoot(): string {
return (
process.env.MONOREPO_ROOT_PATH ||
path.join(dirname(import.meta.url), "..", "..", "..", "..", "..", "..")
)
}

View File

@@ -0,0 +1,16 @@
import path from "path"
import getMonorepoRoot from "./get-monorepo-root.js"
/**
* Retrieves the base path to the `oas-output` directory.
*/
export function getOasOutputBasePath() {
return path.join(getMonorepoRoot(), "www", "utils", "generated", "oas-output")
}
/**
* Retrieves the base path to the `dml-output` directory
*/
export function getDmlOutputBasePath() {
return path.join(getMonorepoRoot(), "www", "utils", "generated", "dml-output")
}

View File

@@ -0,0 +1,15 @@
import path from "path"
/**
* Get relative path of multiple file paths to a specified path.
*
* @param {string[]} filePaths - The file paths to retrieve their relative path.
* @param {string} pathPrefix - The path to retrieve paths relative to.
* @returns {string[]} The relative file paths.
*/
export default function getRelativePaths(
filePaths: string[],
pathPrefix: string
): string[] {
return filePaths.map((filePath) => path.resolve(pathPrefix, filePath))
}

View File

@@ -0,0 +1,24 @@
import ts from "typescript"
/**
* Retrieves the symbol of a node.
*
* @param {ts.Node} node - The node to retrieve its symbol.
* @param {ts.TypeChecker} checker - The type checker of the TypeScript program the symbol is in.
* @returns {ts.Symbol | undefined} The symbol if found.
*/
export default function getSymbol(
node: ts.Node,
checker: ts.TypeChecker
): ts.Symbol | undefined {
if (
ts.isVariableStatement(node) &&
node.declarationList.declarations.length
) {
return getSymbol(node.declarationList.declarations[0], checker)
}
return "symbol" in node && node.symbol
? (node.symbol as ts.Symbol)
: undefined
}

View File

@@ -0,0 +1,15 @@
import ts from "typescript"
export default function isZodObject(itemType: ts.Type): boolean {
if (!itemType.symbol?.declarations?.length) {
return false
}
const parent = itemType.symbol.declarations[0].parent
if (!("typeName" in parent)) {
return false
}
return (parent.typeName as ts.Identifier).getText().includes("ZodObject")
}

View File

@@ -0,0 +1,101 @@
import path from "path"
import getMonorepoRoot from "./get-monorepo-root.js"
import ts from "typescript"
import { minimatch } from "minimatch"
import { capitalize } from "utils"
export const kindsCanHaveNamespace = [
ts.SyntaxKind.SourceFile,
ts.SyntaxKind.ClassDeclaration,
ts.SyntaxKind.EnumDeclaration,
ts.SyntaxKind.ModuleDeclaration,
ts.SyntaxKind.InterfaceDeclaration,
ts.SyntaxKind.TypeAliasDeclaration,
ts.SyntaxKind.MethodDeclaration,
ts.SyntaxKind.MethodSignature,
ts.SyntaxKind.FunctionDeclaration,
ts.SyntaxKind.ArrowFunction,
ts.SyntaxKind.VariableStatement,
]
export const pathsHavingCustomNamespace = [
"**/packages/medusa\\-react/src/hooks/**/index.ts",
"**/packages/medusa\\-react/src/@(helpers|contexts)/**/*.@(tsx|ts)",
]
export const CUSTOM_NAMESPACE_TAG = "@customNamespace"
/**
* Get the path used with the {@link CUSTOM_NAMESPACE_TAG}.
*
* @param {ts.Node} node - The node to retrieve its custom namespace path.
* @returns {string} The namespace path.
*/
export function getNamespacePath(node: ts.Node): string {
const packagePathPrefix = `${path.resolve(
getMonorepoRoot(),
"packages/medusa-react/src"
)}/`
const sourceFile = node.getSourceFile()
let hookPath = path
.dirname(sourceFile.fileName)
.replace(packagePathPrefix, "")
const fileName = path.basename(sourceFile.fileName)
if (
!fileName.startsWith("index") &&
!fileName.startsWith("mutations") &&
!fileName.startsWith("queries")
) {
hookPath += `/${fileName.replace(path.extname(fileName), "")}`
}
return hookPath
.split("/")
.map((pathItem, index) => {
if (index === 0) {
pathItem = pathItem
.replace("contexts", "providers")
.replace("helpers", "utilities")
}
return pathItem
.split("-")
.map((item) => capitalize(item))
.join(" ")
})
.join(".")
}
/**
* Retrieves the full tag of the custom namespace with its value.
*
* @param {ts.Node} node - The node to retrieve its custom namespace path.
* @returns {string} The custom namespace tag and value.
*/
export function getCustomNamespaceTag(node: ts.Node): string {
return `${CUSTOM_NAMESPACE_TAG} ${getNamespacePath(node)}`
}
/**
* Checks whether a node should have a custom namespace path.
*
* @param {ts.Node} node - The node to check.
* @returns {boolean} Whether the node should have a custom namespace.
*/
export function shouldHaveCustomNamespace(node: ts.Node): boolean {
if (!kindsCanHaveNamespace.includes(node.kind)) {
return false
}
const fileName = node.getSourceFile().fileName
return pathsHavingCustomNamespace.some((pattern) =>
minimatch(fileName, pattern, {
matchBase: true,
})
)
}

View File

@@ -0,0 +1,42 @@
import { parse } from "yaml"
import { OpenApiOperation } from "../types/index.js"
import { DOCBLOCK_LINE_ASTRIX } from "../constants.js"
export type ExistingOas = {
oas: OpenApiOperation
oasPrefix: string
}
export default function parseOas(content: string): ExistingOas | undefined {
content = content
.replace(`/**\n`, "")
.replaceAll(DOCBLOCK_LINE_ASTRIX, "")
.replaceAll("*/", "")
.trim()
if (!content.startsWith("@oas")) {
// the file is of an invalid format.
return
}
// extract oas prefix line
const splitNodeComments = content.split("\n")
const oasPrefix = content.split("\n")[0]
content = splitNodeComments.slice(1).join("\n")
let oas: OpenApiOperation | undefined
try {
oas = parse(content) as OpenApiOperation
} catch (e) {
// couldn't parse the OAS, so consider it
// not existent
}
return oas
? {
oas,
oasPrefix,
}
: undefined
}

View File

@@ -0,0 +1,4 @@
import util from "node:util"
import { exec } from "child_process"
export default util.promisify(exec)

View File

@@ -0,0 +1,15 @@
/**
* Remove parts of the name such as DTO, Filterable, etc...
*
* @param {string} str - The name to format.
* @returns {string} The normalized name.
*/
export function normalizeName(str: string): string {
return str
.replace(/^(create|update|delete|upsert)/i, "")
.replace(/DTO$/, "")
.replace(/^Filterable/, "")
.replace(/Props$/, "")
.replace(/^I([A-Z])/, "$1")
.replace(/ModuleService$/, "")
}

View File

@@ -0,0 +1,9 @@
/**
* Retrieves the stringified JSON of a variable formatted.
*
* @param item The item to stringify
* @returns The formatted JSON string
*/
export default function toJsonFormatted(item: unknown): string {
return JSON.stringify(item, undefined, "\t")
}