@uns-kit/api 3.0.19 → 3.0.21
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 +76 -65
- package/dist/parquet.d.ts +15 -3
- package/dist/parquet.js +51 -15
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -7,12 +7,12 @@ For the MQTT topic registry published by the API plugin, see `../../docs/uns-top
|
|
|
7
7
|
|
|
8
8
|
## uns-kit in context
|
|
9
9
|
|
|
10
|
-
| Package
|
|
11
|
-
|
|
|
10
|
+
| Package | Description |
|
|
11
|
+
| ------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
|
|
12
12
|
| [`@uns-kit/core`](https://github.com/uns-openhub/uns-kit/tree/main/packages/uns-core) | Base runtime (UnsProxyProcess, MQTT helpers, config tooling, gRPC gateway). |
|
|
13
|
-
| [`@uns-kit/api`](https://github.com/uns-openhub/uns-kit/tree/main/packages/uns-api)
|
|
14
|
-
| [`@uns-kit/cron`](https://github.com/uns-openhub/uns-kit/tree/main/packages/uns-cron) | Cron-driven scheduler that emits UNS events on a fixed cadence.
|
|
15
|
-
| [`@uns-kit/cli`](https://github.com/uns-openhub/uns-kit/tree/main/packages/uns-cli)
|
|
13
|
+
| [`@uns-kit/api`](https://github.com/uns-openhub/uns-kit/tree/main/packages/uns-api) | Express plugin — HTTP endpoints, JWT/JWKS auth, Swagger, UNS metadata. |
|
|
14
|
+
| [`@uns-kit/cron`](https://github.com/uns-openhub/uns-kit/tree/main/packages/uns-cron) | Cron-driven scheduler that emits UNS events on a fixed cadence. |
|
|
15
|
+
| [`@uns-kit/cli`](https://github.com/uns-openhub/uns-kit/tree/main/packages/uns-cli) | CLI for scaffolding new UNS applications. |
|
|
16
16
|
|
|
17
17
|
## Installation
|
|
18
18
|
|
|
@@ -32,6 +32,7 @@ npm install @uns-kit/api
|
|
|
32
32
|
4. Listen to `apiGetEvent` / `apiPostEvent` to handle incoming requests.
|
|
33
33
|
|
|
34
34
|
Every registered endpoint is:
|
|
35
|
+
|
|
35
36
|
- Automatically secured with JWT/JWKS or a shared secret.
|
|
36
37
|
- Published to the UNS controller as an API metadata record (topic, host, path, method).
|
|
37
38
|
- Added to the Swagger spec served at `/<processName>/<instanceName>/swagger.json`.
|
|
@@ -39,10 +40,10 @@ Every registered endpoint is:
|
|
|
39
40
|
## GET endpoint example
|
|
40
41
|
|
|
41
42
|
```ts
|
|
42
|
-
import UnsProxyProcess from "@uns-kit/core/uns/uns-proxy-process";
|
|
43
|
-
import type { UnsEvents } from "@uns-kit/core";
|
|
44
43
|
import "@uns-kit/api";
|
|
45
44
|
import { type UnsProxyProcessWithApi } from "@uns-kit/api";
|
|
45
|
+
import type { UnsEvents } from "@uns-kit/core";
|
|
46
|
+
import UnsProxyProcess from "@uns-kit/core/uns/uns-proxy-process";
|
|
46
47
|
|
|
47
48
|
const config = await ConfigFile.loadConfig();
|
|
48
49
|
const proc = new UnsProxyProcess(config.infra.host!, { processName: config.uns.processName }) as UnsProxyProcessWithApi;
|
|
@@ -54,23 +55,16 @@ const api = await proc.createApiProxy("my-service", {
|
|
|
54
55
|
|
|
55
56
|
// Register a GET endpoint
|
|
56
57
|
// Signature: api.get(topic, asset, objectType, objectId, attribute, options?)
|
|
57
|
-
await api.get(
|
|
58
|
-
"
|
|
59
|
-
"line-3-furnace",
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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 },
|
|
70
|
-
],
|
|
71
|
-
chatDefaults: { limit: 100 },
|
|
72
|
-
}
|
|
73
|
-
);
|
|
58
|
+
await api.get("enterprise/site/area/line/", "line-3-furnace", "energy-resource", "main-bus", "current", {
|
|
59
|
+
tags: ["Energy"],
|
|
60
|
+
apiDescription: "Current reading for line-3-furnace main-bus",
|
|
61
|
+
queryParams: [
|
|
62
|
+
{ name: "from", type: "string", required: false, description: "Start of time range (ISO 8601)", chatCanonical: "from" },
|
|
63
|
+
{ name: "to", type: "string", required: false, description: "End of time range (ISO 8601)", chatCanonical: "to" },
|
|
64
|
+
{ name: "limit", type: "number", required: false, description: "Maximum records", chatCanonical: "limit", defaultValue: 100 },
|
|
65
|
+
],
|
|
66
|
+
chatDefaults: { limit: 100 },
|
|
67
|
+
});
|
|
74
68
|
|
|
75
69
|
// Handle incoming GET requests
|
|
76
70
|
api.event.on("apiGetEvent", (event: UnsEvents["apiGetEvent"]) => {
|
|
@@ -85,29 +79,22 @@ api.event.on("apiGetEvent", (event: UnsEvents["apiGetEvent"]) => {
|
|
|
85
79
|
```ts
|
|
86
80
|
// Register a POST endpoint
|
|
87
81
|
// Signature: api.post(topic, asset, objectType, objectId, attribute, options?)
|
|
88
|
-
await api.post(
|
|
89
|
-
"
|
|
90
|
-
"line-3-furnace",
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
},
|
|
82
|
+
await api.post("enterprise/site/area/line/", "line-3-furnace", "energy-resource", "main-bus", "setpoint", {
|
|
83
|
+
tags: ["Energy"],
|
|
84
|
+
apiDescription: "Write a new setpoint for line-3-furnace main-bus",
|
|
85
|
+
requestBody: {
|
|
86
|
+
description: "Setpoint payload",
|
|
87
|
+
required: true,
|
|
88
|
+
schema: {
|
|
89
|
+
type: "object",
|
|
90
|
+
required: ["value"],
|
|
91
|
+
properties: {
|
|
92
|
+
value: { type: "number", description: "Target setpoint value" },
|
|
93
|
+
unit: { type: "string", description: "Unit of measurement, e.g. A" },
|
|
107
94
|
},
|
|
108
95
|
},
|
|
109
|
-
}
|
|
110
|
-
);
|
|
96
|
+
},
|
|
97
|
+
});
|
|
111
98
|
|
|
112
99
|
// Handle incoming POST requests — body is pre-parsed JSON
|
|
113
100
|
api.event.on("apiPostEvent", (event: UnsEvents["apiPostEvent"]) => {
|
|
@@ -122,6 +109,7 @@ api.event.on("apiPostEvent", (event: UnsEvents["apiPostEvent"]) => {
|
|
|
122
109
|
```ts
|
|
123
110
|
import "@uns-kit/api";
|
|
124
111
|
import {
|
|
112
|
+
type UnsProxyProcessWithApi,
|
|
125
113
|
defineDataCatalogField,
|
|
126
114
|
defineDataCatalogOfferSource,
|
|
127
115
|
defineDataCatalogQueryParam,
|
|
@@ -129,7 +117,6 @@ import {
|
|
|
129
117
|
defineServiceApi,
|
|
130
118
|
projectRowsForDataCatalogSchema,
|
|
131
119
|
registerApiCatalog,
|
|
132
|
-
type UnsProxyProcessWithApi,
|
|
133
120
|
} from "@uns-kit/api";
|
|
134
121
|
import { ConfigFile, UnsProxyProcess } from "@uns-kit/core";
|
|
135
122
|
|
|
@@ -232,19 +219,17 @@ const dataOfferSources = {
|
|
|
232
219
|
description: "Browse production orders.",
|
|
233
220
|
method: "GET",
|
|
234
221
|
tags: ["orders"],
|
|
235
|
-
queryParams: [
|
|
236
|
-
defineDataCatalogQueryParam("status", "Optional status filter"),
|
|
237
|
-
],
|
|
222
|
+
queryParams: [defineDataCatalogQueryParam("status", "Optional status filter")],
|
|
238
223
|
schema: productionOrdersSchema,
|
|
239
224
|
response: {
|
|
240
225
|
statusCode: "200",
|
|
241
226
|
contentType: "application/json",
|
|
242
227
|
},
|
|
243
228
|
handler: async (event: any) => {
|
|
244
|
-
const statusFilter = String(event.req.query.status ?? "")
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
229
|
+
const statusFilter = String(event.req.query.status ?? "")
|
|
230
|
+
.trim()
|
|
231
|
+
.toLowerCase();
|
|
232
|
+
const filteredRows = statusFilter ? orderRows.filter((row) => row.STATUS.toLowerCase() === statusFilter) : orderRows;
|
|
248
233
|
const data = projectRowsForDataCatalogSchema(filteredRows, productionOrderRowSchema);
|
|
249
234
|
|
|
250
235
|
event.res.json({
|
|
@@ -262,6 +247,32 @@ await registerApiCatalog(api, {
|
|
|
262
247
|
});
|
|
263
248
|
```
|
|
264
249
|
|
|
250
|
+
## Stream a Data Catalog Parquet export
|
|
251
|
+
|
|
252
|
+
`writeSchemaRowsToParquetStream` accepts a synchronous or asynchronous row
|
|
253
|
+
iterator and writes one Parquet row group at a time. It never collects the whole
|
|
254
|
+
source in memory. Pass an already bounded/paged history or database iterator;
|
|
255
|
+
the writer cannot reduce memory used by a source that loads every row first.
|
|
256
|
+
|
|
257
|
+
```ts
|
|
258
|
+
import { writeSchemaRowsToParquetStream } from "@uns-kit/api";
|
|
259
|
+
|
|
260
|
+
const filePath = await writeSchemaRowsToParquetStream({
|
|
261
|
+
schema: rowSchema,
|
|
262
|
+
rows: historyRows(), // AsyncIterable<Record<string, unknown>>
|
|
263
|
+
rowGroupSize: 2_000,
|
|
264
|
+
signal: requestAbortSignal,
|
|
265
|
+
});
|
|
266
|
+
// Stream filePath to the caller, then remove it when the response closes.
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
The helper creates the output directory when needed, writes to a unique
|
|
270
|
+
incomplete file, and renames it only after success. An error or cancellation
|
|
271
|
+
removes the incomplete file and rejects the promise. The caller owns the
|
|
272
|
+
completed file and its cleanup. The older `writeSchemaRowsToParquet` still
|
|
273
|
+
returns `null` on failure for existing applications, but now also accepts a
|
|
274
|
+
row iterator and the same row-group options.
|
|
275
|
+
|
|
265
276
|
## Endpoint signature
|
|
266
277
|
|
|
267
278
|
```
|
|
@@ -269,22 +280,22 @@ api.get(topic, asset, objectType, objectId, attribute, options?)
|
|
|
269
280
|
api.post(topic, asset, objectType, objectId, attribute, options?)
|
|
270
281
|
```
|
|
271
282
|
|
|
272
|
-
| Parameter
|
|
273
|
-
|
|
274
|
-
| `topic`
|
|
275
|
-
| `asset`
|
|
276
|
-
| `objectType` | `UnsObjectType`
|
|
277
|
-
| `objectId`
|
|
278
|
-
| `attribute`
|
|
279
|
-
| `options`
|
|
283
|
+
| Parameter | Type | Description |
|
|
284
|
+
| ------------ | ---------------------------------------------- | ----------------------------------------------------------- |
|
|
285
|
+
| `topic` | `UnsTopics` | UNS topic path prefix (e.g. `"enterprise/site/area/line/"`) |
|
|
286
|
+
| `asset` | `UnsAsset` | Asset identifier (e.g. `"line-3-furnace"`) |
|
|
287
|
+
| `objectType` | `UnsObjectType` | UNS object type (e.g. `"energy-resource"`) |
|
|
288
|
+
| `objectId` | `UnsObjectId` | Object instance id (e.g. `"main-bus"`) |
|
|
289
|
+
| `attribute` | `UnsAttribute` | Attribute name (e.g. `"current"`) |
|
|
290
|
+
| `options` | `IGetEndpointOptions` / `IPostEndpointOptions` | Tags, description, query params / request body |
|
|
280
291
|
|
|
281
292
|
## Auth options
|
|
282
293
|
|
|
283
|
-
| Option
|
|
284
|
-
|
|
294
|
+
| Option | When to use |
|
|
295
|
+
| ----------------------- | ------------------------------------------------------------------------ |
|
|
285
296
|
| `jwks.wellKnownJwksUrl` | Production — verifies RS256 tokens from the UNS controller JWKS endpoint |
|
|
286
|
-
| `jwks.activeKidUrl`
|
|
287
|
-
| `jwtSecret`
|
|
297
|
+
| `jwks.activeKidUrl` | Optional companion to JWKS — narrows which key ID is active |
|
|
298
|
+
| `jwtSecret` | Development / simple deployments — symmetric secret |
|
|
288
299
|
|
|
289
300
|
Requests that fail auth return `401 Unauthorized`. Requests whose token `accessRules` do not match the endpoint path return `403 Forbidden`.
|
|
290
301
|
|
package/dist/parquet.d.ts
CHANGED
|
@@ -1,10 +1,22 @@
|
|
|
1
1
|
import { type ColumnSource } from "hyparquet-writer";
|
|
2
2
|
import type { DataCatalogSchemaRegistration } from "./api-interfaces.js";
|
|
3
3
|
export type CatalogParquetColumn = Omit<ColumnSource, "data">;
|
|
4
|
-
export
|
|
5
|
-
|
|
4
|
+
export type CatalogParquetRows = Iterable<Record<string, unknown>> | AsyncIterable<Record<string, unknown>>;
|
|
5
|
+
export type CatalogParquetWriteInput = {
|
|
6
|
+
rows: CatalogParquetRows;
|
|
6
7
|
schema: DataCatalogSchemaRegistration;
|
|
7
8
|
outputDir?: string;
|
|
8
9
|
fileName?: string;
|
|
9
|
-
|
|
10
|
+
/** Maximum source rows buffered for one Parquet row group. */
|
|
11
|
+
rowGroupSize?: number;
|
|
12
|
+
signal?: AbortSignal;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Write catalog rows a group at a time. The input can be a database cursor or
|
|
16
|
+
* another async source; it is never collected into one array. A failed write
|
|
17
|
+
* leaves no incomplete file at the requested path.
|
|
18
|
+
*/
|
|
19
|
+
export declare function writeSchemaRowsToParquetStream(input: CatalogParquetWriteInput): Promise<string>;
|
|
20
|
+
/** Legacy null-on-error interface retained for existing callers. */
|
|
21
|
+
export declare function writeSchemaRowsToParquet(input: CatalogParquetWriteInput): Promise<string | null>;
|
|
10
22
|
export declare function buildParquetSchemaFromCatalogSchema(schema: DataCatalogSchemaRegistration): CatalogParquetColumn[];
|
package/dist/parquet.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import logger from "@uns-kit/core/logger.js";
|
|
2
2
|
import { randomUUID } from "crypto";
|
|
3
|
-
import fs from "fs";
|
|
3
|
+
import fs from "fs/promises";
|
|
4
4
|
import { fileWriter, parquetWriteRows } from "hyparquet-writer";
|
|
5
5
|
import os from "os";
|
|
6
6
|
import path from "path";
|
|
@@ -12,30 +12,61 @@ const PARQUET_TYPE_MAP = {
|
|
|
12
12
|
date: "TIMESTAMP",
|
|
13
13
|
"date-time": "TIMESTAMP",
|
|
14
14
|
};
|
|
15
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Write catalog rows a group at a time. The input can be a database cursor or
|
|
17
|
+
* another async source; it is never collected into one array. A failed write
|
|
18
|
+
* leaves no incomplete file at the requested path.
|
|
19
|
+
*/
|
|
20
|
+
export async function writeSchemaRowsToParquetStream(input) {
|
|
21
|
+
const outputDir = input.outputDir ?? path.join(os.tmpdir(), "uns-data-offers");
|
|
22
|
+
const fileName = input.fileName ?? `${randomUUID()}.parquet`;
|
|
23
|
+
if (fileName !== path.basename(fileName) || fileName === "." || fileName === "..") {
|
|
24
|
+
throw new Error("Parquet fileName must be a file name without a directory.");
|
|
25
|
+
}
|
|
26
|
+
const rowGroupSize = input.rowGroupSize ?? 2_000;
|
|
27
|
+
if (!Number.isSafeInteger(rowGroupSize) || rowGroupSize < 1 || rowGroupSize > 10_000) {
|
|
28
|
+
throw new Error("Parquet rowGroupSize must be between 1 and 10,000.");
|
|
29
|
+
}
|
|
30
|
+
const columns = buildParquetSchemaFromCatalogSchema(input.schema);
|
|
31
|
+
if (!columns.length) {
|
|
32
|
+
throw new Error(`Parquet schema '${input.schema.id}' has no supported fields.`);
|
|
33
|
+
}
|
|
34
|
+
const filePath = path.join(outputDir, fileName);
|
|
35
|
+
const incompletePath = `${filePath}.${randomUUID()}.partial`;
|
|
36
|
+
await fs.mkdir(outputDir, { recursive: true });
|
|
16
37
|
try {
|
|
17
|
-
const outputDir = input.outputDir ?? path.join(os.tmpdir(), "uns-data-offers");
|
|
18
|
-
const fileName = input.fileName ?? `${randomUUID()}.parquet`;
|
|
19
|
-
const filePath = path.join(outputDir, fileName);
|
|
20
|
-
if (!fs.existsSync(outputDir)) {
|
|
21
|
-
fs.mkdirSync(outputDir, { recursive: true });
|
|
22
|
-
}
|
|
23
|
-
const columns = buildParquetSchemaFromCatalogSchema(input.schema);
|
|
24
|
-
if (!columns.length) {
|
|
25
|
-
throw new Error(`Parquet schema '${input.schema.id}' has no supported fields.`);
|
|
26
|
-
}
|
|
27
38
|
await parquetWriteRows({
|
|
28
|
-
writer: fileWriter(
|
|
29
|
-
rows: input.rows
|
|
39
|
+
writer: fileWriter(incompletePath),
|
|
40
|
+
rows: normalizeParquetRows(input.rows, columns, input.signal),
|
|
30
41
|
columns,
|
|
42
|
+
rowGroupSize,
|
|
31
43
|
});
|
|
44
|
+
input.signal?.throwIfAborted();
|
|
45
|
+
await fs.rename(incompletePath, filePath);
|
|
32
46
|
return filePath;
|
|
33
47
|
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
await fs.rm(incompletePath, { force: true });
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** Legacy null-on-error interface retained for existing callers. */
|
|
54
|
+
export async function writeSchemaRowsToParquet(input) {
|
|
55
|
+
try {
|
|
56
|
+
return await writeSchemaRowsToParquetStream(input);
|
|
57
|
+
}
|
|
34
58
|
catch (error) {
|
|
35
59
|
logger.error("Failed to write schema-driven parquet:", error);
|
|
36
60
|
return null;
|
|
37
61
|
}
|
|
38
62
|
}
|
|
63
|
+
async function* normalizeParquetRows(rows, columns, signal) {
|
|
64
|
+
for await (const row of rows) {
|
|
65
|
+
signal?.throwIfAborted();
|
|
66
|
+
yield normalizeParquetRow(row, columns);
|
|
67
|
+
}
|
|
68
|
+
signal?.throwIfAborted();
|
|
69
|
+
}
|
|
39
70
|
export function buildParquetSchemaFromCatalogSchema(schema) {
|
|
40
71
|
const columns = [];
|
|
41
72
|
for (const field of schema.fields ?? []) {
|
|
@@ -61,7 +92,12 @@ function normalizeParquetRow(row, columns) {
|
|
|
61
92
|
const normalized = {};
|
|
62
93
|
for (const column of columns) {
|
|
63
94
|
const value = row[column.name];
|
|
64
|
-
normalized[column.name] =
|
|
95
|
+
normalized[column.name] =
|
|
96
|
+
column.type === "INT64" && typeof value === "number"
|
|
97
|
+
? BigInt(value)
|
|
98
|
+
: column.type === "TIMESTAMP" && typeof value === "string"
|
|
99
|
+
? new Date(value)
|
|
100
|
+
: value;
|
|
65
101
|
}
|
|
66
102
|
return normalized;
|
|
67
103
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uns-kit/api",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.21",
|
|
4
4
|
"description": "Express-powered API gateway plugin for UnsProxyProcess with JWT/JWKS support.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"multer": "^2.2.0",
|
|
38
38
|
"hyparquet-writer": "^0.16.5",
|
|
39
39
|
"uuid": "^14.0.1",
|
|
40
|
-
"@uns-kit/core": "3.0.
|
|
40
|
+
"@uns-kit/core": "3.0.21"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@types/cookie-parser": "^1.4.10",
|