From 42bc4af60a756100647d2475ce75f318b8d2144b Mon Sep 17 00:00:00 2001 From: Marielle Volz Date: Tue, 5 Oct 2021 12:56:27 +0100 Subject: [PATCH 1/2] Add OpenApi spec and Swagger documentation Add Swagger documentation to the root http://127.0.0.1:1969/?doc with OpenApi 3.0 specification at http://127.0.0.1:1969/?spec Addresses #76 Change-Id: Ide7b45e7dca90b3ccbbf8141358a66f8dc7b1187 --- README.md | 4 ++ package.json | 2 + spec.yaml | 142 ++++++++++++++++++++++++++++++++++++++++++++ src/server.js | 2 + src/specEndpoint.js | 136 ++++++++++++++++++++++++++++++++++++++++++ test/spec_test.js | 18 ++++++ 6 files changed, 304 insertions(+) create mode 100755 spec.yaml create mode 100644 src/specEndpoint.js create mode 100644 test/spec_test.js diff --git a/README.md b/README.md index 0ce2549..5e4f487 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,10 @@ It’s also possible to opt out of proxying for specific hosts by using the `NO_ ## Endpoints +### Documentation + +Swagger documentation is available at http://127.0.0.1:1969/?doc + ### Web Translation #### Retrieve metadata for a webpage: diff --git a/package.json b/package.json index 823d49f..480b4e1 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "aws-sdk": "^2.326.0", "config": "^1.30.0", "iconv-lite": "^0.4.24", + "js-yaml": "^4.1.0", "jsdom": "^13.1.0", "koa": "^2.13.1", "koa-bodyparser": "^4.3.0", @@ -20,6 +21,7 @@ "request": "^2.87.0", "request-promise-native": "^1.0.5", "serverless-http": "^1.6.0", + "swagger-ui-dist": "^3.52.3", "wicked-good-xpath": "git+https://git@github.com/zotero/wicked-good-xpath.git#1b88459", "xregexp": "^4.2.0", "yargs": "^12.0.2" diff --git a/spec.yaml b/spec.yaml new file mode 100755 index 0000000..e726173 --- /dev/null +++ b/spec.yaml @@ -0,0 +1,142 @@ +openapi: 3.0.0 +info: + version: 1.0.0 + title: Zotero translation-server + description: The Zotero translation server lets you use Zotero translators without the Zotero client. + license: + name: AGPL-3.0-only + url: https://spdx.org/licenses/AGPL-3.0-only.html +paths: + /web: + post: + tags: + - Web + description: Retrieve metadata for a webpage + requestBody: + required: true + content: + text/plain: + schema: + type: string + example: http://www.example.com + produces: + - application/json; charset=utf-8; + responses: + '200': + description: Returns an array of translated items in Zotero API JSON format + schema: + type: Array + '300': + description: Multiple items found + schema: + type: Object + properties: + url: + type: string + session: + type: string + items: + type: Object + '400': + description: Remote page not found + schema: + type: string + /search: + post: + tags: + - Search + description: Retrieve metadata from an identifier (DOI, ISBN, PMID, arXiv ID) + requestBody: + required: true + content: + text/plain: + schema: + type: string + encoding: + allowReserved: true + produces: + - application/json; charset=utf-8; + responses: + '200': + description: The citation data in the requested format + schema: + type: Array + '501': + description: Metadata for identifier was not found + schema: + type: string + /export: + post: + tags: + - Export + description: Convert items in Zotero API JSON format to a supported export format (RIS, BibTeX, etc.) + requestBody: + required: true + content: + application/json: + schema: + type: array + parameters: + - name: format + in: query + description: Requested format to covert body to + schema: + type: string + enum: + - bibtex + - biblatex + - bookmarks + - coins + - csljson + - csv + - endnote_xml + - evernote + - mods + - rdf_bibliontology + - rdf_dc + - rdf_zotero + - refer + - refworks_tagged + - ris + - tei + - wikipedia + required: true + produces: + - application/json; charset=utf-8; + responses: + '200': + description: The citation data in the requested format + '415': + description: Unsupported media type + schema: + type: string + '501': + description: Failed to translate + schema: + type: string + /import: + post: + tags: + - Import + description: Convert items in any import format to the Zotero API JSON format + requestBody: + required: true + content: + text/plain: + schema: + type: string + produces: + - application/json; charset=utf-8; + responses: + '200': + description: The citation data in the requested format + schema: + type: array + '415': + description: Unsupported media type + schema: + type: string + '500': + description: No suitable translators found + schema: + type: string diff --git a/src/server.js b/src/server.js index fc6083b..f5c0b59 100644 --- a/src/server.js +++ b/src/server.js @@ -44,6 +44,7 @@ const SearchEndpoint = require('./searchEndpoint'); const WebEndpoint = require('./webEndpoint'); const ExportEndpoint = require('./exportEndpoint'); const ImportEndpoint = require('./importEndpoint'); +const SpecEndpoint = require('./specEndpoint'); const app = module.exports = new Koa(); app.use(cors); @@ -52,6 +53,7 @@ app.use(_.post('/web', WebEndpoint.handle.bind(WebEndpoint))); app.use(_.post('/search', SearchEndpoint.handle.bind(SearchEndpoint))); app.use(_.post('/export', ExportEndpoint.handle.bind(ExportEndpoint))); app.use(_.post('/import', ImportEndpoint.handle.bind(ImportEndpoint))); +app.use(_.get('/', SpecEndpoint.handle.bind(SpecEndpoint))); Debug.init(process.env.DEBUG_LEVEL ? parseInt(process.env.DEBUG_LEVEL) : 1); Translators.init() diff --git a/src/specEndpoint.js b/src/specEndpoint.js new file mode 100644 index 0000000..1a2a547 --- /dev/null +++ b/src/specEndpoint.js @@ -0,0 +1,136 @@ +'use strict'; + +/* + ***** BEGIN LICENSE BLOCK ***** + + Copyright © 2018 Corporation for Digital Scholarship + Vienna, Virginia, USA + https://www.zotero.org + + This file is part of Zotero. + + Zotero is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Zotero is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with Zotero. If not, see . + + ***** END LICENSE BLOCK ***** +*/ + +const path = require('path'); +const fs = require('fs'); +const docRoot = `${require('swagger-ui-dist').absolutePath()}/`; +const DOC_CSP = "default-src 'none'; " + + "script-src 'self' 'unsafe-inline'; connect-src *; " + + "style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self';"; +const yaml = require('js-yaml'); + +function processRequest(ctx) { + + const reqPath = ctx.request.query.path || '/index.html'; + const filePath = path.join(docRoot, reqPath); + let contentType; + + // Disallow relative paths. + // Test relies on docRoot ending on a slash. + if (filePath.substring(0, docRoot.length) !== docRoot) { + Zotero.debug(`${reqPath} could not be found.`) + ctx.throw(404, "File not found\n"); + } + + let body = fs.readFileSync(filePath); + if (reqPath === '/index.html') { + const css = ` + /* Removes Swagger's image from the header bar */ + .topbar-wrapper .link img { + display: none; + } + /* Adds the application's name in the header bar */ + .topbar-wrapper .link::after { + content: zotero; + } + /* Removes input field and explore button from header bar */ + .swagger-ui .topbar .download-url-wrapper { + display: none; + } + /* Modifies the font in the information area */ + .swagger-ui .info li, .swagger-ui .info p, .swagger-ui .info table, .swagger-ui .info a { + font-size: 16px; + line-height: 1.4em; + } + /* Removes authorize button and section */ + .scheme-container { + display: none + } + `; + body = body.toString() + .replace(/((?:src|href)=['"])/g, '$1?doc&path=') + // Some self-promotion + .replace(/<\/style>/, `${css}\n `) + .replace(/[^<]*<\/title>/, `<title>Zotero's translation-server`) + // Replace the default url with ours, switch off validation & + // limit the size of documents to apply syntax highlighting to + .replace(/dom_id: '#swagger-ui'/, 'dom_id: "#swagger-ui", ' + + 'docExpansion: "none", defaultModelsExpandDepth: -1, validatorUrl: null, displayRequestDuration: true') + .replace(/"https:\/\/petstore.swagger.io\/v2\/swagger.json"/, + '"/?spec"'); + + contentType = 'text/html'; + } + if (/\.js$/.test(reqPath)) { + contentType = 'text/javascript'; + body = body.toString() + .replace(/underscore-min\.map/, '?doc&path=lib/underscore-min.map') + .replace(/sourceMappingURL=/, 'sourceMappingURL=/?doc&path='); + } else if (/\.png$/.test(reqPath)) { + contentType = 'image/png'; + } else if (/\.map$/.test(reqPath)) { + contentType = 'application/json'; + } else if (/\.ttf$/.test(reqPath)) { + contentType = 'application/x-font-ttf'; + } else if (/\.css$/.test(reqPath)) { + contentType = 'text/css'; + body = body.toString() + .replace(/\.\.\/(images|fonts)\//g, '?doc&path=$1/') + .replace(/sourceMappingURL=/, 'sourceMappingURL=/?doc&path='); + } + + ctx.set('content-type', contentType); + ctx.set('content-security-policy', DOC_CSP); + ctx.set('x-content-security-policy', DOC_CSP); + ctx.set('x-webkit-csp', DOC_CSP); + ctx.response.body = (body.toString()); + +} + +module.exports = { + handle: async function (ctx, _next) { + + let spec = path.resolve('spec.yaml'); + + if (spec.constructor !== Object) { + try { + spec = yaml.load(fs.readFileSync(spec));; + } catch (e) { + spec = {}; + } + } + + if ({}.hasOwnProperty.call(ctx.request.query || {}, 'spec')) { + ctx.set('content-type', 'application/json'); + ctx.response.body = spec; + } else if ({}.hasOwnProperty.call(ctx.request.query || {}, 'doc')) { + return processRequest(ctx); + } else { + _next(); + } + } +} diff --git a/test/spec_test.js b/test/spec_test.js new file mode 100644 index 0000000..bc15496 --- /dev/null +++ b/test/spec_test.js @@ -0,0 +1,18 @@ +/* global assert:false, request:false */ + +describe("/", function () { + it("should get doc page", async function () { + var response = await request() + .get('/?doc'); + assert.equal(response.statusCode, 200); + }); + + it("should get spec json", async function () { + var response = await request() + .get('/?spec'); + assert.equal(response.statusCode, 200); + var json = response.body; + assert.ok(json.openapi); + }); + +}); From 5052cb61219ccec518a762c867a1feb28164592f Mon Sep 17 00:00:00 2001 From: Marielle Volz Date: Tue, 5 Oct 2021 14:28:18 +0100 Subject: [PATCH 2/2] Convert spaces to tabs Change-Id: I81c8e5afd2e6146dfa67dcaaa06e42d5d7c27414 --- src/specEndpoint.js | 237 ++++++++++++++++++++++---------------------- 1 file changed, 118 insertions(+), 119 deletions(-) diff --git a/src/specEndpoint.js b/src/specEndpoint.js index 1a2a547..ad10114 100644 --- a/src/specEndpoint.js +++ b/src/specEndpoint.js @@ -1,136 +1,135 @@ 'use strict'; /* - ***** BEGIN LICENSE BLOCK ***** - - Copyright © 2018 Corporation for Digital Scholarship - Vienna, Virginia, USA - https://www.zotero.org - - This file is part of Zotero. - - Zotero is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - Zotero is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with Zotero. If not, see . - - ***** END LICENSE BLOCK ***** + ***** BEGIN LICENSE BLOCK ***** + + Copyright © 2018 Corporation for Digital Scholarship + Vienna, Virginia, USA + https://www.zotero.org + + This file is part of Zotero. + + Zotero is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Zotero is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with Zotero. If not, see . + + ***** END LICENSE BLOCK ***** */ const path = require('path'); const fs = require('fs'); const docRoot = `${require('swagger-ui-dist').absolutePath()}/`; const DOC_CSP = "default-src 'none'; " + - "script-src 'self' 'unsafe-inline'; connect-src *; " + - "style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self';"; + "script-src 'self' 'unsafe-inline'; connect-src *; " + + "style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self';"; const yaml = require('js-yaml'); function processRequest(ctx) { + + const reqPath = ctx.request.query.path || '/index.html'; + const filePath = path.join(docRoot, reqPath); + let contentType; + + // Disallow relative paths. + // Test relies on docRoot ending on a slash. + if (filePath.substring(0, docRoot.length) !== docRoot) { + Zotero.debug(`${reqPath} could not be found.`) + ctx.throw(404, "File not found\n"); + } + + let body = fs.readFileSync(filePath); + if (reqPath === '/index.html') { + const css = ` + /* Removes Swagger's image from the header bar */ + .topbar-wrapper .link img { + display: none; + } + /* Adds the application's name in the header bar */ + .topbar-wrapper .link::after { + content: zotero; + } + /* Removes input field and explore button from header bar */ + .swagger-ui .topbar .download-url-wrapper { + display: none; + } + /* Modifies the font in the information area */ + .swagger-ui .info li, .swagger-ui .info p, .swagger-ui .info table, .swagger-ui .info a { + font-size: 16px; + line-height: 1.4em; + } + /* Removes authorize button and section */ + .scheme-container { + display: none + } + `; + body = body.toString() + .replace(/((?:src|href)=['"])/g, '$1?doc&path=') + // Some self-promotion + .replace(/<\/style>/, `${css}\n `) + .replace(/[^<]*<\/title>/, `<title>Zotero's translation-server`) + // Replace the default url with ours, switch off validation & + // limit the size of documents to apply syntax highlighting to + .replace(/dom_id: '#swagger-ui'/, 'dom_id: "#swagger-ui", ' + + 'docExpansion: "none", defaultModelsExpandDepth: -1, validatorUrl: null, displayRequestDuration: true') + .replace(/"https:\/\/petstore.swagger.io\/v2\/swagger.json"/, + '"/?spec"'); - const reqPath = ctx.request.query.path || '/index.html'; - const filePath = path.join(docRoot, reqPath); - let contentType; - - // Disallow relative paths. - // Test relies on docRoot ending on a slash. - if (filePath.substring(0, docRoot.length) !== docRoot) { - Zotero.debug(`${reqPath} could not be found.`) - ctx.throw(404, "File not found\n"); - } - - let body = fs.readFileSync(filePath); - if (reqPath === '/index.html') { - const css = ` - /* Removes Swagger's image from the header bar */ - .topbar-wrapper .link img { - display: none; - } - /* Adds the application's name in the header bar */ - .topbar-wrapper .link::after { - content: zotero; - } - /* Removes input field and explore button from header bar */ - .swagger-ui .topbar .download-url-wrapper { - display: none; - } - /* Modifies the font in the information area */ - .swagger-ui .info li, .swagger-ui .info p, .swagger-ui .info table, .swagger-ui .info a { - font-size: 16px; - line-height: 1.4em; - } - /* Removes authorize button and section */ - .scheme-container { - display: none - } - `; - body = body.toString() - .replace(/((?:src|href)=['"])/g, '$1?doc&path=') - // Some self-promotion - .replace(/<\/style>/, `${css}\n `) - .replace(/[^<]*<\/title>/, `<title>Zotero's translation-server`) - // Replace the default url with ours, switch off validation & - // limit the size of documents to apply syntax highlighting to - .replace(/dom_id: '#swagger-ui'/, 'dom_id: "#swagger-ui", ' + - 'docExpansion: "none", defaultModelsExpandDepth: -1, validatorUrl: null, displayRequestDuration: true') - .replace(/"https:\/\/petstore.swagger.io\/v2\/swagger.json"/, - '"/?spec"'); - - contentType = 'text/html'; - } - if (/\.js$/.test(reqPath)) { - contentType = 'text/javascript'; - body = body.toString() - .replace(/underscore-min\.map/, '?doc&path=lib/underscore-min.map') - .replace(/sourceMappingURL=/, 'sourceMappingURL=/?doc&path='); - } else if (/\.png$/.test(reqPath)) { - contentType = 'image/png'; - } else if (/\.map$/.test(reqPath)) { - contentType = 'application/json'; - } else if (/\.ttf$/.test(reqPath)) { - contentType = 'application/x-font-ttf'; - } else if (/\.css$/.test(reqPath)) { - contentType = 'text/css'; - body = body.toString() - .replace(/\.\.\/(images|fonts)\//g, '?doc&path=$1/') - .replace(/sourceMappingURL=/, 'sourceMappingURL=/?doc&path='); - } - - ctx.set('content-type', contentType); - ctx.set('content-security-policy', DOC_CSP); - ctx.set('x-content-security-policy', DOC_CSP); - ctx.set('x-webkit-csp', DOC_CSP); - ctx.response.body = (body.toString()); - + contentType = 'text/html'; + } + if (/\.js$/.test(reqPath)) { + contentType = 'text/javascript'; + body = body.toString() + .replace(/underscore-min\.map/, '?doc&path=lib/underscore-min.map') + .replace(/sourceMappingURL=/, 'sourceMappingURL=/?doc&path='); + } else if (/\.png$/.test(reqPath)) { + contentType = 'image/png'; + } else if (/\.map$/.test(reqPath)) { + contentType = 'application/json'; + } else if (/\.ttf$/.test(reqPath)) { + contentType = 'application/x-font-ttf'; + } else if (/\.css$/.test(reqPath)) { + contentType = 'text/css'; + body = body.toString() + .replace(/\.\.\/(images|fonts)\//g, '?doc&path=$1/') + .replace(/sourceMappingURL=/, 'sourceMappingURL=/?doc&path='); + } + + ctx.set('content-type', contentType); + ctx.set('content-security-policy', DOC_CSP); + ctx.set('x-content-security-policy', DOC_CSP); + ctx.set('x-webkit-csp', DOC_CSP); + ctx.response.body = (body.toString()); } module.exports = { - handle: async function (ctx, _next) { - - let spec = path.resolve('spec.yaml'); - - if (spec.constructor !== Object) { - try { - spec = yaml.load(fs.readFileSync(spec));; - } catch (e) { - spec = {}; - } - } - - if ({}.hasOwnProperty.call(ctx.request.query || {}, 'spec')) { - ctx.set('content-type', 'application/json'); - ctx.response.body = spec; - } else if ({}.hasOwnProperty.call(ctx.request.query || {}, 'doc')) { - return processRequest(ctx); - } else { - _next(); - } - } + handle: async function (ctx, _next) { + + let spec = path.resolve('spec.yaml'); + + if (spec.constructor !== Object) { + try { + spec = yaml.load(fs.readFileSync(spec));; + } catch (e) { + spec = {}; + } + } + + if ({}.hasOwnProperty.call(ctx.request.query || {}, 'spec')) { + ctx.set('content-type', 'application/json'); + ctx.response.body = spec; + } else if ({}.hasOwnProperty.call(ctx.request.query || {}, 'doc')) { + return processRequest(ctx); + } else { + _next(); + } + } }