Add UI HTTP server (#6)
authorJérôme Benoit <jerome.benoit@sap.com>
Fri, 26 Aug 2022 21:53:46 +0000 (23:53 +0200)
committerGitHub <noreply@github.com>
Fri, 26 Aug 2022 21:53:46 +0000 (23:53 +0200)
14 files changed:
.github/workflows/ci.yml
README.md
build-requirements.js [new file with mode: 0644]
package-lock.json
package.json
rollup.config.mjs
src/charging-station/Bootstrap.ts
src/charging-station/ui-server/UIHttpServer.ts [new file with mode: 0644]
src/charging-station/ui-server/UIServerFactory.ts
src/charging-station/ui-server/ui-services/AbstractUIService.ts
src/charging-station/ui-server/ui-services/UIServiceUtils.ts
src/types/ConfigurationData.ts
src/ui/web/src/composable/UIClient.ts
src/utils/Configuration.ts

index 3feffe8b61c19294e47362a0bd4180803e58a4e9..1f702e1a0521be0e0daec0d99d4156567c49b5bf 100644 (file)
@@ -11,7 +11,7 @@ jobs:
     strategy:
       matrix:
         os: [windows-latest, macos-latest, ubuntu-latest]
-        node: ['14.x', '16.x', '18.x']
+        node: ['16.x', '18.x']
     steps:
       - uses: actions/checkout@v3
         with:
@@ -46,7 +46,7 @@ jobs:
     strategy:
       matrix:
         os: [windows-latest, macos-latest, ubuntu-latest]
-        node: ['14.x', '16.x', '18.x']
+        node: ['16.x', '18.x']
     steps:
       - uses: actions/checkout@v3
         with:
index 563b2e31b93b1b6c02ee0c9ade597d8b607453da..edc08c6c9c10633cd328832580b567de359f789f 100644 (file)
--- a/README.md
+++ b/README.md
@@ -101,7 +101,7 @@ But the modifications to test have to be done to the files in the build target d
 | logFile                    |                                                  | combined.log                                                                                                                                                                                                   | string                                                                                                                                                                                                                              | log file relative path                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
 | logErrorFile               |                                                  | error.log                                                                                                                                                                                                      | string                                                                                                                                                                                                                              | error log file relative path                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
 | worker                     |                                                  | {<br />"processType": "workerSet",<br />"startDelay": 500,<br />"elementStartDelay": 0,<br />"elementsPerWorker": 1,<br />"poolMinSize": 4,<br />"poolMaxSize": 16,<br />"poolStrategy": "ROUND_ROBBIN"<br />} | {<br />processType: WorkerProcessType;<br />startDelay: number;<br />elementStartDelay: number;<br />elementsPerWorker: number;<br />poolMinSize: number;<br />poolMaxSize: number;<br />poolStrategy: WorkerChoiceStrategy;<br />} | Worker configuration section:<br />- processType: worker threads process type (workerSet/staticPool/dynamicPool)<br />- startDelay: milliseconds to wait at worker threads startup (only for workerSet threads process type)<br />- elementStartDelay: milliseconds to wait at charging station startup<br />- elementsPerWorker: number of charging stations per worker threads for the `workerSet` process type<br />- poolMinSize: worker threads pool minimum number of threads</br >- poolMaxSize: worker threads pool maximum number of threads<br />- poolStrategy: worker threads pool [poolifier](https://github.com/poolifier/poolifier) worker choice strategy |
-| uiServer                   |                                                  | {<br />"enabled": true,<br />"options": {<br />"host: "localhost",<br />"port": 8080<br />}<br />}                                                                                                             | {<br />enabled: boolean;<br />options: ServerOptions;<br />}                                                                                                                                                                        | UI WebSocket server configuration section                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
+| uiServer                   |                                                  | {<br />"enabled": true,<br />"type": "ws",<br />"options": {<br />"host: "localhost",<br />"port": 8080<br />}<br />}                                                                                          | {<br />enabled: boolean;<br />type: ApplicationProtocol;<br />options: ServerOptions;<br />}                                                                                                                                        | UI server configuration section                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
 | performanceStorage         |                                                  | {<br />"enabled": false,<br />"type": "jsonfile",<br />"file:///performanceRecords.json"<br />}                                                                                                                | {<br />enabled: boolean;<br />type: string;<br />URI: string;<br />}<br />where type can be 'jsonfile' or 'mongodb'                                                                                                                 | performance storage configuration section                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
 | stationTemplateUrls        |                                                  | {}[]                                                                                                                                                                                                           | {<br />file: string;<br />numberOfStations: number;<br />}[]                                                                                                                                                                        | array of charging station configuration templates URIs configuration section (charging station configuration template file name and number of stations)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
 
diff --git a/build-requirements.js b/build-requirements.js
new file mode 100644 (file)
index 0000000..ebcd5ef
--- /dev/null
@@ -0,0 +1,15 @@
+const chalk = require('chalk');
+// eslint-disable-next-line node/no-unpublished-require
+const SemVer = require('semver');
+
+const enginesNodeVersion = require('./package.json').engines.node;
+
+if (SemVer.satisfies(process.version, enginesNodeVersion) === false) {
+  console.error(
+    chalk.red(
+      `Required node version ${enginesNodeVersion} not satisfied with current version ${process.version}.`
+    )
+  );
+  // eslint-disable-next-line no-process-exit
+  process.exit(1);
+}
index 7ba742948a828abea8f5f5e73ba80fadd6b6ab5d..d1abcf59cc7e1637f3d2000cea86b03b6c91c0d2 100644 (file)
@@ -18,6 +18,7 @@
         "ajv-formats": "^2.1.1",
         "basic-ftp": "^5.0.1",
         "chalk": "^4.1.2",
+        "http-status-codes": "^2.2.0",
         "mnemonist": "^0.39.2",
         "moment": "^2.29.4",
         "mongodb": "^4.9.0",
@@ -71,6 +72,7 @@
         "rollup-plugin-istanbul": "^3.0.0",
         "rollup-plugin-terser": "^7.0.2",
         "rollup-plugin-ts": "^3.0.2",
+        "semver": "^7.3.7",
         "ts-node": "^10.9.1",
         "typescript": "^4.8.2"
       },
         "npm": ">=1.3.7"
       }
     },
+    "node_modules/http-status-codes": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.2.0.tgz",
+      "integrity": "sha512-feERVo9iWxvnejp3SEfm/+oNG517npqL2/PIA8ORjyOZjGC7TwCRQsZylciLS64i6pJ0wRYz3rkXLRwbtFa8Ng=="
+    },
     "node_modules/http2-wrapper": {
       "version": "2.1.11",
       "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.1.11.tgz",
         "sshpk": "^1.7.0"
       }
     },
+    "http-status-codes": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.2.0.tgz",
+      "integrity": "sha512-feERVo9iWxvnejp3SEfm/+oNG517npqL2/PIA8ORjyOZjGC7TwCRQsZylciLS64i6pJ0wRYz3rkXLRwbtFa8Ng=="
+    },
     "http2-wrapper": {
       "version": "2.1.11",
       "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.1.11.tgz",
index 9bfc481b17f27d133aeae8985b3813ca7c90c7ac..ac3c6270db0c9037135ff70a2dc48220a73f251b 100644 (file)
@@ -47,7 +47,8 @@
   },
   "scripts": {
     "prepare": "node prepare.js",
-    "prestart": "npm run build",
+    "build-requirements": "node build-requirements.js",
+    "prestart": "npm run build-requirements && npm run build",
     "start": "cross-env NODE_ENV=production node -r source-map-support/register dist/start.cjs",
     "start:debug": "cross-env NODE_ENV=production node -r source-map-support/register --inspect dist/start.cjs",
     "start:dev": "npm run build:dev && cross-env NODE_ENV=development node -r source-map-support/register dist/start.cjs",
@@ -88,6 +89,7 @@
     "ajv-formats": "^2.1.1",
     "basic-ftp": "^5.0.1",
     "chalk": "^4.1.2",
+    "http-status-codes": "^2.2.0",
     "mnemonist": "^0.39.2",
     "moment": "^2.29.4",
     "mongodb": "^4.9.0",
     "rollup-plugin-istanbul": "^3.0.0",
     "rollup-plugin-terser": "^7.0.2",
     "rollup-plugin-ts": "^3.0.2",
+    "semver": "^7.3.7",
     "ts-node": "^10.9.1",
     "typescript": "^4.8.2"
   }
index 2f1c5d69f5691999189e53d8e3a9bbef39686500..2dc4f5c7de531aabb599f99bcb800ec778babd82 100644 (file)
@@ -42,6 +42,7 @@ export default {
     'chalk',
     'crypto',
     'fs',
+    'http',
     'mnemonist/lru-map-with-delete',
     'moment',
     'mongodb',
index d1e2efd363f3066cea99c1b6558bf23823052caa..9fc759ff69c41bec24926dec35754249e762fb24 100644 (file)
@@ -19,7 +19,6 @@ import {
 } from '../types/ChargingStationWorker';
 import { StationTemplateUrl } from '../types/ConfigurationData';
 import Statistics from '../types/Statistics';
-import { ApplicationProtocol } from '../types/UIProtocol';
 import Configuration from '../utils/Configuration';
 import logger from '../utils/Logger';
 import Utils from '../utils/Utils';
@@ -56,7 +55,7 @@ export class Bootstrap {
     );
     this.initialize();
     Configuration.getUIServer().enabled &&
-      (this.uiServer = UIServerFactory.getUIServerImplementation(ApplicationProtocol.WS, {
+      (this.uiServer = UIServerFactory.getUIServerImplementation(Configuration.getUIServer().type, {
         ...Configuration.getUIServer().options,
         handleProtocols: UIServiceUtils.handleProtocols,
       }));
diff --git a/src/charging-station/ui-server/UIHttpServer.ts b/src/charging-station/ui-server/UIHttpServer.ts
new file mode 100644 (file)
index 0000000..01c0f1b
--- /dev/null
@@ -0,0 +1,140 @@
+import { IncomingMessage, RequestListener, Server, ServerResponse } from 'http';
+
+import StatusCodes from 'http-status-codes';
+
+import BaseError from '../../exception/BaseError';
+import { ServerOptions } from '../../types/ConfigurationData';
+import {
+  ProcedureName,
+  Protocol,
+  ProtocolResponse,
+  ProtocolVersion,
+  RequestPayload,
+  ResponsePayload,
+  ResponseStatus,
+} from '../../types/UIProtocol';
+import Configuration from '../../utils/Configuration';
+import logger from '../../utils/Logger';
+import Utils from '../../utils/Utils';
+import { AbstractUIServer } from './AbstractUIServer';
+import UIServiceFactory from './ui-services/UIServiceFactory';
+import { UIServiceUtils } from './ui-services/UIServiceUtils';
+
+const moduleName = 'UIHttpServer';
+
+type responseHandler = { procedureName: ProcedureName; res: ServerResponse };
+
+export default class UIHttpServer extends AbstractUIServer {
+  private readonly responseHandlers: Map<string, responseHandler>;
+
+  public constructor(private options?: ServerOptions) {
+    super();
+    this.server = new Server(this.requestListener.bind(this) as RequestListener);
+    this.responseHandlers = new Map<string, responseHandler>();
+  }
+
+  public start(): void {
+    (this.server as Server).listen(this.options ?? Configuration.getUIServer().options);
+  }
+
+  public stop(): void {
+    this.chargingStations.clear();
+  }
+
+  // eslint-disable-next-line @typescript-eslint/no-unused-vars
+  public sendRequest(request: string): void {
+    // This is intentionally left blank
+  }
+
+  public sendResponse(response: string): void {
+    const [uuid, payload] = JSON.parse(response) as ProtocolResponse;
+    let statusCode: number;
+    switch (payload.status) {
+      case ResponseStatus.SUCCESS:
+        statusCode = StatusCodes.OK;
+        break;
+      case ResponseStatus.FAILURE:
+      default:
+        statusCode = StatusCodes.BAD_REQUEST;
+        break;
+    }
+    if (this.responseHandlers.has(uuid)) {
+      const { procedureName, res } = this.responseHandlers.get(uuid);
+      res.writeHead(statusCode, { 'Content-Type': 'application/json' });
+      res.write(JSON.stringify(payload));
+      res.end();
+      this.responseHandlers.delete(uuid);
+    } else {
+      logger.error(
+        `${this.logPrefix()} ${moduleName}.sendResponse: Response received for unknown request: ${response}`
+      );
+    }
+  }
+
+  public logPrefix(modName?: string, methodName?: string): string {
+    const logMsg =
+      modName && methodName ? ` UI HTTP Server | ${modName}.${methodName}:` : ' UI HTTP Server |';
+    return Utils.logPrefix(logMsg);
+  }
+
+  private requestListener(req: IncomingMessage, res: ServerResponse): void {
+    // Expected request URL pathname: /ui/:version/:procedureName
+    const [protocol, version, procedureName] = req.url?.split('/').slice(1) as [
+      Protocol,
+      ProtocolVersion,
+      ProcedureName
+    ];
+    const uuid = Utils.generateUUID();
+    this.responseHandlers.set(uuid, { procedureName, res });
+    try {
+      if (UIServiceUtils.isProtocolSupported(protocol, version) === false) {
+        throw new BaseError(`Unsupported UI protocol version: '/${protocol}/${version}'`);
+      }
+      req.on('error', (error) => {
+        logger.error(
+          `${this.logPrefix(
+            moduleName,
+            'requestListener.req.onerror'
+          )} Error at incoming request handling:`,
+          error
+        );
+      });
+      if (!this.uiServices.has(version)) {
+        this.uiServices.set(version, UIServiceFactory.getUIServiceImplementation(version, this));
+      }
+      if (req.method === 'POST') {
+        const bodyBuffer = [];
+        let body: RequestPayload;
+        req
+          .on('data', (chunk) => {
+            bodyBuffer.push(chunk);
+          })
+          .on('end', () => {
+            body = JSON.parse(Buffer.concat(bodyBuffer).toString()) as RequestPayload;
+            this.uiServices
+              .get(version)
+              .requestHandler(this.buildRequest(uuid, procedureName, body ?? {}))
+              .catch(() => {
+                this.sendResponse(this.buildResponse(uuid, { status: ResponseStatus.FAILURE }));
+              });
+          });
+      } else {
+        throw new BaseError(`Unsupported HTTP method: '${req.method}'`);
+      }
+    } catch (error) {
+      this.sendResponse(this.buildResponse(uuid, { status: ResponseStatus.FAILURE }));
+    }
+  }
+
+  private buildRequest(
+    id: string,
+    procedureName: ProcedureName,
+    requestPayload: RequestPayload
+  ): string {
+    return JSON.stringify([id, procedureName, requestPayload]);
+  }
+
+  private buildResponse(id: string, responsePayload: ResponsePayload): string {
+    return JSON.stringify([id, responsePayload]);
+  }
+}
index b0020b2e872e11dc6a59b0a5491f265203453a45..90f61c7a66226003132cccbd96b90b1bda61b641 100644 (file)
@@ -5,6 +5,7 @@ import { ApplicationProtocol } from '../../types/UIProtocol';
 import Configuration from '../../utils/Configuration';
 import { AbstractUIServer } from './AbstractUIServer';
 import { UIServiceUtils } from './ui-services/UIServiceUtils';
+import UIHttpServer from './UIHttpServer';
 import UIWebSocketServer from './UIWebSocketServer';
 
 export default class UIServerFactory {
@@ -26,6 +27,8 @@ export default class UIServerFactory {
     switch (applicationProtocol) {
       case ApplicationProtocol.WS:
         return new UIWebSocketServer(options ?? Configuration.getUIServer().options);
+      case ApplicationProtocol.HTTP:
+        return new UIHttpServer(options ?? Configuration.getUIServer().options);
       default:
         return null;
     }
index 092742b718c6e294f44b5e83165f9633182d9292..e27577d353e004491a7e926facc24cce4efa41d4 100644 (file)
@@ -36,7 +36,7 @@ export default abstract class AbstractUIService {
     this.uiServiceWorkerBroadcastChannel = new UIServiceWorkerBroadcastChannel(this);
   }
 
-  public async requestHandler(request: RawData): Promise<void> {
+  public async requestHandler(request: RawData | JsonType): Promise<void> {
     let messageId: string;
     let command: ProcedureName;
     let requestPayload: RequestPayload | undefined;
@@ -58,10 +58,7 @@ export default abstract class AbstractUIService {
       responsePayload = await this.requestHandlers.get(command)(messageId, requestPayload);
     } catch (error) {
       // Log
-      logger.error(
-        `${this.uiServer.logPrefix(moduleName, 'messageHandler')} Handle request error:`,
-        error
-      );
+      logger.error(`${this.logPrefix(moduleName, 'messageHandler')} Handle request error:`, error);
       responsePayload = {
         status: ResponseStatus.FAILURE,
         command,
@@ -74,7 +71,7 @@ export default abstract class AbstractUIService {
 
     if (responsePayload !== undefined) {
       // Send the response
-      this.uiServer.sendResponse(this.buildProtocolResponse(messageId ?? 'error', responsePayload));
+      this.sendResponse(messageId ?? 'error', responsePayload);
     }
   }
 
@@ -108,12 +105,12 @@ export default abstract class AbstractUIService {
 
   // Validate the raw data received from the WebSocket
   // TODO: should probably be moved to the ws verify clients callback
-  private requestValidation(rawData: RawData): ProtocolRequest {
+  private requestValidation(rawData: RawData | JsonType): ProtocolRequest {
     // logger.debug(
-    //   `${this.uiServer.logPrefix(
+    //   `${this.logPrefix(
     //     moduleName,
-    //     'dataValidation'
-    //   )} Raw data received: ${rawData.toString()}`
+    //     'requestValidation'
+    //   )} Data received in string format: ${rawData.toString()}`
     // );
 
     const data = JSON.parse(rawData.toString()) as JsonType[];
index cf89b0c60598c2625b691288b144819c9e52c209..06db5a47109f2c11b77eb938021748d86968eaf5 100644 (file)
@@ -23,10 +23,7 @@ export class UIServiceUtils {
         protocolIndex + Protocol.UI.length
       ) as Protocol;
       version = fullProtocol.substring(protocolIndex + Protocol.UI.length) as ProtocolVersion;
-      if (
-        Object.values(Protocol).includes(protocol) &&
-        Object.values(ProtocolVersion).includes(version)
-      ) {
+      if (UIServiceUtils.isProtocolSupported(protocol, version) === true) {
         return fullProtocol;
       }
     }
@@ -38,6 +35,9 @@ export class UIServiceUtils {
     return false;
   };
 
+  public static isProtocolSupported = (protocol: Protocol, version: ProtocolVersion): boolean =>
+    Object.values(Protocol).includes(protocol) && Object.values(ProtocolVersion).includes(version);
+
   public static isLoopback(address: string): boolean {
     const isLoopbackRegExp = new RegExp(
       // eslint-disable-next-line no-useless-escape
index e549368138e2c4d0dfbce04eceea9f08ab9db28c..32470f7b6906e336a0e930d209e9574b58473545 100644 (file)
@@ -4,6 +4,7 @@ import type { WorkerChoiceStrategy } from 'poolifier';
 import { ServerOptions as WSServerOptions } from 'ws';
 
 import { StorageType } from './Storage';
+import { ApplicationProtocol } from './UIProtocol';
 import { WorkerProcessType } from './Worker';
 
 export type ServerOptions = WSServerOptions & ListenOptions;
@@ -21,6 +22,7 @@ export interface StationTemplateUrl {
 
 export interface UIServerConfiguration {
   enabled?: boolean;
+  type?: ApplicationProtocol;
   options?: ServerOptions;
 }
 
index 22e3a7f25ddab066606b32cc236466b2ba889b2c..f699a837e9524fa0d981fd4e503db568f423b83d 100644 (file)
@@ -10,9 +10,9 @@ import config from '@/assets/config';
 import { v4 as uuidv4 } from 'uuid';
 
 type ResponseHandler = {
+  procedureName: ProcedureName;
   resolve: (value: ResponsePayload | PromiseLike<ResponsePayload>) => void;
   reject: (reason?: any) => void;
-  procedureName: ProcedureName;
 };
 
 export default class UIClient {
@@ -101,9 +101,9 @@ export default class UIClient {
 
   private setResponseHandler(
     id: string,
+    procedureName: ProcedureName,
     resolve: (value: ResponsePayload | PromiseLike<ResponsePayload>) => void,
-    reject: (reason?: any) => void,
-    procedureName: ProcedureName
+    reject: (reason?: any) => void
   ): void {
     this._responseHandlers.set(id, { resolve, reject, procedureName });
   }
@@ -112,6 +112,10 @@ export default class UIClient {
     return this._responseHandlers.get(id);
   }
 
+  private deleteResponseHandler(id: string): boolean {
+    return this._responseHandlers.delete(id);
+  }
+
   private async sendRequest(command: ProcedureName, data: JsonType): Promise<ResponsePayload> {
     let uuid: string;
     return Utils.promiseWithTimeout(
@@ -128,7 +132,7 @@ export default class UIClient {
           throw new Error(`Send request ${command} message: connection not opened`);
         }
 
-        this.setResponseHandler(uuid, resolve, reject, command);
+        this.setResponseHandler(uuid, command, resolve, reject);
       }),
       60 * 1000,
       Error(`Send request ${command} message timeout`),
@@ -158,6 +162,7 @@ export default class UIClient {
         default:
           throw new Error(`Response status not supported: ${response.status}`);
       }
+      this.deleteResponseHandler(uuid);
     } else {
       throw new Error('Not a response to a request: ' + JSON.stringify(data, null, 2));
     }
index 5de867147666b9e2b5dfd201af3a20edfc968358..f4eb39ad62ae287667e82355255a9f30ca2dbca4 100644 (file)
@@ -15,6 +15,7 @@ import { EmptyObject } from '../types/EmptyObject';
 import { HandleErrorParams } from '../types/Error';
 import { FileType } from '../types/FileType';
 import { StorageType } from '../types/Storage';
+import { ApplicationProtocol } from '../types/UIProtocol';
 import { WorkerProcessType } from '../types/Worker';
 import WorkerConstants from '../worker/WorkerConstants';
 import Constants from './Constants';
@@ -58,6 +59,7 @@ export default class Configuration {
     }
     let uiServerConfiguration: UIServerConfiguration = {
       enabled: true,
+      type: ApplicationProtocol.WS,
       options: {
         host: Constants.DEFAULT_UI_WEBSOCKET_SERVER_HOST,
         port: Constants.DEFAULT_UI_WEBSOCKET_SERVER_PORT,