From 8c90875bc2b9101e25a927527b693318efd8f81b Mon Sep 17 00:00:00 2001 From: Jennie Ji Date: Tue, 21 Apr 2020 16:56:55 +0800 Subject: [PATCH] initial commit --- .gitignore | 1 + README.md | 155 ++++++++++++++++++++++++++++++++ bin/protobuf2swagger.js | 29 ++++++ package-lock.json | 102 +++++++++++++++++++++ package.json | 25 ++++++ utils/bakeRef.js | 5 ++ utils/convert.js | 38 ++++++++ utils/enum2JSON.js | 10 +++ utils/field2JSON.js | 24 +++++ utils/mapType.js | 30 +++++++ utils/message2JSON.js | 24 +++++ utils/processComponents.js | 24 +++++ utils/processOperationObject.js | 44 +++++++++ utils/processPaths.js | 39 ++++++++ 14 files changed, 550 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100755 bin/protobuf2swagger.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 utils/bakeRef.js create mode 100644 utils/convert.js create mode 100644 utils/enum2JSON.js create mode 100644 utils/field2JSON.js create mode 100644 utils/mapType.js create mode 100644 utils/message2JSON.js create mode 100644 utils/processComponents.js create mode 100644 utils/processOperationObject.js create mode 100644 utils/processPaths.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b512c09 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..369f5be --- /dev/null +++ b/README.md @@ -0,0 +1,155 @@ +protobuf2swagger +=== + +Work in progress project for saving some life, update not garrenteed. Welcome for pull request :). + +Main purpose is to convert [protobuf v2](https://developers.google.com/protocol-buffers/docs/proto) file to [openapi v3](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md) JSON schema with NodeJS, and merge with some custom open api configurations. +Then you may render it easily with [SwaggerUI](https://github.com/swagger-api/swagger-ui). + +# What is supported + +- convert *enum*, *message* into components, paths will reference to the components schema +- basic types mapping to JS type *number*, *string*, *boolean* ( long types will be mapped to *string*) +- recognize fields: + - [OperationObject](https://swagger.io/specification/#operationObject).requestBody.$proto + Replace requestBody with a [Reference Object](https://swagger.io/specification/#referenceObject) + - [OperationObject](https://swagger.io/specification/#operationObject).responses.$proto + Replace responses['200'] with a [Reference Object](https://swagger.io/specification/#referenceObject) + +# Install + +`npm i -g protobuf2swagger` + +# Cli Usage + +`protobuf2swagger [config_file]` + +| Argument | Description | +| --- | --- | +| config_file | Customize configuration file. Default to **protobuf2swagger.config.js** under current folder. | + +For options may check `protobuf2swagger --help`. (Nothing there yet, seriously.) + +## Config File + +Example: +```javascript +module.exports = { + file: 'test.proto', + dist: 'apischema.json', + customSchema: { // Similar to openapi v3 format + info: { + title: 'API', + version: '1.0.0', + contact: { + name: 'Jennie Ji', + email: 'jennie.ji@hotmail.com', + url: 'jennieji.github.io' + }, + }, + tags: [{ + name: 'test', + description: '' + }], + paths: { + '/api/test': { + get: { + requestBody: { + $proto: 'GetDataRequest', // Tell me the protobuf message name + }, + responses: { + $proto: 'GetDataResponse', // Tell me the protobuf message name + } + } + } + }, + components: { + securitySchemes: { + cookieAuth: { + type: 'apiKey', + in: 'cookie', + name: 'token' + } + } + }, + security: [{ + cookieAuth: [] + }] + } +}; +``` + +# Display with SwaggerUI + +index.html (modified from swagger-ui-dist) + +```html + + + + + API Document + + + + + +
+ + + + + + +``` + +Serve with simple [express](https://www.npmjs.com/package/express) server: + +```javascript +const express = require('express'); +const app = express(); + +app.use(express.static(__dirname /* path to index.html */)); +app.listen(3000); + +console.info('Served at port 3000'); +``` \ No newline at end of file diff --git a/bin/protobuf2swagger.js b/bin/protobuf2swagger.js new file mode 100755 index 0000000..c25bcf1 --- /dev/null +++ b/bin/protobuf2swagger.js @@ -0,0 +1,29 @@ +#!/usr/bin/env node +'use strict'; + +const path = require('path'); +const program = require('commander'); +const fs = require('fs'); +const convert = require('../utils/convert.js'); + +program.version('0.0.0', '-v --version') + .arguments('[config_file]') + .usage('[config_file]') + .on('--help', () => { + console.log('\n'); + console.log('config_file Customize configuration file. Default to protobuf2swagger.config.js under current folder.') + }) + .parse(process.argv); + +const [configPath] = program.args; +const cwd = process.cwd(); +const DEFAULT_CONFIG_PATH = 'protobuf2swagger.config.js'; +const config = require(path.resolve(cwd, configPath || DEFAULT_CONFIG_PATH)); + + +(async () => { + const content = await convert(config); + const dist = config.dist ? path.resolve(cwd, config.dist) : cwd; + fs.writeFileSync(dist, JSON.stringify(content, null, 2)); + console.info('Converted schema written into ', dist); +})(); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..ccacfbb --- /dev/null +++ b/package-lock.json @@ -0,0 +1,102 @@ +{ + "name": "protobuf2swagger", + "version": "0.0.0-alpha.1", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha1-m4sMxmPWaafY9vXQiToU00jzD78=" + }, + "@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==" + }, + "@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==" + }, + "@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha1-NVy8mLr61ZePntCV85diHx0Ga3A=" + }, + "@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha1-upn7WYYUr2VwDBYZ/wbUVLDYTEU=", + "requires": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha1-Xp4avctz/Ap8uLKR33jIy9l7h9E=" + }, + "@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha1-/yAOPnzyQp4tyvwRQIKOjMY48Ik=" + }, + "@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha1-bMKyDFya1q0NzP0hynZz2Nf79o0=" + }, + "@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha1-Cf0V8tbTq/qbZbw2ZQbWrXhG/1Q=" + }, + "@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha1-p3c2C1s5oaLlEG+OhY8v0tBgxXA=" + }, + "@types/long": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.0.tgz", + "integrity": "sha512-1w52Nyx4Gq47uuu0EVcsHBxZFJgurQ+rTKS3qMHxR1GY2T8c2AJYd6vZoZ9q1rupaDjU0yT+Jc2XTyXkjeMA+Q==" + }, + "@types/node": { + "version": "10.14.9", + "resolved": "https://registry.npmjs.org/@types/node/-/node-10.14.9.tgz", + "integrity": "sha512-NelG/dSahlXYtSoVPErrp06tYFrvzj8XLWmKA+X8x0W//4MqbUyZu++giUG/v0bjAT6/Qxa8IjodrfdACyb0Fg==" + }, + "commander": { + "version": "2.20.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.0.tgz", + "integrity": "sha512-7j2y+40w61zy6YC2iRNpUe/NwhNyoXrYpHMrSunaMG64nRnaf96zO/KMQR4OyN/UnE5KLyEBnKHd4aG3rskjpQ==" + }, + "long": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", + "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==" + }, + "protobufjs": { + "version": "6.8.8", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.8.8.tgz", + "integrity": "sha512-AAmHtD5pXgZfi7GMpllpO3q1Xw1OYldr+dMUlAnffGTAhqkg72WdmSY71uKBF/JuyiKs8psYbtKrhi0ASCD8qw==", + "requires": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/long": "^4.0.0", + "@types/node": "^10.1.0", + "long": "^4.0.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..143fc87 --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "protobuf2swagger", + "version": "0.0.0-alpha.1", + "description": "Convert protobuf to swagger open api v3 format", + "main": "index.js", + "bin": { + "protobuf2swagger": "./bin/protobuf2swagger.js" + }, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": { + "type": "git", + "url": "https://github.com/JennieJi/protobuf2swagger" + }, + "keywords": [ + "protobuf" + ], + "author": "Jennie Ji ", + "license": "MIT", + "dependencies": { + "commander": "^2.20.0", + "protobufjs": "^6.8.8" + } +} diff --git a/utils/bakeRef.js b/utils/bakeRef.js new file mode 100644 index 0000000..dcf86b8 --- /dev/null +++ b/utils/bakeRef.js @@ -0,0 +1,5 @@ +function bakeRef(name) { + return `#/components/schemas/${name}`; +} + +module.exports = bakeRef; \ No newline at end of file diff --git a/utils/convert.js b/utils/convert.js new file mode 100644 index 0000000..c82d539 --- /dev/null +++ b/utils/convert.js @@ -0,0 +1,38 @@ +const protobuf = require('protobufjs'); +const fs = require('fs'); +const processPaths = require('./processPaths.js'); +const processComponents = require('./processComponents'); + +const OPENAPI_VERSION = '3.0.0'; + +async function convert({ + file, + customSchema +}) { + // TODO: custom schema validate + const rootProto = await protobuf.parse(fs.readFileSync(file).toString(), { + alternateCommentMode: true + }); + const { paths: rawPaths, components: rawComponents } = customSchema; + return { + ...customSchema, + openapi: OPENAPI_VERSION, + paths: processPaths(rawPaths), + components: { + ...(rawComponents || {}), + schemas: { + ...processComponents(getProtoByPath(rootProto)), + ...(rawComponents || {}).schemas, + } + } + }; +} + +function getProtoByPath(proto) { + return proto.package + .split('.') + .reduce((data, path) => data.get(path), proto.root) + .nested; +} + +module.exports = convert; \ No newline at end of file diff --git a/utils/enum2JSON.js b/utils/enum2JSON.js new file mode 100644 index 0000000..0c9d7ee --- /dev/null +++ b/utils/enum2JSON.js @@ -0,0 +1,10 @@ +function enum2JSON(proto) { + const { values, comments } = proto; + return { + type: "number", + enum: Object.values(values), + description: Object.keys(values).map(key => `${values[key]} - ${key} ${comments[key] ? `// ${comments[key]}` : ''}`).join('
') + }; +} + +module.exports = enum2JSON; \ No newline at end of file diff --git a/utils/field2JSON.js b/utils/field2JSON.js new file mode 100644 index 0000000..24b2413 --- /dev/null +++ b/utils/field2JSON.js @@ -0,0 +1,24 @@ +const mapType = require('./mapType'); +const bakeRef = require('./bakeRef'); + +function field2JSON ({ + type, + comment: description, + repeated, +}) { + const mappedType = mapType(type); + const schema = mappedType ? { + type: mappedType, + description + } : { + $ref: bakeRef(type) + }; + + return repeated ? { + type: 'array', + items: schema, + description + } : schema; +} + +module.exports = field2JSON; \ No newline at end of file diff --git a/utils/mapType.js b/utils/mapType.js new file mode 100644 index 0000000..a765628 --- /dev/null +++ b/utils/mapType.js @@ -0,0 +1,30 @@ +const API_TYPES = { + number: 'number', + boolean: 'boolean', + string: 'string' +}; + +// TODO: find mapping in protobuf.js for jsdoc +const MAPPING = { + double: API_TYPES.string, + float: API_TYPES.number, + int32: API_TYPES.number, + int64: API_TYPES.string, + uint32: API_TYPES.number, + uint64: API_TYPES.string, + sint32: API_TYPES.number, + sint64: API_TYPES.string, + fixed32: API_TYPES.number, + fixed64: API_TYPES.string, + sfixed32: API_TYPES.number, + sfixed64: API_TYPES.string, + bool: API_TYPES.boolean, + string: API_TYPES.string, + bytes: API_TYPES.string +}; + +function mapType(type) { + return MAPPING[type]; +}; + +module.exports = mapType; \ No newline at end of file diff --git a/utils/message2JSON.js b/utils/message2JSON.js new file mode 100644 index 0000000..8fe5f59 --- /dev/null +++ b/utils/message2JSON.js @@ -0,0 +1,24 @@ + const field2JSON = require('./field2JSON.js'); + + function messageToJSON(proto) { + const { fields, comment: description } = proto; + let required = []; + const properties = Object.keys(fields).reduce((ret, field) => { + const fieldDef = fields[field]; + const { optional, required } = fieldDef; + if (!optional || required) { + required.push(field); + } + return { + ...ret, + [field]: field2JSON(fieldDef) + }; + }, {}); + return { + type: 'object', + properties, + required, + description + }; +} +module.exports = messageToJSON; \ No newline at end of file diff --git a/utils/processComponents.js b/utils/processComponents.js new file mode 100644 index 0000000..5bd91ae --- /dev/null +++ b/utils/processComponents.js @@ -0,0 +1,24 @@ +const message2JSON = require('./message2JSON'); +const enum2JSON = require('./enum2JSON'); + +function processComponents(defs) { + return Object.values(defs).reduce((ret, def) => { + const strDef = def.toString(); + const { name } = def; + if (/^Enum /.test(strDef)) { + return { + ...ret, + [name]: enum2JSON(def) + }; + } + if (/^Type /.test(strDef)) { + return { + ...ret, + [name]: message2JSON(def) + }; + } + return ret; + }, {}) +} + +module.exports = processComponents; \ No newline at end of file diff --git a/utils/processOperationObject.js b/utils/processOperationObject.js new file mode 100644 index 0000000..ab40d3e --- /dev/null +++ b/utils/processOperationObject.js @@ -0,0 +1,44 @@ +const bakeRef = require('./bakeRef'); + +const JSON_META_TYPE = 'application/json'; +const HTML_META_TYPE = 'text/html'; + +function processOperationObject(obj) { + const ret = { ...obj }; + // parameters + if (obj && Array.isArray(obj.parameters)) { + obj.parameters.forEach(({ $proto }, i) => { + if (!$proto) { return; } + ret.parameters[i] = { + $ref: bakeRef($proto) + }; + }); + } + // requestBody + const reqProto = obj && obj.requestBody && obj.requestBody.$proto; + if (reqProto) { + ret.requestBody = bakeContent(reqProto); + } + // responses + const resProto = obj && obj.responses && obj.responses.$proto; + if (resProto) { + ret.responses = { + '200': bakeContent(resProto), + }; + } + return ret; +} + +function bakeContent(protobufName) { + return { + content: { + [JSON_META_TYPE]: { + schema: { + $ref: bakeRef(protobufName) + } + } + } + }; +} + +module.exports = processOperationObject; \ No newline at end of file diff --git a/utils/processPaths.js b/utils/processPaths.js new file mode 100644 index 0000000..89b04f5 --- /dev/null +++ b/utils/processPaths.js @@ -0,0 +1,39 @@ +const processOperationObject = require('./processOperationObject.js'); + +const OPERATIONS = [ + 'get', + 'put', + 'post', + 'delete', + 'options', + 'head', + 'path', + 'trace' +]; + +function processPaths(paths) { + return Object.keys(paths).reduce((updatedPaths, route) => { + const raw = paths[route]; + const { operationId, summary } = raw; + // TODO: remove test code + const updatedRoute = OPERATIONS.reduce((ret, operation) => { + const rawOperation = raw[operation]; + return rawOperation ? { + ...ret, + [operation]: processOperationObject(rawOperation) + } : ret; + }, {}); + if (!operationId) { + updatedRoute.operationId = route; + } + if (!summary) { + updatedRoute.summary = ''; + } + return { + ...updatedPaths, + [route]: updatedRoute + }; + }, paths); +} + +module.exports = processPaths; \ No newline at end of file