Fixes to OCPP command payload validation:
[e-mobility-charging-stations-simulator.git] / src / charging-station / ocpp / OCPPRequestService.ts
1 import Ajv, { type JSONSchemaType } from 'ajv';
2 import ajvFormats from 'ajv-formats';
3
4 import OCPPError from '../../exception/OCPPError';
5 import PerformanceStatistics from '../../performance/PerformanceStatistics';
6 import type { EmptyObject } from '../../types/EmptyObject';
7 import type { HandleErrorParams } from '../../types/Error';
8 import type { JsonObject, JsonType } from '../../types/JsonType';
9 import { ErrorType } from '../../types/ocpp/ErrorType';
10 import { MessageType } from '../../types/ocpp/MessageType';
11 import type { OCPPVersion } from '../../types/ocpp/OCPPVersion';
12 import {
13 type ErrorCallback,
14 type IncomingRequestCommand,
15 type OutgoingRequest,
16 RequestCommand,
17 type RequestParams,
18 type ResponseCallback,
19 type ResponseType,
20 } from '../../types/ocpp/Requests';
21 import type { ErrorResponse, Response } from '../../types/ocpp/Responses';
22 import Constants from '../../utils/Constants';
23 import logger from '../../utils/Logger';
24 import Utils from '../../utils/Utils';
25 import type ChargingStation from '../ChargingStation';
26 import type OCPPResponseService from './OCPPResponseService';
27 import { OCPPServiceUtils } from './OCPPServiceUtils';
28
29 const moduleName = 'OCPPRequestService';
30
31 export default abstract class OCPPRequestService {
32 private static instance: OCPPRequestService | null = null;
33 private readonly version: OCPPVersion;
34 private readonly ajv: Ajv;
35
36 private readonly ocppResponseService: OCPPResponseService;
37
38 protected constructor(version: OCPPVersion, ocppResponseService: OCPPResponseService) {
39 this.version = version;
40 this.ajv = new Ajv({
41 multipleOfPrecision: 2,
42 });
43 ajvFormats(this.ajv);
44 this.ocppResponseService = ocppResponseService;
45 this.requestHandler.bind(this);
46 this.sendMessage.bind(this);
47 this.sendResponse.bind(this);
48 this.sendError.bind(this);
49 this.internalSendMessage.bind(this);
50 this.buildMessageToSend.bind(this);
51 this.validateRequestPayload.bind(this);
52 }
53
54 public static getInstance<T extends OCPPRequestService>(
55 this: new (ocppResponseService: OCPPResponseService) => T,
56 ocppResponseService: OCPPResponseService
57 ): T {
58 if (OCPPRequestService.instance === null) {
59 OCPPRequestService.instance = new this(ocppResponseService);
60 }
61 return OCPPRequestService.instance as T;
62 }
63
64 public async sendResponse(
65 chargingStation: ChargingStation,
66 messageId: string,
67 messagePayload: JsonType,
68 commandName: IncomingRequestCommand
69 ): Promise<ResponseType> {
70 try {
71 // Send response message
72 return await this.internalSendMessage(
73 chargingStation,
74 messageId,
75 messagePayload,
76 MessageType.CALL_RESULT_MESSAGE,
77 commandName
78 );
79 } catch (error) {
80 this.handleSendMessageError(chargingStation, commandName, error as Error, {
81 throwError: true,
82 });
83 }
84 }
85
86 public async sendError(
87 chargingStation: ChargingStation,
88 messageId: string,
89 ocppError: OCPPError,
90 commandName: RequestCommand | IncomingRequestCommand
91 ): Promise<ResponseType> {
92 try {
93 // Send error message
94 return await this.internalSendMessage(
95 chargingStation,
96 messageId,
97 ocppError,
98 MessageType.CALL_ERROR_MESSAGE,
99 commandName
100 );
101 } catch (error) {
102 this.handleSendMessageError(chargingStation, commandName, error as Error);
103 }
104 }
105
106 protected async sendMessage(
107 chargingStation: ChargingStation,
108 messageId: string,
109 messagePayload: JsonType,
110 commandName: RequestCommand,
111 params: RequestParams = {
112 skipBufferingOnError: false,
113 triggerMessage: false,
114 }
115 ): Promise<ResponseType> {
116 try {
117 return await this.internalSendMessage(
118 chargingStation,
119 messageId,
120 messagePayload,
121 MessageType.CALL_MESSAGE,
122 commandName,
123 params
124 );
125 } catch (error) {
126 this.handleSendMessageError(chargingStation, commandName, error as Error);
127 }
128 }
129
130 protected validateRequestPayload<T extends JsonType>(
131 chargingStation: ChargingStation,
132 commandName: RequestCommand | IncomingRequestCommand,
133 payload: T
134 ): boolean {
135 if (chargingStation.getPayloadSchemaValidation() === false) {
136 return true;
137 }
138 const schema = this.getRequestPayloadValidationSchema(chargingStation, commandName);
139 if (schema === false) {
140 return true;
141 }
142 const validate = this.ajv.compile(schema);
143 this.convertDateToISOString<T>(payload);
144 if (validate(payload)) {
145 return true;
146 }
147 logger.error(
148 `${chargingStation.logPrefix()} ${moduleName}.validateRequestPayload: Command '${commandName}' request PDU is invalid: %j`,
149 validate.errors
150 );
151 // OCPPError usage here is debatable: it's an error in the OCPP stack but not targeted to sendError().
152 throw new OCPPError(
153 OCPPServiceUtils.ajvErrorsToErrorType(validate.errors),
154 'Request PDU is invalid',
155 commandName,
156 JSON.stringify(validate.errors, null, 2)
157 );
158 }
159
160 private async internalSendMessage(
161 chargingStation: ChargingStation,
162 messageId: string,
163 messagePayload: JsonType | OCPPError,
164 messageType: MessageType,
165 commandName?: RequestCommand | IncomingRequestCommand,
166 params: RequestParams = {
167 skipBufferingOnError: false,
168 triggerMessage: false,
169 }
170 ): Promise<ResponseType> {
171 if (
172 (chargingStation.isInUnknownState() === true &&
173 commandName === RequestCommand.BOOT_NOTIFICATION) ||
174 (chargingStation.getOcppStrictCompliance() === false &&
175 chargingStation.isInUnknownState() === true) ||
176 chargingStation.isInAcceptedState() === true ||
177 (chargingStation.isInPendingState() === true &&
178 (params.triggerMessage === true || messageType === MessageType.CALL_RESULT_MESSAGE))
179 ) {
180 // eslint-disable-next-line @typescript-eslint/no-this-alias
181 const self = this;
182 // Send a message through wsConnection
183 return Utils.promiseWithTimeout(
184 new Promise((resolve, reject) => {
185 const messageToSend = this.buildMessageToSend(
186 chargingStation,
187 messageId,
188 messagePayload,
189 messageType,
190 commandName,
191 responseCallback,
192 errorCallback
193 );
194 if (chargingStation.getEnableStatistics() === true) {
195 chargingStation.performanceStatistics.addRequestStatistic(commandName, messageType);
196 }
197 // Check if wsConnection opened
198 if (chargingStation.isWebSocketConnectionOpened() === true) {
199 // Yes: Send Message
200 const beginId = PerformanceStatistics.beginMeasure(commandName as string);
201 // FIXME: Handle sending error
202 chargingStation.wsConnection.send(messageToSend);
203 PerformanceStatistics.endMeasure(commandName as string, beginId);
204 logger.debug(
205 `${chargingStation.logPrefix()} >> Command '${commandName}' sent ${this.getMessageTypeString(
206 messageType
207 )} payload: ${messageToSend}`
208 );
209 } else if (params.skipBufferingOnError === false) {
210 // Buffer it
211 chargingStation.bufferMessage(messageToSend);
212 const ocppError = new OCPPError(
213 ErrorType.GENERIC_ERROR,
214 `WebSocket closed for buffered message id '${messageId}' with content '${messageToSend}'`,
215 commandName,
216 (messagePayload as JsonObject)?.details ?? {}
217 );
218 if (messageType === MessageType.CALL_MESSAGE) {
219 // Reject it but keep the request in the cache
220 return reject(ocppError);
221 }
222 return errorCallback(ocppError, false);
223 } else {
224 // Reject it
225 return errorCallback(
226 new OCPPError(
227 ErrorType.GENERIC_ERROR,
228 `WebSocket closed for non buffered message id '${messageId}' with content '${messageToSend}'`,
229 commandName,
230 (messagePayload as JsonObject)?.details ?? {}
231 ),
232 false
233 );
234 }
235 // Response?
236 if (messageType !== MessageType.CALL_MESSAGE) {
237 // Yes: send Ok
238 return resolve(messagePayload);
239 }
240
241 /**
242 * Function that will receive the request's response
243 *
244 * @param payload -
245 * @param requestPayload -
246 */
247 function responseCallback(payload: JsonType, requestPayload: JsonType): void {
248 if (chargingStation.getEnableStatistics() === true) {
249 chargingStation.performanceStatistics.addRequestStatistic(
250 commandName,
251 MessageType.CALL_RESULT_MESSAGE
252 );
253 }
254 // Handle the request's response
255 self.ocppResponseService
256 .responseHandler(
257 chargingStation,
258 commandName as RequestCommand,
259 payload,
260 requestPayload
261 )
262 .then(() => {
263 resolve(payload);
264 })
265 .catch((error) => {
266 reject(error);
267 })
268 .finally(() => {
269 chargingStation.requests.delete(messageId);
270 });
271 }
272
273 /**
274 * Function that will receive the request's error response
275 *
276 * @param error -
277 * @param requestStatistic -
278 */
279 function errorCallback(error: OCPPError, requestStatistic = true): void {
280 if (requestStatistic === true && chargingStation.getEnableStatistics() === true) {
281 chargingStation.performanceStatistics.addRequestStatistic(
282 commandName,
283 MessageType.CALL_ERROR_MESSAGE
284 );
285 }
286 logger.error(
287 `${chargingStation.logPrefix()} Error occurred when calling command ${commandName} with message data ${JSON.stringify(
288 messagePayload
289 )}:`,
290 error
291 );
292 chargingStation.requests.delete(messageId);
293 reject(error);
294 }
295 }),
296 Constants.OCPP_WEBSOCKET_TIMEOUT,
297 new OCPPError(
298 ErrorType.GENERIC_ERROR,
299 `Timeout for message id '${messageId}'`,
300 commandName,
301 (messagePayload as JsonObject)?.details ?? {}
302 ),
303 () => {
304 messageType === MessageType.CALL_MESSAGE && chargingStation.requests.delete(messageId);
305 }
306 );
307 }
308 throw new OCPPError(
309 ErrorType.SECURITY_ERROR,
310 `Cannot send command ${commandName} PDU when the charging station is in ${chargingStation.getRegistrationStatus()} state on the central server`,
311 commandName
312 );
313 }
314
315 private buildMessageToSend(
316 chargingStation: ChargingStation,
317 messageId: string,
318 messagePayload: JsonType | OCPPError,
319 messageType: MessageType,
320 commandName?: RequestCommand | IncomingRequestCommand,
321 responseCallback?: ResponseCallback,
322 errorCallback?: ErrorCallback
323 ): string {
324 let messageToSend: string;
325 // Type of message
326 switch (messageType) {
327 // Request
328 case MessageType.CALL_MESSAGE:
329 // Build request
330 chargingStation.requests.set(messageId, [
331 responseCallback,
332 errorCallback,
333 commandName,
334 messagePayload as JsonType,
335 ]);
336 this.validateRequestPayload(chargingStation, commandName, messagePayload as JsonType);
337 messageToSend = JSON.stringify([
338 messageType,
339 messageId,
340 commandName,
341 messagePayload,
342 ] as OutgoingRequest);
343 break;
344 // Response
345 case MessageType.CALL_RESULT_MESSAGE:
346 // Build response
347 // FIXME: Validate response payload
348 messageToSend = JSON.stringify([messageType, messageId, messagePayload] as Response);
349 break;
350 // Error Message
351 case MessageType.CALL_ERROR_MESSAGE:
352 // Build Error Message
353 messageToSend = JSON.stringify([
354 messageType,
355 messageId,
356 (messagePayload as OCPPError)?.code ?? ErrorType.GENERIC_ERROR,
357 (messagePayload as OCPPError)?.message ?? '',
358 (messagePayload as OCPPError)?.details ?? { commandName },
359 ] as ErrorResponse);
360 break;
361 }
362 return messageToSend;
363 }
364
365 private getMessageTypeString(messageType: MessageType): string {
366 switch (messageType) {
367 case MessageType.CALL_MESSAGE:
368 return 'request';
369 case MessageType.CALL_RESULT_MESSAGE:
370 return 'response';
371 case MessageType.CALL_ERROR_MESSAGE:
372 return 'error';
373 }
374 }
375
376 private handleSendMessageError(
377 chargingStation: ChargingStation,
378 commandName: RequestCommand | IncomingRequestCommand,
379 error: Error,
380 params: HandleErrorParams<EmptyObject> = { throwError: false }
381 ): void {
382 logger.error(`${chargingStation.logPrefix()} Request command '${commandName}' error:`, error);
383 if (params?.throwError === true) {
384 throw error;
385 }
386 }
387
388 private convertDateToISOString<T extends JsonType>(obj: T): void {
389 for (const k in obj) {
390 if (obj[k] instanceof Date) {
391 (obj as JsonObject)[k] = (obj[k] as Date).toISOString();
392 } else if (obj[k] !== null && typeof obj[k] === 'object') {
393 this.convertDateToISOString<T>(obj[k] as T);
394 }
395 }
396 }
397
398 // eslint-disable-next-line @typescript-eslint/no-unused-vars
399 public abstract requestHandler<ReqType extends JsonType, ResType extends JsonType>(
400 chargingStation: ChargingStation,
401 commandName: RequestCommand,
402 commandParams?: JsonType,
403 params?: RequestParams
404 ): Promise<ResType>;
405
406 protected abstract getRequestPayloadValidationSchema(
407 chargingStation: ChargingStation,
408 commandName: RequestCommand | IncomingRequestCommand
409 ): JSONSchemaType<JsonObject> | false;
410 }