openapi: add documentation, small optimisation

This commit is contained in:
Viljami Kuosmanen 2020-03-29 20:25:43 +02:00 committed by muxator
parent c2cca39c7d
commit e821bbcad8
5 changed files with 154 additions and 180 deletions

View file

@ -111,6 +111,8 @@ For **responsible disclosure of vulnerabilities**, please write a mail to the ma
Etherpad is designed to be easily embeddable and provides a [HTTP API](https://github.com/ether/etherpad-lite/wiki/HTTP-API) Etherpad is designed to be easily embeddable and provides a [HTTP API](https://github.com/ether/etherpad-lite/wiki/HTTP-API)
that allows your web application to manage pads, users and groups. It is recommended to use the [available client implementations](https://github.com/ether/etherpad-lite/wiki/HTTP-API-client-libraries) in order to interact with this API. that allows your web application to manage pads, users and groups. It is recommended to use the [available client implementations](https://github.com/ether/etherpad-lite/wiki/HTTP-API-client-libraries) in order to interact with this API.
OpenAPI (previously swagger) definitions for the API are exposed under `/api/openapi.json`.
# jQuery plugin # jQuery plugin
There is a [jQuery plugin](https://github.com/ether/etherpad-lite-jquery-plugin) that helps you to embed Pads into your website. There is a [jQuery plugin](https://github.com/ether/etherpad-lite-jquery-plugin) that helps you to embed Pads into your website.

View file

@ -11,6 +11,10 @@ The API is designed in a way, so you can reuse your existing user system with th
Take a look at [HTTP API client libraries](https://github.com/ether/etherpad-lite/wiki/HTTP-API-client-libraries) to check if a library in your favorite programming language is available. Take a look at [HTTP API client libraries](https://github.com/ether/etherpad-lite/wiki/HTTP-API-client-libraries) to check if a library in your favorite programming language is available.
### OpenAPI
OpenAPI (formerly swagger) definitions are exposed under `/api/openapi.json` (latest) and `/api/{version}/openapi.json`. You can use official tools like [Swagger Editor](https://editor.swagger.io/) to view and explore them.
## Examples ## Examples
### Example 1 ### Example 1

View file

@ -1,6 +1,21 @@
/**
* 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 OpenAPIBackend = require('openapi-backend').default;
const formidable = require('formidable'); const formidable = require('formidable');
const { promisify } = require('util'); const { promisify } = require('util');
const cloneDeep = require('lodash.clonedeep');
const apiHandler = require('../../handler/APIHandler'); const apiHandler = require('../../handler/APIHandler');
const settings = require('../../utils/Settings'); const settings = require('../../utils/Settings');
@ -33,232 +48,203 @@ const APIPathStyle = {
REST: 'rest', // restful paths e.g. /rest/group/create REST: 'rest', // restful paths e.g. /rest/group/create
}; };
function sessionListResponseProcessor(res) { // API resources - describe your API endpoints here
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;
}
// API resources
// add your operations here
const resources = { const resources = {
// Group // Group
group: { group: {
create: { create: {
func: 'createGroup', operationId: 'createGroup',
description: 'creates a new group', summary: 'creates a new group',
response: { groupID: { type: 'string' } }, responseSchema: { groupID: { type: 'string' } },
}, },
createIfNotExistsFor: { createIfNotExistsFor: {
func: 'createGroupIfNotExistsFor', operationId: 'createGroupIfNotExistsFor',
description: 'this functions helps you to map your application group ids to Etherpad group ids', summary: 'this functions helps you to map your application group ids to Etherpad group ids',
response: { groupID: { type: 'string' } }, responseSchema: { groupID: { type: 'string' } },
}, },
delete: { delete: {
func: 'deleteGroup', operationId: 'deleteGroup',
description: 'deletes a group', summary: 'deletes a group',
}, },
listPads: { listPads: {
func: 'listPads', operationId: 'listPads',
description: 'returns all pads of this group', summary: 'returns all pads of this group',
response: { padIDs: { type: 'array', items: { type: 'string' } } }, responseSchema: { padIDs: { type: 'array', items: { type: 'string' } } },
}, },
createPad: { createPad: {
func: 'createGroupPad', operationId: 'createGroupPad',
description: 'creates a new pad in this group', summary: 'creates a new pad in this group',
}, },
listSessions: { listSessions: {
func: 'listSessionsOfGroup', operationId: 'listSessionsOfGroup',
description: '', summary: '',
response: { sessions: { type: 'array', items: { $ref: '#/components/schemas/SessionInfo' } } }, responseSchema: { sessions: { type: 'array', items: { $ref: '#/components/schemas/SessionInfo' } } },
responseProcessor: sessionListResponseProcessor,
}, },
list: { list: {
func: 'listAllGroups', operationId: 'listAllGroups',
description: '', summary: '',
response: { groupIDs: { type: 'array', items: { type: 'string' } } }, responseSchema: { groupIDs: { type: 'array', items: { type: 'string' } } },
}, },
}, },
// Author // Author
author: { author: {
create: { create: {
func: 'createAuthor', operationId: 'createAuthor',
description: 'creates a new author', summary: 'creates a new author',
response: { authorID: { type: 'string' } }, responseSchema: { authorID: { type: 'string' } },
}, },
createIfNotExistsFor: { createIfNotExistsFor: {
func: 'createAuthorIfNotExistsFor', operationId: 'createAuthorIfNotExistsFor',
description: 'this functions helps you to map your application author ids to Etherpad author ids', summary: 'this functions helps you to map your application author ids to Etherpad author ids',
response: { authorID: { type: 'string' } }, responseSchema: { authorID: { type: 'string' } },
}, },
listPads: { listPads: {
func: 'listPadsOfAuthor', operationId: 'listPadsOfAuthor',
description: 'returns an array of all pads this author contributed to', summary: 'returns an array of all pads this author contributed to',
response: { padIDs: { type: 'array', items: { type: 'string' } } }, responseSchema: { padIDs: { type: 'array', items: { type: 'string' } } },
}, },
listSessions: { listSessions: {
func: 'listSessionsOfAuthor', operationId: 'listSessionsOfAuthor',
description: 'returns all sessions of an author', summary: 'returns all sessions of an author',
response: { sessions: { type: 'array', items: { $ref: '#/components/schemas/SessionInfo' } } }, responseSchema: { sessions: { type: 'array', items: { $ref: '#/components/schemas/SessionInfo' } } },
responseProcessor: sessionListResponseProcessor,
}, },
// We need an operation that return a UserInfo so it can be picked up by the codegen :( // We need an operation that return a UserInfo so it can be picked up by the codegen :(
getName: { getName: {
func: 'getAuthorName', operationId: 'getAuthorName',
description: 'Returns the Author Name of the author', summary: 'Returns the Author Name of the author',
responseProcessor: function(response) { responseSchema: { info: { $ref: '#/components/schemas/UserInfo' } },
if (response.data) {
response['info'] = { name: response.data.authorName };
delete response['data'];
}
},
response: { info: { type: 'UserInfo' } },
}, },
}, },
// Session // Session
session: { session: {
create: { create: {
func: 'createSession', operationId: 'createSession',
description: 'creates a new session. validUntil is an unix timestamp in seconds', summary: 'creates a new session. validUntil is an unix timestamp in seconds',
response: { sessionID: { type: 'string' } }, responseSchema: { sessionID: { type: 'string' } },
}, },
delete: { delete: {
func: 'deleteSession', operationId: 'deleteSession',
description: 'deletes a session', summary: 'deletes a session',
}, },
// We need an operation that returns a SessionInfo so it can be picked up by the codegen :( // We need an operation that returns a SessionInfo so it can be picked up by the codegen :(
info: { info: {
func: 'getSessionInfo', operationId: 'getSessionInfo',
description: 'returns informations about a session', summary: 'returns informations about a session',
response: { info: { $ref: '#/components/schemas/SessionInfo' } }, responseSchema: { info: { $ref: '#/components/schemas/SessionInfo' } },
}, },
}, },
// Pad // Pad
pad: { pad: {
listAll: { listAll: {
func: 'listAllPads', operationId: 'listAllPads',
description: 'list all the pads', summary: 'list all the pads',
response: { padIDs: { type: 'array', items: { type: 'string' } } }, responseSchema: { padIDs: { type: 'array', items: { type: 'string' } } },
}, },
createDiffHTML: { createDiffHTML: {
func: 'createDiffHTML', operationId: 'createDiffHTML',
description: '', summary: '',
response: {}, responseSchema: {},
}, },
create: { create: {
func: 'createPad', operationId: 'createPad',
description: description:
'creates a new (non-group) pad. Note that if you need to create a group Pad, you should call createGroupPad', 'creates a new (non-group) pad. Note that if you need to create a group Pad, you should call createGroupPad',
}, },
getText: { getText: {
func: 'getText', operationId: 'getText',
description: 'returns the text of a pad', summary: 'returns the text of a pad',
response: { text: { type: 'string' } }, responseSchema: { text: { type: 'string' } },
}, },
setText: { setText: {
func: 'setText', operationId: 'setText',
description: 'sets the text of a pad', summary: 'sets the text of a pad',
}, },
getHTML: { getHTML: {
func: 'getHTML', operationId: 'getHTML',
description: 'returns the text of a pad formatted as HTML', summary: 'returns the text of a pad formatted as HTML',
response: { html: { type: 'string' } }, responseSchema: { html: { type: 'string' } },
}, },
setHTML: { setHTML: {
func: 'setHTML', operationId: 'setHTML',
description: 'sets the text of a pad with HTML', summary: 'sets the text of a pad with HTML',
}, },
getRevisionsCount: { getRevisionsCount: {
func: 'getRevisionsCount', operationId: 'getRevisionsCount',
description: 'returns the number of revisions of this pad', summary: 'returns the number of revisions of this pad',
response: { revisions: { type: 'integer' } }, responseSchema: { revisions: { type: 'integer' } },
}, },
getLastEdited: { getLastEdited: {
func: 'getLastEdited', operationId: 'getLastEdited',
description: 'returns the timestamp of the last revision of the pad', summary: 'returns the timestamp of the last revision of the pad',
response: { lastEdited: { type: 'integer' } }, responseSchema: { lastEdited: { type: 'integer' } },
}, },
delete: { delete: {
func: 'deletePad', operationId: 'deletePad',
description: 'deletes a pad', summary: 'deletes a pad',
}, },
getReadOnlyID: { getReadOnlyID: {
func: 'getReadOnlyID', operationId: 'getReadOnlyID',
description: 'returns the read only link of a pad', summary: 'returns the read only link of a pad',
response: { readOnlyID: { type: 'string' } }, responseSchema: { readOnlyID: { type: 'string' } },
}, },
setPublicStatus: { setPublicStatus: {
func: 'setPublicStatus', operationId: 'setPublicStatus',
description: 'sets a boolean for the public status of a pad', summary: 'sets a boolean for the public status of a pad',
}, },
getPublicStatus: { getPublicStatus: {
func: 'getPublicStatus', operationId: 'getPublicStatus',
description: 'return true of false', summary: 'return true of false',
response: { publicStatus: { type: 'boolean' } }, responseSchema: { publicStatus: { type: 'boolean' } },
}, },
setPassword: { setPassword: {
func: 'setPassword', operationId: 'setPassword',
description: 'returns ok or a error message', summary: 'returns ok or a error message',
}, },
isPasswordProtected: { isPasswordProtected: {
func: 'isPasswordProtected', operationId: 'isPasswordProtected',
description: 'returns true or false', summary: 'returns true or false',
response: { passwordProtection: { type: 'boolean' } }, responseSchema: { passwordProtection: { type: 'boolean' } },
}, },
authors: { authors: {
func: 'listAuthorsOfPad', operationId: 'listAuthorsOfPad',
description: 'returns an array of authors who contributed to this pad', summary: 'returns an array of authors who contributed to this pad',
response: { authorIDs: { type: 'array', items: { type: 'string' } } }, responseSchema: { authorIDs: { type: 'array', items: { type: 'string' } } },
}, },
usersCount: { usersCount: {
func: 'padUsersCount', operationId: 'padUsersCount',
description: 'returns the number of user that are currently editing this pad', summary: 'returns the number of user that are currently editing this pad',
response: { padUsersCount: { type: 'integer' } }, responseSchema: { padUsersCount: { type: 'integer' } },
}, },
users: { users: {
func: 'padUsers', operationId: 'padUsers',
description: 'returns the list of users that are currently editing this pad', summary: 'returns the list of users that are currently editing this pad',
response: { padUsers: { type: 'array', items: { $ref: '#/components/schemas/UserInfo' } } }, responseSchema: { padUsers: { type: 'array', items: { $ref: '#/components/schemas/UserInfo' } } },
}, },
sendClientsMessage: { sendClientsMessage: {
func: 'sendClientsMessage', operationId: 'sendClientsMessage',
description: 'sends a custom message of type msg to the pad', summary: 'sends a custom message of type msg to the pad',
}, },
checkToken: { checkToken: {
func: 'checkToken', operationId: 'checkToken',
description: 'returns ok when the current api token is valid', summary: 'returns ok when the current api token is valid',
}, },
getChatHistory: { getChatHistory: {
func: 'getChatHistory', operationId: 'getChatHistory',
description: 'returns the chat history', summary: 'returns the chat history',
response: { messages: { type: 'array', items: { $ref: '#/components/schemas/Message' } } }, 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 :( // We need an operation that returns a Message so it can be picked up by the codegen :(
getChatHead: { getChatHead: {
func: 'getChatHead', operationId: 'getChatHead',
description: 'returns the chatHead (chat-message) of the pad', summary: 'returns the chatHead (chat-message) of the pad',
responseProcessor: function(response) { responseSchema: { chatHead: { $ref: '#/components/schemas/Message' } },
// move this to info
if (response.data) {
response['chatHead'] = { time: response.data['chatHead'] };
delete response['data'];
}
},
response: { chatHead: { type: 'Message' } },
}, },
appendChatMessage: { appendChatMessage: {
func: 'appendChatMessage', operationId: 'appendChatMessage',
description: 'appends a chat message', summary: 'appends a chat message',
}, },
}, },
}; };
@ -401,50 +387,30 @@ const defaultResponseRefs = {
}, },
}; };
// convert to a flat list of OAS Operation objects // convert to a dictionary of operation objects
const operations = []; const operations = {};
const responseProcessors = {};
for (const resource in resources) { for (const resource in resources) {
for (const action in resources[resource]) { for (const action in resources[resource]) {
const { func: operationId, description, response, responseProcessor } = resources[resource][action]; const { operationId, responseSchema, ...operation } = resources[resource][action];
// add response objects
const responses = { ...defaultResponseRefs }; const responses = { ...defaultResponseRefs };
if (response) { if (responseSchema) {
responses[200] = { responses[200] = cloneDeep(defaultResponses.Success);
description: 'ok (code 0)', responses[200].content['application/json'].schema.properties.data = {
content: {
'application/json': {
schema: {
type: 'object', type: 'object',
properties: { properties: responseSchema,
code: {
type: 'integer',
example: 0,
},
message: {
type: 'string',
example: 'ok',
},
data: {
type: 'object',
properties: response,
},
},
},
},
},
}; };
} }
const operation = { // add final operation object to dictionary
operations[operationId] = {
operationId, operationId,
summary: description, ...operation,
responses, responses,
tags: [resource], tags: [resource],
_restPath: `/${resource}/${action}`, _restPath: `/${resource}/${action}`,
_responseProcessor: responseProcessor,
}; };
operations[operationId] = operation;
} }
} }
@ -557,10 +523,6 @@ const generateDefinitionForVersion = (version, style = APIPathStyle.FLAT) => {
} }
delete operation._restPath; delete operation._restPath;
// set up response processor
responseProcessors[funcName] = operation._responseProcessor;
delete operation._responseProcessor;
// add to definition // add to definition
// NOTE: It may be confusing that every operation can be called with both GET and POST // NOTE: It may be confusing that every operation can be called with both GET and POST
definition.paths[path] = { definition.paths[path] = {
@ -574,29 +536,32 @@ const generateDefinitionForVersion = (version, style = APIPathStyle.FLAT) => {
}, },
}; };
} }
return definition; return definition;
}; };
exports.expressCreateServer = (_, args) => { exports.expressCreateServer = async (_, args) => {
const { app } = args; const { app } = args;
// create openapi-backend handlers for each api version under /api/{version}/*
for (const version in apiHandler.version) { for (const version in apiHandler.version) {
// create two different styles of api: flat + rest // 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]) { for (const style of [APIPathStyle.FLAT, APIPathStyle.REST]) {
const apiRoot = getApiRootForVersion(version, style); const apiRoot = getApiRootForVersion(version, style);
// generate openapi definition for this API version // generate openapi definition for this API version
const definition = generateDefinitionForVersion(version, style); const definition = generateDefinitionForVersion(version, style);
// serve openapi definition file // serve version specific openapi definition
app.get(`${apiRoot}/openapi.json`, (req, res) => { app.get(`${apiRoot}/openapi.json`, (req, res) => {
res.header('Access-Control-Allow-Origin', '*'); res.header('Access-Control-Allow-Origin', '*');
res.json({ ...definition, servers: [generateServerForApiVersion(apiRoot, req)] }); res.json({ ...definition, servers: [generateServerForApiVersion(apiRoot, req)] });
}); });
// serve latest openapi definition file under /api/openapi.json // serve latest openapi definition file under /api/openapi.json
if (version === apiHandler.latestApiVersion) { const isLatestAPIVersion = version === apiHandler.latestApiVersion;
if (isLatestAPIVersion) {
app.get(`/${style}/openapi.json`, (req, res) => { app.get(`/${style}/openapi.json`, (req, res) => {
res.header('Access-Control-Allow-Origin', '*'); res.header('Access-Control-Allow-Origin', '*');
res.json({ ...definition, servers: [generateServerForApiVersion(apiRoot, req)] }); res.json({ ...definition, servers: [generateServerForApiVersion(apiRoot, req)] });
@ -605,10 +570,12 @@ exports.expressCreateServer = (_, args) => {
// build openapi-backend instance for this api version // build openapi-backend instance for this api version
const api = new OpenAPIBackend({ const api = new OpenAPIBackend({
apiRoot, apiRoot, // each api version has its own root
definition, definition,
validate: false, validate: false,
quick: true, // recommended when running multiple instances in parallel // 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 // register default handlers
@ -629,6 +596,7 @@ exports.expressCreateServer = (_, args) => {
// parse fields from request // parse fields from request
const { header, params, query } = c.request; const { header, params, query } = c.request;
// read form data if method was POST
let formData = {}; let formData = {};
if (c.request.method === 'post') { if (c.request.method === 'post') {
const form = new formidable.IncomingForm(); const form = new formidable.IncomingForm();
@ -650,12 +618,6 @@ exports.expressCreateServer = (_, args) => {
// return in common format // return in common format
const response = { code: 0, message: 'ok', data }; 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 // log response
apiLogger.info(`RESPONSE, ${funcName}, ${JSON.stringify(response)}`); apiLogger.info(`RESPONSE, ${funcName}, ${JSON.stringify(response)}`);

5
src/package-lock.json generated
View file

@ -2422,6 +2422,11 @@
"resolved": "https://registry.npmjs.org/lodash.bind/-/lodash.bind-4.2.1.tgz", "resolved": "https://registry.npmjs.org/lodash.bind/-/lodash.bind-4.2.1.tgz",
"integrity": "sha1-euMBfpOWIqwxt9fX3LGzTbFpDTU=" "integrity": "sha1-euMBfpOWIqwxt9fX3LGzTbFpDTU="
}, },
"lodash.clonedeep": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
"integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8="
},
"lodash.defaults": { "lodash.defaults": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",

View file

@ -46,6 +46,7 @@
"graceful-fs": "4.2.2", "graceful-fs": "4.2.2",
"jsonminify": "0.4.1", "jsonminify": "0.4.1",
"languages4translatewiki": "0.1.3", "languages4translatewiki": "0.1.3",
"lodash.clonedeep": "^4.5.0",
"log4js": "0.6.35", "log4js": "0.6.35",
"measured-core": "1.11.2", "measured-core": "1.11.2",
"nodeify": "^1.0.1", "nodeify": "^1.0.1",