1
0
Fork 0
arangodb/js/server/modules/@arangodb/foxx/router/swagger-context.js

584 lines
15 KiB
JavaScript

'use strict';
////////////////////////////////////////////////////////////////////////////////
/// DISCLAIMER
///
/// Copyright 2015-2016 ArangoDB GmbH, Cologne, Germany
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
/// Copyright holder is ArangoDB GmbH, Cologne, Germany
///
/// @author Alan Plum
////////////////////////////////////////////////////////////////////////////////
const _ = require('lodash');
const joi = require('joi');
const assert = require('assert');
const statuses = require('statuses');
const mimeTypes = require('mime-types');
const mediaTyper = require('media-typer');
const joiToJsonSchema = require('joi-to-json-schema');
const il = require('@arangodb/util').inline;
const tokenize = require('@arangodb/foxx/router/tokenize');
const MIME_JSON = 'application/json; charset=utf-8';
const MIME_BINARY = 'application/octet-stream';
const DEFAULT_ERROR_SCHEMA = joi.object().keys({
error: joi.allow(true).required(),
errorNum: joi.number().integer().optional(),
errorMessage: joi.string().optional(),
code: joi.number().integer().optional()
});
module.exports = exports = class SwaggerContext {
constructor(path) {
if (!path) {
path = '';
}
{
const n = path.length - 1;
if (path.charAt(n) === '/') {
path = path.slice(0, n);
}
if (path.charAt(0) !== '/') {
path = `/${path}`;
}
}
this._headers = new Map();
this._queryParams = new Map();
this._bodyParam = null;
this._responses = new Map();
this._summary = null;
this._description = null;
this._deprecated = false;
this.path = path;
this._pathParams = new Map();
this._pathParamNames = [];
this._pathTokens = tokenize(path, this);
}
header(name, schema, description) {
if (typeof schema === 'string') {
description = schema;
schema = undefined;
}
this._headers.set(name, {schema, description});
return this;
}
pathParam(name, schema, description) {
if (typeof schema === 'string') {
description = schema;
schema = undefined;
}
this._pathParams.set(name, {schema, description});
return this;
}
queryParam(name, schema, description) {
if (typeof schema === 'string') {
description = schema;
schema = undefined;
}
this._queryParams.set(name, {schema, description});
return this;
}
body(model, mimes, description) {
if (
typeof model === 'string'
|| (Array.isArray(model) && typeof model[0] === 'string')
) {
description = mimes;
mimes = model;
model = undefined;
}
if (typeof mimes === 'string') {
description = mimes;
mimes = undefined;
}
let multiple = false;
if (Array.isArray(model)) {
if (model.length !== 1) {
throw new Error(il`
Model must be a model or schema
or an array containing exactly one model or schema.
If you are trying to use multiple schemas
try using joi.alternatives instead.
`);
}
model = model[0];
multiple = true;
}
if (model === null && !mimes) {
this._bodyParam = {
model: null,
multiple,
contentTypes: null,
description
};
return;
}
if (!mimes) {
mimes = [];
}
if (!mimes.length && model) {
mimes.push(MIME_JSON);
}
const contentTypes = mimes.map((mime) => {
if (mime === 'binary') {
mime = MIME_BINARY;
}
const contentType = mimeTypes.contentType(mime) || mime;
const parsed = mediaTyper.parse(contentType);
return mediaTyper.format(_.pick(parsed, [
'type',
'subtype',
'suffix'
]));
});
if (model) {
if (model.isJoi) {
model = {schema: model};
} else {
model = _.clone(model);
}
if (model.schema && !model.schema.isJoi) {
model.schema = joi.object(model.schema).required();
}
if (!model.forClient && typeof model.toClient === 'function') {
console.warn(il`
Found unexpected "toClient" method on request body model.
Did you mean "forClient"?
`);
}
assert(!model.forClient || typeof model.forClient === 'function', il`
Request body model forClient handler must be a function,
not ${typeof model.forClient}
`);
assert(!model.fromClient || typeof model.fromClient === 'function', il`
Request body model fromClient handler must be a function,
not ${typeof model.fromClient}
`);
}
this._bodyParam = {
model,
multiple,
contentTypes,
description
};
return this;
}
response(status, model, mimes, reason) {
let statusCode = Number(status);
if (!status || Number.isNaN(statusCode)) {
reason = mimes;
mimes = model;
model = status;
statusCode = model === null ? 204 : 200;
}
if (
typeof model === 'string'
|| (Array.isArray(model) && typeof model[0] === 'string')
) {
reason = mimes;
mimes = model;
model = undefined;
}
if (typeof mimes === 'string') {
reason = mimes;
mimes = undefined;
}
let multiple = false;
if (Array.isArray(model)) {
if (model.length !== 1) {
throw new Error(il`
Model must be a model or schema
or an array containing exactly one model or schema.
If you are trying to use multiple schemas
try using joi.alternatives instead.
`);
}
model = model[0];
multiple = true;
}
if (!mimes) {
mimes = [];
}
if (!mimes.length && model) {
mimes.push(MIME_JSON);
}
const contentTypes = mimes.map((mime) => {
if (mime === 'binary') {
mime = MIME_BINARY;
}
const contentType = mimeTypes.contentType(mime) || mime;
const parsed = mediaTyper.parse(contentType);
return mediaTyper.format(_.pick(parsed, [
'type',
'subtype',
'suffix'
]));
});
if (model) {
if (model.isJoi) {
model = {schema: model};
} else {
model = _.clone(model);
}
if (model.schema && !model.schema.isJoi) {
model.schema = joi.object(model.schema).required();
}
if (!model.forClient && typeof model.toClient === 'function') {
console.warn(il`
Found unexpected "toClient" method on response body model at ${statusCode}.
Did you mean "forClient"?
`);
}
assert(!model.forClient || typeof model.forClient === 'function', il`
Response body model forClient handler at ${statusCode} must be a function,
not ${typeof model.forClient}
`);
assert(!model.fromClient || typeof model.fromClient === 'function', il`
Response body model fromClient handler at ${statusCode} must be a function,
not ${typeof model.fromClient}
`);
}
this._responses.set(statusCode, {
model,
multiple,
contentTypes,
description: reason
});
return this;
}
error(status, reason) {
if (typeof status === 'string') {
status = statuses(status);
}
return this.response(
status,
DEFAULT_ERROR_SCHEMA,
null,
reason
);
}
summary(text) {
this._summary = text;
return this;
}
description(text) {
this._description = text;
return this;
}
deprecated(flag) {
this._deprecated = typeof flag === 'boolean' ? flag : true;
return this;
}
_merge(swaggerObj, pathOnly) {
if (!pathOnly) {
for (const header of swaggerObj._headers.entries()) {
this._headers.set(header[0], header[1]);
}
for (const queryParam of swaggerObj._queryParams.entries()) {
this._queryParams.set(queryParam[0], queryParam[1]);
}
for (const response of swaggerObj._responses.entries()) {
this._responses.set(response[0], response[1]);
}
if (!this._bodyParam && swaggerObj._bodyParam) {
this._bodyParam = swaggerObj._bodyParam;
}
this._deprecated = swaggerObj._deprecated || this._deprecated;
this._description = swaggerObj._description || this._description;
this._summary = swaggerObj._summary || this._summary;
}
if (this.path.charAt(this.path.length - 1) === '*') {
this.path = this.path.slice(0, this.path.length - 1);
}
if (this.path.charAt(this.path.length - 1) === '/') {
this.path = this.path.slice(0, this.path.length - 1);
}
this._pathTokens.pop();
this._pathTokens = this._pathTokens.concat(swaggerObj._pathTokens);
for (const pathParam of swaggerObj._pathParams.entries()) {
let name = pathParam[0];
const def = pathParam[1];
if (this._pathParams.has(name)) {
let baseName = name;
let i = 2;
const match = name.match(/(^.+)([0-9]+)$/i);
if (match) {
baseName = match[1];
i = Number(match[2]);
}
while (this._pathParams.has(baseName + i)) {
i++;
}
name = baseName + i;
}
this._pathParams.set(name, def);
this._pathParamNames.push(name);
}
this.path = tokenize.reverse(this._pathTokens, this._pathParamNames);
}
_buildOperation() {
const operation = {
produces: [],
parameters: []
};
if (this._deprecated) {
operation.deprecated = this._deprecated;
}
if (this._description) {
operation.description = this._description;
}
if (this._summary) {
operation.summary = this._summary;
}
if (this._bodyParam) {
operation.consumes = (
this._bodyParam.contentTypes
? this._bodyParam.contentTypes.slice()
: []
);
}
for (const response of this._responses.values()) {
if (!response.contentTypes) {
continue;
}
for (const contentType of response.contentTypes) {
if (operation.produces.indexOf(contentType) === -1) {
operation.produces.push(contentType);
}
}
}
if (operation.produces.indexOf('application/json') === -1) {
// ArangoDB errors use 'application/json'
operation.produces.push('application/json');
}
for (const param of this._pathParams.entries()) {
const name = param[0];
const def = param[1];
const parameter = (
def.schema
? swaggerifyParam(def.schema.isJoi ? def.schema : joi.object(def.schema))
: {type: 'string'}
);
parameter.name = name;
parameter.in = 'path';
parameter.required = true;
if (def.description) {
parameter.description = def.description;
}
operation.parameters.push(parameter);
}
for (const param of this._queryParams.entries()) {
const name = param[0];
const def = param[1];
const parameter = (
def.schema
? swaggerifyParam(def.schema.isJoi ? def.schema : joi.object(def.schema))
: {type: 'string'}
);
parameter.name = name;
parameter.in = 'query';
if (def.description) {
parameter.description = def.description;
}
operation.parameters.push(parameter);
}
for (const param of this._headers.entries()) {
const name = param[0];
const def = param[1];
const parameter = (
def.schema
? swaggerifyParam(def.schema.isJoi ? def.schema : joi.object(def.schema))
: {type: 'string'}
);
parameter.name = name;
parameter.in = 'header';
if (def.description) {
parameter.description = def.description;
}
operation.parameters.push(parameter);
}
if (this._bodyParam && this._bodyParam.contentTypes) {
const def = this._bodyParam;
const schema = (
def.model
? (def.model.isJoi ? def.model : def.model.schema)
: null
);
const parameter = (
schema
? swaggerifyBody(schema, def.multiple)
: {schema: {type: 'string'}}
);
parameter.name = 'body';
parameter.in = 'body';
if (def.description) {
parameter.description = def.description;
}
operation.parameters.push(parameter);
}
operation.responses = {
default: {
description: 'Unexpected error.',
schema: joi2schema(DEFAULT_ERROR_SCHEMA)
}
};
for (const entry of this._responses.entries()) {
const code = entry[0];
const def = entry[1];
const schema = (
def.model
? (def.model.isJoi ? def.model : def.model.schema)
: null
);
const response = {};
if (def.contentTypes && def.contentTypes.length) {
response.schema = (
schema
? joi2schema(schema.isJoi ? schema : joi.object(schema), def.multiple)
: {type: 'string'}
);
}
if (schema && schema._description) {
response.description = schema._description;
delete response.schema.description;
}
if (def.description) {
response.description = def.description;
}
if (!response.description) {
const message = statuses[code];
response.description = message ? `HTTP ${code} ${message}.` : (
response.schema
? `Nondescript ${code} response.`
: `Nondescript ${code} response without body.`
);
}
operation.responses[code] = response;
}
return operation;
}
};
function swaggerifyType(joi) {
switch (joi._type) {
default:
return ['string'];
case 'binary':
return ['string', 'binary'];
case 'boolean':
return ['boolean'];
case 'date':
return ['string', 'date-time'];
case 'func':
return ['string'];
case 'number':
if (joi._tests.some((test) => test.name === 'integer')) {
return ['integer'];
}
return ['number'];
case 'array':
return ['array'];
case 'object':
return ['object'];
case 'string':
if (joi._meta.some((meta) => meta.secret)) {
return ['string', 'password'];
}
return ['string'];
}
}
function swaggerifyParam(joi) {
const param = {
required: joi._presence === 'required',
description: joi._description || undefined
};
let item = param;
if (joi._meta.some((meta) => meta.allowMultiple)) {
param.type = 'array';
param.collectionFormat = 'multi';
param.items = {};
item = param.items;
}
const type = swaggerifyType(joi);
item.type = type[0];
if (type.length > 1) {
item.format = type[1];
}
if (joi._valids._set && joi._valids._set.length) {
item.enum = joi._valids._set;
}
if (joi._flags.hasOwnProperty('default')) {
item.default = joi._flags.default;
}
return param;
}
function swaggerifyBody(joi, multiple) {
return {
required: joi._presence === 'required',
description: joi._description || undefined,
schema: joi2schema(joi, multiple)
};
}
function joi2schema(schema, multiple) {
if (multiple) {
schema = joi.array().items(schema);
}
return joiToJsonSchema(schema);
}