saasify-openapi-utils
Version:
OpenAPI utilities for Saasify.
289 lines (231 loc) • 7.08 kB
JavaScript
const pick = require('lodash.pick')
const stringify = require('fast-json-stable-stringify')
const isHttpMethod = require('./is-http-method')
const jsonContentType = 'application/json'
/**
* Extracts any example inputs from an OpenAPI PathItem.
*
* *Assumes the OpenAPI spec has been fully dereferenced.*
*
* @param {object} pathItem - OpenAPI PathItem to extract examples from.
* @param {string} [httpMethod] - HTTP method to focus on.
*
* @return {Promise}
*/
module.exports = async (pathItem, httpMethod) => {
let hasOperationParams = false
let examples = []
const httpMethods = Object.keys(pathItem).filter(isHttpMethod)
for (const method of httpMethods) {
if (httpMethod && method.toLowerCase() !== httpMethod.toLowerCase()) {
continue
}
const op = pathItem[method]
if (op.requestBody && op.requestBody.content) {
const mediaType = op.requestBody.content[jsonContentType]
if (mediaType) {
examples = examples.concat(
getExamplesFromMediaType(mediaType, jsonContentType)
)
if (mediaType.schema) {
examples = examples.concat(getExamplesFromSchema(mediaType.schema))
}
}
}
if (op.parameters) {
hasOperationParams = true
examples = examples.concat(
getExamplesFromParameters([
...(pathItem.parameters || []),
...op.parameters
])
)
}
}
// filter out invalid examples
examples = filterExamples(examples)
if (!examples.length && pathItem.parameters && !hasOperationParams) {
// use as a fallback because PathItem-level parameters may not include all params
examples = examples.concat(getExamplesFromParameters(pathItem.parameters))
}
// TODO: is this too strict?
// works well for standard json inputs but not for other primitive types
// return examples.filter((example) => typeof example.input === 'object')
// filter out invalid examples
examples = filterExamples(examples)
// filter and dedupe example candidates based on their content
const exampleMap = {}
for (const example of examples) {
if (
typeof example.input === 'string' &&
example.inputContentType === jsonContentType
) {
// coerce malformed JSON examples that have been stringified to be valid strings
try {
example.input = JSON.parse(example.input)
} catch (err) {
// ignore
}
}
if (!isNonEmpty(example.input)) {
continue
}
// TODO: filter out potential example candidates that don't validate against all of the operation schemas
const key = stringify(pick(example, ['input', 'inputContentType']))
exampleMap[key] = {
...exampleMap[key],
...example
}
}
examples = Object.values(exampleMap)
return examples
}
function isNonEmpty(obj) {
if (Array.isArray(obj)) {
return obj.some(isNonEmpty)
} else if (typeof obj === 'object') {
return Object.keys(obj).some((key) => isNonEmpty(obj[key]))
}
return obj !== undefined && obj !== null
}
function filterExamples(examples) {
return examples.filter((example) => example && example.input !== undefined)
}
// https://swagger.io/specification/#mediaTypeObject
function getExamplesFromMediaType(mediaType, contentType) {
let examples = []
if (mediaType.example) {
examples.push({
input: mediaType.example,
inputContentType: contentType
})
}
if (mediaType.examples) {
if (Array.isArray(mediaType.examples)) {
examples = examples.concat(
mediaType.examples.map((example) => ({
input: example,
inputContentType: contentType
}))
)
} else {
for (const name of Object.keys(mediaType.examples)) {
// https://swagger.io/specification/#exampleObject
const example = mediaType.examples[name]
if (!example.value) {
continue
}
// TODO: support `externalValue`
examples.push({
name,
description: example.summary,
input: example.value,
inputContentType: contentType
})
}
}
}
return examples
}
function getExamplesFromParameters(parameters) {
const paramExamples = {}
const required = new Set()
for (const param of parameters) {
let examples = []
if (param.in === 'cookie' || param.in === 'header') {
continue
}
if (param.required) {
required.add(param.name)
}
examples = examples.concat(getExamplesFromMediaType(param, jsonContentType))
if (param.content) {
const mediaType = param.content[jsonContentType]
if (mediaType) {
examples = examples.concat(
getExamplesFromMediaType(mediaType, jsonContentType)
)
}
}
if (param.schema) {
examples = examples.concat(getExamplesFromSchema(param.schema))
}
examples = filterExamples(examples)
if (examples.length) {
paramExamples[param.name] = examples
}
}
const hasRequiredExamples = Array.from(required).every(
(key) => paramExamples[key]
)
if (hasRequiredExamples) {
const aggregateExample = Object.keys(paramExamples).reduce(
(acc, key) => ({
...acc,
// TODO: should we always take the first example?
[key]: paramExamples[key][0].input
}),
{}
)
return {
input: aggregateExample,
inputContentType: jsonContentType
}
}
return []
}
function getExamplesFromSchema(schema, ctx) {
// basic logic to prevent infinite recursion from circular references
if (!ctx) {
ctx = new Set()
}
if (ctx.has(schema)) {
return []
}
ctx.add(schema)
let examples = []
if (schema) {
examples = getExamplesFromMediaType(schema, jsonContentType)
if (schema.default !== undefined) {
examples.push({
input: schema.default,
inputContentType: jsonContentType
})
}
// TODO: handle arrays
// TODO: handle constants
// TODO: handle enums with a single element
// TODO: extract a valid value out of an enum
if (schema.type === 'object' && schema.properties) {
const propExamples = {}
for (const key of Object.keys(schema.properties)) {
const valueSchema = schema.properties[key]
const currentPropExamples = filterExamples(
getExamplesFromSchema(valueSchema, ctx)
)
if (currentPropExamples.length) {
propExamples[key] = currentPropExamples
}
}
const hasRequiredExamples = (schema.required || []).every(
(key) => propExamples[key]
)
if (hasRequiredExamples) {
const aggregateExample = Object.keys(propExamples).reduce(
(acc, key) => ({
...acc,
// TODO: should we always take the first example?
[key]: propExamples[key][0].input
}),
{}
)
examples.push({
input: aggregateExample,
inputContentType: jsonContentType
})
}
}
}
return examples
}