perf(simulator): compile payload validation JSON schema only once
[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<JsonObject>>;
48 protected abstract jsonSchemas: Map<RequestCommand, JSONSchemaType<JsonObject>>;
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<JsonObject>>();
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 JsonObject>(
106 chargingStation: ChargingStation,
107 commandName: RequestCommand | IncomingRequestCommand,
108 payload: T,
109 ) => boolean;
110 this.validateIncomingRequestResponsePayload = this.validateIncomingRequestResponsePayload.bind(
111 this,
112 ) as <T extends JsonObject>(
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 JsonObject>(
202 chargingStation: ChargingStation,
203 commandName: RequestCommand | IncomingRequestCommand,
204 payload: T,
205 ): boolean {
206 if (chargingStation.getOcppStrictCompliance() === 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 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)!;
222 payload = cloneObject<T>(payload);
223 OCPPServiceUtils.convertDateToISOString<T>(payload);
224 if (validate(payload)) {
225 return true;
226 }
227 logger.error(
228 `${chargingStation.logPrefix()} ${moduleName}.validateRequestPayload: Command '${commandName}' request PDU is invalid: %j`,
229 validate.errors,
230 );
231 // OCPPError usage here is debatable: it's an error in the OCPP stack but not targeted to sendError().
232 throw new OCPPError(
233 OCPPServiceUtils.ajvErrorsToErrorType(validate.errors!),
234 'Request PDU is invalid',
235 commandName,
236 JSON.stringify(validate.errors, undefined, 2),
237 );
238 }
239
240 private validateIncomingRequestResponsePayload<T extends JsonObject>(
241 chargingStation: ChargingStation,
242 commandName: RequestCommand | IncomingRequestCommand,
243 payload: T,
244 ): boolean {
245 if (chargingStation.getOcppStrictCompliance() === false) {
246 return true;
247 }
248 if (
249 this.ocppResponseService.jsonIncomingRequestResponseSchemas.has(
250 commandName as IncomingRequestCommand,
251 ) === false
252 ) {
253 logger.warn(
254 `${chargingStation.logPrefix()} ${moduleName}.validateIncomingRequestResponsePayload: No JSON schema found for command '${commandName}' PDU validation`,
255 );
256 return true;
257 }
258 if (
259 this.ocppResponseService.jsonIncomingRequestResponseValidateFunctions.has(
260 commandName as IncomingRequestCommand,
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 )!;
277 payload = cloneObject<T>(payload);
278 OCPPServiceUtils.convertDateToISOString<T>(payload);
279 if (validate(payload)) {
280 return true;
281 }
282 logger.error(
283 `${chargingStation.logPrefix()} ${moduleName}.validateIncomingRequestResponsePayload: Command '${commandName}' reponse PDU is invalid: %j`,
284 validate.errors,
285 );
286 // OCPPError usage here is debatable: it's an error in the OCPP stack but not targeted to sendError().
287 throw new OCPPError(
288 OCPPServiceUtils.ajvErrorsToErrorType(validate.errors!),
289 'Response PDU is invalid',
290 commandName,
291 JSON.stringify(validate.errors, undefined, 2),
292 );
293 }
294
295 private async internalSendMessage(
296 chargingStation: ChargingStation,
297 messageId: string,
298 messagePayload: JsonType | OCPPError,
299 messageType: MessageType,
300 commandName: RequestCommand | IncomingRequestCommand,
301 params?: RequestParams,
302 ): Promise<ResponseType> {
303 params = {
304 ...defaultRequestParams,
305 ...params,
306 };
307 if (
308 (chargingStation.inUnknownState() === true &&
309 commandName === RequestCommand.BOOT_NOTIFICATION) ||
310 (chargingStation.getOcppStrictCompliance() === false &&
311 chargingStation.inUnknownState() === true) ||
312 chargingStation.inAcceptedState() === true ||
313 (chargingStation.inPendingState() === true &&
314 (params.triggerMessage === true || messageType === MessageType.CALL_RESULT_MESSAGE))
315 ) {
316 // eslint-disable-next-line @typescript-eslint/no-this-alias
317 const self = this;
318 // Send a message through wsConnection
319 return promiseWithTimeout(
320 new Promise<ResponseType>((resolve, reject) => {
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,
331 MessageType.CALL_RESULT_MESSAGE,
332 );
333 }
334 // Handle the request's response
335 self.ocppResponseService
336 .responseHandler(
337 chargingStation,
338 commandName as RequestCommand,
339 payload,
340 requestPayload,
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,
363 MessageType.CALL_ERROR_MESSAGE,
364 );
365 }
366 logger.error(
367 `${chargingStation.logPrefix()} Error occurred at ${OCPPServiceUtils.getMessageTypeString(
368 messageType,
369 )} command ${commandName} with PDU %j:`,
370 messagePayload,
371 error,
372 );
373 chargingStation.requests.delete(messageId);
374 reject(error);
375 };
376
377 if (chargingStation.getEnableStatistics() === true) {
378 chargingStation.performanceStatistics?.addRequestStatistic(commandName, messageType);
379 }
380 const messageToSend = this.buildMessageToSend(
381 chargingStation,
382 messageId,
383 messagePayload,
384 messageType,
385 commandName,
386 responseCallback,
387 errorCallback,
388 );
389 let sendError = false;
390 // Check if wsConnection opened
391 const wsOpened = chargingStation.isWebSocketConnectionOpened() === true;
392 if (wsOpened) {
393 const beginId = PerformanceStatistics.beginMeasure(commandName);
394 try {
395 chargingStation.wsConnection?.send(messageToSend);
396 logger.debug(
397 `${chargingStation.logPrefix()} >> Command '${commandName}' sent ${OCPPServiceUtils.getMessageTypeString(
398 messageType,
399 )} payload: ${messageToSend}`,
400 );
401 } catch (error) {
402 logger.error(
403 `${chargingStation.logPrefix()} >> Command '${commandName}' failed to send ${OCPPServiceUtils.getMessageTypeString(
404 messageType,
405 )} payload: ${messageToSend}:`,
406 error,
407 );
408 sendError = true;
409 }
410 PerformanceStatistics.endMeasure(commandName, beginId);
411 }
412 const wsClosedOrErrored = !wsOpened || sendError === true;
413 if (wsClosedOrErrored && params?.skipBufferingOnError === false) {
414 // Buffer
415 chargingStation.bufferMessage(messageToSend);
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,
422 (messagePayload as JsonObject)?.details ?? Constants.EMPTY_FROZEN_OBJECT,
423 ),
424 );
425 } else if (wsClosedOrErrored) {
426 const ocppError = new OCPPError(
427 ErrorType.GENERIC_ERROR,
428 `WebSocket closed or errored for non buffered message id '${messageId}' with content '${messageToSend}'`,
429 commandName,
430 (messagePayload as JsonObject)?.details ?? Constants.EMPTY_FROZEN_OBJECT,
431 );
432 // Reject response
433 if (messageType !== MessageType.CALL_MESSAGE) {
434 return reject(ocppError);
435 }
436 // Reject and remove request from the cache
437 return errorCallback(ocppError, false);
438 }
439 // Resolve response
440 if (messageType !== MessageType.CALL_MESSAGE) {
441 return resolve(messagePayload);
442 }
443 }),
444 OCPPConstants.OCPP_WEBSOCKET_TIMEOUT,
445 new OCPPError(
446 ErrorType.GENERIC_ERROR,
447 `Timeout for message id '${messageId}'`,
448 commandName,
449 (messagePayload as JsonObject)?.details ?? Constants.EMPTY_FROZEN_OBJECT,
450 ),
451 () => {
452 messageType === MessageType.CALL_MESSAGE && chargingStation.requests.delete(messageId);
453 },
454 );
455 }
456 throw new OCPPError(
457 ErrorType.SECURITY_ERROR,
458 `Cannot send command ${commandName} PDU when the charging station is in ${chargingStation.getRegistrationStatus()} state on the central server`,
459 commandName,
460 );
461 }
462
463 private buildMessageToSend(
464 chargingStation: ChargingStation,
465 messageId: string,
466 messagePayload: JsonType | OCPPError,
467 messageType: MessageType,
468 commandName: RequestCommand | IncomingRequestCommand,
469 responseCallback: ResponseCallback,
470 errorCallback: ErrorCallback,
471 ): string {
472 let messageToSend: string;
473 // Type of message
474 switch (messageType) {
475 // Request
476 case MessageType.CALL_MESSAGE:
477 // Build request
478 this.validateRequestPayload(chargingStation, commandName, messagePayload as JsonObject);
479 chargingStation.requests.set(messageId, [
480 responseCallback,
481 errorCallback,
482 commandName,
483 messagePayload as JsonType,
484 ]);
485 messageToSend = JSON.stringify([
486 messageType,
487 messageId,
488 commandName,
489 messagePayload,
490 ] as OutgoingRequest);
491 break;
492 // Response
493 case MessageType.CALL_RESULT_MESSAGE:
494 // Build response
495 this.validateIncomingRequestResponsePayload(
496 chargingStation,
497 commandName,
498 messagePayload as JsonObject,
499 );
500 messageToSend = JSON.stringify([messageType, messageId, messagePayload] as Response);
501 break;
502 // Error Message
503 case MessageType.CALL_ERROR_MESSAGE:
504 // Build Error Message
505 messageToSend = JSON.stringify([
506 messageType,
507 messageId,
508 (messagePayload as OCPPError)?.code ?? ErrorType.GENERIC_ERROR,
509 (messagePayload as OCPPError)?.message ?? '',
510 (messagePayload as OCPPError)?.details ?? { commandName },
511 ] as ErrorResponse);
512 break;
513 }
514 return messageToSend;
515 }
516
517 // eslint-disable-next-line @typescript-eslint/no-unused-vars
518 public abstract requestHandler<ReqType extends JsonType, ResType extends JsonType>(
519 chargingStation: ChargingStation,
520 commandName: RequestCommand,
521 // FIXME: should be ReqType
522 commandParams?: JsonType,
523 params?: RequestParams,
524 ): Promise<ResType>;
525 }