@uns-kit/api 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +42 -0
- package/dist/api-example.d.ts +1 -0
- package/dist/api-example.js +54 -0
- package/dist/api-interfaces.d.ts +1 -0
- package/dist/api-interfaces.js +1 -0
- package/dist/app.d.ts +32 -0
- package/dist/app.js +113 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/routes/api.d.ts +6 -0
- package/dist/routes/api.js +48 -0
- package/dist/uns-api-plugin.d.ts +9 -0
- package/dist/uns-api-plugin.js +38 -0
- package/dist/uns-api-proxy.d.ts +34 -0
- package/dist/uns-api-proxy.js +290 -0
- package/package.json +46 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2023 Aljoša Vister
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# @uns-kit/api
|
|
2
|
+
|
|
3
|
+
`@uns-kit/api` exposes Express-based HTTP endpoints for UNS deployments. The plugin attaches a `createApiProxy` method to `UnsProxyProcess`, handles JWT/JWKS access control, and automatically publishes API metadata back into the Unified Namespace.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm add @uns-kit/api
|
|
9
|
+
# or
|
|
10
|
+
npm install @uns-kit/api
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Make sure `@uns-kit/core` is also installed; the plugin augments its runtime types.
|
|
14
|
+
|
|
15
|
+
## Example
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import UnsProxyProcess from "@uns-kit/core/uns/uns-proxy-process";
|
|
19
|
+
import unsApiPlugin, { type UnsProxyProcessWithApi } from "@uns-kit/api";
|
|
20
|
+
|
|
21
|
+
const process = new UnsProxyProcess("mqtt-broker:1883", { processName: "api-gateway" }) as UnsProxyProcessWithApi;
|
|
22
|
+
unsApiPlugin;
|
|
23
|
+
|
|
24
|
+
const api = await process.createApiProxy("gateway", { jwtSecret: "super-secret" });
|
|
25
|
+
await api.get("factory/", "status", {
|
|
26
|
+
apiDescription: "Factory status endpoint",
|
|
27
|
+
tags: ["status"],
|
|
28
|
+
});
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Scripts
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pnpm run typecheck
|
|
35
|
+
pnpm run build
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`build` emits both JavaScript and type declarations to `dist/`.
|
|
39
|
+
|
|
40
|
+
## License
|
|
41
|
+
|
|
42
|
+
MIT © Aljoša Vister
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import UnsProxyProcess from "@uns-kit/core/uns/uns-proxy-process";
|
|
2
|
+
import unsApiPlugin from "./uns-api-plugin";
|
|
3
|
+
import { ConfigFile } from "@uns-kit/core/config-file";
|
|
4
|
+
/**
|
|
5
|
+
* Load the configuration from a file.
|
|
6
|
+
* On the server, this file is provided by the `uns-datahub-controller`.
|
|
7
|
+
* In the development environment, you are responsible for creating and maintaining this file and its contents.
|
|
8
|
+
*/
|
|
9
|
+
const config = await ConfigFile.loadConfig();
|
|
10
|
+
/**
|
|
11
|
+
* Connect to the API proxy process
|
|
12
|
+
*/
|
|
13
|
+
const unsProxyProcess = new UnsProxyProcess(config.infra.host, { processName: config.uns.processName });
|
|
14
|
+
unsApiPlugin;
|
|
15
|
+
const apiOptions = config.uns?.jwksWellKnownUrl
|
|
16
|
+
? {
|
|
17
|
+
jwks: {
|
|
18
|
+
wellKnownJwksUrl: config.uns.jwksWellKnownUrl,
|
|
19
|
+
activeKidUrl: config.uns.kidWellKnownUrl,
|
|
20
|
+
},
|
|
21
|
+
}
|
|
22
|
+
: {
|
|
23
|
+
jwtSecret: "CHANGEME",
|
|
24
|
+
};
|
|
25
|
+
const apiInput = await unsProxyProcess.createApiProxy("templateUnsApiInput", apiOptions);
|
|
26
|
+
/**
|
|
27
|
+
* Register an API endpoint and event handler
|
|
28
|
+
*/
|
|
29
|
+
apiInput.get("sij/", "summary-1", {
|
|
30
|
+
tags: ["Tag1"],
|
|
31
|
+
apiDescription: "Test API endpoint 1",
|
|
32
|
+
queryParams: [
|
|
33
|
+
{ name: "filter", type: "string", required: true, description: "Filter za podatke" },
|
|
34
|
+
{ name: "limit", type: "number", required: false, description: "Koliko podatkov želiš" },
|
|
35
|
+
]
|
|
36
|
+
});
|
|
37
|
+
apiInput.get("sij/", "summary-2", {
|
|
38
|
+
tags: ["Tag2"],
|
|
39
|
+
apiDescription: "Test API endpoint 2",
|
|
40
|
+
queryParams: [
|
|
41
|
+
{ name: "filter", type: "string", required: true, description: "Filter za podatke" },
|
|
42
|
+
{ name: "limit", type: "number", required: false, description: "Koliko podatkov želiš" },
|
|
43
|
+
]
|
|
44
|
+
});
|
|
45
|
+
apiInput.event.on("apiGetEvent", (event) => {
|
|
46
|
+
try {
|
|
47
|
+
const appContext = event.req.appContext;
|
|
48
|
+
// Add SQL query or any other code here
|
|
49
|
+
event.res.send("OK");
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
event.res.status(400).send("Error");
|
|
53
|
+
}
|
|
54
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type { IApiProxyOptions, IGetEndpointOptions, QueryParamDef } from "@uns-kit/core/uns/uns-interfaces";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/app.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module dependencies.
|
|
3
|
+
*/
|
|
4
|
+
import { type Router } from "express";
|
|
5
|
+
import * as http from "http";
|
|
6
|
+
export default class App {
|
|
7
|
+
private expressApplication;
|
|
8
|
+
server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>;
|
|
9
|
+
private port;
|
|
10
|
+
router: Router;
|
|
11
|
+
private processName;
|
|
12
|
+
private instanceName;
|
|
13
|
+
swaggerSpec: {
|
|
14
|
+
openapi: string;
|
|
15
|
+
info: {
|
|
16
|
+
title: string;
|
|
17
|
+
version: string;
|
|
18
|
+
};
|
|
19
|
+
paths: Record<string, any>;
|
|
20
|
+
};
|
|
21
|
+
constructor(port: number, processName: string, instanceName: string, appContext?: any);
|
|
22
|
+
static getExternalIPv4(): string | null;
|
|
23
|
+
getSwaggerSpec(): {
|
|
24
|
+
openapi: string;
|
|
25
|
+
info: {
|
|
26
|
+
title: string;
|
|
27
|
+
version: string;
|
|
28
|
+
};
|
|
29
|
+
paths: Record<string, any>;
|
|
30
|
+
};
|
|
31
|
+
start(): Promise<void>;
|
|
32
|
+
}
|
package/dist/app.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module dependencies.
|
|
3
|
+
*/
|
|
4
|
+
import express from "express";
|
|
5
|
+
import * as http from "http";
|
|
6
|
+
import * as path from "path";
|
|
7
|
+
import cookieParser from "cookie-parser";
|
|
8
|
+
import { basePath } from "@uns-kit/core/base-path";
|
|
9
|
+
import logger from "@uns-kit/core/logger";
|
|
10
|
+
import os from 'os';
|
|
11
|
+
export default class App {
|
|
12
|
+
expressApplication;
|
|
13
|
+
server;
|
|
14
|
+
port;
|
|
15
|
+
router;
|
|
16
|
+
processName;
|
|
17
|
+
instanceName;
|
|
18
|
+
swaggerSpec;
|
|
19
|
+
constructor(port, processName, instanceName, appContext) {
|
|
20
|
+
this.router = express.Router();
|
|
21
|
+
this.port = port;
|
|
22
|
+
this.expressApplication = express();
|
|
23
|
+
this.server = http.createServer(this.expressApplication);
|
|
24
|
+
this.processName = processName;
|
|
25
|
+
this.instanceName = instanceName;
|
|
26
|
+
// Add context
|
|
27
|
+
this.expressApplication.use((req, _res, next) => {
|
|
28
|
+
req.appContext = appContext;
|
|
29
|
+
next();
|
|
30
|
+
});
|
|
31
|
+
// Body parser (req.body)
|
|
32
|
+
this.expressApplication.use(express.json());
|
|
33
|
+
this.expressApplication.use(express.urlencoded({ extended: false }));
|
|
34
|
+
// Add cookie parser
|
|
35
|
+
this.expressApplication.use(cookieParser());
|
|
36
|
+
// Static / public folder
|
|
37
|
+
const publicHome = process.env.PUBLIC_HOME === null || process.env.PUBLIC_HOME === undefined
|
|
38
|
+
? "public"
|
|
39
|
+
: process.env.PUBLIC_HOME;
|
|
40
|
+
this.expressApplication.use(express.static(path.join(basePath, publicHome)));
|
|
41
|
+
// Map routes
|
|
42
|
+
this.router.use((_req, _res, next) => {
|
|
43
|
+
logger.info("Time: ", Date.now());
|
|
44
|
+
next();
|
|
45
|
+
});
|
|
46
|
+
this.expressApplication.use("/api", this.router);
|
|
47
|
+
// Swagger specs
|
|
48
|
+
this.swaggerSpec = {
|
|
49
|
+
openapi: "3.0.0",
|
|
50
|
+
info: {
|
|
51
|
+
title: "UNS API",
|
|
52
|
+
version: "1.0.0",
|
|
53
|
+
},
|
|
54
|
+
paths: {},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
static getExternalIPv4() {
|
|
58
|
+
const interfaces = os.networkInterfaces();
|
|
59
|
+
for (const name of Object.keys(interfaces)) {
|
|
60
|
+
for (const iface of interfaces[name] || []) {
|
|
61
|
+
if (iface.family === 'IPv4' && !iface.internal) {
|
|
62
|
+
return iface.address;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
getSwaggerSpec() {
|
|
69
|
+
return this.swaggerSpec;
|
|
70
|
+
}
|
|
71
|
+
async start() {
|
|
72
|
+
// Listen on provided port, on all network interfaces.
|
|
73
|
+
this.server.listen(this.port);
|
|
74
|
+
this.server.on("error", (error) => {
|
|
75
|
+
if (error.syscall !== "listen") {
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
const bind = typeof this.port === "string"
|
|
79
|
+
? `Pipe ${this.port}`
|
|
80
|
+
: `Port ${this.port}`;
|
|
81
|
+
// handle specific listen errors with friendly messages
|
|
82
|
+
switch (error.code) {
|
|
83
|
+
case "EACCES":
|
|
84
|
+
logger.error(`${bind} requires elevated privileges`);
|
|
85
|
+
process.exit(1);
|
|
86
|
+
break;
|
|
87
|
+
case "EADDRINUSE":
|
|
88
|
+
logger.error(`${bind} is already in use`);
|
|
89
|
+
process.exit(1);
|
|
90
|
+
break;
|
|
91
|
+
default:
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
this.server.on("listening", () => {
|
|
96
|
+
App.bind(this);
|
|
97
|
+
const addressInfo = this.server.address();
|
|
98
|
+
let ip;
|
|
99
|
+
let port;
|
|
100
|
+
if (addressInfo && typeof addressInfo === "object") {
|
|
101
|
+
ip = App.getExternalIPv4();
|
|
102
|
+
port = addressInfo.port;
|
|
103
|
+
}
|
|
104
|
+
else if (typeof addressInfo === "string") {
|
|
105
|
+
ip = App.getExternalIPv4();
|
|
106
|
+
port = "";
|
|
107
|
+
}
|
|
108
|
+
logger.info(`API listening on http://${ip}:${port}/api`);
|
|
109
|
+
logger.info(`Swagger openAPI on http://${ip}:${port}/${this.processName}/${this.instanceName}/swagger.json`);
|
|
110
|
+
this.expressApplication.get(`/${this.processName}/${this.instanceName}/swagger.json`, (req, res) => res.json(this.getSwaggerSpec()));
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import express from "express";
|
|
2
|
+
import multer from "multer";
|
|
3
|
+
import logger from "@uns-kit/core/logger";
|
|
4
|
+
export default class Api {
|
|
5
|
+
router;
|
|
6
|
+
upload;
|
|
7
|
+
constructor() {
|
|
8
|
+
const storage = multer.diskStorage({
|
|
9
|
+
destination: function (req, file, cb) {
|
|
10
|
+
cb(null, "tmp/");
|
|
11
|
+
},
|
|
12
|
+
filename: function (req, file, cb) {
|
|
13
|
+
const uniqueSuffix = Date.now() + "-" + Math.round(Math.random() * 1e9);
|
|
14
|
+
cb(null, file.fieldname + "-" + uniqueSuffix + ".jpg");
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
this.upload = multer({ storage: storage });
|
|
18
|
+
this.router = express.Router();
|
|
19
|
+
this.router.use((req, res, next) => {
|
|
20
|
+
logger.info("Time: ", Date.now());
|
|
21
|
+
next();
|
|
22
|
+
});
|
|
23
|
+
/**
|
|
24
|
+
* Open for all
|
|
25
|
+
*
|
|
26
|
+
* Example post request
|
|
27
|
+
*/
|
|
28
|
+
this.router.post("/call", async function (req, res) {
|
|
29
|
+
try {
|
|
30
|
+
const appContext = req.appContext;
|
|
31
|
+
res.send("OK");
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
res.status(400).send("Error");
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
/**
|
|
38
|
+
* Open for all
|
|
39
|
+
*
|
|
40
|
+
* Upload files
|
|
41
|
+
*/
|
|
42
|
+
this.router.post("/upload", this.upload.single("file"), function (req, res) {
|
|
43
|
+
const appContext = req.appContext;
|
|
44
|
+
logger.info(req.file);
|
|
45
|
+
res.send("Successfully uploaded files");
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { IApiProxyOptions } from "@uns-kit/core/uns/uns-interfaces";
|
|
2
|
+
import UnsProxyProcess, { type UnsProxyProcessPlugin } from "@uns-kit/core/uns/uns-proxy-process";
|
|
3
|
+
import UnsApiProxy from "./uns-api-proxy.js";
|
|
4
|
+
declare const unsApiPlugin: UnsProxyProcessPlugin;
|
|
5
|
+
export default unsApiPlugin;
|
|
6
|
+
export { UnsApiProxy };
|
|
7
|
+
export type UnsProxyProcessWithApi = UnsProxyProcess & {
|
|
8
|
+
createApiProxy(instanceName: string, options: IApiProxyOptions): Promise<UnsApiProxy>;
|
|
9
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import UnsProxyProcess from "@uns-kit/core/uns/uns-proxy-process";
|
|
2
|
+
import UnsApiProxy from "./uns-api-proxy.js";
|
|
3
|
+
const apiProxyRegistry = new WeakMap();
|
|
4
|
+
const getApiProxies = (instance) => {
|
|
5
|
+
let proxies = apiProxyRegistry.get(instance);
|
|
6
|
+
if (!proxies) {
|
|
7
|
+
proxies = [];
|
|
8
|
+
apiProxyRegistry.set(instance, proxies);
|
|
9
|
+
}
|
|
10
|
+
return proxies;
|
|
11
|
+
};
|
|
12
|
+
const unsApiPlugin = ({ define }) => {
|
|
13
|
+
define({
|
|
14
|
+
async createApiProxy(instanceName, options) {
|
|
15
|
+
await this.waitForProcessConnection();
|
|
16
|
+
const internals = this;
|
|
17
|
+
const unsApiProxy = new UnsApiProxy(internals.processName, instanceName, options);
|
|
18
|
+
unsApiProxy.event.on("unsProxyProducedTopics", (event) => {
|
|
19
|
+
internals.processMqttProxy.publish(event.statusTopic, JSON.stringify(event.producedTopics), {
|
|
20
|
+
retain: true,
|
|
21
|
+
properties: { messageExpiryInterval: 120000 },
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
unsApiProxy.event.on("unsProxyProducedApiEndpoints", (event) => {
|
|
25
|
+
internals.processMqttProxy.publish(event.statusTopic, JSON.stringify(event.producedApiEndpoints), {
|
|
26
|
+
retain: true,
|
|
27
|
+
properties: { messageExpiryInterval: 120000 },
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
internals.unsApiProxies.push(unsApiProxy);
|
|
31
|
+
getApiProxies(this).push(unsApiProxy);
|
|
32
|
+
return unsApiProxy;
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
};
|
|
36
|
+
UnsProxyProcess.use(unsApiPlugin);
|
|
37
|
+
export default unsApiPlugin;
|
|
38
|
+
export { UnsApiProxy };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { UnsAttribute } from "@uns-kit/core/uns/uns-interfaces";
|
|
2
|
+
import UnsProxy from "@uns-kit/core/uns/uns-proxy";
|
|
3
|
+
import { UnsTopics } from "@uns-kit/core/uns/uns-topics";
|
|
4
|
+
import { IApiProxyOptions, IGetEndpointOptions } from "@uns-kit/core/uns/uns-interfaces";
|
|
5
|
+
export default class UnsApiProxy extends UnsProxy {
|
|
6
|
+
instanceName: string;
|
|
7
|
+
private topicBuilder;
|
|
8
|
+
private processName;
|
|
9
|
+
protected processStatusTopic: string;
|
|
10
|
+
private app;
|
|
11
|
+
private options;
|
|
12
|
+
private jwksCache?;
|
|
13
|
+
constructor(processName: string, instanceName: string, options: IApiProxyOptions);
|
|
14
|
+
/**
|
|
15
|
+
* Unregister endpoint
|
|
16
|
+
* @param topic - The API topic
|
|
17
|
+
* @param attribute - The attribute for the topic.
|
|
18
|
+
* @param method - The HTTP method (e.g., "GET", "POST", "PUT", "DELETE").
|
|
19
|
+
*/
|
|
20
|
+
unregister(topic: UnsTopics, attribute: UnsAttribute, method: "GET" | "POST" | "PUT" | "DELETE"): Promise<void>;
|
|
21
|
+
/**
|
|
22
|
+
* Register a GET endpoint with optional JWT path filter.
|
|
23
|
+
* @param topic - The API topic
|
|
24
|
+
* @param attribute - The attribute for the topic.
|
|
25
|
+
* @param options.description - Optional description.
|
|
26
|
+
* @param options.tags - Optional tags.
|
|
27
|
+
*/
|
|
28
|
+
get(topic: UnsTopics, attribute: UnsAttribute, options?: IGetEndpointOptions): Promise<void>;
|
|
29
|
+
post(..._args: any[]): any;
|
|
30
|
+
private extractBearerToken;
|
|
31
|
+
private getPublicKeyFromJwks;
|
|
32
|
+
private fetchJwksKeys;
|
|
33
|
+
private certFromX5c;
|
|
34
|
+
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { readFileSync } from "fs";
|
|
2
|
+
import jwt from "jsonwebtoken";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import { createPublicKey } from "crypto";
|
|
5
|
+
import { basePath } from "@uns-kit/core/base-path";
|
|
6
|
+
import { UnsAttributeType } from "@uns-kit/core/graphql/schema";
|
|
7
|
+
import logger from "@uns-kit/core/logger";
|
|
8
|
+
import { MqttTopicBuilder } from "@uns-kit/core/uns-mqtt/mqtt-topic-builder";
|
|
9
|
+
import { UnsPacket } from "@uns-kit/core/uns/uns-packet";
|
|
10
|
+
import UnsProxy from "@uns-kit/core/uns/uns-proxy";
|
|
11
|
+
import { UnsTopicMatcher } from "@uns-kit/core/uns/uns-topic-matcher";
|
|
12
|
+
import App from "./app.js";
|
|
13
|
+
const packageJsonPath = path.join(basePath, "package.json");
|
|
14
|
+
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
|
|
15
|
+
export default class UnsApiProxy extends UnsProxy {
|
|
16
|
+
instanceName;
|
|
17
|
+
topicBuilder;
|
|
18
|
+
processName;
|
|
19
|
+
processStatusTopic;
|
|
20
|
+
app;
|
|
21
|
+
options;
|
|
22
|
+
jwksCache;
|
|
23
|
+
constructor(processName, instanceName, options) {
|
|
24
|
+
super();
|
|
25
|
+
this.options = options;
|
|
26
|
+
this.app = new App(0, processName, instanceName);
|
|
27
|
+
this.app.start();
|
|
28
|
+
this.instanceName = instanceName;
|
|
29
|
+
this.processName = processName;
|
|
30
|
+
// Create the topic builder using packageJson values and the processName.
|
|
31
|
+
this.topicBuilder = new MqttTopicBuilder(`uns-infra/${packageJson.name}/${packageJson.version}/${processName}/`);
|
|
32
|
+
// Generate the processStatusTopic using the builder.
|
|
33
|
+
this.processStatusTopic = this.topicBuilder.getProcessStatusTopic();
|
|
34
|
+
// Derive the instanceStatusTopic by appending the instance name.
|
|
35
|
+
this.instanceStatusTopic = this.processStatusTopic + instanceName + "/";
|
|
36
|
+
// Concatenate processName with instanceName for the worker identification.
|
|
37
|
+
this.instanceNameWithSuffix = `${processName}-${instanceName}`;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Unregister endpoint
|
|
41
|
+
* @param topic - The API topic
|
|
42
|
+
* @param attribute - The attribute for the topic.
|
|
43
|
+
* @param method - The HTTP method (e.g., "GET", "POST", "PUT", "DELETE").
|
|
44
|
+
*/
|
|
45
|
+
async unregister(topic, attribute, method) {
|
|
46
|
+
const fullPath = `/${topic}${attribute}`;
|
|
47
|
+
const apiPath = `/api${fullPath}`;
|
|
48
|
+
const methodKey = method.toLowerCase(); // Express stores method keys in lowercase
|
|
49
|
+
// Remove route from router
|
|
50
|
+
if (this.app.router?.stack) {
|
|
51
|
+
this.app.router.stack = this.app.router.stack.filter((layer) => {
|
|
52
|
+
return !(layer.route &&
|
|
53
|
+
layer.route.path === fullPath &&
|
|
54
|
+
layer.route.methods[methodKey]);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
// Remove from Swagger spec if path exists
|
|
58
|
+
if (this.app.swaggerSpec?.paths?.[apiPath]) {
|
|
59
|
+
delete this.app.swaggerSpec.paths[apiPath][methodKey];
|
|
60
|
+
// If no methods remain for the path, delete the whole path
|
|
61
|
+
if (Object.keys(this.app.swaggerSpec.paths[apiPath]).length === 0) {
|
|
62
|
+
delete this.app.swaggerSpec.paths[apiPath];
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// Unregister from internal endpoint tracking
|
|
66
|
+
this.unregisterApiEndpoint(topic, attribute);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Register a GET endpoint with optional JWT path filter.
|
|
70
|
+
* @param topic - The API topic
|
|
71
|
+
* @param attribute - The attribute for the topic.
|
|
72
|
+
* @param options.description - Optional description.
|
|
73
|
+
* @param options.tags - Optional tags.
|
|
74
|
+
*/
|
|
75
|
+
async get(topic, attribute, options) {
|
|
76
|
+
// Wait until the API server is started
|
|
77
|
+
while (this.app.server.listening === false) {
|
|
78
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
79
|
+
}
|
|
80
|
+
const time = UnsPacket.formatToISO8601(new Date());
|
|
81
|
+
try {
|
|
82
|
+
// Get ip and port from environment variables or defaults
|
|
83
|
+
const addressInfo = this.app.server.address();
|
|
84
|
+
let ip;
|
|
85
|
+
let port;
|
|
86
|
+
if (addressInfo && typeof addressInfo === "object") {
|
|
87
|
+
ip = App.getExternalIPv4();
|
|
88
|
+
port = addressInfo.port;
|
|
89
|
+
}
|
|
90
|
+
else if (typeof addressInfo === "string") {
|
|
91
|
+
ip = App.getExternalIPv4();
|
|
92
|
+
port = "";
|
|
93
|
+
}
|
|
94
|
+
this.registerApiEndpoint({
|
|
95
|
+
timestamp: time,
|
|
96
|
+
topic: topic,
|
|
97
|
+
attribute: attribute,
|
|
98
|
+
apiHost: `http://${ip}:${port}`,
|
|
99
|
+
apiEndpoint: `/api/${topic}${attribute}`,
|
|
100
|
+
apiMethod: "GET",
|
|
101
|
+
apiQueryParams: options.queryParams,
|
|
102
|
+
apiDescription: options?.apiDescription,
|
|
103
|
+
attributeType: UnsAttributeType.Api,
|
|
104
|
+
apiSwaggerEndpoint: `/${this.processName}/${this.instanceName}/swagger.json`,
|
|
105
|
+
});
|
|
106
|
+
const fullPath = `/${topic}${attribute}`;
|
|
107
|
+
const handler = (req, res) => {
|
|
108
|
+
// Query param validation
|
|
109
|
+
if (options?.queryParams) {
|
|
110
|
+
const missingParams = options.queryParams.filter((p) => p.required && req.query[p.name] === undefined).map((p) => p.name);
|
|
111
|
+
if (missingParams.length > 0) {
|
|
112
|
+
return res.status(400).json({ error: `Missing query params: ${missingParams.join(", ")}` });
|
|
113
|
+
}
|
|
114
|
+
// Optional: cast types (basic)
|
|
115
|
+
for (const param of options.queryParams) {
|
|
116
|
+
const value = req.query[param.name];
|
|
117
|
+
if (value !== undefined) {
|
|
118
|
+
switch (param.type) {
|
|
119
|
+
case "number":
|
|
120
|
+
if (isNaN(Number(value))) {
|
|
121
|
+
return res.status(400).json({ error: `Query param ${param.name} must be a number` });
|
|
122
|
+
}
|
|
123
|
+
break;
|
|
124
|
+
case "boolean":
|
|
125
|
+
if (!["true", "false", "1", "0"].includes(String(value))) {
|
|
126
|
+
return res.status(400).json({ error: `Query param ${param.name} must be boolean` });
|
|
127
|
+
}
|
|
128
|
+
break;
|
|
129
|
+
// string: no check
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
this.event.emit("apiGetEvent", { req, res });
|
|
135
|
+
};
|
|
136
|
+
// JWT or JWKS or open
|
|
137
|
+
if (this.options?.jwks?.wellKnownJwksUrl) {
|
|
138
|
+
this.app.router.get(fullPath, async (req, res) => {
|
|
139
|
+
try {
|
|
140
|
+
const token = this.extractBearerToken(req, res);
|
|
141
|
+
if (!token)
|
|
142
|
+
return; // response already sent
|
|
143
|
+
const publicKey = await this.getPublicKeyFromJwks(token);
|
|
144
|
+
const algorithms = this.options.jwks.algorithms || ["RS256"];
|
|
145
|
+
const decoded = jwt.verify(token, publicKey, { algorithms });
|
|
146
|
+
const accessRules = Array.isArray(decoded?.accessRules)
|
|
147
|
+
? decoded.accessRules
|
|
148
|
+
: (typeof decoded?.pathFilter === "string" && decoded.pathFilter.length > 0
|
|
149
|
+
? [decoded.pathFilter]
|
|
150
|
+
: undefined);
|
|
151
|
+
const allowed = Array.isArray(accessRules)
|
|
152
|
+
? accessRules.some((rule) => UnsTopicMatcher.matches(rule, fullPath))
|
|
153
|
+
: false;
|
|
154
|
+
if (!allowed) {
|
|
155
|
+
return res.status(403).json({ error: "Path not allowed by token access rules" });
|
|
156
|
+
}
|
|
157
|
+
handler(req, res);
|
|
158
|
+
}
|
|
159
|
+
catch (err) {
|
|
160
|
+
return res.status(401).json({ error: "Invalid token" });
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
else if (this.options?.jwtSecret) {
|
|
165
|
+
this.app.router.get(fullPath, (req, res) => {
|
|
166
|
+
const authHeader = req.headers["authorization"];
|
|
167
|
+
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
|
168
|
+
return res.status(401).json({ error: "Missing or invalid Authorization header" });
|
|
169
|
+
}
|
|
170
|
+
const token = authHeader.slice(7);
|
|
171
|
+
try {
|
|
172
|
+
const decoded = jwt.verify(token, process.env.JWT_SECRET || this.options.jwtSecret);
|
|
173
|
+
const accessRules = Array.isArray(decoded?.accessRules)
|
|
174
|
+
? decoded.accessRules
|
|
175
|
+
: (typeof decoded?.pathFilter === "string" && decoded.pathFilter.length > 0
|
|
176
|
+
? [decoded.pathFilter]
|
|
177
|
+
: undefined);
|
|
178
|
+
const allowed = Array.isArray(accessRules)
|
|
179
|
+
? accessRules.some((rule) => UnsTopicMatcher.matches(rule, fullPath))
|
|
180
|
+
: false;
|
|
181
|
+
if (!allowed) {
|
|
182
|
+
return res.status(403).json({ error: "Path not allowed by token access rules" });
|
|
183
|
+
}
|
|
184
|
+
handler(req, res);
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
return res.status(401).json({ error: "Invalid token" });
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
this.app.router.get(fullPath, handler);
|
|
193
|
+
}
|
|
194
|
+
if (this.app.swaggerSpec) {
|
|
195
|
+
this.app.swaggerSpec.paths = this.app.swaggerSpec.paths || {};
|
|
196
|
+
this.app.swaggerSpec.paths[`/api${fullPath}`] = {
|
|
197
|
+
get: {
|
|
198
|
+
summary: options?.apiDescription || "No description",
|
|
199
|
+
tags: options?.tags || [],
|
|
200
|
+
parameters: (options?.queryParams || []).map((p) => ({
|
|
201
|
+
name: p.name,
|
|
202
|
+
in: "query",
|
|
203
|
+
required: !!p.required,
|
|
204
|
+
schema: { type: p.type },
|
|
205
|
+
description: p.description,
|
|
206
|
+
})),
|
|
207
|
+
responses: {
|
|
208
|
+
"200": { description: "OK" },
|
|
209
|
+
"400": { description: "Bad Request" },
|
|
210
|
+
"401": { description: "Unauthorized" },
|
|
211
|
+
"403": { description: "Forbidden" },
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
logger.error(`${this.instanceNameWithSuffix} - Error publishing message to topic ${topic}${attribute}: ${error.message}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
post(..._args) {
|
|
222
|
+
// Implement POST logic or route binding here
|
|
223
|
+
return "POST called";
|
|
224
|
+
}
|
|
225
|
+
extractBearerToken(req, res) {
|
|
226
|
+
const authHeader = req.headers["authorization"];
|
|
227
|
+
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
|
228
|
+
res.status(401).json({ error: "Missing or invalid Authorization header" });
|
|
229
|
+
return undefined;
|
|
230
|
+
}
|
|
231
|
+
return authHeader.slice(7);
|
|
232
|
+
}
|
|
233
|
+
async getPublicKeyFromJwks(token) {
|
|
234
|
+
// Decode header to get kid
|
|
235
|
+
const decoded = jwt.decode(token, { complete: true });
|
|
236
|
+
const kid = decoded?.header?.kid;
|
|
237
|
+
const keys = await this.fetchJwksKeys();
|
|
238
|
+
let jwk = kid ? keys.find((k) => k.kid === kid) : undefined;
|
|
239
|
+
// If no kid match and activeKidUrl configured, try that
|
|
240
|
+
if (!jwk && this.options?.jwks?.activeKidUrl) {
|
|
241
|
+
try {
|
|
242
|
+
const resp = await fetch(this.options.jwks.activeKidUrl);
|
|
243
|
+
if (resp.ok) {
|
|
244
|
+
const activeKid = await resp.text();
|
|
245
|
+
jwk = keys.find((k) => k.kid === activeKid.trim());
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
catch (_) {
|
|
249
|
+
// ignore and fall through
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
// If still not found but only one key, use it
|
|
253
|
+
if (!jwk && keys.length === 1) {
|
|
254
|
+
jwk = keys[0];
|
|
255
|
+
}
|
|
256
|
+
if (!jwk) {
|
|
257
|
+
throw new Error("Signing key not found in JWKS");
|
|
258
|
+
}
|
|
259
|
+
// Prefer x5c certificate if provided
|
|
260
|
+
if (Array.isArray(jwk.x5c) && jwk.x5c.length > 0) {
|
|
261
|
+
return this.certFromX5c(jwk.x5c[0]);
|
|
262
|
+
}
|
|
263
|
+
// Build PEM from JWK (RSA)
|
|
264
|
+
if (jwk.kty === "RSA" && jwk.n && jwk.e) {
|
|
265
|
+
const keyObj = createPublicKey({ key: { kty: "RSA", n: jwk.n, e: jwk.e }, format: "jwk" });
|
|
266
|
+
return keyObj.export({ type: "spki", format: "pem" }).toString();
|
|
267
|
+
}
|
|
268
|
+
throw new Error("Unsupported JWK format");
|
|
269
|
+
}
|
|
270
|
+
async fetchJwksKeys() {
|
|
271
|
+
const ttl = this.options?.jwks?.cacheTtlMs ?? 5 * 60 * 1000; // default 5 minutes
|
|
272
|
+
const now = Date.now();
|
|
273
|
+
if (this.jwksCache && now - this.jwksCache.fetchedAt < ttl) {
|
|
274
|
+
return this.jwksCache.keys;
|
|
275
|
+
}
|
|
276
|
+
const url = this.options.jwks.wellKnownJwksUrl;
|
|
277
|
+
const resp = await fetch(url);
|
|
278
|
+
if (!resp.ok) {
|
|
279
|
+
throw new Error(`Failed to fetch JWKS (${resp.status})`);
|
|
280
|
+
}
|
|
281
|
+
const body = await resp.json();
|
|
282
|
+
const keys = Array.isArray(body?.keys) ? body.keys : [];
|
|
283
|
+
this.jwksCache = { keys, fetchedAt: now };
|
|
284
|
+
return keys;
|
|
285
|
+
}
|
|
286
|
+
certFromX5c(x5cFirst) {
|
|
287
|
+
const pemBody = x5cFirst.match(/.{1,64}/g)?.join("\n") ?? x5cFirst;
|
|
288
|
+
return `-----BEGIN CERTIFICATE-----\n${pemBody}\n-----END CERTIFICATE-----\n`;
|
|
289
|
+
}
|
|
290
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@uns-kit/api",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Express-powered API gateway plugin for UnsProxyProcess with JWT/JWKS support.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Aljoša Vister <aljosa.vister@gmail.com>",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/uns-datahub/uns-kit.git",
|
|
11
|
+
"directory": "packages/uns-api"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"uns",
|
|
15
|
+
"api",
|
|
16
|
+
"express",
|
|
17
|
+
"http",
|
|
18
|
+
"mqtt",
|
|
19
|
+
"typescript"
|
|
20
|
+
],
|
|
21
|
+
"files": [
|
|
22
|
+
"dist"
|
|
23
|
+
],
|
|
24
|
+
"exports": {
|
|
25
|
+
"./*": "./dist/*"
|
|
26
|
+
},
|
|
27
|
+
"main": "dist/index.js",
|
|
28
|
+
"types": "dist/index.d.ts",
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@uns-kit/core": "^0.0.1",
|
|
31
|
+
"jsonwebtoken": "^9.0.2",
|
|
32
|
+
"cookie-parser": "^1.4.7",
|
|
33
|
+
"express": "^5.1.0",
|
|
34
|
+
"multer": "^2.0.2"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/jsonwebtoken": "^9.0.10",
|
|
38
|
+
"@types/cookie-parser": "^1.4.9",
|
|
39
|
+
"@types/express": "^5.0.3",
|
|
40
|
+
"@types/multer": "^2.0.0"
|
|
41
|
+
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"build": "tsc -p tsconfig.build.json",
|
|
44
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
45
|
+
}
|
|
46
|
+
}
|