Mercedes framework + OAuth Flow
ADD
src/integration/car-connector/carConnectorFactory.ts
+import { CarConnectorConnectionType, CarConnectorSetting } from '../../types/Setting';
+
+import CarConnectorIntegration from './carConnectorIntegration';
+import Logging from '../../utils/Logging';
+import MercedesCarConnectorIntegration from './mercedes-connector/mercedesCarConnectorIntegration';
+import { ServerAction } from '../../types/Server';
+import SettingStorage from '../../storage/mongodb/SettingStorage';
+import Tenant from '../../types/Tenant';
+import TenantComponents from '../../types/TenantComponents';
+import Utils from '../../utils/Utils';
+
+const MODULE_NAME = 'CarConnectorFactory';
+
+export default class CarConnectorFactory {
+ static async getCarConnectorImpl(tenant: Tenant, connectorId: string): Promise<CarConnectorIntegration<CarConnectorSetting>> {
+ // Check if car connector component is active
+ if (Utils.isTenantComponentActive(tenant, TenantComponents.CAR_CONNECTOR)) {
+ const settings = await SettingStorage.getCarConnectorSettings(tenant.id);
+ if (settings && settings.carConnector && settings.carConnector.connections) {
+ // Find connection
+ const foundConnection = settings.carConnector.connections.find((connection) => connection.type === connectorId);
+ if (foundConnection) {
+ let carConnectorIntegrationImpl: CarConnectorIntegration<CarConnectorSetting> = null;
+ switch (foundConnection.type) {
+ case CarConnectorConnectionType.MERCEDES:
+ carConnectorIntegrationImpl = new MercedesCarConnectorIntegration(tenant, settings.carConnector, foundConnection);
+ break;
+ }
+ return carConnectorIntegrationImpl;
+ }
+ }
+ await Logging.logDebug({
+ tenantID: tenant.id,
+ action: ServerAction.ASSET,
+ module: MODULE_NAME, method: 'getCarConnectorImpl',
+ message: 'Car connector settings are not configured'
+ });
+ }
+ return null;
+ }
+}

ADD
src/integration/car-connector/carConnectorIntegration.ts
+import { CarConnectorConnectionSetting, CarConnectorSetting } from '../../types/Setting';
+
+import Connection from '../../types/Connection';
+import Tenant from '../../types/Tenant';
+import Transaction from '../../types/Transaction';
+
+export default abstract class CarConnectorIntegration<T extends CarConnectorSetting> {
+ protected readonly tenant: Tenant;
+ protected settings: T;
+ protected connection: CarConnectorConnectionSetting;
+
+ protected constructor(tenant: Tenant, settings: T, connection: CarConnectorConnectionSetting) {
+ this.tenant = tenant;
+ this.settings = settings;
+ this.connection = connection;
+ }
+
+ public abstract createConnection(userID: string, data: unknown): Promise<Connection>;
+
+ public abstract checkConnection(userID: string): Promise<void>;
+
+ public abstract getCurrentSOC(userID: string): Promise<number>;
+}

ADD
src/integration/car-connector/mercedes-connector/mercedesCarConnectorIntegration.ts
+import { AxiosInstance, AxiosResponse } from 'axios';
+import { CarConnectorConnectionSetting, CarConnectorConnectionType, CarConnectorSetting } from '../../../types/Setting';
+import axiosRetry, { IAxiosRetryConfig } from 'axios-retry';
+
+import AxiosFactory from '../../../utils/AxiosFactory';
+import BackendError from '../../../exception/BackendError';
+import CarConnectorIntegration from '../carConnectorIntegration';
+import Connection from '../../../types/Connection';
+import ConnectionStorage from '../../../storage/mongodb/ConnectionStorage';
+import Constants from '../../../utils/Constants';
+import Cypher from '../../../utils/Cypher';
+import Logging from '../../../utils/Logging';
+import { ServerAction } from '../../../types/Server';
+import { StatusCodes } from 'http-status-codes';
+import Tenant from '../../../types/Tenant';
+import moment from 'moment-timezone';
+import querystring from 'querystring';
+
+const MODULE_NAME = 'MercedesCarConnectorIntegration';
+const CONNECTOR_ID = CarConnectorConnectionType.MERCEDES;
+
+export default class MercedesCarConnectorIntegration extends CarConnectorIntegration<CarConnectorSetting> {
+ private axiosInstance: AxiosInstance;
+ private readonly axiosRetryConfiguration: IAxiosRetryConfig = {
+ retries: 3,
+ retryCondition: (error) => error.response.status === StatusCodes.INTERNAL_SERVER_ERROR || axiosRetry.isNetworkError(error),
+ retryDelay: (retryCount, error) => {
+ try {
+ if (error.config.method === 'post') {
+ if (error.config.url.endsWith('/token.oauth2')) {
+ throw new BackendError({
+ source: Constants.CENTRAL_SERVER,
+ module: MODULE_NAME,
+ method: 'retryDelay',
+ message: `Unable to post token, response status ${error.response.status}, attempt ${retryCount}`,
+ action: ServerAction.CAR_CONNECTOR,
+ detailedMessages: { response: error.response }
+ });
+ } else {
+ const payload = {
+ error: error.response.data,
+ payload: JSON.parse(error.config.data)
+ };
+ throw new BackendError({
+ source: Constants.CENTRAL_SERVER,
+ module: MODULE_NAME,
+ method: 'retryDelay',
+ message: `Unable to post data on ${error.config.url}, response status ${error.response.status}, attempt ${retryCount}`,
+ action: ServerAction.CAR_CONNECTOR,
+ detailedMessages: { payload }
+ });
+ }
+ } else {
+ throw new BackendError({
+ source: Constants.CENTRAL_SERVER,
+ module: MODULE_NAME,
+ method: 'retryDelay',
+ message: `Unable to make data request on ${error.config.url}, response status ${error.response.status}, attempt ${retryCount}`,
+ action: ServerAction.CAR_CONNECTOR,
+ detailedMessages: { response: error.response.data }
+ });
+ }
+ } catch (err) {
+ void Logging.logException(
+ err, ServerAction.CAR_CONNECTOR, Constants.CENTRAL_SERVER, MODULE_NAME, 'anonymous', this.tenant.id, null);
+ }
+ return axiosRetry.exponentialDelay(retryCount);
+ },
+ shouldResetTimeout: true
+ };
+
+ constructor(tenant: Tenant, settings: CarConnectorSetting, connection: CarConnectorConnectionSetting) {
+ super(tenant, settings, connection);
+ // Get Axios
+ this.axiosInstance = AxiosFactory.getAxiosInstance(this.tenant.id,
+ {
+ axiosRetryConfig: this.axiosRetryConfiguration,
+ });
+ }
+
+ public async checkConnection(userID: string): Promise<void> {
+ const connection = await this.getRefreshedConnection(userID);
+ if (!connection) {
+ throw new BackendError({
+ source: Constants.CENTRAL_SERVER,
+ module: MODULE_NAME,
+ user: userID,
+ method: 'checkConnection', action: ServerAction.CAR_CONNECTOR,
+ message: `The user with ID '${userID}' does not have a valid connection`,
+ });
+ }
+ }
+
+ public async createConnection(userID: string, data: any): Promise<Connection> {
+ try {
+ await Logging.logDebug({
+ tenantID: this.tenant.id,
+ module: MODULE_NAME, method: 'createConnection',
+ action: ServerAction.CAR_CONNECTOR, message: `Request Mercedes access token for User ID '${userID}'`
+ });
+ const mercedesURL = `${this.connection.mercedesConnection.authenticationUrl}/as/token.oauth2 `;
+ const response = await this.axiosInstance.post(mercedesURL,
+ querystring.stringify({
+ code: data.code,
+ redirect_uri: data.redirectUri,
+ grant_type: 'authorization_code'
+ }),
+ {
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ 'Authorization': `Basic ${Buffer.from(this.connection.mercedesConnection.clientId + ':' + await Cypher.decrypt(this.tenant.id, this.connection.mercedesConnection.clientSecret)).toString('base64')}`
+ },
+ });
+ await Logging.logDebug({
+ tenantID: this.tenant.id,
+ module: MODULE_NAME, method: 'createConnection',
+ action: ServerAction.CAR_CONNECTOR, message: `Mercedes access token granted for User ID '${userID}'`
+ });
+ // Check first
+ let connection = await ConnectionStorage.getConnectionByConnectorIdAndUserId(this.tenant, CONNECTOR_ID, userID);
+ if (connection) {
+ // Update
+ connection.data = response.data;
+ connection.updatedAt = new Date();
+ connection.validUntil = this.computeValidUntilAt(response);
+ } else {
+ // Create new
+ connection = {
+ data: response.data,
+ userId: userID,
+ connectorId: CONNECTOR_ID,
+ createdAt: new Date(),
+ validUntil: this.computeValidUntilAt(response)
+ };
+ }
+ // Save
+ connection.id = await ConnectionStorage.saveConnection(this.tenant, connection);
+ return connection;
+ } catch (error) {
+ throw new BackendError({
+ source: Constants.CENTRAL_SERVER,
+ message: `Mercedes access token not granted for User ID '${userID}'`,
+ module: MODULE_NAME,
+ method: 'GetAccessToken',
+ user: userID,
+ action: ServerAction.CAR_CONNECTOR,
+ detailedMessages: { error: error.stack }
+ });
+ }
+ }
+
+ public async getCurrentSOC(userID: string): Promise<number> {
+ // To be implemented
+ const connection = await this.getRefreshedConnection(userID);
+ return 0;
+ }
+
+ private computeValidUntilAt(response: AxiosResponse) {
+ const now = new Date();
+ console.log(now.getTime() + response.data.expires_in * 1000);
+ return new Date(now.getTime() + response.data.expires_in * 1000);
+ }
+
+ private isTokenExpired(connection: Connection) {
+ const referenceDate = connection.updatedAt ? connection.updatedAt : connection.createdAt;
+ if (!referenceDate || !connection.data.expires_in) {
+ return true;
+ }
+ return moment(referenceDate).add(connection.data.expires_in, 'seconds').isBefore(moment.now());
+ }
+
+ private getRetryCount(response) {
+ if (response && response.config) {
+ return response.config['axios-retry'].retryCount;
+ }
+ return 0;
+ }
+
+ private async refreshToken(userID: string, connection: Connection): Promise<Connection> {
+ try {
+ const startDate = moment();
+ const mercedesURL = `${this.connection.mercedesConnection.authenticationUrl}/as/token.oauth2`;
+ const response = await this.axiosInstance.post(mercedesURL,
+ querystring.stringify({
+ refresh_token: connection.data.refresh_token,
+ grant_type: 'refresh_token'
+ }),
+ {
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ 'Authorization': `Basic ${Buffer.from(this.connection.mercedesConnection.clientId + ':' + await Cypher.decrypt(this.tenant.id, this.connection.mercedesConnection.clientSecret)).toString('base64')}`
+ }
+ });
+ await Logging.logDebug({
+ tenantID: this.tenant.id,
+ user: userID,
+ action: ServerAction.CAR_CONNECTOR,
+ module: MODULE_NAME, method: 'refreshToken',
+ // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
+ message: `Mercedes access token has been successfully generated in ${moment().diff(startDate, 'milliseconds')} ms with ${this.getRetryCount(response)} retries`
+ });
+ connection.data = response.data;
+ connection.updatedAt = new Date();
+ connection.validUntil = this.computeValidUntilAt(response);
+ connection.id = await ConnectionStorage.saveConnection(this.tenant, connection);
+ return connection;
+ } catch (error) {
+ throw new BackendError({
+ source: Constants.CENTRAL_SERVER,
+ message: `Mercedes access token not refreshed (ID: '${userID}')`,
+ module: MODULE_NAME,
+ method: 'refreshToken',
+ action: ServerAction.CAR_CONNECTOR,
+ user: userID,
+ detailedMessages: { error: error.stack }
+ });
+ }
+ }
+
+ private async getRefreshedConnection(userID: string): Promise<Connection> {
+ let connection = await ConnectionStorage.getConnectionByConnectorIdAndUserId(this.tenant, CONNECTOR_ID, userID);
+ if (!connection) {
+ throw new BackendError({
+ source: Constants.CENTRAL_SERVER,
+ message: `The user with ID '${userID}' does not have a connection to connector '${CONNECTOR_ID}'`,
+ module: MODULE_NAME,
+ method: 'getRefreshedConnection',
+ action: ServerAction.CAR_CONNECTOR,
+ user: userID
+ });
+ }
+ if (this.isTokenExpired(connection)) {
+ connection = await this.refreshToken(userID, connection);
+ }
+ return connection;
+ }
+}

MODIFY
src/server/rest/v1/service/ConnectionService.ts
src/server/rest/v1/service/ConnectionService.ts
@@ -5,6 +5,8 @@ import { NextFunction, Request, Response } from 'express';
import AppAuthError from '../../../../exception/AppAuthError';
import AppError from '../../../../exception/AppError';
import Authorizations from '../../../../authorization/Authorizations';
+import CarConnectorFactory from '../../../../integration/car-connector/carConnectorFactory';
+import Connection from '../../../../types/Connection';
import ConnectionSecurity from './security/ConnectionSecurity';
import ConnectionStorage from '../../../../storage/mongodb/ConnectionStorage';
import ConnectionValidator from '../validator/ConnectionValidator';
@@ -13,6 +15,7 @@ import Logging from '../../../../utils/Logging';
import RefundFactory from '../../../../integration/refund/RefundFactory';
import { ServerAction } from '../../../../types/Server';
import { StatusCodes } from 'http-status-codes';
+import Utils from '../../../../utils/Utils';
import UtilsService from './UtilsService';
const MODULE_NAME = 'ConnectionService';
@@ -89,14 +92,22 @@ export default class ConnectionService {
module: MODULE_NAME, method: 'handleCreateConnection'
});
}
+ // Check
+ ConnectionValidator.getInstance().validateConnectionCreation(req.body);
// Filter
const filteredRequest = ConnectionSecurity.filterConnectionCreateRequest(req.body);
- // Get factory
- const refundConnector = await RefundFactory.getRefundImpl(req.tenant);
+ let integrationConnector = null;
+ switch (filteredRequest.connectorId) {
+ case 'mercedes':
+ integrationConnector = await CarConnectorFactory.getCarConnectorImpl(req.tenant, filteredRequest.connectorId);
+ break;
+ case 'concur':
+ integrationConnector = await RefundFactory.getRefundImpl(req.tenant);
+ break;
+ }
+ if (Utils.isNullOrUndefined(integrationConnector)) {
// Create
- const connection = await refundConnector.createConnection(filteredRequest.userId, filteredRequest.data);
- // Check
- ConnectionValidator.getInstance().validateConnectionCreation(req.body);
+ const connection: Connection = await integrationConnector.createConnection(filteredRequest.userId, filteredRequest.data);
// Log
await Logging.logSecurityInfo({
tenantID: req.user.tenantID, user: req.user,
@@ -105,6 +116,15 @@ export default class ConnectionService {
action: action,
detailedMessages: { connection }
});
+ } else {
+ throw new AppError({
+ errorCode: HTTPError.GENERAL_ERROR,
+ source: Constants.CENTRAL_SERVER,
+ user: req.user,
+ module: MODULE_NAME, method: 'handleCreateConnection',
+ message: `No integration found for connector '${filteredRequest.connectorId}' `
+ });
+ }
// Ok
res.status(StatusCodes.OK).json(Object.assign({ id: req.user.tenantID }, Constants.REST_RESPONSE_SUCCESS));
next();

MODIFY
src/storage/mongodb/SettingStorage.ts
src/storage/mongodb/SettingStorage.ts
-import { AnalyticsSettings, AnalyticsSettingsType, AssetSettings, AssetSettingsType, BillingSetting, BillingSettings, BillingSettingsType, CryptoSetting, CryptoSettings, CryptoSettingsType, PricingSettings, PricingSettingsType, RefundSettings, RefundSettingsType, RoamingSettings, SettingDB, SmartChargingSettings, SmartChargingSettingsType, TechnicalSettings, UserSettings, UserSettingsType } from '../../types/Setting';
+import { AnalyticsSettings, AnalyticsSettingsType, AssetSettings, AssetSettingsType, BillingSetting, BillingSettings, BillingSettingsType, CarConnectorConnectionSetting, CarConnectorSetting, CarConnectorSettings, CarConnectorSettingsType, CryptoSetting, CryptoSettings, CryptoSettingsType, PricingSettings, PricingSettingsType, RefundSettings, RefundSettingsType, RoamingSettings, SettingDB, SmartChargingSettings, SmartChargingSettingsType, TechnicalSettings, UserSettings, UserSettingsType } from '../../types/Setting';
import global, { FilterParams } from '../../types/GlobalType';
import BackendError from '../../exception/BackendError';
@@ -190,6 +190,30 @@ export default class SettingStorage {
return refundSettings;
}
+ public static async getCarConnectorSettings(tenantID: string): Promise<CarConnectorSettings> {
+ const carConnectorSettings = {
+ identifier: TenantComponents.CAR_CONNECTOR,
+ } as CarConnectorSettings;
+ // Get the settings
+ const settings = await SettingStorage.getSettings(tenantID,
+ { identifier: TenantComponents.CAR_CONNECTOR },
+ Constants.DB_PARAMS_MAX_LIMIT);
+ if (settings && settings.count > 0 && settings.result[0].content) {
+ const config = settings.result[0].content;
+ carConnectorSettings.id = settings.result[0].id;
+ carConnectorSettings.sensitiveData = settings.result[0].sensitiveData;
+ carConnectorSettings.backupSensitiveData = settings.result[0].backupSensitiveData;
+ // Car Connector
+ if (config.carConnector) {
+ carConnectorSettings.type = CarConnectorSettingsType.CAR_CONNECTOR;
+ carConnectorSettings.carConnector = {
+ connections: config.carConnector.connections ? config.carConnector.connections : []
+ };
+ }
+ }
+ return carConnectorSettings;
+ }
+
public static async getPricingSettings(tenantID: string, limit?: number, skip?: number, dateFrom?: Date, dateTo?: Date): Promise<PricingSettings> {
const pricingSettings = {
identifier: TenantComponents.PRICING,

MODIFY
src/types/Server.ts
src/types/Server.ts
@@ -378,6 +378,7 @@ export enum ServerAction {
REMOVE_USERS_FROM_SITE = 'RemoveUsersFromSite',
REFUND = 'Refund',
+ CAR_CONNECTOR = 'CarConnector',
USER_READ = 'UserRead',
USER_CREATE = 'RestUserCreate',

Mercedes framework + OAuth Flow