docs-util: fixes and improvements to OAS generator (#7731)

* fixed associated schema names

* move OAS method and summary descriptions to knowledge base

* refactoring and fixes

* detect default object value in delete responses
This commit is contained in:
Shahed Nasser
2024-06-26 11:54:09 +03:00
committed by GitHub
parent 0462cc5acf
commit 922fff4051
7 changed files with 201 additions and 180 deletions

View File

@@ -1,8 +1,9 @@
import { faker } from "@faker-js/faker"
import { OpenAPIV3 } from "openapi-types"
import { API_ROUTE_PARAM_REGEX, OasArea } from "../kinds/oas.js"
import { OasArea } from "../kinds/oas.js"
import { CodeSample } from "../../types/index.js"
import { capitalize, kebabToCamel, wordsToCamel, wordsToKebab } from "utils"
import { API_ROUTE_PARAM_REGEX } from "../../constants.js"
type CodeSampleData = Omit<CodeSample, "source">

View File

@@ -1,7 +1,18 @@
import ts from "typescript"
import { DOCBLOCK_DOUBLE_LINES, DOCBLOCK_NEW_LINE } from "../../constants.js"
import {
API_ROUTE_PARAM_REGEX,
DOCBLOCK_DOUBLE_LINES,
DOCBLOCK_NEW_LINE,
SUMMARY_PLACEHOLDER,
} from "../../constants.js"
import pluralize from "pluralize"
import { camelToTitle, camelToWords, snakeToWords } from "utils"
import {
camelToTitle,
camelToWords,
kebabToTitle,
snakeToWords,
wordsToKebab,
} from "utils"
import { normalizeName } from "../../utils/str-formatting.js"
type TemplateOptions = {
@@ -537,7 +548,7 @@ class KnowledgeBaseFactory {
*
* @returns {string | undefined} The matching knowledgebase template, if found.
*/
tryToGetOasDescription({
tryToGetOasSchemaDescription({
str,
...options
}: RetrieveOptions): string | undefined {
@@ -548,6 +559,125 @@ class KnowledgeBaseFactory {
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
}
}
export default KnowledgeBaseFactory

View File

@@ -187,11 +187,11 @@ class OasSchemaHelper {
* @param name - The schema's name
* @returns The schema's file name
*/
getSchemaFileName(name: string): string {
getSchemaFileName(name: string, shouldNormalizeName = true): string {
return join(
this.baseOutputPath,
"schemas",
`${this.normalizeSchemaName(name)}.ts`
`${shouldNormalizeName ? this.normalizeSchemaName(name) : name}.ts`
)
}
@@ -202,8 +202,13 @@ class OasSchemaHelper {
* @param name - The schema's name.
* @returns The parsed schema, if found.
*/
getSchemaByName(name: string): ParsedSchema | undefined {
const schemaName = this.normalizeSchemaName(name)
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 {
@@ -211,7 +216,7 @@ class OasSchemaHelper {
schemaPrefix: `@schema ${schemaName}`,
}
}
const schemaFile = this.getSchemaFileName(schemaName)
const schemaFile = this.getSchemaFileName(schemaName, shouldNormalizeName)
const schemaFileContent = ts.sys.readFile(schemaFile)
if (!schemaFileContent) {
@@ -267,7 +272,7 @@ class OasSchemaHelper {
return name
.replace("DTO", "")
.replace(this.schemaRefPrefix, "")
.replace(/Type$/, "")
.replace(/(?<!Type)Type$/, "")
}
/**
@@ -325,13 +330,9 @@ class OasSchemaHelper {
* @param tagName - The name of the tag.
* @returns The possible names of the associated schema.
*/
tagNameToSchemaName(tagName: string, area: OasArea): string[] {
tagNameToSchemaName(tagName: string, area: OasArea): string {
const mainSchemaName = wordsToPascal(pluralize.singular(tagName))
return [
mainSchemaName,
`${mainSchemaName}Response`,
`${capitalize(area)}Create${mainSchemaName}`,
]
return `${capitalize(area)}${mainSchemaName}`
}
}

View File

@@ -4,6 +4,7 @@ import {
DOCBLOCK_END_LINE,
DOCBLOCK_DOUBLE_LINES,
DOCBLOCK_NEW_LINE,
SUMMARY_PLACEHOLDER,
} from "../../constants.js"
import getSymbol from "../../utils/get-symbol.js"
import KnowledgeBaseFactory, {
@@ -56,7 +57,6 @@ class DefaultKindGenerator<T extends ts.Node = ts.Node> {
public name = "default"
protected allowedKinds: ts.SyntaxKind[]
protected checker: ts.TypeChecker
protected defaultSummary = "{summary}"
protected knowledgeBaseFactory: KnowledgeBaseFactory
protected generatorEventManager: GeneratorEventManager
protected options: Pick<CommonCliOptions, "generateExamples">
@@ -186,7 +186,7 @@ class DefaultKindGenerator<T extends ts.Node = ts.Node> {
summary = this.getTypeDocBlock(nodeType, knowledgeBaseOptions)
}
return summary.length > 0 ? summary : this.defaultSummary
return summary.length > 0 ? summary : SUMMARY_PLACEHOLDER
}
/**
@@ -234,7 +234,7 @@ class DefaultKindGenerator<T extends ts.Node = ts.Node> {
}
// do some formatting if the encapsulating type is an array
return `The list of ${capitalize(typeArgumentDoc) || this.defaultSummary}`
return `The list of ${capitalize(typeArgumentDoc) || SUMMARY_PLACEHOLDER}`
}
return (
@@ -360,7 +360,7 @@ class DefaultKindGenerator<T extends ts.Node = ts.Node> {
}
if (options.addDefaultSummary) {
tags.add(this.defaultSummary)
tags.add(SUMMARY_PLACEHOLDER)
}
// check for private or protected modifiers

View File

@@ -5,6 +5,7 @@ import {
DOCBLOCK_END_LINE,
DOCBLOCK_START,
DOCBLOCK_DOUBLE_LINES,
SUMMARY_PLACEHOLDER,
} from "../../constants.js"
import getSymbol from "../../utils/get-symbol.js"
import AiGenerator from "../helpers/ai-generator.js"
@@ -327,7 +328,7 @@ class FunctionKindGenerator extends DefaultKindGenerator<FunctionOrVariableNode>
// add returns
const possibleReturnSummary = !this.hasReturnData(returnTypeStr)
? `Resolves when ${this.defaultSummary}`
? `Resolves when ${SUMMARY_PLACEHOLDER}`
: this.getNodeSummary({
node: actualNode,
nodeType,

View File

@@ -1,11 +1,10 @@
import { readFileSync, writeFileSync } from "fs"
import { OpenAPIV3 } from "openapi-types"
import { basename, join } from "path"
import pluralize from "pluralize"
import ts, { SyntaxKind } from "typescript"
import { capitalize, kebabToTitle, wordsToKebab } from "utils"
import { capitalize, kebabToTitle } from "utils"
import { parse, stringify } from "yaml"
import { DEFAULT_OAS_RESPONSES } from "../../constants.js"
import { DEFAULT_OAS_RESPONSES, SUMMARY_PLACEHOLDER } from "../../constants.js"
import {
OpenApiDocument,
OpenApiOperation,
@@ -18,7 +17,7 @@ import isZodObject from "../../utils/is-zod-object.js"
import parseOas, { ExistingOas } from "../../utils/parse-oas.js"
import OasExamplesGenerator from "../examples/oas.js"
import { GeneratorEvent } from "../helpers/generator-event-manager.js"
import OasSchemaHelper, { ParsedSchema } from "../helpers/oas-schema.js"
import OasSchemaHelper from "../helpers/oas-schema.js"
import SchemaFactory from "../helpers/schema-factory.js"
import { GeneratorOptions, GetDocBlockOptions } from "./default.js"
import FunctionKindGenerator, {
@@ -26,8 +25,8 @@ import FunctionKindGenerator, {
FunctionOrVariableNode,
VariableNode,
} from "./function.js"
import { API_ROUTE_PARAM_REGEX } from "../helpers/../../constants.js"
export const API_ROUTE_PARAM_REGEX = /\[(.+?)\]/g
const RES_STATUS_REGEX = /^res[\s\S]*\.status\((\d+)\)/
type SchemaDescriptionOptions = {
@@ -50,11 +49,6 @@ 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,
@@ -228,11 +222,12 @@ class OasKindGenerator extends FunctionKindGenerator {
const { isAdminAuthenticated, isStoreAuthenticated, isAuthenticated } =
this.getAuthenticationDetails(node, oasPath)
const tagName = this.getTagName(splitOasPath)
const { summary, description } = this.getSummaryAndDescription({
oasPath,
httpMethod: methodName,
tag: tagName || "",
})
const { summary, description } =
this.knowledgeBaseFactory.tryToGetOasMethodSummaryAndDescription({
oasPath,
httpMethod: methodName,
tag: tagName || "",
})
// construct oas
const oas: OpenApiOperation = {
@@ -405,15 +400,16 @@ class OasKindGenerator extends FunctionKindGenerator {
// update summary and description either if they're empty or default summary
const shouldUpdateSummary =
!oas.summary || oas.summary === this.defaultSummary
!oas.summary || oas.summary === SUMMARY_PLACEHOLDER
const shouldUpdateDescription =
!oas.description || oas.description === this.defaultSummary
!oas.description || oas.description === SUMMARY_PLACEHOLDER
if (shouldUpdateSummary || shouldUpdateDescription) {
const { summary, description } = this.getSummaryAndDescription({
oasPath,
httpMethod: methodName,
tag: tagName || "",
})
const { summary, description } =
this.knowledgeBaseFactory.tryToGetOasMethodSummaryAndDescription({
oasPath,
httpMethod: methodName,
tag: tagName || "",
})
if (shouldUpdateSummary) {
oas.summary = summary
@@ -775,126 +771,6 @@ class OasKindGenerator extends FunctionKindGenerator {
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.
*
@@ -1284,7 +1160,7 @@ class OasKindGenerator extends FunctionKindGenerator {
)
: title
? this.getSchemaDescription({ typeStr: title, nodeType: itemType })
: this.defaultSummary
: SUMMARY_PLACEHOLDER
const typeAsString =
zodObjectTypeName || this.checker.typeToString(itemType)
@@ -1493,6 +1369,10 @@ class OasKindGenerator extends FunctionKindGenerator {
const properties: Record<string, OpenApiSchema> = {}
const requiredProperties: string[] = []
const baseType = itemType.getBaseTypes()?.[0]
const isDeleteResponse =
baseType?.aliasSymbol?.getEscapedName() === "DeleteResponse"
if (level + 1 <= this.MAX_LEVEL) {
itemType.getProperties().forEach((property) => {
if (
@@ -1514,6 +1394,15 @@ class OasKindGenerator extends FunctionKindGenerator {
parentName: title || descriptionOptions?.parentName,
},
})
if (isDeleteResponse && property.name === "object") {
// try to retrieve default from `DeleteResponse`'s type argument
const deleteTypeArg = baseType.aliasTypeArguments?.[0]
properties[property.name].default =
deleteTypeArg && "value" in deleteTypeArg
? (deleteTypeArg.value as string)
: properties[property.name].default
}
})
}
@@ -1565,12 +1454,12 @@ class OasKindGenerator extends FunctionKindGenerator {
// either retrieve the description from the knowledge base or use
// the default summary
return (
this.knowledgeBaseFactory.tryToGetOasDescription({
this.knowledgeBaseFactory.tryToGetOasSchemaDescription({
str: typeStr,
templateOptions: {
parentName,
},
}) || this.defaultSummary
}) || SUMMARY_PLACEHOLDER
)
}
@@ -1592,7 +1481,7 @@ class OasKindGenerator extends FunctionKindGenerator {
description = this.getSymbolDocBlock(symbol)
}
return description.length ? description : this.defaultSummary
return description.length ? description : SUMMARY_PLACEHOLDER
}
/**
@@ -1745,7 +1634,7 @@ class OasKindGenerator extends FunctionKindGenerator {
if (
updatedParameter.description !== parameter.description &&
parameter.description === this.defaultSummary
parameter.description === SUMMARY_PLACEHOLDER
) {
parameter.description = updatedParameter.description
}
@@ -1775,7 +1664,7 @@ class OasKindGenerator extends FunctionKindGenerator {
if (
(updatedParameter.schema as OpenApiSchema).description !==
(parameter.schema as OpenApiSchema).description &&
(parameter.schema as OpenApiSchema).description === this.defaultSummary
(parameter.schema as OpenApiSchema).description === SUMMARY_PLACEHOLDER
) {
;(parameter.schema as OpenApiSchema).description = (
updatedParameter.schema as OpenApiSchema
@@ -1856,10 +1745,10 @@ class OasKindGenerator extends FunctionKindGenerator {
if (
oldSchemaObj!.description !== newSchemaObj?.description &&
oldSchemaObj!.description === this.defaultSummary
oldSchemaObj!.description === SUMMARY_PLACEHOLDER
) {
oldSchemaObj!.description =
newSchemaObj?.description || this.defaultSummary
newSchemaObj?.description || SUMMARY_PLACEHOLDER
}
oldSchemaObj!.required = newSchemaObj?.required
@@ -1971,15 +1860,8 @@ class OasKindGenerator extends FunctionKindGenerator {
this.tags.get(area)?.forEach((tag) => {
const existingTag = areaYaml.tags!.find((baseTag) => baseTag.name === tag)
// try to retrieve associated schema
let schema: ParsedSchema | undefined
this.oasSchemaHelper.tagNameToSchemaName(tag, area).some((schemaName) => {
schema = this.oasSchemaHelper.getSchemaByName(schemaName)
if (schema) {
return true
}
})
const schemaName = this.oasSchemaHelper.tagNameToSchemaName(tag, area)
const schema = this.oasSchemaHelper.getSchemaByName(schemaName, false)
const associatedSchema = schema?.schema?.["x-schemaName"]
? {
$ref: this.oasSchemaHelper.constructSchemaReference(

View File

@@ -27,3 +27,9 @@ export const DEFAULT_OAS_RESPONSES: {
$ref: "#/components/responses/500_error",
},
}
export const API_ROUTE_PARAM_REGEX = /\[(.+?)\]/g
// we can't use `{summary}` because it causes an MDX error
// when we finally render the summary. We can alternatively
// use `\{summary\}` but it wouldn't look pretty in the OAS,
// so doing this for now.
export const SUMMARY_PLACEHOLDER = "SUMMARY"