@citrineos/base
Version:
The base module for OCPP v2.0.1 including all interfaces. This module is not intended to be used directly, but rather as a dependency for other modules.
254 lines • 10.9 kB
JavaScript
// SPDX-FileCopyrightText: 2026 Contributors to the CitrineOS Project
//
// SPDX-License-Identifier: Apache-2.0
import { Ajv } from 'ajv';
import addFormats from 'ajv-formats';
import { Logger } from 'tslog';
import { OCPP1_6_CALL_RESULT_SCHEMA_RECORD, OCPP1_6_CALL_SCHEMA_RECORD, OCPP2_0_1_CALL_RESULT_SCHEMA_RECORD, OCPP2_0_1_CALL_SCHEMA_RECORD, OCPP2_1_CALL_RESULT_SCHEMA_RECORD, OCPP2_1_CALL_SCHEMA_RECORD, } from '../schema/MappingSchema.js';
import { OCPP_CallAction, OcppError, OCPPVersion } from '../../ocpp/rpc/message.js';
export class OCPPValidator {
_ajv;
_logger;
constructor(logger, ajv) {
this._ajv = ajv || OCPPValidator.createValidatorAjvInstance();
this._logger = logger
? logger.getSubLogger({ name: this.constructor.name })
: new Logger({ name: this.constructor.name });
}
/**
* Creates an Ajv instance configured for Fastify HTTP schema compilation.
* Enables type coercion since HTTP query/path params arrive as strings,
* and does not include OCPP-specific keywords.
*
* @param ajv - Optional existing Ajv instance to use instead of creating a new one
* @returns Configured Ajv instance for Fastify schema compilation
*/
static createServerAjvInstance(ajv) {
const ajvInstance = ajv ||
new Ajv({
removeAdditional: 'failing',
useDefaults: true,
coerceTypes: true, // HTTP query/path params arrive as strings and need coercion
strict: false,
allErrors: true,
});
OCPPValidator.addFormats(ajvInstance);
return ajvInstance;
}
/**
* Creates an Ajv instance configured for OCPP message validation.
* Does not coerce types since OCPP messages arrive as parsed JSON with correct types.
* Includes OCPP-specific schema keywords and strict number/required validation.
*
* @param ajv - Optional existing Ajv instance to use instead of creating a new one
* @returns Configured Ajv instance for OCPP message validation
*/
static createValidatorAjvInstance(ajv) {
const ajvInstance = ajv ||
new Ajv({
useDefaults: true,
// No coerceTypes: OCPP messages are parsed JSON — types are already correct,
// and coercion could silently corrupt data that should instead be rejected.
strict: false,
strictNumbers: true, // Reject numeric strings where a number is required
validateFormats: true,
allErrors: true,
});
OCPPValidator.addOcppKeywords(ajvInstance);
OCPPValidator.addFormats(ajvInstance);
return ajvInstance;
}
/**
* Adds custom keywords for OCPP schema metadata to an Ajv instance.
* These keywords are used in OCPP JSON schemas but don't affect validation.
*
* @param ajv - The Ajv instance to add keywords to
*/
static addOcppKeywords(ajv) {
// Add custom keywords for OCPP schema metadata
ajv.addKeyword({
keyword: 'comment',
compile: () => () => true,
});
ajv.addKeyword({
keyword: 'javaType',
compile: () => () => true,
});
ajv.addKeyword({
keyword: 'tsEnumNames',
compile: () => () => true,
});
}
/**
* Adds format validation for date-time and URI formats to an Ajv instance.
*
* @param ajv - The Ajv instance to add formats to
*/
static addFormats(ajv) {
addFormats.default(ajv, {
mode: 'fast',
formats: ['date-time', 'uri'],
});
}
/**
* Validates an OCPP Request object against its schema.
*
* @param {CallAction} action - The original CallAction.
* @param {OcppRequest} payload - The OCPP Request object to validate.
* @param {OCPPVersion} protocol - The OCPP protocol version.
* @return {boolean} - Returns true if the OCPP Request object is valid, false otherwise.
*/
validateOCPPRequest(action, payload, protocol) {
let schema;
switch (protocol) {
case OCPPVersion.OCPP1_6:
schema = OCPP1_6_CALL_SCHEMA_RECORD[action];
break;
case OCPPVersion.OCPP2_0_1:
schema = OCPP2_0_1_CALL_SCHEMA_RECORD[action];
break;
case OCPPVersion.OCPP2_1:
schema = OCPP2_1_CALL_SCHEMA_RECORD[action];
break;
default:
this._logger.error('Unknown subprotocol', protocol);
return { isValid: false };
}
if (schema) {
let validate = this._ajv.getSchema(schema['$id']);
if (!validate) {
schema['$id'] = `${protocol}-${schema['$id']}`;
this._logger.debug(`Updated call schema id: ${schema['$id']}`);
this.fixRefs(schema);
validate = this._ajv.compile(schema);
}
const result = validate(payload);
if (!result) {
const validationErrorsDeepCopy = JSON.parse(JSON.stringify(validate.errors));
this._logger.debug('Validate Call failed', validationErrorsDeepCopy);
return { isValid: false, errors: validationErrorsDeepCopy };
}
else {
if (action === OCPP_CallAction.DataTransfer) {
const dataTransferRequest = payload;
const dataTransferPayloadValidate = this._ajv.getSchema(`${protocol}-${dataTransferRequest.vendorId}${dataTransferRequest.messageId ? `-${dataTransferRequest.messageId}` : ''}`);
if (dataTransferPayloadValidate) {
const dataTransferPayloadResult = dataTransferPayloadValidate(JSON.parse(dataTransferRequest.data));
if (!dataTransferPayloadResult) {
const validationErrorsDeepCopy = JSON.parse(JSON.stringify(dataTransferPayloadValidate.errors));
this._logger.debug('Validate DataTransfer payload failed', validationErrorsDeepCopy);
return { isValid: false, errors: validationErrorsDeepCopy };
}
}
}
return { isValid: true };
}
}
else {
this._logger.error('No schema found for action', action, payload);
return { isValid: false };
}
}
/**
* Validates an OCPP Response against its schema.
*
* @param {CallAction} action - The original CallAction.
* @param {OcppResponse} payload - The OCPPResponse object to validate.
* @param {OCPPVersion} protocol - The OCPP protocol version.
* @return {boolean} - Returns true if the OCPPResponse object is valid, false otherwise.
*/
validateOCPPResponse(action, payload, protocol) {
if (payload instanceof OcppError || payload.name === 'OcppError') {
this._logger.debug('OcppError payload, skipping schema validation', payload);
return { isValid: true };
}
let schema;
switch (protocol) {
case OCPPVersion.OCPP1_6:
schema = OCPP1_6_CALL_RESULT_SCHEMA_RECORD[action];
break;
case OCPPVersion.OCPP2_0_1:
schema = OCPP2_0_1_CALL_RESULT_SCHEMA_RECORD[action];
break;
case OCPPVersion.OCPP2_1:
schema = OCPP2_1_CALL_RESULT_SCHEMA_RECORD[action];
break;
default:
this._logger.error('Unknown subprotocol', protocol);
return { isValid: false };
}
if (schema) {
let validate = this._ajv.getSchema(schema['$id']);
if (!validate) {
schema['$id'] = `${protocol}-${schema['$id']}`;
this._logger.debug(`Updated call result schema id: ${schema['$id']}`);
// this.addSchemaDefinitionsRecursively(schema);
validate = this._ajv.compile(schema);
}
const result = validate(payload);
if (!result) {
const validationErrorsDeepCopy = JSON.parse(JSON.stringify(validate.errors));
this._logger.debug('Validate CallResult failed', validationErrorsDeepCopy);
return { isValid: false, errors: validationErrorsDeepCopy };
}
else {
return { isValid: true };
}
}
else {
this._logger.error('No schema found for call result with action', action, payload);
return { isValid: false };
}
}
/**
* Prepares an OCPP Payload for sending by removing any null values, as OCPP does not allow null values in its messages.
*
* @param message OCPP Payload, as an object
* @returns The sanitized OCPP Payload, with null values removed
*/
sanitizeOCPPPayload(message) {
this._logger.debug('Sanitizing OCPP message: ', message);
const sanitizedMessage = this.removeNulls(message);
this._logger.debug('Sanitized OCPP message: ', sanitizedMessage);
return sanitizedMessage;
}
// Intentionally removing NULL values from object for OCPP conformity
removeNulls(obj) {
if (obj === null)
return undefined;
if (typeof obj !== 'object')
return obj;
if (Array.isArray(obj)) {
return obj.filter((item) => item !== null).map((item) => this.removeNulls(item));
}
const result = {};
for (const [key, value] of Object.entries(obj)) {
result[key] = this.removeNulls(value);
}
return result;
}
// Recursively fix $ref in schema to ensure they are compatible with Ajv when compiling
fixRefs(schema) {
if (!schema.properties)
return;
Object.keys(schema.properties).forEach((key) => {
const property = schema.properties[key];
if (property.$ref) {
property.$ref = property.$ref.replace('#/definitions/', '');
}
if (property.items && property.items.$ref) {
property.items.$ref = property.items.$ref.replace('#/definitions/', '');
}
});
if (schema.definitions) {
Object.keys(schema.definitions).forEach((key) => {
const definition = schema.definitions[key];
if (!definition['$id']) {
definition['$id'] = key;
}
this.fixRefs(definition);
});
}
}
}
//# sourceMappingURL=OCPPValidator.js.map