Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

25 move open api converter #28

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions .github/workflows/ci-open-api-converter.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
name: OpenAPI Converter CI Pipeline

on:
push:
branches: [main]
paths:
- "node/open-api-converter/**"
pull_request:
branches: [main]
paths:
- "node/open-api-converter/**"
workflow_dispatch:

defaults:
run:
working-directory: node/open-api-converter

jobs:
setup-and-test:
name: Tests (${{ matrix.os }}, Node ${{ matrix.node-version }})
runs-on: ${{ matrix.os }}

strategy:
matrix:
os: [ubuntu-latest]
node-version: [18.x, 20.x]

timeout-minutes: 30

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Use Node.js ${{ matrix.os }} ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
cache: "npm"

- name: Install Dependencies
run: npm ci

- name: Test
timeout-minutes: 10
run: npm run test:coverage

- name: Upload to codecov.io
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
file: coverage/lcov.info
verbose: true
2 changes: 2 additions & 0 deletions node/open-api-converter/.npmignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# empty ignore file, to include the "dist" directory in the package
out
38 changes: 38 additions & 0 deletions node/open-api-converter/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Eclipse Thingweb - TD to OpenAPI Converter

The package supports OpenAPI instance generation (output as `json` or `yaml`), using a Thing Description (TD) as input.

## Usage

This package can integrate OpenAPI instance generation from a TD in your application.

- Install this package via NPM (`npm install @thingweb/open-api-converter`) (or clone the repo, change to `node/open-api-converter`, and install the package with `npm install`)

- Node.js or Browser import:

- Node.js: Require the package and use the functions

```javascript
const tdToOpenAPI = require("@thingweb/open-api-converter");
```

- Browser: Import the `tdToOpenAPI` object as a global by adding a script tag to your HTML.

```html
<script src="./node_modules/@thingweb/open-api-converter/dist/web-bundle.min.js"></script>
```

- Now, you can convert a TD to an OpenAPI instance.

```javascript
tdToOpenAPI(td).then((OpenAPI) => {
console.log(JSON.stringify(OpenAPI.json, undefined, 2));
console.log(OpenAPI.yaml);
});
```

You can find usage examples in the [tests folder](./tests/),

## License

Licensed under the Eclipse or W3C license, see [License](https://github.com/eclipse-thingweb/td-tools/blob/main/LICENSE.md).
270 changes: 270 additions & 0 deletions node/open-api-converter/crawlPaths.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
/*
* Copyright (c) 2020 Contributors to the Eclipse Foundation
*
* See the NOTICE file(s) distributed with this work for additional
* information regarding copyright ownership.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License v. 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0, or the W3C Software Notice and
* Document License (2015-05-13) which is available at
* https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document.
*
* SPDX-License-Identifier: EPL-2.0 OR W3C-20150513
*/

const genInteraction = require("./genInteraction");
const { Server } = require("./definitions");
const { mapFormSecurity } = require("./mapSecurity");

module.exports = crawlPaths;

/**
* Generates the openAPI paths object from a TD
* @param {object} td The TD that is used as input
* @returns {object} The openAPI paths object
*/
function crawlPaths(td) {
const cPaths = new Map();
const interactionTypes = ["properties", "actions", "events"];
const httpBase = td.base && (td.base.startsWith("http://") || td.base.startsWith("https://")) ? true : false;

// crawl Interaction Affordances forms
interactionTypes.forEach((interactionType) => {
if (td[interactionType] !== undefined) {
// generate interactions tag
const tags = [interactionType];

Object.keys(td[interactionType]).forEach((interactionName) => {
const tdInteraction = td[interactionType][interactionName];
const { interactionInfo, interactionSchemas } = genInteraction(interactionName, tdInteraction, tags);

td[interactionType][interactionName].forms.forEach((form) => {
// define type
const mapDefaults = {
properties: ["readproperty", "writeproperty"],
actions: "invokeaction",
events: [],
};
const op = form.op ? form.op : mapDefaults[interactionType];

interactionInfo.description += "op:" + (typeof op === "string" ? op : op.join(", "));

addForm(form, interactionInfo, interactionSchemas, op, httpBase, cPaths, td.securityDefinitions);
});
});
}
});

// crawl multiple Interaction forms at the root-level of the TD
if (td.forms) {
td.forms.forEach((form) => {
// require op
if (form.op) {
// generate interactionInfo
const tags = ["rootInteractions"];
const summary = typeof form.op === "string" ? form.op : form.op.join(", ");
const interactionInfo = { tags, summary };

const interactionSchemas = { requestSchema: {}, responseSchema: {} };

addForm(form, interactionInfo, interactionSchemas, form.op, httpBase, cPaths);
}
});
}

// The method should return object instead of map
// So we need to convert all maps to objects before
cPaths.forEach((v, k) => cPaths.set(k, Object.fromEntries(v)));
return Object.fromEntries(cPaths);
}

/**
* Check if form has a relevant path/ the base Url is http(s)
* Generate some basic information common for all methods
* Find out which methods should be generated
* Call next function for further processing
* @param {object} form The element of the interactions forms array
* @param {object} interactionInfo The common interaction info
* @param {object} interactionSchemas The common request & response schemas
* @param {string|string[]} myOp The op property (or default value) of the form
* @param {boolean} httpBase Is there a httpBase
* @param {map<string, object>} cPaths The openAPI paths object being generated
* @param {object} tdSecurityDefinitions The TD security definitions object
*/
function addForm(form, interactionInfo, interactionSchemas, myOp, httpBase, cPaths, tdSecurityDefinitions) {
if (
form.href.startsWith("http://") ||
form.href.startsWith("https://") ||
(httpBase && form.href.indexOf("://") === -1)
) {
// add the operation
const { path, server } = extractPath(form.href);

// define the content type of the response
let contentType;
if (form.response && form.response.contentType) {
contentType = form.response.contentType;
} else {
// if response is not defined explicitly use general interaction content Type
if (form.contentType) {
contentType = form.contentType;
} else {
contentType = "application/json";
}
}

// define content type of request
let requestType;
if (form.contentType) {
requestType = form.contentType;
} else {
requestType = "application/json";
}
const types = { contentType, requestType };

// define methods by htv-property or op-property
let methods;
const htvMethods = ["GET", "PUT", "POST", "DELETE", "PATCH"];
if (form["htv:methodName"] && htvMethods.some((htvMethod) => htvMethod === form["htv:methodName"])) {
methods = [form["htv:methodName"].toLowerCase()];
} else {
methods = recognizeMethod(myOp);
}

// assume get as default method for longpoll eventing
if (methods.length === 0 && form.subprotocol && form.subprotocol === "longpoll") {
methods.push("get");
}

// get security stuff
const formInfo = mapFormSecurity(tdSecurityDefinitions, form.security, form.scopes);
if (formInfo.security.length > 0) {
Object.assign(interactionInfo, formInfo);
}

addPaths(methods, path, server, types, interactionInfo, interactionSchemas, cPaths);
}
}

/**
* Detect type of link and separate into server and path, e.g.:
* * Link `http://example.com/asdf/1`
* * Server `http://example.com`
* * Path `/asdf/1`
* @param {string} link The whole or partial URL
*/
function extractPath(link) {
let server;
let path;
if (link.startsWith("http://")) {
server = "http://" + link.slice(7).split("/").shift();
path = "/" + link.slice(7).split("/").slice(1).join("/");
} else if (link.startsWith("https://")) {
server = "https://" + link.slice(8).split("/").shift();
path = "/" + link.slice(8).split("/").slice(1).join("/");
} else {
path = link;
if (!path.startsWith("/")) {
path = "/" + path;
}
}
return { path, server };
}

/**
* Returns an array of http methods to describe e.g.: ["get", "put"]
* @param {array} ops the op values e.g.: ["readproperty", "writeproperty"]
*/
function recognizeMethod(ops) {
const mapping = {
readproperty: "get",
writeproperty: "put",
invokeaction: "post",
readallproperties: "get",
writeallproperties: "put",
readmultipleproperties: "get",
writemultipleproperties: "put",
};

const methods = [];
if (typeof ops === "string") {
ops = [ops];
}
ops.forEach((op) => {
if (Object.keys(mapping).some((prop) => prop === op)) {
methods.push(mapping[op]);
}
});

return methods;
}

/**
* Create/Adapt the OAP Paths with the found path+server+methods combinations
* @param {array} methods The methods found for this server&path combination
* @param {string} path The path (e.g. /asdf/1)
* @param {string} server The server (e.g. http://example.com)
* @param {{contentType: string, requestType: string}} types The content type of the response/request (e.g. application/json)
* @param {array} interactionInfo The interactionInfo associated to the form (one/some of Property, Action, Event)
* @param {object} interactionSchemas The common request & response schemas
* @param {map<string, object>} cPaths The paths object to extend
*/
function addPaths(methods, path, server, types, interactionInfo, interactionSchemas, cPaths) {
if (!cPaths.get(path) && methods.length > 0) {
cPaths.set(path, new Map());
}

methods.forEach((method) => {
// check if same method is already there (e.g. as http instead of https version)
if (cPaths.get(path).get(method)) {
if (server) {
if (cPaths.get(path).get(method).servers) {
if (
!cPaths
.get(path)
.get(method)
.servers.some((someServer) => someServer.url === server)
) {
cPaths.get(path).get(method).servers.push(new Server(server));
}
} else {
cPaths.get(path).get(method).servers = [new Server(server)];
}
}
} else {
cPaths.get(path).set(method, {
responses: {
200: {
description: "default success response",
content: {
[types.contentType]: interactionSchemas.responseSchema,
},
},
default: {
description: "some error",
content: {
[types.contentType]: {}, // assumption that an error message won't follow the general response schema
},
},
},
requestBody: {
content: {
[types.requestType]: interactionSchemas.requestSchema,
},
},
});

Object.assign(cPaths.get(path).get(method), interactionInfo);

if (method === "get") {
delete cPaths.get(path).get(method).requestBody;
}

// check if server is given (ain't the case for "base" url fragments) and add
if (server) {
cPaths.get(path).get(method).servers = [new Server(server)];
}
}
});
}
Loading
Loading