@lucaapp/service-utils 5.4.2 → 5.5.0

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.
@@ -1,3 +1,3 @@
1
1
  export { KafkaClient } from './kafkaClient';
2
2
  export { KafkaTopic } from './events';
3
- export type { KafkaEvent, EventPayloadHandler, KafkaConfiguration, } from './types';
3
+ export type { KafkaEvent, GenericKafkaEvent, EventPayloadHandler, GenericEventPayloadHandler, KafkaConfiguration, CustomTopicConfig, } from './types';
@@ -1,13 +1,14 @@
1
1
  import { Consumer } from 'kafkajs';
2
2
  import { Logger } from 'pino';
3
3
  import { ServiceIdentity } from '../serviceIdentity';
4
- import type { EventPayloadHandler, KafkaConfiguration, KafkaEvent } from './types';
4
+ import type { EventPayloadHandler, GenericEventPayloadHandler, KafkaConfiguration, KafkaEvent, GenericKafkaEvent, CustomTopicConfig } from './types';
5
5
  import { KafkaTopic } from './events';
6
6
  declare class KafkaClient {
7
7
  private readonly environment;
8
8
  private readonly kafkaClient;
9
9
  private readonly logger;
10
10
  private readonly topicSecrets;
11
+ private readonly customTopics;
11
12
  private readonly admin;
12
13
  private readonly producer;
13
14
  private readonly consumers;
@@ -25,6 +26,26 @@ declare class KafkaClient {
25
26
  private ensureTopics;
26
27
  consume: <T extends KafkaTopic>(kafkaTopic: T, handler: EventPayloadHandler<T>, fromBeginning?: boolean) => Promise<Consumer>;
27
28
  produce: <T extends KafkaTopic>(kafkaTopic: T, key: string, value: KafkaEvent<T>) => Promise<void>;
29
+ /**
30
+ * Register a custom topic (not in KafkaTopic enum)
31
+ * This allows services to define their own topics without modifying service-utils
32
+ */
33
+ registerCustomTopic: (config: CustomTopicConfig) => void;
34
+ /**
35
+ * Get full topic name for custom topic
36
+ */
37
+ private getCustomTopic;
38
+ /**
39
+ * Produce message to custom topic
40
+ */
41
+ produceCustom: <T = unknown>(topicName: string, key: string, value: GenericKafkaEvent<T>) => Promise<void>;
42
+ /**
43
+ * Consume messages from custom topic
44
+ */
45
+ consumeCustom: <T = unknown>(topicName: string, handler: GenericEventPayloadHandler<T>, fromBeginning?: boolean) => Promise<Consumer>;
46
+ private encryptCustomValue;
47
+ private decryptCustomValue;
48
+ private parseCustomValue;
28
49
  shutdown: () => Promise<void>;
29
50
  }
30
51
  export { KafkaClient };
@@ -245,6 +245,132 @@ class KafkaClient {
245
245
  throw (0, utils_1.logAndGetError)(this.logger, `Could not produce message for topic=${topic}`, error);
246
246
  }
247
247
  };
248
+ /**
249
+ * Register a custom topic (not in KafkaTopic enum)
250
+ * This allows services to define their own topics without modifying service-utils
251
+ */
252
+ this.registerCustomTopic = (config) => {
253
+ this.customTopics.set(config.topic, config);
254
+ this.logger.info({ topic: config.topic, issuer: config.issuer }, 'Registered custom Kafka topic');
255
+ };
256
+ /**
257
+ * Get full topic name for custom topic
258
+ */
259
+ this.getCustomTopic = async (topicName) => {
260
+ const config = this.customTopics.get(topicName);
261
+ if (!config) {
262
+ throw (0, utils_1.logAndGetError)(this.logger, `Custom topic ${topicName} not registered. Call registerCustomTopic() first.`);
263
+ }
264
+ const topic = `${this.environment}_${config.issuer}_${topicName}`;
265
+ await this.ensureTopics(topic);
266
+ return topic;
267
+ };
268
+ /**
269
+ * Produce message to custom topic
270
+ */
271
+ this.produceCustom = async (topicName, key, value) => {
272
+ const topic = await this.getCustomTopic(topicName);
273
+ const serializedValue = JSON.stringify(value);
274
+ // For custom topics, encryption is optional (skip if no secret provided)
275
+ const config = this.customTopics.get(topicName);
276
+ const encryptedValue = config.secret && this.encryptionEnabled
277
+ ? await this.encryptCustomValue(config.secret, serializedValue)
278
+ : serializedValue;
279
+ const signature = await this.generateSignature(serializedValue);
280
+ try {
281
+ const producerRecord = {
282
+ topic,
283
+ messages: [
284
+ {
285
+ key,
286
+ value: encryptedValue,
287
+ headers: { signature },
288
+ },
289
+ ],
290
+ };
291
+ await this.producer.send(producerRecord);
292
+ this.logger.debug(producerRecord, 'Custom topic record sent');
293
+ messageProducedSizeCounter
294
+ .labels({ topic })
295
+ .observe(Buffer.byteLength(encryptedValue));
296
+ }
297
+ catch (error) {
298
+ messageProduceError.labels({ topic }).inc();
299
+ throw (0, utils_1.logAndGetError)(this.logger, `Could not produce message for custom topic=${topicName}`, error);
300
+ }
301
+ };
302
+ /**
303
+ * Consume messages from custom topic
304
+ */
305
+ this.consumeCustom = async (topicName, handler, fromBeginning = false) => {
306
+ const topic = await this.getCustomTopic(topicName);
307
+ const groupId = `${this.environment.valueOf()}_${topicName}_${this.serviceIdentity.identityName}`;
308
+ try {
309
+ const consumer = this.kafkaClient.consumer({
310
+ groupId,
311
+ sessionTimeout: 20000,
312
+ heartbeatInterval: 3000,
313
+ });
314
+ this.consumers.push(consumer);
315
+ await consumer.connect();
316
+ await consumer.subscribe({ topic, fromBeginning });
317
+ await consumer.run({
318
+ autoCommit: true,
319
+ eachMessage: async ({ message }) => {
320
+ try {
321
+ messageConsumedCounter.labels({ topic: topicName, groupId }).inc();
322
+ // Decrypt if secret was provided
323
+ const config = this.customTopics.get(topicName);
324
+ const decryptedValue = config.secret && this.encryptionEnabled
325
+ ? await this.decryptCustomValue(config.secret, message.value)
326
+ : message.value;
327
+ const value = this.parseCustomValue(decryptedValue);
328
+ this.logger.debug({
329
+ key: message.key?.toString(),
330
+ value,
331
+ timestamp: message.timestamp,
332
+ }, 'Custom topic record received');
333
+ try {
334
+ await handler({ ...message, value });
335
+ }
336
+ catch (error) {
337
+ throw (0, utils_1.logAndGetError)(this.logger, `Could not consume message for custom topic=${topicName}`, error);
338
+ }
339
+ messageAcknowledgedCounter
340
+ .labels({ topic: topicName, groupId })
341
+ .inc();
342
+ }
343
+ catch (error) {
344
+ messageConsumedErrorCounter
345
+ .labels({ topic: topicName, groupId })
346
+ .inc();
347
+ throw error;
348
+ }
349
+ },
350
+ });
351
+ return consumer;
352
+ }
353
+ catch (error) {
354
+ throw (0, utils_1.logAndGetError)(this.logger, `Could not create consumer for custom topic=${topicName}`, error);
355
+ }
356
+ };
357
+ this.encryptCustomValue = async (secret, value) => {
358
+ const jwe = await new jose.CompactEncrypt(new util_1.TextEncoder().encode(value));
359
+ jwe.setProtectedHeader({ alg: 'A256GCMKW', enc: 'A256GCM' });
360
+ return jwe.encrypt(Buffer.from(secret));
361
+ };
362
+ this.decryptCustomValue = async (secret, jwe) => {
363
+ if (!jwe)
364
+ return null;
365
+ const { plaintext } = await jose.compactDecrypt(jwe, Buffer.from(secret));
366
+ return Buffer.from(plaintext);
367
+ };
368
+ this.parseCustomValue = (value) => {
369
+ if (!value) {
370
+ throw (0, utils_1.logAndGetError)(this.logger, 'Unexpected event format `null`');
371
+ }
372
+ return JSON.parse(value.toString());
373
+ };
248
374
  this.shutdown = async () => {
249
375
  try {
250
376
  for (const consumer of this.consumers) {
@@ -269,6 +395,7 @@ class KafkaClient {
269
395
  });
270
396
  this.serviceIdentity = serviceIdentity;
271
397
  this.topicSecrets = topicSecrets;
398
+ this.customTopics = new Map();
272
399
  try {
273
400
  this.kafkaClient = new kafkajs_1.Kafka({
274
401
  brokers: [kafkaConfig.broker],
@@ -1,11 +1,16 @@
1
1
  import { KafkaTopic, MessageFormats } from './events';
2
- import { Environment } from '../serviceIdentity';
2
+ import { Environment, Service } from '../serviceIdentity';
3
3
  import { KafkaMessage } from 'kafkajs';
4
4
  type KafkaEvent<T extends KafkaTopic> = {
5
5
  id: string;
6
6
  type: 'create' | 'update' | 'soft-destroy' | 'destroy';
7
7
  entity: MessageFormats[T];
8
8
  };
9
+ type GenericKafkaEvent<T = unknown> = {
10
+ id: string;
11
+ type: 'create' | 'update' | 'soft-destroy' | 'destroy';
12
+ entity: T;
13
+ };
9
14
  type KafkaConfiguration = {
10
15
  environment: Environment;
11
16
  broker: string;
@@ -18,4 +23,12 @@ type KafkaConfiguration = {
18
23
  type EventPayloadHandler<T extends KafkaTopic> = (message: Omit<KafkaMessage, 'value'> & {
19
24
  value: KafkaEvent<T>;
20
25
  }) => Promise<void>;
21
- export type { KafkaEvent, KafkaConfiguration, EventPayloadHandler };
26
+ type GenericEventPayloadHandler<T = unknown> = (message: Omit<KafkaMessage, 'value'> & {
27
+ value: GenericKafkaEvent<T>;
28
+ }) => Promise<void>;
29
+ type CustomTopicConfig = {
30
+ topic: string;
31
+ issuer: Service;
32
+ secret?: string;
33
+ };
34
+ export type { KafkaEvent, GenericKafkaEvent, KafkaConfiguration, EventPayloadHandler, GenericEventPayloadHandler, CustomTopicConfig, };
@@ -1,3 +1,4 @@
1
- export { ServiceIdentity } from './serviceIdentity';
1
+ export { ServiceIdentity, ServiceIdentityError, ServiceIdentityErrorType, } from './serviceIdentity';
2
+ export type { CallServiceOptions } from './serviceIdentity';
2
3
  export { Environment } from './environment';
3
4
  export { Service } from './service';
@@ -1,8 +1,10 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.Service = exports.Environment = exports.ServiceIdentity = void 0;
3
+ exports.Service = exports.Environment = exports.ServiceIdentityErrorType = exports.ServiceIdentityError = exports.ServiceIdentity = void 0;
4
4
  var serviceIdentity_1 = require("./serviceIdentity");
5
5
  Object.defineProperty(exports, "ServiceIdentity", { enumerable: true, get: function () { return serviceIdentity_1.ServiceIdentity; } });
6
+ Object.defineProperty(exports, "ServiceIdentityError", { enumerable: true, get: function () { return serviceIdentity_1.ServiceIdentityError; } });
7
+ Object.defineProperty(exports, "ServiceIdentityErrorType", { enumerable: true, get: function () { return serviceIdentity_1.ServiceIdentityErrorType; } });
6
8
  var environment_1 = require("./environment");
7
9
  Object.defineProperty(exports, "Environment", { enumerable: true, get: function () { return environment_1.Environment; } });
8
10
  var service_1 = require("./service");
@@ -18,6 +18,18 @@ export declare class ServiceIdentityError extends Error {
18
18
  meta?: object | undefined;
19
19
  constructor(type: ServiceIdentityErrorType, message?: string, meta?: object | undefined);
20
20
  }
21
+ type HttpMethodWithoutBody = 'GET' | 'HEAD' | 'DELETE' | 'OPTIONS';
22
+ export type CallServiceOptions<M extends HttpMethod = HttpMethod> = M extends HttpMethodWithoutBody ? {
23
+ data?: never;
24
+ responseType?: ResponseType;
25
+ customHeaders?: Record<string, string>;
26
+ params?: Record<string, string | number | Array<string | number> | undefined>;
27
+ } : {
28
+ data?: unknown;
29
+ responseType?: ResponseType;
30
+ customHeaders?: Record<string, string>;
31
+ params?: Record<string, string | number | Array<string | number> | undefined>;
32
+ };
21
33
  declare class ServiceIdentity {
22
34
  readonly identityName: string;
23
35
  readonly identityKid: string;
@@ -31,8 +43,12 @@ declare class ServiceIdentity {
31
43
  private getJwtVerifyOptions;
32
44
  getIdentityPublicKey: () => Promise<jose.KeyLike>;
33
45
  getIdentityPrivateKey: () => Promise<jose.KeyLike>;
34
- callService: <T>(service: Service, method: HttpMethod, url: string, data?: unknown, responseType?: ResponseType, customHeaders?: Record<string, string>) => Promise<T>;
35
- signJWT: (service: string, method: HttpMethod, url: string, data?: unknown) => Promise<string>;
46
+ callServiceV2<T>(service: Service, method: HttpMethodWithoutBody, url: string, options?: CallServiceOptions<HttpMethodWithoutBody>): Promise<T>;
47
+ callServiceV2<T>(service: Service, method: HttpMethod, url: string, options?: CallServiceOptions<HttpMethod>): Promise<T>;
48
+ callService<T>(service: Service, method: HttpMethodWithoutBody, url: string, options?: CallServiceOptions<HttpMethodWithoutBody>): Promise<T>;
49
+ callService<T>(service: Service, method: HttpMethod, url: string, options?: CallServiceOptions<HttpMethod>): Promise<T>;
50
+ callService<T>(service: Service, method: HttpMethod, url: string, data?: unknown, responseType?: ResponseType, customHeaders?: Record<string, string>, params?: Record<string, string | number | Array<string | number> | undefined>): Promise<T>;
51
+ signJWT: (service: string, method: HttpMethod, url: string, data?: unknown, params?: Record<string, string | number | Array<string | number> | undefined>) => Promise<string>;
36
52
  verifyJWT: (request: Request, service: string) => Promise<{
37
53
  payload: jose.JWTPayload;
38
54
  }>;
@@ -46,9 +62,12 @@ declare class ServiceIdentity {
46
62
  'x-identity': string;
47
63
  }>, z.ZodObject<{
48
64
  payload: z.ZodAny;
65
+ params: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnion<[z.ZodString, z.ZodNumber, z.ZodArray<z.ZodUnion<[z.ZodString, z.ZodNumber]>, "many">, z.ZodUndefined]>>>;
49
66
  }, "strip", z.ZodTypeAny, {
67
+ params?: Record<string, string | number | (string | number)[] | undefined> | undefined;
50
68
  payload?: any;
51
69
  }, {
70
+ params?: Record<string, string | number | (string | number)[] | undefined> | undefined;
52
71
  payload?: any;
53
72
  }>>;
54
73
  identityJWKSRoute: RequestHandler;
@@ -86,27 +86,12 @@ class ServiceIdentity {
86
86
  }
87
87
  return this.keyCache.private;
88
88
  };
89
- this.callService = async (service, method, url, data, responseType, customHeaders) => {
90
- const jwt = await this.signJWT(service, method, url, data);
91
- const request = {
92
- headers: {
93
- [JWT_HEADER_NAME]: jwt,
94
- ...(0, requestTracer_1.getRequestIdHeader)(),
95
- ...customHeaders,
96
- },
97
- baseURL: `http://${service}:8080/`,
98
- url,
99
- method,
100
- responseType,
101
- };
102
- const response = await this.axiosClient.request(request);
103
- return response.data;
104
- };
105
- this.signJWT = async (service, method, url, data) => {
89
+ this.signJWT = async (service, method, url, data, params) => {
106
90
  return await new jose.SignJWT({
107
91
  method,
108
92
  url,
109
93
  data,
94
+ params,
110
95
  })
111
96
  .setProtectedHeader({
112
97
  alg: JWT_ALGORITHM,
@@ -141,6 +126,9 @@ class ServiceIdentity {
141
126
  if (request.method !== payload.method)
142
127
  throw (0, boom_1.forbidden)(`${request.method} !== ${payload.method}`);
143
128
  request.body = payload.data;
129
+ if (payload.params) {
130
+ request.query = payload.params;
131
+ }
144
132
  next();
145
133
  };
146
134
  this.requireServiceIdentityV3 = (...services) => (0, api_1.createMiddleware)({
@@ -148,7 +136,17 @@ class ServiceIdentity {
148
136
  headers: zod_1.z.object({
149
137
  'x-identity': zod_1.z.string().refine(value => validator_1.default.isJWT(value)),
150
138
  }),
151
- context: zod_1.z.object({ payload: zod_1.z.any() }),
139
+ context: zod_1.z.object({
140
+ payload: zod_1.z.any(),
141
+ params: zod_1.z
142
+ .record(zod_1.z.string(), zod_1.z.union([
143
+ zod_1.z.string(),
144
+ zod_1.z.number(),
145
+ zod_1.z.array(zod_1.z.union([zod_1.z.string(), zod_1.z.number()])),
146
+ zod_1.z.undefined(),
147
+ ]))
148
+ .optional(),
149
+ }),
152
150
  },
153
151
  responses: [],
154
152
  errors: {
@@ -179,7 +177,10 @@ class ServiceIdentity {
179
177
  if (method !== payload.method) {
180
178
  throw new ServiceIdentityError(ServiceIdentityErrorType.METHOD_MISMATCH, `${method} !== ${payload.method}`);
181
179
  }
182
- return next({ payload: payload.data });
180
+ return next({
181
+ payload: payload.data,
182
+ params: payload.params,
183
+ });
183
184
  });
184
185
  this.identityJWKSRoute = async (_, response) => {
185
186
  response.send(await this.getIdentityJWKS());
@@ -210,5 +211,48 @@ class ServiceIdentity {
210
211
  });
211
212
  }
212
213
  }
214
+ // Implementation
215
+ async callServiceV2(service, method, url, options) {
216
+ const { data, responseType, customHeaders, params } = options || {};
217
+ // HTTP methods that should not have a request body
218
+ const methodsWithoutBody = ['GET', 'HEAD', 'DELETE', 'OPTIONS'];
219
+ const shouldIncludeBody = !methodsWithoutBody.includes(method);
220
+ const jwt = await this.signJWT(service, method, url, data, params);
221
+ const request = {
222
+ headers: {
223
+ [JWT_HEADER_NAME]: jwt,
224
+ ...(0, requestTracer_1.getRequestIdHeader)(),
225
+ ...customHeaders,
226
+ },
227
+ baseURL: `http://${service}:8080/`,
228
+ url,
229
+ method,
230
+ responseType,
231
+ ...(params ? { params } : {}),
232
+ ...(data && shouldIncludeBody ? { data } : {}),
233
+ };
234
+ const response = await this.axiosClient.request(request);
235
+ return response.data;
236
+ }
237
+ // Implementation
238
+ async callService(service, method, url, dataOrOptions, responseType, customHeaders, params) {
239
+ // Check if using new options object pattern
240
+ if (dataOrOptions &&
241
+ typeof dataOrOptions === 'object' &&
242
+ ('data' in dataOrOptions ||
243
+ 'responseType' in dataOrOptions ||
244
+ 'customHeaders' in dataOrOptions ||
245
+ 'params' in dataOrOptions)) {
246
+ // Delegate to V2 with options object
247
+ return this.callServiceV2(service, method, url, dataOrOptions);
248
+ }
249
+ // Old positional parameters pattern - delegate to V2
250
+ return this.callServiceV2(service, method, url, {
251
+ data: dataOrOptions,
252
+ responseType,
253
+ customHeaders,
254
+ params,
255
+ });
256
+ }
213
257
  }
214
258
  exports.ServiceIdentity = ServiceIdentity;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lucaapp/service-utils",
3
- "version": "5.4.2",
3
+ "version": "5.5.0",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "files": [
@@ -81,5 +81,8 @@
81
81
  "vite-tsconfig-paths": "4.3.2",
82
82
  "vitest": "3.2.4"
83
83
  },
84
- "resolutions": {}
84
+ "resolutions": {
85
+ "tar": "^7.5.7",
86
+ "lodash": "^4.17.23"
87
+ }
85
88
  }