route-helper.js 11.9 KB
// Copyright IBM Corp. 2015,2016. All Rights Reserved.
// Node module: loopback-swagger
// This file is licensed under the MIT License.
// License text available at https://opensource.org/licenses/MIT

'use strict';

// Globalization
var g = require('strong-globalize')();

/**
 * Module dependencies.
 */

var debug = require('debug')('loopback:explorer:routeHelpers');
var _assign = require('lodash').assign;
var typeConverter = require('./type-converter');
var schemaBuilder = require('./schema-builder');

/**
 * Export the routeHelper singleton.
 */
var routeHelper = module.exports = {
  /**
   * Given a route, generate an API description and add it to the doc.
   * If a route shares a path with another route (same path, different verb),
   * add it as a new operation under that path entry.
   *
   * Routes can be translated to API declaration 'operations',
   * but they need a little massaging first. The `accepts` and
   * `returns` declarations need some basic conversions to be compatible.
   *
   * This method will convert the route and add it to the doc.
   *
   * @param  {Route} route    Strong Remoting Route object.
   * @param  {Class} classDef Strong Remoting class.
   * @param  {TypeRegistry} typeRegistry Registry of types and models.
   * @param  {Object} operationIdRegistry Registry of operationIds mapping
   *  operationId to an operation object.
   * @param  {Object} paths   Swagger Path Object,
   *   see https://github.com/swagger-api/swagger-spec/blob/master/versions/2.0.md#pathsObject
   */
  addRouteToSwaggerPaths: function(route, classDef, typeRegistry,
                                   operationIdRegistry, paths) {
    var entryToAdd = routeHelper.routeToPathEntry(route, classDef,
                                                  typeRegistry,
                                                  operationIdRegistry);
    if (!(entryToAdd.path in paths)) {
      paths[entryToAdd.path] = {};
    }
    paths[entryToAdd.path][entryToAdd.method] = entryToAdd.operation;
  },

  /**
   * Massage route.accepts.
   * @param  {Object} route    Strong Remoting Route object.
   * @param  {Class}  classDef Strong Remoting class.
   * @param  {TypeRegistry} typeRegistry Registry of types and models.
   * @return {Array}           Array of param docs.
   */
  convertAcceptsToSwagger: function(route, classDef, typeRegistry) {
    var accepts = route.accepts || [];
    var split = route.method.split('.');
    if (classDef && classDef.sharedCtor &&
        classDef.sharedCtor.accepts && split.length > 2 /* HACK */) {
      accepts = accepts.concat(classDef.sharedCtor.accepts);
    }

    // Filter out parameters that are generated from the incoming request,
    // or generated by functions that use those resources.
    accepts = accepts.filter(function(arg) {
      if (!arg.http) return true;
      // Don't show derived arguments.
      if (typeof arg.http === 'function') return false;
      // Don't show arguments set to the incoming http request.
      // Please note that body needs to be shown, such as User.create().
      if (arg.http.source === 'req' ||
        arg.http.source === 'res' ||
        arg.http.source === 'context') {
        return false;
      }
      return true;
    });

    // Turn accept definitions in to parameter docs.
    accepts = accepts.map(
      routeHelper.acceptToParameter(route, classDef, typeRegistry));

    return accepts;
  },

  /**
   * Massage route.returns.
   * @param  {Object} route    Strong Remoting Route object.
   * @return {Object}          A single returns param doc.
   */
  convertReturnsToSwagger: function(route, typeRegistry) {
    var routeReturns = route.returns;
    if (!routeReturns || !routeReturns.length) {
      // An operation that returns nothing will have
      // no schema declaration for its response.
      return undefined;
    }

    if (routeReturns.length === 1 && routeReturns[0].root) {
      if (routeReturns[0].model) {
        return { $ref: typeRegistry.reference(routeReturns[0].model) };
      }
      return schemaBuilder.buildFromLoopBackType(routeReturns[0], typeRegistry);
    } else if (routeReturns.length === 1 && routeReturns[0].type === 'ReadableStream') {
      return { type: 'file' };
    }

    // Construct scheme for the return object
    var schema = { type: 'object' };
    schema.properties = {};
    routeReturns.forEach(function(ret) {
      var propName = ret.name || ret.arg;
      var idlType = schemaBuilder.getLdlTypeName(ret.type);
      // Take care of array which can be nested.
      // Note that we cannot simply use buildFromLoopBackType since it converts unknown type to
      // '$ref': '#/definitions/UnknownType', whereas we decided to emit 'type: object' for such a case.
      // See https://github.com/strongloop/loopback-swagger/pull/28#discussion_r54873911
      // The following code is needed to take care of a nested array of an unknown type.
      var itemIdlType = idlType;
      var genericIdlType = { type: 'object' };
      while (Array.isArray(itemIdlType)) {
        itemIdlType = schemaBuilder.getLdlTypeName(itemIdlType[0]);
        genericIdlType = { type: 'array', items: genericIdlType };
      }
      if (schemaBuilder.isPrimitiveType(itemIdlType)) {
        schema.properties[propName] =
          schemaBuilder.buildFromLoopBackType(ret, typeRegistry);
      } else {
        debug('Swagger: temporarily using `object` instead of unknown ' +
          'type %j found in route %j', itemIdlType, route);
        schema.properties[propName] = genericIdlType;
      }
      if (ret.required) {
        if (schema.required == null) {
          schema.required = [];
        }
        schema.required.push(propName);
      }
    });

    return schema;
  },

  /**
   * Converts from an sl-remoting-formatted "Route" description to a
   * Swagger-formatted "Path Item Object"
   * See swagger-spec/2.0.md#pathItemObject
   */
  routeToPathEntry: function(route, classDef,
                             typeRegistry, operationIdRegistry) {
    // Some parameters need to be altered; eventually most of this should
    // be removed.
    var accepts = routeHelper.convertAcceptsToSwagger(route, classDef,
                                                      typeRegistry);
    var returns = routeHelper.convertReturnsToSwagger(route, typeRegistry);
    var statusCode = route.returns && route.returns.length ? 200 : 204;

    if (route.http && route.http.status) {
      statusCode = route.http.status;
    }

    var responseMessages = {};
    responseMessages[statusCode] = {
      description: 'Request was successful',
      schema: returns,
      // TODO - headers, examples
    };

    if (route.errors) {
      // TODO define new LDL syntax that is status-code-indexed
      // and which allow users to specify headers & examples
      route.errors.forEach(function(msg) {
        var schema = null;
        if (msg.responseModel) {
          schema = schemaBuilder.buildFromLoopBackType(msg.responseModel,
                                                       typeRegistry);
        }
        responseMessages[msg.code] = {
          description: msg.message,
          schema: schema,
          // TODO - headers, examples
        };
      });
    }

    if (route.http) {
      var errorStatus = route.http.errorStatus;
      if (!responseMessages[errorStatus]) {
        responseMessages[errorStatus] = {
          description: 'Unknown error',
          // TODO - headers, examples
        };
      }
    }

    debug('route %j', route);

    var path = routeHelper.convertPathFragments(route.path);
    var verb = routeHelper.convertVerb(route.verb);

    var tags = [];
    if (classDef && classDef.name) {
      tags.push(classDef.name);
    }

    var operationId = createUniqueOperationId(route.method, verb, path,
                                              operationIdRegistry);
    var entry = {
      path: path,
      method: verb,
      operation: {
        tags: tags,
        summary: typeConverter.convertText(route.description),
        description: typeConverter.convertText(route.notes),
        operationId: operationId,
        // [bajtos] we are omitting consumes and produces, as they are same
        // for all methods and they are already specified in top-level fields
        parameters: accepts,
        responses: responseMessages,
        deprecated: !!route.deprecated,
        // TODO: security
      },
    };

    operationIdRegistry[operationId] = entry;

    return entry;
  },

  convertPathFragments: function convertPathFragments(path) {
    return path.split('/').map(function(fragment) {
      if (fragment.charAt(0) === ':') {
        return '{' + fragment.slice(1) + '}';
      }
      return fragment;
    }).join('/');
  },

  convertVerb: function convertVerb(verb) {
    if (verb.toLowerCase() === 'all') {
      return 'post';
    }

    if (verb.toLowerCase() === 'del') {
      return 'delete';
    }

    return verb.toLowerCase();
  },

  /**
   * A generator to convert from an sl-remoting-formatted "Accepts" description
   * to a Swagger-formatted "Parameter" description.
   */
  acceptToParameter: function acceptToParameter(route, classDef, typeRegistry) {
    var DEFAULT_TYPE =
      route.verb.toLowerCase() === 'get' ?  'query' : 'formData';

    return function(accepts) {
      var name = accepts.name || accepts.arg;
      var paramType = DEFAULT_TYPE;

      // TODO: Regex. This is leaky.
      if (route.path.indexOf(':' + name) !== -1) {
        paramType = 'path';
      }

      // Check the http settings for the argument
      if (accepts.http && accepts.http.source) {
        paramType = accepts.http.source === 'form' ?
          'formData' :
          accepts.http.source;
      }

      // TODO: ensure that paramType has a valid value
      //  path, query, header, body, formData
      // See swagger-spec/2.0.md#parameterObject

      var paramObject = {
        name: name,
        in: paramType,
        description: typeConverter.convertText(accepts.description),
        required: !!accepts.required,
      };

      var schema = schemaBuilder.buildFromLoopBackType(accepts, typeRegistry);
      if (paramType === 'body') {
        // HACK: Derive the type from model
        if (paramObject.name === 'data' && schema.type === 'object') {
          paramObject.schema = { $ref: typeRegistry.reference(classDef.name) };
        } else {
          paramObject.schema = schema;
        }
      } else {
        var isComplexType = schema.type === 'object' ||
                            schema.type === 'array' ||
                            schema.$ref;
        if (isComplexType) {
          paramObject.type = 'string';
          paramObject.format = 'JSON';
          // TODO support array of primitive types
          // and map them to Swagger array of primitive types
        } else {
          _assign(paramObject, schema);
        }
      }

      return paramObject;
    };
  },
};

function createUniqueOperationId(methodName, verb, path, operationIdRegistry) {
  // [bajtos] We used to remove leading model name from the operation
  // name for Swagger Spec 1.2. Swagger Spec 2.0 requires
  // operation ids to be unique, thus we have to include the model name.
  var id = methodName;

  if (!(id in operationIdRegistry)) {
    // The id is already unique
    return id;
  }

  var baseId = id;
  id = createLongOperationId(baseId, verb, path);
  if (id in operationIdRegistry) {
    g.warn('Warning: detected multiple remote methods ' +
      'at the same HTTP endpoint. ' +
      '{{Swagger operation ids}} will NOT be unique.');
  }

  // Rename the first operation so that all operation ids of
  // a multi-endpoint method are consistently using the long form
  if (operationIdRegistry[baseId]) {
    var oldEntry = operationIdRegistry[baseId];
    var newId = createLongOperationId(baseId, oldEntry.method, oldEntry.path);
    oldEntry.operation.operationId = newId;
    operationIdRegistry[newId] = oldEntry;
    operationIdRegistry[baseId] = null;
  }

  return id;
}

function createLongOperationId(baseId, verb, path) {
  return baseId + '__' + verb + path.replace(/[\/:]+/g, '_');
}