UNPKG

@axway/api-builder-runtime

Version:

API Builder Runtime

952 lines (884 loc) 27.1 kB
const _ = require('lodash'); const chalk = require('chalk'); const util = require('util'); const Swagger = require('@axway/api-builder-openapi-doc'); const schemas = require('@axway/api-builder-schema'); const apiBuilderConfig = require('@axway/api-builder-config'); const ObjectModel = require('./objectmodel'); const yaml = require('js-yaml'); // For parsing npm author strings // For example, "Barney Rubble <b@rubble.com> (http://barnyrubble.tumblr.com/)" const authorRegex = /^(.*?)(?:\s+<(.*?)>)?(?:\s+\((.*?)\))?$/; const verbMap = { POST: 'create', GET: 'find', PUT: 'update', DELETE: 'delete' }; const ResponseModel = { type: 'object', required: [ 'success', 'request-id' ], properties: { code: { type: 'integer', format: 'int32' }, success: { type: 'boolean' }, 'request-id': { type: 'string' }, message: { type: 'string' }, url: { type: 'string' } } }; const ErrorModel = { type: 'object', required: [ 'message', 'code', 'success', 'request-id' ], properties: { code: { type: 'integer', format: 'int32' }, success: { type: 'boolean', default: false }, 'request-id': { type: 'string' }, message: { type: 'string' }, url: { type: 'string' } } }; const UnauthorizedError = { type: 'object', required: [ 'message', 'success', 'id' ], properties: { success: { type: 'boolean' }, id: { enum: [ 'com.appcelerator.api.unauthorized' ] }, message: { type: 'string' } }, additionalProperties: false }; const PayloadTooLargeError = { type: 'object', required: [ 'message', 'code', 'success', 'request-id' ], properties: { code: { type: 'integer', format: 'int32' }, success: { type: 'boolean' }, 'request-id': { type: 'string' }, message: { type: 'string' } }, additionalProperties: false }; /** * Default response codes applied on the endpoints */ const emptySuccessResponse = { description: 'The operation succeeded', schema: {} }; const responseByCode = { 201: emptySuccessResponse, 204: emptySuccessResponse, 401: { description: 'Unauthorized', schema: { $ref: '#/definitions/UnauthorizedError' } }, 413: { description: 'Payload too large', schema: { $ref: '#/definitions/PayloadTooLargeError' } } }; module.exports.bindRoutes = bindRoutes; module.exports.generateSwagger = generateSwagger; module.exports.schemaIdToSwaggerName = schemaIdToSwaggerName; /** * Converts a schema id to a swagger friendly name. Conversion as per the ticket RDPP-1673 * * @param {string} id - Schema identifier, e.g. schema:///foo * @returns {string} the friendly name */ function schemaIdToSwaggerName(id) { if (id.startsWith('schema:///')) { const ids = id.slice(10).split('/'); // e.g. schema:///model/mongo%2Fmovies-full. // e.g. schema:///schema/demo/error // The model name is URI encoded and should be decoded before adding to swagger let name = decodeURIComponent(ids.slice(1).join('_')); if (ids[0] && ids[0] !== 'model') { name = ids[0] + '.' + name; } return name; } return id; } function bindRoutes(apibuilder) { const app = apibuilder.app; const objectModel = new ObjectModel(apibuilder); const { getAbsoluteURLs } = apibuilder._internal; const prefix = apibuilder.config.apidoc.prefix; apibuilder.logger.trace('Registering swagger routes under path ' + prefix); app.get(prefix + '/docs.json', util.deprecate(getDefinition.bind(null, 'json'), `${prefix}/docs.json is deprecated (${prefix}/swagger.json should be used instead). See: https://docs.axway.com/bundle/api-builder/page/docs/deprecations/index.html#D001`, 'D001' ) ); app.get(prefix + '/swagger.json', getDefinition.bind(null, 'json')); app.get(prefix + '/swagger.yml', getDefinition.bind(null, 'yaml')); app.get(prefix + '/swagger.yaml', getDefinition.bind(null, 'yaml')); // Get and log absolute URLs to the document for HTTP/HTTPs. const docUrls = getAbsoluteURLs(`${prefix}/swagger.json`); for (const url of docUrls) { apibuilder.logger.info( 'Access the OpenAPI document generated for your service at', chalk.yellow.underline(url)); } // Generate the document once for local use. The only difference from the accessed const generated = generateSwagger( apibuilder, // host. prefer http, fall back to https apibuilder._internal.host || apibuilder._internal.sslHost, objectModel, // name unused undefined, // force unused undefined, // secure: if not running on HTTP then this is true and the scheme becomes HTTPS apibuilder.host === undefined ); apibuilder._internal.setDynamicOpenAPI(docUrls[0], generated); function getDefinition(format, req, res) { try { var keys = Object.keys(req.query), typeKeys = keys.filter(function (a) { return a.startsWith('endpoints/') || a.startsWith('apis/'); }), force = req.query.force ? (req.query.force === 'true' || req.query.force === '1') : false, ignoreOverrides = req.query.ignoreOverrides ? (req.query.ignoreOverrides === 'true' || req.query.ignoreOverrides === '1') : false, host = req.get('host'), name, type, result; // extract name and type. eg. ?apis/appc.arrowdb/acl (type=apis, name=appc.arrowdb/acl) if (typeKeys.length) { type = typeKeys[0].split('/')[0]; name = typeKeys[0] .substr(type.length + 1, typeKeys[0].length).replace(/\.(json|html)/g, ''); } result = generateSwagger(apibuilder, host, objectModel, type, name, force, req.secure, ignoreOverrides); if (format === 'yaml') { result = yaml.safeDump(result); res.set('Content-Type', 'text/yaml'); res.send(result); } else if (typeof result === 'number') { res.sendStatus(result); } else { res.set('Content-Type', 'application/json'); res.send(result); } } catch (ex) { apibuilder.logger.error('Error generating swagger:', ex); res.status(500).send({ error: 'Server error' }); } } } /** * Get the models referenced by fields in model composition. */ function getCompositionModels(om, modelName, models) { var model = om.models[modelName]; if (model) { var referencedModels = new Set( Object.keys(model.fields) .map(function (fieldName) { return model.fields[fieldName]; }) .filter(function (field) { return field.model && ( field.type === 'array' || field.type === Array || field.type === 'object' || field.type === Object); }) .map(function (field) { return field.model; }) .filter(function (modelName) { return !models || !models.has(modelName); }) ); // Add the referenced models models = models || new Set(); referencedModels.forEach(function (m) { models.add(m); }); // Look for nested dependencies referencedModels.forEach(function (m) { getCompositionModels(om, m, models); }); } return models; } // Get the models reference by the APIs function getAPIModels(om) { const referenceModels = new Set(); // Get a unique list of models reference by the API endpoints. for (const apiName in om.apis) { const api = om.apis[apiName]; referenceModels.add(apiName); for (const endpoint of api.endpoints || []) { if (endpoint.model) { referenceModels.add(endpoint.model); } if (endpoint.response) { referenceModels.add(endpoint.response); } } } // Extend the list to include models referenced via field composition var referenceAndCompositionlModels = new Set(Array.from(referenceModels).map( function (m) { return transformKeyForComparison(m); } )); referenceModels.forEach( function (model) { var compModel = getCompositionModels(om, model); compModel && compModel.forEach(function (compModel) { return referenceAndCompositionlModels.add(transformKeyForComparison(compModel)); }); } ); return _.pickBy(om.models, function (value, name) { return referenceAndCompositionlModels.has(transformKeyForComparison(name)); }); } function hasAPIPrefixSecurity(apibuilder) { const { apiPrefixSecurity } = apibuilder.config.accessControl; return apiPrefixSecurity !== 'none'; } function addAPIPrefixSecurity(apibuilder, swagger) { if (!hasAPIPrefixSecurity(apibuilder)) { return; } swagger.schema('UnauthorizedError', UnauthorizedError); } /** * Returns true if any limit has been configured and enabled that would * result in the service returning 413 */ function hasPayloadLimit(apibuilder) { // We have one limit right now but this could expand to different limits. const { multipartPartSize } = apibuilder.config.limits; // Limit is only going to possibly fire if multipartPartSize is finite return multipartPartSize !== Infinity; } function addPayloadTooLarge(apibuilder, swagger) { if (!hasPayloadLimit(apibuilder)) { return; } swagger.schema('PayloadTooLargeError', PayloadTooLargeError); } /** * Generates a swagger document for the current api builder instance * @param {object} apibuilder api builder instance * @param {string} host host that is requesting the doc * @param {object} objectModel api builder object model instance * @param {string} type either 'apis' or 'endpoints' when fetching swagger by name * @param {string} name the name of the api/endpoint to generate swagger for * @param {bool} force include disabled endpoints in the requested swagger - does not * effect requests for a specific endpoint/api * @param {bool} secure is the server running ssl? * @param {bool} ignoreOverrides do not set user provided overrides on the swagger document */ function generateSwagger( apibuilder, host, objectModel, type, name, force, secure, ignoreOverrides) { const swagger = new Swagger(); let om; const docOverrides = ignoreOverrides ? {} : apibuilder.config.apidoc.overrides; // use overriden host or host from request, or fall back to localhost and server port. const port = secure ? apibuilder.config.ssl.port : apibuilder.port; const myHost = docOverrides.host !== undefined ? docOverrides.host : host || `127.0.0.1:${port}`; const basePath = docOverrides.basePath !== undefined ? docOverrides.basePath : apibuilder.config.apiPrefix; // default to http, use https if accessed over ssl or use the override const mySchemes = docOverrides.schemes !== undefined ? docOverrides.schemes : [ secure ? 'https' : 'http' ]; // Set the swagger API info swagger.info( apibuilder.metadata.name || 'API', apibuilder.metadata.version || '1.0', apibuilder.metadata.description || 'API description' ); if (myHost) { swagger.host(myHost); } if (basePath) { swagger.basePath(basePath); } /* Applying globalSchemes here has no affect because there are no paths in the * initial state. The merge will first copy all global schemes to the current * local paths, and delete the globals. But since there are no paths, the globals * are deleted. So, the merge will have the affect of removing schemes from the * document. This is works as designed, just a little unexpected. It is more * correct to not have schemes in the Swagger - they default to the scheme used * to access the document. In fact, schemes do not detail how to access each scheme. */ // .globalSchemes(createSchemes(objectModel)); /* Attempts to parse the contact details. Supports: * 1. Author information being a string: * "Barney Rubble <b@rubble.com> (http://barnyrubble.tumblr.com/) * 2. Author information being an object - containing name, email(optional) * and url(optional): * "author": { * "email": "banana@axway.com", * "name": "Banana", * "url": "https://axway.com" * } */ if (typeof apibuilder.metadata.author === 'object') { const { name, email, url } = apibuilder.metadata.author; swagger.contact(name, email, url); } else if (apibuilder.metadata.author) { const match = authorRegex.exec(apibuilder.metadata.author); if (match) { const [ , name, email, url ] = match; swagger.contact(name, email, url); } } apibuilder.metadata.license && swagger.license(apibuilder.metadata.license); // return 404 for undefined name or invalid types if (type && (!name || [ 'apis', 'endpoints' ].indexOf(type) === -1)) { return 404; } // Merge in the valid enabled endpoints if (!type || type === 'endpoints') { const endpoints = apibuilder && apibuilder.endpoints; if (endpoints) { if (type && !endpoints[name]) { return 404; } function filter(swagger, path, verb) { if (force) { return true; } const xenabled = swagger.paths[path][verb]['x-enabled']; if (xenabled && xenabled.enabled === false) { return false; } return true; } Object.keys(endpoints).forEach(function (epName) { const xEnabled = endpoints[epName].endpoint['x-enabled']; const endpointSwagger = endpoints[epName].swagger; let mergeOptions; if (!type && xEnabled && xEnabled.enabled) { // in case of a consolidated swagger, merge endpoints on their basepath to avoid // collision. also need to filter out the disabled endpoint paths mergeOptions = { prefix: endpointSwagger.basePath, filter: filter, extensions: /^x-(?!(enabled|flow)$)/, mergeBlacklist: [ 'schemes', 'securityDefinitions' ], // swagger merge option `express` is to *never* format `:name` to express // as the endpoint is already Swagger express: false }; } else if (epName === name) { swagger.basePath(basePath + (endpointSwagger.basePath || '')); mergeOptions = { filter: filter, extensions: /^x-(?!(enabled|flow)$)/, mergeBlacklist: [ 'schemes', 'securityDefinitions' ], // swagger merge option `express` is to *never* format `:name` to express // as the endpoint is already Swagger express: false }; } else { // not a valid/enabled endpoint or not the specific named // endpoint we're looking for. return; } if (hasAPIPrefixSecurity(apibuilder)) { // this is providing the 401 response which is to be // applied to operation responses when merging. mergeOptions.responses = { 401: responseByCode[401] }; } if (hasPayloadLimit(apibuilder)) { // We add 413 to all methods since we can't always guarantee which APIs // will accept an upload. This was the simplest solution. mergeOptions.responses = mergeOptions.responses || {}; mergeOptions.responses[413] = responseByCode[413]; } swagger.merge(endpointSwagger, mergeOptions); }); } } // Merge in the model endpoints var lookupDefinitions = {}; if (!type || type === 'apis') { if (type) { om = _.clone(objectModel); var transQuery = transformKeyForComparison(name); // lower-case function matchesQuery(value, key) { return transformKeyForComparison(key) === transQuery; } om.apis = _.pickBy(om.apis, matchesQuery); if (!Object.keys(om.apis).length) { return 404; } om.models = getAPIModels(om); } else { om = objectModel; } if (om && om.apis && Object.keys(om.apis).length) { swagger.schemas(createDefinitions(apibuilder, om, lookupDefinitions)); swagger.paths(createPaths(om, apibuilder, lookupDefinitions)); } } // Adding UnauthorizedError to schemas if we have security enabled addAPIPrefixSecurity(apibuilder, swagger); // Add payload too large error to schemas if we have limits in place addPayloadTooLarge(apibuilder, swagger); // Security definition (see: apibuilder/lib/authentication/index.js) // The getSwaggerSecurity method is optional. If the auth plugin defines one, then it // should be used. const sec = apibuilder.authStrategy.getSwaggerSecurity(); if (sec && sec.securityDefinitions) { const defs = sec.securityDefinitions; if (defs) { Object.keys(defs).forEach(function (key) { swagger.securityDefinition(key, defs[key]); }); } if (sec.security) { if (sec.security instanceof Array) { swagger.globalSecurity(sec.security); } else if (apibuilder.logger) { apibuilder.logger.error('invalid swagger security definition: ', sec.security); } } } var doc = swagger.apidoc(); // Define the scheme used to access the document if (mySchemes) { doc.schemes = mySchemes; } // dereference all $ref to loaded schemas return schemas.dereference(doc, { target: '#/definitions', rename: schemaIdToSwaggerName }); } function transformKeyForComparison(val) { return val.replace(/[^a-z0-9]/ig, '').toLowerCase(); } function createDefinitions(apibuilder, objectModel, lookupDefinitions) { const retVal = {}; const models = objectModel.models; Object.entries(models).forEach(([ modelName, model ]) => { const schema = [ { key: `#/definitions/${modelName.replace(/\//, '_')}`, id: apibuilder.getModelSchemaId(model) }, { key: `#/definitions/${modelName.replace(/\//, '_')}-ex`, id: apibuilder.getModelSchemaExId(model) } ]; if (!apiBuilderConfig.flags.enableModelsWithNoPrimaryKey || !(model.metadata && model.metadata.primarykey === null)) { schema.push( { key: `#/definitions/${modelName.replace(/\//, '_')}-full`, id: apibuilder.getModelSchemaFullId(model) }, { key: `#/definitions/${modelName.replace(/\//, '_')}-fullEx`, id: apibuilder.getModelSchemaFullExId(model) } ); } schema.forEach(({ key, id }) => { if (!schemas.get(id)) { let msg = `failed to get schema for model: ${modelName}`; apibuilder.logger.error(msg); throw new Error(msg); } lookupDefinitions[key] = id; }); }); retVal.ResponseModel = ResponseModel; retVal.ErrorModel = ErrorModel; return retVal; } // This is precalculated in the api constructor based on the // model/response or predefined value function getResponseKey(endpoint, isArray) { if (isArray) { return endpoint.plural || 'result'; } else { return endpoint.singular || 'result'; } } function generateResponses(apibuilder, endpoint, lookupDefinitions) { const isEmpty = !endpoint.responses || !Object.keys(endpoint.responses).length; const responses = isEmpty ? generateResponseByModel(endpoint, lookupDefinitions) : generateResponsesByStatus(endpoint, lookupDefinitions); if (hasAPIPrefixSecurity(apibuilder)) { responses[401] = responseByCode[401]; } else if (endpoint.generated) { // When security is disabled, only delete the 401 responses // for model auto-generated API. If custom API have 401 // responses, these are OK. delete responses[401]; } if (hasPayloadLimit(apibuilder)) { // 413 gets added to all APIs since we can't guarantee which ones actually // use/accept a multipart file/field responses[413] = responseByCode[413]; } return responses; } // This is specific for defining outputs for custom APIs. It leverage // 'response' and 'responseIsArray' properties. function generateResponseByModel(endpoint, lookupDefinitions) { const response = endpoint.response; const responseSpec = {}; // The user can configure if their API response is going to be documented // as returning an array or not. It defaults to false. let isArray = endpoint.responseIsArray; let ref = {}; // Get the response key that was set by the API. This can be a custom // singular/plural, or the one from the response model, otherwise // falling back to 'result'. const key = getResponseKey(endpoint, isArray); if (response) { ref.$ref = lookupDefinitions[ `#/definitions/${response.replace(/\//, '_')}-full` ]; } if (isArray) { ref = { type: 'array', items: ref }; } // Use default to apply generically to any possible response code. responseSpec.default = { description: 'Response from server', schema: { allOf: [ { $ref: '#/definitions/ResponseModel' }, { type: 'object', properties: { key: { type: 'string', enum: [ key ] }, [key]: ref } } ] } }; const method = endpoint.method.toLowerCase(); if ([ 'put', 'delete' ].includes(method)) { responseSpec[204] = responseByCode[204]; } else if (method === 'post') { responseSpec[201] = responseByCode[201]; } return responseSpec; } // Add responses in the api doc when specified. The 'responses' are // usually available in the generated APIs. They are unlikely to be in // set on custom APIs because 'responses' is not documented. // Still, we will process 'responses' for all APIs as an // advanced mechanism to allow the user to override model/response // properties. function generateResponsesByStatus(endpoint, lookupDefinitions) { const responses = endpoint.responses; const responseSpec = {}; for (const status in responses) { const response = responses[status]; if (endpoint.generated && status === '200') { // TODO: move this to the autogenerated API code and // make use of allOf just like generateResponsesByModel const schema = JSON.parse(JSON.stringify(ResponseModel)); if (response.schema) { const isArray = response.schema.type === 'array'; const key = getResponseKey(endpoint, isArray); schema.required.push('key'); schema.properties.key = { type: 'string', enum: [ key ] }; translateSchemaRefs(response, lookupDefinitions); schema.properties[key] = JSON.parse(JSON.stringify(response.schema)); delete schema.properties[key].description; } responseSpec[status] = { description: response.description || `${endpoint.name} Response`, schema, headers: responses.headers, examples: responses.examples }; } else { // Only wrapping success responseSpec[status] = response; } } return responseSpec; } /** * Lookup the model definition and return the schema:/// equivalent, properly * encoded. The effect it has: * - swaps $refs to #/definitions/ with schema:/// * - if the model contains slash e.g. "mongo/lime" the '/' is replaced with '_' * - the refs will contain the encoded version of '_' which is '~1' */ function translateSchemaRefs(response, lookupDefinitions) { const { schema } = response; const { items } = schema; if (schema.$ref && schema.$ref in lookupDefinitions) { schema.$ref = lookupDefinitions[schema.$ref]; } else if (items && items.$ref && items.$ref in lookupDefinitions) { items.$ref = lookupDefinitions[items.$ref]; } } function createPaths(objectModel, apiBuilder, lookupDefinitions) { const paths = {}; const apis = objectModel.apis; for (const groupName in apis) { if (apis.hasOwnProperty(groupName)) { const api = apis[groupName]; for (const endpoint of api.endpoints) { // These are not swagger "Endpoints". These are still "APIs" const eppath = endpoint.pathUnescaped || endpoint.path; let newRelativePath = eppath; if (eppath.startsWith(apiBuilder.config.apiPrefix)) { newRelativePath = eppath.replace(apiBuilder.config.apiPrefix, ''); } const relativePath = translatePath(newRelativePath); let def = paths[relativePath]; if (endpoint.enabled === false) { continue; } if (!def) { paths[relativePath] = def = {}; } const pathID = endpoint.method.toLowerCase(); const addConsumes = [ 'post', 'put', 'patch', 'options' ].includes(pathID); def[pathID] = compact({ summary: endpoint.description, operationId: getOperationId(endpoint), deprecated: endpoint.deprecated, parameters: translateParameters(endpoint, apiBuilder.logger), responses: generateResponses( apiBuilder, endpoint, lookupDefinitions ), tags: [ groupName ], produces: [ 'application/json', 'application/xml', 'text/yaml', 'text/csv', 'text/plain' ], ...addConsumes ? { consumes: [ 'application/json', 'application/x-www-form-urlencoded', 'multipart/form-data' ] } : {} }); } } } return { paths: paths }; } function translatePath(path) { return path.replace(/:([^/]+)\?/g, '{$1}'); } function translateParameters(endpoint, logger) { const retVal = []; let bodyParams; for (const name in endpoint.parameters) { if (!endpoint.parameters.hasOwnProperty(name)) { continue; } const param = endpoint.parameters[name]; if (param.type === 'body') { if (!bodyParams) { bodyParams = { name: endpoint.nickname, in: 'body', description: endpoint.nickname + ' body', required: true, schema: { type: 'object', required: [], properties: {} } }; } if (param.required) { bodyParams.schema.required.push(name); } bodyParams.schema.properties[name] = transformAPIBuilderProperty(param); continue; } if (param.type === 'form' && bodyParams) { // We can't define both body and form params; there can be only one body per endpoint. if (param.required) { bodyParams.schema.required.push(name); } bodyParams.schema.properties[name] = transformAPIBuilderProperty(param); continue; } // Force form parameters to be required (as required by the Swagger spec). if (param.type === 'path' && !param.required) { logger.warn(`OpenAPI does not support optional path parameters. Parameter ${name} for ${endpoint.method} ${endpoint.path} will be documented as required`); param.required = true; } const translated = { name: name, in: param.type, description: param.description, required: !!param.required, type: param.dataType || 'string' }; // TODO: We need more information about the sub-types of objects and arrays. if (param.dataType === 'object') { translated.type = 'string'; } if (param.dataType === 'array') { translated.items = { type: 'object' }; } if (param.dataType === 'date') { translated.type = 'string'; } retVal.push(compact(translated)); } if (bodyParams) { if (bodyParams.schema.required.length === 0) { delete bodyParams.schema.required; } retVal.push(bodyParams); } return retVal; } function transformAPIBuilderProperty(apibuilderProperty) { var dataType = apibuilderProperty.dataType || 'string'; var swaggerProperty = { type: dataType, description: apibuilderProperty.description }; switch (dataType) { case 'date': swaggerProperty.type = 'string'; break; case 'array': { swaggerProperty.items = { type: 'string' }; break; } } return swaggerProperty; } function getOperationId(endpoint) { let retVal = verbMap[endpoint.method] || endpoint.method.toLowerCase(); const splits = endpoint.path.replace(/_[a-z]/ig, (val) => { return val[1].toUpperCase(); }) .slice(1).split('/'); for (var i = 1; i < splits.length; i++) { var split = splits[i]; if (split[0] === ':') { retVal += 'By' + split.slice(1).toUpperCase(); } else if (split[0] !== undefined) { retVal += split[0].toUpperCase() + split.slice(1); } } return retVal; } function compact(obj) { return _.omitBy(obj, function (val, key) { if (Array.isArray(val)) { return val.length === 0; } if (_.isObject(val)) { obj[key] = compact(val); if (Object.keys(obj[key]).length === 0) { return false; } } return !val; }); }