1
0
mirror of synced 2025-12-19 18:10:59 -05:00

Reorganize REST code (#34323)

This commit is contained in:
Rachael Sewell
2023-02-13 18:13:21 -08:00
committed by GitHub
parent 69b20b2da1
commit 6ab083d643
57 changed files with 84 additions and 95 deletions

File diff suppressed because it is too large Load Diff

25
src/rest/README.md Normal file
View File

@@ -0,0 +1,25 @@
# REST
## About this directory
* `src/rest/lib/index.js` is human-editable.
* `src/rest/data/*.json` are generated by [scripts](../../src/rest/scripts/README.md).
## Editable files
* `src/rest/lib/index.js` consumes the static decorated schema files and exports `categories`, `operations`, and `operationsEnabledForGitHubApps` used by the REST middleware contextualizer.
## Static files
Generated by `src/rest/scripts/update-files.js`:
* `src/rest/data` - files generated from the dereferenced OpenAPI schema with the Markdown descriptions rendered in HTML
* `src/rest/data/dereferenced` - **NOTE** These are only generated if you pass the --keep-dereferenced-files option and are not checked into the repository. This option is for debug only.
## Rendering docs
When the server starts, `middleware/contextualizers/rest.js` accesses the data exported from the static decorated JSON files, fetches the data for the current version and requested path, and adds it to the `context` object. The added property is:
* `req.context.currentRestOperations` - all operations with a category matching the current path
Markdown files in `content/rest/reference` use Liquid to loop over these context properties. The Liquid calls HTML files in the `includes` directory to do most of the rendering. Writers can add content to the Markdown files alongside the Liquid.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

444703
src/rest/data/ghes-3.4.json Normal file

File diff suppressed because one or more lines are too long

452379
src/rest/data/ghes-3.5.json Normal file

File diff suppressed because one or more lines are too long

466128
src/rest/data/ghes-3.6.json Normal file

File diff suppressed because one or more lines are too long

470516
src/rest/data/ghes-3.7.json Normal file

File diff suppressed because one or more lines are too long

493604
src/rest/data/ghes-3.8.json Normal file

File diff suppressed because one or more lines are too long

401485
src/rest/data/github.ae.json Normal file

File diff suppressed because one or more lines are too long

148
src/rest/lib/index.js Normal file
View File

@@ -0,0 +1,148 @@
import fs from 'fs'
import path from 'path'
import { readCompressedJsonFileFallback } from '../../../lib/read-json-file.js'
import { getAutomatedPageMiniTocItems } from '../../../lib/get-mini-toc-items.js'
import { allVersions } from '../../../lib/all-versions.js'
import languages from '../../../lib/languages.js'
const schemasPath = 'src/rest/data'
const ENABLED_APPS_FILENAME = 'src/github-apps/data/enabled-for-apps.json'
const contentPath = 'content/rest'
/*
Loads the schemas from the static/decorated folder into a single
object organized by version. Not all products are calendar date
versioned.
Example:
{
free-pro-team@latest: {
2022-08-09: {
category: {
subcategory: [operations],
}
},
2022-11-14: {
category: {
subcategory: [operations],
}
}
}
enterprise-server@3.2: {
'not_api_versioned': {
category: {
subcategory: [operations],
}
}
}
}
*/
const NOT_API_VERSIONED = 'not_api_versioned'
const restOperationData = new Map()
const restOperations = new Map()
Object.keys(languages).forEach((language) => {
restOperationData.set(language, new Map())
Object.keys(allVersions).forEach((version) => {
// setting to undefined will allow us to perform checks
// more easily later on
restOperationData.get(language).set(version, new Map())
if (allVersions[version].apiVersions && allVersions[version].apiVersions.length > 0) {
allVersions[version].apiVersions.forEach((date) => {
restOperationData.get(language).get(version).set(date, new Map())
})
} else {
// Products that are not been calendar date versioned
restOperationData.get(language).get(version).set(NOT_API_VERSIONED, new Map())
}
})
})
export const categoriesWithoutSubcategories = fs
.readdirSync(contentPath)
.filter((file) => {
return file.endsWith('.md') && !file.includes('index.md') && !file.includes('README.md')
})
.map((filteredFile) => filteredFile.replace('.md', ''))
// version: plan + release e.g. For ghes-3.5, ghes is the plan and 3.5 is the release
// apiVersion (not all versions have apiVersions): REST API Calendar Dates
// openApiVersion (below, every version has an openApiVersion mapping): There's a mapping between our Docs versions
// and the OpenApi Version bc it's not the same
export default async function getRest(version, apiVersion, category, subCategory) {
const openApiVersion = getOpenApiVersion(version)
const filename = apiVersion ? `${openApiVersion}.${apiVersion}.json` : `${openApiVersion}.json`
const apiDate = apiVersion || NOT_API_VERSIONED
if (!restOperations.has(openApiVersion)) {
restOperations.set(openApiVersion, new Map())
restOperations.get(openApiVersion).set(apiDate, new Map())
// The `readCompressedJsonFileFallback()` function
// will check for both a .br and .json extension.
restOperations
.get(openApiVersion)
.set(apiDate, readCompressedJsonFileFallback(path.join(schemasPath, filename)))
} else if (!restOperations.get(openApiVersion).has(apiDate)) {
restOperations.get(openApiVersion).set(apiDate, new Map())
// The `readCompressedJsonFileFallback()` function
// will check for both a .br and .json extension.
restOperations
.get(openApiVersion)
.set(apiDate, readCompressedJsonFileFallback(path.join(schemasPath, filename)))
}
if (subCategory) {
return restOperations.get(openApiVersion).get(apiDate)[category][subCategory]
} else if (category) {
return restOperations.get(openApiVersion).get(apiDate)[category]
} else {
return restOperations.get(openApiVersion).get(apiDate)
}
}
function getOpenApiVersion(version) {
if (!(version in allVersions)) {
throw new Error(`Unrecognized version '${version}'. Not found in ${Object.keys(allVersions)}`)
}
return allVersions[version].openApiVersionName
}
// Generates the miniToc for a rest reference page.
export async function getRestMiniTocItems(
category,
subCategory,
apiVersion,
restOperations,
language,
version,
context
) {
const apiDate = apiVersion || NOT_API_VERSIONED
if (!restOperationData.get(language).get(version).get(apiDate).has(category)) {
restOperationData.get(language).get(version).get(apiDate).set(category, new Map())
}
if (!restOperationData.get(language).get(version).get(apiDate).get(category).get(subCategory)) {
const languageTree = restOperationData.get(language)
const titles = restOperations.map((operation) => operation.title)
const restOperationsMiniTocItems = await getAutomatedPageMiniTocItems(titles, context, 3)
languageTree.get(version).get(apiDate).get(category).set(subCategory, {
restOperationsMiniTocItems,
})
restOperationData.set(restOperationData, languageTree)
}
return restOperationData.get(language).get(version).get(apiDate).get(category).get(subCategory)
}
const enabledForApps = {}
export async function getEnabledForApps(docsVersion, apiVersion) {
if (Object.keys(enabledForApps).length === 0) {
// The `readCompressedJsonFileFallback()` function
// will check for both a .br and .json extension.
Object.assign(enabledForApps, readCompressedJsonFileFallback(ENABLED_APPS_FILENAME))
}
const openApiVersion = getOpenApiVersion(docsVersion) + (apiVersion ? `.${apiVersion}` : '')
return enabledForApps[openApiVersion]
}

View File

@@ -0,0 +1,21 @@
# REST scripts
Writers run the [update-files.js](./update-files.js) script to get the latest dereferenced OpenAPI schema files.
```
src/rest/scripts/update-files.js
```
These scripts update the dereferenced OpenAPI files to create [the decorated files](../../src/rest/data) used to
render REST docs. See the [`src/rest/README`](../../src/rest/README.md)
for more info.
## Production `--decorate-only` option
When changes to the OpenAPI are merged to the default branch of the `github/github` repository, a pull request is automatically opened with the updated dereferenced OpenAPI files. When pull requests are authored by `github-openapi-bot`, a CI test runs the `src/rest/scripts/update-files.js` script with the `--decorate-only` option. The `--decorate-only` option only decorates the dereferenced OpenAPI files, using the existing dereferenced OpenAPI schema files, and checks those changes in to the existing branch. The `--decorate-only` option is only used by a 🤖 and is only used on production dereferenced OpenAPI schema files.
The `.github/workflows/openapi-schema-check.yml` CI test checks that the dereferenced and decorated schema files match. If the files don't match, potential causes could be:
- something went wrong when the schema changes (created by `github-openapi-bot`) were merged into another branch
- the workflow that generates the decorated files didn't run or failed
⚠️ Only do this if you know exactly what the `--decorate-only` option does. ⚠️
If you know that the dereferenced schema files are correct, you can run the `src/rest/scripts/update-files.js --decorate-only` command on the branch locally to update the decorated files in your branch.

View File

@@ -0,0 +1,57 @@
#!/usr/bin/env node
// [start-readme]
//
// Run this script to check if OpenAPI files can be decorated successfully.
//
// [end-readme]
import fs from 'fs'
import path from 'path'
import glob from 'glob'
import { program } from 'commander'
import getOperations from './utils/get-operations.js'
program
.description('Generate dereferenced OpenAPI and decorated schema files.')
.requiredOption(
'-f, --files [files...]',
'A list of OpenAPI description files to check. Can parse literal glob patterns.'
)
.parse(process.argv)
const filenames = program.opts().files
const filesToCheck = filenames.flatMap((filename) => glob.sync(filename))
if (filesToCheck.length) {
check(filesToCheck)
} else {
console.log('No files to verify.')
process.exit(1)
}
async function check(files) {
console.log('Verifying OpenAPI files are valid with decorator')
const documents = files.map((filename) => [
filename,
JSON.parse(fs.readFileSync(path.join(process.cwd(), filename))),
])
for (const [filename, schema] of documents) {
try {
// munge OpenAPI definitions object in an array of operations objects
const operations = await getOperations(schema)
// process each operation, asynchronously rendering markdown and stuff
await Promise.all(operations.map((operation) => operation.process()))
console.log(`Successfully could decorate OpenAPI operations for document ${filename}`)
} catch (error) {
console.error(error)
console.log(
`🐛 Whoops! It looks like the decorator script wasn't able to parse the dereferenced schema in file ${filename}. A recent change may not yet be supported by the decorator. Please reach out in the #docs-engineering slack channel for help.`
)
process.exit(1)
}
}
}

View File

@@ -0,0 +1,168 @@
#!/usr/bin/env node
// [start-readme]
//
// Run this script to check if OpenAPI operations match versions in content/rest operations
//
// [end-readme]
import fs from 'fs'
import path from 'path'
import _ from 'lodash'
import frontmatter from '../../../lib/read-frontmatter.js'
import getApplicableVersions from '../../../lib/get-applicable-versions.js'
import { allVersions, getDocsVersion } from '../../../lib/all-versions.js'
const contentFiles = []
export async function getDiffOpenAPIContentRest() {
const contentPath = path.join(process.cwd(), 'content/rest')
// Recursively go through the content/rest directory and add all categories/subcategories to contentFiles
throughDirectory(contentPath)
// Creating the categories/subcategories based on the current content directory
const checkContentDir = await createCheckContentDirectory(contentFiles)
// Create categories/subcategories from OpenAPI Schemas
const openAPISchemaCheck = await createOpenAPISchemasCheck()
// Get Differences between categories/subcategories from dereferenced schemas and the content/rest directory frontmatter versions
const differences = getDifferences(openAPISchemaCheck, checkContentDir)
const errorMessages = {}
if (Object.keys(differences).length > 0) {
for (const schemaName in differences) {
errorMessages[schemaName] = {}
differences[schemaName].forEach((category) => {
if (!errorMessages[schemaName]) errorMessages[schemaName] = category
errorMessages[schemaName][category] = {
contentDir: checkContentDir[schemaName][category],
openAPI: openAPISchemaCheck[schemaName][category],
}
})
}
}
return errorMessages
}
async function createOpenAPISchemasCheck() {
const schemasPath = path.join(process.cwd(), 'src/rest/data')
const openAPICheck = createCheckObj()
const schemas = fs.readdirSync(schemasPath)
schemas.forEach((file) => {
const fileData = fs.readFileSync(path.join(schemasPath, file))
const fileSchema = JSON.parse(fileData.toString())
const categories = Object.keys(fileSchema).sort()
const version = getDocsVersion(file.split(/.json/)[0])
categories.forEach((category) => {
const subcategories = Object.keys(fileSchema[category])
if (isApiVersioned(version)) {
getOnlyApiVersions(version).forEach(
(apiVersion) => (openAPICheck[apiVersion][category] = subcategories.sort())
)
} else {
openAPICheck[version][category] = subcategories.sort()
}
})
})
return openAPICheck
}
async function createCheckContentDirectory(contentFiles) {
const checkContent = createCheckObj()
for (const filename of contentFiles) {
const { data } = frontmatter(await fs.promises.readFile(filename, 'utf8'))
const applicableVersions = getApplicableVersions(data.versions, filename)
const splitPath = filename.split('/')
const subCategory = splitPath[splitPath.length - 1].replace('.md', '')
const category =
splitPath[splitPath.length - 2] === 'rest' ? subCategory : splitPath[splitPath.length - 2]
// All versions with appended calendar date versions if it exists
const allCompleteVersions = applicableVersions.flatMap((version) => {
return isApiVersioned(version)
? allVersions[version].apiVersions.map(
(apiVersion) => `${allVersions[version].version}.${apiVersion}`
)
: version
})
allCompleteVersions.forEach((version) => {
!checkContent[version][category]
? (checkContent[version][category] = [subCategory])
: checkContent[version][category].push(subCategory)
checkContent[version][category].sort()
})
}
return checkContent
}
function isApiVersioned(version) {
return allVersions[version] && allVersions[version].apiVersions.length > 0
}
function getOnlyApiVersions(version) {
return allVersions[version].apiVersions.map(
(apiVersion) => `${allVersions[version].version}.${apiVersion}`
)
}
function createCheckObj() {
const versions = {}
Object.keys(allVersions).forEach((version) => {
isApiVersioned(version)
? getOnlyApiVersions(version).forEach((apiVersion) => (versions[apiVersion] = {}))
: (versions[`${allVersions[version].version}`] = {})
})
return versions
}
function getDifferences(openAPISchemaCheck, contentCheck) {
const differences = {}
for (const version in openAPISchemaCheck) {
const diffOpenApiContent = difference(openAPISchemaCheck[version], contentCheck[version])
if (Object.keys(diffOpenApiContent).length > 0) differences[version] = diffOpenApiContent
}
return differences
}
function throughDirectory(directory) {
fs.readdirSync(directory).forEach((file) => {
const absolute = path.join(directory, file)
if (fs.statSync(absolute).isDirectory()) {
return throughDirectory(absolute)
} else if (
!directory.includes('rest/guides') &&
!directory.includes('rest/overview') &&
!file.includes('index.md') &&
!file.includes('quickstart.md') &&
!file.includes('README.md')
) {
return contentFiles.push(absolute)
}
})
}
function difference(obj1, obj2) {
const diff = Object.keys(obj1).reduce((result, key) => {
if (!Object.prototype.hasOwnProperty.call(obj2, key)) {
result.push(key)
} else if (_.isEqual(obj1[key], obj2[key])) {
const resultKeyIndex = result.indexOf(key)
result.splice(resultKeyIndex, 1)
}
return result
}, Object.keys(obj2))
return diff
}

169
src/rest/scripts/update-files.js Executable file
View File

@@ -0,0 +1,169 @@
#!/usr/bin/env node
// [start-readme]
//
// Run this script to pull openAPI files from github/github, dereference them, and decorate them.
//
// [end-readme]
import { stat, readdir } from 'fs/promises'
import path from 'path'
import { program } from 'commander'
import { execSync } from 'child_process'
import mkdirp from 'mkdirp'
import rimraf from 'rimraf'
import { decorate } from './utils/decorator.js'
import { validateVersionsOptions } from './utils/get-openapi-schemas.js'
import { allVersions } from '../../../lib/all-versions.js'
const TEMP_DOCS_DIR = path.join(process.cwd(), 'openapiTmp')
const DOCS_DEREF_OPENAPI_DIR = path.join(process.cwd(), 'src/rest/data/dereferenced')
const GITHUB_REP_DIR = path.join(process.cwd(), '../github')
program
.description('Generate dereferenced OpenAPI and decorated schema files.')
.option(
'--decorate-only',
'⚠️ Only used by a 🤖 to generate decorated schema files from existing dereferenced schema files.'
)
.option(
'-v --versions <VERSIONS...>',
'A list of undeprecated, published versions to build, separated by a space. Example `-v ghes-3.1` or `-v api.github.com github.ae`'
)
.option('-d --include-deprecated', 'Includes schemas that are marked as `deprecated: true`')
.option('-u --include-unpublished', 'Includes schemas that are marked as `published: false`')
.option(
'-k --keep-dereferenced-files',
'Keeps the dereferenced files after the script runs. You will need to delete them manually.'
)
.option('-n --next', 'Generate the next OpenAPI calendar-date version.')
.option('-s --open-source', 'Generate the OpenAPI schema from github/rest-api-description')
.parse(process.argv)
const {
decorateOnly,
versions,
includeUnpublished,
includeDeprecated,
keepDereferencedFiles,
next,
openSource,
} = program.opts()
main()
async function main() {
await validateInputParameters()
// Generate the dereferenced OpenAPI schema files
if (!decorateOnly) {
await getBundledFiles()
}
// When we get the dereferenced OpenAPI files from the open-source
// github/rest-api-description repo, we need to remove any versions
// that are deprecated.
if (openSource) {
const currentOpenApiVersions = Object.values(allVersions).map((elem) => elem.openApiVersionName)
const allSchemas = await readdir(DOCS_DEREF_OPENAPI_DIR)
allSchemas.forEach((schema) => {
// if the schema does not start with a current version name, delete it
if (!currentOpenApiVersions.some((version) => schema.startsWith(version))) {
rimraf.sync(path.join(DOCS_DEREF_OPENAPI_DIR, schema))
}
})
}
const schemas = await readdir(DOCS_DEREF_OPENAPI_DIR)
// Decorate the dereferenced files in a format ingestible by docs.github.com
await decorate(schemas)
console.log(
'\n🏁 The static REST API files are now up-to-date with your local `github/github` checkout. To revert uncommitted changes, run `git checkout src/rest/data/*`.\n\n'
)
if (!keepDereferencedFiles) {
rimraf.sync(DOCS_DEREF_OPENAPI_DIR)
}
}
async function getBundledFiles() {
// Get the github/github repo branch name and pull latest
const githubBranch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: GITHUB_REP_DIR })
.toString()
.trim()
// Only pull master branch because development mode branches are assumed
// to be up-to-date during active work.
if (githubBranch === 'master') {
execSync('git pull', { cwd: GITHUB_REP_DIR })
}
// Create a tmp directory to store schema files generated from github/github
rimraf.sync(TEMP_DOCS_DIR)
await mkdirp(TEMP_DOCS_DIR)
console.log(
`\n🏃🏃🏃Running \`bin/openapi bundle\` in branch '${githubBranch}' of your github/github checkout to generate the dereferenced OpenAPI schema files.\n`
)
// Format the command supplied to the bundle script in `github/github`
const bundlerOptions = await getBundlerOptions()
const bundleCommand = `bundle -v -w${next ? ' -n' : ''} -o ${TEMP_DOCS_DIR} ${bundlerOptions}`
try {
console.log(bundleCommand)
execSync(`${path.join(GITHUB_REP_DIR, 'bin/openapi')} ${bundleCommand}`, { stdio: 'inherit' })
} catch (error) {
console.error(error)
const errorMsg =
'🛑 Whoops! It looks like the `bin/openapi bundle` command failed to run in your `github/github` repository checkout.\n\n✅ Troubleshooting:\n - Make sure you have a codespace with a checkout of `github/github` at the same level as your `github/docs-internal` repo before running this script. See this documentation for details: https://thehub.github.com/epd/engineering/products-and-services/public-apis/rest/openapi/openapi-in-the-docs/#previewing-changes-in-the-docs.\n - Ensure that your OpenAPI schema YAML is formatted correctly. A CI test runs on your `github/github` PR that flags malformed YAML. You can check the PR diff view for comments left by the OpenAPI CI test to find and fix any formatting errors.\n\n'
throw new Error(errorMsg)
}
// Moving the dereferenced files to the docs directory creates a consistent
// place to generate the decorated files from. This is where they will be
// delivered in automated pull requests and because of that we move them
// to the same location during local development.
await mkdirp(DOCS_DEREF_OPENAPI_DIR)
execSync(
`find ${TEMP_DOCS_DIR} -type f -name "*deref.json" -exec mv '{}' ${DOCS_DEREF_OPENAPI_DIR} ';'`
)
rimraf.sync(TEMP_DOCS_DIR)
}
async function getBundlerOptions() {
let includeParams = []
if (versions) {
includeParams = versions
}
if (includeUnpublished) {
includeParams.push('--include_unpublished')
}
if (includeDeprecated) {
includeParams.push('--include_deprecated')
}
return includeParams.join(' ')
}
async function validateInputParameters() {
// The `--versions` and `--decorate-only` options cannot be used
// with the `--include-deprecated` or `--include-unpublished` options
if ((includeDeprecated || includeUnpublished) && (decorateOnly || versions)) {
const errorMsg = `🛑 You cannot use the versions option with the include-unpublished or include-deprecated options. This is not currently supported in the bundler.\nYou cannot use the decorate-only option with include-unpublished or include-deprecated because the include-unpublished and include-deprecated options are only available when running the bundler. The decorate-only option skips running the bundler.\nPlease reach out to #docs-engineering if a new use case should be supported.`
throw new Error(errorMsg)
}
// Check that the github/github repo exists. If the files are only being
// decorated, the github/github repo isn't needed.
if (!decorateOnly) {
try {
await stat(GITHUB_REP_DIR)
} catch (error) {
const errorMsg = `🛑 The ${GITHUB_REP_DIR} does not exist. Make sure you have a codespace with a checkout of \`github/github\` at the same level as your \`github/docs-internal \`repo before running this script. See this documentation for details: https://thehub.github.com/epd/engineering/products-and-services/public-apis/rest/openapi/openapi-in-the-docs/#previewing-changes-in-the-docs.`
throw new Error(errorMsg)
}
}
if (versions && versions.length) {
await validateVersionsOptions(versions)
}
}

View File

@@ -0,0 +1,359 @@
#!/usr/bin/env node
// In the case that there are more than one example requests, and
// no content responses, a request with an example key that matches the
// status code of a response will be matched.
const DEFAULT_EXAMPLE_DESCRIPTION = 'Example'
const DEFAULT_EXAMPLE_KEY = 'default'
const DEFAULT_ACCEPT_HEADER = 'application/vnd.github.v3+json'
// Retrieves request and response examples and attempts to
// merge them to create matching request/response examples
// The key used in the media type `examples` property is
// used to match requests to responses.
export default function getCodeSamples(operation) {
const responseExamples = getResponseExamples(operation)
const requestExamples = getRequestExamples(operation)
const mergedExamples = mergeExamples(requestExamples, responseExamples)
// If there are multiple examples and if the request body
// has the same description, add a number to the example
if (mergedExamples.length > 1) {
const count = {}
mergedExamples.forEach((item) => {
count[item.request.description] = (count[item.request.description] || 0) + 1
})
const newMergedExamples = mergedExamples.map((example, i) => ({
...example,
request: {
...example.request,
description:
count[example.request.description] > 1
? example.request.description +
' ' +
(i + 1) +
': Status Code ' +
example.response.statusCode
: example.request.description,
},
}))
return newMergedExamples
}
return mergedExamples
}
export function mergeExamples(requestExamples, responseExamples) {
// There is always at least one request example, but it won't create
// a meaningful example unless it has a response example.
if (requestExamples.length === 1 && responseExamples.length === 0) {
return []
}
// If there is one request and one response example, we don't
// need to merge the requests and responses, and we don't need
// to match keys directly. This allows falling back in the
// case that the existing OpenAPI schema has mismatched example keys.
if (requestExamples.length === 1 && responseExamples.length === 1) {
return [{ ...requestExamples[0], response: responseExamples[0].response }]
}
// If there is a request with no request body parameters and all of
// the responses have no content, then we can create a docs
// example for just status codes below 300. All other status codes will
// be listed in the status code table in the docs.
if (
requestExamples.length === 1 &&
responseExamples.length > 1 &&
!responseExamples.find((ex) => ex.response.example)
) {
return responseExamples
.filter((resp) => parseInt(resp.response.statusCode, 10) < 300)
.map((ex) => ({ ...requestExamples[0], ...ex }))
}
// If there is exactly one request example and one or more response
// examples, we can make a docs example for the response examples that
// have content. All remaining status codes with no content
// will be listed in the status code table in the docs.
if (
requestExamples.length === 1 &&
responseExamples.length > 1 &&
responseExamples.filter((ex) => ex.response.example).length >= 1
) {
return responseExamples
.filter((ex) => ex.response.example)
.map((ex) => ({ ...requestExamples[0], ...ex }))
}
// Finally, we'll attempt to match examples with matching keys.
// This iterates through the longer array and compares key values to keys in
// the shorter array.
const requestsExamplesLarger = requestExamples.length >= responseExamples.length
const target = requestsExamplesLarger ? requestExamples : responseExamples
const source = requestsExamplesLarger ? responseExamples : requestExamples
// Iterates over the larger array or "target" (or if equal requests) to see
// if there are any matches in the smaller array or "source"
// (or if equal responses) that can be added to target array. If a request
// example and response example have matching keys they will be merged into
// an example. If there is more than one key match, the first match will
// be used.
return target.filter((targetEx) => {
const match = source.find((srcEx) => srcEx.key === targetEx.key)
if (match) return Object.assign(targetEx, match)
return false
})
}
/*
Create an example object for each example in the requestBody property
of the schema. Each requestBody can have more than one content type.
Each content type can have more than one example. We create an object
for each permutation of content type and example.
Returns an array of objects in the format:
{
key,
request: {
contentType,
description,
acceptHeader,
bodyParameters,
parameters,
}
}
*/
export function getRequestExamples(operation) {
const requestExamples = []
const parameterExamples = getParameterExamples(operation)
// When no request body or parameters are defined, we create a generic
// request example. Not all operations have request bodies or parameters,
// but we always want to show at least an example with the path.
if (!operation.requestBody && Object.keys(parameterExamples).length === 0) {
return [
{
key: DEFAULT_EXAMPLE_KEY,
request: {
description: DEFAULT_EXAMPLE_DESCRIPTION,
acceptHeader: DEFAULT_ACCEPT_HEADER,
},
},
]
}
// When no request body exists, we create an example from the parameters
if (!operation.requestBody) {
return Object.keys(parameterExamples).map((key) => {
return {
key,
request: {
description: DEFAULT_EXAMPLE_DESCRIPTION,
acceptHeader: DEFAULT_ACCEPT_HEADER,
parameters: parameterExamples[key] || parameterExamples.default,
},
}
})
}
// Requests can have multiple content types each with their own set of
// examples.
Object.keys(operation.requestBody.content).forEach((contentType) => {
let examples = {}
// This is a fallback to allow using the `example` property in
// the schema. If we start to enforce using examples vs. example using
// a linter, we can remove the check for `example`.
// For now, we'll use the key default, which is a common default
// example name in the OpenAPI schema.
if (operation.requestBody.content[contentType].example) {
examples = {
default: {
value: operation.requestBody.content[contentType].example,
},
}
} else if (operation.requestBody.content[contentType].examples) {
examples = operation.requestBody.content[contentType].examples
} else {
// Example for this content type doesn't exist so we'll try and create one
requestExamples.push({
key: DEFAULT_EXAMPLE_KEY,
request: {
contentType,
description: DEFAULT_EXAMPLE_DESCRIPTION,
acceptHeader: DEFAULT_ACCEPT_HEADER,
parameters: parameterExamples.default,
},
})
return
}
// There can be more than one example for a given content type. We need to
// iterate over the keys of the examples to create individual
// example objects
Object.keys(examples).forEach((key) => {
// A content type that includes `+json` is a custom media type
// The default accept header is application/vnd.github.v3+json
// Which would have a content type of `application/json`
const acceptHeader = contentType.includes('+json')
? contentType
: 'application/vnd.github.v3+json'
const example = {
key,
request: {
contentType,
description: examples[key].summary || DEFAULT_EXAMPLE_DESCRIPTION,
acceptHeader,
bodyParameters: examples[key].value,
parameters: parameterExamples[key] || parameterExamples.default,
},
}
requestExamples.push(example)
})
})
return requestExamples
}
/*
Create an example object for each example in the response property
of the schema. Each response can have more than one status code,
each with more than one content type. And each content type can
have more than one example. We create an object
for each permutation of status, content type, and example.
Returns an array of objects in the format:
{
key,
response: {
statusCode,
contentType,
description,
example,
}
}
*/
export function getResponseExamples(operation) {
const responseExamples = []
Object.keys(operation.responses).forEach((statusCode) => {
// We don't want to create examples for error codes
// Error codes are displayed in the status table in the docs
if (parseInt(statusCode, 10) >= 400) return
const content = operation.responses[statusCode].content
// A response doesn't always have content (ex:, status 304)
// In this case we create a generic example for the status code
// with a key that matches the status code.
if (!content) {
const example = {
key: statusCode,
response: {
statusCode,
description: operation.responses[statusCode].description,
},
}
responseExamples.push(example)
return
}
// Responses can have multiple content types each with their own set of
// examples.
Object.keys(content).forEach((contentType) => {
let examples = {}
// This is a fallback to allow using the `example` property in
// the schema. If we start to enforce using examples vs. example using
// a linter, we can remove the check for `example`.
// For now, we'll use the key default, which is a common default
// example name in the OpenAPI schema.
if (operation.responses[statusCode].content[contentType].example) {
examples = {
default: {
value: operation.responses[statusCode].content[contentType].example,
},
}
} else if (operation.responses[statusCode].content[contentType].examples) {
examples = operation.responses[statusCode].content[contentType].examples
} else if (parseInt(statusCode, 10) < 300) {
// Sometimes there are missing examples for say a 200 response and
// the operation also has a 304 no content status. If we don't add
// the 200 response example, even though it has not example response,
// the resulting responseExamples would only contain the 304 response.
// That would be confusing in the docs because it's expected to see the
// common or success responses by default.
const example = {
key: statusCode,
response: {
statusCode,
description: operation.responses[statusCode].description,
},
}
responseExamples.push(example)
return
} else {
// Example for this content type doesn't exist.
// We could also check if there is a fully populated example
// directly in the response schema examples properties.
return
}
// There can be more than one example for a given content type. We need to
// iterate over the keys of the examples to create individual
// example objects
Object.keys(examples).forEach((key) => {
const example = {
key,
response: {
statusCode,
contentType,
description: examples[key].summary || operation.responses[statusCode].description,
example: examples[key].value,
// TODO adding the schema quadruples the JSON file size. Changing
// how we write the JSON file helps a lot, but we should revisit
// adding the response schema to ensure we have a way to view the
// prettified JSON before minimizing it.
schema: operation.responses[statusCode].content[contentType].schema,
},
}
responseExamples.push(example)
})
})
})
return responseExamples
}
/*
Path parameters can have more than one example key. We need to create
an example for each and then choose the most appropriate example when
we merge requests with responses.
Parameter examples are in the format:
{
[parameter key]: {
[parameter name]: value
}
}
*/
export function getParameterExamples(operation) {
if (!operation.parameters) {
return {}
}
const parameters = operation.parameters.filter((param) => param.in === 'path')
const parameterExamples = {}
parameters.forEach((parameter) => {
const examples = parameter.examples
// If there are no examples, create an example from the uppercase parameter
// name, so that it is more visible that the value is fake data
// in the route path.
if (!examples) {
if (!parameterExamples.default) parameterExamples.default = {}
parameterExamples.default[parameter.name] = parameter.name.toUpperCase()
} else {
Object.keys(examples).forEach((key) => {
if (!parameterExamples[key]) parameterExamples[key] = {}
parameterExamples[key][parameter.name] = examples[key].value
})
}
})
return parameterExamples
}

View File

@@ -0,0 +1,333 @@
import { existsSync, mkdirSync } from 'fs'
import { readFile, writeFile } from 'fs/promises'
import path from 'path'
import { slug } from 'github-slugger'
import { allVersions } from '../../../../lib/all-versions.js'
import { categoriesWithoutSubcategories } from '../../lib/index.js'
import getOperations, { getWebhooks } from './get-operations.js'
const ENABLED_APPS = 'src/github-apps/data/enabled-for-apps.json'
const STATIC_REDIRECTS = 'lib/redirects/static/client-side-rest-api-redirects.json'
const REST_DECORATED_DIR = 'src/rest/data'
const WEBHOOK_DECORATED_DIR = 'src/webhooks/data'
const REST_DEREFERENCED_DIR = 'src/rest/data/dereferenced'
export async function decorate(schemas) {
console.log('\n🎄 Decorating the OpenAPI schema files in src/rest/data/dereferenced.\n')
const { restSchemas, webhookSchemas } = await getOpenApiSchemaFiles(schemas)
const webhookOperations = await getWebhookOperations(webhookSchemas)
await createStaticWebhookFiles(webhookOperations)
const restOperations = await getRestOperations(restSchemas)
await createStaticRestFiles(restOperations)
}
async function getRestOperations(restSchemas) {
console.log('\n⏭ Start generating static REST files\n')
const restSchemaData = await getDereferencedFiles(restSchemas)
const restOperations = {}
for (const [schemaName, schema] of Object.entries(restSchemaData)) {
try {
// get all of the operations and wehbooks for a particular version of the openapi
const operations = await getOperations(schema)
// process each operation and webhook, asynchronously rendering markdown and stuff
if (operations.length) {
console.log(`...processing ${schemaName} rest operations`)
await Promise.all(operations.map((operation) => operation.process()))
restOperations[schemaName] = operations
}
} catch (error) {
throw new Error(
"🐛 Whoops! It looks like the decorator script wasn't able to parse the dereferenced schema. A recent change may not yet be supported by the decorator. Please reach out in the #docs-engineering slack channel for help."
)
}
}
return restOperations
}
async function getWebhookOperations(webhookSchemas) {
console.log('⏭️ Start generating static webhook files\n')
const webhookSchemaData = await getDereferencedFiles(webhookSchemas)
const webhookOperations = {}
for (const [schemaName, schema] of Object.entries(webhookSchemaData)) {
try {
const webhooks = await getWebhooks(schema)
if (webhooks.length) {
console.log(`...processing ${schemaName} webhook operations`)
await Promise.all(webhooks.map((webhook) => webhook.process()))
webhookOperations[schemaName] = webhooks
}
} catch (error) {
throw new Error(
"🐛 Whoops! It looks like the decorator script wasn't able to parse the dereferenced schema. A recent change may not yet be supported by the decorator. Please reach out in the #docs-engineering slack channel for help."
)
}
}
return webhookOperations
}
async function createStaticRestFiles(restOperations) {
const operationsEnabledForGitHubApps = {}
const clientSideRedirects = await getCategoryOverrideRedirects()
for (const schemaName in restOperations) {
const operations = restOperations[schemaName]
await addRestClientSideRedirects(operations, clientSideRedirects)
const categories = [...new Set(operations.map((operation) => operation.category))].sort()
// Orders the operations by their category and subcategories.
// All operations must have a category, but operations don't need
// a subcategory. When no subcategory is present, the subcategory
// property is an empty string ('').
/*
Example:
{
[category]: {
'': {
"description": "",
"operations": []
},
[subcategory sorted alphabetically]: {
"description": "",
"operations": []
}
}
}
*/
const operationsByCategory = {}
categories.forEach((category) => {
operationsByCategory[category] = {}
const categoryOperations = operations.filter((operation) => operation.category === category)
categoryOperations
.filter((operation) => !operation.subcategory)
.map((operation) => (operation.subcategory = operation.category))
const subcategories = [
...new Set(categoryOperations.map((operation) => operation.subcategory)),
].sort()
// the first item should be the item that has no subcategory
// e.g., when the subcategory = category
const firstItemIndex = subcategories.indexOf(category)
if (firstItemIndex > -1) {
const firstItem = subcategories.splice(firstItemIndex, 1)[0]
subcategories.unshift(firstItem)
}
subcategories.forEach((subcategory) => {
operationsByCategory[category][subcategory] = {}
const subcategoryOperations = categoryOperations.filter(
(operation) => operation.subcategory === subcategory
)
operationsByCategory[category][subcategory] = subcategoryOperations
})
})
const restFilename = path.join(REST_DECORATED_DIR, `${schemaName}.json`).replace('.deref', '')
// write processed operations to disk
await writeFile(restFilename, JSON.stringify(operationsByCategory, null, 2))
console.log('Wrote', path.relative(process.cwd(), restFilename))
// Create the enabled-for-apps.json file used for
// https://docs.github.com/en/rest/overview/endpoints-available-for-github-apps
operationsEnabledForGitHubApps[schemaName] = {}
for (const category of categories) {
const categoryOperations = operations.filter((operation) => operation.category === category)
// This is a collection of operations that have `enabledForGitHubApps = true`
// It's grouped by resource title to make rendering easier
operationsEnabledForGitHubApps[schemaName][category] = categoryOperations
.filter((operation) => operation.enabledForGitHubApps)
.map((operation) => ({
slug: slug(operation.title),
subcategory: operation.subcategory,
verb: operation.verb,
requestPath: operation.requestPath,
}))
}
}
await writeFile(ENABLED_APPS, JSON.stringify(operationsEnabledForGitHubApps, null, 2))
console.log('Wrote', ENABLED_APPS)
await writeFile(STATIC_REDIRECTS, JSON.stringify(clientSideRedirects, null, 2), 'utf8')
console.log('Wrote', STATIC_REDIRECTS)
}
async function getDereferencedFiles(schemas) {
const schemaData = {}
for (const filename of schemas) {
const file = path.join(REST_DEREFERENCED_DIR, `${filename}.deref.json`)
const schema = JSON.parse(await readFile(file))
schemaData[filename] = schema
}
return schemaData
}
async function createStaticWebhookFiles(webhookSchemas) {
if (!Object.keys(webhookSchemas).length) {
console.log(
'🟡 No webhooks exist in the dereferenced files. No static webhook files will be generated.\n'
)
return
}
// Create a map of webhooks (e.g. check_run, issues, release) to the
// webhook's actions (e.g. created, deleted, etc.).
//
// Some webhooks like the ping webhook have no action types -- in cases
// like this we set a default action of 'default'.
//
// Example:
/*
{
'branch-protection-rule': {
created: Webhook {
descriptionHtml: '<p>A branch protection rule was created.</p>',
summaryHtml: '<p>This event occurs when there is activity relating to branch protection rules. For more information, see "<a href="https://docs.github.com/repositories/configuring-branches-and-merges-in-your-repository/defining-the-mergeability-of-pull-requests/about-protected-branches">About protected branches</a>." For information about the Branch protection APIs, see <a href="https://docs.github.com/graphql/reference/objects#branchprotectionrule">the GraphQL documentation</a> and <a href="https://docs.github.com/rest/branches/branch-protection">the REST API documentation</a>.</p>\n' +
'<p>In order to install this event on a GitHub App, the app must have <code>read-only</code> access on repositories administration.</p>',
bodyParameters: [Array],
availability: [Array],
action: 'created',
category: 'branch-protection-rule'
},
deleted: Webhook {
descriptionHtml: '<p>A branch protection rule was deleted.</p>',
summaryHtml: '<p>This event occurs when there is activity relating to branch protection rules. For more information, see "<a href="https://docs.github.com/repositories/configuring-branches-and-merges-in-your-repository/defining-the-mergeability-of-pull-requests/about-protected-branches">About protected branches</a>." For information about the Branch protection APIs, see <a href="https://docs.github.com/graphql/reference/objects#branchprotectionrule">the GraphQL documentation</a> and <a href="https://docs.github.com/rest/branches/branch-protection">the REST API documentation</a>.</p>\n' +
'<p>In order to install this event on a GitHub App, the app must have <code>read-only</code> access on repositories administration.</p>',
bodyParameters: [Array],
availability: [Array],
action: 'deleted',
category: 'branch-protection-rule'
},
...
}
*/
const categorizedWebhooks = {}
for (const [schemaName, webhooks] of Object.entries(webhookSchemas)) {
webhooks.forEach((webhook) => {
if (!webhook.action) webhook.action = 'default'
if (categorizedWebhooks[webhook.category]) {
categorizedWebhooks[webhook.category][webhook.action] = webhook
} else {
categorizedWebhooks[webhook.category] = {}
categorizedWebhooks[webhook.category][webhook.action] = webhook
}
})
const webhooksFilename = path
.join(WEBHOOK_DECORATED_DIR, `${schemaName}.json`)
.replace('.deref', '')
if (Object.keys(categorizedWebhooks).length > 0) {
if (!existsSync(WEBHOOK_DECORATED_DIR)) {
mkdirSync(WEBHOOK_DECORATED_DIR)
}
await writeFile(webhooksFilename, JSON.stringify(categorizedWebhooks, null, 2))
console.log('Wrote', path.relative(process.cwd(), webhooksFilename))
}
}
}
async function getCategoryOverrideRedirects() {
const { operationUrls, sectionUrls } = JSON.parse(
await readFile('src/rest/scripts/utils/rest-api-overrides.json', 'utf8')
)
const operationRedirects = {}
console.log('\n➡ Updating REST API redirect exception list.\n')
Object.values(operationUrls).forEach((value) => {
const oldUrl = value.originalUrl.replace('/rest/reference', '/rest')
const anchor = oldUrl.split('#')[1]
const subcategory = value.subcategory
const redirectTo = subcategory
? `/rest/${value.category}/${subcategory}#${anchor}`
: `/rest/${value.category}#${anchor}`
operationRedirects[oldUrl] = redirectTo
})
const redirects = {
...operationRedirects,
...sectionUrls,
}
return redirects
}
async function addRestClientSideRedirects(operations, clientSideRedirects) {
// For each rest operation that doesn't have an override defined
// in src/rest/scripts/utils/rest-api-overrides.json,
// add a client-side redirect
operations.forEach((operation) => {
// A handful of operations don't have external docs properties
const externalDocs = operation.getExternalDocs()
if (!externalDocs) {
return
}
const oldUrl = `/rest${externalDocs.url.replace('/rest/reference', '/rest').split('/rest')[1]}`
if (!(oldUrl in clientSideRedirects)) {
// There are some operations that aren't nested in the sidebar
// For these, don't need to add a client-side redirect, the
// frontmatter redirect will handle it for us.
if (categoriesWithoutSubcategories.includes(operation.category)) {
return
}
const anchor = oldUrl.split('#')[1]
const subcategory = operation.subcategory
// If there is no subcategory, a new page with the same name as the
// category was created. That page name may change going forward.
const redirectTo = subcategory
? `/rest/${operation.category}/${subcategory}#${anchor}`
: `/rest/${operation.category}/${operation.category}#${anchor}`
clientSideRedirects[oldUrl] = redirectTo
}
// There are a lot of section headings that we'll want to redirect too,
// now that subcategories are on their own page. For example,
// /rest/reference/actions#artifacts should redirect to
// /rest/actions/artifacts
if (operation.subcategory) {
const sectionRedirectFrom = `/rest/${operation.category}#${operation.subcategory}`
const sectionRedirectTo = `/rest/${operation.category}/${operation.subcategory}`
if (!(sectionRedirectFrom in clientSideRedirects)) {
clientSideRedirects[sectionRedirectFrom] = sectionRedirectTo
}
}
})
}
export async function getOpenApiSchemaFiles(schemas) {
const webhookSchemas = []
const restSchemas = []
// All of the schema releases that we store in allVersions
// Ex: 'api.github.com', 'ghec', 'ghes-3.6', 'ghes-3.5',
// 'ghes-3.4', 'ghes-3.3', 'ghes-3.2', 'github.ae'
const openApiVersions = Object.keys(allVersions).map(
(elem) => allVersions[elem].openApiVersionName
)
// The full list of dereferened OpenAPI schemas received from
// bundling the OpenAPI in github/github
const schemaBaseNames = schemas.map((schema) => path.basename(schema, '.deref.json'))
for (const schema of schemaBaseNames) {
// catches all of the schemas that are not
// calendar date versioned. Ex: ghec, ghes-3.7, and api.github.com
if (openApiVersions.includes(schema)) {
webhookSchemas.push(schema)
// Non-calendar date schemas could also match the calendar date versioned
// counterpart.
// Ex: api.github.com would match api.github.com and
// api.github.com.2022-09-09
const filteredMatches = schemaBaseNames.filter((elem) => elem.includes(schema))
// If there is only one match then there are no calendar date counterparts
// and this is the only schema for this plan and release.
if (filteredMatches.length === 1) {
restSchemas.push(schema)
}
// catches all of the calendar date versioned schemas in the
// format api.github.com.<year>-<month>-<day>
} else {
restSchemas.push(schema)
}
}
return { restSchemas, webhookSchemas }
}

View File

@@ -0,0 +1,201 @@
#!/usr/bin/env node
import renderContent from '../../../../lib/render-content/index.js'
// If there is a oneOf at the top level, then we have to present just one
// in the docs. We don't currently have a convention for showing more than one
// set of input parameters in the docs. Having a top-level oneOf is also very
// uncommon.
// Currently there aren't very many operations that require this treatment.
// As an example, the 'Add status check contexts' and 'Set status check contexts'
// operations have a top-level oneOf.
async function getTopLevelOneOfProperty(schema) {
if (!schema.oneOf) {
throw new Error('Schema does not have a requestBody oneOf property defined')
}
if (!(Array.isArray(schema.oneOf) && schema.oneOf.length > 0)) {
throw new Error('Schema requestBody oneOf property is not an array')
}
// 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.
const firstOneOfObject = schema.oneOf[0]
const allOneOfAreObjects = schema.oneOf.every((elem) => elem.type === 'object')
let required = firstOneOfObject.required || []
let properties = firstOneOfObject.properties || {}
// 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.
if (allOneOfAreObjects) {
for (const each of schema.oneOf.slice(1)) {
Object.assign(firstOneOfObject.properties, each.properties)
required = firstOneOfObject.required.concat(each.required)
}
properties = firstOneOfObject.properties
}
return { properties, required }
}
// Gets the body parameters for a given schema recursively.
export async function getBodyParams(schema, topLevel = false) {
const bodyParametersParsed = []
const schemaObject = schema.oneOf && topLevel ? await getTopLevelOneOfProperty(schema) : schema
const properties = schemaObject.properties || {}
const required = schemaObject.required || []
// Most operation requestBody schemas are objects. When the type is an array,
// there will not be properties on the `schema` object.
if (topLevel && schema.type === 'array') {
const childParamsGroups = []
const arrayType = schema.items.type
const paramType = [schema.type]
if (arrayType === 'object') {
childParamsGroups.push(...(await getBodyParams(schema.items, false)))
} else {
paramType.splice(paramType.indexOf('array'), 1, `array of ${arrayType}s`)
}
const paramDecorated = await getTransformedParam(schema, paramType, {
required,
topLevel,
childParamsGroups,
})
return [paramDecorated]
}
for (const [paramKey, param] of Object.entries(properties)) {
// OpenAPI 3.0 only had a single value for `type`. OpenAPI 3.1
// will either be a single value or an array of values.
// This makes type an array regardless of how many values the array
// includes. This allows us to support 3.1 while remaining backwards
// compatible with 3.0.
const paramType = Array.isArray(param.type) ? param.type : [param.type]
const additionalPropertiesType = param.additionalProperties
? Array.isArray(param.additionalProperties.type)
? param.additionalProperties.type
: [param.additionalProperties.type]
: []
const childParamsGroups = []
// If the parameter is an array or object there may be child params
// If the parameter has oneOf or additionalProperties, they need to be
// recursively read too.
// There are a couple operations with additionalProperties, which allows
// the api to define input parameters with the type dictionary. These are the only
// two operations (at the time of adding this code) that use additionalProperties
// Create a snapshot of dependencies for a repository
// Update a gist
if (param.additionalProperties && additionalPropertiesType.includes('object')) {
const keyParam = {
type: 'object',
name: 'key',
description: await renderContent(
`A user-defined key to represent an item in \`${paramKey}\`.`
),
isRequired: param.required,
enum: param.enum,
default: param.default,
childParamsGroups: [],
}
keyParam.childParamsGroups.push(...(await getBodyParams(param.additionalProperties, false)))
childParamsGroups.push(keyParam)
} else if (paramType && paramType.includes('array')) {
const arrayType = param.items.type
if (arrayType) {
paramType.splice(paramType.indexOf('array'), 1, `array of ${arrayType}s`)
}
if (arrayType === 'object') {
childParamsGroups.push(...(await getBodyParams(param.items, false)))
}
} else if (paramType && paramType.includes('object')) {
childParamsGroups.push(...(await getBodyParams(param, false)))
} else if (param && param.oneOf) {
// get concatenated description and type
const descriptions = []
for (const childParam of param.oneOf) {
paramType.push(childParam.type)
// If there is no parent description, create a description from
// each type
if (!param.description) {
if (childParam.type === 'array') {
if (childParam.items.description) {
descriptions.push({
type: childParam.type,
description: childParam.items.description,
})
}
} else {
if (childParam.description) {
descriptions.push({ type: childParam.type, description: childParam.description })
}
}
}
}
// Occasionally, there is no parent description and the description
// is in the first child parameter.
const oneOfDescriptions = descriptions.length ? descriptions[0].description : ''
if (!param.description) param.description = oneOfDescriptions
// This is a workaround for an operation that incorrectly defines allOf for a
// body parameter. As a workaround, we will use the first object in the list of
// the allOf array. Otherwise, fallback to the first item in the array.
// This isn't ideal, and in the case of an actual allOf occurrence, we should
// handle it differently by merging all of the properties. There is currently
// only one occurrence for the operation id repos/update-information-about-pages-site
// See Ecosystem API issue number #3332 for future plans to fix this in the OpenAPI
} else if (param && param.anyOf && Object.keys(param).length === 1) {
const firstObject = Object.values(param.anyOf).find((item) => item.type === 'object')
if (firstObject) {
paramType.push('object')
param.description = firstObject.description
param.isRequired = firstObject.required
childParamsGroups.push(...(await getBodyParams(firstObject, false)))
} else {
paramType.push(param.anyOf[0].type)
param.description = param.anyOf[0].description
param.isRequired = param.anyOf[0].required
}
}
const paramDecorated = await getTransformedParam(param, paramType, {
paramKey,
required,
childParamsGroups,
topLevel,
})
bodyParametersParsed.push(paramDecorated)
}
return bodyParametersParsed
}
async function getTransformedParam(param, paramType, props) {
const { paramKey, required, childParamsGroups, topLevel } = props
const paramDecorated = {}
// Supports backwards compatibility for OpenAPI 3.0
// In 3.1 a nullable type is part of the param.type array and
// the property param.nullable does not exist.
if (param.nullable) paramType.push('null')
paramDecorated.type = paramType.filter(Boolean).join(' or ')
paramDecorated.name = paramKey
if (topLevel) {
paramDecorated.in = 'body'
}
paramDecorated.description = await renderContent(param.description)
if (required && required.includes(paramKey)) {
paramDecorated.isRequired = true
}
if (childParamsGroups && childParamsGroups.length > 0) {
paramDecorated.childParamsGroups = childParamsGroups
}
if (param.enum) {
paramDecorated.enum = param.enum
}
// we also want to catch default values of `false` for booleans
if (param.default !== undefined) {
paramDecorated.default = param.default
}
return paramDecorated
}

View File

@@ -0,0 +1,65 @@
import { readFile, readdir } from 'fs/promises'
import yaml from 'js-yaml'
import path from 'path'
import { allVersions } from '../../../../lib/all-versions.js'
const OPEN_API_RELEASES_DIR = path.join('..', 'github', '/app/api/description/config/releases')
// Gets the full list of unpublished + active, deprecated + active,
// or active schemas from the github/github repo
// `openApiReleaseDir` is the path to the `app/api/description/config/releases`
// directory in `github/github`
// You can also specify getting specific versions of schemas.
export async function getSchemas(directory = OPEN_API_RELEASES_DIR) {
const openAPIConfigs = await readdir(directory)
const unpublished = []
const deprecated = []
const currentReleases = []
// The file content in the `github/github` repo is YAML before it is
// bundled into JSON.
for (const file of openAPIConfigs) {
const fileBaseName = path.basename(file, '.yaml')
const newFileName = `${fileBaseName}.deref.json`
const content = await readFile(path.join(directory, file), 'utf8')
const yamlContent = yaml.load(content)
const isDeprecatedInDocs = !Object.keys(allVersions).find(
(version) => allVersions[version].openApiVersionName === fileBaseName
)
if (!yamlContent.published) {
unpublished.push(newFileName)
}
// If it's deprecated, it must have been published at some point in the past
// This checks if the schema is deprecated in github/github and
// github/docs-internal. Sometimes deprecating in github/github lags
// behind deprecating in github/docs-internal a few days
if (
(yamlContent.deprecated && yamlContent.published) ||
(isDeprecatedInDocs && yamlContent.published)
) {
deprecated.push(newFileName)
}
if (!yamlContent.deprecated && !isDeprecatedInDocs && yamlContent.published) {
currentReleases.push(newFileName)
}
}
return { currentReleases, unpublished, deprecated }
}
export async function validateVersionsOptions(versions) {
const schemas = await getSchemas()
// Validate individual versions provided
versions.forEach((version) => {
if (
schemas.deprecated.includes(`${version}.deref.json`) ||
schemas.unpublished.includes(`${version}.deref.json`)
) {
const errorMsg = `🛑 This script doesn't support generating individual deprecated or unpublished schemas. Please reach out to #docs-engineering if this is a use case that you need.`
throw new Error(errorMsg)
} else if (!schemas.currentReleases.includes(`${version}.deref.json`)) {
throw new Error(`🛑 The version (${version}) you specified is not valid.`)
}
})
}

View File

@@ -0,0 +1,33 @@
#!/usr/bin/env node
import Operation from './operation.js'
import Webhook from './webhook.js'
// The module accepts a JSON schema object as input
// and returns an array of its operation objects with their
// HTTP verb and requestPath attached as properties
export default async function getOperations(schema) {
const operations = []
for (const [requestPath, operationsAtPath] of Object.entries(schema.paths)) {
for (const [verb, operationProps] of Object.entries(operationsAtPath)) {
const operation = new Operation(verb, requestPath, operationProps, schema.servers)
operations.push(operation)
}
}
return operations
}
export async function getWebhooks(schema) {
// In OpenAPI version 3.1, the schema data is under the `webhooks` key, but
// in 3.0 the schema data was in `x-webhooks`. We just fallback to
// `x-webhooks` for now since there's currently otherwise no difference with
// the schema data so we can handle either version.
const webhooks = schema.webhooks ?? schema['x-webhooks']
if (webhooks) {
return Object.values(webhooks).map((webhook) => new Webhook(webhook.post))
}
return []
}

View File

@@ -0,0 +1,68 @@
#!/usr/bin/env node
// This schema is used to validate each generated operation object at build time
export default {
type: 'object',
required: [
'title',
'verb',
'requestPath',
'category',
'parameters',
'statusCodes',
'codeExamples',
],
properties: {
// Properties from the source OpenAPI schema that this module depends on
title: {
description: 'The title of the operation',
type: 'string',
},
verb: {
description: 'The HTTP method',
type: 'string',
enum: ['get', 'put', 'post', 'delete', 'patch', 'head'],
},
requestPath: {
description: 'The URL path',
type: 'string',
minLength: 1,
},
descriptionHTML: {
description: 'The rendered HTML version of the markdown `description` property',
type: 'string',
},
category: {
description: 'the `issues` in `/v3/issues/events/`; supports legacy developer site URLs',
type: 'string',
},
subcategory: {
description: 'the `events` in `/v3/issues/events/`; supports legacy developer site URLs',
type: 'string',
},
parameters: {
description: 'Parameters to the operation that can be present in the URL path or query',
type: 'array',
},
codeSamples: {
description: 'Code samples for the operation',
type: 'array',
},
statusCodes: {
description: 'The possible HTTP status codes for the operation',
type: 'array',
},
previews: {
description: 'The information about the preview headers',
type: 'array',
},
enabledForGitHubApps: {
description: 'Whether the operation is enabled for server-to-server GitHub Apps',
type: 'boolean',
},
bodyParameters: {
description: 'The request body parameters for the operation',
type: 'array',
},
},
}

View File

@@ -0,0 +1,187 @@
#!/usr/bin/env node
import Ajv from 'ajv'
import httpStatusCodes from 'http-status-code'
import { readFile } from 'fs/promises'
import { get, isPlainObject } from 'lodash-es'
import { parseTemplate } from 'url-template'
import renderContent from '../../../../lib/render-content/index.js'
import getCodeSamples from './create-rest-examples.js'
import operationSchema from './operation-schema.js'
import { getBodyParams } from './get-body-params.js'
const { operationUrls } = JSON.parse(
await readFile('src/rest/scripts/utils/rest-api-overrides.json', 'utf8')
)
export default class Operation {
#operation
constructor(verb, requestPath, operation, globalServers) {
this.#operation = operation
// The global server object sets metadata including the base url for
// all operations in a version. Individual operations can override
// the global server url at the operation level.
this.serverUrl = operation.servers ? operation.servers[0].url : globalServers[0].url
const serverVariables = operation.servers
? operation.servers[0].variables
: globalServers[0].variables
if (serverVariables) {
const templateVariables = {}
Object.keys(serverVariables).forEach(
(key) => (templateVariables[key] = serverVariables[key].default)
)
this.serverUrl = parseTemplate(this.serverUrl).expand(templateVariables)
}
this.serverUrl = this.serverUrl.replace('http:', 'http(s):')
// Attach some global properties to the operation object to use
// during processing
this.#operation.serverUrl = this.serverUrl
this.#operation.requestPath = requestPath
this.#operation.verb = verb
this.verb = verb
this.requestPath = requestPath
this.title = operation.summary
this.setCategories()
this.parameters = operation.parameters || []
this.bodyParameters = []
this.enabledForGitHubApps = operation['x-github'].enabledForGitHubApps
this.codeExamples = getCodeSamples(this.#operation)
return this
}
setCategories() {
const operationId = this.#operation.operationId
const xGithub = this.#operation['x-github']
// Set category
// 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 api code every time we move
// an endpoint to a new page.
this.category = operationUrls[operationId]
? operationUrls[operationId].category
: xGithub.category
// Set 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 api code every time we move
// an endpoint to a new page.
if (operationUrls[operationId]) {
if (operationUrls[operationId].subcategory) {
this.subcategory = operationUrls[operationId].subcategory
}
} else if (xGithub.subcategory) {
this.subcategory = xGithub.subcategory
}
}
async process() {
await Promise.all([
this.renderDescription(),
this.renderStatusCodes(),
this.renderParameterDescriptions(),
this.renderBodyParameterDescriptions(),
this.renderExampleResponseDescriptions(),
this.renderPreviewNotes(),
])
const ajv = new Ajv()
const valid = ajv.validate(operationSchema, this)
if (!valid) {
console.error(JSON.stringify(ajv.errors, null, 2))
throw new Error('Invalid OpenAPI operation found')
}
}
getExternalDocs() {
return this.#operation.externalDocs
}
async renderDescription() {
this.descriptionHTML = await renderContent(this.#operation.description)
return this
}
async renderExampleResponseDescriptions() {
return Promise.all(
this.codeExamples.map(async (codeExample) => {
codeExample.response.description = await renderContent(codeExample.response.description)
return codeExample
})
)
}
async renderStatusCodes() {
const responses = this.#operation.responses
const responseKeys = Object.keys(responses)
if (responseKeys.length === 0) return []
this.statusCodes = await Promise.all(
responseKeys.map(async (responseCode) => {
const response = responses[responseCode]
const httpStatusCode = responseCode
const httpStatusMessage = httpStatusCodes.getMessage(Number(responseCode), 'HTTP/2')
// The OpenAPI should be updated to provide better descriptions, but
// until then, we can catch some known generic descriptions and replace
// them with the default http status message.
const responseDescription =
response.description.toLowerCase() === 'response'
? await renderContent(httpStatusMessage)
: await renderContent(response.description)
return {
httpStatusCode,
description: responseDescription,
}
})
)
}
async renderParameterDescriptions() {
return Promise.all(
this.parameters.map(async (param) => {
param.description = await renderContent(param.description)
return param
})
)
}
async renderBodyParameterDescriptions() {
if (!this.#operation.requestBody) return []
// There is currently only one operation with more than one content type
// and the request body parameter types are the same for both.
// Operation Id: markdown/render-raw
const contentType = Object.keys(this.#operation.requestBody.content)[0]
const schema = get(this.#operation, `requestBody.content.${contentType}.schema`, {})
// TODO: Remove this check
if (this.#operation.operationId === 'checks/create') {
delete schema.oneOf
}
this.bodyParameters = isPlainObject(schema) ? await getBodyParams(schema, true) : []
}
async renderPreviewNotes() {
const previews = get(this.#operation, 'x-github.previews', [])
this.previews = await 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```')
return await renderContent(note)
})
)
}
}

View File

@@ -0,0 +1,794 @@
{
"operationUrls": {
"repos/list-collaborators": {
"category": "collaborators",
"subcategory": "collaborators",
"originalUrl": "/rest/reference/repos#list-repository-collaborators"
},
"repos/check-collaborator": {
"category": "collaborators",
"subcategory": "collaborators",
"originalUrl": "/rest/reference/repos#check-if-a-user-is-a-repository-collaborator"
},
"repos/add-collaborator": {
"category": "collaborators",
"subcategory": "collaborators",
"originalUrl": "/rest/reference/repos#add-a-repository-collaborator"
},
"repos/remove-collaborator": {
"category": "collaborators",
"subcategory": "collaborators",
"originalUrl": "/rest/reference/repos#remove-a-repository-collaborator"
},
"repos/get-collaborator-permission-level": {
"category": "collaborators",
"subcategory": "collaborators",
"originalUrl": "/rest/reference/repos#get-repository-permissions-for-a-user"
},
"repos/list-commit-comments-for-repo": {
"category": "commits",
"subcategory": "comments",
"originalUrl": "/rest/reference/repos#list-commit-comments-for-a-repository"
},
"repos/get-commit-comment": {
"category": "commits",
"subcategory": "comments",
"originalUrl": "/rest/reference/repos#get-a-commit-comment"
},
"repos/update-commit-comment": {
"category": "commits",
"subcategory": "comments",
"originalUrl": "/rest/reference/repos#update-a-commit-comment"
},
"repos/delete-commit-comment": {
"category": "commits",
"subcategory": "comments",
"originalUrl": "/rest/reference/repos#delete-a-commit-comment"
},
"repos/list-commits": {
"category": "commits",
"subcategory": "commits",
"originalUrl": "/rest/reference/repos#list-commits"
},
"repos/list-branches-for-head-commit": {
"category": "commits",
"subcategory": "commits",
"originalUrl": "/rest/reference/repos#list-branches-for-head-commit"
},
"repos/list-comments-for-commit": {
"category": "commits",
"subcategory": "comments",
"originalUrl": "/rest/reference/repos#list-commit-comments"
},
"repos/create-commit-comment": {
"category": "commits",
"subcategory": "comments",
"originalUrl": "/rest/reference/repos#create-a-commit-comment"
},
"repos/list-pull-requests-associated-with-commit": {
"category": "commits",
"subcategory": "commits",
"originalUrl": "/rest/reference/repos#list-pull-requests-associated-with-a-commit"
},
"repos/get-commit": {
"category": "commits",
"subcategory": "commits",
"originalUrl": "/rest/reference/repos#get-a-commit"
},
"repos/get-combined-status-for-ref": {
"category": "commits",
"subcategory": "statuses",
"originalUrl": "/rest/reference/repos#get-the-combined-status-for-a-specific-reference"
},
"repos/list-commit-statuses-for-ref": {
"category": "commits",
"subcategory": "statuses",
"originalUrl": "/rest/reference/repos#list-commit-statuses-for-a-reference"
},
"repos/get-community-profile-metrics": {
"category": "metrics",
"subcategory": "community",
"originalUrl": "/rest/reference/repos#get-community-profile-metrics"
},
"repos/compare-commits": {
"category": "commits",
"subcategory": "commits",
"originalUrl": "/rest/reference/repos#compare-two-commits"
},
"repos/get-all-environments": {
"category": "deployments",
"subcategory": "environments",
"originalUrl": "/rest/reference/repos#get-all-environments"
},
"repos/get-environment": {
"category": "deployments",
"subcategory": "environments",
"originalUrl": "/rest/reference/repos#get-an-environment"
},
"repos/create-or-update-environment": {
"category": "deployments",
"subcategory": "environments",
"originalUrl": "/rest/reference/repos#create-or-update-an-environment"
},
"repos/delete-an-environment": {
"category": "deployments",
"subcategory": "environments",
"originalUrl": "/rest/reference/repos#delete-an-environment"
},
"repos/list-invitations": {
"category": "collaborators",
"subcategory": "invitations",
"originalUrl": "/rest/reference/repos#list-repository-invitations"
},
"repos/update-invitation": {
"category": "collaborators",
"subcategory": "invitations",
"originalUrl": "/rest/reference/repos#update-a-repository-invitation"
},
"repos/delete-invitation": {
"category": "collaborators",
"subcategory": "invitations",
"originalUrl": "/rest/reference/repos#delete-a-repository-invitation"
},
"repos/list-deploy-keys": {
"category": "deploy-keys",
"subcategory": null,
"originalUrl": "/rest/reference/repos#list-deploy-keys"
},
"repos/create-deploy-key": {
"category": "deploy-keys",
"subcategory": null,
"originalUrl": "/rest/reference/repos#create-a-deploy-key"
},
"repos/get-deploy-key": {
"category": "deploy-keys",
"subcategory": null,
"originalUrl": "/rest/reference/repos#get-a-deploy-key"
},
"repos/delete-deploy-key": {
"category": "deploy-keys",
"subcategory": null,
"originalUrl": "/rest/reference/repos#delete-a-deploy-key"
},
"repos/get-pages": {
"category": "pages",
"subcategory": null,
"originalUrl": "/rest/reference/repos#get-a-github-pages-site"
},
"repos/create-pages-site": {
"category": "pages",
"subcategory": null,
"originalUrl": "/rest/reference/repos#create-a-github-pages-site"
},
"repos/update-information-about-pages-site": {
"category": "pages",
"subcategory": null,
"originalUrl": "/rest/reference/repos#update-information-about-a-github-pages-site"
},
"repos/delete-pages-site": {
"category": "pages",
"subcategory": null,
"originalUrl": "/rest/reference/repos#delete-a-github-pages-site"
},
"repos/list-pages-builds": {
"category": "pages",
"subcategory": null,
"originalUrl": "/rest/reference/repos#list-github-pages-builds"
},
"repos/request-pages-build": {
"category": "pages",
"subcategory": null,
"originalUrl": "/rest/reference/repos#request-a-github-pages-build"
},
"repos/get-latest-pages-build": {
"category": "pages",
"subcategory": null,
"originalUrl": "/rest/reference/repos#get-latest-pages-build"
},
"repos/get-pages-build": {
"category": "pages",
"subcategory": null,
"originalUrl": "/rest/reference/repos#get-github-pages-build"
},
"repos/get-pages-health-check": {
"category": "pages",
"subcategory": null,
"originalUrl": "/rest/reference/repos#get-a-dns-health-check-for-github-pages"
},
"repos/create-pages-deployment": {
"category": "pages",
"subcategory": null,
"originalUrl": "/rest/reference/repos#create-a-github-pages-deployment"
},
"repos/get-code-frequency-stats": {
"category": "metrics",
"subcategory": "statistics",
"originalUrl": "/rest/reference/repos#get-the-weekly-commit-activity"
},
"repos/get-commit-activity-stats": {
"category": "metrics",
"subcategory": "statistics",
"originalUrl": "/rest/reference/repos#get-the-last-year-of-commit-activity"
},
"repos/get-contributors-stats": {
"category": "metrics",
"subcategory": "statistics",
"originalUrl": "/rest/reference/repos#get-all-contributor-commit-activity"
},
"repos/get-participation-stats": {
"category": "metrics",
"subcategory": "statistics",
"originalUrl": "/rest/reference/repos#get-the-weekly-commit-count"
},
"repos/get-punch-card-stats": {
"category": "metrics",
"subcategory": "statistics",
"originalUrl": "/rest/reference/repos#get-the-hourly-commit-count-for-each-day"
},
"repos/create-commit-status": {
"category": "commits",
"subcategory": "statuses",
"originalUrl": "/rest/reference/repos#create-a-commit-status"
},
"repos/get-clones": {
"category": "metrics",
"subcategory": "traffic",
"originalUrl": "/rest/reference/repos#get-repository-clones"
},
"repos/get-top-paths": {
"category": "metrics",
"subcategory": "traffic",
"originalUrl": "/rest/reference/repos#get-top-referral-paths"
},
"repos/get-top-referrers": {
"category": "metrics",
"subcategory": "traffic",
"originalUrl": "/rest/reference/repos#get-top-referral-sources"
},
"repos/get-views": {
"category": "metrics",
"subcategory": "traffic",
"originalUrl": "/rest/reference/repos#get-page-views"
},
"repos/list-invitations-for-authenticated-user": {
"category": "collaborators",
"subcategory": "invitations",
"originalUrl": "/rest/reference/repos#list-repository-invitations-for-the-authenticated-user"
},
"repos/accept-invitation-for-authenticated-user": {
"category": "collaborators",
"subcategory": "invitations",
"originalUrl": "/rest/reference/repos#accept-a-repository-invitation"
},
"repos/decline-invitation-for-authenticated-user": {
"category": "collaborators",
"subcategory": "invitations",
"originalUrl": "/rest/reference/repos#decline-a-repository-invitation"
},
"repos/list-branches": {
"category": "branches",
"subcategory": "branches",
"originalUrl": "/rest/reference/repos#list-branches"
},
"repos/get-branch": {
"category": "branches",
"subcategory": "branches",
"originalUrl": "/rest/reference/repos#get-a-branch"
},
"repos/rename-branch": {
"category": "branches",
"subcategory": "branches",
"originalUrl": "/rest/reference/repos#rename-a-branch"
},
"repos/merge-upstream": {
"category": "branches",
"subcategory": "branches",
"originalUrl": "/rest/reference/repos#sync-a-fork-branch-with-the-upstream-repository"
},
"repos/merge": {
"category": "branches",
"subcategory": "branches",
"originalUrl": "/rest/reference/repos#merge-a-branch"
},
"repos/add-app-access-restrictions": {
"category": "branches",
"subcategory": "branch-protection",
"originalUrl": "/rest/reference/repos#add-app-access-restrictions"
},
"repos/add-status-check-contexts": {
"category": "branches",
"subcategory": "branch-protection",
"originalUrl": "/rest/reference/repos#add-status-check-contexts"
},
"repos/add-team-access-restrictions": {
"category": "branches",
"subcategory": "branch-protection",
"originalUrl": "/rest/reference/repos#add-team-access-restrictions"
},
"repos/add-user-access-restrictions": {
"category": "branches",
"subcategory": "branch-protection",
"originalUrl": "/rest/reference/repos#add-user-access-restrictions"
},
"repos/create-commit-signature-protection": {
"category": "branches",
"subcategory": "branch-protection",
"originalUrl": "/rest/reference/repos#create-commit-signature-protection"
},
"repos/delete-access-restrictions": {
"category": "branches",
"subcategory": "branch-protection",
"originalUrl": "/rest/reference/repos#delete-access-restrictions"
},
"repos/delete-admin-branch-protection": {
"category": "branches",
"subcategory": "branch-protection",
"originalUrl": "/rest/reference/repos#delete-admin-branch-protection"
},
"repos/delete-branch-protection": {
"category": "branches",
"subcategory": "branch-protection",
"originalUrl": "/rest/reference/repos#delete-branch-protection"
},
"repos/delete-commit-signature-protection": {
"category": "branches",
"subcategory": "branch-protection",
"originalUrl": "/rest/reference/repos#delete-commit-signature-protection"
},
"repos/delete-pull-request-review-protection": {
"category": "branches",
"subcategory": "branch-protection",
"originalUrl": "/rest/reference/repos#delete-pull-request-review-protection"
},
"repos/get-access-restrictions": {
"category": "branches",
"subcategory": "branch-protection",
"originalUrl": "/rest/reference/repos#get-access-restrictions"
},
"repos/get-admin-branch-protection": {
"category": "branches",
"subcategory": "branch-protection",
"originalUrl": "/rest/reference/repos#get-admin-branch-protection"
},
"repos/get-all-status-check-contexts": {
"category": "branches",
"subcategory": "branch-protection",
"originalUrl": "/rest/reference/repos#get-all-status-check-contexts"
},
"repos/get-apps-with-access-to-protected-branch": {
"category": "branches",
"subcategory": "branch-protection",
"originalUrl": "/rest/reference/repos#list-apps-with-access-to-the-protected-branch"
},
"repos/update-pull-request-review-protection": {
"category": "branches",
"subcategory": "branch-protection",
"originalUrl": "/rest/reference/repos#update-pull-request-review-protection"
},
"repos/get-branch-protection": {
"category": "branches",
"subcategory": "branch-protection",
"originalUrl": "/rest/reference/repos#get-branch-protection"
},
"repos/get-commit-signature-protection": {
"category": "branches",
"subcategory": "branch-protection",
"originalUrl": "/rest/reference/repos#get-commit-signature-protection"
},
"repos/get-pull-request-review-protection": {
"category": "branches",
"subcategory": "branch-protection",
"originalUrl": "/rest/reference/repos#get-pull-request-review-protection"
},
"repos/get-status-checks-protection": {
"category": "branches",
"subcategory": "branch-protection",
"originalUrl": "/rest/reference/repos#get-status-checks-protection"
},
"repos/get-teams-with-access-to-protected-branch": {
"category": "branches",
"subcategory": "branch-protection",
"originalUrl": "/rest/reference/repos#list-teams-with-access-to-the-protected-branch"
},
"repos/get-users-with-access-to-protected-branch": {
"category": "branches",
"subcategory": "branch-protection",
"originalUrl": "/rest/reference/repos#list-users-with-access-to-the-protected-branch"
},
"repos/remove-app-access-restrictions": {
"category": "branches",
"subcategory": "branch-protection",
"originalUrl": "/rest/reference/repos#remove-app-access-restrictions"
},
"repos/remove-status-check-contexts": {
"category": "branches",
"subcategory": "branch-protection",
"originalUrl": "/rest/reference/repos#remove-status-check-contexts"
},
"repos/remove-status-check-protection": {
"category": "branches",
"subcategory": "branch-protection",
"originalUrl": "/rest/reference/repos#remove-status-check-protection"
},
"repos/remove-team-access-restrictions": {
"category": "branches",
"subcategory": "branch-protection",
"originalUrl": "/rest/reference/repos#remove-team-access-restrictions"
},
"repos/remove-user-access-restrictions": {
"category": "branches",
"subcategory": "branch-protection",
"originalUrl": "/rest/reference/repos#remove-user-access-restrictions"
},
"repos/set-admin-branch-protection": {
"category": "branches",
"subcategory": "branch-protection",
"originalUrl": "/rest/reference/repos#set-admin-branch-protection"
},
"repos/set-app-access-restrictions": {
"category": "branches",
"subcategory": "branch-protection",
"originalUrl": "/rest/reference/repos#set-app-access-restrictions"
},
"repos/set-status-check-contexts": {
"category": "branches",
"subcategory": "branch-protection",
"originalUrl": "/rest/reference/repos#set-status-check-contexts"
},
"repos/set-team-access-restrictions": {
"category": "branches",
"subcategory": "branch-protection",
"originalUrl": "/rest/reference/repos#set-team-access-restrictions"
},
"repos/set-user-access-restrictions": {
"category": "branches",
"subcategory": "branch-protection",
"originalUrl": "/rest/reference/repos#set-user-access-restrictions"
},
"repos/update-branch-protection": {
"category": "branches",
"subcategory": "branch-protection",
"originalUrl": "/rest/reference/repos#update-branch-protection"
},
"repos/update-status-check-protection": {
"category": "branches",
"subcategory": "branch-protection",
"originalUrl": "/rest/reference/repos#update-status-check-protection"
},
"repos/create-deployment-status": {
"category": "deployments",
"subcategory": "statuses",
"originalUrl": "/rest/reference/repos#create-a-deployment-status"
},
"repos/list-deployment-statuses": {
"category": "deployments",
"subcategory": "statuses",
"originalUrl": "/rest/reference/repos#list-deployment-statuses"
},
"repos/get-deployment-status": {
"category": "deployments",
"subcategory": "statuses",
"originalUrl": "/rest/reference/repos#get-a-deployment-status"
},
"repos/list-deployments": {
"category": "deployments",
"subcategory": "deployments",
"originalUrl": "/rest/reference/repos#list-deployments"
},
"repos/create-deployment": {
"category": "deployments",
"subcategory": "deployments",
"originalUrl": "/rest/reference/repos#create-a-deployment"
},
"repos/get-deployment": {
"category": "deployments",
"subcategory": "deployments",
"originalUrl": "/rest/reference/repos#get-a-deployment"
},
"repos/delete-deployment": {
"category": "deployments",
"subcategory": "deployments",
"originalUrl": "/rest/reference/repos#delete-a-deployment"
},
"repos/list-releases": {
"category": "releases",
"subcategory": "releases",
"originalUrl": "/rest/reference/repos#list-releases"
},
"repos/create-release": {
"category": "releases",
"subcategory": "releases",
"originalUrl": "/rest/reference/repos#create-a-release"
},
"repos/generate-release-notes": {
"category": "releases",
"subcategory": "releases",
"originalUrl": "/rest/reference/repos#generate-release-notes"
},
"repos/get-latest-release": {
"category": "releases",
"subcategory": "releases",
"originalUrl": "/rest/reference/repos#get-the-latest-release"
},
"repos/get-release-by-tag": {
"category": "releases",
"subcategory": "releases",
"originalUrl": "/rest/reference/repos#get-a-release-by-tag-name"
},
"repos/get-release": {
"category": "releases",
"subcategory": "releases",
"originalUrl": "/rest/reference/repos#get-a-release"
},
"repos/update-release": {
"category": "releases",
"subcategory": "releases",
"originalUrl": "/rest/reference/repos#update-a-release"
},
"repos/delete-release": {
"category": "releases",
"subcategory": "releases",
"originalUrl": "/rest/reference/repos#delete-a-release"
},
"repos/delete-release-asset": {
"category": "releases",
"subcategory": "assets",
"originalUrl": "/rest/reference/repos#delete-a-release-asset"
},
"repos/get-release-asset": {
"category": "releases",
"subcategory": "assets",
"originalUrl": "/rest/reference/repos#get-a-release-asset"
},
"repos/list-release-assets": {
"category": "releases",
"subcategory": "assets",
"originalUrl": "/rest/reference/repos#list-release-assets"
},
"repos/update-release-asset": {
"category": "releases",
"subcategory": "assets",
"originalUrl": "/rest/reference/repos#update-a-release-asset"
},
"repos/upload-release-asset": {
"category": "releases",
"subcategory": "assets",
"originalUrl": "/rest/reference/repos#upload-a-release-asset"
},
"repos/list-webhooks": {
"category": "webhooks",
"subcategory": "repos",
"originalUrl": "/rest/reference/repos#list-repository-webhooks"
},
"repos/create-webhook": {
"category": "webhooks",
"subcategory": "repos",
"originalUrl": "/rest/reference/repos#create-a-repository-webhook"
},
"repos/get-webhook": {
"category": "webhooks",
"subcategory": "repos",
"originalUrl": "/rest/reference/repos#get-a-repository-webhook"
},
"repos/update-webhook": {
"category": "webhooks",
"subcategory": "repos",
"originalUrl": "/rest/reference/repos#update-a-repository-webhook"
},
"repos/delete-webhook": {
"category": "webhooks",
"subcategory": "repos",
"originalUrl": "/rest/reference/repos#delete-a-repository-webhook"
},
"repos/ping-webhook": {
"category": "webhooks",
"subcategory": "repos",
"originalUrl": "/rest/reference/repos#ping-a-repository-webhook"
},
"repos/test-push-webhook": {
"category": "webhooks",
"subcategory": "repos",
"originalUrl": "/rest/reference/repos#test-the-push-repository-webhook"
},
"repos/get-webhook-config-for-repo": {
"category": "webhooks",
"subcategory": "repo-config",
"originalUrl": "/rest/reference/repos#get-a-webhook-configuration-for-a-repository"
},
"repos/update-webhook-config-for-repo": {
"category": "webhooks",
"subcategory": "repo-config",
"originalUrl": "/rest/reference/repos#update-a-webhook-configuration-for-a-repository"
},
"repos/get-webhook-delivery": {
"category": "webhooks",
"subcategory": "repo-deliveries",
"originalUrl": "/rest/reference/repos#get-a-delivery-for-a-repository-webhook"
},
"repos/list-webhook-deliveries": {
"category": "webhooks",
"subcategory": "repo-deliveries",
"originalUrl": "/rest/reference/repos#list-deliveries-for-a-repository-webhook"
},
"repos/redeliver-webhook-delivery": {
"category": "webhooks",
"subcategory": "repo-deliveries",
"originalUrl": "/rest/reference/repos#redeliver-a-delivery-for-a-repository-webhook"
},
"enterprise-admin/add-custom-labels-to-self-hosted-runner-for-enterprise": {
"category": "actions",
"subcategory": "self-hosted-runners",
"originalUrl": "/rest/reference/enterprise-admin#add-custom-labels-to-a-self-hosted-runner-for-an-enterprise"
},
"enterprise-admin/add-org-access-to-self-hosted-runner-group-in-enterprise": {
"category": "actions",
"subcategory": "self-hosted-runner-groups",
"originalUrl": "/rest/reference/enterprise-admin#add-organization-access-to-a-self-hosted-runner-group-in-an-enterprise"
},
"enterprise-admin/add-self-hosted-runner-to-group-for-enterprise": {
"category": "actions",
"subcategory": "self-hosted-runner-groups",
"originalUrl": "/rest/reference/enterprise-admin#add-custom-labels-to-a-self-hosted-runner-for-an-enterprise"
},
"enterprise-admin/create-registration-token-for-enterprise": {
"category": "actions",
"subcategory": "self-hosted-runners",
"originalUrl": "/rest/reference/enterprise-admin#create-a-registration-token-for-an-enterprise"
},
"enterprise-admin/create-remove-token-for-enterprise": {
"category": "actions",
"subcategory": "self-hosted-runners",
"originalUrl": "/rest/reference/enterprise-admin#create-a-remove-token-for-an-enterprise"
},
"enterprise-admin/create-self-hosted-runner-group-for-enterprise": {
"category": "actions",
"subcategory": "self-hosted-runner-groups",
"originalUrl": "/rest/reference/enterprise-admin#create-self-hosted-runner-group-for-an-enterprise"
},
"enterprise-admin/delete-self-hosted-runner-from-enterprise": {
"category": "actions",
"subcategory": "self-hosted-runners",
"originalUrl": "/rest/reference/enterprise-admin#delete-self-hosted-runner-from-an-enterprise"
},
"enterprise-admin/delete-self-hosted-runner-group-from-enterprise": {
"category": "actions",
"subcategory": "self-hosted-runner-groups",
"originalUrl": "/rest/reference/enterprise-admin#delete-a-self-hosted-runner-group-from-an-enterprise"
},
"enterprise-admin/disable-selected-organization-github-actions-enterprise": {
"category": "actions",
"subcategory": "permissions",
"originalUrl": "/rest/reference/enterprise-admin#disable-a-selected-organization-for-github-actions-in-an-enterprise"
},
"enterprise-admin/enable-selected-organization-github-actions-enterprise": {
"category": "actions",
"subcategory": "permissions",
"originalUrl": "/rest/reference/enterprise-admin#enable-a-selected-organization-for-github-actions-in-an-enterprise"
},
"enterprise-admin/get-allowed-actions-enterprise": {
"category": "actions",
"subcategory": "permissions",
"originalUrl": "/rest/reference/enterprise-admin#get-allowed-actions-for-an-enterprise"
},
"enterprise-admin/get-github-actions-permissions-enterprise": {
"category": "actions",
"subcategory": "permissions",
"originalUrl": "/rest/reference/enterprise-admin#get-github-actions-permissions-for-an-enterprise"
},
"enterprise-admin/get-self-hosted-runner-for-enterprise": {
"category": "actions",
"subcategory": "self-hosted-runners",
"originalUrl": "/rest/reference/enterprise-admin#get-a-self-hosted-runner-for-an-enterprise"
},
"enterprise-admin/get-self-hosted-runner-group-for-enterprise": {
"category": "actions",
"subcategory": "self-hosted-runner-groups",
"originalUrl": "/rest/reference/enterprise-admin#get-a-self-hosted-runner-group-for-an-enterprise"
},
"enterprise-admin/list-labels-for-self-hosted-runner-for-enterprise": {
"category": "actions",
"subcategory": "self-hosted-runners",
"originalUrl": "/rest/reference/enterprise-admin#list-labels-for-a-self-hosted-runner-for-an-enterprise"
},
"enterprise-admin/list-org-access-to-self-hosted-runner-group-in-enterprise": {
"category": "actions",
"subcategory": "self-hosted-runner-groups",
"originalUrl": "/rest/reference/enterprise-admin#list-organization-access-to-a-self-hosted-runner-group-in-a-enterprise"
},
"enterprise-admin/list-runner-applications-for-enterprise": {
"category": "actions",
"subcategory": "self-hosted-runners",
"originalUrl": "/rest/reference/enterprise-admin#list-runner-applications-for-an-enterprise"
},
"enterprise-admin/list-selected-organizations-enabled-github-actions-enterprise": {
"category": "actions",
"subcategory": "permissions",
"originalUrl": "/rest/reference/enterprise-admin#list-selected-organizations-enabled-for-github-actions-in-an-enterprise"
},
"enterprise-admin/list-self-hosted-runner-groups-for-enterprise": {
"category": "actions",
"subcategory": "self-hosted-runner-groups",
"originalUrl": "/rest/reference/enterprise-admin#list-self-hosted-runner-groups-for-an-enterprise"
},
"enterprise-admin/list-self-hosted-runners-for-enterprise": {
"category": "actions",
"subcategory": "self-hosted-runners",
"originalUrl": "/rest/reference/enterprise-admin#list-self-hosted-runners-for-an-enterprise"
},
"enterprise-admin/list-self-hosted-runners-in-group-for-enterprise": {
"category": "actions",
"subcategory": "self-hosted-runner-groups",
"originalUrl": "/rest/reference/enterprise-admin#list-self-hosted-runners-in-a-group-for-an-enterprise"
},
"enterprise-admin/remove-all-custom-labels-from-self-hosted-runner-for-enterprise": {
"category": "actions",
"subcategory": "self-hosted-runners",
"originalUrl": "/rest/reference/enterprise-admin#remove-all-custom-labels-from-a-self-hosted-runner-for-an-enterprise"
},
"enterprise-admin/remove-custom-label-from-self-hosted-runner-for-enterprise": {
"category": "actions",
"subcategory": "self-hosted-runners",
"originalUrl": "/rest/reference/enterprise-admin#remove-a-custom-label-from-a-self-hosted-runner-for-an-enterprise"
},
"enterprise-admin/remove-org-access-to-self-hosted-runner-group-in-enterprise": {
"category": "actions",
"subcategory": "self-hosted-runner-groups",
"originalUrl": "/rest/reference/enterprise-admin#remove-organization-access-to-a-self-hosted-runner-group-in-an-enterprise"
},
"enterprise-admin/remove-self-hosted-runner-from-group-for-enterprise": {
"category": "actions",
"subcategory": "self-hosted-runner-groups",
"originalUrl": "/rest/reference/enterprise-admin#remove-a-self-hosted-runner-from-a-group-for-an-enterprise"
},
"enterprise-admin/set-allowed-actions-enterprise": {
"category": "actions",
"subcategory": "permissions",
"originalUrl": "/rest/reference/enterprise-admin#set-allowed-actions-for-an-enterprise"
},
"enterprise-admin/set-custom-labels-for-self-hosted-runner-for-enterprise": {
"category": "actions",
"subcategory": "self-hosted-runners",
"originalUrl": "/rest/reference/enterprise-admin#set-custom-labels-for-a-self-hosted-runner-for-an-enterprise"
},
"enterprise-admin/set-github-actions-permissions-enterprise": {
"category": "actions",
"subcategory": "permissions",
"originalUrl": "/rest/reference/enterprise-admin#set-github-actions-permissions-for-an-enterprise"
},
"enterprise-admin/set-org-access-to-self-hosted-runner-group-in-enterprise": {
"category": "actions",
"subcategory": "self-hosted-runner-groups",
"originalUrl": "/rest/reference/enterprise-admin#set-organization-access-to-a-self-hosted-runner-group-in-an-enterprise"
},
"enterprise-admin/set-selected-organizations-enabled-github-actions-enterprise": {
"category": "actions",
"subcategory": "permissions",
"originalUrl": "/rest/reference/enterprise-admin#set-selected-organizations-enabled-for-github-actions-in-an-enterprise"
},
"enterprise-admin/set-self-hosted-runners-in-group-for-enterprise": {
"category": "actions",
"subcategory": "self-hosted-runner-groups",
"originalUrl": "/rest/reference/enterprise-admin#set-self-hosted-runners-in-a-group-for-an-enterprise"
},
"enterprise-admin/update-self-hosted-runner-group-for-enterprise": {
"category": "actions",
"subcategory": "self-hosted-runner-groups",
"originalUrl": "/rest/reference/enterprise-admin#update-a-self-hosted-runner-group-for-an-enterprise"
},
"orgs/list-custom-roles": {
"category": "orgs",
"subcategory": "custom-roles",
"originalUrl": "/rest/reference/orgs#list-custom-repository-roles-in-an-organization"
},
"apps/oauth-applications": {
"category": "apps",
"subcategory": "apps",
"originalUrl": "/rest/apps/oauth-applications#create-a-scoped-access-token"
}
},
"sectionUrls": {
"/rest/repos#deploy-keys": "/rest/deploy-keys",
"/rest/deployments#deploy-keys": "/rest/deploy-keys",
"/rest/repos#statuses": "/rest/commits/statuses"
}
}

View File

@@ -0,0 +1,34 @@
#!/usr/bin/env node
// This schema is used to validate each generated webhook object at build time
export default {
type: 'object',
required: ['availability', 'bodyParameters', 'category', 'descriptionHtml', 'summaryHtml'],
properties: {
// Properties from the source OpenAPI schema that this module depends on
action: {
description: 'The webhook action type',
type: ['string', 'null'],
},
availability: {
description: 'The supported origins of the webhook',
type: 'array',
},
bodyParameters: {
description: 'The request body parameters for the webhook',
type: 'array',
},
category: {
description: 'The name of the webhook and also the subcategory of the OpenAPI operation.',
type: 'string',
},
descriptionHtml: {
description: 'The description of the action property in the requestBody',
type: 'string',
},
summaryHtml: {
description: 'The description of the webhook',
type: 'string',
},
},
}

View File

@@ -0,0 +1,79 @@
#!/usr/bin/env node
import Ajv from 'ajv'
import { get, isPlainObject } from 'lodash-es'
import renderContent from '../../../../lib/render-content/index.js'
import webhookSchema from './webhook-schema.js'
import { getBodyParams } from './get-body-params.js'
const NO_CHILD_PROPERTIES = [
'action',
'enterprise',
'installation',
'organization',
'repository',
'sender',
]
export default class Webhook {
#webhook
constructor(webhook) {
this.#webhook = webhook
this.descriptionHtml = ''
this.summaryHtml = ''
this.bodyParameters = []
this.availability = webhook['x-github']['supported-webhook-types']
this.action = get(
webhook,
`requestBody.content['application/json'].schema.properties.action.enum[0]`,
null
)
// for some webhook action types (like some pull-request webhook types) the
// schema properties are under a oneOf so we try and take the action from
// the first one (the action will be the same across oneOf items)
if (!this.action) {
this.action = get(
webhook,
`requestBody.content['application/json'].schema.oneOf[0].properties.action.enum[0]`,
null
)
}
// The OpenAPI uses hyphens for the webhook names, but the webhooks
// are sent using underscores (e.g. `branch_protection_rule` instead
// of `branch-protection-rule`)
this.category = webhook['x-github'].subcategory.replaceAll('-', '_')
return this
}
async process() {
await Promise.all([this.renderDescription(), this.renderBodyParameterDescriptions()])
const ajv = new Ajv()
const valid = ajv.validate(webhookSchema, this)
if (!valid) {
console.error(JSON.stringify(ajv.errors, null, 2))
throw new Error(`Invalid OpenAPI webhook found: ${this.category}`)
}
}
async renderDescription() {
this.descriptionHtml = await renderContent(this.#webhook.description)
this.summaryHtml = await renderContent(this.#webhook.summary)
return this
}
async renderBodyParameterDescriptions() {
if (!this.#webhook.requestBody) return []
const schema = get(this.#webhook, `requestBody.content.['application/json'].schema`, {})
this.bodyParameters = isPlainObject(schema) ? await getBodyParams(schema, true, this.title) : []
// Removes the children of the common properties
this.bodyParameters.forEach((param) => {
if (NO_CHILD_PROPERTIES.includes(param.name)) {
param.childParamsGroups = []
}
})
}
}

44
src/webhooks/README.md Normal file
View File

@@ -0,0 +1,44 @@
# Webhooks
## About this directory
* `src/webhooks/lib/index.js` is human-editable.
* `src/rest/data/**/*.payload.json` are manually edited and copied. When a new GHES release is created, the static webhook files from the previous version's directory are copied to a new version directory.
## Editable files
* `src/webhooks/lib/index.js` consumes the static JSON files in `src/webhooks/data` and exports the data used by the REST middleware contextualizer.
## Static files
Generated by `src/rest/scripts/update-files.js`:
* `src/rest/data` - files generated from the dereferenced OpenAPI schema with the Markdown descriptions rendered in HTML
* `src/rest/data/dereferenced` - **NOTE** These are only generated if you pass the --keep-dereferenced-files option and are not checked into the repository. This option is for debug only.
## Rendering docs
When the server starts, `middleware/contextualizers/webhooks.js` accesses the data exported from the static webhook JSON files, fetches the data for the current version and requested path, and adds it to the `context` object. The added property is:
* `req.context.webhookPayloadsForCurrentVersion` - all webhook payloads with a version matching the current version
Markdown files in `content/developers/webhooks-and-events/webhooks/webhook-events-and-payloads.md` use Liquid to display the webhook payloads in `req.context.webhookPayloadsForCurrentVersion`. For example `{{ webhookPayloadsForCurrentVersion.user.created }}` references the payload file `user.created.payload.json` for the version being viewed.
**Note** Payload files either contain the webhook action type or no action type at all. For example, `user.created.payload.json` is the webhook `user` with the action type of `created`. Not all webhooks have action types. If a file exists with no action type (e.g., `user.payload.json`) and the action types (e.g., `user.created.payload.json` and `user.deleted.payload.json`), the entry in the context for the file with no action type will be `default`. For example, for the three static file mentioned, the object would be:
```
{
user: {
default: "STRING VALUE",
created: "STRING VALUE",
deleted: "STRING VALUE"
}
}
```
If no action types exist, and only `user.payload.json` exists, the object would be:
```
{
user: "STRING VALUE"
}

File diff suppressed because it is too large Load Diff

169382
src/webhooks/data/ghec.json Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

168292
src/webhooks/data/ghes-3.7.json Normal file

File diff suppressed because it is too large Load Diff

169996
src/webhooks/data/ghes-3.8.json Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

83
src/webhooks/lib/index.js Normal file
View File

@@ -0,0 +1,83 @@
import path from 'path'
import { allVersions } from '../../../lib/all-versions.js'
import { readCompressedJsonFileFallback } from '../../../lib/read-json-file.js'
const schemasPath = 'src/webhooks/data'
// cache for webhook data per version
const webhooksCache = new Map()
// cache for webhook data for when you first visit the webhooks page where we
// show all webhooks for the current version but only 1 action type per webhook
// and also no nested parameters
const initialWebhooksCache = new Map()
// return the webhoook data as described for `initialWebhooksCache` for the given
// version
export async function getInitialPageWebhooks(version) {
if (initialWebhooksCache.has(version)) {
return initialWebhooksCache.get(version)
}
const allWebhooks = await getWebhooks(version)
const initialWebhooks = []
// The webhooks page shows all webhooks but for each webhook only a single
// webhook action type at a time. We pick the first webhook type from each
// webhook's set of action types to show.
for (const [key, webhook] of Object.entries(allWebhooks)) {
const actionTypes = Object.keys(webhook)
const defaultAction = actionTypes ? actionTypes[0] : null
const initialWebhook = {
name: key,
actionTypes,
data: webhook[defaultAction],
}
// remove all nested params for the initial webhooks page, we'll load
// them by request
if (initialWebhook.data.bodyParameters) {
initialWebhook.data.bodyParameters.forEach((bodyParam) => {
if (bodyParam.childParamsGroups) {
bodyParam.childParamsGroups = []
}
})
}
initialWebhooks.push({ ...initialWebhook })
}
initialWebhooksCache.set(version, initialWebhooks)
return initialWebhooks
}
// returns the webhook data for the given version and webhook category (e.g.
// `check_run`) -- this includes all the data per webhook action type and all
// nested parameters
export async function getWebhook(version, webhookCategory) {
const webhooks = await getWebhooks(version)
return webhooks[webhookCategory]
}
// returns all the webhook data for the given version
export async function getWebhooks(version) {
const openApiVersion = getOpenApiVersion(version)
if (!webhooksCache.has(openApiVersion)) {
const filename = `${openApiVersion}.json`
// The `readCompressedJsonFileFallback()` function
// will check for both a .br and .json extension.
webhooksCache.set(
openApiVersion,
readCompressedJsonFileFallback(path.join(schemasPath, filename))
)
}
return webhooksCache.get(openApiVersion)
}
function getOpenApiVersion(version) {
if (!(version in allVersions)) {
throw new Error(`Unrecognized version '${version}'. Not found in ${Object.keys(allVersions)}`)
}
return allVersions[version].openApiVersionName
}