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