UNPKG

@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
// 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