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