diff --git a/README.md b/README.md index f795d17b2..5a36a28ba 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ API Elements adapters: - [API Blueprint Serializer](packages/apib-serializer) - [OpenAPI 2 Parser](packages/openapi2-parser) - [OpenAPI 3 Parser](packages/openapi3-parser) +- [OpenAPI 3 Serializer](packages/openapi3-serializer) ## Usage diff --git a/packages/openapi3-serializer/README.md b/packages/openapi3-serializer/README.md new file mode 100644 index 000000000..35a26dcad --- /dev/null +++ b/packages/openapi3-serializer/README.md @@ -0,0 +1,26 @@ +# API Elements: OpenAPI 3 Serializer + +[![NPM version](https://img.shields.io/npm/v/@apielements/openapi3-serializer.svg)](https://www.npmjs.org/package/@apielements/openapi3-serializer) +[![License](https://img.shields.io/npm/l/@apielements/openapi3-serializer.svg)](https://www.npmjs.org/package/@apielements/openapi3-serializer) + +This adapter provides support for serializing [OpenAPI 3.0](https://spec.openapis.org/oas/v3.0.3) in [Fury.js](https://github.com/apiaryio/api-elements.js/tree/master/packages/fury) from API Elements. + +## Install + +```sh +$ npm install @apielements/openapi3-serializer +``` + +## Usage + +```js +const fury = require('fury'); +const openapi3Serializer = require('@apielements/openapi3-serializer'); + +fury.use(openapi3Serializer); + +// Assume `api` is a Minim element instance, e.g. from `fury.parse(...)` +fury.serialize({ api, mediaType: 'application/vnd.oai.openapi' }, (error, content) => { + console.log(content); +}); +``` diff --git a/packages/openapi3-serializer/STATUS.md b/packages/openapi3-serializer/STATUS.md new file mode 100644 index 000000000..7296b021f --- /dev/null +++ b/packages/openapi3-serializer/STATUS.md @@ -0,0 +1,97 @@ +# API Element Support + +## Category Element + +| Class | Support | +|:--------------|:--------| +| api | ✓ | +| authSchemes | | +| hosts | | +| resourceGroup | ✓ | +| scenario | | +| transitions | | + +## Resource Element + +| Meta | Support | +|:------|:--------| +| title | ✓ | + +| Attributes | Support | +|:--------------|:--------| +| hosts | | +| href | ✓ | +| hrefVariables | ✓ | + +| Content | Support | +|:-----------------------|:--------| +| Copy Element | ✓ | +| Category Element | | +| Transition Element | | +| Data Structure Element | | + +## Href Variables: Member Element + +| Meta | Support | +|:------------|---------| +| description | | + +| Content | Support | +|:--------|---------| +| key | ✓ | +| value | | + +## Transition Element + +| Attributes | Support | +|:--------------|:--------| +| contentTypes | | +| hosts | | +| href | | +| hrefVariables | | +| relation | | + +| Content | Support | +|:-------------------------|:--------| +| Copy Element | | +| HTTP Transaction Element | ✓ | + +## HTTP Transaction Element + +| Attributes | Support | +|:-------------|:--------| +| authSchemes | | + +| Content | Support | +|:--------------|:--------| +| Copy Element | | +| HTTP Request | | +| HTTP Response | ✓ | + +## HTTP Request + +| Attributes | Support | +|:--------------|:--------| +| method | ✓ | +| href | | +| hrefVariables | | +| headers | | + +| Content | Support | +|:----------------|:--------| +| Copy | | +| Data Structure | | +| Asset | | + +## HTTP Response + +| Attributes | Support | +|:-------------|:--------| +| statusCode | ✓ | +| headers | | + +| Content | Support | +|:----------------|:--------| +| Copy | | +| Data Structure | | +| Asset | | diff --git a/packages/openapi3-serializer/lib/adapter.js b/packages/openapi3-serializer/lib/adapter.js new file mode 100644 index 000000000..0793fa6f0 --- /dev/null +++ b/packages/openapi3-serializer/lib/adapter.js @@ -0,0 +1,29 @@ +const yaml = require('js-yaml'); +const serializeApi = require('./serialize/serializeApi'); + +const name = 'openapi3'; +const openApiMediaType = 'application/vnd.oai.openapi'; +const openApiJsonMediaType = 'application/vnd.oai.openapi+json'; + +// Per https://github.com/OAI/OpenAPI-Specification/issues/110#issuecomment-364498200 +const mediaTypes = [ + openApiMediaType, + openApiJsonMediaType, +]; + +function serialize({ api, mediaType }) { + return new Promise((resolve, reject) => { + const document = serializeApi(api); + + + if (mediaType === openApiMediaType) { + resolve(yaml.dump(document)); + } else if (mediaType === openApiJsonMediaType) { + resolve(JSON.stringify(document)); + } else { + reject(new Error(`Unsupported media type ${mediaType}`)); + } + }); +} + +module.exports = { name, mediaTypes, serialize }; diff --git a/packages/openapi3-serializer/lib/serialize/serializeApi.js b/packages/openapi3-serializer/lib/serialize/serializeApi.js new file mode 100644 index 000000000..a56accf8d --- /dev/null +++ b/packages/openapi3-serializer/lib/serialize/serializeApi.js @@ -0,0 +1,60 @@ +const R = require('ramda'); +const serializeResource = require('./serializeResource'); + +const isResource = element => element.element === 'resource'; +const isCategory = element => element.element === 'category'; +const isResourceGroup = R.allPass([ + isCategory, + category => category.classes.contains('resourceGroup'), +]); +const isResourceOrResourceGroup = R.anyPass([isResource, isResourceGroup]); + +function convertUri(href) { + return href.toValue().replace(/{[+#./;?&](.*)\*?}/, ''); +} + +function serializeResourceGroup(category) { + let paths = {}; + + category.forEach((element) => { + if (isResource(element)) { + paths[convertUri(element.href)] = serializeResource(element); + } else if (isResourceOrResourceGroup(element)) { + paths = R.mergeAll([paths, serializeResourceGroup(element)]); + } + }); + + return paths; +} + +function serializeApi(api) { + const info = {}; + + const title = api.meta.get('title'); + if (title) { + info.title = title.toValue(); + } else { + info.title = 'API'; + } + + const version = api.attributes.get('version'); + if (version) { + info.version = version.toValue(); + } else { + info.version = 'Unknown'; + } + + if (api.copy.length > 0) { + info.description = api.copy.toValue().join('\n\n'); + } + + const document = { + openapi: '3.0.3', + info, + paths: serializeResourceGroup(api), + }; + + return document; +} + +module.exports = serializeApi; diff --git a/packages/openapi3-serializer/lib/serialize/serializeHrefVariables.js b/packages/openapi3-serializer/lib/serialize/serializeHrefVariables.js new file mode 100644 index 000000000..c6cd85ec0 --- /dev/null +++ b/packages/openapi3-serializer/lib/serialize/serializeHrefVariables.js @@ -0,0 +1,18 @@ +function serializeHrefVariables(href, hrefVariables) { + return hrefVariables.map((value, key) => { + const parameter = { + name: key.toValue(), + }; + + if (href.toValue().includes(`{${key.toValue()}}`)) { + parameter.in = 'path'; + } else { + // FIXME assuming parameter is query + parameter.in = 'query'; + } + + return parameter; + }); +} + +module.exports = serializeHrefVariables; diff --git a/packages/openapi3-serializer/lib/serialize/serializeHttpResponse.js b/packages/openapi3-serializer/lib/serialize/serializeHttpResponse.js new file mode 100644 index 000000000..916534998 --- /dev/null +++ b/packages/openapi3-serializer/lib/serialize/serializeHttpResponse.js @@ -0,0 +1,9 @@ +function serializeHttpResponse() { + const response = { + description: 'Unknown', + }; + + return response; +} + +module.exports = serializeHttpResponse; diff --git a/packages/openapi3-serializer/lib/serialize/serializeResource.js b/packages/openapi3-serializer/lib/serialize/serializeResource.js new file mode 100644 index 000000000..9cc052b9b --- /dev/null +++ b/packages/openapi3-serializer/lib/serialize/serializeResource.js @@ -0,0 +1,31 @@ +const serializeHrefVariables = require('./serializeHrefVariables'); +const serializeTransition = require('./serializeTransition'); + +function serializeResource(resource) { + const pathItem = {}; + + const title = resource.meta.get('title'); + if (title) { + pathItem.summary = title.toValue(); + } + + if (resource.copy.length > 0) { + pathItem.description = resource.copy.toValue().join('\n\n'); + } + + if (resource.hrefVariables) { + pathItem.parameters = serializeHrefVariables(resource.href, resource.hrefVariables); + } + + if (resource.transitions) { + resource.transitions.forEach((transition) => { + if (transition.method) { + pathItem[transition.method.toValue().toLowerCase()] = serializeTransition(transition); + } + }); + } + + return pathItem; +} + +module.exports = serializeResource; diff --git a/packages/openapi3-serializer/lib/serialize/serializeTransition.js b/packages/openapi3-serializer/lib/serialize/serializeTransition.js new file mode 100644 index 000000000..8d3a4633a --- /dev/null +++ b/packages/openapi3-serializer/lib/serialize/serializeTransition.js @@ -0,0 +1,19 @@ +const serializeHttpResponse = require('./serializeHttpResponse'); + +function serializeTransition(transition) { + const operation = { + responses: {}, + }; + + transition.transactions + .compactMap(transaction => transaction.response) + .forEach((response) => { + const statusCode = String(response.statusCode.toValue()); + operation.responses[statusCode] = serializeHttpResponse(response); + }); + + + return operation; +} + +module.exports = serializeTransition; diff --git a/packages/openapi3-serializer/package.json b/packages/openapi3-serializer/package.json new file mode 100644 index 000000000..440eaf7b8 --- /dev/null +++ b/packages/openapi3-serializer/package.json @@ -0,0 +1,32 @@ +{ + "name": "@apielements/openapi3-serializer", + "version": "0.1.0", + "description": "Open API Specification 3 API Elements Serializer", + "main": "./lib/adapter.js", + "repository": { + "type": "git", + "url": "https://github.com/apiaryio/api-elements.js.git" + }, + "scripts": { + "lint": "eslint .", + "test": "mocha --recursive test" + }, + "dependencies": { + "js-yaml": "^3.13.1", + "ramda": "0.27.0" + }, + "peerDependencies": { + "fury": "3.0.0-beta.14" + }, + "devDependencies": { + "chai": "^4.2.0", + "eslint": "^5.16.0", + "fury": "3.0.0-beta.14", + "mocha": "^7.1.1" + }, + "engines": { + "node": ">=8" + }, + "author": "Apiary.io ", + "license": "MIT" +} diff --git a/packages/openapi3-serializer/test/unit/adapter-test.js b/packages/openapi3-serializer/test/unit/adapter-test.js new file mode 100644 index 000000000..e47ede71b --- /dev/null +++ b/packages/openapi3-serializer/test/unit/adapter-test.js @@ -0,0 +1,44 @@ +const { expect } = require('chai'); +const { Fury } = require('fury'); + +const adapter = require('../../lib/adapter'); + +const fury = new Fury(); +fury.use(adapter); + +describe('Adapter', () => { + it('has a name', () => { + expect(adapter.name).to.equal('openapi3'); + }); + + it('has OpenAPI media types', () => { + expect(adapter.mediaTypes).to.deep.equal([ + 'application/vnd.oai.openapi', + 'application/vnd.oai.openapi+json', + ]); + }); + + it('serializes an API Element to OpenAPI 3 as YAML', (done) => { + const api = new fury.minim.elements.Category([], { classes: ['api'] }); + api.title = 'Polls API'; + api.attributes.set('version', '2.0.0'); + + fury.serialize({ api, mediaType: 'application/vnd.oai.openapi' }, (err, result) => { + expect(err).to.be.null; + expect(result).to.equal('openapi: 3.0.3\ninfo:\n title: Polls API\n version: 2.0.0\npaths: {}\n'); + done(); + }); + }); + + it('serializes an API Element to OpenAPI 3 as JSON', (done) => { + const api = new fury.minim.elements.Category([], { classes: ['api'] }); + api.title = 'Polls API'; + api.attributes.set('version', '2.0.0'); + + fury.serialize({ api, mediaType: 'application/vnd.oai.openapi+json' }, (err, result) => { + expect(err).to.be.null; + expect(result).to.equal('{"openapi":"3.0.3","info":{"title":"Polls API","version":"2.0.0"},"paths":{}}'); + done(); + }); + }); +}); diff --git a/packages/openapi3-serializer/test/unit/serialize/serializeApi-test.js b/packages/openapi3-serializer/test/unit/serialize/serializeApi-test.js new file mode 100644 index 000000000..8085bc2fa --- /dev/null +++ b/packages/openapi3-serializer/test/unit/serialize/serializeApi-test.js @@ -0,0 +1,90 @@ +const { expect } = require('chai'); +const { Fury } = require('fury'); + +const serializeApi = require('../../../lib/serialize/serializeApi'); + +const namespace = new Fury().minim; + +describe('#serializeApi', () => { + it('serializes empty API resources', () => { + const api = new namespace.elements.Category([], { classes: ['api'] }); + + const document = serializeApi(api); + + expect(document).to.deep.equal({ + openapi: '3.0.3', + info: { + title: 'API', + version: 'Unknown', + }, + paths: {}, + }); + }); + + describe('Info Object', () => { + it('serializes API resources with title', () => { + const api = new namespace.elements.Category([], { classes: ['api'] }); + api.title = 'Polls API'; + + const document = serializeApi(api); + expect(document.info.title).to.equal('Polls API'); + }); + + it('serializes API resources with version', () => { + const api = new namespace.elements.Category([], { classes: ['api'] }); + api.attributes.set('version', '1.0.0'); + + const document = serializeApi(api); + expect(document.info.version).to.equal('1.0.0'); + }); + + it('serializes API resources with copy', () => { + const api = new namespace.elements.Category([], { classes: ['api'] }); + api.push(new namespace.elements.Copy('Hello World')); + api.push(new namespace.elements.Copy('Another Copy')); + + const document = serializeApi(api); + expect(document.info.description).to.equal('Hello World\n\nAnother Copy'); + }); + }); + + describe('Paths Object', () => { + it('serializes resource in paths', () => { + const resource = new namespace.elements.Resource(); + resource.href = '/users'; + + const api = new namespace.elements.Category([resource], { classes: ['api'] }); + + const document = serializeApi(api); + expect(document.paths).to.deep.equal({ + '/users': {}, + }); + }); + + it('serializes resource inside resource group in paths', () => { + const resource = new namespace.elements.Resource(); + resource.href = '/users'; + + const resourceGroup = new namespace.elements.Category([resource], { classes: ['resourceGroup'] }); + + const api = new namespace.elements.Category([resourceGroup], { classes: ['api'] }); + + const document = serializeApi(api); + expect(document.paths).to.deep.equal({ + '/users': {}, + }); + }); + + it('serializes resource with URI Template in paths', () => { + const resource = new namespace.elements.Resource(); + resource.href = '/users/{username}{/segments}{?filter,tags*}{&foo}'; + + const api = new namespace.elements.Category([resource], { classes: ['api'] }); + + const document = serializeApi(api); + expect(document.paths).to.deep.equal({ + '/users/{username}': {}, + }); + }); + }); +}); diff --git a/packages/openapi3-serializer/test/unit/serialize/serializeHrefVariables-test.js b/packages/openapi3-serializer/test/unit/serialize/serializeHrefVariables-test.js new file mode 100644 index 000000000..fe4bbaf40 --- /dev/null +++ b/packages/openapi3-serializer/test/unit/serialize/serializeHrefVariables-test.js @@ -0,0 +1,38 @@ +const { expect } = require('chai'); +const { Fury } = require('fury'); + +const serializeHrefVariables = require('../../../lib/serialize/serializeHrefVariables'); + +const namespace = new Fury().minim; + +describe('#serializeHrefVariables', () => { + it('converts path hrefVariables to parameters', () => { + const href = new namespace.elements.String('/users/{username}'); + const hrefVariables = new namespace.elements.HrefVariables([ + new namespace.elements.Member('username'), + ]); + + const parameters = serializeHrefVariables(href, hrefVariables); + expect(parameters).to.deep.equal([ + { + name: 'username', + in: 'path', + }, + ]); + }); + + it('converts query hrefVariables to parameters', () => { + const href = new namespace.elements.String('/list/{?tags}'); + const hrefVariables = new namespace.elements.HrefVariables([ + new namespace.elements.Member('tags'), + ]); + + const parameters = serializeHrefVariables(href, hrefVariables); + expect(parameters).to.deep.equal([ + { + name: 'tags', + in: 'query', + }, + ]); + }); +}); diff --git a/packages/openapi3-serializer/test/unit/serialize/serializeHttpResponse-test.js b/packages/openapi3-serializer/test/unit/serialize/serializeHttpResponse-test.js new file mode 100644 index 000000000..883b71155 --- /dev/null +++ b/packages/openapi3-serializer/test/unit/serialize/serializeHttpResponse-test.js @@ -0,0 +1,17 @@ +const { expect } = require('chai'); +const { Fury } = require('fury'); + +const serializeHttpResponse = require('../../../lib/serialize/serializeHttpResponse'); + +const namespace = new Fury().minim; + +describe('#serializeHttpResponse', () => { + it('serializes empty response', () => { + const response = new namespace.elements.HttpResponse(); + + const operation = serializeHttpResponse(response); + expect(operation).to.deep.equal({ + description: 'Unknown', + }); + }); +}); diff --git a/packages/openapi3-serializer/test/unit/serialize/serializeResource-test.js b/packages/openapi3-serializer/test/unit/serialize/serializeResource-test.js new file mode 100644 index 000000000..3487061b2 --- /dev/null +++ b/packages/openapi3-serializer/test/unit/serialize/serializeResource-test.js @@ -0,0 +1,67 @@ +const { expect } = require('chai'); +const { Fury } = require('fury'); + +const serializeResource = require('../../../lib/serialize/serializeResource'); + +const namespace = new Fury().minim; + +describe('#serializeResource', () => { + it('serializes empty API resources', () => { + const resource = new namespace.elements.Resource(); + + const document = serializeResource(resource); + expect(document).to.deep.equal({}); + }); + + it('serializes resource title as summary', () => { + const resource = new namespace.elements.Resource(); + resource.title = 'User'; + + const pathItem = serializeResource(resource); + expect(pathItem.summary).to.equal('User'); + }); + + it('serializes resource copy as summary', () => { + const resource = new namespace.elements.Resource(); + resource.push(new namespace.elements.Copy('Hello')); + resource.push(new namespace.elements.Copy('Another Copy')); + + const pathItem = serializeResource(resource); + expect(pathItem.description).to.equal('Hello\n\nAnother Copy'); + }); + + it('serializes hrefVariables as parameters', () => { + const resource = new namespace.elements.Resource(); + resource.href = '/users/{username}{?tags}'; + resource.hrefVariables = new namespace.elements.HrefVariables(); + resource.hrefVariables.push(new namespace.elements.Member('username')); + resource.hrefVariables.push(new namespace.elements.Member('tags')); + + const pathItem = serializeResource(resource); + expect(pathItem.parameters).to.deep.equal([ + { + name: 'username', + in: 'path', + }, + { + name: 'tags', + in: 'query', + }, + ]); + }); + + it('serializes transition elements', () => { + const request = new namespace.elements.HttpRequest(); + request.method = 'GET'; + const transaction = new namespace.elements.HttpTransaction([request]); + + const transition = new namespace.elements.Transition([transaction]); + const resource = new namespace.elements.Resource([transition]); + resource.href = '/'; + + const pathItem = serializeResource(resource); + expect(pathItem.get).to.deep.equal({ + responses: {}, + }); + }); +}); diff --git a/packages/openapi3-serializer/test/unit/serialize/serializeTransition-test.js b/packages/openapi3-serializer/test/unit/serialize/serializeTransition-test.js new file mode 100644 index 000000000..886cc4aa8 --- /dev/null +++ b/packages/openapi3-serializer/test/unit/serialize/serializeTransition-test.js @@ -0,0 +1,34 @@ +const { expect } = require('chai'); +const { Fury } = require('fury'); + +const serializeTransition = require('../../../lib/serialize/serializeTransition'); + +const namespace = new Fury().minim; + +describe('#serializeTransition', () => { + it('serializes empty transition', () => { + const transition = new namespace.elements.Transition(); + + const operation = serializeTransition(transition); + expect(operation).to.deep.equal({ + responses: {}, + }); + }); + + it('serializes transition with http response', () => { + const response = new namespace.elements.HttpResponse(); + response.statusCode = 200; + + const transaction = new namespace.elements.HttpTransaction([response]); + const transition = new namespace.elements.Transition([transaction]); + + const operation = serializeTransition(transition); + expect(operation).to.deep.equal({ + responses: { + 200: { + description: 'Unknown', + }, + }, + }); + }); +});