saasify-openapi-utils
Version:
OpenAPI utilities for Saasify.
305 lines (233 loc) • 8.5 kB
JavaScript
const cloneDeep = require('clone-deep')
const contentType = require('content-type')
const codegen = require('saasify-codegen')
const { parseFaasIdentifier } = require('saasify-faas-utils')
const openapiHeaderBlacklist = require('./openapi-header-blacklist')
const pathToService = require('./path-to-service')
const processReadme = require('./process-readme')
const isHttpMethod = require('./is-http-method')
/**
* Annotates a valid OpenAPI spec with extra metadata specific to Saasify's SaaS web client
* and [Redoc](https://github.com/Redocly/redoc).
*
* @param {object} spec - OpenAPI spec.
* @param {object} deployment - Parent Saasify deployment.
* @param {object} [opts] - Optional config.
*
* @return {Promise}
*/
module.exports = async (spec, deployment, opts = {}) => {
const { baseUrl = 'https://ssfy.sh' } = opts
const api = cloneDeep(spec)
const version = deployment.version ? `v${deployment.version}` : undefined
api.paths = api.paths || {}
const paths = Object.keys(api.paths)
// It's important that we overwrite the downstream origin servers and any security
// requirements they may use.
api.servers = [{ url: baseUrl }]
api.security = [{ 'API Key': [] }]
api.tags = api.tags || []
if (paths.length) {
api.tags = api.tags.concat([
{
name: 'Endpoints',
'x-displayName': 'Endpoints'
}
])
}
const readmeRaw = deployment.readme || ''
const { readme, quickStart, supportingOSS } = processReadme(readmeRaw)
const sections = (deployment.saas && deployment.saas.sections) || {}
api.info = {
...api.info,
title: deployment.project.name,
version,
termsOfService: '/terms',
contact: {
name: 'API Support',
email:
(sections.navHeader && sections.navHeader.support) ||
'support@saasify.sh'
},
description: `
${quickStart || readme}
# API
## Authentication
### API Key
Optional API key for authenticated access. Note that we use "auth token" and "API key" interchangably in these docs.
Authenticated requests must include an \`Authorization\` header containing your subscription's auth token.
| Security Schema Type | Header Name | Example Token |
| --- | --- | --- |
| API Key | \`Authorization\` | aebfbb4729a300da7cc5c470 |
In the following example, \`TOKEN\` represents the auth token for your account.
\`\`\`
curl --header 'authorization: TOKEN'
\`\`\`
You can view and manage your auth tokens in the [Dashboard](/dashboard).
Be sure to keep your auth tokens secure. Do not share them in publicly accessible areas such as GitHub, client-side code, and so forth.
Also note that all API requests must be made over **HTTPS**. Calls made over plain HTTP will attempt to be automatically upgraded to HTTPS, though this use cases is discouraged.
${
!sections.docs || sections.docs.rateLimits !== false
? `
## Rate Limits
API requests may be rate limited depending on your subscription plan and traffic patterns. The following response headers will be present in these cases:
| Header | Description |
| ------ | ----------- |
| \`X-RateLimit-Limit\` | The maximum number of requests that the consumer is permitted to make. |
| \`X-RateLimit-Remaining\` | The number of requests remaining in the current rate limit window. |
| \`X-RateLimit-Reset\` | The time at which the current rate limit window resets in UTC epoch seconds. |
When the rate limit is **exceeded**, an error is returned with the status "**429 Too Many Requests**":
\`\`\`json
{
"error": {
"code": "too_many_requests",
"message": "Rate limit exceeded"
}
}
\`\`\`
`
: ''
}
## Errors
This API uses conventional HTTP response codes to indicate the success or failure of API requests. In general: Codes in the \`2xx\` range indicate success. Codes in the \`4xx\` range indicate an error that failed given the information provided (e.g., a required parameter was omitted, endpoint not found, etc.). Codes in the \`5xx\` range indicate an error with our API (these are rare).
${supportingOSS}
`
}
api.components = {
...api.components,
securitySchemes: {
...(api.components && api.components.securitySchemes),
'API Key': {
type: 'apiKey',
name: 'Authorization',
in: 'header',
description: `Optional API key for authenticated access.
Unauthenticated (public) requests are subject to rate limiting. See [pricing](/pricing) for specifics on these rate limits.
You can view and manage your API key in the [Dashboard](/dashboard).
Be sure to keep your API key secure. Do not share it in publicly accessible areas such as GitHub, client-side code, and so forth.
All API requests must be made over HTTPS. Calls made over plain HTTP will fail.`
}
}
}
for (const path of paths) {
const pathItem = api.paths[path]
annotatePathItem({ pathItem, path, api, deployment })
}
return api
}
function annotatePathItem({ pathItem, path, api, deployment }) {
// TODO: ideally we wouldn't need to treat openapi and non-openapi deployments differently here
const needsFullRoute = !!deployment.openapi
if (!needsFullRoute) {
const parsedPath = parseFaasIdentifier(path)
if (parsedPath) {
path = parsedPath.servicePath
}
}
const service = pathToService(path, deployment)
if (!service) {
throw new Error(`Unable to find matching service for path "${path}"`)
}
const { name, route } = service
if (needsFullRoute) {
delete api.paths[path]
api.paths[route] = pathItem
}
const httpMethods = Object.keys(pathItem).filter(isHttpMethod)
for (const httpMethod of httpMethods) {
const op = pathItem[httpMethod]
op.tags = ['Endpoints']
if (!op.operationId) {
op.operationId = `${name}${httpMethod.toUpperCase()}`
}
if (!op.summary) {
op.summary = `${name} (${httpMethod.toUpperCase()})`
}
annotateOperationParameters({ op, httpMethod, service })
annotateOperationResponses({ op, httpMethod, service })
annotateOperationCodeSamples({ op, httpMethod, service })
// TODO: not sure what to do with this...
delete op.security
// TODO: move codegen and example logic from saasify-to-openapi into here
}
}
function annotateOperationParameters({ op }) {
if (!op.parameters) {
return
}
op.parameters = op.parameters.filter((param) => {
if (param.in === 'header' && openapiHeaderBlacklist.has(param.name)) {
return false
}
return true
})
}
function annotateOperationResponses({ op, service }) {
const { responses } = op
if (!responses) {
return
}
if (!responses['400']) {
responses['400'] = {
description: 'Invalid input'
}
}
if (!responses['429']) {
responses['429'] = {
description: 'Rate limit exceeded'
}
}
// add concrete examples to the JSON success responses
const success = responses['200']
if (success) {
const mediaType = success.content && success.content['application/json']
annotateMediaTypeExamples({ mediaType, service })
}
}
function annotateOperationCodeSamples({ op, httpMethod, service }) {
let example
try {
example = codegen(service, null, { method: httpMethod.toUpperCase() })
} catch (err) {
console.warn('codegen warning', err.message)
}
if (example && !op['x-code-samples']) {
op['x-code-samples'] = example.snippets.map((sample) => ({
lang: sample.language,
label: sample.label,
source: sample.code
}))
}
const mediaType =
op.requestBody &&
op.requestBody.content &&
op.requestBody.content['application/json']
annotateMediaTypeExamples({ mediaType, service })
}
function annotateMediaTypeExamples({ mediaType, service }) {
if (mediaType) {
if (!mediaType.examples || !Object.keys(mediaType.examples).length) {
mediaType.examples = service.examples.reduce((acc, example) => {
const ct = contentType.parse(example.inputContentType)
const type = ct && ct.type
if (type !== 'application/json') {
return acc
}
const ex = { summary: example.name, description: example.description }
if (example.inputUrl) {
ex.externalValue = example.inputUrl
} else {
ex.value = example.input
}
return {
...acc,
[example.name]: ex
}
}, {})
if (!Object.keys(mediaType.examples).length) {
delete mediaType.examples
}
}
}
}