1
0
mirror of synced 2025-12-20 10:28:40 -05:00
Files
docs/script/rest/utils/operation.js
2021-04-21 15:09:36 -07:00

356 lines
13 KiB
JavaScript

const { get, flatten, isPlainObject } = require('lodash')
const { sentenceCase } = require('change-case')
const slugger = new (require('github-slugger'))()
const httpStatusCodes = require('http-status-code')
const renderContent = require('../../../lib/render-content')
const createCodeSamples = require('./create-code-samples')
const Ajv = require('ajv')
// titles that can't be derived by sentence-casing the ID
const categoryTitles = { scim: 'SCIM' }
module.exports = 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('.', '')
this.category = this['x-github'].category
this.categoryLabel = categoryTitles[this.category] || sentenceCase(this.category)
// Add subcategory
if (this['x-github'].subcategory) {
this.subcategory = this['x-github'].subcategory
this.subcategoryLabel = sentenceCase(this.subcategory)
}
return this
}
get schema () {
return require('./operation-schema')
}
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.application/json.schema.properties', {})
let requiredParams = get(this, 'requestBody.content.application/json.schema.required', [])
const oneOfObject = get(this, 'requestBody.content.application/json.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/mg, '```\n')
.replace(/```\n\n/mg, '```\n')
.replace(/\n\n\n```/mg, '\n```')
.replace(/\n\n```/mg, '\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')
}