UNPKG

graphql2rest

Version:

GraphQL to REST converter: automatically generate a RESTful API from your existing GraphQL API

428 lines (371 loc) 17.8 kB
const express = require('express'); const formatPath = require('url-join'); const pickDeep = require('lodash-pickdeep'); const cloneDeep = require('lodash.clonedeep'); const omit = require('lodash.omit'); const httpStatuses = require('http-status-codes'); const objectPath = require('object-path'); const dot = require('dot-object'); const mingo = require('mingo'); const jmespath = require('jmespath'); const gql = require('graphql-tag'); const { readDefaultsConfigFile, validateConfig, loadDependencies } = require('./setup'); const { stripResponseData, defaultFormatErrorFn } = require('./formatters'); const { initLogger, log, debug, error } = require('./logging.js'); const { pretty, isArrayWithEmptyObjs } = require('./common.js'); const errorHandling = require('./errorHandling'); const consts = require('./consts.js'); const expressRouter = express.Router(); /* * * * * Globals * * * */ // execution and parsing functions const funcs = { formatErrorFn: defaultFormatErrorFn, formatDataFn: stripResponseData }; let config; // global configuration object let errorMap; // maps GraphQL error codes to HTTP status codes let errHandler; // object with API error handling functions let middlewares; // optional user-provided .js module with middleware functions /* * * * * * * * * * * * * * Public Functions * * * * * * * * * * * * * */ /** * Initialize the GraphQL2REST wrapper. Reads the manifest file and maps GraphQL operations to REST endpoints. * * @param {Object} schemaObj - GraphQL schema object (of type GraphQLSchema) * @param {function} executeFn - GraphQL execution function; receives {query, variables, context} and executes the GraphQL operation it describes * @param {Object} options - Options object which describes various optional settings. If undefined, init will fallback to default definitions. * @param {function} [formatErrorFn] - Optional function to custom format the returned GraphQL error string, upon error * @param {function} [formatDataFn] - Optional function to custom format the returned GraphQL response data (if not provided, strips encapsulating data object and removes errors) * @param {function} [router] - Optional express.Router() instance to attach new routes to (if not provided, a new Express instance will be returned) * * @returns {Router} An instance of express.Router() function with new REST routes. Returns null if initialization failed. * * options object may define fields such as: apiPrefix, manifestFile, filterFieldsName, logger, gqlGeneratorOutputFolder and more. * If not provided or partially defined, defaults.json file will be used to populate missing fields. * * Any (optional) paths in the options object are relative to this source file. Use path.resolve() for different relative root. * * @example * const restRouter = init(schema, apolloFetchExecute, { apiPrefix: '/api/v1' }); * * @example * const restRouter = init( * schema, * execute, * { apiPrefix: '/api/v3', gqlGeneratorOutputFolder: './gql-output/', manifestFile: './manifest.json' }); **/ const init = ( schemaObj, executeFn, options, formatErrorFn = funcs.formatErrorFn, formatDataFn = funcs.formatDataFn, router = expressRouter ) => { initLogger(options && options.logger); const configFileObj = readDefaultsConfigFile(); if (!configFileObj) { if (!options) { error('init: error reading config file and no options object provided. Aborting initialization.'); return null; } } config = { ...configFileObj, ...options }; // options override configFileObj defaults, should always come last // populate global custom functions object funcs.formatErrorFn = formatErrorFn; funcs.formatDataFn = formatDataFn; funcs.executeFn = executeFn; if (!validateConfig(config, funcs)) return null; const { manifest, queryStrings, middlewaresModule } = loadDependencies(config); if (!manifest || !queryStrings) return null; if (!schemaObj) { error('init: schema object is required but missing. Aborting initialization.'); return null; } if (!manifest.endpoints) { error('init: cannot find "endpoints" object in manifest json file.'); return null; } if (middlewaresModule === null) { error('init: optional middlewaresFile was specified but cannot be loaded.'); return null; } const legend = manifest.endpoints; errorMap = manifest.errors; config.queryStrings = queryStrings; middlewares = middlewaresModule; errHandler = errorHandling(errorMap, config); Object.keys(legend).forEach(apipath => parseManifestEndpoints(legend[apipath], apipath, router)); return router; }; /* * * * * * * * * * * * * * Private Functions * * * * * * * * * * * * * */ /* Replaces original parameter names with custom parameter names */ const replaceParams = (queryString, params) => { let modifiedStr = queryString; Object.keys(params).forEach((key) => { /* eslint-disable prefer-template */ const replacement = '\\$' + key; modifiedStr = modifiedStr.replace(new RegExp(replacement, 'g'), '$' + params[key]); }); return modifiedStr; /* eslint-enable prefer-template */ }; /* Deletes REST parameters marked for deletion, so that they will not be sent by the GraphQL layer (ignored) */ const omitDeletedParams = (paramsToDelete, requestParameters) => { Object.keys(paramsToDelete).forEach((key) => { if (paramsToDelete[key] === '__DELETED__') { objectPath.del(requestParameters, key); log(`Parameter ${key} was marked for deletion and removed from the call to GraphQL operation.`); } }); }; /* Verifies that operations referred to in the manifest actually exist in the GraphQL schema */ const validateGqlOperations = ({ operations, verb, route }) => { const { queryStrings } = config; for (const item of operations) { const { operation } = item; const queryString = (queryStrings.queries && queryStrings.queries[operation]) || (queryStrings.mutations && queryStrings.mutations[operation]); if (!queryString) { error(`GraphQL operation ${operation} is not found - cannot add endpoint ${verb} for ${route}. Skipping this route.`); return false; } if (item.requestMiddlewareFunction) { if (!middlewares || !middlewares[item.requestMiddlewareFunction]) { error(`Middleware function ${item.requestMiddlewareFunction} is specified for endpoint ${verb} ${route} but cannot be found in middlewares file. Skipping this route.`); return false; } if (typeof middlewares[item.requestMiddlewareFunction] !== 'function') { error(`Middleware ${item.requestMiddlewareFunction} provided for ${verb} ${route} is not a function. Skipping this route.`); return false; } } } return true; }; /* Remove user specified fields from final response (as indicated in the manifest) */ const hideFields = (response, hiddenFieldsArray) => { if (!hiddenFieldsArray || !Array.isArray(hiddenFieldsArray) || hiddenFieldsArray.length === 0) return response; log(`Hiding requested fields from response. Removed the following fields from response: ${hiddenFieldsArray}`); if (typeof response === 'object' && (!Array.isArray(response))) return omit(response, hiddenFieldsArray); // else response is an array. if it has objects, remove the fields from all those objects: return response.map((element) => { if (typeof element === 'object') { return omit(element, hiddenFieldsArray); } return element; }); }; /* Filters formatted response based on request query parameter */ const filterResponse = (response, request, filterFieldName) => { if (!request.query || !request.query[filterFieldName]) return response; const filterQuery = request.query[filterFieldName]; if (filterQuery.trim().startsWith(':')) { response = applyJmesPathFilter(response, filterQuery.trim().substring(1)); } else { const responseFilter = filterQuery.split(',').map(field => field.trim()); if (responseFilter && responseFilter.length > 0 && response && Object.keys(response).length > 0) { if (typeof response === 'object' && (!Array.isArray(response))) { response = pickDeep(response, responseFilter); } else { // response is an array. if it has objects, filter on the fields from all those objects: response = response.map((element) => { if (typeof element === 'object') { return pickDeep(element, responseFilter); } return element; }); if (isArrayWithEmptyObjs(response)) response = []; } debug('Response filtered on: ', responseFilter); } } return response; }; /* Applies JMESPath filter (www.jmespath.org) on JSON response */ const applyJmesPathFilter = (response, jmespathExpression) => { const emptyResponse = Array.isArray(response) ? [] : {}; // JMESPath may return null, but that is not valid JSON try { response = jmespath.search(response, jmespathExpression) || emptyResponse; debug('Response filtered on JMESPath expression: ', jmespathExpression); } catch (e) { response = emptyResponse; error(`JMESPath filter failed parsing expression: "${jmespathExpression}. Returning empty object or array."`); error(e); } return response; }; /* Copies provided body, query, params into the request. Used to reset request between action iterations */ const resetRequestChanges = ({ req, body, query, params }) => { req.body = cloneDeep(body); req.query = cloneDeep(query); req.params = cloneDeep(params); return req; }; /* Wraps request body object with a custom property. Supports nested properties expressed as dot separated strings */ const wrapRequestBodyWithProperty = (request, property) => { if (property === undefined || property === null || String(property).trim().length === 0) return request; if (!request || !request.body) return request; debug(`Wrapping request body with property "${property}" before sending to GraphQL execute function.`); property = String(property).trim(); const newRequestBody = {}; dot.str(property, request.body, newRequestBody); // data.text.moreText => "data: { text: { moreText: { body } } } }" request.body = newRequestBody; return request; }; /* Generates REST endpoint and adds it to Express router */ const addRestEndpoint = ({ action, router }) => { try { const route = formatPath(config.apiPrefix, action.path); const verbInCaps = action.verb.toUpperCase(); log(`==> Adding endpoint ${verbInCaps} ${route}`); if (!validateGqlOperations({ operations: action.operations, verb: verbInCaps, route })) return; router[action.verb](route, async (req, res) => { debug(`REST router was invoked: route ${verbInCaps} ${route}`); debug(`Actual path: ${req.path}`); const statusCode = (action.successStatusCode && Number.isInteger(action.successStatusCode)) ? action.successStatusCode : consts.SUCCESS_STATUS_CODE; let lastOperation = false; const originalBody = cloneDeep(req.body); const originalQuery = cloneDeep(req.query); const originalParams = cloneDeep(req.params); if (!req.body) log(`warning: req.body is undefined (for invoked REST call on ${route})`); let allParams; action.operations.forEach(async (operation) => { try { req = resetRequestChanges({ req, body: originalBody, query: originalQuery, params: originalParams }); if (operation.successStatusCode) debug('Warning: "successStatusCode" is an attribute of action, not operation, in the manifest. Detected "operation.successStatusCode" or "operations[].successStatusCode" field in the manifest - it will be ignored.'); const { queryStrings } = config; // Assuming uniqueness between queries and mutations (GraphQL spec doesn't allow two type definitions with the same name) let queryString = queryStrings.queries[operation.operation] || queryStrings.mutations[operation.operation]; if (operation.requestMiddlewareFunction) { req = middlewares[operation.requestMiddlewareFunction](req, route, verbInCaps, operation.operation); if (!req || typeof req !== 'object') throw new Error('Server Error: invalid request: request middleware malformed request.'); } if (operation.params && req.body) omitDeletedParams(operation.params, req.body); allParams = { ...req.params, ...req.query, ...req.body }; // order is important: body params override query params which override path params if (operation.params) { queryString = replaceParams(queryString, operation.params); } let isExecuteOperation = true; if (operation.condition) { isExecuteOperation = new mingo.Query(operation.condition).test(allParams); if (!isExecuteOperation) debug(`Action did not satisfy condition test: ${pretty(operation.condition)} so not invoking operation ${operation.operation}`); // if last operation send()/return } if (isExecuteOperation && operation.wrapRequestBodyWith) { req = wrapRequestBodyWithProperty(req, operation.wrapRequestBodyWith); allParams = { ...req.params, ...req.query, ...req.body }; // refresh with body change } if (isExecuteOperation) { await executeOperation({ req, res, queryString, allParams, statusCode, hiddenFields: operation.hide, operationName: operation.operation }); } } catch (e) { // some unhandled error within forEach loop occurred, error response cannot be sent error('Encountered error in execution chain:'); error(e); } }); // end forEach }); // end express callback (async (req, res)) } catch (e) { // express or other error error(e); } }; /* Operation invoker, called by Express callback function created by addRestEndpoint() */ const executeOperation = async ({ req, res, queryString, allParams, statusCode, hiddenFields, operationName }) => { let response; let isErrorResponse = false; debug(`Executing "${queryString.substring(0, 100).replace(/(\r\n|\n|\r)/gm, '')}..." with parameters:`); debug(pretty(allParams)); try { response = await funcs.executeFn({ query: gql(queryString), variables: allParams, context: { headers: req.headers, restRequest: req }, operationName }); debug('[Original (unformatted and unfiltered) response from GraphQL]:'); debug(pretty(response)); if (response === undefined) throw new Error('Server Error: response is undefined'); if (typeof response !== 'object') throw new Error('Server Error: response is not an object'); if (errHandler.isError(response)) { debug('GraphQL response contains error.'); statusCode = errHandler.getErrorCode(response) || consts.CLIENT_ERROR_CODE; isErrorResponse = true; response = errHandler.attachCustomErrorDescription(response); } } catch (e) { // Workaround for apollo-link which may throw legitimate client errors as exceptions if (e.result && e.result.errors && Array.isArray(e.result.errors)) { response = { errors: e.result.errors }; debug('GraphQL response contains error.'); statusCode = errHandler.getErrorCode(response) || consts.CLIENT_ERROR_CODE; isErrorResponse = true; response = errHandler.attachCustomErrorDescription(response); } else { // GraphQL server error error(e); statusCode = errHandler.getErrorCode(response) || consts.SERVER_ERROR_CODE; if (Object.values(httpStatuses).includes(statusCode)) res.status(statusCode); else res.status(consts.SERVER_ERROR_CODE); res.send(consts.INTERNAL_SERVER_ERROR_FORMATTED); return; } } // If we reached here there is no server error (only valid GraphQL response) if (!Object.values(httpStatuses).includes(statusCode)) statusCode = consts.SUCCESS_STATUS_CODE; res.status(statusCode); if (!isErrorResponse) { response = funcs.formatDataFn(response); response = hideFields(response, hiddenFields); response = filterResponse(response, req, config.filterFieldName); } else { delete response.data; response = funcs.formatErrorFn(response, statusCode); } debug(`Returning HTTP status code ${statusCode}`); res.send(response); }; /* Parses and normalizes endpoints and associated actions from manifest file */ const parseManifestEndpoints = (endpointObj, apipath, router) => { if (!endpointObj || !apipath) return; apipath = apipath.trim(); consts.HTTP_VERBS.forEach((httpMethod) => { if (endpointObj[httpMethod]) { let action = endpointObj[httpMethod]; if (!action.operations && !action.operation) return; if (action.operations && action.operation) { error(`manifest file: endpoint action has both "operation" (string) and "operations" (array) fields - only one is allowed.\nSkipping endpoint ${httpMethod.toUpperCase()} ${apipath}`); return; } if (action.operation) { // single operation mode detected - convert it to array ("multi-operation" mode) action.operations = []; action.operations.push({ operation: action.operation, params: action.params, condition: action.condition, onFail: action.onFail, hide: action.hide, wrapRequestBodyWith: action.wrapRequestBodyWith, requestMiddlewareFunction: action.requestMiddlewareFunction }); action = omit(action, ['operation', 'params', 'condition', 'onFail', 'hide', 'wrapRequestBodyWith', 'requestMiddlewareFunction']); } else if (!Array.isArray(action.operations) || action.operations.length === 0 || !action.operations[0].operation) { return; } action.verb = httpMethod; action.path = apipath; addRestEndpoint({ action, router }); } }); }; module.exports = { init };