@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.
342 lines • 15 kB
JavaScript
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
//
// SPDX-License-Identifier: Apache-2.0
import 'reflect-metadata';
import { Logger } from 'tslog';
import { METADATA_DATA_ENDPOINTS, METADATA_MESSAGE_ENDPOINTS } from '../api/metadata.js';
import { HttpMethod } from '../api/HttpMethods.js';
import { ConfigStoreFactory } from '../../config/ConfigStore.js';
import { MessageConfirmationSchema } from '../../ocpp/persistence/querySchema.js';
import { Namespace, OCPP1_6_Namespace } from '../../ocpp/persistence/namespace.js';
import { OCPPVersion } from '../../ocpp/rpc/message.js';
import { systemConfigSchema } from '../../config/types.js';
import { OCPP2_Namespace } from '../../ocpp/persistence/index.js';
import { IMessageQuerystringSchema } from '../api/MessageQuerystring.js';
import { AuthorizationSecurity } from '../api/AuthorizationSecurity.js';
import { z } from 'zod';
/**
* Abstract module api class implementation.
*/
export class AbstractModuleApi {
_server;
_module;
_logger;
_ocppVersion;
constructor(module, server, ocppVersion, logger) {
this._module = module;
this._server = server;
this._ocppVersion = ocppVersion;
this._logger = logger
? logger.getSubLogger({ name: this.constructor.name })
: new Logger({ name: this.constructor.name });
this._init(this._module);
}
/**
* Initializes the API for the given module.
*
* @param {T} module - The module to initialize the API for.
*/
_init(module) {
Reflect.getMetadata(METADATA_MESSAGE_ENDPOINTS, this.constructor)?.forEach((expose) => {
this._addMessageRoute.call(this, expose.action, expose.method, typeof expose.bodySchema === 'function' ? expose.bodySchema(this) : expose.bodySchema, expose.optionalQuerystrings);
});
const dataEndpointDefinitions = Reflect.getMetadata(METADATA_DATA_ENDPOINTS, this.constructor);
dataEndpointDefinitions?.forEach((expose) => {
this._addDataRoute.call(this, expose.namespace, expose.method, expose.httpMethod, expose.querySchema, expose.paramSchema, expose.headerSchema, expose.bodySchema, expose.responseSchema, expose.tags, expose.description, expose.security);
});
if (dataEndpointDefinitions && dataEndpointDefinitions.length > 0) {
this.registerSystemConfigRoutes(module);
}
}
/**
* Add a message route to the server.
*
* @param {CallAction} action - The action to be called.
* @param {Function} method - The method to be executed.
* @param {object} bodySchema - The schema for the route.
* @param {Record<string, any>} optionalQuerystrings - Optional querystrings for the route.
* @return {void}
*/
_addMessageRoute(action, method, bodySchema, optionalQuerystrings) {
if (!bodySchema) {
this._logger.debug(`Skipping message route for ${action} — schema not available for ${this._ocppVersion}`);
return;
}
this._logger.debug(`Adding message route for ${action}`, this._toMessagePath(action));
/**
* Executes the handler function for the given request.
*
* @param {FastifyRequest<{ Body: OcppRequest, Querystring: IMessageQuerystring }>} request - The request object containing the body and querystring.
* @return {Promise<IMessageConfirmation>} The promise that resolves to the message confirmation.
*/
const _handler = async (request) => {
const { identifier, tenantId, callbackUrl, ...extraQueries } = request.query;
const identifiers = Array.isArray(identifier) ? identifier : [identifier];
return method.call(this, identifiers, request.body, callbackUrl, tenantId, Object.keys(extraQueries).length > 0 ? extraQueries : undefined);
};
const mergedQuerySchema = {
...IMessageQuerystringSchema,
properties: {
...IMessageQuerystringSchema.properties,
...(optionalQuerystrings || {}),
},
};
const _opts = {
method: HttpMethod.Post,
url: this._toMessagePath(action),
handler: _handler,
schema: {
body: bodySchema,
querystring: mergedQuerySchema,
response: {
200: {
$id: 'MessageConfirmationSchemaArray',
type: 'array',
items: MessageConfirmationSchema,
},
},
},
};
if (this._module.config.util.swagger?.exposeMessage) {
this._server.register(async (fastifyInstance) => {
this.registerSchemaForOpts(fastifyInstance, _opts);
fastifyInstance.route(_opts);
});
}
else {
this._server.route(_opts);
}
}
/**
* Add a message route to the server.
*
* @param {OCPP2_Namespace | OCPP1_6_Namespace | Namespace} namespace - The entity type.
* @param {Function} method - The method to be executed.
* @param {HttpMethod} httpMethod - The HTTP method to be used.
* @param {object} querySchema - The schema for the querystring.
* @param {object} paramSchema - The schema for the parameters.
* @param {object} headerSchema - The schema for the headers.
* @param {object} bodySchema - The schema for the body.
* @param {object} responseSchema - The schema for the response.
* @param {string[]} tags - The tags for the route.
* @param {string} description - The description for the route.
* @param {object[]} security - The security for the route.
* @return {void}
*/
_addDataRoute(namespace, method, httpMethod, querySchema, paramSchema, headerSchema, bodySchema, responseSchema, tags, description, security) {
this._logger.debug(`Adding data route for ${namespace}`, this._toDataPath(namespace), httpMethod);
const schema = {};
if (querySchema) {
schema['querystring'] = querySchema;
}
if (bodySchema) {
schema['body'] = bodySchema;
}
if (paramSchema) {
schema['params'] = paramSchema;
}
if (headerSchema) {
schema['headers'] = headerSchema;
}
if (responseSchema) {
schema['response'] = {
200: responseSchema,
};
}
if (tags) {
schema['tags'] = tags;
}
if (description) {
schema['description'] = description;
}
if (security) {
schema['security'] = security;
}
/**
* Handles the request and returns a Promise resolving to an object.
*
* @param {FastifyRequest<{ Body: object, Querystring: object }>} request - the request object
* @param {FastifyReply} reply - the reply object
* @return {Promise<any>} - a Promise resolving to an object
*/
const _handler = async (request, reply) => method.call(this, request, reply).catch((err) => {
// TODO: figure out better error codes & messages
this._logger.error('Error in handling data route', err);
const statusCode = err.statusCode ? err.statusCode : 500;
reply.status(statusCode).send(err);
});
const _opts = {
method: httpMethod,
url: this._toDataPath(namespace),
schema: schema,
handler: _handler,
};
if (!!schema &&
!!schema.headers &&
!!schema.headers.properties &&
!!schema.headers.properties.Authorization) {
_opts['preHandler'] = this._server.auth([
this._server.authorization,
]);
if (!_opts['security']) {
_opts.schema['security'] = [AuthorizationSecurity];
}
else {
_opts.schema['security'].push(AuthorizationSecurity);
}
}
if (this._module.config.util.swagger?.exposeData) {
this._server.register(async (fastifyInstance) => {
this.registerSchemaForOpts(fastifyInstance, _opts);
fastifyInstance.route(_opts);
});
}
else {
this._server.route(_opts);
}
}
registerSchemaForOpts = (fastifyInstance, _opts) => {
if (_opts.schema['querystring']) {
_opts.schema['querystring'] = this.registerSchema(fastifyInstance, _opts.schema['querystring']);
}
if (_opts.schema['body']) {
_opts.schema['body'] = this.registerSchema(fastifyInstance, _opts.schema['body'], this._ocppVersion ? `${this._ocppVersion}-` : '');
}
if (_opts.schema['params']) {
_opts.schema['params'] = this.registerSchema(fastifyInstance, _opts.schema['params']);
}
if (_opts.schema['headers']) {
_opts.schema['headers'] = this.registerSchema(fastifyInstance, _opts.schema['headers']);
}
if (_opts.schema['response']) {
_opts.schema['response'] = {
200: this.registerSchema(fastifyInstance, _opts.schema['response'][200]),
};
}
};
registerSchema = (fastifyInstance, schema, schemaIdPrefix) => {
let id = schema['$id'];
if (!id) {
this._logger.error('Could not register schema because no ID', schema);
}
try {
const schemaCopy = this.removeUnknownKeys(schema);
if (id && schemaIdPrefix) {
id = schemaIdPrefix + id;
schemaCopy['$id'] = id;
this._logger.debug(`Update schema id: ${schemaCopy['$id']}`);
}
if (schemaCopy.required &&
Array.isArray(schemaCopy.required) &&
schemaCopy.required.length === 0) {
delete schemaCopy.required;
}
if (schema.definitions) {
Object.keys(schema.definitions).forEach((key) => {
const definition = schema.definitions[key];
if (!definition['$id']) {
definition['$id'] = key;
}
this.registerSchema(fastifyInstance, definition);
});
}
if (schemaCopy.properties) {
Object.keys(schemaCopy.properties).forEach((key) => {
const property = schemaCopy.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/', '');
}
});
}
fastifyInstance.addSchema(schemaCopy);
this._server.addSchema(schemaCopy);
return {
$ref: `${id}`,
};
}
catch (e) {
// ignore already declared
if (e.code === 'FST_ERR_SCH_ALREADY_PRESENT') {
return {
$ref: `${id}`,
};
}
else {
this._logger.error('Could not register schema', e, schema);
}
return null;
}
};
registerSystemConfigRoutes(module) {
this._addDataRoute.call(this, OCPP2_Namespace.SystemConfig, () => new Promise((resolve) => resolve(module.config)), HttpMethod.Get);
const systemConfigJsonSchema = z.toJSONSchema(systemConfigSchema, {
target: 'openapi-3.0',
});
this._addDataRoute.call(this, OCPP2_Namespace.SystemConfig, async (request) => {
await ConfigStoreFactory.getInstance().saveConfig(request.body);
module.config = request.body;
}, HttpMethod.Put, undefined, undefined, undefined, {
...systemConfigJsonSchema,
$id: 'SystemConfigSchema',
});
}
// TODO: for performance reasons can these unknown keys be removed directly from schemas?
removeUnknownKeys = (schema) => {
// Create a deep copy of the schema
const schemaCopy = structuredClone(schema); // Use structuredClone for a true deep copy
const cleanSchema = (obj) => {
if (typeof obj !== 'object' || obj === null)
return;
// Remove specific unknown keys
for (const unknownKey of ['comment', 'javaType', 'tsEnumNames']) {
if (unknownKey in obj) {
delete obj[unknownKey];
}
}
// Remove `additionalItems` if `items` is not an array
if ('items' in obj && !Array.isArray(obj.items) && 'additionalItems' in obj) {
delete obj.additionalItems;
}
// Remove `additionalProperties` if `type` is not "object"
if ('additionalProperties' in obj && obj.type !== 'object') {
delete obj.additionalProperties;
}
// Recursively process nested objects
for (const key in obj) {
if (typeof obj[key] === 'object') {
cleanSchema(obj[key]);
}
}
};
// Clean the copied schema
cleanSchema(schemaCopy);
return schemaCopy;
};
/**
* Convert a {@link CallAction} to a normed lowercase URL path.
*
* @param {CallAction} input - The {@link CallAction} to convert to a URL path.
* @param {string} prefix - The module name.
* @returns {string} - String representation of URL path.
*/
_toMessagePath(input, prefix) {
const endpointPrefix = prefix || '';
const endpointVersion = (this._ocppVersion ? this._ocppVersion : OCPPVersion.OCPP2_0_1).replace(/^ocpp/, '');
return `/ocpp/${endpointVersion}${!endpointPrefix.startsWith('/') ? '/' : ''}${endpointPrefix}${!endpointPrefix.endsWith('/') ? '/' : ''}${input.charAt(0).toLowerCase() + input.slice(1)}`;
}
/**
* Convert a namespace to a normed lowercase URL path.
*
* @param {OCPP2_Namespace | OCPP1_6_Namespace | Namespace} input - The {@link OCPP2_Namespace} or {@link OCPP1_6_Namespace} or {@link Namespace} to convert to a URL path.
* @param {string} prefix - The module name.
* @returns {string} - String representation of URL path.
*/
_toDataPath(input, prefix) {
const endpointPrefix = prefix || '';
return `/data${!endpointPrefix.startsWith('/') ? '/' : ''}${endpointPrefix}${!endpointPrefix.endsWith('/') ? '/' : ''}${input.charAt(0).toLowerCase() + input.slice(1)}`;
}
}
//# sourceMappingURL=AbstractModuleApi.js.map