etherpad-lite/src/node/hooks/express/openapi.js

699 lines
19 KiB
JavaScript
Raw Normal View History

const OpenAPIBackend = require('openapi-backend').default;
const formidable = require('formidable');
const { promisify } = require('util');
const apiHandler = require('../../handler/APIHandler');
const settings = require('../../utils/Settings');
const log4js = require('log4js');
const apiLogger = log4js.getLogger('API');
// https://github.com/OAI/OpenAPI-Specification/tree/master/schemas/v3.0
const OPENAPI_VERSION = '3.0.2'; // Swagger/OAS version
2020-03-29 18:47:42 +02:00
const info = {
title: 'Etherpad API',
description:
'Etherpad is a real-time collaborative editor scalable to thousands of simultaneous real time users. It provides full data export capabilities, and runs on your server, under your control.',
termsOfService: 'https://etherpad.org/',
contact: {
name: 'The Etherpad Foundation',
url: 'https://etherpad.org/',
email: 'support@example.com',
},
license: {
name: 'Apache 2.0',
url: 'https://www.apache.org/licenses/LICENSE-2.0.html',
},
version: apiHandler.latestApiVersion,
};
const APIPathStyle = {
FLAT: 'api', // flat paths e.g. /api/createGroup
REST: 'rest', // restful paths e.g. /rest/group/create
};
function sessionListResponseProcessor(res) {
if (res.data) {
var sessions = [];
for (var sessionId in res.data) {
var sessionInfo = res.data[sessionId];
sessionId['id'] = sessionId;
sessions.push(sessionInfo);
}
res.data = sessions;
}
return res;
}
2020-03-29 18:47:42 +02:00
// API resources
// add your operations here
2020-03-29 18:47:42 +02:00
const resources = {
2020-03-29 16:39:45 +02:00
// Group
group: {
create: {
func: 'createGroup',
description: 'creates a new group',
response: { groupID: { type: 'string' } },
},
createIfNotExistsFor: {
func: 'createGroupIfNotExistsFor',
description: 'this functions helps you to map your application group ids to Etherpad group ids',
response: { groupID: { type: 'string' } },
},
delete: {
func: 'deleteGroup',
description: 'deletes a group',
},
listPads: {
func: 'listPads',
description: 'returns all pads of this group',
2020-03-29 18:47:42 +02:00
response: { padIDs: { type: 'array', items: { type: 'string' } } },
2020-03-29 16:39:45 +02:00
},
createPad: {
func: 'createGroupPad',
description: 'creates a new pad in this group',
},
listSessions: {
func: 'listSessionsOfGroup',
description: '',
2020-03-29 18:47:42 +02:00
response: { sessions: { type: 'array', items: { $ref: '#/components/schemas/SessionInfo' } } },
responseProcessor: sessionListResponseProcessor,
2020-03-29 16:39:45 +02:00
},
list: {
func: 'listAllGroups',
description: '',
2020-03-29 18:47:42 +02:00
response: { groupIDs: { type: 'array', items: { type: 'string' } } },
2020-03-29 16:39:45 +02:00
},
},
// Author
author: {
create: {
func: 'createAuthor',
description: 'creates a new author',
response: { authorID: { type: 'string' } },
},
createIfNotExistsFor: {
func: 'createAuthorIfNotExistsFor',
description: 'this functions helps you to map your application author ids to Etherpad author ids',
response: { authorID: { type: 'string' } },
},
listPads: {
func: 'listPadsOfAuthor',
description: 'returns an array of all pads this author contributed to',
2020-03-29 18:47:42 +02:00
response: { padIDs: { type: 'array', items: { type: 'string' } } },
2020-03-29 16:39:45 +02:00
},
listSessions: {
func: 'listSessionsOfAuthor',
description: 'returns all sessions of an author',
2020-03-29 18:47:42 +02:00
response: { sessions: { type: 'array', items: { $ref: '#/components/schemas/SessionInfo' } } },
responseProcessor: sessionListResponseProcessor,
2020-03-29 16:39:45 +02:00
},
// We need an operation that return a UserInfo so it can be picked up by the codegen :(
getName: {
func: 'getAuthorName',
description: 'Returns the Author Name of the author',
responseProcessor: function(response) {
if (response.data) {
response['info'] = { name: response.data.authorName };
delete response['data'];
}
},
response: { info: { type: 'UserInfo' } },
2020-03-29 16:39:45 +02:00
},
},
// Session
session: {
create: {
func: 'createSession',
description: 'creates a new session. validUntil is an unix timestamp in seconds',
response: { sessionID: { type: 'string' } },
},
delete: {
func: 'deleteSession',
description: 'deletes a session',
},
// We need an operation that returns a SessionInfo so it can be picked up by the codegen :(
info: {
func: 'getSessionInfo',
description: 'returns informations about a session',
2020-03-29 18:47:42 +02:00
response: { info: { $ref: '#/components/schemas/SessionInfo' } },
2020-03-29 16:39:45 +02:00
},
},
// Pad
pad: {
listAll: {
func: 'listAllPads',
description: 'list all the pads',
2020-03-29 18:47:42 +02:00
response: { padIDs: { type: 'array', items: { type: 'string' } } },
2020-03-29 16:39:45 +02:00
},
createDiffHTML: {
func: 'createDiffHTML',
description: '',
response: {},
},
create: {
func: 'createPad',
description:
'creates a new (non-group) pad. Note that if you need to create a group Pad, you should call createGroupPad',
},
getText: {
func: 'getText',
description: 'returns the text of a pad',
response: { text: { type: 'string' } },
},
setText: {
func: 'setText',
description: 'sets the text of a pad',
},
getHTML: {
func: 'getHTML',
description: 'returns the text of a pad formatted as HTML',
response: { html: { type: 'string' } },
},
setHTML: {
func: 'setHTML',
description: 'sets the text of a pad with HTML',
},
getRevisionsCount: {
func: 'getRevisionsCount',
description: 'returns the number of revisions of this pad',
response: { revisions: { type: 'integer' } },
},
getLastEdited: {
func: 'getLastEdited',
description: 'returns the timestamp of the last revision of the pad',
response: { lastEdited: { type: 'integer' } },
},
delete: {
func: 'deletePad',
description: 'deletes a pad',
},
getReadOnlyID: {
func: 'getReadOnlyID',
description: 'returns the read only link of a pad',
response: { readOnlyID: { type: 'string' } },
},
setPublicStatus: {
func: 'setPublicStatus',
description: 'sets a boolean for the public status of a pad',
},
getPublicStatus: {
func: 'getPublicStatus',
description: 'return true of false',
response: { publicStatus: { type: 'boolean' } },
},
setPassword: {
func: 'setPassword',
description: 'returns ok or a error message',
},
isPasswordProtected: {
func: 'isPasswordProtected',
description: 'returns true or false',
response: { passwordProtection: { type: 'boolean' } },
},
authors: {
func: 'listAuthorsOfPad',
description: 'returns an array of authors who contributed to this pad',
2020-03-29 18:47:42 +02:00
response: { authorIDs: { type: 'array', items: { type: 'string' } } },
2020-03-29 16:39:45 +02:00
},
usersCount: {
func: 'padUsersCount',
description: 'returns the number of user that are currently editing this pad',
response: { padUsersCount: { type: 'integer' } },
},
users: {
func: 'padUsers',
description: 'returns the list of users that are currently editing this pad',
2020-03-29 18:47:42 +02:00
response: { padUsers: { type: 'array', items: { $ref: '#/components/schemas/UserInfo' } } },
2020-03-29 16:39:45 +02:00
},
sendClientsMessage: {
func: 'sendClientsMessage',
description: 'sends a custom message of type msg to the pad',
},
checkToken: {
func: 'checkToken',
description: 'returns ok when the current api token is valid',
},
getChatHistory: {
func: 'getChatHistory',
description: 'returns the chat history',
2020-03-29 18:47:42 +02:00
response: { messages: { type: 'array', items: { $ref: '#/components/schemas/Message' } } },
2020-03-29 16:39:45 +02:00
},
// We need an operation that returns a Message so it can be picked up by the codegen :(
getChatHead: {
func: 'getChatHead',
description: 'returns the chatHead (chat-message) of the pad',
responseProcessor: function(response) {
// move this to info
if (response.data) {
response['chatHead'] = { time: response.data['chatHead'] };
delete response['data'];
}
},
response: { chatHead: { type: 'Message' } },
2020-03-29 16:39:45 +02:00
},
appendChatMessage: {
func: 'appendChatMessage',
description: 'appends a chat message',
},
},
};
const defaultResponses = {
2020-03-29 18:47:42 +02:00
Success: {
description: 'ok (code 0)',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
code: {
type: 'integer',
example: 0,
},
message: {
type: 'string',
example: 'ok',
},
data: {
type: 'object',
example: null,
},
},
},
},
},
},
ApiError: {
description: 'generic api error (code 1)',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
code: {
type: 'integer',
example: 1,
},
message: {
type: 'string',
example: 'error message',
},
data: {
type: 'object',
example: null,
},
},
},
},
},
},
InternalError: {
description: 'internal api error (code 2)',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
code: {
type: 'integer',
example: 2,
},
message: {
type: 'string',
example: 'internal error',
},
data: {
type: 'object',
example: null,
},
},
},
},
},
},
NotFound: {
description: 'no such function (code 4)',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
code: {
type: 'integer',
example: 3,
},
message: {
type: 'string',
example: 'no such function',
},
data: {
type: 'object',
example: null,
},
},
},
},
},
},
Unauthorized: {
description: 'no or wrong API key (code 4)',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
code: {
type: 'integer',
example: 4,
},
message: {
type: 'string',
example: 'no or wrong API key',
},
data: {
type: 'object',
example: null,
},
},
},
},
},
},
};
const defaultResponseRefs = {
200: {
2020-03-29 18:47:42 +02:00
$ref: '#/components/responses/Success',
},
400: {
$ref: '#/components/responses/ApiError',
},
401: {
$ref: '#/components/responses/Unauthorized',
},
500: {
$ref: '#/components/responses/InternalError',
},
};
// convert to a flat list of OAS Operation objects
const operations = [];
const responseProcessors = {};
2020-03-29 18:47:42 +02:00
for (const resource in resources) {
for (const action in resources[resource]) {
const { func: operationId, description, response, responseProcessor } = resources[resource][action];
2020-03-29 18:47:42 +02:00
const responses = { ...defaultResponseRefs };
if (response) {
responses[200] = {
description: 'ok (code 0)',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
code: {
type: 'integer',
example: 0,
},
message: {
type: 'string',
example: 'ok',
},
data: {
type: 'object',
properties: response,
},
},
},
},
},
};
}
const operation = {
operationId,
summary: description,
2020-03-29 18:47:42 +02:00
responses,
tags: [resource],
_restPath: `/${resource}/${action}`,
_responseProcessor: responseProcessor,
};
operations[operationId] = operation;
}
}
const generateDefinitionForVersion = (version, style = APIPathStyle.FLAT) => {
const definition = {
openapi: OPENAPI_VERSION,
2020-03-29 18:47:42 +02:00
info,
paths: {},
components: {
parameters: {},
schemas: {
SessionInfo: {
type: 'object',
properties: {
id: {
type: 'string',
},
authorID: {
type: 'string',
},
groupID: {
type: 'string',
},
validUntil: {
type: 'integer',
},
},
},
UserInfo: {
type: 'object',
properties: {
id: {
type: 'string',
},
colorId: {
type: 'string',
},
name: {
type: 'string',
},
timestamp: {
type: 'integer',
},
},
},
Message: {
type: 'object',
properties: {
text: {
type: 'string',
},
userId: {
type: 'string',
},
userName: {
type: 'string',
},
time: {
type: 'integer',
},
},
},
},
2020-03-29 18:47:42 +02:00
responses: {
...defaultResponses,
},
securitySchemes: {
ApiKey: {
type: 'apiKey',
in: 'query',
name: 'apikey',
},
},
},
security: [{ ApiKey: [] }],
};
// build operations
for (const funcName in apiHandler.version[version]) {
let operation = {};
if (operations[funcName]) {
operation = { ...operations[funcName] };
} else {
// console.warn(`No operation found for function: ${funcName}`);
operation = {
operationId: funcName,
2020-03-29 18:47:42 +02:00
responses: defaultResponseRefs,
};
}
// set parameters
operation.parameters = operation.parameters || [];
for (const paramName of apiHandler.version[version][funcName]) {
operation.parameters.push({ $ref: `#/components/parameters/${paramName}` });
if (!definition.components.parameters[paramName]) {
definition.components.parameters[paramName] = {
name: paramName,
in: 'query',
schema: {
type: 'string',
},
};
}
}
// set path
let path = `/${operation.operationId}`; // APIPathStyle.FLAT
if (style === APIPathStyle.REST && operation._restPath) {
path = operation._restPath;
}
delete operation._restPath;
// set up response processor
responseProcessors[funcName] = operation._responseProcessor;
delete operation._responseProcessor;
// add to definition
// NOTE: It may be confusing that every operation can be called with both GET and POST
definition.paths[path] = {
get: {
...operation,
operationId: `${operation.operationId}UsingGET`,
},
post: {
...operation,
operationId: `${operation.operationId}UsingPOST`,
},
};
}
return definition;
};
exports.expressCreateServer = (_, args) => {
const { app } = args;
for (const version in apiHandler.version) {
// create two different styles of api: flat + rest
for (const style of [APIPathStyle.FLAT, APIPathStyle.REST]) {
const apiRoot = getApiRootForVersion(version, style);
// generate openapi definition for this API version
const definition = generateDefinitionForVersion(version, style);
// serve openapi definition file
app.get(`${apiRoot}/openapi.json`, (req, res) => {
2020-03-29 18:47:42 +02:00
res.header('Access-Control-Allow-Origin', '*');
res.json({ ...definition, servers: [generateServerForApiVersion(apiRoot, req)] });
});
// serve latest openapi definition file under /api/openapi.json
if (version === apiHandler.latestApiVersion) {
app.get(`/${style}/openapi.json`, (req, res) => {
2020-03-29 18:47:42 +02:00
res.header('Access-Control-Allow-Origin', '*');
res.json({ ...definition, servers: [generateServerForApiVersion(apiRoot, req)] });
});
}
// build openapi-backend instance for this api version
const api = new OpenAPIBackend({
apiRoot,
definition,
2020-03-29 16:39:45 +02:00
validate: false,
quick: true, // recommended when running multiple instances in parallel
});
// register default handlers
api.register({
notFound: (c, req, res) => {
res.statusCode = 404;
return { code: 3, message: 'no such function', data: null };
},
notImplemented: (c, req, res) => {
res.statusCode = 501;
return { code: 3, message: 'not implemented', data: null };
},
});
// register operation handlers (calls apiHandler.handle)
for (const funcName in apiHandler.version[version]) {
const handler = async (c, req, res) => {
// parse fields from request
const { header, params, query } = c.request;
let formData = {};
if (c.request.method === 'post') {
const form = new formidable.IncomingForm();
const parseForm = promisify(form.parse).bind(form);
formData = await parseForm(req);
}
const fields = Object.assign({}, header, params, query, formData);
// log request
apiLogger.info(`REQUEST, v${version}:${funcName}, ${JSON.stringify(fields)}`);
// pass to api handler
let data = await apiHandler.handle(version, funcName, fields, req, res);
if (!data) {
data = null;
}
// return in common format
const response = { code: 0, message: 'ok', data };
// NOTE: the original swagger implementation had response processors, but the tests
// clearly assume the processors are turned off
/*if (responseProcessors[funcName]) {
response = responseProcessors[funcName](response);
}*/
// log response
apiLogger.info(`RESPONSE, ${funcName}, ${JSON.stringify(response)}`);
return response;
};
// each operation can be called with either GET or POST
api.register(`${funcName}UsingGET`, handler);
api.register(`${funcName}UsingPOST`, handler);
}
// start and bind to express
api.init();
2020-03-29 18:47:42 +02:00
app.use(apiRoot, async (req, res) => {
try {
// allow cors
res.header('Access-Control-Allow-Origin', '*');
res.send(await api.handleRequest(req, req, res));
} catch (err) {
if (err.name == 'apierror') {
// parameters were wrong and the api stopped execution, pass the error
res.send({ code: 1, message: err.message, data: null });
} else {
// an unknown error happened
res.send({ code: 2, message: 'internal error', data: null });
throw err;
}
}
2020-03-29 18:47:42 +02:00
});
}
}
};
2020-03-29 18:47:42 +02:00
// helper to get api root
const getApiRootForVersion = (version, style = APIPathStyle.FLAT) => `/${style}/${version}`;
// helper to generate an OpenAPI server object when serving definitions
const generateServerForApiVersion = (apiRoot, req) => ({
url: `${settings.ssl ? 'https' : 'http'}://${req.headers.host}${apiRoot}`,
});