@uns-kit/api 3.0.24 → 3.0.26
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/dist/api-interfaces.d.ts +185 -0
- package/dist/api-interfaces.js +311 -0
- package/dist/app.d.ts +48 -0
- package/dist/app.js +148 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/routes/api.d.ts +6 -0
- package/dist/routes/api.js +44 -0
- package/dist/uns-api-plugin.d.ts +9 -0
- package/dist/uns-api-plugin.js +52 -0
- package/dist/uns-api-proxy.d.ts +81 -0
- package/dist/uns-api-proxy.js +846 -0
- package/package.json +2 -2
|
@@ -0,0 +1,846 @@
|
|
|
1
|
+
import { readFileSync } from "fs";
|
|
2
|
+
import jwt from "jsonwebtoken";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import { createPublicKey } from "crypto";
|
|
5
|
+
import { basePath } from "@uns-kit/core/base-path.js";
|
|
6
|
+
import { UnsAttributeType } from "@uns-kit/core/graphql/schema.js";
|
|
7
|
+
import logger from "@uns-kit/core/logger.js";
|
|
8
|
+
import { MqttTopicBuilder } from "@uns-kit/core/uns-mqtt/mqtt-topic-builder.js";
|
|
9
|
+
import { UnsPacket } from "@uns-kit/core/uns/uns-packet.js";
|
|
10
|
+
import UnsProxy from "@uns-kit/core/uns/uns-proxy.js";
|
|
11
|
+
import { UnsTopicMatcher } from "@uns-kit/core/uns/uns-topic-matcher.js";
|
|
12
|
+
import { DataSizeMeasurements, PhysicalMeasurements } from "@uns-kit/core/uns/uns-measurements.js";
|
|
13
|
+
import { buildUnsRoutePath } from "@uns-kit/core/uns/uns-path.js";
|
|
14
|
+
import App from "./app.js";
|
|
15
|
+
const packageJsonPath = path.join(basePath, "package.json");
|
|
16
|
+
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
|
|
17
|
+
const normalizeBasePrefix = (value) => {
|
|
18
|
+
if (!value)
|
|
19
|
+
return "";
|
|
20
|
+
const trimmed = value.trim();
|
|
21
|
+
if (!trimmed)
|
|
22
|
+
return "";
|
|
23
|
+
const withLeading = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
24
|
+
return withLeading.replace(/\/+$/, "");
|
|
25
|
+
};
|
|
26
|
+
const buildSwaggerPath = (base, processName, instanceName) => {
|
|
27
|
+
const processSegment = `/${processName}`;
|
|
28
|
+
let baseWithProcess = base || "/";
|
|
29
|
+
if (!baseWithProcess.endsWith(processSegment)) {
|
|
30
|
+
baseWithProcess = `${baseWithProcess}${processSegment}`;
|
|
31
|
+
}
|
|
32
|
+
return `${baseWithProcess}/${instanceName}/swagger.json`.replace(/\/{2,}/g, "/");
|
|
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
|
+
};
|
|
49
|
+
export default class UnsApiProxy extends UnsProxy {
|
|
50
|
+
instanceName;
|
|
51
|
+
topicBuilder;
|
|
52
|
+
processName;
|
|
53
|
+
processStatusTopic;
|
|
54
|
+
app;
|
|
55
|
+
options;
|
|
56
|
+
apiBasePrefix;
|
|
57
|
+
swaggerBasePrefix;
|
|
58
|
+
jwksCache;
|
|
59
|
+
catchAllRouteRegistered = false;
|
|
60
|
+
startedAt;
|
|
61
|
+
statusInterval = null;
|
|
62
|
+
statusIntervalMs = 10_000;
|
|
63
|
+
dataCatalogOffers = new Map();
|
|
64
|
+
constructor(processName, instanceName, options) {
|
|
65
|
+
super();
|
|
66
|
+
this.options = options;
|
|
67
|
+
this.apiBasePrefix =
|
|
68
|
+
normalizeBasePrefix(options.apiBasePath ?? process.env.UNS_API_BASE_PATH) || "/api";
|
|
69
|
+
const rawSwaggerBase = normalizeBasePrefix(options.swaggerBasePath ?? process.env.UNS_SWAGGER_BASE_PATH) || this.apiBasePrefix;
|
|
70
|
+
this.swaggerBasePrefix = rawSwaggerBase.endsWith("/api")
|
|
71
|
+
? rawSwaggerBase.replace(/\/api\/?$/, "") || "/"
|
|
72
|
+
: rawSwaggerBase;
|
|
73
|
+
this.app = new App(0, processName, instanceName, undefined, {
|
|
74
|
+
apiBasePrefix: this.apiBasePrefix,
|
|
75
|
+
swaggerBasePrefix: this.swaggerBasePrefix,
|
|
76
|
+
disableDefaultApiMount: options.disableDefaultApiMount ?? false,
|
|
77
|
+
});
|
|
78
|
+
this.app.start();
|
|
79
|
+
this.startedAt = Date.now();
|
|
80
|
+
this.instanceName = instanceName;
|
|
81
|
+
this.processName = processName;
|
|
82
|
+
// Create the topic builder using packageJson values and the processName.
|
|
83
|
+
this.topicBuilder = new MqttTopicBuilder(`uns-infra/${MqttTopicBuilder.sanitizeTopicPart(packageJson.name)}/${MqttTopicBuilder.sanitizeTopicPart(packageJson.version)}/${MqttTopicBuilder.sanitizeTopicPart(processName)}/`);
|
|
84
|
+
// Generate the processStatusTopic using the builder.
|
|
85
|
+
this.processStatusTopic = this.topicBuilder.getProcessStatusTopic();
|
|
86
|
+
// Derive the instanceStatusTopic by appending the instance name.
|
|
87
|
+
this.instanceStatusTopic = this.processStatusTopic + instanceName + "/";
|
|
88
|
+
// Concatenate processName with instanceName for the worker identification.
|
|
89
|
+
this.instanceNameWithSuffix = `${processName}-${instanceName}`;
|
|
90
|
+
this.registerHealthEndpoint();
|
|
91
|
+
// Emit once after listeners are attached in the plugin, then on the regular cadence.
|
|
92
|
+
setTimeout(() => this.emitStatusMetrics(), 0);
|
|
93
|
+
this.statusInterval = setInterval(() => this.emitStatusMetrics(), this.statusIntervalMs);
|
|
94
|
+
}
|
|
95
|
+
getProcessName() {
|
|
96
|
+
return this.processName;
|
|
97
|
+
}
|
|
98
|
+
getPublishedApiHost() {
|
|
99
|
+
const addressInfo = this.app.server.address();
|
|
100
|
+
const port = addressInfo && typeof addressInfo === "object" ? addressInfo.port : undefined;
|
|
101
|
+
const configuredHost = this.options.publishedApiHost?.trim() || process.env.UNS_PUBLISHED_API_HOST?.trim();
|
|
102
|
+
if (configuredHost) {
|
|
103
|
+
const origin = new URL(/^[a-z][a-z\d+.-]*:\/\//i.test(configuredHost)
|
|
104
|
+
? configuredHost
|
|
105
|
+
: `http://${configuredHost}`);
|
|
106
|
+
if (!origin.port && port !== undefined) {
|
|
107
|
+
origin.port = String(port);
|
|
108
|
+
}
|
|
109
|
+
origin.pathname = "";
|
|
110
|
+
origin.search = "";
|
|
111
|
+
origin.hash = "";
|
|
112
|
+
return origin.origin;
|
|
113
|
+
}
|
|
114
|
+
return port === undefined
|
|
115
|
+
? `http://${App.getExternalIPv4()}`
|
|
116
|
+
: `http://${App.getExternalIPv4()}:${port}`;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Unregister endpoint
|
|
120
|
+
* @param topic - The API topic
|
|
121
|
+
* @param attribute - The attribute for the topic.
|
|
122
|
+
* @param method - The HTTP method (e.g., "GET", "POST", "PUT", "DELETE").
|
|
123
|
+
*/
|
|
124
|
+
async unregister(topic, asset, objectType, objectId, attribute, method) {
|
|
125
|
+
const fullPath = buildUnsRoutePath(topic, asset, objectType, objectId, attribute);
|
|
126
|
+
const apiPath = `${this.apiBasePrefix}${fullPath}`.replace(/\/{2,}/g, "/");
|
|
127
|
+
const methodKey = method.toLowerCase(); // Express stores method keys in lowercase
|
|
128
|
+
// Remove route from router
|
|
129
|
+
if (this.app.router?.stack) {
|
|
130
|
+
this.app.router.stack = this.app.router.stack.filter((layer) => {
|
|
131
|
+
return !(layer.route &&
|
|
132
|
+
layer.route.path === fullPath &&
|
|
133
|
+
layer.route.methods[methodKey]);
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
// Remove from Swagger spec if path exists
|
|
137
|
+
if (this.app.swaggerSpec?.paths?.[apiPath]) {
|
|
138
|
+
delete this.app.swaggerSpec.paths[apiPath][methodKey];
|
|
139
|
+
// If no methods remain for the path, delete the whole path
|
|
140
|
+
if (Object.keys(this.app.swaggerSpec.paths[apiPath]).length === 0) {
|
|
141
|
+
delete this.app.swaggerSpec.paths[apiPath];
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
// Unregister from internal endpoint tracking
|
|
145
|
+
this.unregisterApiEndpoint(topic, asset, objectType, objectId, attribute);
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Register a GET endpoint with optional JWT path filter.
|
|
149
|
+
* @param topic - The API topic
|
|
150
|
+
* @param attribute - The attribute for the topic.
|
|
151
|
+
* @param options.description - Optional description.
|
|
152
|
+
* @param options.tags - Optional tags.
|
|
153
|
+
*/
|
|
154
|
+
async get(topic, asset, objectType, objectId, attribute, options) {
|
|
155
|
+
// Wait until the API server is started
|
|
156
|
+
while (this.app.server.listening === false) {
|
|
157
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
158
|
+
}
|
|
159
|
+
const time = UnsPacket.formatToISO8601(new Date());
|
|
160
|
+
const fullPath = buildUnsRoutePath(topic, asset, objectType, objectId, attribute);
|
|
161
|
+
const apiPath = `${this.apiBasePrefix}${fullPath}`.replace(/\/{2,}/g, "/");
|
|
162
|
+
const swaggerPath = buildSwaggerPath(this.swaggerBasePrefix, this.processName, this.instanceName);
|
|
163
|
+
try {
|
|
164
|
+
this.registerApiEndpoint({
|
|
165
|
+
timestamp: time,
|
|
166
|
+
topic: topic,
|
|
167
|
+
attribute: attribute,
|
|
168
|
+
routeOnly: options?.routeOnly === true,
|
|
169
|
+
registryTopic: options?.registryTopic ??
|
|
170
|
+
"api-endpoints",
|
|
171
|
+
apiHost: this.getPublishedApiHost(),
|
|
172
|
+
apiEndpoint: apiPath,
|
|
173
|
+
apiMethod: "GET",
|
|
174
|
+
apiQueryParams: options?.queryParams ?? [],
|
|
175
|
+
apiDescription: options?.apiDescription,
|
|
176
|
+
serviceApi: options?.serviceApi ?? null,
|
|
177
|
+
attributeType: UnsAttributeType.Api,
|
|
178
|
+
apiSwaggerEndpoint: swaggerPath,
|
|
179
|
+
asset,
|
|
180
|
+
objectType,
|
|
181
|
+
objectId
|
|
182
|
+
});
|
|
183
|
+
const handler = (req, res) => {
|
|
184
|
+
// Query param validation
|
|
185
|
+
if (options?.queryParams) {
|
|
186
|
+
const missingParams = options.queryParams.filter((p) => p.required && req.query[p.name] === undefined).map((p) => p.name);
|
|
187
|
+
if (missingParams.length > 0) {
|
|
188
|
+
return res.status(400).json({ error: `Missing query params: ${missingParams.join(", ")}` });
|
|
189
|
+
}
|
|
190
|
+
// Optional: cast types (basic)
|
|
191
|
+
for (const param of options.queryParams) {
|
|
192
|
+
const value = req.query[param.name];
|
|
193
|
+
if (value !== undefined) {
|
|
194
|
+
switch (param.type) {
|
|
195
|
+
case "number":
|
|
196
|
+
if (isNaN(Number(value))) {
|
|
197
|
+
return res.status(400).json({ error: `Query param ${param.name} must be a number` });
|
|
198
|
+
}
|
|
199
|
+
break;
|
|
200
|
+
case "boolean":
|
|
201
|
+
if (!["true", "false", "1", "0"].includes(String(value))) {
|
|
202
|
+
return res.status(400).json({ error: `Query param ${param.name} must be boolean` });
|
|
203
|
+
}
|
|
204
|
+
break;
|
|
205
|
+
// string: no check
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
this.event.emit("apiGetEvent", { req, res });
|
|
211
|
+
};
|
|
212
|
+
// JWT or JWKS or open
|
|
213
|
+
if (this.options?.jwks?.wellKnownJwksUrl) {
|
|
214
|
+
this.app.router.get(fullPath, async (req, res) => {
|
|
215
|
+
try {
|
|
216
|
+
const token = this.extractBearerToken(req, res);
|
|
217
|
+
if (!token)
|
|
218
|
+
return; // response already sent
|
|
219
|
+
const publicKey = await this.getPublicKeyFromJwks(token);
|
|
220
|
+
const algorithms = this.options.jwks.algorithms || ["RS256"];
|
|
221
|
+
const decoded = jwt.verify(token, publicKey, { algorithms });
|
|
222
|
+
const accessRules = Array.isArray(decoded?.accessRules)
|
|
223
|
+
? decoded.accessRules
|
|
224
|
+
: (typeof decoded?.pathFilter === "string" && decoded.pathFilter.length > 0
|
|
225
|
+
? [decoded.pathFilter]
|
|
226
|
+
: undefined);
|
|
227
|
+
const allowed = Array.isArray(accessRules)
|
|
228
|
+
? accessRules.some((rule) => UnsTopicMatcher.matches(rule, fullPath))
|
|
229
|
+
: false;
|
|
230
|
+
if (!allowed) {
|
|
231
|
+
return res.status(403).json({ error: "Path not allowed by token access rules" });
|
|
232
|
+
}
|
|
233
|
+
handler(req, res);
|
|
234
|
+
}
|
|
235
|
+
catch (err) {
|
|
236
|
+
return res.status(401).json({ error: "Invalid token" });
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
else if (this.options?.jwtSecret) {
|
|
241
|
+
this.app.router.get(fullPath, (req, res) => {
|
|
242
|
+
const authHeader = req.headers["authorization"];
|
|
243
|
+
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
|
244
|
+
return res.status(401).json({ error: "Missing or invalid Authorization header" });
|
|
245
|
+
}
|
|
246
|
+
const token = authHeader.slice(7);
|
|
247
|
+
try {
|
|
248
|
+
const decoded = jwt.verify(token, process.env.JWT_SECRET || this.options.jwtSecret);
|
|
249
|
+
const accessRules = Array.isArray(decoded?.accessRules)
|
|
250
|
+
? decoded.accessRules
|
|
251
|
+
: (typeof decoded?.pathFilter === "string" && decoded.pathFilter.length > 0
|
|
252
|
+
? [decoded.pathFilter]
|
|
253
|
+
: undefined);
|
|
254
|
+
const allowed = Array.isArray(accessRules)
|
|
255
|
+
? accessRules.some((rule) => UnsTopicMatcher.matches(rule, fullPath))
|
|
256
|
+
: false;
|
|
257
|
+
if (!allowed) {
|
|
258
|
+
return res.status(403).json({ error: "Path not allowed by token access rules" });
|
|
259
|
+
}
|
|
260
|
+
handler(req, res);
|
|
261
|
+
}
|
|
262
|
+
catch (err) {
|
|
263
|
+
return res.status(401).json({ error: "Invalid token" });
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
else {
|
|
268
|
+
this.app.router.get(fullPath, handler);
|
|
269
|
+
}
|
|
270
|
+
if (this.app.swaggerSpec) {
|
|
271
|
+
const queryParams = options?.queryParams || [];
|
|
272
|
+
const canonicalParams = queryParams.reduce((acc, param) => {
|
|
273
|
+
if (typeof param.chatCanonical === "string" && param.chatCanonical.trim().length) {
|
|
274
|
+
acc[param.chatCanonical.trim()] = param.name;
|
|
275
|
+
}
|
|
276
|
+
return acc;
|
|
277
|
+
}, {});
|
|
278
|
+
const chatDefaults = {};
|
|
279
|
+
for (const param of queryParams) {
|
|
280
|
+
if (param.defaultValue !== undefined) {
|
|
281
|
+
chatDefaults[param.name] = param.defaultValue;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
const optionDefaults = options?.chatDefaults ?? {};
|
|
285
|
+
for (const [key, value] of Object.entries(optionDefaults)) {
|
|
286
|
+
if (value !== undefined) {
|
|
287
|
+
chatDefaults[key] = value;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
const unsChatMeta = Object.keys(canonicalParams).length || Object.keys(chatDefaults).length
|
|
291
|
+
? {
|
|
292
|
+
canonicalParams,
|
|
293
|
+
defaults: chatDefaults,
|
|
294
|
+
}
|
|
295
|
+
: null;
|
|
296
|
+
this.app.swaggerSpec.paths = this.app.swaggerSpec.paths || {};
|
|
297
|
+
this.app.swaggerSpec.paths[apiPath] = {
|
|
298
|
+
get: {
|
|
299
|
+
summary: options?.apiDescription || "No description",
|
|
300
|
+
tags: options?.tags || [],
|
|
301
|
+
parameters: queryParams.map((p) => ({
|
|
302
|
+
name: p.name,
|
|
303
|
+
in: "query",
|
|
304
|
+
required: !!p.required,
|
|
305
|
+
schema: {
|
|
306
|
+
type: p.type,
|
|
307
|
+
...(p.defaultValue !== undefined ? { default: p.defaultValue } : {}),
|
|
308
|
+
},
|
|
309
|
+
description: p.description,
|
|
310
|
+
...(p.chatCanonical ? { "x-uns-chat-canonical": p.chatCanonical } : {}),
|
|
311
|
+
})),
|
|
312
|
+
...(unsChatMeta ? { "x-uns-chat": unsChatMeta } : {}),
|
|
313
|
+
responses: {
|
|
314
|
+
"200": { description: "OK" },
|
|
315
|
+
"400": { description: "Bad Request" },
|
|
316
|
+
"401": { description: "Unauthorized" },
|
|
317
|
+
"403": { description: "Forbidden" },
|
|
318
|
+
},
|
|
319
|
+
},
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
catch (error) {
|
|
324
|
+
logger.error(`${this.instanceNameWithSuffix} - Error publishing message to route ${fullPath}: ${error.message}`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Register a catch-all API mapping for a topic prefix (e.g., "sij/acroni/#").
|
|
329
|
+
* Does not create individual API attribute nodes; the controller treats this as a fallback.
|
|
330
|
+
*
|
|
331
|
+
* This is intended for use by the uns-api-global microservice, which acts as a
|
|
332
|
+
* catch-all gateway for an entire topic namespace. Regular microservices should
|
|
333
|
+
* NOT call this — use registerGetEndpoint() for individual attribute endpoints instead.
|
|
334
|
+
*/
|
|
335
|
+
async registerCatchAll(topicPrefix, options) {
|
|
336
|
+
while (this.app.server.listening === false) {
|
|
337
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
338
|
+
}
|
|
339
|
+
const finalOptions = options ?? {};
|
|
340
|
+
const topicNormalized = topicPrefix.endsWith("/") ? topicPrefix : `${topicPrefix}`;
|
|
341
|
+
const apiBase = typeof finalOptions?.apiBase === "string" && finalOptions.apiBase.length
|
|
342
|
+
? finalOptions.apiBase
|
|
343
|
+
: this.getPublishedApiHost();
|
|
344
|
+
const apiBasePath = typeof finalOptions?.apiBasePath === "string" && finalOptions.apiBasePath.length
|
|
345
|
+
? finalOptions.apiBasePath
|
|
346
|
+
: "/api";
|
|
347
|
+
const swaggerPath = typeof finalOptions?.swaggerPath === "string" && finalOptions.swaggerPath.length
|
|
348
|
+
? finalOptions.swaggerPath
|
|
349
|
+
: `/${this.processName}/${this.instanceName}/catchall-swagger.json`;
|
|
350
|
+
const normalizedSwaggerPath = swaggerPath.startsWith("/") ? swaggerPath : `/${swaggerPath}`;
|
|
351
|
+
const swaggerDoc = finalOptions.swaggerDoc ||
|
|
352
|
+
{
|
|
353
|
+
openapi: "3.0.0",
|
|
354
|
+
info: {
|
|
355
|
+
title: "Catch-all API",
|
|
356
|
+
version: "1.0.0",
|
|
357
|
+
},
|
|
358
|
+
paths: {
|
|
359
|
+
"/api/{topicPath}": {
|
|
360
|
+
get: {
|
|
361
|
+
summary: finalOptions.apiDescription || "Catch-all handler",
|
|
362
|
+
tags: finalOptions.tags || [],
|
|
363
|
+
parameters: [
|
|
364
|
+
{
|
|
365
|
+
name: "topicPath",
|
|
366
|
+
in: "path",
|
|
367
|
+
required: true,
|
|
368
|
+
schema: { type: "string" },
|
|
369
|
+
description: "Resolved UNS topic path",
|
|
370
|
+
},
|
|
371
|
+
...(finalOptions.queryParams || []).map((p) => ({
|
|
372
|
+
name: p.name,
|
|
373
|
+
in: "query",
|
|
374
|
+
required: !!p.required,
|
|
375
|
+
schema: { type: p.type },
|
|
376
|
+
description: p.description,
|
|
377
|
+
})),
|
|
378
|
+
],
|
|
379
|
+
responses: {
|
|
380
|
+
"200": { description: "OK" },
|
|
381
|
+
"400": { description: "Bad Request" },
|
|
382
|
+
"401": { description: "Unauthorized" },
|
|
383
|
+
"403": { description: "Forbidden" },
|
|
384
|
+
},
|
|
385
|
+
},
|
|
386
|
+
},
|
|
387
|
+
},
|
|
388
|
+
};
|
|
389
|
+
this.app.registerSwaggerDoc(normalizedSwaggerPath, swaggerDoc);
|
|
390
|
+
logger.info(`${this.instanceNameWithSuffix} - Catch-all Swagger available at ${normalizedSwaggerPath} (target ${apiBase.replace(/\/+$/, "")}${normalizedSwaggerPath})`);
|
|
391
|
+
if (!this.catchAllRouteRegistered) {
|
|
392
|
+
this.app.router.use((req, res) => {
|
|
393
|
+
const topicPath = (req.path ?? "").replace(/^\/+/, "");
|
|
394
|
+
req.params = { ...(req.params || {}), topicPath };
|
|
395
|
+
this.event.emit("apiGetEvent", { req, res });
|
|
396
|
+
});
|
|
397
|
+
this.catchAllRouteRegistered = true;
|
|
398
|
+
}
|
|
399
|
+
this.registerApiCatchAll({
|
|
400
|
+
topic: topicNormalized,
|
|
401
|
+
apiBase,
|
|
402
|
+
apiBasePath,
|
|
403
|
+
swaggerPath,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
async post(topic, asset, objectType, objectId, attribute, options) {
|
|
407
|
+
await this.registerMutationEndpoint("POST", topic, asset, objectType, objectId, attribute, options);
|
|
408
|
+
}
|
|
409
|
+
async put(topic, asset, objectType, objectId, attribute, options) {
|
|
410
|
+
await this.registerMutationEndpoint("PUT", topic, asset, objectType, objectId, attribute, options);
|
|
411
|
+
}
|
|
412
|
+
async patch(topic, asset, objectType, objectId, attribute, options) {
|
|
413
|
+
await this.registerMutationEndpoint("PATCH", topic, asset, objectType, objectId, attribute, options);
|
|
414
|
+
}
|
|
415
|
+
async delete(topic, asset, objectType, objectId, attribute, options) {
|
|
416
|
+
await this.registerMutationEndpoint("DELETE", topic, asset, objectType, objectId, attribute, options);
|
|
417
|
+
}
|
|
418
|
+
async registerMutationEndpoint(method, topic, asset, objectType, objectId, attribute, options) {
|
|
419
|
+
while (this.app.server.listening === false) {
|
|
420
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
421
|
+
}
|
|
422
|
+
const time = UnsPacket.formatToISO8601(new Date());
|
|
423
|
+
const fullPath = buildUnsRoutePath(topic, asset, objectType, objectId, attribute);
|
|
424
|
+
const apiPath = `${this.apiBasePrefix}${fullPath}`.replace(/\/{2,}/g, "/");
|
|
425
|
+
const swaggerPath = buildSwaggerPath(this.swaggerBasePrefix, this.processName, this.instanceName);
|
|
426
|
+
const methodKey = method.toLowerCase();
|
|
427
|
+
try {
|
|
428
|
+
this.registerApiEndpoint({
|
|
429
|
+
timestamp: time,
|
|
430
|
+
topic,
|
|
431
|
+
attribute,
|
|
432
|
+
routeOnly: options?.routeOnly === true,
|
|
433
|
+
registryTopic: options?.registryTopic ??
|
|
434
|
+
"api-endpoints",
|
|
435
|
+
apiHost: this.getPublishedApiHost(),
|
|
436
|
+
apiEndpoint: apiPath,
|
|
437
|
+
apiMethod: method,
|
|
438
|
+
apiQueryParams: [],
|
|
439
|
+
apiDescription: options?.apiDescription,
|
|
440
|
+
serviceApi: options?.serviceApi ?? null,
|
|
441
|
+
attributeType: UnsAttributeType.Api,
|
|
442
|
+
apiSwaggerEndpoint: swaggerPath,
|
|
443
|
+
asset,
|
|
444
|
+
objectType,
|
|
445
|
+
objectId,
|
|
446
|
+
});
|
|
447
|
+
const handler = (req, res) => {
|
|
448
|
+
this.event.emit(this.getApiEventName(method), { req, res });
|
|
449
|
+
};
|
|
450
|
+
const routerMethod = this.app.router[methodKey].bind(this.app.router);
|
|
451
|
+
if (this.options?.jwks?.wellKnownJwksUrl) {
|
|
452
|
+
routerMethod(fullPath, async (req, res) => {
|
|
453
|
+
try {
|
|
454
|
+
const token = this.extractBearerToken(req, res);
|
|
455
|
+
if (!token)
|
|
456
|
+
return;
|
|
457
|
+
const publicKey = await this.getPublicKeyFromJwks(token);
|
|
458
|
+
const algorithms = this.options.jwks.algorithms || ["RS256"];
|
|
459
|
+
const decoded = jwt.verify(token, publicKey, { algorithms });
|
|
460
|
+
const accessRules = Array.isArray(decoded?.accessRules)
|
|
461
|
+
? decoded.accessRules
|
|
462
|
+
: (typeof decoded?.pathFilter === "string" && decoded.pathFilter.length > 0
|
|
463
|
+
? [decoded.pathFilter]
|
|
464
|
+
: undefined);
|
|
465
|
+
const allowed = Array.isArray(accessRules)
|
|
466
|
+
? accessRules.some((rule) => UnsTopicMatcher.matches(rule, fullPath))
|
|
467
|
+
: false;
|
|
468
|
+
if (!allowed) {
|
|
469
|
+
return res.status(403).json({ error: "Path not allowed by token access rules" });
|
|
470
|
+
}
|
|
471
|
+
handler(req, res);
|
|
472
|
+
}
|
|
473
|
+
catch (_err) {
|
|
474
|
+
return res.status(401).json({ error: "Invalid token" });
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
else if (this.options?.jwtSecret) {
|
|
479
|
+
routerMethod(fullPath, (req, res) => {
|
|
480
|
+
const authHeader = req.headers["authorization"];
|
|
481
|
+
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
|
482
|
+
return res.status(401).json({ error: "Missing or invalid Authorization header" });
|
|
483
|
+
}
|
|
484
|
+
const token = authHeader.slice(7);
|
|
485
|
+
try {
|
|
486
|
+
const decoded = jwt.verify(token, process.env.JWT_SECRET || this.options.jwtSecret);
|
|
487
|
+
const accessRules = Array.isArray(decoded?.accessRules)
|
|
488
|
+
? decoded.accessRules
|
|
489
|
+
: (typeof decoded?.pathFilter === "string" && decoded.pathFilter.length > 0
|
|
490
|
+
? [decoded.pathFilter]
|
|
491
|
+
: undefined);
|
|
492
|
+
const allowed = Array.isArray(accessRules)
|
|
493
|
+
? accessRules.some((rule) => UnsTopicMatcher.matches(rule, fullPath))
|
|
494
|
+
: false;
|
|
495
|
+
if (!allowed) {
|
|
496
|
+
return res.status(403).json({ error: "Path not allowed by token access rules" });
|
|
497
|
+
}
|
|
498
|
+
handler(req, res);
|
|
499
|
+
}
|
|
500
|
+
catch (_err) {
|
|
501
|
+
return res.status(401).json({ error: "Invalid token" });
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
else {
|
|
506
|
+
routerMethod(fullPath, handler);
|
|
507
|
+
}
|
|
508
|
+
if (this.app.swaggerSpec) {
|
|
509
|
+
const requestBody = options?.requestBody;
|
|
510
|
+
this.app.swaggerSpec.paths = this.app.swaggerSpec.paths || {};
|
|
511
|
+
this.app.swaggerSpec.paths[apiPath] = this.app.swaggerSpec.paths[apiPath] || {};
|
|
512
|
+
this.app.swaggerSpec.paths[apiPath][methodKey] = {
|
|
513
|
+
summary: options?.apiDescription || "No description",
|
|
514
|
+
tags: options?.tags || [],
|
|
515
|
+
...(requestBody
|
|
516
|
+
? {
|
|
517
|
+
requestBody: {
|
|
518
|
+
description: requestBody.description,
|
|
519
|
+
required: requestBody.required ?? true,
|
|
520
|
+
content: {
|
|
521
|
+
"application/json": {
|
|
522
|
+
schema: requestBody.schema ?? { type: "object" },
|
|
523
|
+
},
|
|
524
|
+
},
|
|
525
|
+
},
|
|
526
|
+
}
|
|
527
|
+
: {}),
|
|
528
|
+
responses: {
|
|
529
|
+
"200": { description: "OK" },
|
|
530
|
+
"400": { description: "Bad Request" },
|
|
531
|
+
"401": { description: "Unauthorized" },
|
|
532
|
+
"403": { description: "Forbidden" },
|
|
533
|
+
},
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
catch (error) {
|
|
538
|
+
logger.error(`${this.instanceNameWithSuffix} - Error registering ${method} route ${fullPath}: ${error.message}`);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
registerDataOffer(input) {
|
|
542
|
+
const offerId = typeof input.offerId === "string" ? input.offerId.trim() : "";
|
|
543
|
+
const displayName = typeof input.displayName === "string" ? input.displayName.trim() : "";
|
|
544
|
+
if (!offerId || !displayName) {
|
|
545
|
+
throw new Error("Data catalog offer requires non-empty offerId and displayName.");
|
|
546
|
+
}
|
|
547
|
+
const offerSchemas = (Array.isArray(input.schemas) ? input.schemas : []).map((schema) => this.normalizeDataOfferSchema(schema));
|
|
548
|
+
const offerSchemaIndex = new Map(offerSchemas.map((schema) => [schema.id, schema]));
|
|
549
|
+
const normalizedOperations = (Array.isArray(input.operations) ? input.operations : [])
|
|
550
|
+
.map((operation, index) => this.normalizeDataOfferOperation(operation, offerId, index, offerSchemaIndex))
|
|
551
|
+
.filter((operation) => Boolean(operation));
|
|
552
|
+
if (!normalizedOperations.length) {
|
|
553
|
+
throw new Error(`Data catalog offer ${offerId} requires at least one operation.`);
|
|
554
|
+
}
|
|
555
|
+
const basePaths = Array.from(new Set([
|
|
556
|
+
...(Array.isArray(input.basePaths) ? input.basePaths.map((entry) => normalizePathValue(entry)) : []),
|
|
557
|
+
...normalizedOperations.map((operation) => {
|
|
558
|
+
const segments = operation.path.split("/").filter(Boolean);
|
|
559
|
+
if (segments.length <= 1) {
|
|
560
|
+
return operation.path;
|
|
561
|
+
}
|
|
562
|
+
return `/${segments.slice(0, -1).join("/")}`;
|
|
563
|
+
}),
|
|
564
|
+
].filter((value) => Boolean(value))));
|
|
565
|
+
this.publishDataCatalogOffer({
|
|
566
|
+
offerId,
|
|
567
|
+
displayName,
|
|
568
|
+
description: typeof input.description === "string" ? input.description.trim() || null : null,
|
|
569
|
+
owner: typeof input.owner === "string" ? input.owner.trim() || null : null,
|
|
570
|
+
status: typeof input.status === "string" && input.status.trim() ? input.status.trim() : "available",
|
|
571
|
+
tags: normalizeStringArray(input.tags),
|
|
572
|
+
categories: normalizeStringArray(input.categories),
|
|
573
|
+
microserviceName: typeof input.microserviceName === "string" && input.microserviceName.trim()
|
|
574
|
+
? input.microserviceName.trim()
|
|
575
|
+
: this.processName,
|
|
576
|
+
version: typeof input.version === "string" && input.version.trim() ? input.version.trim() : packageJson.version,
|
|
577
|
+
swaggerPath: normalizePathValue(input.swaggerPath),
|
|
578
|
+
basePaths,
|
|
579
|
+
operations: normalizedOperations,
|
|
580
|
+
schemas: offerSchemas,
|
|
581
|
+
metadata: input.metadata ?? null,
|
|
582
|
+
packageName: packageJson.name,
|
|
583
|
+
processName: this.processName,
|
|
584
|
+
processVersion: packageJson.version,
|
|
585
|
+
instanceName: this.instanceName,
|
|
586
|
+
controllerName: process.env["UNS_CONTROLLER_NAME"] ?? null,
|
|
587
|
+
controllerPublicBase: process.env["UNS_CONTROLLER_PUBLIC_BASE"] ?? process.env["UNS_PUBLIC_BASE"] ?? null,
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
emitStatusMetrics() {
|
|
591
|
+
const uptimeMinutes = Math.round((Date.now() - this.startedAt) / 60000);
|
|
592
|
+
// Process-level status
|
|
593
|
+
this.event.emit("mqttProxyStatus", {
|
|
594
|
+
event: "uptime",
|
|
595
|
+
value: uptimeMinutes,
|
|
596
|
+
uom: PhysicalMeasurements.Minute,
|
|
597
|
+
statusTopic: this.processStatusTopic + "uptime",
|
|
598
|
+
});
|
|
599
|
+
this.event.emit("mqttProxyStatus", {
|
|
600
|
+
event: "alive",
|
|
601
|
+
value: 1,
|
|
602
|
+
uom: DataSizeMeasurements.Bit,
|
|
603
|
+
statusTopic: this.processStatusTopic + "alive",
|
|
604
|
+
});
|
|
605
|
+
// Instance-level status
|
|
606
|
+
this.event.emit("mqttProxyStatus", {
|
|
607
|
+
event: "uptime",
|
|
608
|
+
value: uptimeMinutes,
|
|
609
|
+
uom: PhysicalMeasurements.Minute,
|
|
610
|
+
statusTopic: this.instanceStatusTopic + "uptime",
|
|
611
|
+
});
|
|
612
|
+
this.event.emit("mqttProxyStatus", {
|
|
613
|
+
event: "alive",
|
|
614
|
+
value: 1,
|
|
615
|
+
uom: DataSizeMeasurements.Bit,
|
|
616
|
+
statusTopic: this.instanceStatusTopic + "alive",
|
|
617
|
+
});
|
|
618
|
+
this.emitDataCatalogOffers();
|
|
619
|
+
}
|
|
620
|
+
registerHealthEndpoint() {
|
|
621
|
+
const routePath = "/status";
|
|
622
|
+
this.app.router.get(routePath, (_req, res) => {
|
|
623
|
+
res.json({
|
|
624
|
+
alive: true,
|
|
625
|
+
processName: this.processName,
|
|
626
|
+
instanceName: this.instanceName,
|
|
627
|
+
package: packageJson.name,
|
|
628
|
+
version: packageJson.version,
|
|
629
|
+
startedAt: new Date(this.startedAt).toISOString(),
|
|
630
|
+
uptimeMs: Date.now() - this.startedAt,
|
|
631
|
+
timestamp: new Date().toISOString(),
|
|
632
|
+
});
|
|
633
|
+
});
|
|
634
|
+
if (this.app.swaggerSpec) {
|
|
635
|
+
this.app.swaggerSpec.paths = this.app.swaggerSpec.paths || {};
|
|
636
|
+
const swaggerPath = `${this.apiBasePrefix}${routePath}`.replace(/\/{2,}/g, "/");
|
|
637
|
+
this.app.swaggerSpec.paths[swaggerPath] = {
|
|
638
|
+
get: {
|
|
639
|
+
summary: "Health status",
|
|
640
|
+
responses: {
|
|
641
|
+
"200": { description: "OK" },
|
|
642
|
+
},
|
|
643
|
+
},
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
getApiEventName(method) {
|
|
648
|
+
switch (method) {
|
|
649
|
+
case "GET":
|
|
650
|
+
return "apiGetEvent";
|
|
651
|
+
case "POST":
|
|
652
|
+
return "apiPostEvent";
|
|
653
|
+
case "PUT":
|
|
654
|
+
return "apiPutEvent";
|
|
655
|
+
case "PATCH":
|
|
656
|
+
return "apiPatchEvent";
|
|
657
|
+
case "DELETE":
|
|
658
|
+
return "apiDeleteEvent";
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
extractBearerToken(req, res) {
|
|
662
|
+
const authHeader = req.headers["authorization"];
|
|
663
|
+
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
|
664
|
+
res.status(401).json({ error: "Missing or invalid Authorization header" });
|
|
665
|
+
return undefined;
|
|
666
|
+
}
|
|
667
|
+
return authHeader.slice(7);
|
|
668
|
+
}
|
|
669
|
+
async getPublicKeyFromJwks(token) {
|
|
670
|
+
// Decode header to get kid
|
|
671
|
+
const decoded = jwt.decode(token, { complete: true });
|
|
672
|
+
const kid = decoded?.header?.kid;
|
|
673
|
+
const keys = await this.fetchJwksKeys();
|
|
674
|
+
let jwk = kid ? keys.find((k) => k.kid === kid) : undefined;
|
|
675
|
+
// If no kid match and activeKidUrl configured, try that
|
|
676
|
+
if (!jwk && this.options?.jwks?.activeKidUrl) {
|
|
677
|
+
try {
|
|
678
|
+
const resp = await fetch(this.options.jwks.activeKidUrl);
|
|
679
|
+
if (resp.ok) {
|
|
680
|
+
const activeKid = await resp.text();
|
|
681
|
+
jwk = keys.find((k) => k.kid === activeKid.trim());
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
catch (_) {
|
|
685
|
+
// ignore and fall through
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
// If still not found but only one key, use it
|
|
689
|
+
if (!jwk && keys.length === 1) {
|
|
690
|
+
jwk = keys[0];
|
|
691
|
+
}
|
|
692
|
+
if (!jwk) {
|
|
693
|
+
throw new Error("Signing key not found in JWKS");
|
|
694
|
+
}
|
|
695
|
+
// Prefer x5c certificate if provided
|
|
696
|
+
if (Array.isArray(jwk.x5c) && jwk.x5c.length > 0) {
|
|
697
|
+
return this.certFromX5c(jwk.x5c[0]);
|
|
698
|
+
}
|
|
699
|
+
// Build PEM from JWK (RSA)
|
|
700
|
+
if (jwk.kty === "RSA" && jwk.n && jwk.e) {
|
|
701
|
+
const keyObj = createPublicKey({ key: { kty: "RSA", n: jwk.n, e: jwk.e }, format: "jwk" });
|
|
702
|
+
return keyObj.export({ type: "spki", format: "pem" }).toString();
|
|
703
|
+
}
|
|
704
|
+
throw new Error("Unsupported JWK format");
|
|
705
|
+
}
|
|
706
|
+
async fetchJwksKeys() {
|
|
707
|
+
const ttl = this.options?.jwks?.cacheTtlMs ?? 5 * 60 * 1000; // default 5 minutes
|
|
708
|
+
const now = Date.now();
|
|
709
|
+
if (this.jwksCache && now - this.jwksCache.fetchedAt < ttl) {
|
|
710
|
+
return this.jwksCache.keys;
|
|
711
|
+
}
|
|
712
|
+
const url = this.options.jwks.wellKnownJwksUrl;
|
|
713
|
+
const resp = await fetch(url);
|
|
714
|
+
if (!resp.ok) {
|
|
715
|
+
throw new Error(`Failed to fetch JWKS (${resp.status})`);
|
|
716
|
+
}
|
|
717
|
+
const body = await resp.json();
|
|
718
|
+
const keys = Array.isArray(body?.keys) ? body.keys : [];
|
|
719
|
+
this.jwksCache = { keys, fetchedAt: now };
|
|
720
|
+
return keys;
|
|
721
|
+
}
|
|
722
|
+
certFromX5c(x5cFirst) {
|
|
723
|
+
const pemBody = x5cFirst.match(/.{1,64}/g)?.join("\n") ?? x5cFirst;
|
|
724
|
+
return `-----BEGIN CERTIFICATE-----\n${pemBody}\n-----END CERTIFICATE-----\n`;
|
|
725
|
+
}
|
|
726
|
+
async stop() {
|
|
727
|
+
if (this.statusInterval) {
|
|
728
|
+
clearInterval(this.statusInterval);
|
|
729
|
+
this.statusInterval = null;
|
|
730
|
+
}
|
|
731
|
+
await super.stop();
|
|
732
|
+
}
|
|
733
|
+
publishDataCatalogOffer(offer) {
|
|
734
|
+
const offerId = typeof offer.offerId === "string" ? offer.offerId.trim() : "";
|
|
735
|
+
if (!offerId) {
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
this.dataCatalogOffers.set(offerId, offer);
|
|
739
|
+
this.emitDataCatalogOffers();
|
|
740
|
+
logger.info(`${this.instanceNameWithSuffix} - Registered data catalog offer: ${offerId}`);
|
|
741
|
+
}
|
|
742
|
+
emitDataCatalogOffers() {
|
|
743
|
+
if (this.instanceStatusTopic === "" || this.dataCatalogOffers.size === 0) {
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
this.event.emit("unsProxyProducedDataCatalogOffers", {
|
|
747
|
+
producedDataCatalogOffers: [...this.dataCatalogOffers.values()],
|
|
748
|
+
statusTopic: this.instanceStatusTopic + "data-catalog-offers",
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
normalizeDataOfferOperation(operation, offerId, index, offerSchemaIndex) {
|
|
752
|
+
const method = typeof operation.method === "string" ? operation.method.trim().toUpperCase() : "";
|
|
753
|
+
const path = normalizePathValue(operation.path);
|
|
754
|
+
if (!method || !path) {
|
|
755
|
+
return null;
|
|
756
|
+
}
|
|
757
|
+
return {
|
|
758
|
+
id: typeof operation.id === "string" && operation.id.trim() ? operation.id.trim() : `${offerId}-${method.toLowerCase()}-${index + 1}`,
|
|
759
|
+
method,
|
|
760
|
+
path,
|
|
761
|
+
summary: typeof operation.summary === "string" ? operation.summary.trim() || null : null,
|
|
762
|
+
description: typeof operation.description === "string" ? operation.description.trim() || null : null,
|
|
763
|
+
tags: normalizeStringArray(operation.tags),
|
|
764
|
+
deprecated: operation.deprecated === true,
|
|
765
|
+
parameters: (Array.isArray(operation.parameters) ? operation.parameters : []).map((parameter) => this.normalizeDataOfferParameter(parameter)),
|
|
766
|
+
headers: (Array.isArray(operation.headers) ? operation.headers : []).map((parameter) => this.normalizeDataOfferParameter(parameter)),
|
|
767
|
+
requestBody: operation.requestBody ? this.normalizeDataOfferRequestBody(operation.requestBody, offerSchemaIndex) : null,
|
|
768
|
+
responses: (Array.isArray(operation.responses) ? operation.responses : []).map((response) => this.normalizeDataOfferResponse(response, offerSchemaIndex)),
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
normalizeDataOfferParameter(parameter) {
|
|
772
|
+
return {
|
|
773
|
+
name: typeof parameter.name === "string" ? parameter.name.trim() : "",
|
|
774
|
+
in: typeof parameter.in === "string" ? parameter.in.trim() : "query",
|
|
775
|
+
required: parameter.required === true,
|
|
776
|
+
description: typeof parameter.description === "string" ? parameter.description.trim() || null : null,
|
|
777
|
+
type: typeof parameter.type === "string" && parameter.type.trim() ? parameter.type.trim() : "string",
|
|
778
|
+
format: typeof parameter.format === "string" ? parameter.format.trim() || null : null,
|
|
779
|
+
nullable: parameter.nullable === true,
|
|
780
|
+
example: parameter.example ?? null,
|
|
781
|
+
enumValues: normalizeStringArray(parameter.enumValues),
|
|
782
|
+
schema: parameter.schema ?? null,
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
normalizeDataOfferRequestBody(requestBody, offerSchemaIndex) {
|
|
786
|
+
return {
|
|
787
|
+
required: requestBody.required === true,
|
|
788
|
+
description: typeof requestBody.description === "string" ? requestBody.description.trim() || null : null,
|
|
789
|
+
contentType: typeof requestBody.contentType === "string" ? requestBody.contentType.trim() || null : null,
|
|
790
|
+
schemas: this.resolveOfferSchemas(requestBody.schemas, requestBody.schemaIds, offerSchemaIndex),
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
normalizeDataOfferResponse(response, offerSchemaIndex) {
|
|
794
|
+
return {
|
|
795
|
+
statusCode: typeof response.statusCode === "string" ? response.statusCode.trim() : "",
|
|
796
|
+
description: typeof response.description === "string" ? response.description.trim() || null : null,
|
|
797
|
+
contentType: typeof response.contentType === "string" ? response.contentType.trim() || null : null,
|
|
798
|
+
schemas: this.resolveOfferSchemas(response.schemas, response.schemaIds, offerSchemaIndex),
|
|
799
|
+
examplePayloads: Array.isArray(response.examplePayloads) ? response.examplePayloads : [],
|
|
800
|
+
headers: (Array.isArray(response.headers) ? response.headers : []).map((parameter) => this.normalizeDataOfferParameter(parameter)),
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
normalizeDataOfferSchema(schema) {
|
|
804
|
+
return {
|
|
805
|
+
id: typeof schema.id === "string" ? schema.id.trim() : "",
|
|
806
|
+
title: typeof schema.title === "string" ? schema.title.trim() : "",
|
|
807
|
+
kind: typeof schema.kind === "string" && schema.kind.trim() ? schema.kind.trim() : "schema",
|
|
808
|
+
source: typeof schema.source === "string" && schema.source.trim() ? schema.source.trim() : "registered",
|
|
809
|
+
contentType: typeof schema.contentType === "string" ? schema.contentType.trim() || null : null,
|
|
810
|
+
rootType: typeof schema.rootType === "string" && schema.rootType.trim() ? schema.rootType.trim() : "object",
|
|
811
|
+
nullable: schema.nullable === true,
|
|
812
|
+
description: typeof schema.description === "string" ? schema.description.trim() || null : null,
|
|
813
|
+
fields: (Array.isArray(schema.fields) ? schema.fields : []).map((field) => this.normalizeDataOfferSchemaField(field)),
|
|
814
|
+
examplePayloads: Array.isArray(schema.examplePayloads) ? schema.examplePayloads : [],
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
normalizeDataOfferSchemaField(field) {
|
|
818
|
+
const defaultName = typeof field.name === "string" ? field.name.trim() : "";
|
|
819
|
+
const path = typeof field.path === "string" && field.path.trim() ? field.path.trim() : defaultName;
|
|
820
|
+
return {
|
|
821
|
+
path,
|
|
822
|
+
name: defaultName || (path.split(".").at(-1) ?? path),
|
|
823
|
+
type: typeof field.type === "string" && field.type.trim() ? field.type.trim() : "string",
|
|
824
|
+
format: typeof field.format === "string" ? field.format.trim() || null : null,
|
|
825
|
+
nullable: field.nullable === true,
|
|
826
|
+
required: field.required === true,
|
|
827
|
+
description: typeof field.description === "string" ? field.description.trim() || null : null,
|
|
828
|
+
enumValues: normalizeStringArray(field.enumValues),
|
|
829
|
+
example: field.example ?? null,
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
resolveOfferSchemas(inlineSchemas, schemaIds, offerSchemaIndex) {
|
|
833
|
+
const result = (Array.isArray(inlineSchemas) ? inlineSchemas : []).map((schema) => this.normalizeDataOfferSchema(schema));
|
|
834
|
+
for (const schemaId of Array.isArray(schemaIds) ? schemaIds : []) {
|
|
835
|
+
const normalizedId = typeof schemaId === "string" ? schemaId.trim() : "";
|
|
836
|
+
if (!normalizedId) {
|
|
837
|
+
continue;
|
|
838
|
+
}
|
|
839
|
+
const resolved = offerSchemaIndex.get(normalizedId);
|
|
840
|
+
if (resolved && !result.some((schema) => schema.id === resolved.id)) {
|
|
841
|
+
result.push(resolved);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
return result;
|
|
845
|
+
}
|
|
846
|
+
}
|