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.

342 lines 15 kB
// 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