From 4d5230cf9c5003260e360330be9d94eee60c71d4 Mon Sep 17 00:00:00 2001 From: SamTV12345 <40429738+samtv12345@users.noreply.github.com> Date: Sun, 15 Sep 2024 13:54:09 +0200 Subject: [PATCH] Added swagger ui --- bin/make_docs.ts | 2 +- pnpm-lock.yaml | 30 ++ src/node/handler/RestAPI.ts | 525 +++++++++++++++++++++++-- src/node/hooks/express/specialpages.ts | 9 +- src/package.json | 2 + 5 files changed, 533 insertions(+), 35 deletions(-) diff --git a/bin/make_docs.ts b/bin/make_docs.ts index d414822dd..d4abfc97d 100644 --- a/bin/make_docs.ts +++ b/bin/make_docs.ts @@ -57,7 +57,7 @@ createDirIfNotExists('../out/doc/api') -exec(`asciidoctor -D ../out/doc ../doc/index.adoc */**.adoc -a VERSION=${VERSION}`) +exec(`asciidoctor -D ../out/doc ../doc/index.adoc ../*/**.adoc -a VERSION=${VERSION}`) exec(`asciidoctor -D ../out/doc/api ../doc/api/*.adoc -a VERSION=${VERSION}`) copyFolderSync('../doc/public/', '../out/doc/') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 05a505805..ff4f5cd76 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -257,6 +257,9 @@ importers: superagent: specifier: 10.1.0 version: 10.1.0 + swagger-ui-express: + specifier: ^5.0.1 + version: 5.0.1(express@4.21.0) tinycon: specifier: 0.6.8 version: 0.6.8 @@ -324,6 +327,9 @@ importers: '@types/supertest': specifier: ^6.0.2 version: 6.0.2 + '@types/swagger-ui-express': + specifier: ^4.1.6 + version: 4.1.6 '@types/underscore': specifier: ^1.11.15 version: 1.11.15 @@ -1613,6 +1619,9 @@ packages: '@types/supertest@6.0.2': resolution: {integrity: sha512-137ypx2lk/wTQbW6An6safu9hXmajAifU/s7szAHLN/FeIm5w7yR0Wkl9fdJMRSHwOn4HLAI0DaB2TOORuhPDg==} + '@types/swagger-ui-express@4.1.6': + resolution: {integrity: sha512-UVSiGYXa5IzdJJG3hrc86e8KdZWLYxyEsVoUI4iPXc7CO4VZ3AfNP8d/8+hrDRIqz+HAaSMtZSqAsF3Nq2X/Dg==} + '@types/tar@6.1.13': resolution: {integrity: sha512-IznnlmU5f4WcGTh2ltRu/Ijpmk8wiWXfF0VA4s+HPjHZgvFggk1YaIkbo5krX/zUCzWF8N/l4+W/LNxnvAJ8nw==} @@ -4258,6 +4267,15 @@ packages: swagger-schema-official@2.0.0-bab6bed: resolution: {integrity: sha512-rCC0NWGKr/IJhtRuPq/t37qvZHI/mH4I4sxflVM+qgVe5Z2uOCivzWaVbuioJaB61kvm5UvB7b49E+oBY0M8jA==} + swagger-ui-dist@5.17.14: + resolution: {integrity: sha512-CVbSfaLpstV65OnSjbXfVd6Sta3q3F7Cj/yYuvHMp1P90LztOLs6PfUnKEVAeiIVQt9u2SaPwv0LiH/OyMjHRw==} + + swagger-ui-express@5.0.1: + resolution: {integrity: sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==} + engines: {node: '>= v0.10.32'} + peerDependencies: + express: '>=4.0.0 || >=5.0.0-beta' + symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} @@ -5888,6 +5906,11 @@ snapshots: '@types/methods': 1.1.4 '@types/superagent': 8.1.7 + '@types/swagger-ui-express@4.1.6': + dependencies: + '@types/express': 4.17.21 + '@types/serve-static': 1.15.7 + '@types/tar@6.1.13': dependencies: '@types/node': 22.5.4 @@ -9000,6 +9023,13 @@ snapshots: swagger-schema-official@2.0.0-bab6bed: {} + swagger-ui-dist@5.17.14: {} + + swagger-ui-express@5.0.1(express@4.21.0): + dependencies: + express: 4.21.0 + swagger-ui-dist: 5.17.14 + symbol-tree@3.2.4: {} tabbable@6.2.0: {} diff --git a/src/node/handler/RestAPI.ts b/src/node/handler/RestAPI.ts index 38c4c4c68..9a0481e08 100644 --- a/src/node/handler/RestAPI.ts +++ b/src/node/handler/RestAPI.ts @@ -4,28 +4,451 @@ import {IncomingForm} from "formidable"; import {ErrorCaused} from "../types/ErrorCaused"; import createHTTPError from "http-errors"; const apiHandler = require('./APIHandler') +import {serve, setup} from 'swagger-ui-express' +import express from "express"; +const settings = require('../utils/Settings') + type RestAPIMapping = { apiVersion: string; - functionName: string + functionName: string, + summary?: string, + operationId?: string, + requestBody?: any, + responses?: any, + tags?: string[], } const mapping = new Map> +const GET = "GET" +const POST = "POST" +const PUT = "PUT" +const DELETE = "DELETE" +const PATCH = "PATCH" + + +const defaultResponses = { + "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": { + "groupID": { + "type": "string" + } + } + } + } + } + } + } + }, + "400": { + "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 + } + } + } + } + } + }, + "401": { + "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 + } + } + } + } + } + }, + "500": { + "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 + } + } + } + } + } + }, + "tags": [ + "group" + ], + "parameters": [] +} + +const prepareResponses = (data: { + type: string, + properties: Record, +})=>{ + return { + ...defaultResponses, + 200: { + ...defaultResponses["200"], + content: { + ...defaultResponses["200"].content, + "application/json": { + schema: { + type: "object", + properties: { + ...defaultResponses["200"].content["application/json"].schema.properties, + data: { + type: "object", + properties: { + groupID: { + type: "string" + } + } + } + } + } + } + } + } + } +} + + + +const prepareDefinition = (mapping: Map>)=>{ + const authenticationMethod = settings.authenticationMethod + + + const definitions: { + "openapi": string, + "info": { + "title": string, + "description": string, + "termsOfService": string, + "contact": { + "name": string, + "url": string, + "email": string, + }, + }, + "components": { + "securitySchemes": { + "apiKey": { + "type": string, + "name": string, + "in": string + + }, + "sso"?: { + "type": string, + "flows": { + "authorizationCode": { + "authorizationUrl": string, + "tokenUrl": string, + "scopes": { + "openid": string, + "profile": string, + "email": string, + "admin": string + } + } + } + } + }, + }, + "servers": [ + { + "url": string + } + ], + "paths": Record, + }>>, + "security": any[] + } = { + "openapi": "3.0.2", + "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": "", + }, + }, + "components": { + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "name": "apikey", + "in": "query" + + }, + }, + }, + "servers": [ + { + "url": "http://localhost:9001/api/2" + } + ], + "paths": {}, + "security": [] + } + + if (authenticationMethod === "apikey") { + definitions.security = [ + { + "apiKey": [] + } + ] + } else if (authenticationMethod === "sso") { + definitions.components.securitySchemes.sso = { + 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" + } + } + }, + } + + definitions.security = [ + { + "sso": [] + } + ] + } + + + + for (const [method, value] of mapping) { + for (const [path, mapping] of Object.entries(value)) { + const {apiVersion, functionName, summary, operationId, requestBody, responses, tags} = mapping + if (!definitions.paths[path]) { + definitions.paths[path] = {} + } + + const methodLowercased = method.toLowerCase() + + definitions.paths[path][methodLowercased] = { + summary: summary!, + operationId: operationId!, + responses, + tags: tags! + } + + if (method === GET) { + definitions.paths[path][methodLowercased].parameters = requestBody + } else { + definitions.paths[path][methodLowercased].requestBody = requestBody + } + } + } + return definitions +} + + export const expressCreateServer = async (hookName: string, {app}: ArgsExpressType) => { - mapping.set('GET', {}) - mapping.set('POST', {}) - mapping.set('PUT', {}) - mapping.set('DELETE', {}) - mapping.set('PATCH', {}) + mapping.set(GET, {}) + mapping.set(POST, {}) + mapping.set(PUT, {}) + mapping.set(DELETE, {}) + mapping.set(PATCH, {}) // Version 1 - mapping.get('POST')!["/groups"] = {apiVersion: '1', functionName: 'createGroup'} - mapping.get('GET')!["/pads"] = {apiVersion: '1', functionName: 'listPads'} - mapping.get('POST')!["/groups/createIfNotExistsFor"] = {apiVersion: '1', functionName: 'createGroupIfNotExistsFor'}; + mapping.get(POST)!["/groups"] = {apiVersion: '1', + functionName: 'createGroup', summary: 'Creates a new group', + operationId: 'createGroup', tags: ['group'], responses: prepareResponses({type: "object", properties: {groupID: {type: "string"}}}) + } + mapping.get(POST)!["/groups/createIfNotExistsFor"] = {apiVersion: '1', functionName: 'createGroupIfNotExistsFor', + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + groupMapper: { + type: "string" + } + }, + required: ["groupMapper"] + } + } + } + }, + responses: prepareResponses({type: "object", properties: {groupID: {type: "string"}}}), + summary: "Creates a new group if it doesn't exist", operationId: 'createGroupIfNotExistsFor', tags: ['group']}; + mapping.get(GET)!["/groups/pads"] = {apiVersion: '1', functionName: 'listPads', + summary: "Lists all pads in a group", tags: ['group'], + operationId: 'listPads', responses: prepareResponses({type: "object", properties: {padIDs: {type: "string"}}}), + requestBody: [ + { + "name": "groupID", + "in": "query", + "schema": { + "type": "string" + } + } + ] + } + mapping.get(DELETE)!["/groups"] = {apiVersion: '1', functionName: 'deleteGroup'} + mapping.get(POST)!["/authors"] = {apiVersion: '1', functionName: 'createAuthor'} + mapping.get(POST)!["/authors/createIfNotExistsFor"] = {apiVersion: '1', functionName: 'createAuthorIfNotExistsFor'} + mapping.get(GET)!["/authors/pads"] = {apiVersion: '1', functionName: 'listPadsOfAuthor'} + mapping.get(POST)!["/sessions"] = {apiVersion: '1', functionName: 'createSession'} + mapping.get(DELETE)!["/sessions"] = {apiVersion: '1', functionName: 'deleteSession'} + mapping.get(GET)!["/sessions/info"] = {apiVersion: '1', functionName: 'getSessionInfo'} + mapping.get(GET)!["/sessions/group"] = {apiVersion: '1', functionName: 'listSessionsOfGroup'} + mapping.get(GET)!["/sessions/author"] = {apiVersion: '1', functionName: 'listSessionsOfAuthor'} + mapping.get(GET)!["/pads/text"] = {apiVersion: '1', functionName: 'getText'} + mapping.get(GET)!["/pads/html"] = {apiVersion: '1', functionName: 'getHTML'} + mapping.get(GET)!["/pads/revisions"] = {apiVersion: '1', functionName: 'getRevisionsCount'} + mapping.get(GET)!["/pads/lastEdited"] = {apiVersion: '1', functionName: 'getLastEdited'} + mapping.get(DELETE)!["/pads"] = {apiVersion: '1', functionName: 'deletePad'} + mapping.get(GET)!["/pads/readonly"] = {apiVersion: '1', functionName: 'getReadOnlyID'} + mapping.get(POST)!["/pads/publicStatus"] = {apiVersion: '1', functionName: 'setPublicStatus'} + mapping.get(GET)!["/pads/publicStatus"] = {apiVersion: '1', functionName: 'getPublicStatus'} + mapping.get(GET)!["/pads/authors"] = {apiVersion: '1', functionName: 'listAuthorsOfPad'} + mapping.get(GET)!["/pads/usersCount"] = {apiVersion: '1', functionName: 'padUsersCount'} + + + // Version 1.1 + mapping.get(GET)!["/authors/name"] = {apiVersion: '1.1', functionName: 'getAuthorName'} + mapping.get(GET)!["/pads/users"] = {apiVersion: '1.1', functionName: 'padUsers'} + mapping.get(POST)!["/pads/sendClientsMessage"] = {apiVersion: '1.1', functionName: 'sendClientsMessage'} + mapping.get(GET)!["/groups"] = {apiVersion: '1.1', functionName: 'listAllGroups'} + + + // Version 1.2 + mapping.get(GET)!["/checkToken"] = {apiVersion: '1.2', functionName: 'checkToken'} + + // Version 1.2.1 + mapping.get(GET)!["/pads"] = {apiVersion: '1.2.1', functionName: 'listAllPads'} + + // Version 1.2.7 + mapping.get(POST)!["/pads/diff"] = {apiVersion: '1.2.7', functionName: 'createDiffHTML'} + mapping.get(GET)!["/pads/chatHistory"] = {apiVersion: '1.2.7', functionName: 'getChatHistory'} + mapping.get(GET)!["/pads/chatHead"] = {apiVersion: '1.2.7', functionName: 'getChatHead'} + + // Version 1.2.8 + mapping.get(GET)!["/pads/attributePool"] = {apiVersion: '1.2.8', functionName: 'getAttributePool'} + mapping.get(GET)!["/pads/revisionChangeset"] = {apiVersion: '1.2.8', functionName: 'getRevisionChangeset'} + + // Version 1.2.9 + mapping.get(POST)!["/pads/copypad"] = {apiVersion: '1.2.9', functionName: 'copyPad'} + mapping.get(POST)!["/pads/movepad"] = {apiVersion: '1.2.9', functionName: 'movePad'} + + // Version 1.2.10 + mapping.get(POST)!["/pads/padId"] = {apiVersion: '1.2.10', functionName: 'getPadID'} + + // Version 1.2.11 + mapping.get(GET)!["/savedRevisions"] = {apiVersion: '1.2.11', functionName: 'listSavedRevisions'} + mapping.get(POST)!["/savedRevisions"] = {apiVersion: '1.2.11', functionName: 'saveRevision'} + mapping.get(GET)!["/savedRevisions/revisionsCount"] = {apiVersion: '1.2.11', functionName: 'getSavedRevisionsCount'} + + // Version 1.2.12 + mapping.get(PATCH)!["/chats/messages"] = {apiVersion: '1.2.12', functionName: 'appendChatMessage'} + + // Version 1.2.13 + + // Version 1.2.14 + mapping.get(GET)!["/stats"] = {apiVersion: '1.2.14', functionName: 'getStats'} + + // Version 1.2.15 + + // Version 1.3.0 + mapping.get(PATCH)!["/pads/text"] = {apiVersion: '1.3.0', functionName: 'appendText'} + mapping.get(POST)!["/pads/copyWithoutHistory"] = {apiVersion: '1.3.0', functionName: 'copyPadWithoutHistory'} + mapping.get(POST)!["/pads/group"] = {apiVersion: '1.3.0', functionName: 'createGroupPad'} + mapping.get(POST)!["/pads"] = {apiVersion: '1.3.0', functionName: 'createPad'} + mapping.get(PATCH)!["/savedRevisions"] = {apiVersion: '1.3.0', functionName: 'restoreRevision'} + mapping.get(POST)!["/pads/html"] = {apiVersion: '1.3.0', functionName: 'setHTML'} + mapping.get(POST)!["/pads/text"] = {apiVersion: '1.3.0', functionName: 'setText'} + + + app.use('/api-docs', serve); + app.get('/api-docs', setup(undefined,{ + swaggerOptions: { + url: '/api-docs.json', + }, + })); + + app.use(express.json()); + + app.get('/api-docs.json', (req, res)=>{ + const generatedDefinition = prepareDefinition(mapping) + res.json(generatedDefinition) + }) app.use('/api/2', async (req, res, next) => { const method = req.method const pathToFunction = req.path @@ -34,12 +457,17 @@ export const expressCreateServer = async (hookName: string, {app}: ArgsExpressTy // read form data if method was POST let formData: MapArrayType = {}; - if (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]; + if (method.toLowerCase() === 'post') { + if (!req.headers['content-type'] || req.headers['content-type']!.startsWith('application/json')) { + // parse json + formData = req.body; + } else { + 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]; + } } } } @@ -49,29 +477,62 @@ export const expressCreateServer = async (hookName: string, {app}: ArgsExpressTy if (mapping.has(method) && pathToFunction in mapping.get(method)!) { const {apiVersion, functionName} = mapping.get(method)![pathToFunction]! // pass to api handler - let data; + let response; try { - data = await apiHandler.handle(apiVersion, functionName, fields, req, res); + try { + let data = await apiHandler.handle(apiVersion, functionName, fields, req, res); + + // return in common format + response = {code: 0, message: 'ok', data: data || null}; + } 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 + console.error(errCaused.stack || errCaused.toString()); + throw new createHTTPError.InternalServerError('internal error'); + } + } } 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 - console.error(errCaused.stack || errCaused.toString()); - throw new createHTTPError.InternalServerError('internal error'); + // 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; } } - // return in common format - const response = {code: 0, message: 'ok', data: data || null}; console.debug(`RESPONSE, ${functionName}, ${JSON.stringify(response)}`); diff --git a/src/node/hooks/express/specialpages.ts b/src/node/hooks/express/specialpages.ts index ce4a90bb4..bb6c6faf5 100644 --- a/src/node/hooks/express/specialpages.ts +++ b/src/node/hooks/express/specialpages.ts @@ -12,6 +12,7 @@ const webaccess = require('./webaccess'); const plugins = require('../../../static/js/pluginfw/plugin_defs'); import {build, buildSync} from 'esbuild' +import {ArgsExpressType} from "../../types/ArgsExpressType"; let ioI: { sockets: { sockets: any[]; }; } | null = null exports.socketio = (hookName: string, {io}: any) => { @@ -19,7 +20,7 @@ exports.socketio = (hookName: string, {io}: any) => { } -exports.expressPreSession = async (hookName:string, {app}:any) => { +exports.expressPreSession = async (hookName:string, {app}:ArgsExpressType) => { // This endpoint is intended to conform to: // https://www.ietf.org/archive/id/draft-inadarei-api-health-check-06.html app.get('/health', (req:any, res:any) => { @@ -243,7 +244,7 @@ const convertTypescriptWatched = (content: string, cb: (output:string, hash: str }) } -exports.expressCreateServer = async (hookName: string, args: any, cb: Function) => { +exports.expressCreateServer = async (hookName: string, args: ArgsExpressType, cb: Function) => { const padString = eejs.require('ep_etherpad-lite/templates/padBootstrap.js', { pluginModules: (() => { const pluginModules = new Set(); @@ -369,4 +370,8 @@ exports.expressCreateServer = async (hookName: string, args: any, cb: Function) // express-session automatically calls req.session.touch() so we don't need to do it here. res.json({status: 'ok'}); }); + + + + }; diff --git a/src/package.json b/src/package.json index 75281e338..3c0b44395 100644 --- a/src/package.json +++ b/src/package.json @@ -69,6 +69,7 @@ "socket.io": "^4.7.5", "socket.io-client": "^4.7.5", "superagent": "10.1.0", + "swagger-ui-express": "^5.0.1", "tinycon": "0.6.8", "tsx": "4.19.1", "ueberdb2": "^5.0.2", @@ -97,6 +98,7 @@ "@types/semver": "^7.5.8", "@types/sinon": "^17.0.3", "@types/supertest": "^6.0.2", + "@types/swagger-ui-express": "^4.1.6", "@types/underscore": "^1.11.15", "@types/whatwg-mimetype": "^3.0.2", "chokidar": "^4.0.0",