Skip to content
This repository has been archived by the owner on Jul 27, 2020. It is now read-only.

refactor: generate openapi directly instead of legacy format #482

Merged
merged 9 commits into from
Aug 14, 2019
39 changes: 14 additions & 25 deletions lib/check-or-update-routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,6 @@ const Cache = require('./cache')
const getEndpoint = require('./endpoint/get')
const getGheVersions = require('./get-ghe-versions')
const parseUrlsOption = require('./options/urls')
const {
convertEndpointToOperation,
findEndpointNameDeprecation
} = require('./openapi')

async function checkOrUpdateRoutes (options) {
if (!options.urls && !options.cached) {
Expand Down Expand Up @@ -52,7 +48,7 @@ async function checkOrUpdateRoutes (options) {
const urls = await parseUrlsOption(state, options.urls)
console.log(`🤖 Looking for sections at ${urls.length} URLs`)

const allEndpoints = []
const routes = []
await urls.reduce(async (promise, url) => {
await promise

Expand All @@ -64,7 +60,7 @@ async function checkOrUpdateRoutes (options) {
return
}

allEndpoints.push(...endpoints)
routes.push(...endpoints)
}, null)

console.log('')
Expand All @@ -75,15 +71,16 @@ async function checkOrUpdateRoutes (options) {
state.gheVersion ? `ghe-${state.gheVersion}` : 'api.github.com'
)
const mainSchema = require(mainSchemaFileRelativePath)
mainSchema.paths = {}
const mainSchemaFilePath = require.resolve(mainSchemaFileRelativePath)
allEndpoints.forEach(async endpoint => {
// do not create operations files for endpoints that had a name change
if (endpoint.deprecated && endpoint.deprecated.before) {
return
}

const path = endpoint.path.replace(/:(\w+)/g, '{$1}')
const [scope] = endpoint.documentationUrl.substr(state.baseUrl.length).split('/')
routes.forEach(async route => {
const { operation } = route
const method = route.method.toLowerCase()
const path = route.path.replace(/:(\w+)/g, '{$1}')
const [scope] = operation.externalDocs.url
.substr(state.baseUrl.length).split('/')
const idName = operation.operationId
.replace(new RegExp(`^${scope}[^a-z]`, 'i'), '')

// TODO: handle "Identical path templates detected" error
if ([
Expand All @@ -94,19 +91,11 @@ async function checkOrUpdateRoutes (options) {
return
}

_.set(mainSchema.paths, `["${path}"].${endpoint.method.toLowerCase()}`, {
$ref: `operations/${scope}/${endpoint.idName}.json`
})

const nameDeprecation = findEndpointNameDeprecation(allEndpoints, endpoint)
const operation = convertEndpointToOperation({
endpoint,
scope,
baseUrl: state.baseUrl,
nameDeprecation
_.set(mainSchema.paths, `["${path}"].${method}`, {
$ref: `operations/${scope}/${idName}.json`
})

const operationFilePath = pathResolve(mainSchemaFilePath, '..', 'operations', scope, endpoint.idName) + '.json'
const operationFilePath = pathResolve(mainSchemaFilePath, '..', 'operations', scope, idName) + '.json'
await ensureFile(operationFilePath)
await writeJson(operationFilePath, operation, { spaces: 2 })
})
Expand Down
269 changes: 269 additions & 0 deletions lib/endpoint/add-code-samples.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
module.exports = addCodeSamples

const urlTemplate = require('url-template')
const { stringify } = require('javascript-stringify')

// TODO: find a better place to define parameter examples
const PARAMETER_EXAMPLES = {
owner: 'octocat',
repo: 'hello-world',
issue_number: 1,
title: 'title'
}

function addCodeSamples ({ routes, scope, baseUrl }) {
routes.forEach(route => {
const codeSampleParams = { route, scope, baseUrl }
route.operation['x-code-samples'] = route.operation['x-code-samples'].concat(
{ lang: 'Shell', source: toShellExample(codeSampleParams) },
{ lang: 'JS', source: toJsExample(codeSampleParams) }
)
})
}

function toShellExample ({ route, scope, baseUrl }) {
const path = urlTemplate.parse(route.path.replace(/:(\w+)/, '{$1}')).expand(PARAMETER_EXAMPLES)
const params = {}
Object.assign(params, getRequiredBodyParamDict(route.operation))

const method = route.method.toUpperCase()
const defaultAcceptHeader = route.operation.parameters[0].schema.default

const args = [
method !== 'GET' && `-X${method}`,
`-H"Accept: ${defaultAcceptHeader}"`,
new URL(path, baseUrl).href,
Object.keys(params).length && `-d '${JSON.stringify(params)}'`
].filter(Boolean)
return `curl \\\n ${args.join(' \\\n ')}`
}

function toJsExample ({ route, scope, baseUrl }) {
const params = route.operation.parameters
.filter(param => !param.deprecated)
.filter(param => param.in !== 'header')
.filter(param => param.required)
.reduce((params, param) => Object.assign(params, {
[param.name]: PARAMETER_EXAMPLES[param.name] || param.name
}), {})
Object.assign(params, getRequiredBodyParamDict(route.operation))

return `octokit.${scope}.get(${Object.keys(params).length ? stringify(params, null, 2) : ''})`
}

function getRequiredBodyParamDict (operation) {
let schema
try {
schema = operation.requestBody.content['application/json'].schema
} catch (noResponseBody) {
return {}
}
if (schema.type === 'string') {
const paramName = operation['x-github'].requestBodyParameterName
return { [paramName]: PARAMETER_EXAMPLES[paramName] || paramName }
}
const requiredBodyParamDict = {}
const requiredBodyParams = [].concat(schema.required || [])
// Temporary workarounds for PR#482:
const { prepareParams, prepareParamDict } = getParamAlterers(operation.operationId)
prepareParams && prepareParams(requiredBodyParams)
requiredBodyParams.length > 0 && Object.assign(
requiredBodyParamDict,
requiredBodyParams.reduce(reduceToParamDict, {})
)
prepareParamDict && prepareParamDict(requiredBodyParamDict)
return requiredBodyParamDict

function reduceToParamDict (paramDict, paramName) {
const paramSchema = operation.requestBody.content['application/json']
.schema.properties[paramName]
if (!paramSchema.deprecated) {
paramDict[paramName] = PARAMETER_EXAMPLES[paramName] || paramName

// Temporarily add object props as separate string values because we're
// not touching the OpenAPI docs in this PR
// TODO: Make code samples respect parameter types and really just be less weird
let props, prefix
if (paramSchema.type === 'object') {
props = paramSchema.properties
prefix = paramName
} else if (paramSchema.type === 'array' && paramSchema.items.type === 'object') {
props = paramSchema.items.properties
prefix = `${paramName}[]`
}
if (props && prefix) {
for (const propName of Object.keys(props)) {
const fauxPropName = `${prefix}.${propName}`
paramDict[fauxPropName] = fauxPropName
}
}
}
return paramDict
}
}

function getParamAlterers (operationId) {
switch (operationId) {
case 'checks-create':
return {
prepareParamDict: (paramDict) => {
Object.assign(paramDict, {
'output.title': 'output.title',
'output.summary': 'output.summary',
'output.annotations[].path': 'output.annotations[].path',
'output.annotations[].start_line': 'output.annotations[].start_line',
'output.annotations[].end_line': 'output.annotations[].end_line',
'output.annotations[].annotation_level': 'output.annotations[].annotation_level',
'output.annotations[].message': 'output.annotations[].message',
'output.images[].alt': 'output.images[].alt',
'output.images[].image_url': 'output.images[].image_url',
'actions[].label': 'actions[].label',
'actions[].description': 'actions[].description',
'actions[].identifier': 'actions[].identifier'
})
}
}
case 'checks-update':
return {
prepareParamDict: (paramDict) => {
Object.assign(paramDict, {
'output.summary': 'output.summary',
'output.annotations[].path': 'output.annotations[].path',
'output.annotations[].start_line': 'output.annotations[].start_line',
'output.annotations[].end_line': 'output.annotations[].end_line',
'output.annotations[].annotation_level': 'output.annotations[].annotation_level',
'output.annotations[].message': 'output.annotations[].message',
'output.images[].alt': 'output.images[].alt',
'output.images[].image_url': 'output.images[].image_url',
'actions[].label': 'actions[].label',
'actions[].description': 'actions[].description',
'actions[].identifier': 'actions[].identifier'
})
}
}
case 'checks-set-suites-preferences':
return {
prepareParams: (params) => params.push('auto_trigger_checks'),
prepareParamDict: (paramDict) => {
delete paramDict.auto_trigger_checks
}
}
case 'gists-create':
return {
prepareParamDict: (paramDict) => {
delete paramDict['files.content']
}
}
case 'git-create-tree':
return {
prepareParamDict: (paramDict) => {
delete paramDict['tree[].path']
delete paramDict['tree[].mode']
delete paramDict['tree[].type']
delete paramDict['tree[].sha']
delete paramDict['tree[].content']
}
}
case 'pulls-create-review':
return {
prepareParams: (params) => params.push('comments'),
prepareParamDict: (paramDict) => {
delete paramDict.comments
}
}
case 'repos-create-or-update-file':
return {
prepareParams: (params) => params.push('committer', 'author'),
prepareParamDict: (paramDict) => {
delete paramDict.committer
delete paramDict.author
}
}
case 'repos-update-branch-protection':
return {
prepareParamDict: (paramDict) => {
delete paramDict['required_pull_request_reviews.dismissal_restrictions']
delete paramDict['required_pull_request_reviews.dismiss_stale_reviews']
delete paramDict['required_pull_request_reviews.require_code_owner_reviews']
delete paramDict['required_pull_request_reviews.required_approving_review_count']
delete paramDict['restrictions.users']
delete paramDict['restrictions.teams']
}
}
case 'orgs-create-hook':
case 'repos-create-hook':
return {
prepareParamDict: (paramDict) => {
delete paramDict['config.content_type']
delete paramDict['config.secret']
delete paramDict['config.insecure_ssl']
}
}
case 'orgs-update-hook':
case 'repos-update-hook':
return {
prepareParams: (params) => params.push('config'),
prepareParamDict: (paramDict) => {
delete paramDict.config
delete paramDict['config.content_type']
delete paramDict['config.secret']
delete paramDict['config.insecure_ssl']
}
}
case 'repos-add-protected-branch-required-status-checks-contexts':
case 'repos-remove-protected-branch-required-status-checks-contexts':
case 'repos-replace-protected-branch-required-status-checks-contexts':
return {
prepareParamDict: (paramDict) => {
Object.assign(paramDict, {
contexts: 'contexts'
})
}
}
case 'repos-add-protected-branch-team-restrictions':
case 'repos-remove-protected-branch-team-restrictions':
case 'repos-replace-protected-branch-team-restrictions':
return {
prepareParamDict: (paramDict) => {
Object.assign(paramDict, {
teams: 'teams'
})
}
}
case 'repos-add-protected-branch-user-restrictions':
case 'repos-remove-protected-branch-user-restrictions':
case 'repos-replace-protected-branch-user-restrictions':
return {
prepareParamDict: (paramDict) => {
Object.assign(paramDict, {
users: 'users'
})
}
}
case 'markdown-render-raw':
return {
prepareParamDict: (paramDict) => {
Object.assign(paramDict, {
data: 'data'
})
}
}
case 'enterprise-admin-create-global-hook':
return {
prepareParamDict: (paramDict) => {
delete paramDict['config.content_type']
delete paramDict['config.secret']
delete paramDict['config.insecure_ssl']
}
}
case 'enterprise-admin-update-global-hook':
return {
prepareParamDict: (paramDict) => {
Object.assign(paramDict, {
'config.url': 'config.url'
})
}
}
}
return {}
}
6 changes: 3 additions & 3 deletions lib/endpoint/add-triggers-notification-flag.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ module.exports = addTriggersNotificationFlag
* - Source: https://developer.github.com/v3/guides/best-practices-for-integrators/#dealing-with-abuse-rate-limits
*/
function addTriggersNotificationFlag (state) {
state.results.forEach(endpoint => {
if (/This endpoint triggers \[notifications\]/.test(endpoint.description)) {
endpoint.triggersNotification = true
state.routes.forEach(route => {
if (/This endpoint triggers \[notifications\]/.test(route.operation.description)) {
route.operation['x-github'].triggersNotification = true
}
})
}
28 changes: 28 additions & 0 deletions lib/endpoint/find-accept-header.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
module.exports = findAcceptHeader

function findAcceptHeader (state) {
const previewBlocks = state.blocks.filter(block => block.type === 'preview')

state.routes.forEach(route => {
// TODO: handle optional preview blocks
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this comment is obsolete and can be removed

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

currently, there are 73 routes on api.github.com that come through with previewBlocks that have required: false

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sorry for that long comment...the non-required accept headers for those routes are being ignored right now...after a cursory look, they seem to generally be used for adding extra info in the response...which if that's true, we could just always just get everything...but some analysis may be necessary to figure out exactly how to treat these, and even ensure if they're being parsed correctly as required: false

const requiredPreviewHeaderBlocks = previewBlocks.filter(block => block.required)
const defaultAcceptHeader = requiredPreviewHeaderBlocks.length
? requiredPreviewHeaderBlocks.map(block => `application/vnd.github.${block.preview}-preview+json`).join(',')
: 'application/vnd.github.v3+json'
const acceptHeaderParam = {
name: 'accept',
description: requiredPreviewHeaderBlocks.length ? 'This API is under preview and subject to change.' : 'Setting to `application/vnd.github.v3+json` is recommended',
in: 'header',
schema: {
type: 'string',
default: defaultAcceptHeader
}
}

if (requiredPreviewHeaderBlocks.length) {
acceptHeaderParam.required = true
}

route.operation.parameters.unshift(acceptHeaderParam)
})
}
Loading