From f9f5402c7448c3b165ae2432be05f8c806518c09 Mon Sep 17 00:00:00 2001 From: "amaury.zarzelli" Date: Fri, 16 Oct 2026 12:13:37 +0200 Subject: [PATCH] feat(sources): add proxy sources --- changelog.md | 4 + .../configuration/resources/proxy.resource | 45 ++++ .../configuration/sources/osrm-proxy.mapping | 22 ++ .../configuration/sources/osrm-proxy.source | 20 ++ documentation/configuration/sources/proxy.md | 48 ++++ .../configuration/sources/proxy.source | 32 +++ .../sources/valhalla-proxy.mapping | 48 ++++ src/js/resources/proxyResource.js | 61 +++++ src/js/resources/resourceManager.js | 6 +- src/js/sources/proxyMapper.js | 234 +++++++++++++++++ src/js/sources/proxyResponseWriter.js | 179 +++++++++++++ src/js/sources/proxySource.js | 238 ++++++++++++++++++ src/js/sources/sourceManager.js | 82 +++++- .../mocha/sources/integrationProxySource.js | 73 ++++++ test/unit/mocha/sources/testsProxyMapper.js | 90 +++++++ 15 files changed, 1180 insertions(+), 2 deletions(-) create mode 100644 documentation/configuration/resources/proxy.resource create mode 100644 documentation/configuration/sources/osrm-proxy.mapping create mode 100644 documentation/configuration/sources/osrm-proxy.source create mode 100644 documentation/configuration/sources/proxy.md create mode 100644 documentation/configuration/sources/proxy.source create mode 100644 documentation/configuration/sources/valhalla-proxy.mapping create mode 100644 src/js/resources/proxyResource.js create mode 100644 src/js/sources/proxyMapper.js create mode 100644 src/js/sources/proxyResponseWriter.js create mode 100644 src/js/sources/proxySource.js create mode 100644 test/integration/mocha/sources/integrationProxySource.js create mode 100644 test/unit/mocha/sources/testsProxyMapper.js diff --git a/changelog.md b/changelog.md index da4a7c1..e062141 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,9 @@ # CHANGELOG +## Unreleased +ADDED: +- `proxy` source and resource type: Road2 translates `route` and `isochrone` requests to a remote HTTP service (e.g. a Valhalla container) using a declarative mapping file (`source.proxy.url`, `source.proxy.mapping`). See `documentation/configuration/sources/proxy.source` and `valhalla-proxy.mapping`. + ## 3.1.8 FIXED: - Service process crashed with OOM at startup: `assert.deepStrictEqual(x, {})` used as an emptiness check always failed on non-empty catalogs, and building the assertion message serialized the whole object graph (servers, resources, sources). Replaced with `Object.keys(x).length === 0` checks. diff --git a/documentation/configuration/resources/proxy.resource b/documentation/configuration/resources/proxy.resource new file mode 100644 index 0000000..c98acc0 --- /dev/null +++ b/documentation/configuration/resources/proxy.resource @@ -0,0 +1,45 @@ +{ + "resource": { + "id": "valhalla-proxy", + "type": "proxy", + "description": "Ressource proxy vers un service Valhalla.", + "sources": ["valhalla-proxy"], + "availableOperations": [ + { + "id": "route", + "parameters": [ + { "id": "resource", "values": ["valhalla-proxy"] }, + { "id": "start", "values": { "bbox": "-180,-90,180,90", "projection": "EPSG:4326" } }, + { "id": "end", "values": { "bbox": "-180,-90,180,90", "projection": "EPSG:4326" } }, + { "id": "profile", "defaultValueContent": "car", "values": ["car", "pedestrian"] }, + { "id": "optimization", "defaultValueContent": "fastest", "values": ["fastest", "shortest"] }, + { "id": "intermediates", "values": { "bbox": "-180,-90,180,90", "projection": "EPSG:4326" } }, + { "id": "getSteps", "defaultValueContent": "true" }, + { "id": "waysAttributes", "values": ["name"] }, + { "id": "geometryFormat", "defaultValueContent": "geojson", "values": ["geojson", "polyline", "wkt"] }, + { "id": "bbox", "defaultValueContent": "true" }, + { "id": "projection", "defaultValueContent": "EPSG:4326", "values": ["EPSG:4326", "EPSG:2154"] }, + { "id": "date_time" }, + { "id": "timeUnit", "defaultValueContent": "minute", "values": ["hour", "minute", "second", "standard"] }, + { "id": "distanceUnit", "defaultValueContent": "meter", "values": ["meter", "kilometer"] } + ] + }, + { + "id": "isochrone", + "parameters": [ + { "id": "resource", "values": ["valhalla-proxy"] }, + { "id": "point", "values": { "bbox": "-180,-90,180,90", "projection": "EPSG:4326" } }, + { "id": "costType", "defaultValueContent": "time", "values": ["time", "distance"] }, + { "id": "costValue", "values": { "min": 100, "max": 50000 } }, + { "id": "profile", "defaultValueContent": "car", "values": ["car", "pedestrian"] }, + { "id": "direction", "defaultValueContent": "departure", "values": ["departure", "arrival"] }, + { "id": "date_time" }, + { "id": "projection", "defaultValueContent": "EPSG:4326", "values": ["EPSG:4326", "EPSG:2154"] }, + { "id": "geometryFormat", "defaultValueContent": "geojson", "values": ["geojson", "polyline", "wkt"] }, + { "id": "timeUnit", "defaultValueContent": "second", "values": ["hour", "minute", "second"] }, + { "id": "distanceUnit", "defaultValueContent": "meter", "values": ["meter", "kilometer"] } + ] + } + ] + } +} diff --git a/documentation/configuration/sources/osrm-proxy.mapping b/documentation/configuration/sources/osrm-proxy.mapping new file mode 100644 index 0000000..fe3bb13 --- /dev/null +++ b/documentation/configuration/sources/osrm-proxy.mapping @@ -0,0 +1,22 @@ +{ + "route": { + "method": "GET", + "path": { + "$concat": [ + "/route/v1/", + "${cost.profile}", + "/", + { "$join": { "from": "coordinates", "separator": ";", "as": "${item.0},${item.1}" } } + ] + }, + "query": { + "overview": "full", + "geometries": "polyline6", + "steps": "true" + }, + "response": { + "format": "osrm", + "polylinePrecision": 6 + } + } +} diff --git a/documentation/configuration/sources/osrm-proxy.source b/documentation/configuration/sources/osrm-proxy.source new file mode 100644 index 0000000..0a5b7c0 --- /dev/null +++ b/documentation/configuration/sources/osrm-proxy.source @@ -0,0 +1,20 @@ +{ + "id": "osrm-proxy-car", + "description": "Service osrm-routed distant (profil voiture)", + "type": "proxy", + "projection": "EPSG:4326", + "bbox": "-180,-90,180,90", + "proxy": { + "url": "http://osrm:5000", + "timeout": 10000, + "mapping": "/home/docker/config/osrm-proxy.mapping" + }, + "costs": [ + { + "profile": "car", + "optimization": "fastest", + "costType": "time", + "parameters": { "profile": "driving" } + } + ] +} diff --git a/documentation/configuration/sources/proxy.md b/documentation/configuration/sources/proxy.md new file mode 100644 index 0000000..90c63b2 --- /dev/null +++ b/documentation/configuration/sources/proxy.md @@ -0,0 +1,48 @@ +# Source et ressource de type proxy + +Une source `proxy` ne calcule rien : elle traduit la requête Road2 (`route`, `isochrone`) en requête HTTP vers un service distant (par exemple Valhalla déployé via son image Docker), puis convertit la réponse au format Road2. Une ressource de type `proxy` s'appuie sur ces sources. + +Exemples : `proxy.source`, `valhalla-proxy.mapping` et `../resources/proxy.resource`. + +## Configuration de la source + +| Clé | Description | +|---|---| +| `type` | `proxy` | +| `proxy.url` | URL du service (http/https) | +| `proxy.timeout` | Timeout en ms (optionnel, 30000 par défaut) | +| `proxy.mapping` | Chemin du fichier de mapping, ou objet de mapping en ligne | +| `costs[]` | `profile`, `optimization`, `costType` pour le choix de la source, et `parameters` : variables libres accessibles dans le mapping sous `cost.*` | + +Le nom de la ressource est son `id` dans le fichier `.resource`. + +## Fichier de mapping + +Un objet par opération (`route`, `isochrone`) : + +- `method` : `GET` ou `POST` (défaut `POST`) +- `path` : chemin ajouté à `proxy.url` +- `query` : paramètres d'URL (template) +- `body` : corps JSON (template, pour `POST`) +- `headers` : en-têtes (template) +- `response.format` : `osrm` (route) ou `geojson` (isochrone) ; `response.root` : chemin pointé vers la réponse utile ; `response.polylinePrecision` : précision des polylines (5 par défaut) + +### Variables disponibles + +- route : `coordinates` (liste de `[x, y]` dans la projection de la source), `start`, `end`, `intermediates`, `optimization` +- isochrone : `point`, `costType`, `costValue`, `direction` +- communes : `resource`, `profile`, `date_time`, `constraints` (champs bannis), `cost` (`costs[].parameters`) + +### Templates + +- `"${variable.chemin}"` : valeur typée si c'est toute la chaîne, sinon interpolation. Fonctionne aussi dans les clés. +- Les valeurs indéfinies sont omises. +- `{"$for": "liste", "$as": template}` : itération (`item`, `index`) +- `{"$if": "variable", "$then": t, "$else": t}` +- `{"$divide": [a, b]}`, `{"$multiply": [a, b]}` +- `{"$lookup": {"value": "${x}", "table": {...}, "default": v}}` +- `{"$fromEntries": {"from": "liste", "key": "${item}", "value": v}}` +- `{"$concat": [t1, t2, ...]}` : concaténation (utile pour construire `path`) +- `{"$join": {"from": "liste", "separator": ";", "as": template}}` : jointure des éléments d'une liste + +Mappings fournis : `valhalla-proxy.mapping` (route et isochrone) et `osrm-proxy.mapping` (route seulement, OSRM n'a pas d'isochrone), avec les sources d'exemple `proxy.source` et `osrm-proxy.source`. diff --git a/documentation/configuration/sources/proxy.source b/documentation/configuration/sources/proxy.source new file mode 100644 index 0000000..99ce3b0 --- /dev/null +++ b/documentation/configuration/sources/proxy.source @@ -0,0 +1,32 @@ +{ + "id": "valhalla-proxy", + "description": "Service Valhalla distant", + "type": "proxy", + "projection": "EPSG:4326", + "bbox": "-180,-90,180,90", + "proxy": { + "url": "http://valhalla:8002", + "timeout": 10000, + "mapping": "/home/docker/config/valhalla-proxy.mapping" + }, + "costs": [ + { + "profile": "car", + "optimization": "fastest", + "costType": "time", + "parameters": { "costing": "auto", "contourDivisor": 60 } + }, + { + "profile": "car", + "optimization": "shortest", + "costType": "distance", + "parameters": { "costing": "auto_shorter", "contourDivisor": 1000 } + }, + { + "profile": "pedestrian", + "optimization": "shortest", + "costType": "distance", + "parameters": { "costing": "pedestrian", "contourDivisor": 1000, "options": { "walking_speed": 4 } } + } + ] +} diff --git a/documentation/configuration/sources/valhalla-proxy.mapping b/documentation/configuration/sources/valhalla-proxy.mapping new file mode 100644 index 0000000..8c40175 --- /dev/null +++ b/documentation/configuration/sources/valhalla-proxy.mapping @@ -0,0 +1,48 @@ +{ + "route": { + "method": "POST", + "path": "/route", + "body": { + "locations": { + "$for": "coordinates", + "$as": { "lat": "${item.1}", "lon": "${item.0}" } + }, + "costing": "${cost.costing}", + "costing_options": { "${cost.costing}": "${cost.options}" }, + "format": "osrm", + "date_time": { + "$if": "date_time", + "$then": { "type": 1, "value": "${date_time}" } + } + }, + "response": { + "format": "osrm", + "polylinePrecision": 6 + } + }, + "isochrone": { + "method": "POST", + "path": "/isochrone", + "body": { + "locations": [ + { "lat": "${point.1}", "lon": "${point.0}" } + ], + "costing": "${cost.costing}", + "costing_options": { "${cost.costing}": "${cost.options}" }, + "contours": [ + { "${costType}": { "$divide": ["${costValue}", "${cost.contourDivisor}"] } } + ], + "reverse": { + "$lookup": { "value": "${direction}", "table": { "arrival": true }, "default": false } + }, + "polygons": true, + "date_time": { + "$if": "date_time", + "$then": { "type": 1, "value": "${date_time}" } + } + }, + "response": { + "format": "geojson" + } + } +} diff --git a/src/js/resources/proxyResource.js b/src/js/resources/proxyResource.js new file mode 100644 index 0000000..5380f19 --- /dev/null +++ b/src/js/resources/proxyResource.js @@ -0,0 +1,61 @@ +'use strict'; + +const valhallaResource = require('./valhallaResource'); +const log4js = require('log4js'); + +var LOGGER = log4js.getLogger("PROXYRESOURCE"); + +/** +* +* @class +* @name proxyResource +* @description Classe modélisant une ressource qui délègue les calculs à des sources de type proxy. +* La sélection de la source suit la même logique que valhallaResource (profile/optimization ou profile/costType). +* +*/ +module.exports = class proxyResource extends valhallaResource { + + /** + * + * @function + * @name initResource + * @description Créer les liens entre la ressource et ses sources proxy + * @param {SourceManager} sourceManager - Manager des sources du service + * @return {boolean} + * + */ + initResource(sourceManager) { + + for (const sourceId of this._configuration.sources) { + + if (!sourceManager.isLoadedSourceAvailable(sourceId)) { + LOGGER.error("La source n'a pas été chargée"); + return false; + } + + const source = sourceManager.getSourceById(sourceId); + + if (source.type !== "proxy") { + LOGGER.error("La source n'est pas de type 'proxy'"); + return false; + } + + for (const operation of this._configuration.availableOperations) { + if (!source.isOperationMapped(operation.id)) { + LOGGER.error("L'opération " + operation.id + " n'est pas définie dans le mapping de la source " + sourceId); + return false; + } + } + + for (const cost of source.configuration.costs) { + this._linkedSource[cost.profile + cost.optimization] = source.configuration.id; + this._linkedSource[cost.profile + cost.costType] = source.configuration.id; + } + + } + + return true; + + } + +} diff --git a/src/js/resources/resourceManager.js b/src/js/resources/resourceManager.js index 95e3c72..1b3ff84 100644 --- a/src/js/resources/resourceManager.js +++ b/src/js/resources/resourceManager.js @@ -5,6 +5,7 @@ const path = require('path'); const osrmResource = require('../resources/osrmResource'); const pgrResource = require('../resources/pgrResource'); const valhallaResource = require('../resources/valhallaResource'); +const proxyResource = require('../resources/proxyResource'); const log4js = require('log4js'); // Création du LOGGER @@ -36,7 +37,8 @@ module.exports = class resourceManager { this._operationsByType = { "osrm": ["nearest", "route"], "pgr": ["route", "isochrone"], - "valhalla": ["route", "isochrone"] + "valhalla": ["route", "isochrone"], + "proxy": ["route", "isochrone"] }; // Manager de source @@ -414,6 +416,8 @@ module.exports = class resourceManager { resource = new pgrResource(resourceJsonObject, resourceOperationHash); } else if (resourceJsonObject.resource.type === "valhalla") { resource = new valhallaResource(resourceJsonObject, resourceOperationHash); + } else if (resourceJsonObject.resource.type === "proxy") { + resource = new proxyResource(resourceJsonObject, resourceOperationHash); } else { LOGGER.error("Type de la ressource inconnue"); return false; diff --git a/src/js/sources/proxyMapper.js b/src/js/sources/proxyMapper.js new file mode 100644 index 0000000..3b26b51 --- /dev/null +++ b/src/js/sources/proxyMapper.js @@ -0,0 +1,234 @@ +'use strict'; + +const fs = require('fs'); + +const SUPPORTED_OPERATIONS = ["route", "isochrone"]; +const SUPPORTED_METHODS = ["GET", "POST"]; +const SUPPORTED_RESPONSE_FORMATS = { + "route": ["osrm"], + "isochrone": ["geojson"] +}; +const DIRECTIVES = ["$for", "$if", "$divide", "$multiply", "$lookup", "$fromEntries", "$concat", "$join"]; + +/** +* +* @function +* @name resolvePath +* @description Résoudre un chemin pointé (ex: "cost.costing" ou "coordinates.0") dans un contexte. +* @param {object} context - Contexte +* @param {string} path - Chemin +* @return {*} undefined si le chemin n'existe pas +* +*/ +function resolvePath(context, path) { + let current = context; + for (const key of path.trim().split(".")) { + if (current === undefined || current === null) { + return undefined; + } + current = current[key]; + } + return current; +} + +function isTruthy(value) { + return value !== undefined && value !== null && value !== false && value !== "" && !(Array.isArray(value) && value.length === 0); +} + +function renderString(str, context) { + // Une chaîne composée uniquement d'un placeholder conserve le type de la valeur + const whole = str.match(/^\$\{([^}]+)\}$/); + if (whole) { + return resolvePath(context, whole[1]); + } + return str.replace(/\$\{([^}]+)\}/g, (match, path) => { + const value = resolvePath(context, path); + return value === undefined || value === null ? "" : String(value); + }); +} + +function renderNumber(node, context) { + const value = render(node, context); + return typeof value === "string" ? Number(value) : value; +} + +/** +* +* @function +* @name render +* @description Évaluer un template de mapping avec un contexte. +* Les valeurs qui s'évaluent à undefined sont omises des objets et tableaux. +* @param {*} node - Template (chaîne, tableau, objet ou valeur scalaire) +* @param {object} context - Variables disponibles pour les placeholders ${...} +* @return {*} +* +*/ +function render(node, context) { + + if (typeof node === "string") { + return renderString(node, context); + } + + if (Array.isArray(node)) { + return node.map((n) => render(n, context)).filter((v) => v !== undefined); + } + + if (node === null || typeof node !== "object") { + return node; + } + + if (node.$for !== undefined) { + const list = resolvePath(context, node.$for); + if (!Array.isArray(list)) { + return undefined; + } + return list.map((item, index) => render(node.$as, {...context, item, index})).filter((v) => v !== undefined); + } + + if (node.$if !== undefined) { + if (isTruthy(resolvePath(context, node.$if))) { + return render(node.$then, context); + } + return node.$else === undefined ? undefined : render(node.$else, context); + } + + if (node.$divide !== undefined) { + return renderNumber(node.$divide[0], context) / renderNumber(node.$divide[1], context); + } + + if (node.$multiply !== undefined) { + return renderNumber(node.$multiply[0], context) * renderNumber(node.$multiply[1], context); + } + + if (node.$lookup !== undefined) { + const key = render(node.$lookup.value, context); + if (key !== undefined && Object.prototype.hasOwnProperty.call(node.$lookup.table, key)) { + return node.$lookup.table[key]; + } + return node.$lookup.default; + } + + if (node.$fromEntries !== undefined) { + const list = resolvePath(context, node.$fromEntries.from); + if (!Array.isArray(list)) { + return undefined; + } + const result = {}; + list.forEach((item, index) => { + const itemContext = {...context, item, index}; + result[render(node.$fromEntries.key, itemContext)] = render(node.$fromEntries.value, itemContext); + }); + return result; + } + + if (node.$concat !== undefined) { + return node.$concat.map((n) => render(n, context)).filter((v) => v !== undefined && v !== null).join(""); + } + + if (node.$join !== undefined) { + const list = resolvePath(context, node.$join.from); + if (!Array.isArray(list)) { + return undefined; + } + return list.map((item, index) => render(node.$join.as, {...context, item, index})).join(node.$join.separator || ","); + } + + const result = {}; + for (const key of Object.keys(node)) { + const value = render(node[key], context); + if (value !== undefined) { + // Les clés peuvent contenir des placeholders (ex: "${cost.costing}") + result[renderString(key, context)] = value; + } + } + return result; + +} + +function findUnknownDirective(node) { + if (Array.isArray(node)) { + for (const n of node) { + const found = findUnknownDirective(n); + if (found) { + return found; + } + } + } else if (node !== null && typeof node === "object") { + for (const key of Object.keys(node)) { + if (key.startsWith("$") && !key.startsWith("${") && !DIRECTIVES.includes(key) && !["$as", "$then", "$else"].includes(key)) { + return key; + } + const found = findUnknownDirective(node[key]); + if (found) { + return found; + } + } + } + return null; +} + +/** +* +* @function +* @name checkMapping +* @description Vérifier la structure d'un mapping. +* @param {object} mapping - Mapping des opérations +* @return {string|null} Message d'erreur ou null si le mapping est valide +* +*/ +function checkMapping(mapping) { + + if (mapping === null || typeof mapping !== "object" || Array.isArray(mapping)) { + return "le mapping n'est pas un objet"; + } + + const operations = Object.keys(mapping); + if (operations.length === 0) { + return "le mapping ne contient aucune opération"; + } + + for (const operation of operations) { + + if (!SUPPORTED_OPERATIONS.includes(operation)) { + return "l'opération '" + operation + "' n'est pas supportée"; + } + + const conf = mapping[operation]; + if (conf === null || typeof conf !== "object") { + return "la configuration de l'opération '" + operation + "' est invalide"; + } + if (typeof conf.path !== "string" && (conf.path === null || typeof conf.path !== "object")) { + return "'" + operation + ".path' absent"; + } + if (conf.method !== undefined && !SUPPORTED_METHODS.includes(conf.method)) { + return "'" + operation + ".method' doit valoir " + SUPPORTED_METHODS.join(" ou "); + } + if (!conf.response || !SUPPORTED_RESPONSE_FORMATS[operation].includes(conf.response.format)) { + return "'" + operation + ".response.format' doit valoir " + SUPPORTED_RESPONSE_FORMATS[operation].join(" ou "); + } + + const unknown = findUnknownDirective([conf.path, conf.query, conf.body]); + if (unknown) { + return "directive inconnue '" + unknown + "' dans l'opération '" + operation + "'"; + } + + } + + return null; + +} + +/** +* +* @function +* @name loadMappingFile +* @description Lire un fichier de mapping JSON. +* @param {string} file - Chemin du fichier +* @return {object} +* +*/ +function loadMappingFile(file) { + return JSON.parse(fs.readFileSync(file)); +} + +module.exports = { render, resolvePath, checkMapping, loadMappingFile, SUPPORTED_OPERATIONS }; diff --git a/src/js/sources/proxyResponseWriter.js b/src/js/sources/proxyResponseWriter.js new file mode 100644 index 0000000..fbd6bcb --- /dev/null +++ b/src/js/sources/proxyResponseWriter.js @@ -0,0 +1,179 @@ +'use strict'; + +const RouteResponse = require('../responses/routeResponse'); +const IsochroneResponse = require('../responses/isochroneResponse'); +const Route = require('../responses/route'); +const Portion = require('../responses/portion'); +const Line = require('../geometry/line'); +const Point = require('../geometry/point'); +const Polygon = require('../geometry/polygon'); +const Step = require('../responses/step'); +const Distance = require('../geography/distance'); +const Duration = require('../time/duration'); +const errorManager = require('../utils/errorManager'); +const turf = require('@turf/turf'); + +/** +* +* @function +* @name writeRouteResponse +* @description Convertir une réponse au format OSRM en RouteResponse. +* @param {RouteRequest} routeRequest - Requête d'origine +* @param {object} osrmResponse - Réponse du moteur au format OSRM +* @param {string} projection - Projection des coordonnées du moteur +* @param {number} precision - Précision des polylines +* @return {RouteResponse} +* +*/ +function writeRouteResponse(routeRequest, osrmResponse, projection, precision) { + + if (!osrmResponse || !Array.isArray(osrmResponse.waypoints) || osrmResponse.waypoints.length < 2) { + throw errorManager.createError(" Proxy response is invalid: the number of waypoints is lower than 2. "); + } + + if (!Array.isArray(osrmResponse.routes) || osrmResponse.routes.length === 0) { + throw errorManager.createError(" No route found ", 404); + } + + const askedProjection = routeRequest.start.projection; + const waypoints = osrmResponse.waypoints; + + const toPoint = (waypoint) => { + const point = new Point(waypoint.location[0], waypoint.location[1], projection); + if (!point.transform(askedProjection)) { + throw errorManager.createError(" Error during reprojection of a point in proxy response. "); + } + return point; + }; + + const routeResponse = new RouteResponse(routeRequest.resource, toPoint(waypoints[0]), toPoint(waypoints[waypoints.length - 1]), routeRequest.profile, routeRequest.optimization); + + const routes = []; + + for (let i = 0; i < osrmResponse.routes.length; i++) { + + const current = osrmResponse.routes[i]; + + routes[i] = new Route(new Line(current.geometry, "polyline", projection, precision)); + if (!routes[i].geometry.transform(askedProjection)) { + throw errorManager.createError(" Error during reprojection of geometry in proxy response. "); + } + + routes[i].distance = new Distance(current.distance, "meter"); + routes[i].duration = new Duration(current.duration, "second"); + + if (current.legs.length !== waypoints.length - 1) { + throw errorManager.createError(" Proxy response is invalid: the number of legs is not proportionnal to the number of waypoints. "); + } + + const portions = []; + + for (let j = 0; j < current.legs.length; j++) { + + const leg = current.legs[j]; + + portions[j] = new Portion(toPoint(waypoints[j]), toPoint(waypoints[j + 1])); + portions[j].distance = new Distance(leg.distance, "meter"); + portions[j].duration = new Duration(leg.duration, "second"); + + const steps = []; + const legSteps = leg.steps || []; + + for (let k = 0; k < legSteps.length; k++) { + + const legStep = legSteps[k]; + + steps[k] = new Step(new Line(legStep.geometry, "polyline", projection, precision)); + if (!steps[k].geometry.transform(askedProjection)) { + throw errorManager.createError(" Error during reprojection of step's geometry in proxy response. "); + } + + let nameAttributs; + try { + nameAttributs = JSON.parse(legStep.name); + } catch (error) { + nameAttributs = legStep.name; + } + steps[k].setAttributById("name", nameAttributs); + + steps[k].distance = new Distance(legStep.distance, "meter"); + steps[k].duration = new Duration(legStep.duration, "second"); + + steps[k].instruction = {}; + steps[k].instruction.type = legStep.maneuver.type; + if (legStep.maneuver.modifier) { + steps[k].instruction.modifier = legStep.maneuver.modifier; + } + if (legStep.maneuver.exit) { + steps[k].instruction.exit = legStep.maneuver.exit; + } + } + + portions[j].steps = steps; + + } + + routes[i].portions = portions; + + } + + routeResponse.routes = routes; + + return routeResponse; + +} + +/** +* +* @function +* @name writeIsochroneResponse +* @description Convertir une réponse GeoJSON en IsochroneResponse. +* @param {IsochroneRequest} isochroneRequest - Requête d'origine +* @param {object} geojsonResponse - Réponse du moteur au format GeoJSON (FeatureCollection) +* @param {string} projection - Projection des coordonnées du moteur +* @return {IsochroneResponse} +* +*/ +function writeIsochroneResponse(isochroneRequest, geojsonResponse, projection) { + + if (!geojsonResponse || !Array.isArray(geojsonResponse.features) || geojsonResponse.features.length === 0) { + throw errorManager.createError(" No data found ", 404); + } + + const askedProjection = isochroneRequest.point.projection; + + const point = isochroneRequest.point; + if (!point.transform(askedProjection)) { + throw errorManager.createError(" Error during reprojection of point in proxy response"); + } + + const rawGeometry = geojsonResponse.features[0].geometry; + let geometry; + + if (rawGeometry === null) { + // Pas d'isochrone, par exemple si costValue est trop faible + geometry = new Polygon({type: 'Point', coordinates: [point.x, point.y]}, "geojson", askedProjection); + } else { + turf.cleanCoords(rawGeometry, {mutate: true}); + geometry = new Polygon(rawGeometry, "geojson", projection); + if (!geometry.transform(askedProjection)) { + throw errorManager.createError(" Error during reprojection of geometry in proxy response"); + } + } + + return new IsochroneResponse( + point, + isochroneRequest.resource, + isochroneRequest.costType, + isochroneRequest.costValue, + geometry, + isochroneRequest.profile, + isochroneRequest.direction, + isochroneRequest.askedProjection, + isochroneRequest.timeUnit, + isochroneRequest.distanceUnit + ); + +} + +module.exports = { writeRouteResponse, writeIsochroneResponse }; diff --git a/src/js/sources/proxySource.js b/src/js/sources/proxySource.js new file mode 100644 index 0000000..fe75ceb --- /dev/null +++ b/src/js/sources/proxySource.js @@ -0,0 +1,238 @@ +'use strict'; + +const Source = require('./source'); +const proxyMapper = require('./proxyMapper'); +const responseWriter = require('./proxyResponseWriter'); +const httpQuery = require('../utils/httpQuery'); +const errorManager = require('../utils/errorManager'); + +const log4js = require('log4js'); +const LOGGER = log4js.getLogger("PROXYSOURCE"); + +const DEFAULT_TIMEOUT = 30000; + +/** +* +* @class +* @name proxySource +* @description Classe modélisant une source qui délègue le calcul à un service HTTP distant. +* Les paramètres de l'API Road2 sont traduits grâce à un mapping configurable. +* +*/ +module.exports = class proxySource extends Source { + + /** + * + * @function + * @name constructor + * @description Constructeur de la classe proxySource + * @param {json} sourceJsonObject - Description de la source + * + */ + constructor(sourceJsonObject) { + + super(sourceJsonObject.id, "proxy", sourceJsonObject.description, sourceJsonObject.projection, sourceJsonObject.bbox); + + this._configuration = sourceJsonObject; + + this._url = sourceJsonObject.proxy.url.replace(/\/+$/, ""); + this._timeout = sourceJsonObject.proxy.timeout || DEFAULT_TIMEOUT; + + // Le mapping peut être donné en ligne ou par un chemin de fichier + const mapping = sourceJsonObject.proxy.mapping; + this._mapping = typeof mapping === "string" ? proxyMapper.loadMappingFile(mapping) : mapping; + + this._httpQuery = new httpQuery(); + + // Correspondance profile/optimization et profile/costType vers la configuration du coût + this._costs = {}; + for (const cost of sourceJsonObject.costs) { + this._costs[cost.profile + "|" + cost.optimization] = cost; + this._costs[cost.profile + "|" + cost.costType] = cost; + } + + } + + get configuration() { + return this._configuration; + } + + set configuration(conf) { + this._configuration = conf; + } + + /** + * + * @function + * @name isOperationMapped + * @description Savoir si le mapping gère une opération + * @param {string} operation - Id de l'opération + * @return {boolean} + * + */ + isOperationMapped(operation) { + return !!this._mapping[operation]; + } + + async connect() { + this._connected = true; + } + + async disconnect() { + this._connected = false; + } + + /** + * + * @function + * @name computeRequest + * @description Traiter une requête en la traduisant pour le service distant. + * @param {Request} request - Objet Request ou dérivant de la classe Request + * @return {Promise} + * + */ + computeRequest(request) { + + LOGGER.debug("computeRequest()"); + + const operation = request.operation; + + if (!this.isOperationMapped(operation)) { + return Promise.reject(errorManager.createError(" Operation not available for this source ", 400)); + } + + const operationMapping = this._mapping[operation]; + + let context; + try { + context = this.buildContext(request); + } catch (error) { + return Promise.reject(error); + } + + const method = operationMapping.method || "POST"; + const url = this._url + proxyMapper.render(operationMapping.path, context); + + const options = { + responseType: "json", + resolveBodyOnly: true, + timeout: { request: this._timeout } + }; + + if (operationMapping.headers) { + options.headers = proxyMapper.render(operationMapping.headers, context); + } + if (operationMapping.query) { + options.searchParams = this.toSearchParams(proxyMapper.render(operationMapping.query, context)); + } + + let call; + if (method === "GET") { + call = this._httpQuery.get(url, options); + } else { + if (operationMapping.body) { + options.json = proxyMapper.render(operationMapping.body, context); + } + call = this._httpQuery.post(url, options); + } + + LOGGER.info(method + " " + url); + + return call.then((body) => { + + this.state = "green"; + LOGGER.debug(body); + + let engineResponse = body; + if (operationMapping.response.root) { + engineResponse = proxyMapper.resolvePath(body, operationMapping.response.root); + } + + if (operation === "route") { + return responseWriter.writeRouteResponse(request, engineResponse, this.projection, operationMapping.response.polylinePrecision || 5); + } + return responseWriter.writeIsochroneResponse(request, engineResponse, this.projection); + + }, (error) => { + + LOGGER.error("proxy error for " + operation + " :"); + LOGGER.error(error); + + if (error && error.response) { + // Le service a répondu : il est joignable mais n'a pas pu traiter la requête + this.state = "green"; + return Promise.reject(errorManager.createError(" No path found ", 404)); + } + + this.state = "red"; + return Promise.reject("Internal PROXY error"); + + }); + + } + + /** + * + * @function + * @name buildContext + * @description Construire les variables disponibles dans le mapping à partir de la requête. + * @param {Request} request - Objet Request ou dérivant de la classe Request + * @return {object} + * + */ + buildContext(request) { + + const cost = this._costs[request.profile + "|" + (request.operation === "route" ? request.optimization : request.costType)]; + + const context = { + resource: request.resource, + operation: request.operation, + profile: request.profile, + projection: this.projection, + date_time: request.date_time, + cost: cost && cost.parameters ? cost.parameters : {}, + constraints: (request.constraints || []).filter((c) => c.type === "banned").map((c) => c.field) + }; + + if (request.operation === "route") { + + const intermediates = (request.intermediates || []).map((p) => p.getCoordinatesIn(this.projection)); + + context.optimization = request.optimization; + context.start = request.start.getCoordinatesIn(this.projection); + context.end = request.end.getCoordinatesIn(this.projection); + context.intermediates = intermediates; + context.coordinates = [context.start, ...intermediates, context.end]; + + } else { + + context.costType = request.costType; + context.costValue = request.costValue; + context.direction = request.direction; + context.point = request.point.getCoordinatesIn(this.projection); + + } + + return context; + + } + + /** + * + * @function + * @name toSearchParams + * @description Convertir un objet en paramètres d'URL (les tableaux sont joints par des virgules). + * @param {object} query - Paramètres + * @return {object} + * + */ + toSearchParams(query) { + const params = {}; + for (const key of Object.keys(query)) { + const value = query[key]; + params[key] = Array.isArray(value) ? value.join(",") : (typeof value === "object" ? JSON.stringify(value) : value); + } + return params; + } + +} diff --git a/src/js/sources/sourceManager.js b/src/js/sources/sourceManager.js index f78ace2..73d38ed 100644 --- a/src/js/sources/sourceManager.js +++ b/src/js/sources/sourceManager.js @@ -6,6 +6,8 @@ const assert = require('assert').strict; const osrmSource = require('../sources/osrmSource'); const pgrSource = require('../sources/pgrSource'); const valhallaSource = require('../sources/valhallaSource'); +const proxySource = require('../sources/proxySource'); +const proxyMapper = require('../sources/proxyMapper'); const log4js = require('log4js'); // Création du LOGGER @@ -49,7 +51,8 @@ module.exports = class sourceManager { this._operationsByType = { "osrm": ["nearest", "route"], "pgr": ["route", "isochrone"], - "valhalla": ["route", "isochrone"] + "valhalla": ["route", "isochrone"], + "proxy": ["route", "isochrone"] }; } @@ -350,6 +353,8 @@ module.exports = class sourceManager { validation = await this.checkSourcePgr(sourceJsonObject); } else if (sourceJsonObject.type === "valhalla") { validation = this.checkSourceValhalla(sourceJsonObject); + } else if (sourceJsonObject.type === "proxy") { + validation = this.checkSourceProxy(sourceJsonObject); } else { LOGGER.error("La source indique un type invalide : " + sourceJsonObject.type); return false; @@ -740,6 +745,79 @@ module.exports = class sourceManager { } + /** + * + * @function + * @name checkSourceProxy + * @description Fonction utilisée pour vérifier le contenu d'un fichier de description d'une source proxy. + * @param {json} sourceJsonObject - Description JSON de la source + * @return {boolean} + * + */ + checkSourceProxy(sourceJsonObject) { + + LOGGER.info("Verification de la source proxy..."); + + const proxy = sourceJsonObject.proxy; + + if (!proxy) { + LOGGER.error("Mauvaise configuration : 'source.proxy' absent"); + return false; + } + + if (typeof proxy.url !== "string" || !/^https?:\/\/.+/.test(proxy.url)) { + LOGGER.error("Mauvaise configuration : 'source.proxy.url' absent ou n'est pas une url http(s)"); + return false; + } + + if (proxy.timeout !== undefined && (!Number.isInteger(proxy.timeout) || proxy.timeout <= 0)) { + LOGGER.error("Mauvaise configuration : 'source.proxy.timeout' doit être un entier positif (ms)"); + return false; + } + + if (!proxy.mapping) { + LOGGER.error("Mauvaise configuration : 'source.proxy.mapping' absent"); + return false; + } + + let mapping = proxy.mapping; + if (typeof mapping === "string") { + try { + mapping = proxyMapper.loadMappingFile(mapping); + } catch (error) { + LOGGER.error("Impossible de lire le mapping " + proxy.mapping + " : " + error); + return false; + } + } + + const mappingError = proxyMapper.checkMapping(mapping); + if (mappingError) { + LOGGER.error("Mauvaise configuration du mapping : " + mappingError); + return false; + } + + if (!Array.isArray(sourceJsonObject.costs) || sourceJsonObject.costs.length === 0) { + LOGGER.error("Mauvaise configuration : 'source.costs' absent, n'est pas un tableau ou est vide"); + return false; + } + + for (let i = 0; i < sourceJsonObject.costs.length; i++) { + const cost = sourceJsonObject.costs[i]; + if (!cost.profile || !cost.optimization || !cost.costType) { + LOGGER.error("Mauvaise configuration : 'source.costs[" + i + "]' doit contenir profile, optimization et costType"); + return false; + } + if (cost.parameters !== undefined && (cost.parameters === null || typeof cost.parameters !== "object")) { + LOGGER.error("Mauvaise configuration : 'source.costs[" + i + "].parameters' doit être un objet"); + return false; + } + } + + LOGGER.info("Fin de la verification de la source proxy."); + return true; + + } + /** * * @function @@ -914,6 +992,8 @@ module.exports = class sourceManager { } else if (sourceJsonObject.type === "valhalla") { source = new valhallaSource(sourceJsonObject); + } else if (sourceJsonObject.type === "proxy") { + source = new proxySource(sourceJsonObject); } else { LOGGER.error("Le type de la source est inconnu"); return false; diff --git a/test/integration/mocha/sources/integrationProxySource.js b/test/integration/mocha/sources/integrationProxySource.js new file mode 100644 index 0000000..08220c5 --- /dev/null +++ b/test/integration/mocha/sources/integrationProxySource.js @@ -0,0 +1,73 @@ +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const sinon = require('sinon'); +const proxySource = require('../../../../src/js/sources/proxySource'); +const RouteRequest = require('../../../../src/js/requests/routeRequest'); +const logManager = require('../logManager'); +const mapping = JSON.parse(fs.readFileSync(path.join(__dirname, '../../../../documentation/configuration/sources/valhalla-proxy.mapping'))); + +describe('Test de la classe proxySource', function() { + + before(function() { + logManager.manageLogs(); + }); + + const sourceDescription = { + "id": "proxy-test", + "type": "proxy", + "projection": "EPSG:4326", + "bbox": "-180,-90,180,90", + "proxy": { "url": "http://valhalla:8002/", "timeout": 5000, "mapping": mapping }, + "costs": [{ "profile": "car", "optimization": "fastest", "costType": "time", "parameters": { "costing": "auto", "contourDivisor": 60 } }] + }; + + let source; + beforeEach(function() { + source = new proxySource(sourceDescription); + }); + + it('getters', function() { + assert.equal(source.id, "proxy-test"); + assert.equal(source.type, "proxy"); + assert.equal(source.connected, false); + assert.equal(source.isOperationMapped("route"), true); + assert.equal(source.isOperationMapped("nearest"), false); + }); + + it('buildContext() pour un itinéraire', function() { + const start = { getCoordinatesIn() { return [8.73, 41.92]; } }; + const end = { getCoordinatesIn() { return [8.76, 41.95]; } }; + const request = new RouteRequest("res", start, end, "car", "fastest"); + const context = source.buildContext(request); + assert.deepStrictEqual(context.coordinates, [[8.73, 41.92], [8.76, 41.95]]); + assert.deepStrictEqual(context.cost, sourceDescription.costs[0].parameters); + }); + + it('computeRequest() envoie le corps traduit', async function() { + const start = { getCoordinatesIn() { return [8.73, 41.92]; } }; + const end = { getCoordinatesIn() { return [8.76, 41.95]; } }; + const request = new RouteRequest("res", start, end, "car", "fastest"); + + source._httpQuery.post = sinon.stub().resolves({ waypoints: [] }); + // La réponse invalide déclenche une erreur, mais la requête sortante est celle qui nous intéresse + await source.computeRequest(request).catch(() => {}); + + const [url, options] = source._httpQuery.post.firstCall.args; + assert.equal(url, "http://valhalla:8002/route"); + assert.deepStrictEqual(options.json.locations, [{ lat: 41.92, lon: 8.73 }, { lat: 41.95, lon: 8.76 }]); + assert.equal(options.json.costing, "auto"); + assert.equal(options.json.date_time, undefined); + }); + + it('computeRequest() passe la source en red si le service est injoignable', async function() { + const start = { getCoordinatesIn() { return [8.73, 41.92]; } }; + const end = { getCoordinatesIn() { return [8.76, 41.95]; } }; + const request = new RouteRequest("res", start, end, "car", "fastest"); + + source._httpQuery.post = sinon.stub().rejects(new Error("ECONNREFUSED")); + await assert.rejects(source.computeRequest(request)); + assert.equal(source.state, "red"); + }); + +}); diff --git a/test/unit/mocha/sources/testsProxyMapper.js b/test/unit/mocha/sources/testsProxyMapper.js new file mode 100644 index 0000000..962ac75 --- /dev/null +++ b/test/unit/mocha/sources/testsProxyMapper.js @@ -0,0 +1,90 @@ +const assert = require('assert'); +const proxyMapper = require('../../../../src/js/sources/proxyMapper'); + +describe('Test de proxyMapper', function() { + + const context = { + profile: "car", + coordinates: [[2.1, 48.1], [2.2, 48.2]], + costValue: 600, + date_time: undefined, + cost: { costing: "auto" }, + constraints: ["toll"], + direction: "arrival" + }; + + describe('render()', function() { + + it('conserve le type pour un placeholder seul', function() { + assert.strictEqual(proxyMapper.render("${costValue}", context), 600); + }); + + it('interpole une chaîne', function() { + assert.strictEqual(proxyMapper.render("/route/${profile}", context), "/route/car"); + }); + + it('omet les valeurs indéfinies et rend les clés', function() { + const result = proxyMapper.render({ a: "${date_time}", "${cost.costing}": { b: 1 } }, context); + assert.deepStrictEqual(result, { auto: { b: 1 } }); + }); + + it('$for', function() { + const result = proxyMapper.render({ $for: "coordinates", $as: { lat: "${item.1}", lon: "${item.0}" } }, context); + assert.deepStrictEqual(result, [{ lat: 48.1, lon: 2.1 }, { lat: 48.2, lon: 2.2 }]); + }); + + it('$if avec et sans valeur', function() { + assert.deepStrictEqual(proxyMapper.render({ $if: "profile", $then: "yes", $else: "no" }, context), "yes"); + assert.deepStrictEqual(proxyMapper.render({ x: { $if: "date_time", $then: 1 } }, context), {}); + }); + + it('$divide et $multiply', function() { + assert.strictEqual(proxyMapper.render({ $divide: ["${costValue}", 60] }, context), 10); + assert.strictEqual(proxyMapper.render({ $multiply: ["${costValue}", 2] }, context), 1200); + }); + + it('$lookup', function() { + const node = { $lookup: { value: "${direction}", table: { arrival: true }, default: false } }; + assert.strictEqual(proxyMapper.render(node, context), true); + assert.strictEqual(proxyMapper.render(node, { direction: "departure" }), false); + }); + + it('$concat et $join', function() { + const node = { $concat: ["/route/", "${profile}", "/", { $join: { from: "coordinates", separator: ";", as: "${item.0},${item.1}" } }] }; + assert.strictEqual(proxyMapper.render(node, context), "/route/car/2.1,48.1;2.2,48.2"); + }); + + it('$fromEntries', function() { + const result = proxyMapper.render({ $fromEntries: { from: "constraints", key: "${item}", value: "1" } }, context); + assert.deepStrictEqual(result, { toll: "1" }); + }); + + }); + + describe('checkMapping()', function() { + + const valid = { route: { path: "/route", response: { format: "osrm" } } }; + + it('mapping valide', function() { + assert.strictEqual(proxyMapper.checkMapping(valid), null); + }); + + it('opération inconnue', function() { + assert.notStrictEqual(proxyMapper.checkMapping({ nearest: valid.route }), null); + }); + + it('path absent', function() { + assert.notStrictEqual(proxyMapper.checkMapping({ route: { response: { format: "osrm" } } }), null); + }); + + it('format de réponse invalide', function() { + assert.notStrictEqual(proxyMapper.checkMapping({ route: { path: "/r", response: { format: "geojson" } } }), null); + }); + + it('directive inconnue', function() { + assert.notStrictEqual(proxyMapper.checkMapping({ route: { path: "/r", body: { $foo: 1 }, response: { format: "osrm" } } }), null); + }); + + }); + +});