refactor: factor out JSON schema validation function getter
[e-mobility-charging-stations-simulator.git] / src / charging-station / ocpp / OCPPRequestService.ts
1 import Ajv, { type JSONSchemaType, type ValidateFunction } from 'ajv';
2 import ajvFormats from 'ajv-formats';
3
4 import { OCPPConstants } from './OCPPConstants';
5 import type { OCPPResponseService } from './OCPPResponseService';
6 import { OCPPServiceUtils } from './OCPPServiceUtils';
7 import type { ChargingStation } from '../../charging-station';
8 import { OCPPError } from '../../exception';
9 import { PerformanceStatistics } from '../../performance';
10 import {
11 type ErrorCallback,
12 type ErrorResponse,
13 ErrorType,
14 type IncomingRequestCommand,
15 type JsonObject,
16 type JsonType,
17 MessageType,
18 type OCPPVersion,
19 type OutgoingRequest,
20 RequestCommand,
21 type RequestParams,
22 type Response,
23 type ResponseCallback,
24 type ResponseType,
25 } from '../../types';
26 import {
27 Constants,
28 cloneObject,
29 handleSendMessageError,
30 logger,
31 promiseWithTimeout,
32 } from '../../utils';
33
34 const moduleName = 'OCPPRequestService';
35
36 const defaultRequestParams: RequestParams = {
37 skipBufferingOnError: false,
38 triggerMessage: false,
39 throwError: false,
40 };
41
42 export abstract class OCPPRequestService {
43 private static instance: OCPPRequestService | null = null;
44 private readonly version: OCPPVersion;
45 private readonly ajv: Ajv;
46 private readonly ocppResponseService: OCPPResponseService;
47 private readonly jsonValidateFunctions: Map<RequestCommand, ValidateFunction<JsonType>>;
48 protected abstract jsonSchemas: Map<RequestCommand, JSONSchemaType<JsonType>>;
49
50 protected constructor(version: OCPPVersion, ocppResponseService: OCPPResponseService) {
51 this.version = version;
52 this.ajv = new Ajv({
53 keywords: ['javaType'],
54 multipleOfPrecision: 2,
55 });
56 ajvFormats(this.ajv);
57 this.jsonValidateFunctions = new Map<RequestCommand, ValidateFunction<JsonType>>();
58 this.ocppResponseService = ocppResponseService;
59 this.requestHandler = this.requestHandler.bind(this) as <
60 // eslint-disable-next-line @typescript-eslint/no-unused-vars
61 ReqType extends JsonType,
62 ResType extends JsonType,
63 >(
64 chargingStation: ChargingStation,
65 commandName: RequestCommand,
66 commandParams?: JsonType,
67 params?: RequestParams,
68 ) => Promise<ResType>;
69 this.sendMessage = this.sendMessage.bind(this) as (
70 chargingStation: ChargingStation,
71 messageId: string,
72 messagePayload: JsonType,
73 commandName: RequestCommand,
74 params?: RequestParams,
75 ) => Promise<ResponseType>;
76 this.sendResponse = this.sendResponse.bind(this) as (
77 chargingStation: ChargingStation,
78 messageId: string,
79 messagePayload: JsonType,
80 commandName: IncomingRequestCommand,
81 ) => Promise<ResponseType>;
82 this.sendError = this.sendError.bind(this) as (
83 chargingStation: ChargingStation,
84 messageId: string,
85 ocppError: OCPPError,
86 commandName: RequestCommand | IncomingRequestCommand,
87 ) => Promise<ResponseType>;
88 this.internalSendMessage = this.internalSendMessage.bind(this) as (
89 chargingStation: ChargingStation,
90 messageId: string,
91 messagePayload: JsonType | OCPPError,
92 messageType: MessageType,
93 commandName: RequestCommand | IncomingRequestCommand,
94 params?: RequestParams,
95 ) => Promise<ResponseType>;
96 this.buildMessageToSend = this.buildMessageToSend.bind(this) as (
97 chargingStation: ChargingStation,
98 messageId: string,
99 messagePayload: JsonType | OCPPError,
100 messageType: MessageType,
101 commandName: RequestCommand | IncomingRequestCommand,
102 responseCallback: ResponseCallback,
103 errorCallback: ErrorCallback,
104 ) => string;
105 this.validateRequestPayload = this.validateRequestPayload.bind(this) as <T extends JsonType>(
106 chargingStation: ChargingStation,
107 commandName: RequestCommand | IncomingRequestCommand,
108 payload: T,
109 ) => boolean;
110 this.validateIncomingRequestResponsePayload = this.validateIncomingRequestResponsePayload.bind(
111 this,
112 ) as <T extends JsonType>(
113 chargingStation: ChargingStation,
114 commandName: RequestCommand | IncomingRequestCommand,
115 payload: T,
116 ) => boolean;
117 }
118
119 public static getInstance<T extends OCPPRequestService>(
120 this: new (ocppResponseService: OCPPResponseService) => T,
121 ocppResponseService: OCPPResponseService,
122 ): T {
123 if (OCPPRequestService.instance === null) {
124 OCPPRequestService.instance = new this(ocppResponseService);
125 }
126 return OCPPRequestService.instance as T;
127 }
128
129 public async sendResponse(
130 chargingStation: ChargingStation,
131 messageId: string,
132 messagePayload: JsonType,
133 commandName: IncomingRequestCommand,
134 ): Promise<ResponseType> {
135 try {
136 // Send response message
137 return await this.internalSendMessage(
138 chargingStation,
139 messageId,
140 messagePayload,
141 MessageType.CALL_RESULT_MESSAGE,
142 commandName,
143 );
144 } catch (error) {
145 handleSendMessageError(chargingStation, commandName, error as Error, {
146 throwError: true,
147 });
148 return null;
149 }
150 }
151
152 public async sendError(
153 chargingStation: ChargingStation,
154 messageId: string,
155 ocppError: OCPPError,
156 commandName: RequestCommand | IncomingRequestCommand,
157 ): Promise<ResponseType> {
158 try {
159 // Send error message
160 return await this.internalSendMessage(
161 chargingStation,
162 messageId,
163 ocppError,
164 MessageType.CALL_ERROR_MESSAGE,
165 commandName,
166 );
167 } catch (error) {
168 handleSendMessageError(chargingStation, commandName, error as Error);
169 return null;
170 }
171 }
172
173 protected async sendMessage(
174 chargingStation: ChargingStation,
175 messageId: string,
176 messagePayload: JsonType,
177 commandName: RequestCommand,
178 params?: RequestParams,
179 ): Promise<ResponseType> {
180 params = {
181 ...defaultRequestParams,
182 ...params,
183 };
184 try {
185 return await this.internalSendMessage(
186 chargingStation,
187 messageId,
188 messagePayload,
189 MessageType.CALL_MESSAGE,
190 commandName,
191 params,
192 );
193 } catch (error) {
194 handleSendMessageError(chargingStation, commandName, error as Error, {
195 throwError: params.throwError,
196 });
197 return null;
198 }
199 }
200
201 private validateRequestPayload<T extends JsonType>(
202 chargingStation: ChargingStation,
203 commandName: RequestCommand | IncomingRequestCommand,
204 payload: T,
205 ): boolean {
206 if (chargingStation.stationInfo?.ocppStrictCompliance === false) {
207 return true;
208 }
209 if (this.jsonSchemas.has(commandName as RequestCommand) === false) {
210 logger.warn(
211 `${chargingStation.logPrefix()} ${moduleName}.validateRequestPayload: No JSON schema found for command '${commandName}' PDU validation`,
212 );
213 return true;
214 }
215 const validate = this.getJsonRequestValidateFunction<T>(commandName as RequestCommand);
216 payload = cloneObject<T>(payload);
217 OCPPServiceUtils.convertDateToISOString<T>(payload);
218 if (validate(payload)) {
219 return true;
220 }
221 logger.error(
222 `${chargingStation.logPrefix()} ${moduleName}.validateRequestPayload: Command '${commandName}' request PDU is invalid: %j`,
223 validate.errors,
224 );
225 // OCPPError usage here is debatable: it's an error in the OCPP stack but not targeted to sendError().
226 throw new OCPPError(
227 OCPPServiceUtils.ajvErrorsToErrorType(validate.errors),
228 'Request PDU is invalid',
229 commandName,
230 JSON.stringify(validate.errors, undefined, 2),
231 );
232 }
233
234 private getJsonRequestValidateFunction<T extends JsonType>(commandName: RequestCommand) {
235 if (this.jsonValidateFunctions.has(commandName) === false) {
236 this.jsonValidateFunctions.set(
237 commandName,
238 this.ajv.compile<T>(this.jsonSchemas.get(commandName)!).bind(this),
239 );
240 }
241 return this.jsonValidateFunctions.get(commandName)!;
242 }
243
244 private validateIncomingRequestResponsePayload<T extends JsonType>(
245 chargingStation: ChargingStation,
246 commandName: RequestCommand | IncomingRequestCommand,
247 payload: T,
248 ): boolean {
249 if (chargingStation.stationInfo?.ocppStrictCompliance === false) {
250 return true;
251 }
252 if (
253 this.ocppResponseService.jsonIncomingRequestResponseSchemas.has(
254 commandName as IncomingRequestCommand,
255 ) === false
256 ) {
257 logger.warn(
258 `${chargingStation.logPrefix()} ${moduleName}.validateIncomingRequestResponsePayload: No JSON schema found for command '${commandName}' PDU validation`,
259 );
260 return true;
261 }
262 const validate = this.getJsonRequestResponseValidateFunction<T>(
263 commandName as IncomingRequestCommand,
264 );
265 payload = cloneObject<T>(payload);
266 OCPPServiceUtils.convertDateToISOString<T>(payload);
267 if (validate(payload)) {
268 return true;
269 }
270 logger.error(
271 `${chargingStation.logPrefix()} ${moduleName}.validateIncomingRequestResponsePayload: Command '${commandName}' reponse PDU is invalid: %j`,
272 validate.errors,
273 );
274 // OCPPError usage here is debatable: it's an error in the OCPP stack but not targeted to sendError().
275 throw new OCPPError(
276 OCPPServiceUtils.ajvErrorsToErrorType(validate.errors),
277 'Response PDU is invalid',
278 commandName,
279 JSON.stringify(validate.errors, undefined, 2),
280 );
281 }
282
283 private getJsonRequestResponseValidateFunction<T extends JsonType>(
284 commandName: IncomingRequestCommand,
285 ) {
286 if (
287 this.ocppResponseService.jsonIncomingRequestResponseValidateFunctions.has(commandName) ===
288 false
289 ) {
290 this.ocppResponseService.jsonIncomingRequestResponseValidateFunctions.set(
291 commandName,
292 this.ajv
293 .compile<T>(this.ocppResponseService.jsonIncomingRequestResponseSchemas.get(commandName)!)
294 .bind(this),
295 );
296 }
297 return this.ocppResponseService.jsonIncomingRequestResponseValidateFunctions.get(commandName)!;
298 }
299
300 private async internalSendMessage(
301 chargingStation: ChargingStation,
302 messageId: string,
303 messagePayload: JsonType | OCPPError,
304 messageType: MessageType,
305 commandName: RequestCommand | IncomingRequestCommand,
306 params?: RequestParams,
307 ): Promise<ResponseType> {
308 params = {
309 ...defaultRequestParams,
310 ...params,
311 };
312 if (
313 (chargingStation.inUnknownState() === true &&
314 commandName === RequestCommand.BOOT_NOTIFICATION) ||
315 (chargingStation.stationInfo?.ocppStrictCompliance === false &&
316 chargingStation.inUnknownState() === true) ||
317 chargingStation.inAcceptedState() === true ||
318 (chargingStation.inPendingState() === true &&
319 (params.triggerMessage === true || messageType === MessageType.CALL_RESULT_MESSAGE))
320 ) {
321 // eslint-disable-next-line @typescript-eslint/no-this-alias
322 const self = this;
323 // Send a message through wsConnection
324 return promiseWithTimeout(
325 new Promise<ResponseType>((resolve, reject) => {
326 /**
327 * Function that will receive the request's response
328 *
329 * @param payload -
330 * @param requestPayload -
331 */
332 const responseCallback = (payload: JsonType, requestPayload: JsonType): void => {
333 if (chargingStation.stationInfo?.enableStatistics === true) {
334 chargingStation.performanceStatistics?.addRequestStatistic(
335 commandName,
336 MessageType.CALL_RESULT_MESSAGE,
337 );
338 }
339 // Handle the request's response
340 self.ocppResponseService
341 .responseHandler(
342 chargingStation,
343 commandName as RequestCommand,
344 payload,
345 requestPayload,
346 )
347 .then(() => {
348 resolve(payload);
349 })
350 .catch((error) => {
351 reject(error);
352 })
353 .finally(() => {
354 chargingStation.requests.delete(messageId);
355 });
356 };
357
358 /**
359 * Function that will receive the request's error response
360 *
361 * @param error -
362 * @param requestStatistic -
363 */
364 const errorCallback = (error: OCPPError, requestStatistic = true): void => {
365 if (
366 requestStatistic === true &&
367 chargingStation.stationInfo?.enableStatistics === true
368 ) {
369 chargingStation.performanceStatistics?.addRequestStatistic(
370 commandName,
371 MessageType.CALL_ERROR_MESSAGE,
372 );
373 }
374 logger.error(
375 `${chargingStation.logPrefix()} Error occurred at ${OCPPServiceUtils.getMessageTypeString(
376 messageType,
377 )} command ${commandName} with PDU %j:`,
378 messagePayload,
379 error,
380 );
381 chargingStation.requests.delete(messageId);
382 reject(error);
383 };
384
385 if (chargingStation.stationInfo?.enableStatistics === true) {
386 chargingStation.performanceStatistics?.addRequestStatistic(commandName, messageType);
387 }
388 const messageToSend = this.buildMessageToSend(
389 chargingStation,
390 messageId,
391 messagePayload,
392 messageType,
393 commandName,
394 responseCallback,
395 errorCallback,
396 );
397 let sendError = false;
398 // Check if wsConnection opened
399 const wsOpened = chargingStation.isWebSocketConnectionOpened() === true;
400 if (wsOpened) {
401 const beginId = PerformanceStatistics.beginMeasure(commandName);
402 try {
403 chargingStation.wsConnection?.send(messageToSend);
404 logger.debug(
405 `${chargingStation.logPrefix()} >> Command '${commandName}' sent ${OCPPServiceUtils.getMessageTypeString(
406 messageType,
407 )} payload: ${messageToSend}`,
408 );
409 } catch (error) {
410 logger.error(
411 `${chargingStation.logPrefix()} >> Command '${commandName}' failed to send ${OCPPServiceUtils.getMessageTypeString(
412 messageType,
413 )} payload: ${messageToSend}:`,
414 error,
415 );
416 sendError = true;
417 }
418 PerformanceStatistics.endMeasure(commandName, beginId);
419 }
420 const wsClosedOrErrored = !wsOpened || sendError === true;
421 if (wsClosedOrErrored && params?.skipBufferingOnError === false) {
422 // Buffer
423 chargingStation.bufferMessage(messageToSend);
424 // Reject and keep request in the cache
425 return reject(
426 new OCPPError(
427 ErrorType.GENERIC_ERROR,
428 `WebSocket closed or errored for buffered message id '${messageId}' with content '${messageToSend}'`,
429 commandName,
430 (messagePayload as JsonObject)?.details ?? Constants.EMPTY_FROZEN_OBJECT,
431 ),
432 );
433 } else if (wsClosedOrErrored) {
434 const ocppError = new OCPPError(
435 ErrorType.GENERIC_ERROR,
436 `WebSocket closed or errored for non buffered message id '${messageId}' with content '${messageToSend}'`,
437 commandName,
438 (messagePayload as JsonObject)?.details ?? Constants.EMPTY_FROZEN_OBJECT,
439 );
440 // Reject response
441 if (messageType !== MessageType.CALL_MESSAGE) {
442 return reject(ocppError);
443 }
444 // Reject and remove request from the cache
445 return errorCallback(ocppError, false);
446 }
447 // Resolve response
448 if (messageType !== MessageType.CALL_MESSAGE) {
449 return resolve(messagePayload);
450 }
451 }),
452 OCPPConstants.OCPP_WEBSOCKET_TIMEOUT,
453 new OCPPError(
454 ErrorType.GENERIC_ERROR,
455 `Timeout for message id '${messageId}'`,
456 commandName,
457 (messagePayload as JsonObject)?.details ?? Constants.EMPTY_FROZEN_OBJECT,
458 ),
459 () => {
460 messageType === MessageType.CALL_MESSAGE && chargingStation.requests.delete(messageId);
461 },
462 );
463 }
464 throw new OCPPError(
465 ErrorType.SECURITY_ERROR,
466 `Cannot send command ${commandName} PDU when the charging station is in ${chargingStation.getRegistrationStatus()} state on the central server`,
467 commandName,
468 );
469 }
470
471 private buildMessageToSend(
472 chargingStation: ChargingStation,
473 messageId: string,
474 messagePayload: JsonType | OCPPError,
475 messageType: MessageType,
476 commandName: RequestCommand | IncomingRequestCommand,
477 responseCallback: ResponseCallback,
478 errorCallback: ErrorCallback,
479 ): string {
480 let messageToSend: string;
481 // Type of message
482 switch (messageType) {
483 // Request
484 case MessageType.CALL_MESSAGE:
485 // Build request
486 this.validateRequestPayload(chargingStation, commandName, messagePayload as JsonType);
487 chargingStation.requests.set(messageId, [
488 responseCallback,
489 errorCallback,
490 commandName,
491 messagePayload as JsonType,
492 ]);
493 messageToSend = JSON.stringify([
494 messageType,
495 messageId,
496 commandName,
497 messagePayload,
498 ] as OutgoingRequest);
499 break;
500 // Response
501 case MessageType.CALL_RESULT_MESSAGE:
502 // Build response
503 this.validateIncomingRequestResponsePayload(
504 chargingStation,
505 commandName,
506 messagePayload as JsonType,
507 );
508 messageToSend = JSON.stringify([messageType, messageId, messagePayload] as Response);
509 break;
510 // Error Message
511 case MessageType.CALL_ERROR_MESSAGE:
512 // Build Error Message
513 messageToSend = JSON.stringify([
514 messageType,
515 messageId,
516 (messagePayload as OCPPError)?.code ?? ErrorType.GENERIC_ERROR,
517 (messagePayload as OCPPError)?.message ?? '',
518 (messagePayload as OCPPError)?.details ?? { commandName },
519 ] as ErrorResponse);
520 break;
521 }
522 return messageToSend;
523 }
524
525 // eslint-disable-next-line @typescript-eslint/no-unused-vars
526 public abstract requestHandler<ReqType extends JsonType, ResType extends JsonType>(
527 chargingStation: ChargingStation,
528 commandName: RequestCommand,
529 // FIXME: should be ReqType
530 commandParams?: JsonType,
531 params?: RequestParams,
532 ): Promise<ResType>;
533 }