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