Files
medusa-store/docs-util/packages/docblock-generator/src/classes/kinds/oas.ts
Shahed Nasser 21156f945d docs-util: added AI generator (#6770)
## What

Adds an AI generator to the docblock tool that uses OpenAI.

The generator at the moment only generates examples for functions when the `--generate-examples` option is provided.

## Note

I've included the generated examples of the `IOrderModuleService` as a reference of the type of result provided by the AI generator, with minor tweeks I've made. I haven't made any changes to descriptions in that file.
2024-03-28 11:32:30 +00:00

1882 lines
54 KiB
TypeScript

import ts, { SyntaxKind } from "typescript"
import FunctionKindGenerator, {
FunctionNode,
FunctionOrVariableNode,
VariableNode,
} from "./function.js"
import { GeneratorOptions, GetDocBlockOptions } from "./default.js"
import { basename, join } from "path"
import {
capitalize,
kebabToTitle,
wordsToKebab,
} from "../../utils/str-formatting.js"
import {
OpenApiDocument,
OpenApiOperation,
OpenApiSchema,
} from "../../types/index.js"
import { OpenAPIV3 } from "openapi-types"
import { parse, stringify } from "yaml"
import { GeneratorEvent } from "../helpers/generator-event-manager.js"
import { readFileSync, writeFileSync } from "fs"
import OasExamplesGenerator from "../examples/oas.js"
import pluralize from "pluralize"
import getOasOutputBasePath from "../../utils/get-oas-output-base-path.js"
import parseOas, { ExistingOas } from "../../utils/parse-oas.js"
import OasSchemaHelper from "../helpers/oas-schema.js"
import formatOas from "../../utils/format-oas.js"
import { DEFAULT_OAS_RESPONSES } from "../../constants.js"
export const API_ROUTE_PARAM_REGEX = /\[(.+?)\]/g
const RES_STATUS_REGEX = /^res[\s\S]*\.status\((\d+)\)/
type SchemaDescriptionOptions = {
symbol?: ts.Symbol
node?: ts.Node
nodeType?: ts.Type
typeStr: string
parentName?: string
}
export type OasArea = "admin" | "store"
type ParameterType = "query" | "path"
/**
* OAS generator for API routes. It extends the {@link FunctionKindGenerator}
* since API routes are functions.
*/
class OasKindGenerator extends FunctionKindGenerator {
public name = "oas"
protected allowedKinds: SyntaxKind[] = [ts.SyntaxKind.FunctionDeclaration]
private MAX_LEVEL = 4
// 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.
protected defaultSummary = "SUMMARY"
/**
* This map collects tags of all the generated OAS, then, once the generation process finishes,
* it checks if it should be added to the base OAS document of the associated area.
*/
private tags: Map<OasArea, Set<string>>
/**
* The path to the directory holding the base YAML files.
*/
protected baseOutputPath: string
protected oasExamplesGenerator: OasExamplesGenerator
protected oasSchemaHelper: OasSchemaHelper
constructor(options: GeneratorOptions) {
super(options)
this.oasExamplesGenerator = new OasExamplesGenerator()
this.baseOutputPath = getOasOutputBasePath()
this.tags = new Map()
this.oasSchemaHelper = new OasSchemaHelper()
this.init()
this.generatorEventManager.listen(
GeneratorEvent.FINISHED_GENERATE_EVENT,
this.afterGenerate.bind(this)
)
}
/**
* Check whether the generator can be used for the specified node. The node must be a function that has
* two parameters of types `MedusaRequest` and `MedusaResponse` respectively.
*
* @param node - The node to check.
* @returns Whether the generator can be used for the specified node.
*/
isAllowed(node: ts.Node): node is FunctionOrVariableNode {
const isFunction =
this.allowedKinds.includes(node.kind) ||
(ts.isVariableStatement(node) && this.isFunctionVariable(node))
if (!isFunction) {
return false
}
const functionNode = ts.isFunctionDeclaration(node)
? node
: this.extractFunctionNode(node as VariableNode)
if (!functionNode) {
return false
}
// function must have 2 parameters, first parameter of type `MedusaRequest`
// and the second of type `MedusaResponse`
return (
(functionNode.parameters.length === 2 &&
(functionNode.parameters[0].type
?.getText()
.startsWith("MedusaRequest") ||
functionNode.parameters[0].type
?.getText()
.startsWith("AuthenticatedMedusaRequest")) &&
functionNode.parameters[1].type
?.getText()
.startsWith("MedusaResponse")) ||
false
)
}
/**
* Check whether the node can be documented.
*
* @param node - The node to check.
* @returns Whether the node can be documented.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
canDocumentNode(node: ts.Node): boolean {
// unlike other generators, this one
// can update existing OAS, so we just return
// true
return true
}
/**
* Try to retrieve the OAS previously generated for the function. If the OAS is retrieved, the
* OAS is updated rather than created.
*
* @param node - The node to retrieve its existing OAS.
* @returns The node's existing OAS, if available.
*/
getExistingOas(node: FunctionOrVariableNode): ExistingOas | undefined {
// read the file holding the OAS, if it's available.
const fileContent = ts.sys.readFile(this.getAssociatedFileName(node))
if (!fileContent) {
// the file doesn't exist, meaning there's no existing OAS.
return
}
return parseOas(fileContent)
}
/**
* Retrieves the docblock of the node. If the node has existing OAS, the OAS is updated and returned. Otherwise,
* the OAS is generated.
*
* @param node - The node to get its OAS.
* @param options - The options to get the OAS.
* @returns The OAS as a string that can be used as a comment in a TypeScript file.
*/
async getDocBlock(
node: ts.Node | FunctionOrVariableNode,
options?: GetDocBlockOptions
): Promise<string> {
// TODO use AiGenerator to generate descriptions + examples
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)
}
const methodName = this.getHTTPMethodName(node)
const existingOas = this.getExistingOas(node)
if (existingOas?.oas) {
return this.updateExistingOas({
node: actualNode,
methodName,
oasOptions: existingOas,
})
}
return this.getNewOasDocBlock({
node: actualNode,
methodName,
})
}
/**
* Generate OAS of a node.
*
* @param param0 - The node's details.
* @returns The OAS comment.
*/
getNewOasDocBlock({
node,
methodName,
}: {
/**
* The node to generate its OAS.
*/
node: FunctionNode
/**
* The lowercase name of the method. For example, `get`.
*/
methodName: string
}): string {
// collect necessary variables
const { oasPath, normalized: normalizedOasPath } = this.getOasPath(node)
const splitOasPath = oasPath.split("/")
const oasPrefix = this.getOasPrefix(methodName, normalizedOasPath)
const { isAdminAuthenticated, isStoreAuthenticated, isAuthenticated } =
this.getAuthenticationDetails(node, oasPath)
const tagName = this.getTagName(splitOasPath)
const { summary, description } = this.getSummaryAndDescription({
oasPath,
httpMethod: methodName,
tag: tagName || "",
})
// construct oas
const oas: OpenApiOperation = {
operationId: this.getOperationId({
methodName,
splitOasPath,
}),
summary,
description,
"x-authenticated": isAuthenticated,
parameters: this.getPathParameters({ oasPath, tagName }),
security: [],
}
// retreive query and request parameters
const { queryParameters, requestSchema } = this.getRequestParameters({
node,
tagName,
})
oas.parameters?.push(...queryParameters)
if (requestSchema && Object.keys(requestSchema).length > 0) {
oas.requestBody = {
content: {
"application/json": {
schema:
this.oasSchemaHelper.schemaToReference(requestSchema) ||
requestSchema,
},
},
}
}
// retrieve response schema
const responseSchema = this.getResponseSchema({
node,
tagName,
})
// retrieve code examples
// only generate cURL examples, and for the rest
// check if the --generate-examples option is enabled
oas["x-codeSamples"] = [
{
...OasExamplesGenerator.CURL_CODESAMPLE_DATA,
source: this.oasExamplesGenerator.generateCurlExample({
method: methodName,
path: normalizedOasPath,
isAdminAuthenticated,
isStoreAuthenticated,
requestSchema,
}),
},
]
if (this.options.generateExamples) {
oas["x-codeSamples"].push(
{
...OasExamplesGenerator.JSCLIENT_CODESAMPLE_DATA,
source: this.oasExamplesGenerator.generateJSClientExample({
oasPath,
httpMethod: methodName,
area: splitOasPath[0] as OasArea,
tag: tagName || "",
isAdminAuthenticated,
isStoreAuthenticated,
parameters: (oas.parameters as OpenAPIV3.ParameterObject[])?.filter(
(parameter) => parameter.in === "path"
),
requestBody: requestSchema,
responseBody: responseSchema,
}),
},
{
...OasExamplesGenerator.MEDUSAREACT_CODESAMPLE_DATA,
source: "EXAMPLE", // TODO figure out if we can generate examples for medusa react
}
)
}
// add security details if applicable
oas.security = this.getSecurity({ isAdminAuthenticated, isAuthenticated })
if (tagName) {
oas.tags = [tagName]
}
// detect returned response status
const responseStatus = this.getResponseStatus(node)
// add responses
oas.responses = {
[responseStatus]: {
description: "OK",
},
}
if (responseSchema && Object.keys(responseSchema).length > 0) {
;(oas.responses[responseStatus] as OpenAPIV3.ResponseObject).content = {
"application/json": {
schema:
this.oasSchemaHelper.schemaToReference(responseSchema) ||
responseSchema,
},
}
}
oas.responses = {
...(oas.responses || {}),
...DEFAULT_OAS_RESPONSES,
}
// push new tag to the tags property
if (tagName) {
const areaTags = this.tags.get(splitOasPath[0] as OasArea)
areaTags?.add(tagName)
}
return formatOas(oas, oasPrefix)
}
/**
* Update an existing OAS operation.
*
* @param param0 - The OAS's details.
* @returns The updated OAS
*/
updateExistingOas({
node,
methodName,
oasOptions: { oas, oasPrefix },
}: {
/**
* The node that the OAS is associated with.
*/
node: FunctionNode
/**
* The lower case method name of the operation.
*/
methodName: string
/**
* The existing OAS's details.
*/
oasOptions: ExistingOas
}): string {
// collect necessary variables
const { oasPath, normalized: normalizedOasPath } = this.getOasPath(node)
const splitOasPath = oasPath.split("/")
const tagName = this.getTagName(splitOasPath)
// update tag name
oas.tags = tagName ? [tagName] : []
// check if the prefix line should be updated.
const updatedOasPrefix = this.getOasPrefix(methodName, normalizedOasPath)
if (updatedOasPrefix !== oasPrefix) {
oasPrefix = updatedOasPrefix
}
// check if operation ID should be updated
const updatedOperationId = this.getOperationId({
methodName,
splitOasPath,
})
if (updatedOperationId !== oas.operationId) {
oas.operationId = updatedOperationId
}
// update summary and description either if they're empty or default summary
const shouldUpdateSummary =
!oas.summary || oas.summary === this.defaultSummary
const shouldUpdateDescription =
!oas.description || oas.description === this.defaultSummary
if (shouldUpdateSummary || shouldUpdateDescription) {
const { summary, description } = this.getSummaryAndDescription({
oasPath,
httpMethod: methodName,
tag: tagName || "",
})
if (shouldUpdateSummary) {
oas.summary = summary
}
if (shouldUpdateDescription) {
oas.description = description
}
}
// check if authentication details (including security) should be updated
const { isAdminAuthenticated, isStoreAuthenticated, isAuthenticated } =
this.getAuthenticationDetails(node, oasPath)
oas["x-authenticated"] = isAuthenticated
oas.security = this.getSecurity({ isAdminAuthenticated, isAuthenticated })
// update path parameters
const newPathParameters = this.getPathParameters({ oasPath, tagName })
oas.parameters = this.updateParameters({
oldParameters: oas.parameters as OpenAPIV3.ParameterObject[],
newParameters: newPathParameters,
type: "path",
})
// retrieve updated query and request schemas
const { queryParameters, requestSchema } = this.getRequestParameters({
node,
tagName,
})
// update query parameters
oas.parameters = this.updateParameters({
oldParameters: oas.parameters as OpenAPIV3.ParameterObject[],
newParameters: queryParameters,
type: "query",
})
// update request schema
const existingRequestBodySchema = (
oas.requestBody as OpenAPIV3.RequestBodyObject
)?.content?.["application/json"].schema as OpenApiSchema
const updatedRequestSchema = this.updateSchema({
oldSchema: existingRequestBodySchema,
newSchema: requestSchema,
})
if (!updatedRequestSchema && existingRequestBodySchema) {
// if there's no request schema, remove it from the OAS
delete oas.requestBody
} else {
// update the schema
oas.requestBody = {
content: {
"application/json": {
schema: updatedRequestSchema
? this.oasSchemaHelper.schemaToReference(updatedRequestSchema) ||
updatedRequestSchema
: updatedRequestSchema,
},
},
}
}
// update response schema and status
const newStatus = this.getResponseStatus(node)
const newResponseSchema = this.getResponseSchema({
node,
tagName,
})
let updatedResponseSchema: OpenApiSchema | undefined
if (!oas.responses && newResponseSchema) {
// add response schema
oas.responses = {
[newStatus]: {
description: "OK",
content: {
"application/json": {
schema:
this.oasSchemaHelper.schemaToReference(newResponseSchema) ||
newResponseSchema,
},
},
},
...DEFAULT_OAS_RESPONSES,
}
updatedResponseSchema = newResponseSchema
} else if (oas.responses && !newResponseSchema) {
// remove response schema by only keeping the default responses
oas.responses = DEFAULT_OAS_RESPONSES
} else {
// check if response status should be changed
const oldResponseStatus = Object.keys(oas.responses!).find(
(status) => !Object.keys(DEFAULT_OAS_RESPONSES).includes(status)
)
const oldResponseSchema = oldResponseStatus
? ((oas.responses![oldResponseStatus] as OpenAPIV3.ResponseObject)
.content?.["application/json"].schema as OpenApiSchema)
: undefined
updatedResponseSchema = this.updateSchema({
oldSchema: oldResponseSchema,
newSchema: newResponseSchema,
})
if (oldResponseStatus && oldResponseSchema !== newStatus) {
// delete the old response schema if its status is different
delete oas.responses![oldResponseStatus]
}
// update the response schema
oas.responses![newStatus] = {
description: "OK",
content: {
"application/json": {
schema: updatedResponseSchema
? this.oasSchemaHelper.schemaToReference(updatedResponseSchema) ||
updatedResponseSchema
: updatedResponseSchema,
},
},
}
}
// update examples if the --generate-examples option is enabled
if (this.options.generateExamples) {
const oldJsExampleIndex = oas["x-codeSamples"]
? oas["x-codeSamples"].findIndex(
(example) =>
example.label ==
OasExamplesGenerator.JSCLIENT_CODESAMPLE_DATA.label
)
: -1
if (oldJsExampleIndex === -1) {
// only generate a new example if it doesn't have an example
const newJsExample = this.oasExamplesGenerator.generateJSClientExample({
oasPath,
httpMethod: methodName,
area: splitOasPath[0] as OasArea,
tag: tagName || "",
isAdminAuthenticated,
isStoreAuthenticated,
parameters: (oas.parameters as OpenAPIV3.ParameterObject[])?.filter(
(parameter) => parameter.in === "path"
),
requestBody: updatedRequestSchema,
responseBody: updatedResponseSchema,
})
oas["x-codeSamples"] = [
...(oas["x-codeSamples"] || []),
{
...OasExamplesGenerator.JSCLIENT_CODESAMPLE_DATA,
source: newJsExample,
},
]
}
// TODO add for Medusa React once we figure out how to generate it
}
// check if cURL example should be updated.
const oldCurlExampleIndex = oas["x-codeSamples"]
? oas["x-codeSamples"].findIndex(
(example) =>
example.label === OasExamplesGenerator.CURL_CODESAMPLE_DATA.label
)
: -1
if (oldCurlExampleIndex === -1) {
// only generate example if it doesn't already exist
const newCurlExample = this.oasExamplesGenerator.generateCurlExample({
method: methodName,
path: normalizedOasPath,
isAdminAuthenticated,
isStoreAuthenticated,
requestSchema,
})
oas["x-codeSamples"] = [
...(oas["x-codeSamples"] || []),
{
...OasExamplesGenerator.CURL_CODESAMPLE_DATA,
source: newCurlExample,
},
]
}
// push new tags to the tags property
if (tagName) {
const areaTags = this.tags.get(splitOasPath[0] as OasArea)
areaTags?.add(tagName)
}
return formatOas(oas, oasPrefix)
}
/**
* Get the API route's path details.
*
* @param node - The node to retrieve its path details.
* @returns The path details.
*/
getOasPath(node: FunctionOrVariableNode): {
/**
* The path, generally left as-is, which helps detecting path parameters.
*/
oasPath: string
/**
* The normalized path which adds a backslash at the beginning of the
* oasPath and replaces path parameters of pattern `[paramName]` with
* `{paramName}`. This can be used in the prefix line of the OAS.
*/
normalized: string
} {
const filePath = node.getSourceFile().fileName
const oasPath = (
filePath.includes("/api-v2/")
? filePath.substring(filePath.indexOf("/api-v2/"))
: filePath.substring(filePath.indexOf("/api/"))
)
.replace(/^\/api(-v2)?\//, "")
.replace(`/${basename(filePath)}`, "")
const normalizedOasPath = `/${oasPath.replaceAll(
API_ROUTE_PARAM_REGEX,
`{$1}`
)}`
return {
oasPath,
normalized: normalizedOasPath,
}
}
/**
* Get the function's name, which is used to retrieve the HTTP method.
*
* @param node - The node to retrieve its function name.
* @returns the name of the function.
*/
getFunctionName(node: FunctionOrVariableNode): string {
if (ts.isFunctionDeclaration(node)) {
return node.name?.getText() || ""
}
return (
node as ts.VariableStatement
).declarationList.declarations[0].name.getText()
}
/**
* Retrieve the HTTP method of a node.
*
* @param node - The node to retrieve its HTTP method.
* @returns The lowercase HTTP method name.
*/
getHTTPMethodName(node: FunctionOrVariableNode): string {
return this.getFunctionName(node).toLowerCase()
}
/**
* Retrieve the OAS prefix line that's added before the YAML schema.
*
* @param methodName - The HTTP method name.
* @param oasPath - The API route's path
* @returns The OAS prefix line.
*/
getOasPrefix(methodName: string, oasPath: string): string {
return `@oas [${methodName}] ${oasPath}`
}
/**
* Retrieve the tag name from the split OAS path.
*
* @param splitOasPath - The split OAS path.
* @returns The tag name if available.
*/
getTagName(splitOasPath: string[]): string | undefined {
return splitOasPath.length >= 2 ? kebabToTitle(splitOasPath[1]) : undefined
}
/**
* Retrieve the authentication details of a node.
*
* @param node - The node to retrieve its authentication details.
* @param oasPath - The OAS path of the node.
* @returns The authentication details.
*/
getAuthenticationDetails(
node: FunctionNode,
oasPath: string
): {
/**
* Whether the OAS operation requires admin authentication.
*/
isAdminAuthenticated: boolean
/**
* Whether the OAS operation requires customer authentication.
*/
isStoreAuthenticated: boolean
/**
* Whether the OAS operation requires authentication in genral.
*/
isAuthenticated: boolean
} {
const isAuthenticationDisabled = node
.getSourceFile()
.statements.some((statement) =>
statement.getText().includes("AUTHENTICATE = false")
)
const isAdminAuthenticated =
!isAuthenticationDisabled &&
oasPath.startsWith("admin") &&
!oasPath.startsWith("admin/auth")
const isStoreAuthenticated =
!isAuthenticationDisabled && oasPath.startsWith("store/me")
const isAuthenticated = isAdminAuthenticated || isStoreAuthenticated
return {
isAdminAuthenticated,
isStoreAuthenticated,
isAuthenticated,
}
}
/**
* Retrieve the OAS operation's ID.
*
* @param param0 - The OAS operation's details.
* @returns The operation's ID.
*/
getOperationId({
methodName,
splitOasPath,
}: {
/**
* The HTTP method's name.
*/
methodName: string
/**
* The split OAS path.
*/
splitOasPath: string[]
}): string {
let str = capitalize(methodName)
splitOasPath.slice(1).forEach((item) => {
if (API_ROUTE_PARAM_REGEX.test(item)) {
item = item.replace(API_ROUTE_PARAM_REGEX, "$1")
}
str += item
.split("-")
.map((subitem) => capitalize(subitem))
.join("")
})
return str
}
/**
* Retrieve the summary and description of the OAS.
*
* @param param0 - The OAS operation's details.
* @returns The summary and description.
*/
getSummaryAndDescription({
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: this.defaultSummary,
description: this.defaultSummary,
}
// 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 the security details of an OAS operation.
*
* @param param0 - The authentication details.
* @returns The security details.
*/
getSecurity({
isAdminAuthenticated,
isAuthenticated,
}: {
/**
* Whether the operation requires admin authentication.
*/
isAdminAuthenticated: boolean
/**
* Whether the operation requires general authentication.
*/
isAuthenticated: boolean
}): OpenAPIV3.SecurityRequirementObject[] | undefined {
const security: OpenAPIV3.SecurityRequirementObject[] = []
if (isAdminAuthenticated) {
security.push({
api_token: [],
})
}
if (isAuthenticated) {
security.push(
{
cookie_auth: [],
},
{
jwt_token: [],
}
)
}
return security.length ? security : undefined
}
/**
* Format a schema as a parameter object. Can be used for path or query parameters.
*
* @param param0 - The operation's details.
* @returns The parameter object.
*/
getParameterObject({
type,
name,
description,
required,
schema,
}: {
/**
* The parameter type.
*/
type: "path" | "query"
/**
* The name of the parameter.
*/
name: string
/**
* Whether the parameter is required.
*/
required: boolean
/**
* The parameter's description.
*/
description?: string
/**
* The parameter's schema.
*/
schema: OpenApiSchema
}): OpenAPIV3.ParameterObject {
return {
name: name,
in: type,
description: description,
required: required,
schema: schema,
}
}
/**
* Retrieve the path parameters.
*
* @param param0 - The OAS operation's details.
* @returns The list of path parameters.
*/
getPathParameters({
oasPath,
tagName,
}: {
/**
* The OAS path.
*/
oasPath: string
/**
* The tag name.
*/
tagName?: string
}): OpenAPIV3.ParameterObject[] {
// reset regex manually
API_ROUTE_PARAM_REGEX.lastIndex = 0
let pathParameters: string[] | undefined
const parameters: OpenAPIV3.ParameterObject[] = []
while (
(pathParameters = API_ROUTE_PARAM_REGEX.exec(oasPath)?.slice(1)) !==
undefined
) {
if (pathParameters.length) {
pathParameters.forEach((parameter) =>
parameters.push(
this.getParameterObject({
type: "path",
name: parameter,
description: this.getSchemaDescription({
typeStr: parameter,
parentName: tagName,
}),
required: true,
schema: {
type: "string",
},
})
)
)
}
}
return parameters
}
/**
* Retrieve the request query parameters and body schema.
*
* @param param0 - The operation's details.
* @returns The request query parameters and body schema.
*/
getRequestParameters({
node,
tagName,
}: {
/**
* The node to retrieve its request parameters.
*/
node: FunctionNode
/**
* The tag's name.
*/
tagName?: string
}): {
/**
* The query parameters.
*/
queryParameters: OpenAPIV3.ParameterObject[]
/**
* The request schema.
*/
requestSchema?: OpenApiSchema
} {
const parameters: OpenAPIV3.ParameterObject[] = []
let requestSchema: OpenApiSchema | undefined
if (
node.parameters[0].type &&
ts.isTypeReferenceNode(node.parameters[0].type)
) {
const requestType = this.checker.getTypeFromTypeNode(
node.parameters[0].type
) as ts.TypeReference
// TODO for now I'll use the type for validatedQuery until
// we have an actual approach to infer query types
const querySymbol = requestType.getProperty("validatedQuery")
if (querySymbol) {
const queryType = this.checker.getTypeOfSymbol(querySymbol)
queryType.getProperties().forEach((property) => {
const propertyType = this.checker.getTypeOfSymbol(property)
const descriptionOptions: SchemaDescriptionOptions = {
typeStr: property.getName(),
parentName: tagName,
node: property.valueDeclaration,
symbol: property,
nodeType: propertyType,
}
parameters.push(
this.getParameterObject({
name: property.getName(),
type: "query",
description: this.getSchemaDescription(descriptionOptions),
required: this.isRequired(property),
schema: this.typeToSchema({
itemType: propertyType,
title: property.getName(),
descriptionOptions,
}),
})
)
})
}
const requestTypeArguments = this.checker.getTypeArguments(requestType)
if (requestTypeArguments.length === 1) {
requestSchema = this.typeToSchema({
itemType: requestTypeArguments[0],
descriptionOptions: {
parentName: tagName,
},
})
}
}
return {
queryParameters: parameters,
requestSchema,
}
}
/**
* Retrieve the response's status.
*
* @param node - The node to retrieve its response status.
* @returns The response's status.
*/
getResponseStatus(node: FunctionNode): string {
let responseStatus = "200"
if ("body" in node && node.body && "statements" in node.body) {
node.body.statements.forEach((statement) => {
const matched = RES_STATUS_REGEX.exec(statement.getText())?.splice(1)
if (matched?.length === 1) {
responseStatus = matched[0]
}
})
}
return responseStatus
}
/**
* Retrieve the response schema of the OAS operation.
*
* @param param0 - The OAS operation's details.
* @returns The response schema.
*/
getResponseSchema({
node,
tagName,
}: {
/**
* The node to retrieve its response schema.
*/
node: FunctionNode
/**
* The tag's name.
*/
tagName?: string
}): OpenApiSchema | undefined {
let responseSchema: OpenApiSchema | undefined
if (
node.parameters[1].type &&
ts.isTypeReferenceNode(node.parameters[1].type)
) {
const responseType = this.checker.getTypeFromTypeNode(
node.parameters[1].type
) as ts.TypeReference
const responseTypeArguments =
responseType.aliasTypeArguments ||
this.checker.getTypeArguments(responseType)
if (responseTypeArguments.length === 1) {
responseSchema = this.typeToSchema({
itemType: responseTypeArguments[0],
descriptionOptions: {
parentName: tagName,
},
})
}
}
return responseSchema
}
/**
* Convert a TypeScript type to a schema object.
*
* @param param0 - The type and additional details.
* @returns The schema object.
*/
typeToSchema({
itemType,
level = 1,
title,
descriptionOptions,
allowedChildren,
disallowedChildren,
}: {
/**
* The TypeScript type.
*/
itemType: ts.Type
/**
* The current level. Used to limit the recursive loop.
*/
level?: number
/**
* The type's title, if available.
*/
title?: string
/**
* options to retrieve a parameter/property's description.
*/
descriptionOptions?: Partial<SchemaDescriptionOptions>
/**
* Children that can be allowed to retrieve. If this property is supplied,
* only children in this array are added to the returned schema.
*/
allowedChildren?: string[]
/**
* Children that aren't allowed to retrieve. If this property is supplied,
* only children not included in this array are added to the schema.
*/
disallowedChildren?: string[]
}): OpenApiSchema {
if (level > this.MAX_LEVEL) {
return {}
}
const symbol = itemType.aliasSymbol || itemType.symbol
const description = descriptionOptions?.typeStr
? this.getSchemaDescription(
descriptionOptions as SchemaDescriptionOptions
)
: title
? this.getSchemaDescription({ typeStr: title, nodeType: itemType })
: this.defaultSummary
const typeAsString = this.checker.typeToString(itemType)
switch (true) {
case itemType.flags === ts.TypeFlags.Enum:
const enumMembers: string[] = []
symbol?.members?.forEach((enumMember) => {
if ((enumMember.valueDeclaration as ts.EnumMember).initializer) {
enumMembers.push(
(
enumMember.valueDeclaration as ts.EnumMember
).initializer!.getText()
)
}
})
return {
type: "string",
enum: enumMembers,
}
case itemType.isLiteral():
return {
type:
itemType.flags === ts.TypeFlags.StringLiteral
? "string"
: itemType.flags === ts.TypeFlags.NumberLiteral
? "number"
: "boolean",
title: title || typeAsString,
description,
format: this.getSchemaTypeFormat({
typeName: typeAsString,
name: title,
}),
}
case itemType.flags === ts.TypeFlags.String ||
itemType.flags === ts.TypeFlags.Number ||
itemType.flags === ts.TypeFlags.Boolean ||
typeAsString === "Date":
return {
type:
typeAsString === "Date"
? "string"
: (typeAsString as OpenAPIV3.NonArraySchemaObjectType),
title: title || typeAsString,
description,
default: symbol?.valueDeclaration
? this.getDefaultValue(symbol?.valueDeclaration)
: undefined,
format: this.getSchemaTypeFormat({
typeName: typeAsString,
name: title,
}),
}
case "intrinsicName" in itemType && itemType.intrinsicName === "boolean":
return {
type: "boolean",
title: title || typeAsString,
description,
default: symbol?.valueDeclaration
? this.getDefaultValue(symbol?.valueDeclaration)
: undefined,
}
case this.checker.isArrayType(itemType):
return {
type: "array",
description,
items: this.typeToSchema({
itemType: this.checker.getTypeArguments(
itemType as ts.TypeReference
)[0],
// can't increment level because
// array must have items in it
level,
title,
descriptionOptions:
descriptionOptions || title
? {
...descriptionOptions,
parentName: title || descriptionOptions?.parentName,
}
: undefined,
}),
}
case itemType.isUnion():
// if it's a union of literal types,
// consider it an enum
const allLiteral = (itemType as ts.UnionType).types.every((unionType) =>
unionType.isLiteral()
)
if (allLiteral) {
return {
type: "string",
enum: (itemType as ts.UnionType).types.map(
(unionType) => (unionType as ts.LiteralType).value
),
}
}
return {
oneOf: (itemType as ts.UnionType).types.map((unionType) =>
this.typeToSchema({
itemType: unionType,
// not incrementing considering the
// current schema isn't actually a
// schema
level,
title,
descriptionOptions,
})
),
}
case itemType.isIntersection():
return {
allOf: (itemType as ts.IntersectionType).types.map(
(intersectionType) => {
return this.typeToSchema({
itemType: intersectionType,
// not incrementing considering the
// current schema isn't actually a
// schema
level,
title,
descriptionOptions,
})
}
),
}
case typeAsString.startsWith("Pick"):
const pickTypeArgs =
itemType.aliasTypeArguments ||
this.checker.getTypeArguments(itemType as ts.TypeReference)
if (pickTypeArgs.length < 2) {
return {}
}
const pickedProperties = pickTypeArgs[1].isUnion()
? pickTypeArgs[1].types.map((unionType) =>
this.getTypeName(unionType)
)
: [this.getTypeName(pickTypeArgs[1])]
return this.typeToSchema({
itemType: pickTypeArgs[0],
title,
level,
descriptionOptions,
allowedChildren: pickedProperties,
})
case typeAsString.startsWith("Omit"):
const omitTypeArgs =
itemType.aliasTypeArguments ||
this.checker.getTypeArguments(itemType as ts.TypeReference)
if (omitTypeArgs.length < 2) {
return {}
}
const omitProperties = omitTypeArgs[1].isUnion()
? omitTypeArgs[1].types.map((unionType) =>
this.getTypeName(unionType)
)
: [this.getTypeName(omitTypeArgs[1])]
return this.typeToSchema({
itemType: omitTypeArgs[0],
title,
level,
descriptionOptions,
disallowedChildren: omitProperties,
})
case typeAsString.startsWith("Partial"):
const typeArg =
itemType.aliasTypeArguments ||
this.checker.getTypeArguments(itemType as ts.TypeReference)
if (!typeArg.length) {
return {}
}
const schema = this.typeToSchema({
itemType: typeArg[0],
title,
level,
descriptionOptions,
disallowedChildren,
allowedChildren,
})
// remove all required items
delete schema.required
return schema
case itemType.isClassOrInterface() ||
itemType.isTypeParameter() ||
(itemType as ts.Type).flags === ts.TypeFlags.Object:
const properties: Record<string, OpenApiSchema> = {}
const requiredProperties: string[] = []
if (level + 1 <= this.MAX_LEVEL) {
itemType.getProperties().forEach((property) => {
if (
(allowedChildren && !allowedChildren.includes(property.name)) ||
(disallowedChildren && disallowedChildren.includes(property.name))
) {
return
}
if (this.isRequired(property)) {
requiredProperties.push(property.name)
}
properties[property.name] = this.typeToSchema({
itemType: this.checker.getTypeOfSymbol(property),
level: level + 1,
title: property.name,
descriptionOptions: {
...descriptionOptions,
typeStr: property.name,
parentName: title || descriptionOptions?.parentName,
},
})
})
}
const objSchema: OpenApiSchema = {
type: "object",
description,
"x-schemaName":
itemType.isClassOrInterface() || itemType.isTypeParameter()
? this.oasSchemaHelper.normalizeSchemaName(typeAsString)
: undefined,
required:
requiredProperties.length > 0 ? requiredProperties : undefined,
properties,
}
if (objSchema["x-schemaName"]) {
// add object to schemas to be created
// if necessary
this.oasSchemaHelper.schemaToReference(objSchema)
}
return objSchema
default:
return {}
}
}
/**
* Retrieve the description of a symbol, type, or node. Can be used to retrieve
* the description of a property or parameter in a schema.
*
* @param param0 - The details of the item to retrieve its description.
* @returns The description.
*/
getSchemaDescription({
symbol,
node,
nodeType,
typeStr,
parentName,
}: SchemaDescriptionOptions): string {
if (!symbol && !node && !nodeType) {
// if none of the associated symbol, node, or type are provided,
// either retrieve the description from the knowledge base or use
// the default summary
return (
this.knowledgeBaseFactory.tryToGetOasDescription({
str: typeStr,
templateOptions: {
parentName,
},
}) || this.defaultSummary
)
}
if (node) {
return this.getNodeSummary({
node: node,
symbol,
nodeType,
})
}
let description = ""
if (nodeType) {
description = this.getTypeDocBlock(nodeType)
}
if (!description.length && symbol) {
description = this.getSymbolDocBlock(symbol)
}
return description.length ? description : this.defaultSummary
}
/**
* Check whether a symbol is required.
*
* @param symbol - The symbol to check.
* @returns Whether the symbol is required.
*/
isRequired(symbol: ts.Symbol): boolean {
let isRequired = true
const checkNode = (node: ts.Node) => {
if (node.kind === ts.SyntaxKind.QuestionToken) {
isRequired = false
}
if (!isRequired) {
return
}
node.forEachChild(checkNode)
}
symbol.valueDeclaration?.forEachChild(checkNode)
return isRequired
}
/**
* Retrieve the format of a property/parameter in a schema.
*
* @param param0 - The item's details.
* @returns The format, if available.
*/
getSchemaTypeFormat({
typeName,
name,
}: {
typeName: string
name?: string
}): string | undefined {
switch (true) {
case typeName === "Date":
return "date-time"
case name?.includes("email"):
return "email"
case name?.includes("password"):
return "password"
}
}
/**
* Retrieve the name of a type. This is useful when retrieving allowed/disallowed
* properties in an Omit/Pick type.
*
* @param itemType - The type to retrieve its name.
* @returns The type's name.
*/
getTypeName(itemType: ts.Type): string {
if (itemType.symbol || itemType.aliasSymbol) {
return (itemType.aliasSymbol || itemType.symbol).name
}
if (itemType.isLiteral()) {
return itemType.value.toString()
}
return this.checker.typeToString(itemType)
}
/**
* Initialize the {@link tags} property.
*/
init() {
this.tags.set("admin", new Set())
this.tags.set("store", new Set())
}
/**
* Update an array of parameters with a new one.
*
* @param param0 - The parameter details.
* @returns The updated parameters.
*/
updateParameters({
oldParameters,
newParameters,
type,
}: {
/**
* The old list of parameters.
*/
oldParameters?: OpenAPIV3.ParameterObject[]
/**
* The new list of parameters.
*/
newParameters?: OpenAPIV3.ParameterObject[]
/**
* The type of parameters.
*/
type: ParameterType
}): OpenAPIV3.ParameterObject[] {
if (!oldParameters) {
return newParameters || []
}
const oppositeParamType = type === "query" ? "path" : "query"
const oppositeParams: OpenAPIV3.ParameterObject[] =
oldParameters?.filter((param) => param.in === oppositeParamType) || []
// check and update/add parameters if necessary
const existingParams: OpenAPIV3.ParameterObject[] =
oldParameters?.filter((param) => param.in === type) || []
const paramsToRemove = new Set<string>()
existingParams.forEach((parameter) => {
const updatedParameter = newParameters?.find(
(newParam) => newParam.name === parameter.name
)
if (!updatedParameter) {
// remove the parameter
paramsToRemove.add(parameter.name)
return
}
if (
updatedParameter.description !== parameter.description &&
parameter.description === this.defaultSummary
) {
parameter.description = updatedParameter.description
}
if (updatedParameter.required !== parameter.required) {
parameter.required = updatedParameter.required
}
if (
(updatedParameter.schema as OpenApiSchema).type !==
(parameter.schema as OpenApiSchema).type
) {
;(parameter.schema as OpenApiSchema).type = (
updatedParameter.schema as OpenApiSchema
).type
}
if (
(updatedParameter.schema as OpenApiSchema).title !==
(parameter.schema as OpenApiSchema).title
) {
;(parameter.schema as OpenApiSchema).title = (
updatedParameter.schema as OpenApiSchema
).title
}
if (
(updatedParameter.schema as OpenApiSchema).description !==
(parameter.schema as OpenApiSchema).description &&
(parameter.schema as OpenApiSchema).description === this.defaultSummary
) {
;(parameter.schema as OpenApiSchema).description = (
updatedParameter.schema as OpenApiSchema
).description
}
})
// find new parameters to add
newParameters?.forEach((parameter) => {
if (existingParams.some((newParam) => newParam.name === parameter.name)) {
return
}
existingParams?.push(parameter)
})
// remove parameters no longer existing
return [
...oppositeParams,
...(existingParams?.filter(
(parameter) =>
(parameter as OpenAPIV3.ParameterObject).in === oppositeParamType ||
!paramsToRemove.has((parameter as OpenAPIV3.ParameterObject).name)
) || []),
]
}
/**
* Retrieve the updated schema. Can be used for request and response schemas.
*
* @param param0 - The schema details.
* @returns The updated schema.
*/
updateSchema({
oldSchema,
newSchema,
}: {
/**
* The old schema.
*/
oldSchema?: OpenApiSchema | OpenAPIV3.ReferenceObject
/**
* The new schema.
*/
newSchema?: OpenApiSchema | OpenAPIV3.ReferenceObject
}): OpenApiSchema | undefined {
const oldSchemaObj = (
oldSchema && "$ref" in oldSchema
? this.oasSchemaHelper.getSchemaByName(oldSchema.$ref)?.schema
: oldSchema
) as OpenApiSchema | undefined
const newSchemaObj = (
newSchema && "$ref" in newSchema
? this.oasSchemaHelper.getSchemaByName(newSchema.$ref)?.schema
: newSchema
) as OpenApiSchema | undefined
if (!oldSchemaObj && newSchemaObj) {
return newSchemaObj
} else if (oldSchemaObj && !newSchemaObj) {
return undefined
}
// update schema
if (oldSchemaObj!.type !== newSchemaObj?.type) {
oldSchemaObj!.type = newSchemaObj?.type
}
if (
oldSchemaObj!.description !== newSchemaObj?.description &&
oldSchemaObj!.description === this.defaultSummary
) {
oldSchemaObj!.description =
newSchemaObj?.description || this.defaultSummary
}
oldSchemaObj!.required = newSchemaObj?.required
if (oldSchemaObj!.type === "object") {
if (!oldSchemaObj?.properties && newSchemaObj?.properties) {
oldSchemaObj!.properties = newSchemaObj.properties
} else if (oldSchemaObj?.properties && !newSchemaObj?.properties) {
delete oldSchemaObj!.properties
} else {
// update existing properties
Object.entries(oldSchemaObj!.properties!).forEach(
([propertyName, propertySchema]) => {
const newPropertySchemaKey = Object.keys(
newSchemaObj!.properties!
).find((newPropertyName) => newPropertyName === propertyName)
if (!newPropertySchemaKey) {
// remove property
delete oldSchemaObj!.properties![propertyName]
return
}
oldSchemaObj!.properties![propertyName] =
this.updateSchema({
oldSchema: propertySchema as OpenApiSchema,
newSchema: newSchemaObj!.properties![
propertyName
] as OpenApiSchema,
}) || propertySchema
}
)
// add new properties
Object.keys(newSchemaObj!.properties!)
.filter(
(propertyKey) =>
!Object.hasOwn(oldSchemaObj!.properties!, propertyKey)
)
.forEach((newPropertyKey) => {
oldSchemaObj!.properties![newPropertyKey] =
newSchemaObj!.properties![newPropertyKey]
})
}
} else if (
oldSchemaObj?.type === "array" &&
newSchemaObj?.type === "array"
) {
oldSchemaObj.items =
this.updateSchema({
oldSchema: oldSchemaObj.items as OpenApiSchema,
newSchema: newSchemaObj!.items as OpenApiSchema,
}) || oldSchemaObj.items
}
return oldSchemaObj
}
/**
* Retrieve the file name that's used to write the OAS operation of a node.
*
* @param node - The node to retrieve its associated file name.
* @returns The file name.
*/
getAssociatedFileName(node: FunctionOrVariableNode): string {
const methodName = this.getHTTPMethodName(node)
const { oasPath } = this.getOasPath(node)
const area = oasPath.split("/")[0]
const filename = `${methodName}_${oasPath.replaceAll("/", "_")}.ts`
return join(this.baseOutputPath, "operations", area, filename)
}
/**
* This method is executed when the {@link GeneratorEvent.FINISHED_GENERATE_EVENT} event is triggered.
* It writes new tags, if available, in base YAML.
*/
afterGenerate() {
this.writeNewTags("admin")
this.writeNewTags("store")
this.oasSchemaHelper.writeNewSchemas()
// reset tags
this.init()
// reset schemas
this.oasSchemaHelper.init()
}
/**
* Add new tags to the base YAML of an area.
*
* @param area - The area that the tag belongs to.
*/
writeNewTags(area: OasArea) {
// load base oas files
const areaYamlPath = join(
this.baseOutputPath,
"base-v2",
`${area}.oas.base.yaml`
)
const areaYaml = parse(
readFileSync(areaYamlPath, "utf-8")
) as OpenApiDocument
let addedTags = false
areaYaml.tags = [...(areaYaml.tags || [])]
this.tags.get(area)?.forEach((tag) => {
const existingTag = areaYaml.tags!.find((baseTag) => baseTag.name === tag)
if (!existingTag) {
// try to retrieve associated schema
const schema = this.oasSchemaHelper.getSchemaByName(
this.oasSchemaHelper.tagNameToSchemaName(tag)
)
areaYaml.tags!.push({
name: tag,
"x-associatedSchema": schema?.schema?.["x-schemaName"]
? {
$ref: this.oasSchemaHelper.constructSchemaReference(
schema.schema["x-schemaName"]
),
}
: undefined,
})
addedTags = true
}
})
if (addedTags) {
// sort alphabetically
areaYaml.tags.sort((tagA, tagB) => {
return tagA.name.localeCompare(tagB.name)
})
// write to the file
writeFileSync(areaYamlPath, stringify(areaYaml))
}
}
}
export default OasKindGenerator