Co-authored-by: Lucas Costi <lucascosti@users.noreply.github.com> Co-authored-by: Peter Bengtsson <peterbe@github.com>
402 lines
14 KiB
JavaScript
402 lines
14 KiB
JavaScript
#!/usr/bin/env node
|
|
import { readFile } from 'fs/promises'
|
|
import { get, flatten, isPlainObject } from 'lodash-es'
|
|
import { sentenceCase } from 'change-case'
|
|
import GitHubSlugger from 'github-slugger'
|
|
import httpStatusCodes from 'http-status-code'
|
|
import renderContent from '../../../lib/render-content/index.js'
|
|
import createCodeSamples from './create-code-samples.js'
|
|
import Ajv from 'ajv'
|
|
import operationSchema from './operation-schema.js'
|
|
const overrideOperations = JSON.parse(
|
|
await readFile('script/rest/utils/rest-api-overrides.json', 'utf8')
|
|
)
|
|
const slugger = new GitHubSlugger()
|
|
|
|
// titles that can't be derived by sentence-casing the ID
|
|
const categoryTitles = { scim: 'SCIM' }
|
|
|
|
export default class Operation {
|
|
constructor(verb, requestPath, props, serverUrl) {
|
|
const defaultProps = {
|
|
parameters: [],
|
|
'x-codeSamples': [],
|
|
responses: {},
|
|
}
|
|
|
|
Object.assign(this, { verb, requestPath, serverUrl }, defaultProps, props)
|
|
|
|
slugger.reset()
|
|
this.slug = slugger.slug(this.summary)
|
|
|
|
// Add category
|
|
|
|
// workaround for misnamed `code-scanning.` category bug
|
|
// https://github.com/github/rest-api-description/issues/38
|
|
this['x-github'].category = this['x-github'].category.replace('.', '')
|
|
// A temporary override file allows us to override the category defined in
|
|
// the openapi schema. Without it, we'd have to update several
|
|
// @documentation_urls in the github/github code every time we move
|
|
// an endpoint to a new page.
|
|
this.category = overrideOperations[this.operationId]
|
|
? overrideOperations[this.operationId].category
|
|
: this['x-github'].category
|
|
this.categoryLabel = categoryTitles[this.category] || sentenceCase(this.category)
|
|
|
|
// Add subcategory
|
|
|
|
// A temporary override file allows us to override the subcategory
|
|
// defined in the openapi schema. Without it, we'd have to update several
|
|
// @documentation_urls in the github/github code every time we move
|
|
// an endpoint to a new page.
|
|
if (overrideOperations[this.operationId]) {
|
|
if (overrideOperations[this.operationId].subcategory) {
|
|
this.subcategory = overrideOperations[this.operationId].subcategory
|
|
this.subcategoryLabel = sentenceCase(this.subcategory)
|
|
}
|
|
} else if (this['x-github'].subcategory) {
|
|
this.subcategory = this['x-github'].subcategory
|
|
this.subcategoryLabel = sentenceCase(this.subcategory)
|
|
}
|
|
|
|
// Add content type. We only display one example and default
|
|
// to the first example defined.
|
|
const contentTypes = Object.keys(get(this, 'requestBody.content', []))
|
|
this.contentType = contentTypes[0]
|
|
|
|
return this
|
|
}
|
|
|
|
get schema() {
|
|
return operationSchema
|
|
}
|
|
|
|
async process() {
|
|
this['x-codeSamples'] = createCodeSamples(this)
|
|
|
|
await Promise.all([
|
|
this.renderDescription(),
|
|
this.renderCodeSamples(),
|
|
this.renderResponses(),
|
|
this.renderParameterDescriptions(),
|
|
this.renderBodyParameterDescriptions(),
|
|
this.renderPreviewNotes(),
|
|
this.renderNotes(),
|
|
])
|
|
|
|
const ajv = new Ajv()
|
|
const valid = ajv.validate(this.schema, this)
|
|
if (!valid) {
|
|
console.error(JSON.stringify(ajv.errors, null, 2))
|
|
throw new Error('Invalid operation found')
|
|
}
|
|
}
|
|
|
|
async renderDescription() {
|
|
this.descriptionHTML = await renderContent(this.description)
|
|
return this
|
|
}
|
|
|
|
async renderCodeSamples() {
|
|
return Promise.all(
|
|
this['x-codeSamples'].map(async (sample) => {
|
|
const markdown = createCodeBlock(sample.source, sample.lang.toLowerCase())
|
|
sample.html = await renderContent(markdown)
|
|
return sample
|
|
})
|
|
)
|
|
}
|
|
|
|
async renderResponses() {
|
|
// clone and delete this.responses so we can turn it into a clean array of objects
|
|
const rawResponses = JSON.parse(JSON.stringify(this.responses))
|
|
delete this.responses
|
|
|
|
this.responses = await Promise.all(
|
|
Object.keys(rawResponses).map(async (responseCode) => {
|
|
const rawResponse = rawResponses[responseCode]
|
|
const httpStatusCode = responseCode
|
|
const httpStatusMessage = httpStatusCodes.getMessage(Number(responseCode))
|
|
const responseDescription = rawResponse.description
|
|
|
|
const cleanResponses = []
|
|
|
|
/* Responses can have zero, one, or multiple examples. The `examples`
|
|
* property often only contains one example object. Both the `example`
|
|
* and `examples` properties can be used in the OpenAPI but `example`
|
|
* doesn't work with `$ref`.
|
|
* This works:
|
|
* schema:
|
|
* '$ref': '../../components/schemas/foo.yaml'
|
|
* example:
|
|
* id: 10
|
|
* description: This is a summary
|
|
* foo: bar
|
|
*
|
|
* This doesn't
|
|
* schema:
|
|
* '$ref': '../../components/schemas/foo.yaml'
|
|
* example:
|
|
* '$ref': '../../components/examples/bar.yaml'
|
|
*/
|
|
const examplesProperty = get(rawResponse, 'content.application/json.examples')
|
|
const exampleProperty = get(rawResponse, 'content.application/json.example')
|
|
|
|
// Return early if the response doesn't have an example payload
|
|
if (!exampleProperty && !examplesProperty) {
|
|
return [
|
|
{
|
|
httpStatusCode,
|
|
httpStatusMessage,
|
|
description: responseDescription,
|
|
},
|
|
]
|
|
}
|
|
|
|
// Use the same format for `example` as `examples` property so that all
|
|
// examples can be handled the same way.
|
|
const normalizedExampleProperty = {
|
|
default: {
|
|
value: exampleProperty,
|
|
},
|
|
}
|
|
|
|
const rawExamples = examplesProperty || normalizedExampleProperty
|
|
const rawExampleKeys = Object.keys(rawExamples)
|
|
|
|
for (const exampleKey of rawExampleKeys) {
|
|
const exampleValue = rawExamples[exampleKey].value
|
|
const exampleSummary = rawExamples[exampleKey].summary
|
|
const cleanResponse = {
|
|
httpStatusCode,
|
|
httpStatusMessage,
|
|
}
|
|
|
|
// If there is only one example, use the response description
|
|
// property. For cases with more than one example, some don't have
|
|
// summary properties with a description, so we can sentence case
|
|
// the property name as a fallback
|
|
cleanResponse.description =
|
|
rawExampleKeys.length === 1
|
|
? exampleSummary || responseDescription
|
|
: exampleSummary || sentenceCase(exampleKey)
|
|
|
|
const payloadMarkdown = createCodeBlock(exampleValue, 'json')
|
|
cleanResponse.payload = await renderContent(payloadMarkdown)
|
|
|
|
cleanResponses.push(cleanResponse)
|
|
}
|
|
return cleanResponses
|
|
})
|
|
)
|
|
|
|
// flatten child arrays
|
|
this.responses = flatten(this.responses)
|
|
}
|
|
|
|
async renderParameterDescriptions() {
|
|
return Promise.all(
|
|
this.parameters.map(async (param) => {
|
|
param.descriptionHTML = await renderContent(param.description)
|
|
return param
|
|
})
|
|
)
|
|
}
|
|
|
|
async renderBodyParameterDescriptions() {
|
|
let bodyParamsObject = get(
|
|
this,
|
|
`requestBody.content.${this.contentType}.schema.properties`,
|
|
{}
|
|
)
|
|
let requiredParams = get(this, `requestBody.content.${this.contentType}.schema.required`, [])
|
|
const oneOfObject = get(this, `requestBody.content.${this.contentType}.schema.oneOf`, undefined)
|
|
|
|
// oneOf is an array of input parameter options, so we need to either
|
|
// use the first option or munge the options together.
|
|
if (oneOfObject) {
|
|
const firstOneOfObject = oneOfObject[0]
|
|
const allOneOfAreObjects =
|
|
oneOfObject.filter((elem) => elem.type === 'object').length === oneOfObject.length
|
|
|
|
// TODO: Remove this check
|
|
// This operation shouldn't have a oneOf in this case, it needs to be
|
|
// removed from the schema in the github/github repo.
|
|
if (this.operationId === 'checks/create') {
|
|
delete bodyParamsObject.oneOf
|
|
} else if (allOneOfAreObjects) {
|
|
// When all of the oneOf objects have the `type: object` we
|
|
// need to display all of the parameters.
|
|
// This merges all of the properties and required values into the
|
|
// first requestBody object.
|
|
for (let i = 1; i < oneOfObject.length; i++) {
|
|
Object.assign(firstOneOfObject.properties, oneOfObject[i].properties)
|
|
requiredParams = firstOneOfObject.required.concat(oneOfObject[i].required)
|
|
}
|
|
bodyParamsObject = firstOneOfObject.properties
|
|
} else if (oneOfObject) {
|
|
// When a oneOf exists but the `type` differs, the case has historically
|
|
// been that the alternate option is an array, where the first option
|
|
// is the array as a property of the object. We need to ensure that the
|
|
// first option listed is the most comprehensive and preferred option.
|
|
bodyParamsObject = firstOneOfObject.properties
|
|
requiredParams = firstOneOfObject.required
|
|
}
|
|
}
|
|
|
|
this.bodyParameters = await getBodyParams(bodyParamsObject, requiredParams)
|
|
}
|
|
|
|
async renderPreviewNotes() {
|
|
const previews = get(this, 'x-github.previews', []).filter((preview) => preview.note)
|
|
|
|
return Promise.all(
|
|
previews.map(async (preview) => {
|
|
const note = preview.note
|
|
// remove extra leading and trailing newlines
|
|
.replace(/```\n\n\n/gm, '```\n')
|
|
.replace(/```\n\n/gm, '```\n')
|
|
.replace(/\n\n\n```/gm, '\n```')
|
|
.replace(/\n\n```/gm, '\n```')
|
|
|
|
// convert single-backtick code snippets to fully fenced triple-backtick blocks
|
|
// example: This is the description.\n\n`application/vnd.github.machine-man-preview+json`
|
|
.replace(/\n`application/, '\n```\napplication')
|
|
.replace(/json`$/, 'json\n```')
|
|
preview.html = await renderContent(note)
|
|
})
|
|
)
|
|
}
|
|
|
|
// add additional notes to this array whenever we want
|
|
async renderNotes() {
|
|
this.notes = []
|
|
|
|
return Promise.all(this.notes.map(async (note) => renderContent(note)))
|
|
}
|
|
}
|
|
|
|
// need to use this function recursively to get child and grandchild params
|
|
async function getBodyParams(paramsObject, requiredParams) {
|
|
if (!isPlainObject(paramsObject)) return []
|
|
|
|
return Promise.all(
|
|
Object.keys(paramsObject).map(async (paramKey) => {
|
|
const param = paramsObject[paramKey]
|
|
param.name = paramKey
|
|
param.in = 'body'
|
|
param.rawType = param.type
|
|
param.rawDescription = param.description
|
|
|
|
// Stores the types listed under the `Type` column in the `Parameters`
|
|
// table in the REST API docs. When the parameter contains oneOf
|
|
// there are multiple acceptable parameters that we should list.
|
|
const paramArray = []
|
|
|
|
const oneOfArray = param.oneOf
|
|
const isOneOfObjectOrArray = oneOfArray
|
|
? oneOfArray.filter((elem) => elem.type !== 'object' || elem.type !== 'array')
|
|
: false
|
|
|
|
// When oneOf has the type array or object, the type is defined
|
|
// in a child object
|
|
if (oneOfArray && isOneOfObjectOrArray.length > 0) {
|
|
// Store the defined types
|
|
paramArray.push(oneOfArray.filter((elem) => elem.type).map((elem) => elem.type))
|
|
|
|
// If an object doesn't have a description, it is invalid
|
|
const oneOfArrayWithDescription = oneOfArray.filter((elem) => elem.description)
|
|
|
|
// Use the parent description when set, otherwise enumerate each
|
|
// description in the `Description` column of the `Parameters` table.
|
|
if (!param.description && oneOfArrayWithDescription.length > 1) {
|
|
param.description = oneOfArray
|
|
.filter((elem) => elem.description)
|
|
.map((elem) => `**Type ${elem.type}** - ${elem.description}`)
|
|
.join('\n\n')
|
|
} else if (!param.description && oneOfArrayWithDescription.length === 1) {
|
|
// When there is only on valid description, use that one.
|
|
param.description = oneOfArrayWithDescription[0].description
|
|
}
|
|
}
|
|
|
|
// Arrays require modifying the displayed type (e.g., array of strings)
|
|
if (param.type === 'array') {
|
|
if (param.items.type) paramArray.push(`array of ${param.items.type}s`)
|
|
if (param.items.oneOf) {
|
|
paramArray.push(param.items.oneOf.map((elem) => `array of ${elem.type}s`))
|
|
}
|
|
} else if (param.type) {
|
|
paramArray.push(param.type)
|
|
}
|
|
|
|
if (param.nullable) paramArray.push('nullable')
|
|
|
|
param.type = paramArray.flat().join(' or ')
|
|
param.description = param.description || ''
|
|
const isRequired = requiredParams && requiredParams.includes(param.name)
|
|
const requiredString = isRequired ? '**Required**. ' : ''
|
|
param.description = await renderContent(requiredString + param.description)
|
|
|
|
// there may be zero, one, or multiple object parameters that have children parameters
|
|
param.childParamsGroups = []
|
|
const childParamsGroup = await getChildParamsGroup(param)
|
|
|
|
if (childParamsGroup && childParamsGroup.params.length) {
|
|
param.childParamsGroups.push(childParamsGroup)
|
|
}
|
|
|
|
// if the param is an object, it may have child object params that have child params :/
|
|
if (param.rawType === 'object') {
|
|
param.childParamsGroups.push(
|
|
...flatten(
|
|
childParamsGroup.params
|
|
.filter((param) => param.childParamsGroups.length)
|
|
.map((param) => param.childParamsGroups)
|
|
)
|
|
)
|
|
}
|
|
|
|
return param
|
|
})
|
|
)
|
|
}
|
|
|
|
async function getChildParamsGroup(param) {
|
|
// only objects, arrays of objects, anyOf, allOf, and oneOf have child params
|
|
if (!(param.rawType === 'array' || param.rawType === 'object' || param.oneOf)) return
|
|
if (
|
|
param.oneOf &&
|
|
!param.oneOf.filter((param) => param.type === 'object' || param.type === 'array')
|
|
)
|
|
return
|
|
if (param.items && param.items.type !== 'object') return
|
|
|
|
const childParamsObject = param.rawType === 'array' ? param.items.properties : param.properties
|
|
const requiredParams = param.rawType === 'array' ? param.items.required : param.required
|
|
const childParams = await getBodyParams(childParamsObject, requiredParams)
|
|
|
|
// adjust the type for easier readability in the child table
|
|
const parentType = param.rawType === 'array' ? 'items' : param.rawType
|
|
|
|
// add an ID to the child table so they can be linked to
|
|
slugger.reset()
|
|
const id = slugger.slug(`${param.name}-${parentType}`)
|
|
|
|
return {
|
|
parentName: param.name,
|
|
parentType,
|
|
id,
|
|
params: childParams,
|
|
}
|
|
}
|
|
|
|
function createCodeBlock(input, language) {
|
|
// stringify JSON if needed
|
|
if (language === 'json' && typeof input !== 'string') {
|
|
input = JSON.stringify(input, null, 2)
|
|
}
|
|
|
|
return ['```' + language, input, '```'].join('\n')
|
|
}
|