UNPKG

saasify-openapi-utils

Version:
289 lines (231 loc) 7.08 kB
'use strict' 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 }