@uns-kit/api 2.0.29 → 2.0.31

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/README.md CHANGED
@@ -1,21 +1,18 @@
1
1
  # @uns-kit/api
2
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.
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, automatically publishes API metadata back into the Unified Namespace, and serves a Swagger UI for every registered endpoint.
4
4
 
5
5
  Note: Apps built with uns-kit are intended to be managed by the **UNS Datahub controller**.
6
6
  For the MQTT topic registry published by the API plugin, see `../../docs/uns-topics.md`.
7
7
 
8
8
  ## uns-kit in context
9
9
 
10
- uns-kit is a batteries-included toolkit for Unified Namespace applications. It standardizes MQTT wiring, auth, config schemas, and scaffolding so you can focus on your API surface instead of boilerplate. The toolkit packages are:
11
-
12
10
  | Package | Description |
13
11
  | --- | --- |
14
- | [`@uns-kit/core`](https://github.com/uns-datahub/uns-kit/tree/main/packages/uns-core) | Base runtime utilities (UnsProxyProcess, MQTT helpers, configuration tooling, gRPC gateway support). |
15
- | [`@uns-kit/api`](https://github.com/uns-datahub/uns-kit/tree/main/packages/uns-api) | Express plugin that exposes HTTP endpoints, handles JWT/JWKS auth, and republishes API metadata to UNS. |
12
+ | [`@uns-kit/core`](https://github.com/uns-datahub/uns-kit/tree/main/packages/uns-core) | Base runtime (UnsProxyProcess, MQTT helpers, config tooling, gRPC gateway). |
13
+ | [`@uns-kit/api`](https://github.com/uns-datahub/uns-kit/tree/main/packages/uns-api) | Express plugin HTTP endpoints, JWT/JWKS auth, Swagger, UNS metadata. |
16
14
  | [`@uns-kit/cron`](https://github.com/uns-datahub/uns-kit/tree/main/packages/uns-cron) | Cron-driven scheduler that emits UNS events on a fixed cadence. |
17
- | [`@uns-kit/temporal`](https://github.com/uns-datahub/uns-kit/tree/main/packages/uns-temporal) | Temporal.io integration that wires workflows into UnsProxyProcess. |
18
- | [`@uns-kit/cli`](https://github.com/uns-datahub/uns-kit/tree/main/packages/uns-cli) | Command line tool for scaffolding new UNS applications. |
15
+ | [`@uns-kit/cli`](https://github.com/uns-datahub/uns-kit/tree/main/packages/uns-cli) | CLI for scaffolding new UNS applications. |
19
16
 
20
17
  ## Installation
21
18
 
@@ -25,43 +22,141 @@ pnpm add @uns-kit/api
25
22
  npm install @uns-kit/api
26
23
  ```
27
24
 
28
- Make sure `@uns-kit/core` is also installed; the plugin augments its runtime types.
25
+ `@uns-kit/core` must also be present — the plugin augments its `UnsProxyProcess` type.
29
26
 
30
- ## Example
27
+ ## How it works
31
28
 
32
- ```ts
33
- import UnsProxyProcess from "@uns-kit/core/uns/uns-proxy-process";
34
- import type { UnsProxyProcessWithApi } from "@uns-kit/api";
35
- import "@uns-kit/api"; // registers the plugin side-effect
29
+ 1. Import `@uns-kit/api` as a side-effect to register the plugin.
30
+ 2. Call `process.createApiProxy(instanceName, options)` to start an Express server.
31
+ 3. Register endpoints with `api.get(...)` or `api.post(...)`.
32
+ 4. Listen to `apiGetEvent` / `apiPostEvent` to handle incoming requests.
36
33
 
37
- async function main() {
38
- const process = new UnsProxyProcess("mqtt-broker:1883", { processName: "api-gateway" }) as UnsProxyProcessWithApi;
34
+ Every registered endpoint is:
35
+ - Automatically secured with JWT/JWKS or a shared secret.
36
+ - Published to the UNS controller as an API metadata record (topic, host, path, method).
37
+ - Added to the Swagger spec served at `/<processName>/<instanceName>/swagger.json`.
39
38
 
40
- const api = await process.createApiProxy("gateway", { jwtSecret: "super-secret" });
39
+ ## GET endpoint example
41
40
 
42
- api.get("factory/", "status", {
43
- apiDescription: "Factory status endpoint",
44
- tags: ["status"],
41
+ ```ts
42
+ import UnsProxyProcess from "@uns-kit/core/uns/uns-proxy-process";
43
+ import type { UnsEvents } from "@uns-kit/core";
44
+ import "@uns-kit/api";
45
+ import { type UnsProxyProcessWithApi } from "@uns-kit/api";
46
+
47
+ const config = await ConfigFile.loadConfig();
48
+ const proc = new UnsProxyProcess(config.infra.host!, { processName: config.uns.processName }) as UnsProxyProcessWithApi;
49
+
50
+ const api = await proc.createApiProxy("my-service", {
51
+ jwks: { wellKnownJwksUrl: config.uns.jwksWellKnownUrl },
52
+ // or for simple/dev deployments: jwtSecret: "CHANGEME"
53
+ });
54
+
55
+ // Register a GET endpoint
56
+ // Signature: api.get(topic, asset, objectType, objectId, attribute, options?)
57
+ await api.get(
58
+ "enterprise/site/area/line/",
59
+ "line-3-furnace",
60
+ "energy-resource",
61
+ "main-bus",
62
+ "current",
63
+ {
64
+ tags: ["Energy"],
65
+ apiDescription: "Current reading for line-3-furnace main-bus",
45
66
  queryParams: [
46
- { name: "from", type: "string", chatCanonical: "from" },
47
- { name: "to", type: "string", chatCanonical: "to" },
48
- { name: "limit", type: "number", chatCanonical: "limit", defaultValue: 200 },
67
+ { name: "from", type: "string", required: false, description: "Start of time range (ISO 8601)", chatCanonical: "from" },
68
+ { name: "to", type: "string", required: false, description: "End of time range (ISO 8601)", chatCanonical: "to" },
69
+ { name: "limit", type: "number", required: false, description: "Maximum records", chatCanonical: "limit", defaultValue: 100 },
49
70
  ],
50
- chatDefaults: {
51
- limit: 200,
71
+ chatDefaults: { limit: 100 },
72
+ }
73
+ );
74
+
75
+ // Handle incoming GET requests
76
+ api.event.on("apiGetEvent", (event: UnsEvents["apiGetEvent"]) => {
77
+ const { from, to, limit } = event.req.query;
78
+ // fetch your data here
79
+ event.res.json({ status: "ok", data: [] });
80
+ });
81
+ ```
82
+
83
+ ## POST endpoint example
84
+
85
+ ```ts
86
+ // Register a POST endpoint
87
+ // Signature: api.post(topic, asset, objectType, objectId, attribute, options?)
88
+ await api.post(
89
+ "enterprise/site/area/line/",
90
+ "line-3-furnace",
91
+ "energy-resource",
92
+ "main-bus",
93
+ "setpoint",
94
+ {
95
+ tags: ["Energy"],
96
+ apiDescription: "Write a new setpoint for line-3-furnace main-bus",
97
+ requestBody: {
98
+ description: "Setpoint payload",
99
+ required: true,
100
+ schema: {
101
+ type: "object",
102
+ required: ["value"],
103
+ properties: {
104
+ value: { type: "number", description: "Target setpoint value" },
105
+ unit: { type: "string", description: "Unit of measurement, e.g. A" },
106
+ },
107
+ },
52
108
  },
53
- });
109
+ }
110
+ );
111
+
112
+ // Handle incoming POST requests — body is pre-parsed JSON
113
+ api.event.on("apiPostEvent", (event: UnsEvents["apiPostEvent"]) => {
114
+ const { value, unit } = event.req.body;
115
+ // write/command logic here
116
+ event.res.json({ status: "ok", received: { value, unit } });
117
+ });
118
+ ```
54
119
 
55
- api.event.on("apiGetEvent", (event) => {
56
- event.res.json({ status: "ok" });
57
- });
58
- }
120
+ ## Endpoint signature
59
121
 
60
- void main();
61
122
  ```
123
+ api.get(topic, asset, objectType, objectId, attribute, options?)
124
+ api.post(topic, asset, objectType, objectId, attribute, options?)
125
+ ```
126
+
127
+ | Parameter | Type | Description |
128
+ |---|---|---|
129
+ | `topic` | `UnsTopics` | UNS topic path prefix (e.g. `"enterprise/site/area/line/"`) |
130
+ | `asset` | `UnsAsset` | Asset identifier (e.g. `"line-3-furnace"`) |
131
+ | `objectType` | `UnsObjectType` | UNS object type (e.g. `"energy-resource"`) |
132
+ | `objectId` | `UnsObjectId` | Object instance id (e.g. `"main-bus"`) |
133
+ | `attribute` | `UnsAttribute` | Attribute name (e.g. `"current"`) |
134
+ | `options` | `IGetEndpointOptions` / `IPostEndpointOptions` | Tags, description, query params / request body |
135
+
136
+ ## Auth options
137
+
138
+ | Option | When to use |
139
+ |---|---|
140
+ | `jwks.wellKnownJwksUrl` | Production — verifies RS256 tokens from the UNS controller JWKS endpoint |
141
+ | `jwks.activeKidUrl` | Optional companion to JWKS — narrows which key ID is active |
142
+ | `jwtSecret` | Development / simple deployments — symmetric secret |
143
+
144
+ Requests that fail auth return `401 Unauthorized`. Requests whose token `accessRules` do not match the endpoint path return `403 Forbidden`.
145
+
146
+ ## Unregistering an endpoint
147
+
148
+ ```ts
149
+ await api.unregister(topic, asset, objectType, objectId, attribute, "GET");
150
+ await api.unregister(topic, asset, objectType, objectId, attribute, "POST");
151
+ ```
152
+
153
+ This removes the route from Express, the Swagger spec, and the internal UNS endpoint registry.
154
+
155
+ ## registerCatchAll (uns-api-global only)
62
156
 
63
- `queryParams[].chatCanonical`, `queryParams[].defaultValue`, and `chatDefaults` are optional.
64
- When provided, they are published into OpenAPI vendor metadata (`x-uns-chat`) so assistant tooling can map canonical chat inputs (`from`, `to`, `limit`, `topic`, ...) to endpoint-specific query parameters.
157
+ `api.registerCatchAll()` is reserved for the **uns-api-global** microservice, which acts as a
158
+ catch-all gateway for an entire topic namespace. **Regular microservices must not call this**
159
+ use `api.get()` / `api.post()` for per-attribute endpoints instead.
65
160
 
66
161
  ## Scripts
67
162
 
@@ -1,7 +1,7 @@
1
1
  import { UnsAttribute } from "@uns-kit/core/uns/uns-interfaces.js";
2
2
  import UnsProxy from "@uns-kit/core/uns/uns-proxy.js";
3
3
  import { UnsTopics } from "@uns-kit/core/uns/uns-topics.js";
4
- import { IApiProxyOptions, IGetEndpointOptions } from "@uns-kit/core/uns/uns-interfaces.js";
4
+ import { IApiProxyOptions, IGetEndpointOptions, IPostEndpointOptions } from "@uns-kit/core/uns/uns-interfaces.js";
5
5
  import { UnsAsset } from "@uns-kit/core/uns/uns-asset.js";
6
6
  import { UnsObjectType, UnsObjectId } from "@uns-kit/core/uns/uns-object.js";
7
7
  export default class UnsApiProxy extends UnsProxy {
@@ -37,6 +37,10 @@ export default class UnsApiProxy extends UnsProxy {
37
37
  /**
38
38
  * Register a catch-all API mapping for a topic prefix (e.g., "sij/acroni/#").
39
39
  * Does not create individual API attribute nodes; the controller treats this as a fallback.
40
+ *
41
+ * This is intended for use by the uns-api-global microservice, which acts as a
42
+ * catch-all gateway for an entire topic namespace. Regular microservices should
43
+ * NOT call this — use registerGetEndpoint() for individual attribute endpoints instead.
40
44
  */
41
45
  registerCatchAll(topicPrefix: string, options?: {
42
46
  apiBase?: string;
@@ -47,7 +51,15 @@ export default class UnsApiProxy extends UnsProxy {
47
51
  tags?: string[];
48
52
  queryParams?: IGetEndpointOptions["queryParams"];
49
53
  }): Promise<void>;
50
- post(..._args: any[]): any;
54
+ /**
55
+ * Register a POST endpoint.
56
+ * @param topic - The API topic
57
+ * @param attribute - The attribute for the topic.
58
+ * @param options.apiDescription - Optional description.
59
+ * @param options.tags - Optional tags.
60
+ * @param options.requestBody - Optional request body schema for Swagger.
61
+ */
62
+ post(topic: UnsTopics, asset: UnsAsset, objectType: UnsObjectType, objectId: UnsObjectId, attribute: UnsAttribute, options?: IPostEndpointOptions): Promise<void>;
51
63
  private emitStatusMetrics;
52
64
  private registerHealthEndpoint;
53
65
  private extractBearerToken;
@@ -296,6 +296,10 @@ export default class UnsApiProxy extends UnsProxy {
296
296
  /**
297
297
  * Register a catch-all API mapping for a topic prefix (e.g., "sij/acroni/#").
298
298
  * Does not create individual API attribute nodes; the controller treats this as a fallback.
299
+ *
300
+ * This is intended for use by the uns-api-global microservice, which acts as a
301
+ * catch-all gateway for an entire topic namespace. Regular microservices should
302
+ * NOT call this — use registerGetEndpoint() for individual attribute endpoints instead.
299
303
  */
300
304
  async registerCatchAll(topicPrefix, options) {
301
305
  while (this.app.server.listening === false) {
@@ -379,9 +383,141 @@ export default class UnsApiProxy extends UnsProxy {
379
383
  swaggerPath,
380
384
  });
381
385
  }
382
- post(..._args) {
383
- // Implement POST logic or route binding here
384
- return "POST called";
386
+ /**
387
+ * Register a POST endpoint.
388
+ * @param topic - The API topic
389
+ * @param attribute - The attribute for the topic.
390
+ * @param options.apiDescription - Optional description.
391
+ * @param options.tags - Optional tags.
392
+ * @param options.requestBody - Optional request body schema for Swagger.
393
+ */
394
+ async post(topic, asset, objectType, objectId, attribute, options) {
395
+ while (this.app.server.listening === false) {
396
+ await new Promise((resolve) => setTimeout(resolve, 100));
397
+ }
398
+ const time = UnsPacket.formatToISO8601(new Date());
399
+ const fullPath = buildUnsRoutePath(topic, asset, objectType, objectId, attribute);
400
+ const apiPath = `${this.apiBasePrefix}${fullPath}`.replace(/\/{2,}/g, "/");
401
+ const swaggerPath = buildSwaggerPath(this.swaggerBasePrefix, this.processName, this.instanceName);
402
+ try {
403
+ const addressInfo = this.app.server.address();
404
+ let ip;
405
+ let port;
406
+ if (addressInfo && typeof addressInfo === "object") {
407
+ ip = App.getExternalIPv4();
408
+ port = addressInfo.port;
409
+ }
410
+ else if (typeof addressInfo === "string") {
411
+ ip = App.getExternalIPv4();
412
+ port = "";
413
+ }
414
+ this.registerApiEndpoint({
415
+ timestamp: time,
416
+ topic,
417
+ attribute,
418
+ apiHost: `http://${ip}:${port}`,
419
+ apiEndpoint: apiPath,
420
+ apiMethod: "POST",
421
+ apiQueryParams: [],
422
+ apiDescription: options?.apiDescription,
423
+ attributeType: UnsAttributeType.Api,
424
+ apiSwaggerEndpoint: swaggerPath,
425
+ asset,
426
+ objectType,
427
+ objectId,
428
+ });
429
+ const handler = (req, res) => {
430
+ this.event.emit("apiPostEvent", { req, res });
431
+ };
432
+ if (this.options?.jwks?.wellKnownJwksUrl) {
433
+ this.app.router.post(fullPath, async (req, res) => {
434
+ try {
435
+ const token = this.extractBearerToken(req, res);
436
+ if (!token)
437
+ return;
438
+ const publicKey = await this.getPublicKeyFromJwks(token);
439
+ const algorithms = this.options.jwks.algorithms || ["RS256"];
440
+ const decoded = jwt.verify(token, publicKey, { algorithms });
441
+ const accessRules = Array.isArray(decoded?.accessRules)
442
+ ? decoded.accessRules
443
+ : (typeof decoded?.pathFilter === "string" && decoded.pathFilter.length > 0
444
+ ? [decoded.pathFilter]
445
+ : undefined);
446
+ const allowed = Array.isArray(accessRules)
447
+ ? accessRules.some((rule) => UnsTopicMatcher.matches(rule, fullPath))
448
+ : false;
449
+ if (!allowed) {
450
+ return res.status(403).json({ error: "Path not allowed by token access rules" });
451
+ }
452
+ handler(req, res);
453
+ }
454
+ catch (err) {
455
+ return res.status(401).json({ error: "Invalid token" });
456
+ }
457
+ });
458
+ }
459
+ else if (this.options?.jwtSecret) {
460
+ this.app.router.post(fullPath, (req, res) => {
461
+ const authHeader = req.headers["authorization"];
462
+ if (!authHeader || !authHeader.startsWith("Bearer ")) {
463
+ return res.status(401).json({ error: "Missing or invalid Authorization header" });
464
+ }
465
+ const token = authHeader.slice(7);
466
+ try {
467
+ const decoded = jwt.verify(token, process.env.JWT_SECRET || this.options.jwtSecret);
468
+ const accessRules = Array.isArray(decoded?.accessRules)
469
+ ? decoded.accessRules
470
+ : (typeof decoded?.pathFilter === "string" && decoded.pathFilter.length > 0
471
+ ? [decoded.pathFilter]
472
+ : undefined);
473
+ const allowed = Array.isArray(accessRules)
474
+ ? accessRules.some((rule) => UnsTopicMatcher.matches(rule, fullPath))
475
+ : false;
476
+ if (!allowed) {
477
+ return res.status(403).json({ error: "Path not allowed by token access rules" });
478
+ }
479
+ handler(req, res);
480
+ }
481
+ catch (err) {
482
+ return res.status(401).json({ error: "Invalid token" });
483
+ }
484
+ });
485
+ }
486
+ else {
487
+ this.app.router.post(fullPath, handler);
488
+ }
489
+ if (this.app.swaggerSpec) {
490
+ const requestBody = options?.requestBody;
491
+ this.app.swaggerSpec.paths = this.app.swaggerSpec.paths || {};
492
+ this.app.swaggerSpec.paths[apiPath] = this.app.swaggerSpec.paths[apiPath] || {};
493
+ this.app.swaggerSpec.paths[apiPath].post = {
494
+ summary: options?.apiDescription || "No description",
495
+ tags: options?.tags || [],
496
+ ...(requestBody
497
+ ? {
498
+ requestBody: {
499
+ description: requestBody.description,
500
+ required: requestBody.required ?? true,
501
+ content: {
502
+ "application/json": {
503
+ schema: requestBody.schema ?? { type: "object" },
504
+ },
505
+ },
506
+ },
507
+ }
508
+ : {}),
509
+ responses: {
510
+ "200": { description: "OK" },
511
+ "400": { description: "Bad Request" },
512
+ "401": { description: "Unauthorized" },
513
+ "403": { description: "Forbidden" },
514
+ },
515
+ };
516
+ }
517
+ }
518
+ catch (error) {
519
+ logger.error(`${this.instanceNameWithSuffix} - Error registering POST route ${fullPath}: ${error.message}`);
520
+ }
385
521
  }
386
522
  emitStatusMetrics() {
387
523
  const uptimeMinutes = Math.round((Date.now() - this.startedAt) / 60000);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uns-kit/api",
3
- "version": "2.0.29",
3
+ "version": "2.0.31",
4
4
  "description": "Express-powered API gateway plugin for UnsProxyProcess with JWT/JWKS support.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -35,7 +35,7 @@
35
35
  "cookie-parser": "^1.4.7",
36
36
  "express": "^5.1.0",
37
37
  "multer": "^2.0.2",
38
- "@uns-kit/core": "2.0.29"
38
+ "@uns-kit/core": "2.0.31"
39
39
  },
40
40
  "devDependencies": {
41
41
  "@types/jsonwebtoken": "^9.0.10",