etherpad-lite/src/node/hooks/express/openapi.ts
2024-05-14 22:36:16 +02:00

737 lines
22 KiB
TypeScript

'use strict';
import {OpenAPIOperations, OpenAPISuccessResponse, SwaggerUIResource} from "../../types/SwaggerUIResource";
import {MapArrayType} from "../../types/MapType";
import {ErrorCaused} from "../../types/ErrorCaused";
/**
* node/hooks/express/openapi.js
*
* This module generates OpenAPI definitions for each API version defined by
* APIHandler.js and hooks into express to route the API using openapi-backend.
*
* The openapi definition files are publicly available under:
*
* - /api/openapi.json
* - /rest/openapi.json
* - /api/{version}/openapi.json
* - /rest/{version}/openapi.json
*/
const OpenAPIBackend = require('openapi-backend').default;
const IncomingForm = require('formidable').IncomingForm;
const cloneDeep = require('lodash.clonedeep');
const createHTTPError = require('http-errors');
const apiHandler = require('../../handler/APIHandler');
const settings = require('../../utils/Settings');
const log4js = require('log4js');
const logger = log4js.getLogger('API');
// https://github.com/OAI/OpenAPI-Specification/tree/master/schemas/v3.0
const OPENAPI_VERSION = '3.0.2'; // Swagger/OAS version
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
};
// API resources - describe your API endpoints here
const resources:SwaggerUIResource = {
// Group
group: {
create: {
operationId: 'createGroup',
summary: 'creates a new group',
responseSchema: {groupID: {type: 'string'}},
},
createIfNotExistsFor: {
operationId: 'createGroupIfNotExistsFor',
summary: 'this functions helps you to map your application group ids to Etherpad group ids',
responseSchema: {groupID: {type: 'string'}},
},
delete: {
operationId: 'deleteGroup',
summary: 'deletes a group',
},
listPads: {
operationId: 'listPads',
summary: 'returns all pads of this group',
responseSchema: {padIDs: {type: 'array', items: {type: 'string'}}},
},
createPad: {
operationId: 'createGroupPad',
summary: 'creates a new pad in this group',
},
listSessions: {
operationId: 'listSessionsOfGroup',
summary: '',
responseSchema: {
sessions: {type: 'array', items: {$ref: '#/components/schemas/SessionInfo'}},
},
},
list: {
operationId: 'listAllGroups',
summary: '',
responseSchema: {groupIDs: {type: 'array', items: {type: 'string'}}},
},
},
// Author
author: {
create: {
operationId: 'createAuthor',
summary: 'creates a new author',
responseSchema: {authorID: {type: 'string'}},
},
createIfNotExistsFor: {
operationId: 'createAuthorIfNotExistsFor',
summary: 'this functions helps you to map your application author ids to Etherpad author ids',
responseSchema: {authorID: {type: 'string'}},
},
listPads: {
operationId: 'listPadsOfAuthor',
summary: 'returns an array of all pads this author contributed to',
responseSchema: {padIDs: {type: 'array', items: {type: 'string'}}},
},
listSessions: {
operationId: 'listSessionsOfAuthor',
summary: 'returns all sessions of an author',
responseSchema: {
sessions: {type: 'array', items: {$ref: '#/components/schemas/SessionInfo'}},
},
},
// We need an operation that return a UserInfo so it can be picked up by the codegen :(
getName: {
operationId: 'getAuthorName',
summary: 'Returns the Author Name of the author',
responseSchema: {info: {$ref: '#/components/schemas/UserInfo'}},
},
},
// Session
session: {
create: {
operationId: 'createSession',
summary: 'creates a new session. validUntil is an unix timestamp in seconds',
responseSchema: {sessionID: {type: 'string'}},
},
delete: {
operationId: 'deleteSession',
summary: 'deletes a session',
},
// We need an operation that returns a SessionInfo so it can be picked up by the codegen :(
info: {
operationId: 'getSessionInfo',
summary: 'returns information about a session',
responseSchema: {info: {$ref: '#/components/schemas/SessionInfo'}},
},
},
// Pad
pad: {
listAll: {
operationId: 'listAllPads',
summary: 'list all the pads',
responseSchema: {padIDs: {type: 'array', items: {type: 'string'}}},
},
createDiffHTML: {
operationId: 'createDiffHTML',
summary: '',
responseSchema: {},
},
create: {
operationId: 'createPad',
description:
'creates a new (non-group) pad. Note that if you need to create a group Pad, ' +
'you should call createGroupPad',
},
getText: {
operationId: 'getText',
summary: 'returns the text of a pad',
responseSchema: {text: {type: 'string'}},
},
setText: {
operationId: 'setText',
summary: 'sets the text of a pad',
},
getHTML: {
operationId: 'getHTML',
summary: 'returns the text of a pad formatted as HTML',
responseSchema: {html: {type: 'string'}},
},
setHTML: {
operationId: 'setHTML',
summary: 'sets the text of a pad with HTML',
},
getRevisionsCount: {
operationId: 'getRevisionsCount',
summary: 'returns the number of revisions of this pad',
responseSchema: {revisions: {type: 'integer'}},
},
getLastEdited: {
operationId: 'getLastEdited',
summary: 'returns the timestamp of the last revision of the pad',
responseSchema: {lastEdited: {type: 'integer'}},
},
delete: {
operationId: 'deletePad',
summary: 'deletes a pad',
},
getReadOnlyID: {
operationId: 'getReadOnlyID',
summary: 'returns the read only link of a pad',
responseSchema: {readOnlyID: {type: 'string'}},
},
setPublicStatus: {
operationId: 'setPublicStatus',
summary: 'sets a boolean for the public status of a pad',
},
getPublicStatus: {
operationId: 'getPublicStatus',
summary: 'return true of false',
responseSchema: {publicStatus: {type: 'boolean'}},
},
authors: {
operationId: 'listAuthorsOfPad',
summary: 'returns an array of authors who contributed to this pad',
responseSchema: {authorIDs: {type: 'array', items: {type: 'string'}}},
},
usersCount: {
operationId: 'padUsersCount',
summary: 'returns the number of user that are currently editing this pad',
responseSchema: {padUsersCount: {type: 'integer'}},
},
users: {
operationId: 'padUsers',
summary: 'returns the list of users that are currently editing this pad',
responseSchema: {padUsers: {type: 'array', items: {$ref: '#/components/schemas/UserInfo'}}},
},
sendClientsMessage: {
operationId: 'sendClientsMessage',
summary: 'sends a custom message of type msg to the pad',
},
checkToken: {
operationId: 'checkToken',
summary: 'returns ok when the current api token is valid',
},
getChatHistory: {
operationId: 'getChatHistory',
summary: 'returns the chat history',
responseSchema: {messages: {type: 'array', items: {$ref: '#/components/schemas/Message'}}},
},
// We need an operation that returns a Message so it can be picked up by the codegen :(
getChatHead: {
operationId: 'getChatHead',
summary: 'returns the chatHead (chat-message) of the pad',
responseSchema: {chatHead: {$ref: '#/components/schemas/Message'}},
},
appendChatMessage: {
operationId: 'appendChatMessage',
summary: 'appends a chat message',
},
},
};
const defaultResponses = {
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:OpenAPISuccessResponse = {
200: {
$ref: '#/components/responses/Success',
},
400: {
$ref: '#/components/responses/ApiError',
},
401: {
$ref: '#/components/responses/Unauthorized',
},
500: {
$ref: '#/components/responses/InternalError',
},
};
// convert to a dictionary of operation objects
const operations: OpenAPIOperations = {};
for (const [resource, actions] of Object.entries(resources)) {
for (const [action, spec] of Object.entries(actions)) {
const {operationId,responseSchema, ...operation} = spec;
// add response objects
const responses:OpenAPISuccessResponse = {...defaultResponseRefs};
if (responseSchema) {
responses[200] = cloneDeep(defaultResponses.Success);
responses[200].content!['application/json'].schema.properties.data = {
type: 'object',
properties: responseSchema,
};
}
// add final operation object to dictionary
operations[operationId] = {
operationId,
...operation,
responses,
tags: [resource],
_restPath: `/${resource}/${action}`,
};
}
}
const generateDefinitionForVersion = (version:string, style = APIPathStyle.FLAT) => {
const definition = {
openapi: OPENAPI_VERSION,
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',
},
},
},
},
responses: {
...defaultResponses,
},
securitySchemes: {
openid: {
type: "oauth2",
flows: {
authorizationCode: {
authorizationUrl: settings.sso.issuer+"/oidc/auth",
tokenUrl: settings.sso.issuer+"/oidc/token",
scopes: {
openid: "openid",
profile: "profile",
email: "email",
admin: "admin"
}
}
},
},
},
},
security: [{openid: []}],
};
// build operations
for (const funcName of Object.keys(apiHandler.version[version])) {
let operation:OpenAPIOperations = {};
if (operations[funcName]) {
operation = {...operations[funcName]};
} else {
// console.warn(`No operation found for function: ${funcName}`);
operation = {
operationId: funcName,
responses: defaultResponseRefs,
};
}
// set parameters
operation.parameters = operation.parameters || [];
for (const paramName of apiHandler.version[version][funcName]) {
operation.parameters.push({$ref: `#/components/parameters/${paramName}`});
// @ts-ignore
if (!definition.components.parameters[paramName]) {
// @ts-ignore
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;
// add to definition
// NOTE: It may be confusing that every operation can be called with both GET and POST
// @ts-ignore
definition.paths[path] = {
get: {
...operation,
operationId: `${operation.operationId}UsingGET`,
},
post: {
...operation,
operationId: `${operation.operationId}UsingPOST`,
},
};
}
return definition;
};
exports.expressPreSession = async (hookName:string, {app}:any) => {
// create openapi-backend handlers for each api version under /api/{version}/*
for (const version of Object.keys(apiHandler.version)) {
// we support two different styles of api: flat + rest
// TODO: do we really want to support both?
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 version specific openapi definition
app.get(`${apiRoot}/openapi.json`, (req:any, res:any) => {
// For openapi definitions, wide CORS is probably fine
res.header('Access-Control-Allow-Origin', '*');
res.json({...definition, servers: [generateServerForApiVersion(apiRoot, req)]});
});
// serve latest openapi definition file under /api/openapi.json
const isLatestAPIVersion = version === apiHandler.latestApiVersion;
if (isLatestAPIVersion) {
app.get(`/${style}/openapi.json`, (req:any, res:any) => {
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({
definition,
validate: false,
// for a small optimisation, we can run the quick startup for older
// API versions since they are subsets of the latest api definition
quick: !isLatestAPIVersion,
});
// register default handlers
api.register({
notFound: () => {
throw new createHTTPError.NotFound('no such function');
},
notImplemented: () => {
throw new createHTTPError.NotImplemented('function not implemented');
},
});
// register operation handlers
for (const funcName of Object.keys(apiHandler.version[version])) {
const handler = async (c: any, req:any, res:any) => {
// parse fields from request
const {headers, params, query} = c.request;
// read form data if method was POST
let formData:MapArrayType<any> = {};
if (c.request.method === 'post') {
const form = new IncomingForm();
formData = (await form.parse(req))[0];
for (const k of Object.keys(formData)) {
if (formData[k] instanceof Array) {
formData[k] = formData[k][0];
}
}
}
const fields = Object.assign({}, headers, params, query, formData);
if (logger.isDebugEnabled()) {
logger.debug(`REQUEST, v${version}:${funcName}, ${JSON.stringify(fields)}`);
}
// pass to api handler
let data;
try {
data = await apiHandler.handle(version, funcName, fields, req, res);
} catch (err) {
const errCaused = err as ErrorCaused
// convert all errors to http errors
if (createHTTPError.isHttpError(err)) {
// pass http errors thrown by handler forward
throw err;
} else if (errCaused.name === 'apierror') {
// parameters were wrong and the api stopped execution, pass the error
// convert to http error
throw new createHTTPError.BadRequest(errCaused.message);
} else {
// an unknown error happened
// log it and throw internal error
logger.error(errCaused.stack || errCaused.toString());
throw new createHTTPError.InternalError('internal error');
}
}
// return in common format
const response = {code: 0, message: 'ok', data: data || null};
if (logger.isDebugEnabled()) {
logger.debug(`RESPONSE, ${funcName}, ${JSON.stringify(response)}`);
}
// return the response data
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
await api.init();
app.use(apiRoot, async (req:any, res:any) => {
let response = null;
try {
if (style === APIPathStyle.REST) {
// @TODO: Don't allow CORS from everywhere
// This is purely to maintain compatibility with old swagger-node-express
res.header('Access-Control-Allow-Origin', '*');
}
// pass to openapi-backend handler
response = await api.handleRequest(req, req, res);
} catch (err) {
const errCaused = err as ErrorCaused
// handle http errors
// @ts-ignore
res.statusCode = errCaused.statusCode || 500;
// convert to our json response format
// https://github.com/ether/etherpad-lite/tree/master/doc/api/http_api.md#response-format
switch (res.statusCode) {
case 403: // forbidden
response = {code: 4, message: errCaused.message, data: null};
break;
case 401: // unauthorized (no or wrong api key)
response = {code: 4, message: errCaused.message, data: null};
break;
case 404: // not found (no such function)
response = {code: 3, message: errCaused.message, data: null};
break;
case 500: // server error (internal error)
response = {code: 2, message: errCaused.message, data: null};
break;
case 400: // bad request (wrong parameters)
// respond with 200 OK to keep old behavior and pass tests
res.statusCode = 200; // @TODO: this is bad api design
response = {code: 1, message: errCaused.message, data: null};
break;
default:
response = {code: 1, message: errCaused.message, data: null};
break;
}
}
// send response
return res.send(response);
});
}
}
};
/**
* Helper to get the current root path for an API version
* @param {String} version The API version
* @param {APIPathStyle} style The style of the API path
* @return {String} The root path for the API version
*/
const getApiRootForVersion = (version:string, style:any = APIPathStyle.FLAT): string => `/${style}/${version}`;
/**
* Helper to generate an OpenAPI server object when serving definitions
* @param {String} apiRoot The root path for the API version
* @param {Request} req The express request object
* @return {url: String} The server object for the OpenAPI definition location
*/
const generateServerForApiVersion = (apiRoot:string, req:any): {
url:string
} => ({
url: `${settings.ssl ? 'https' : 'http'}://${req.headers.host}${apiRoot}`,
});