@uns-kit/api 2.0.44 → 2.0.46

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
@@ -117,6 +117,151 @@ api.event.on("apiPostEvent", (event: UnsEvents["apiPostEvent"]) => {
117
117
  });
118
118
  ```
119
119
 
120
+ ## Data offer example
121
+
122
+ ```ts
123
+ import "@uns-kit/api";
124
+ import {
125
+ defineDataCatalogField,
126
+ defineDataCatalogOfferSource,
127
+ defineDataCatalogQueryParam,
128
+ defineDataCatalogSchema,
129
+ defineServiceApi,
130
+ projectRowsForDataCatalogSchema,
131
+ registerApiCatalog,
132
+ type UnsProxyProcessWithApi,
133
+ } from "@uns-kit/api";
134
+ import { ConfigFile, UnsProxyProcess } from "@uns-kit/core";
135
+
136
+ const config = await ConfigFile.loadConfig();
137
+ const proc = new UnsProxyProcess(config.infra.host!, {
138
+ processName: config.uns.processName,
139
+ }) as UnsProxyProcessWithApi;
140
+
141
+ const api = await proc.createApiProxy("catalog", {
142
+ jwks: { wellKnownJwksUrl: config.uns.jwksWellKnownUrl! },
143
+ });
144
+
145
+ const orderRows = [
146
+ { ORDER_ID: "PO-1001", STATUS: "released" },
147
+ { ORDER_ID: "PO-1002", STATUS: "finished" },
148
+ ];
149
+
150
+ const productionOrdersSchema = defineDataCatalogSchema({
151
+ id: "production-orders",
152
+ title: "Production Orders",
153
+ contentType: "application/json",
154
+ fields: [
155
+ defineDataCatalogField("count", "number", "Number of returned rows"),
156
+ defineDataCatalogField("data", "array", "Returned rows"),
157
+ defineDataCatalogField("orderId", "string", "Order id", { sourceKey: "ORDER_ID" }),
158
+ defineDataCatalogField("status", "string", "Order status", { sourceKey: "STATUS" }),
159
+ ],
160
+ examplePayloads: [
161
+ {
162
+ count: 1,
163
+ data: [{ orderId: "PO-1001", status: "released" }],
164
+ },
165
+ ],
166
+ });
167
+
168
+ const productionOrderRowSchema = defineDataCatalogSchema({
169
+ id: "production-orders-row",
170
+ title: "Production Order Row",
171
+ contentType: "application/json",
172
+ fields: productionOrdersSchema.fields?.filter((field) => field.name !== "count" && field.name !== "data") ?? [],
173
+ });
174
+
175
+ const serviceApis = {
176
+ status: defineServiceApi({
177
+ attribute: "status",
178
+ method: "GET",
179
+ description: "Service status endpoint",
180
+ tags: ["service"],
181
+ handler: async (event: any) => {
182
+ event.res.json({ status: "ok" });
183
+ },
184
+ }),
185
+ command: defineServiceApi({
186
+ attribute: "command",
187
+ method: "POST",
188
+ description: "Service command endpoint",
189
+ tags: ["service"],
190
+ requestBody: {
191
+ required: true,
192
+ description: "Command payload",
193
+ contentType: "application/json",
194
+ schemas: [
195
+ defineDataCatalogSchema({
196
+ id: "service-command-request",
197
+ title: "Service Command Request",
198
+ contentType: "application/json",
199
+ fields: [
200
+ defineDataCatalogField("command", "string", "Command name", { required: true, example: "refresh-cache" }),
201
+ defineDataCatalogField("target", "string", "Optional target", { example: "orders" }),
202
+ ],
203
+ }),
204
+ ],
205
+ },
206
+ handler: async (event: any) => {
207
+ const { command, target } = event.req.body ?? {};
208
+ if (!command) {
209
+ return event.res.status(400).json({ error: "Missing command" });
210
+ }
211
+
212
+ event.res.json({
213
+ status: "accepted",
214
+ received: {
215
+ command,
216
+ ...(target ? { target } : {}),
217
+ },
218
+ });
219
+ },
220
+ }),
221
+ };
222
+
223
+ const dataOfferSources = {
224
+ productionOrders: defineDataCatalogOfferSource({
225
+ offerId: "production-orders",
226
+ topic: "factory/demo/app",
227
+ asset: "orders",
228
+ objectType: "dataset",
229
+ objectId: "production",
230
+ attribute: "list",
231
+ displayName: "Production Orders",
232
+ description: "Browse production orders.",
233
+ method: "GET",
234
+ tags: ["orders"],
235
+ queryParams: [
236
+ defineDataCatalogQueryParam("status", "Optional status filter"),
237
+ ],
238
+ schema: productionOrdersSchema,
239
+ response: {
240
+ statusCode: "200",
241
+ contentType: "application/json",
242
+ },
243
+ handler: async (event: any) => {
244
+ const statusFilter = String(event.req.query.status ?? "").trim().toLowerCase();
245
+ const filteredRows = statusFilter
246
+ ? orderRows.filter((row) => row.STATUS.toLowerCase() === statusFilter)
247
+ : orderRows;
248
+ const data = projectRowsForDataCatalogSchema(filteredRows, productionOrderRowSchema);
249
+
250
+ event.res.json({
251
+ count: data.length,
252
+ data,
253
+ });
254
+ },
255
+ }),
256
+ };
257
+
258
+ await registerApiCatalog(api, {
259
+ serviceApis,
260
+ dataOfferSources,
261
+ context: undefined,
262
+ });
263
+ ```
264
+
120
265
  ## Endpoint signature
121
266
 
122
267
  ```
@@ -0,0 +1,39 @@
1
+ import type { UnsEvents } from "@uns-kit/core/uns/uns-interfaces.js";
2
+ import type { ApiInteractionDefinition, ApiInteractionMethod, DataCatalogOfferRegistration, DataCatalogOfferSourceRegistration, ServiceApiRegistration } from "./api-interfaces.js";
3
+ import type UnsApiProxy from "./uns-api-proxy.js";
4
+ type ApiEventEnvelope = {
5
+ req: any;
6
+ res: any;
7
+ };
8
+ type ApiEventByMethod = {
9
+ GET: UnsEvents["apiGetEvent"];
10
+ POST: UnsEvents["apiPostEvent"];
11
+ PUT: ApiEventEnvelope;
12
+ PATCH: ApiEventEnvelope;
13
+ DELETE: ApiEventEnvelope;
14
+ };
15
+ export type ApiHandler<Method extends ApiInteractionMethod = ApiInteractionMethod, Context = void> = (event: ApiEventByMethod[Method], context: Context) => Promise<void>;
16
+ export type RegisterApiEventsOptions = {
17
+ notFoundMessage?: string;
18
+ onError?: (input: {
19
+ method: ApiInteractionMethod;
20
+ reqPath?: string;
21
+ error: unknown;
22
+ }) => void;
23
+ };
24
+ export type RegisterApiCatalogOptions<Context, Handler, ServiceApis extends Record<string, ServiceApiRegistration<Handler>>, OfferSources extends Record<string, DataCatalogOfferSourceRegistration<Handler>>> = {
25
+ serviceApis?: ServiceApis;
26
+ dataOfferSources?: OfferSources;
27
+ context: Context;
28
+ dataCatalogOffers?: DataCatalogOfferRegistration[];
29
+ options?: RegisterApiEventsOptions;
30
+ };
31
+ export declare function normalizeApiRequestPath(path?: string): string | undefined;
32
+ export declare function registerApiCatalog<Context, Handler, ServiceApis extends Record<string, ServiceApiRegistration<Handler>>, OfferSources extends Record<string, DataCatalogOfferSourceRegistration<Handler>>>(apiInput: UnsApiProxy, input: RegisterApiCatalogOptions<Context, Handler, ServiceApis, OfferSources>): Promise<{
33
+ serviceApiRoutes: ApiInteractionDefinition<Handler>[];
34
+ dataOfferRoutes: ApiInteractionDefinition<Handler>[];
35
+ dataCatalogOffers: DataCatalogOfferRegistration[];
36
+ }>;
37
+ export declare function registerApiInteractions<Handler>(apiInput: UnsApiProxy, routes: ApiInteractionDefinition<Handler>[]): Promise<void>;
38
+ export declare function registerApiEvents<Context, Handler>(apiInput: UnsApiProxy, routes: ApiInteractionDefinition<Handler>[], context: Context, options?: RegisterApiEventsOptions): void;
39
+ export {};
@@ -0,0 +1,88 @@
1
+ import { buildApiInteractionTopicPath, buildApiInteractionsFromDataOfferSources, buildDataCatalogOffersFromSources, buildServiceApiInteractions, } from "./api-interfaces.js";
2
+ const EVENT_NAME_BY_METHOD = {
3
+ GET: "apiGetEvent",
4
+ POST: "apiPostEvent",
5
+ PUT: "apiPutEvent",
6
+ PATCH: "apiPatchEvent",
7
+ DELETE: "apiDeleteEvent",
8
+ };
9
+ export function normalizeApiRequestPath(path) {
10
+ const normalized = path?.replace(/^\/+|\/+$/g, "");
11
+ return normalized?.length ? normalized : undefined;
12
+ }
13
+ export async function registerApiCatalog(apiInput, input) {
14
+ const serviceApiRoutes = Object.values(buildServiceApiInteractions(apiInput.getProcessName(), input.serviceApis ?? {}));
15
+ const dataOfferRoutes = Object.values(buildApiInteractionsFromDataOfferSources(input.dataOfferSources ?? {}));
16
+ const dataCatalogOffers = input.dataCatalogOffers ?? buildDataCatalogOffersFromSources(input.dataOfferSources ?? {});
17
+ const allRoutes = [...serviceApiRoutes, ...dataOfferRoutes];
18
+ await registerApiInteractions(apiInput, allRoutes);
19
+ for (const offer of dataCatalogOffers) {
20
+ apiInput.registerDataOffer(offer);
21
+ }
22
+ registerApiEvents(apiInput, allRoutes, input.context, input.options);
23
+ return { serviceApiRoutes, dataOfferRoutes, dataCatalogOffers };
24
+ }
25
+ export async function registerApiInteractions(apiInput, routes) {
26
+ for (const route of routes) {
27
+ switch (route.method) {
28
+ case "GET":
29
+ await apiInput.get(route.topic, route.asset, route.objectType, route.objectId, route.attribute, route.options);
30
+ break;
31
+ case "POST":
32
+ await apiInput.post(route.topic, route.asset, route.objectType, route.objectId, route.attribute, route.options);
33
+ break;
34
+ case "PUT":
35
+ await apiInput.put(route.topic, route.asset, route.objectType, route.objectId, route.attribute, route.options);
36
+ break;
37
+ case "PATCH":
38
+ await apiInput.patch(route.topic, route.asset, route.objectType, route.objectId, route.attribute, route.options);
39
+ break;
40
+ case "DELETE":
41
+ await apiInput.delete(route.topic, route.asset, route.objectType, route.objectId, route.attribute, route.options);
42
+ break;
43
+ default:
44
+ throw new Error(`Unsupported API method: ${route.method}`);
45
+ }
46
+ }
47
+ }
48
+ export function registerApiEvents(apiInput, routes, context, options = {}) {
49
+ const handlersByMethod = buildRouteHandlerIndex(routes);
50
+ bindMethodEvent(apiInput, "GET", handlersByMethod.GET, context, options);
51
+ bindMethodEvent(apiInput, "POST", handlersByMethod.POST, context, options);
52
+ bindMethodEvent(apiInput, "PUT", handlersByMethod.PUT, context, options);
53
+ bindMethodEvent(apiInput, "PATCH", handlersByMethod.PATCH, context, options);
54
+ bindMethodEvent(apiInput, "DELETE", handlersByMethod.DELETE, context, options);
55
+ }
56
+ function buildRouteHandlerIndex(routes) {
57
+ return routes.reduce((acc, route) => {
58
+ acc[route.method][buildApiInteractionTopicPath(route)] = route.handler;
59
+ return acc;
60
+ }, {
61
+ GET: {},
62
+ POST: {},
63
+ PUT: {},
64
+ PATCH: {},
65
+ DELETE: {},
66
+ });
67
+ }
68
+ function bindMethodEvent(apiInput, method, handlers, context, options) {
69
+ const eventName = EVENT_NAME_BY_METHOD[method];
70
+ apiInput.event.on(eventName, async (event) => {
71
+ const reqPath = normalizeApiRequestPath(event.req.path);
72
+ const handler = reqPath ? handlers[reqPath] : undefined;
73
+ await executeHandler(event, handler, context, method, reqPath, options);
74
+ });
75
+ }
76
+ async function executeHandler(event, handler, context, method, reqPath, options) {
77
+ if (!handler) {
78
+ event.res.status(404).send(options.notFoundMessage ?? "API handler not found");
79
+ return;
80
+ }
81
+ try {
82
+ await handler(event, context);
83
+ }
84
+ catch (error) {
85
+ options.onError?.({ method, reqPath, error });
86
+ event.res.status(500).send("Server error");
87
+ }
88
+ }
@@ -1 +1,185 @@
1
- export type { IApiProxyOptions, IGetEndpointOptions, QueryParamDef } from "@uns-kit/core/uns/uns-interfaces.js";
1
+ export type { IApiProxyOptions, IGetEndpointOptions, IPostEndpointOptions, QueryParamDef, } from "@uns-kit/core/uns/uns-interfaces.js";
2
+ import type { IGetEndpointOptions, IPostEndpointOptions } from "@uns-kit/core/uns/uns-interfaces.js";
3
+ export type ApiInteractionMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
4
+ export type DataCatalogParameterRegistration = {
5
+ name: string;
6
+ in: "path" | "query" | "header" | "cookie";
7
+ required?: boolean;
8
+ description?: string | null;
9
+ type?: string | null;
10
+ format?: string | null;
11
+ nullable?: boolean;
12
+ example?: unknown;
13
+ enumValues?: string[];
14
+ schema?: unknown;
15
+ };
16
+ export type DataCatalogSchemaFieldRegistration = {
17
+ name: string;
18
+ path?: string;
19
+ sourceKey?: string | null;
20
+ type?: string | null;
21
+ format?: string | null;
22
+ nullable?: boolean;
23
+ required?: boolean;
24
+ description?: string | null;
25
+ enumValues?: string[];
26
+ example?: unknown;
27
+ };
28
+ export type DataCatalogSchemaRegistration = {
29
+ id: string;
30
+ title: string;
31
+ kind?: string | null;
32
+ source?: string | null;
33
+ contentType?: string | null;
34
+ rootType?: string | null;
35
+ fieldPathPrefix?: string | null;
36
+ nullable?: boolean;
37
+ description?: string | null;
38
+ fields?: DataCatalogSchemaFieldRegistration[];
39
+ examplePayloads?: unknown[];
40
+ };
41
+ export type DataCatalogRequestBodyRegistration = {
42
+ required?: boolean;
43
+ description?: string | null;
44
+ contentType?: string | null;
45
+ schemas?: DataCatalogSchemaRegistration[];
46
+ schemaIds?: string[];
47
+ };
48
+ export type DataCatalogResponseRegistration = {
49
+ statusCode: string;
50
+ description?: string | null;
51
+ contentType?: string | null;
52
+ schemas?: DataCatalogSchemaRegistration[];
53
+ schemaIds?: string[];
54
+ examplePayloads?: unknown[];
55
+ headers?: DataCatalogParameterRegistration[];
56
+ };
57
+ export type DataCatalogOperationRegistration = {
58
+ id?: string;
59
+ method: ApiInteractionMethod | "HEAD" | "OPTIONS";
60
+ path: string;
61
+ summary?: string | null;
62
+ description?: string | null;
63
+ tags?: string[];
64
+ deprecated?: boolean;
65
+ parameters?: DataCatalogParameterRegistration[];
66
+ headers?: DataCatalogParameterRegistration[];
67
+ requestBody?: DataCatalogRequestBodyRegistration | null;
68
+ responses?: DataCatalogResponseRegistration[];
69
+ };
70
+ export type DataCatalogOfferRegistration = {
71
+ offerId: string;
72
+ displayName: string;
73
+ description?: string | null;
74
+ owner?: string | null;
75
+ status?: string | null;
76
+ tags?: string[];
77
+ categories?: string[];
78
+ microserviceName?: string | null;
79
+ version?: string | null;
80
+ basePaths?: string[];
81
+ operations: DataCatalogOperationRegistration[];
82
+ schemas?: DataCatalogSchemaRegistration[];
83
+ swaggerPath?: string | null;
84
+ metadata?: Record<string, unknown> | null;
85
+ };
86
+ type ApiRouteIdentity<Handler = unknown> = {
87
+ topic: string;
88
+ asset: string;
89
+ objectType: string;
90
+ objectId: string;
91
+ attribute: string;
92
+ handler: Handler;
93
+ };
94
+ export type DataCatalogOfferSourceRegistration<Handler = unknown> = ApiRouteIdentity<Handler> & {
95
+ offerId: string;
96
+ displayName: string;
97
+ description: string;
98
+ owner?: string;
99
+ status?: string;
100
+ tags?: string[];
101
+ categories?: string[];
102
+ method?: ApiInteractionMethod;
103
+ queryParams?: DataCatalogParameterRegistration[];
104
+ headers?: DataCatalogParameterRegistration[];
105
+ requestBody?: DataCatalogRequestBodyRegistration | null;
106
+ response?: {
107
+ statusCode?: string;
108
+ description?: string | null;
109
+ contentType?: string | null;
110
+ headers?: DataCatalogParameterRegistration[];
111
+ examplePayloads?: unknown[];
112
+ };
113
+ schema: DataCatalogSchemaRegistration;
114
+ apiDescription?: string;
115
+ };
116
+ export type ServiceApiRegistration<Handler = unknown> = {
117
+ attribute: string;
118
+ handler: Handler;
119
+ method?: ApiInteractionMethod;
120
+ description: string;
121
+ publishInUnsTree?: boolean;
122
+ tags?: string[];
123
+ queryParams?: DataCatalogParameterRegistration[];
124
+ requestBody?: DataCatalogRequestBodyRegistration | null;
125
+ response?: DataCatalogResponseRegistration;
126
+ schema?: DataCatalogSchemaRegistration;
127
+ topic?: string;
128
+ asset?: string;
129
+ objectType?: string;
130
+ objectId?: string;
131
+ };
132
+ export type ApiInteractionDefinition<Handler = unknown> = {
133
+ id: string;
134
+ topic: string;
135
+ asset: string;
136
+ objectType: string;
137
+ objectId: string;
138
+ attribute: string;
139
+ method: ApiInteractionMethod;
140
+ routeOnly?: boolean;
141
+ registryTopic?: "api-endpoints" | "service-endpoints" | "data-offer-endpoints";
142
+ options: IGetEndpointOptions | IPostEndpointOptions;
143
+ handler: Handler;
144
+ };
145
+ export declare function defineApiInteraction<Handler>(input: ApiInteractionDefinition<Handler>): ApiInteractionDefinition<Handler>;
146
+ export declare function defineServiceApi<Handler>(input: ServiceApiRegistration<Handler>): ServiceApiRegistration<Handler>;
147
+ export declare function buildApiInteractionPath(route: Pick<ApiInteractionDefinition, "topic" | "asset" | "objectType" | "objectId" | "attribute">, apiBasePrefix?: string): string;
148
+ export declare function buildApiInteractionTopicPath(route: Pick<ApiInteractionDefinition, "topic" | "asset" | "objectType" | "objectId" | "attribute">): string;
149
+ export declare function defineDataCatalogQueryParam(name: string, description: string, options?: {
150
+ required?: boolean;
151
+ type?: string;
152
+ format?: string;
153
+ example?: unknown;
154
+ }): DataCatalogParameterRegistration;
155
+ export declare function defineDataCatalogField(name: string, type: string, description: string, options?: {
156
+ path?: string;
157
+ sourceKey?: string;
158
+ required?: boolean;
159
+ format?: string;
160
+ nullable?: boolean;
161
+ example?: unknown;
162
+ enumValues?: string[];
163
+ }): DataCatalogSchemaFieldRegistration;
164
+ export declare function defineDataCatalogSchema(input: {
165
+ id: string;
166
+ title: string;
167
+ contentType: string;
168
+ description?: string;
169
+ kind?: string;
170
+ source?: string;
171
+ rootType?: string;
172
+ fieldPathPrefix?: string;
173
+ fields: DataCatalogSchemaFieldRegistration[];
174
+ examplePayloads?: unknown[];
175
+ }): DataCatalogSchemaRegistration;
176
+ export declare function buildDataCatalogOfferOperation(route: Pick<ApiInteractionDefinition, "topic" | "asset" | "objectType" | "objectId" | "attribute">, operation: Omit<DataCatalogOperationRegistration, "path">, apiBasePrefix?: string): DataCatalogOperationRegistration;
177
+ export declare function defineDataCatalogOfferSource<Handler>(input: DataCatalogOfferSourceRegistration<Handler>): DataCatalogOfferSourceRegistration<Handler>;
178
+ export declare function buildServiceApiInteractions<Handler, T extends Record<string, ServiceApiRegistration<Handler>>>(processName: string, definitions: T): Record<keyof T, ApiInteractionDefinition<Handler>>;
179
+ export declare function buildApiInteractionsFromDataOfferSources<Handler, T extends Record<string, DataCatalogOfferSourceRegistration<Handler>>>(sources: T): Record<keyof T, ApiInteractionDefinition<Handler>>;
180
+ export declare function buildDataCatalogOffersFromSources<T extends Record<string, DataCatalogOfferSourceRegistration<any>>>(sources: T): DataCatalogOfferRegistration[];
181
+ export declare function projectRowsForDataCatalogSchema(rows: Array<Record<string, unknown>>, schema: DataCatalogSchemaRegistration): Array<Record<string, unknown>>;
182
+ export declare function buildSchemaFieldPathIndex(schema: DataCatalogSchemaRegistration): Array<{
183
+ path: string;
184
+ name: string;
185
+ }>;
@@ -1 +1,311 @@
1
- export {};
1
+ import { buildUnsRoutePath } from "@uns-kit/core/uns/uns-path.js";
2
+ export function defineApiInteraction(input) {
3
+ return input;
4
+ }
5
+ export function defineServiceApi(input) {
6
+ return input;
7
+ }
8
+ export function buildApiInteractionPath(route, apiBasePrefix = "/api") {
9
+ return `${apiBasePrefix}${buildUnsRoutePath(route.topic, route.asset, route.objectType, route.objectId, route.attribute)}`.replace(/\/{2,}/g, "/");
10
+ }
11
+ export function buildApiInteractionTopicPath(route) {
12
+ return buildUnsRoutePath(route.topic, route.asset, route.objectType, route.objectId, route.attribute).slice(1);
13
+ }
14
+ export function defineDataCatalogQueryParam(name, description, options = {}) {
15
+ return {
16
+ name,
17
+ in: "query",
18
+ type: options.type ?? "string",
19
+ required: options.required === true,
20
+ description,
21
+ ...(options.format ? { format: options.format } : {}),
22
+ ...(options.example !== undefined ? { example: options.example } : {}),
23
+ };
24
+ }
25
+ export function defineDataCatalogField(name, type, description, options = {}) {
26
+ return {
27
+ name,
28
+ path: options.path ?? name,
29
+ type,
30
+ description,
31
+ required: options.required === true,
32
+ nullable: options.nullable === true,
33
+ ...(options.format ? { format: options.format } : {}),
34
+ ...(options.example !== undefined ? { example: options.example } : {}),
35
+ ...(Array.isArray(options.enumValues) ? { enumValues: options.enumValues } : {}),
36
+ ...(options.sourceKey ? { sourceKey: options.sourceKey } : {}),
37
+ };
38
+ }
39
+ export function defineDataCatalogSchema(input) {
40
+ const fieldPathPrefix = input.fieldPathPrefix?.trim();
41
+ const fields = input.fields.map((field) => ({
42
+ ...field,
43
+ path: field.path ?? buildSchemaFieldPath(field.name, field.type ?? "string", fieldPathPrefix),
44
+ }));
45
+ const shouldAutogenerateExamples = !isBinaryCatalogContentType(input.contentType);
46
+ const examplePayloads = Array.isArray(input.examplePayloads) && input.examplePayloads.length
47
+ ? input.examplePayloads
48
+ : shouldAutogenerateExamples
49
+ ? buildExamplePayloadsFromFields(fields)
50
+ : [];
51
+ return {
52
+ id: input.id,
53
+ title: input.title,
54
+ kind: input.kind ?? "response",
55
+ source: input.source ?? "registered",
56
+ contentType: input.contentType,
57
+ rootType: input.rootType ?? inferSchemaRootType(input.contentType, input.fields),
58
+ ...(fieldPathPrefix ? { fieldPathPrefix } : {}),
59
+ ...(input.description ? { description: input.description } : {}),
60
+ fields,
61
+ ...(examplePayloads?.length ? { examplePayloads } : {}),
62
+ };
63
+ }
64
+ function isBinaryCatalogContentType(contentType) {
65
+ const normalized = String(contentType ?? "").toLowerCase();
66
+ return normalized.includes("parquet") || normalized.includes("octet-stream");
67
+ }
68
+ export function buildDataCatalogOfferOperation(route, operation, apiBasePrefix = "/api") {
69
+ return {
70
+ ...operation,
71
+ path: buildApiInteractionPath(route, apiBasePrefix),
72
+ };
73
+ }
74
+ export function defineDataCatalogOfferSource(input) {
75
+ return input;
76
+ }
77
+ export function buildServiceApiInteractions(processName, definitions) {
78
+ return Object.fromEntries(Object.entries(definitions).map(([key, definition]) => [
79
+ key,
80
+ defineApiInteraction({
81
+ id: String(key),
82
+ topic: definition.topic ?? "system",
83
+ asset: definition.asset ?? "service",
84
+ objectType: definition.objectType ?? "runtime",
85
+ objectId: definition.objectId ?? processName,
86
+ attribute: definition.attribute,
87
+ method: definition.method ?? "GET",
88
+ routeOnly: definition.publishInUnsTree !== true,
89
+ registryTopic: "service-endpoints",
90
+ options: buildApiInteractionOptions(definition.method ?? "GET", definition.description, definition.tags, definition.queryParams, definition.requestBody, definition.publishInUnsTree !== true, "service-endpoints", buildServiceApiPayload(String(key), definition)),
91
+ handler: definition.handler,
92
+ }),
93
+ ]));
94
+ }
95
+ export function buildApiInteractionsFromDataOfferSources(sources) {
96
+ return Object.fromEntries(Object.entries(sources).map(([key, source]) => [
97
+ key,
98
+ defineApiInteraction({
99
+ id: source.offerId,
100
+ topic: source.topic,
101
+ asset: source.asset,
102
+ objectType: source.objectType,
103
+ objectId: source.objectId,
104
+ attribute: source.attribute,
105
+ method: source.method ?? "GET",
106
+ routeOnly: true,
107
+ registryTopic: "data-offer-endpoints",
108
+ options: buildApiInteractionOptions(source.method ?? "GET", source.apiDescription ?? source.description, source.tags, source.queryParams, source.requestBody, true, "data-offer-endpoints"),
109
+ handler: source.handler,
110
+ }),
111
+ ]));
112
+ }
113
+ export function buildDataCatalogOffersFromSources(sources) {
114
+ return Object.values(sources).map((source) => ({
115
+ offerId: source.offerId,
116
+ displayName: source.displayName,
117
+ description: source.description,
118
+ ...(source.owner ? { owner: source.owner } : {}),
119
+ status: source.status ?? "available",
120
+ ...(source.tags ? { tags: source.tags } : {}),
121
+ ...(source.categories ? { categories: source.categories } : {}),
122
+ schemas: [source.schema],
123
+ operations: [
124
+ buildDataCatalogOfferOperation(source, {
125
+ id: `${source.offerId}-${(source.method ?? "GET").toLowerCase()}`,
126
+ method: source.method ?? "GET",
127
+ summary: source.displayName,
128
+ description: source.description,
129
+ ...(source.tags ? { tags: source.tags } : {}),
130
+ ...(source.queryParams ? { parameters: source.queryParams } : {}),
131
+ ...(source.headers ? { headers: source.headers } : {}),
132
+ ...(source.requestBody !== undefined ? { requestBody: source.requestBody } : {}),
133
+ responses: [
134
+ {
135
+ statusCode: source.response?.statusCode ?? "200",
136
+ ...(source.response?.description ? { description: source.response.description } : { description: source.description }),
137
+ contentType: source.response?.contentType ?? source.schema.contentType ?? null,
138
+ schemas: [source.schema],
139
+ ...(source.response?.examplePayloads ? { examplePayloads: source.response.examplePayloads } : {}),
140
+ ...(source.response?.headers ? { headers: source.response.headers } : { headers: [] }),
141
+ },
142
+ ],
143
+ }),
144
+ ],
145
+ }));
146
+ }
147
+ export function projectRowsForDataCatalogSchema(rows, schema) {
148
+ const leafFields = (schema.fields ?? []).filter((field) => field.type !== "array");
149
+ return rows.map((row) => {
150
+ const projected = {};
151
+ for (const field of leafFields) {
152
+ const outputKey = field.name;
153
+ const sourceKey = field.sourceKey?.trim() || field.name;
154
+ projected[outputKey] = getRowValue(row, sourceKey);
155
+ }
156
+ return projected;
157
+ });
158
+ }
159
+ export function buildSchemaFieldPathIndex(schema) {
160
+ return (schema.fields ?? []).map((field) => ({
161
+ path: field.path ?? field.name,
162
+ name: field.name,
163
+ }));
164
+ }
165
+ function inferSchemaRootType(contentType, fields) {
166
+ if (contentType.includes("parquet") || contentType.includes("octet-stream")) {
167
+ return "table";
168
+ }
169
+ if (fields.some((field) => (field.path ?? field.name).includes("[]"))) {
170
+ return "object";
171
+ }
172
+ return "object";
173
+ }
174
+ function buildSchemaFieldPath(name, type, fieldPathPrefix) {
175
+ if (fieldPathPrefix && type !== "array") {
176
+ return `${fieldPathPrefix}.${name}`;
177
+ }
178
+ return name;
179
+ }
180
+ function buildApiInteractionOptions(method, description, tags, queryParams, requestBody, routeOnly = true, registryTopic = "api-endpoints", serviceApi) {
181
+ if (method === "GET") {
182
+ return {
183
+ apiDescription: description,
184
+ ...(routeOnly ? { routeOnly: true } : {}),
185
+ ...(registryTopic ? { registryTopic } : {}),
186
+ ...(serviceApi ? { serviceApi } : {}),
187
+ ...(tags ? { tags } : {}),
188
+ ...(queryParams
189
+ ? {
190
+ queryParams: queryParams
191
+ .filter((parameter) => parameter.in === "query")
192
+ .map((parameter) => ({
193
+ name: parameter.name,
194
+ type: parameter.type === "number" || parameter.type === "boolean"
195
+ ? parameter.type
196
+ : "string",
197
+ required: parameter.required,
198
+ ...(parameter.description ? { description: parameter.description } : {}),
199
+ })),
200
+ }
201
+ : {}),
202
+ };
203
+ }
204
+ const requestSchema = requestBody?.schemas?.[0]
205
+ ? buildJsonSchemaFromCatalogSchema(requestBody.schemas[0])
206
+ : null;
207
+ return {
208
+ apiDescription: description,
209
+ ...(routeOnly ? { routeOnly: true } : {}),
210
+ ...(registryTopic ? { registryTopic } : {}),
211
+ ...(serviceApi ? { serviceApi } : {}),
212
+ ...(tags ? { tags } : {}),
213
+ ...(requestBody
214
+ ? {
215
+ requestBody: {
216
+ required: requestBody.required ?? true,
217
+ ...(requestBody.description ? { description: requestBody.description } : {}),
218
+ ...(requestSchema && typeof requestSchema === "object" ? { schema: requestSchema } : {}),
219
+ },
220
+ }
221
+ : {}),
222
+ };
223
+ }
224
+ function buildServiceApiPayload(id, definition) {
225
+ const schemas = definition.schema ? [definition.schema] : [];
226
+ const response = definition.response ?? {
227
+ statusCode: "200",
228
+ description: definition.description,
229
+ ...(definition.schema?.contentType ? { contentType: definition.schema.contentType } : {}),
230
+ ...(definition.schema ? { schemas: [definition.schema] } : {}),
231
+ };
232
+ return {
233
+ id,
234
+ summary: definition.description,
235
+ description: definition.description,
236
+ tags: definition.tags ?? [],
237
+ parameters: definition.queryParams ?? [],
238
+ headers: [],
239
+ requestBody: definition.requestBody ?? null,
240
+ responses: [response],
241
+ schemas,
242
+ };
243
+ }
244
+ function getRowValue(row, key) {
245
+ if (Object.prototype.hasOwnProperty.call(row, key)) {
246
+ return row[key];
247
+ }
248
+ const normalizedKey = key.toLowerCase();
249
+ const match = Object.keys(row).find((candidate) => candidate.toLowerCase() === normalizedKey);
250
+ return match ? row[match] : null;
251
+ }
252
+ function buildJsonSchemaFromCatalogSchema(schema) {
253
+ const leafFields = (schema.fields ?? []).filter((field) => field.type !== "array");
254
+ const properties = Object.fromEntries(leafFields.map((field) => [
255
+ field.name,
256
+ {
257
+ type: toJsonSchemaType(field.type ?? "string"),
258
+ ...(field.description ? { description: field.description } : {}),
259
+ ...(field.format ? { format: field.format } : {}),
260
+ ...(field.example !== undefined ? { example: field.example } : {}),
261
+ ...(Array.isArray(field.enumValues) && field.enumValues.length ? { enum: field.enumValues } : {}),
262
+ },
263
+ ]));
264
+ const required = leafFields.filter((field) => field.required === true).map((field) => field.name);
265
+ return {
266
+ type: "object",
267
+ ...(required.length ? { required } : {}),
268
+ properties,
269
+ };
270
+ }
271
+ function toJsonSchemaType(type) {
272
+ switch (type) {
273
+ case "number":
274
+ case "integer":
275
+ case "boolean":
276
+ case "array":
277
+ case "object":
278
+ return type;
279
+ default:
280
+ return "string";
281
+ }
282
+ }
283
+ function buildExamplePayloadsFromFields(fields) {
284
+ const arrayFields = fields.filter((field) => field.type === "array");
285
+ const scalarFields = fields.filter((field) => field.type !== "array");
286
+ if (!scalarFields.length) {
287
+ return undefined;
288
+ }
289
+ if (arrayFields.length === 1 && isGenericListField(arrayFields[0].name)) {
290
+ const listField = arrayFields[0];
291
+ const rowFields = scalarFields.filter((field) => field.name !== "count");
292
+ if (!rowFields.length || rowFields.some((field) => field.example === undefined)) {
293
+ return undefined;
294
+ }
295
+ const payload = {
296
+ [listField.name]: [Object.fromEntries(rowFields.map((field) => [field.name, field.example]))],
297
+ };
298
+ const countField = scalarFields.find((field) => field.name === "count");
299
+ if (countField) {
300
+ payload[countField.name] = countField.example ?? 1;
301
+ }
302
+ return [payload];
303
+ }
304
+ if (scalarFields.some((field) => field.example === undefined)) {
305
+ return undefined;
306
+ }
307
+ return [Object.fromEntries(scalarFields.map((field) => [field.name, field.example]))];
308
+ }
309
+ function isGenericListField(name) {
310
+ return name === "data" || name === "rows";
311
+ }
package/dist/index.d.ts CHANGED
@@ -1,3 +1,5 @@
1
1
  export { default } from "./uns-api-plugin.js";
2
2
  export { UnsApiProxy, type UnsProxyProcessWithApi } from "./uns-api-plugin.js";
3
3
  export * from "./api-interfaces.js";
4
+ export * from "./api-event-routing.js";
5
+ export * from "./parquet.js";
package/dist/index.js CHANGED
@@ -1,3 +1,5 @@
1
1
  export { default } from "./uns-api-plugin.js";
2
2
  export { UnsApiProxy } from "./uns-api-plugin.js";
3
3
  export * from "./api-interfaces.js";
4
+ export * from "./api-event-routing.js";
5
+ export * from "./parquet.js";
@@ -0,0 +1,8 @@
1
+ import type { DataCatalogSchemaRegistration } from "./api-interfaces.js";
2
+ export declare function writeSchemaRowsToParquet(input: {
3
+ rows: Array<Record<string, unknown>>;
4
+ schema: DataCatalogSchemaRegistration;
5
+ outputDir?: string;
6
+ fileName?: string;
7
+ }): Promise<string | null>;
8
+ export declare function buildParquetSchemaFromCatalogSchema(schema: DataCatalogSchemaRegistration): any;
@@ -0,0 +1,61 @@
1
+ import { randomUUID } from "crypto";
2
+ import fs from "fs";
3
+ import os from "os";
4
+ import path from "path";
5
+ import { ParquetSchema, ParquetWriter } from "parquets";
6
+ import logger from "@uns-kit/core/logger.js";
7
+ const PARQUET_TYPE_MAP = {
8
+ string: "UTF8",
9
+ number: "DOUBLE",
10
+ integer: "INT64",
11
+ boolean: "BOOLEAN",
12
+ date: "TIMESTAMP_MILLIS",
13
+ "date-time": "TIMESTAMP_MILLIS",
14
+ };
15
+ export async function writeSchemaRowsToParquet(input) {
16
+ 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 writer = await ParquetWriter.openFile(buildParquetSchemaFromCatalogSchema(input.schema), filePath);
24
+ for (const row of input.rows) {
25
+ await writer.appendRow(normalizeParquetRow(row));
26
+ }
27
+ await writer.close();
28
+ return filePath;
29
+ }
30
+ catch (error) {
31
+ logger.error("Failed to write schema-driven parquet:", error);
32
+ return null;
33
+ }
34
+ }
35
+ export function buildParquetSchemaFromCatalogSchema(schema) {
36
+ const schemaDef = {};
37
+ for (const field of schema.fields ?? []) {
38
+ const parquetType = toParquetType(field.type ?? "string", field.format ?? null);
39
+ if (!parquetType) {
40
+ continue;
41
+ }
42
+ schemaDef[field.name] = {
43
+ type: parquetType,
44
+ optional: field.required !== true,
45
+ };
46
+ }
47
+ return new ParquetSchema(schemaDef);
48
+ }
49
+ function toParquetType(type, format) {
50
+ if (format && PARQUET_TYPE_MAP[format]) {
51
+ return PARQUET_TYPE_MAP[format];
52
+ }
53
+ return PARQUET_TYPE_MAP[type] ?? null;
54
+ }
55
+ function normalizeParquetRow(row) {
56
+ const normalized = {};
57
+ for (const [key, value] of Object.entries(row)) {
58
+ normalized[key] = value instanceof Date ? new Date(value) : value;
59
+ }
60
+ return normalized;
61
+ }
@@ -22,9 +22,18 @@ const unsApiPlugin = ({ define }) => {
22
22
  unsApiProxy.event.on("unsProxyProducedApiEndpoints", (event) => {
23
23
  internals.processMqttProxy.publish(event.statusTopic, JSON.stringify(event.producedApiEndpoints));
24
24
  });
25
+ unsApiProxy.event.on("unsProxyProducedServiceEndpoints", (event) => {
26
+ internals.processMqttProxy.publish(event.statusTopic, JSON.stringify(event.producedServiceEndpoints));
27
+ });
28
+ unsApiProxy.event.on("unsProxyProducedDataOfferEndpoints", (event) => {
29
+ internals.processMqttProxy.publish(event.statusTopic, JSON.stringify(event.producedDataOfferEndpoints));
30
+ });
25
31
  unsApiProxy.event.on("unsProxyProducedApiCatchAll", (event) => {
26
32
  internals.processMqttProxy.publish(event.statusTopic, JSON.stringify(event.producedCatchall));
27
33
  });
34
+ unsApiProxy.event.on("unsProxyProducedDataCatalogOffers", (event) => {
35
+ internals.processMqttProxy.publish(event.statusTopic, JSON.stringify(event.producedDataCatalogOffers));
36
+ });
28
37
  unsApiProxy.event.on("mqttProxyStatus", (event) => {
29
38
  const time = UnsPacket.formatToISO8601(new Date());
30
39
  const unsMessage = { data: { time, value: event.value, uom: event.uom } };
@@ -4,6 +4,7 @@ import { UnsTopics } from "@uns-kit/core/uns/uns-topics.js";
4
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
+ import type { DataCatalogOfferRegistration } from "./api-interfaces.js";
7
8
  export default class UnsApiProxy extends UnsProxy {
8
9
  instanceName: string;
9
10
  private topicBuilder;
@@ -18,7 +19,9 @@ export default class UnsApiProxy extends UnsProxy {
18
19
  private startedAt;
19
20
  private statusInterval;
20
21
  private readonly statusIntervalMs;
22
+ private readonly dataCatalogOffers;
21
23
  constructor(processName: string, instanceName: string, options: IApiProxyOptions);
24
+ getProcessName(): string;
22
25
  /**
23
26
  * Unregister endpoint
24
27
  * @param topic - The API topic
@@ -51,20 +54,27 @@ export default class UnsApiProxy extends UnsProxy {
51
54
  tags?: string[];
52
55
  queryParams?: IGetEndpointOptions["queryParams"];
53
56
  }): Promise<void>;
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
57
  post(topic: UnsTopics, asset: UnsAsset, objectType: UnsObjectType, objectId: UnsObjectId, attribute: UnsAttribute, options?: IPostEndpointOptions): Promise<void>;
58
+ put(topic: UnsTopics, asset: UnsAsset, objectType: UnsObjectType, objectId: UnsObjectId, attribute: UnsAttribute, options?: IPostEndpointOptions): Promise<void>;
59
+ patch(topic: UnsTopics, asset: UnsAsset, objectType: UnsObjectType, objectId: UnsObjectId, attribute: UnsAttribute, options?: IPostEndpointOptions): Promise<void>;
60
+ delete(topic: UnsTopics, asset: UnsAsset, objectType: UnsObjectType, objectId: UnsObjectId, attribute: UnsAttribute, options?: IPostEndpointOptions): Promise<void>;
61
+ private registerMutationEndpoint;
62
+ registerDataOffer(input: DataCatalogOfferRegistration): void;
63
63
  private emitStatusMetrics;
64
64
  private registerHealthEndpoint;
65
+ private getApiEventName;
65
66
  private extractBearerToken;
66
67
  private getPublicKeyFromJwks;
67
68
  private fetchJwksKeys;
68
69
  private certFromX5c;
69
70
  stop(): Promise<void>;
71
+ private publishDataCatalogOffer;
72
+ private emitDataCatalogOffers;
73
+ private normalizeDataOfferOperation;
74
+ private normalizeDataOfferParameter;
75
+ private normalizeDataOfferRequestBody;
76
+ private normalizeDataOfferResponse;
77
+ private normalizeDataOfferSchema;
78
+ private normalizeDataOfferSchemaField;
79
+ private resolveOfferSchemas;
70
80
  }
@@ -31,6 +31,21 @@ const buildSwaggerPath = (base, processName, instanceName) => {
31
31
  }
32
32
  return `${baseWithProcess}/${instanceName}/swagger.json`.replace(/\/{2,}/g, "/");
33
33
  };
34
+ const normalizeStringArray = (value) => Array.isArray(value)
35
+ ? Array.from(new Set(value
36
+ .map((entry) => (typeof entry === "string" ? entry.trim() : ""))
37
+ .filter((entry) => entry.length > 0)))
38
+ : [];
39
+ const normalizePathValue = (value) => {
40
+ if (typeof value !== "string") {
41
+ return null;
42
+ }
43
+ const trimmed = value.trim();
44
+ if (!trimmed) {
45
+ return null;
46
+ }
47
+ return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
48
+ };
34
49
  export default class UnsApiProxy extends UnsProxy {
35
50
  instanceName;
36
51
  topicBuilder;
@@ -45,6 +60,7 @@ export default class UnsApiProxy extends UnsProxy {
45
60
  startedAt;
46
61
  statusInterval = null;
47
62
  statusIntervalMs = 10_000;
63
+ dataCatalogOffers = new Map();
48
64
  constructor(processName, instanceName, options) {
49
65
  super();
50
66
  this.options = options;
@@ -76,6 +92,9 @@ export default class UnsApiProxy extends UnsProxy {
76
92
  setTimeout(() => this.emitStatusMetrics(), 0);
77
93
  this.statusInterval = setInterval(() => this.emitStatusMetrics(), this.statusIntervalMs);
78
94
  }
95
+ getProcessName() {
96
+ return this.processName;
97
+ }
79
98
  /**
80
99
  * Unregister endpoint
81
100
  * @param topic - The API topic
@@ -138,11 +157,15 @@ export default class UnsApiProxy extends UnsProxy {
138
157
  timestamp: time,
139
158
  topic: topic,
140
159
  attribute: attribute,
160
+ routeOnly: options?.routeOnly === true,
161
+ registryTopic: options?.registryTopic ??
162
+ "api-endpoints",
141
163
  apiHost: `http://${ip}:${port}`,
142
164
  apiEndpoint: apiPath,
143
165
  apiMethod: "GET",
144
166
  apiQueryParams: options.queryParams,
145
167
  apiDescription: options?.apiDescription,
168
+ serviceApi: options?.serviceApi ?? null,
146
169
  attributeType: UnsAttributeType.Api,
147
170
  apiSwaggerEndpoint: swaggerPath,
148
171
  asset,
@@ -383,15 +406,19 @@ export default class UnsApiProxy extends UnsProxy {
383
406
  swaggerPath,
384
407
  });
385
408
  }
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
409
  async post(topic, asset, objectType, objectId, attribute, options) {
410
+ await this.registerMutationEndpoint("POST", topic, asset, objectType, objectId, attribute, options);
411
+ }
412
+ async put(topic, asset, objectType, objectId, attribute, options) {
413
+ await this.registerMutationEndpoint("PUT", topic, asset, objectType, objectId, attribute, options);
414
+ }
415
+ async patch(topic, asset, objectType, objectId, attribute, options) {
416
+ await this.registerMutationEndpoint("PATCH", topic, asset, objectType, objectId, attribute, options);
417
+ }
418
+ async delete(topic, asset, objectType, objectId, attribute, options) {
419
+ await this.registerMutationEndpoint("DELETE", topic, asset, objectType, objectId, attribute, options);
420
+ }
421
+ async registerMutationEndpoint(method, topic, asset, objectType, objectId, attribute, options) {
395
422
  while (this.app.server.listening === false) {
396
423
  await new Promise((resolve) => setTimeout(resolve, 100));
397
424
  }
@@ -399,6 +426,7 @@ export default class UnsApiProxy extends UnsProxy {
399
426
  const fullPath = buildUnsRoutePath(topic, asset, objectType, objectId, attribute);
400
427
  const apiPath = `${this.apiBasePrefix}${fullPath}`.replace(/\/{2,}/g, "/");
401
428
  const swaggerPath = buildSwaggerPath(this.swaggerBasePrefix, this.processName, this.instanceName);
429
+ const methodKey = method.toLowerCase();
402
430
  try {
403
431
  const addressInfo = this.app.server.address();
404
432
  let ip;
@@ -415,11 +443,15 @@ export default class UnsApiProxy extends UnsProxy {
415
443
  timestamp: time,
416
444
  topic,
417
445
  attribute,
446
+ routeOnly: options?.routeOnly === true,
447
+ registryTopic: options?.registryTopic ??
448
+ "api-endpoints",
418
449
  apiHost: `http://${ip}:${port}`,
419
450
  apiEndpoint: apiPath,
420
- apiMethod: "POST",
451
+ apiMethod: method,
421
452
  apiQueryParams: [],
422
453
  apiDescription: options?.apiDescription,
454
+ serviceApi: options?.serviceApi ?? null,
423
455
  attributeType: UnsAttributeType.Api,
424
456
  apiSwaggerEndpoint: swaggerPath,
425
457
  asset,
@@ -427,10 +459,11 @@ export default class UnsApiProxy extends UnsProxy {
427
459
  objectId,
428
460
  });
429
461
  const handler = (req, res) => {
430
- this.event.emit("apiPostEvent", { req, res });
462
+ this.event.emit(this.getApiEventName(method), { req, res });
431
463
  };
464
+ const routerMethod = this.app.router[methodKey].bind(this.app.router);
432
465
  if (this.options?.jwks?.wellKnownJwksUrl) {
433
- this.app.router.post(fullPath, async (req, res) => {
466
+ routerMethod(fullPath, async (req, res) => {
434
467
  try {
435
468
  const token = this.extractBearerToken(req, res);
436
469
  if (!token)
@@ -451,13 +484,13 @@ export default class UnsApiProxy extends UnsProxy {
451
484
  }
452
485
  handler(req, res);
453
486
  }
454
- catch (err) {
487
+ catch (_err) {
455
488
  return res.status(401).json({ error: "Invalid token" });
456
489
  }
457
490
  });
458
491
  }
459
492
  else if (this.options?.jwtSecret) {
460
- this.app.router.post(fullPath, (req, res) => {
493
+ routerMethod(fullPath, (req, res) => {
461
494
  const authHeader = req.headers["authorization"];
462
495
  if (!authHeader || !authHeader.startsWith("Bearer ")) {
463
496
  return res.status(401).json({ error: "Missing or invalid Authorization header" });
@@ -478,19 +511,19 @@ export default class UnsApiProxy extends UnsProxy {
478
511
  }
479
512
  handler(req, res);
480
513
  }
481
- catch (err) {
514
+ catch (_err) {
482
515
  return res.status(401).json({ error: "Invalid token" });
483
516
  }
484
517
  });
485
518
  }
486
519
  else {
487
- this.app.router.post(fullPath, handler);
520
+ routerMethod(fullPath, handler);
488
521
  }
489
522
  if (this.app.swaggerSpec) {
490
523
  const requestBody = options?.requestBody;
491
524
  this.app.swaggerSpec.paths = this.app.swaggerSpec.paths || {};
492
525
  this.app.swaggerSpec.paths[apiPath] = this.app.swaggerSpec.paths[apiPath] || {};
493
- this.app.swaggerSpec.paths[apiPath].post = {
526
+ this.app.swaggerSpec.paths[apiPath][methodKey] = {
494
527
  summary: options?.apiDescription || "No description",
495
528
  tags: options?.tags || [],
496
529
  ...(requestBody
@@ -516,9 +549,58 @@ export default class UnsApiProxy extends UnsProxy {
516
549
  }
517
550
  }
518
551
  catch (error) {
519
- logger.error(`${this.instanceNameWithSuffix} - Error registering POST route ${fullPath}: ${error.message}`);
552
+ logger.error(`${this.instanceNameWithSuffix} - Error registering ${method} route ${fullPath}: ${error.message}`);
520
553
  }
521
554
  }
555
+ registerDataOffer(input) {
556
+ const offerId = typeof input.offerId === "string" ? input.offerId.trim() : "";
557
+ const displayName = typeof input.displayName === "string" ? input.displayName.trim() : "";
558
+ if (!offerId || !displayName) {
559
+ throw new Error("Data catalog offer requires non-empty offerId and displayName.");
560
+ }
561
+ const offerSchemas = (Array.isArray(input.schemas) ? input.schemas : []).map((schema) => this.normalizeDataOfferSchema(schema));
562
+ const offerSchemaIndex = new Map(offerSchemas.map((schema) => [schema.id, schema]));
563
+ const normalizedOperations = (Array.isArray(input.operations) ? input.operations : [])
564
+ .map((operation, index) => this.normalizeDataOfferOperation(operation, offerId, index, offerSchemaIndex))
565
+ .filter((operation) => Boolean(operation));
566
+ if (!normalizedOperations.length) {
567
+ throw new Error(`Data catalog offer ${offerId} requires at least one operation.`);
568
+ }
569
+ const basePaths = Array.from(new Set([
570
+ ...(Array.isArray(input.basePaths) ? input.basePaths.map((entry) => normalizePathValue(entry)) : []),
571
+ ...normalizedOperations.map((operation) => {
572
+ const segments = operation.path.split("/").filter(Boolean);
573
+ if (segments.length <= 1) {
574
+ return operation.path;
575
+ }
576
+ return `/${segments.slice(0, -1).join("/")}`;
577
+ }),
578
+ ].filter((value) => Boolean(value))));
579
+ this.publishDataCatalogOffer({
580
+ offerId,
581
+ displayName,
582
+ description: typeof input.description === "string" ? input.description.trim() || null : null,
583
+ owner: typeof input.owner === "string" ? input.owner.trim() || null : null,
584
+ status: typeof input.status === "string" && input.status.trim() ? input.status.trim() : "available",
585
+ tags: normalizeStringArray(input.tags),
586
+ categories: normalizeStringArray(input.categories),
587
+ microserviceName: typeof input.microserviceName === "string" && input.microserviceName.trim()
588
+ ? input.microserviceName.trim()
589
+ : this.processName,
590
+ version: typeof input.version === "string" && input.version.trim() ? input.version.trim() : packageJson.version,
591
+ swaggerPath: normalizePathValue(input.swaggerPath),
592
+ basePaths,
593
+ operations: normalizedOperations,
594
+ schemas: offerSchemas,
595
+ metadata: input.metadata ?? null,
596
+ packageName: packageJson.name,
597
+ processName: this.processName,
598
+ processVersion: packageJson.version,
599
+ instanceName: this.instanceName,
600
+ controllerName: process.env["UNS_CONTROLLER_NAME"] ?? null,
601
+ controllerPublicBase: process.env["UNS_CONTROLLER_PUBLIC_BASE"] ?? process.env["UNS_PUBLIC_BASE"] ?? null,
602
+ });
603
+ }
522
604
  emitStatusMetrics() {
523
605
  const uptimeMinutes = Math.round((Date.now() - this.startedAt) / 60000);
524
606
  // Process-level status
@@ -547,6 +629,7 @@ export default class UnsApiProxy extends UnsProxy {
547
629
  uom: DataSizeMeasurements.Bit,
548
630
  statusTopic: this.instanceStatusTopic + "alive",
549
631
  });
632
+ this.emitDataCatalogOffers();
550
633
  }
551
634
  registerHealthEndpoint() {
552
635
  const routePath = "/status";
@@ -575,6 +658,20 @@ export default class UnsApiProxy extends UnsProxy {
575
658
  };
576
659
  }
577
660
  }
661
+ getApiEventName(method) {
662
+ switch (method) {
663
+ case "GET":
664
+ return "apiGetEvent";
665
+ case "POST":
666
+ return "apiPostEvent";
667
+ case "PUT":
668
+ return "apiPutEvent";
669
+ case "PATCH":
670
+ return "apiPatchEvent";
671
+ case "DELETE":
672
+ return "apiDeleteEvent";
673
+ }
674
+ }
578
675
  extractBearerToken(req, res) {
579
676
  const authHeader = req.headers["authorization"];
580
677
  if (!authHeader || !authHeader.startsWith("Bearer ")) {
@@ -647,4 +744,117 @@ export default class UnsApiProxy extends UnsProxy {
647
744
  }
648
745
  await super.stop();
649
746
  }
747
+ publishDataCatalogOffer(offer) {
748
+ const offerId = typeof offer.offerId === "string" ? offer.offerId.trim() : "";
749
+ if (!offerId) {
750
+ return;
751
+ }
752
+ this.dataCatalogOffers.set(offerId, offer);
753
+ this.emitDataCatalogOffers();
754
+ logger.info(`${this.instanceNameWithSuffix} - Registered data catalog offer: ${offerId}`);
755
+ }
756
+ emitDataCatalogOffers() {
757
+ if (this.instanceStatusTopic === "" || this.dataCatalogOffers.size === 0) {
758
+ return;
759
+ }
760
+ this.event.emit("unsProxyProducedDataCatalogOffers", {
761
+ producedDataCatalogOffers: [...this.dataCatalogOffers.values()],
762
+ statusTopic: this.instanceStatusTopic + "data-catalog-offers",
763
+ });
764
+ }
765
+ normalizeDataOfferOperation(operation, offerId, index, offerSchemaIndex) {
766
+ const method = typeof operation.method === "string" ? operation.method.trim().toUpperCase() : "";
767
+ const path = normalizePathValue(operation.path);
768
+ if (!method || !path) {
769
+ return null;
770
+ }
771
+ return {
772
+ id: typeof operation.id === "string" && operation.id.trim() ? operation.id.trim() : `${offerId}-${method.toLowerCase()}-${index + 1}`,
773
+ method,
774
+ path,
775
+ summary: typeof operation.summary === "string" ? operation.summary.trim() || null : null,
776
+ description: typeof operation.description === "string" ? operation.description.trim() || null : null,
777
+ tags: normalizeStringArray(operation.tags),
778
+ deprecated: operation.deprecated === true,
779
+ parameters: (Array.isArray(operation.parameters) ? operation.parameters : []).map((parameter) => this.normalizeDataOfferParameter(parameter)),
780
+ headers: (Array.isArray(operation.headers) ? operation.headers : []).map((parameter) => this.normalizeDataOfferParameter(parameter)),
781
+ requestBody: operation.requestBody ? this.normalizeDataOfferRequestBody(operation.requestBody, offerSchemaIndex) : null,
782
+ responses: (Array.isArray(operation.responses) ? operation.responses : []).map((response) => this.normalizeDataOfferResponse(response, offerSchemaIndex)),
783
+ };
784
+ }
785
+ normalizeDataOfferParameter(parameter) {
786
+ return {
787
+ name: typeof parameter.name === "string" ? parameter.name.trim() : "",
788
+ in: typeof parameter.in === "string" ? parameter.in.trim() : "query",
789
+ required: parameter.required === true,
790
+ description: typeof parameter.description === "string" ? parameter.description.trim() || null : null,
791
+ type: typeof parameter.type === "string" && parameter.type.trim() ? parameter.type.trim() : "string",
792
+ format: typeof parameter.format === "string" ? parameter.format.trim() || null : null,
793
+ nullable: parameter.nullable === true,
794
+ example: parameter.example ?? null,
795
+ enumValues: normalizeStringArray(parameter.enumValues),
796
+ schema: parameter.schema ?? null,
797
+ };
798
+ }
799
+ normalizeDataOfferRequestBody(requestBody, offerSchemaIndex) {
800
+ return {
801
+ required: requestBody.required === true,
802
+ description: typeof requestBody.description === "string" ? requestBody.description.trim() || null : null,
803
+ contentType: typeof requestBody.contentType === "string" ? requestBody.contentType.trim() || null : null,
804
+ schemas: this.resolveOfferSchemas(requestBody.schemas, requestBody.schemaIds, offerSchemaIndex),
805
+ };
806
+ }
807
+ normalizeDataOfferResponse(response, offerSchemaIndex) {
808
+ return {
809
+ statusCode: typeof response.statusCode === "string" ? response.statusCode.trim() : "",
810
+ description: typeof response.description === "string" ? response.description.trim() || null : null,
811
+ contentType: typeof response.contentType === "string" ? response.contentType.trim() || null : null,
812
+ schemas: this.resolveOfferSchemas(response.schemas, response.schemaIds, offerSchemaIndex),
813
+ examplePayloads: Array.isArray(response.examplePayloads) ? response.examplePayloads : [],
814
+ headers: (Array.isArray(response.headers) ? response.headers : []).map((parameter) => this.normalizeDataOfferParameter(parameter)),
815
+ };
816
+ }
817
+ normalizeDataOfferSchema(schema) {
818
+ return {
819
+ id: typeof schema.id === "string" ? schema.id.trim() : "",
820
+ title: typeof schema.title === "string" ? schema.title.trim() : "",
821
+ kind: typeof schema.kind === "string" && schema.kind.trim() ? schema.kind.trim() : "schema",
822
+ source: typeof schema.source === "string" && schema.source.trim() ? schema.source.trim() : "registered",
823
+ contentType: typeof schema.contentType === "string" ? schema.contentType.trim() || null : null,
824
+ rootType: typeof schema.rootType === "string" && schema.rootType.trim() ? schema.rootType.trim() : "object",
825
+ nullable: schema.nullable === true,
826
+ description: typeof schema.description === "string" ? schema.description.trim() || null : null,
827
+ fields: (Array.isArray(schema.fields) ? schema.fields : []).map((field) => this.normalizeDataOfferSchemaField(field)),
828
+ examplePayloads: Array.isArray(schema.examplePayloads) ? schema.examplePayloads : [],
829
+ };
830
+ }
831
+ normalizeDataOfferSchemaField(field) {
832
+ const defaultName = typeof field.name === "string" ? field.name.trim() : "";
833
+ const path = typeof field.path === "string" && field.path.trim() ? field.path.trim() : defaultName;
834
+ return {
835
+ path,
836
+ name: defaultName || (path.split(".").at(-1) ?? path),
837
+ type: typeof field.type === "string" && field.type.trim() ? field.type.trim() : "string",
838
+ format: typeof field.format === "string" ? field.format.trim() || null : null,
839
+ nullable: field.nullable === true,
840
+ required: field.required === true,
841
+ description: typeof field.description === "string" ? field.description.trim() || null : null,
842
+ enumValues: normalizeStringArray(field.enumValues),
843
+ example: field.example ?? null,
844
+ };
845
+ }
846
+ resolveOfferSchemas(inlineSchemas, schemaIds, offerSchemaIndex) {
847
+ const result = (Array.isArray(inlineSchemas) ? inlineSchemas : []).map((schema) => this.normalizeDataOfferSchema(schema));
848
+ for (const schemaId of Array.isArray(schemaIds) ? schemaIds : []) {
849
+ const normalizedId = typeof schemaId === "string" ? schemaId.trim() : "";
850
+ if (!normalizedId) {
851
+ continue;
852
+ }
853
+ const resolved = offerSchemaIndex.get(normalizedId);
854
+ if (resolved && !result.some((schema) => schema.id === resolved.id)) {
855
+ result.push(resolved);
856
+ }
857
+ }
858
+ return result;
859
+ }
650
860
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uns-kit/api",
3
- "version": "2.0.44",
3
+ "version": "2.0.46",
4
4
  "description": "Express-powered API gateway plugin for UnsProxyProcess with JWT/JWKS support.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -31,16 +31,18 @@
31
31
  "main": "dist/index.js",
32
32
  "types": "dist/index.d.ts",
33
33
  "dependencies": {
34
- "jsonwebtoken": "^9.0.2",
35
34
  "cookie-parser": "^1.4.7",
36
35
  "express": "^5.1.0",
36
+ "jsonwebtoken": "^9.0.2",
37
37
  "multer": "^2.0.2",
38
- "@uns-kit/core": "2.0.44"
38
+ "parquets": "^0.10.10",
39
+ "uuid": "^14.0.0",
40
+ "@uns-kit/core": "2.0.46"
39
41
  },
40
42
  "devDependencies": {
41
- "@types/jsonwebtoken": "^9.0.10",
42
43
  "@types/cookie-parser": "^1.4.9",
43
44
  "@types/express": "^5.0.3",
45
+ "@types/jsonwebtoken": "^9.0.10",
44
46
  "@types/multer": "^2.0.0"
45
47
  },
46
48
  "scripts": {