From 3e4e94e5b87f48c64ec4461a9d73d33b1f9357b9 Mon Sep 17 00:00:00 2001 From: Steph Ponzo Date: Fri, 27 Feb 2026 11:08:16 +0100 Subject: [PATCH] =?utf8?q?feat(microservices):=20ajout=20gateway=20+=20d?= =?utf8?q?=C3=A9pendances=20mises=20=C3=A0=20jour?= MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit --- Wallette/gateway/gateway.js | 239 +++++++++++++++++++++++++++++ Wallette/gateway/package-lock.json | 27 ++++ Wallette/gateway/package.json | 14 ++ Wallette/server/package-lock.json | 111 ++++++++++++++ Wallette/server/package.json | 1 + 5 files changed, 392 insertions(+) create mode 100644 Wallette/gateway/gateway.js create mode 100644 Wallette/gateway/package-lock.json create mode 100644 Wallette/gateway/package.json diff --git a/Wallette/gateway/gateway.js b/Wallette/gateway/gateway.js new file mode 100644 index 0000000..239035d --- /dev/null +++ b/Wallette/gateway/gateway.js @@ -0,0 +1,239 @@ +// ========================================================= +// API GATEWAY - Wall-e-tte +// ========================================================= +// Point d'entrée unique pour le frontend. +// +// USAGE : node gateway.js (depuis Wallette/gateway/) +// PORT : process.env.GATEWAY_PORT || process.env.PORT || 3000 +// +// ROUTING TABLE : +// /api/price/* → PRICE_BASE_URL (défaut: http://127.0.0.1:3001) +// /api/alerts/* → ALERTS_BASE_URL (défaut: http://127.0.0.1:3003) +// /api/strategy/* → STRATEGY_BASE_URL (défaut: http://127.0.0.1:3002) +// +// PATH REWRITE : +// Les services exposent leurs routes avec le préfixe complet /api/* +// Le gateway transmet donc le path COMPLET tel quel. +// Ex: GET /api/price/current → http://127.0.0.1:3001/api/price/current +// ========================================================= + +import dotenv from 'dotenv'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import http from 'http'; +import https from 'https'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +dotenv.config({ path: path.resolve(__dirname, '../server/.env') }); + +// ========================================================= +// CONFIGURATION +// ========================================================= + +const GATEWAY_PORT = process.env.GATEWAY_PORT || process.env.PORT || 3000; + +const UPSTREAMS = { + '/api/price': process.env.PRICE_BASE_URL || 'http://127.0.0.1:3001', + '/api/alerts': process.env.ALERTS_BASE_URL || 'http://127.0.0.1:3003', + '/api/strategy': process.env.STRATEGY_BASE_URL || 'http://127.0.0.1:3002', +}; + +const PROXY_TIMEOUT_MS = 5000; + +// ========================================================= +// SERVEUR HTTP NATIF (Node >= 18, pas de dépendances lourdes) +// ========================================================= + +const server = http.createServer(async (req, res) => { + const url = req.url || '/'; + + // ------------------------------------------------------- + // ROUTES INTERNES DU GATEWAY + // ------------------------------------------------------- + + // GET /api/gateway/routes → table de routing (dev) + if (req.method === 'GET' && url === '/api/gateway/routes') { + return sendJSON(res, 200, { + ok: true, + data: { + gateway_port: GATEWAY_PORT, + path_rewrite: 'FULL PATH (path transmis tel quel aux services)', + routes: Object.entries(UPSTREAMS).map(([prefix, upstream]) => ({ + prefix, + upstream, + example: `${prefix}/... → ${upstream}${prefix}/...` + })) + } + }); + } + + // GET /api/gateway/health → ping des services upstream + if (req.method === 'GET' && url === '/api/gateway/health') { + const checks = await Promise.all( + Object.entries(UPSTREAMS).map(async ([prefix, upstream]) => { + const healthUrl = `${upstream}/health`; + try { + const result = await proxyFetch('GET', healthUrl, null, 2000); + return { prefix, upstream, status: result.status, ok: result.status < 400 }; + } catch (e) { + return { prefix, upstream, status: 'DOWN', ok: false, error: e.message }; + } + }) + ); + const allOk = checks.every(c => c.ok); + return sendJSON(res, allOk ? 200 : 207, { ok: allOk, data: { services: checks } }); + } + + // ------------------------------------------------------- + // PROXY VERS LES SERVICES + // ------------------------------------------------------- + + // Trouver quel upstream correspond au path + const matchedPrefix = Object.keys(UPSTREAMS).find(prefix => url.startsWith(prefix)); + + if (!matchedPrefix) { + return sendJSON(res, 404, { ok: false, error: { code: 'NOT_FOUND', message: `No route for ${url}` } }); + } + + const upstreamBase = UPSTREAMS[matchedPrefix]; + + // PATH REWRITE : on garde le path complet (les services exposent /api/...) + const targetUrl = upstreamBase + url; + + // Lire le body pour les méthodes non-GET/HEAD + let body = null; + if (!['GET', 'HEAD'].includes(req.method)) { + body = await readBody(req); + } + + console.log(`[gateway] ${req.method} ${url} → ${targetUrl}`); + + try { + const result = await proxyFetch(req.method, targetUrl, body, PROXY_TIMEOUT_MS, req.headers); + + // Retransmettre le statut et le body au client + res.writeHead(result.status, { 'Content-Type': 'application/json' }); + res.end(result.body); + + console.log(`[gateway] ${req.method} ${url} → ${targetUrl} (${result.status})`); + } catch (err) { + // Service upstream injoignable → 502 + const serviceName = matchedPrefix.replace('/api/', '') + '-service'; + console.error(`[gateway] UPSTREAM_DOWN: ${targetUrl} — ${err.message}`); + return sendJSON(res, 502, { + ok: false, + error: { + code: 'UPSTREAM_DOWN', + message: `${serviceName} unavailable` + } + }); + } +}); + +// ========================================================= +// HELPERS +// ========================================================= + +/** + * Lit le body d'une requête entrante et retourne un Buffer + */ +function readBody(req) { + return new Promise((resolve, reject) => { + const chunks = []; + req.on('data', chunk => chunks.push(chunk)); + req.on('end', () => resolve(Buffer.concat(chunks))); + req.on('error', reject); + }); +} + +/** + * Envoie une requête HTTP vers un upstream + * @param {string} method + * @param {string} url + * @param {Buffer|null} body + * @param {number} timeoutMs + * @param {object} [incomingHeaders] + * @returns {Promise<{status: number, body: string}>} + */ +function proxyFetch(method, url, body, timeoutMs = PROXY_TIMEOUT_MS, incomingHeaders = {}) { + return new Promise((resolve, reject) => { + const parsed = new URL(url); + const isHttps = parsed.protocol === 'https:'; + const lib = isHttps ? https : http; + + // Headers à transmettre (on filtre les headers problématiques) + const headers = { + 'Content-Type': 'application/json', + }; + if (incomingHeaders['authorization']) headers['Authorization'] = incomingHeaders['authorization']; + if (body && body.length > 0) headers['Content-Length'] = body.length; + + const options = { + hostname: parsed.hostname, + port: parsed.port || (isHttps ? 443 : 80), + path: parsed.pathname + parsed.search, + method, + headers, + timeout: timeoutMs, + }; + + const proxyReq = lib.request(options, (proxyRes) => { + const chunks = []; + proxyRes.on('data', chunk => chunks.push(chunk)); + proxyRes.on('end', () => { + resolve({ + status: proxyRes.statusCode, + body: Buffer.concat(chunks).toString('utf8') + }); + }); + }); + + proxyReq.on('timeout', () => { + proxyReq.destroy(); + reject(new Error(`Timeout after ${timeoutMs}ms`)); + }); + + proxyReq.on('error', reject); + + if (body && body.length > 0) { + proxyReq.write(body); + } + + proxyReq.end(); + }); +} + +/** + * Envoie une réponse JSON + */ +function sendJSON(res, status, data) { + const body = JSON.stringify(data); + res.writeHead(status, { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body) + }); + res.end(body); +} + +// ========================================================= +// DÉMARRAGE +// ========================================================= + +server.listen(GATEWAY_PORT, () => { + console.log(` +╔══════════════════════════════════════════════════════════╗ +║ API GATEWAY - Wall-e-tte ║ +╠══════════════════════════════════════════════════════════╣ +║ Écoute sur : http://localhost:${GATEWAY_PORT} ║ +║ ║ +║ ROUTING : ║ +║ /api/price/* → ${UPSTREAMS['/api/price']} ║ +║ /api/alerts/* → ${UPSTREAMS['/api/alerts']} ║ +║ /api/strategy/* → ${UPSTREAMS['/api/strategy']} ║ +║ ║ +║ ENDPOINTS GATEWAY : ║ +║ GET /api/gateway/routes → table de routing ║ +║ GET /api/gateway/health → état des services ║ +╚══════════════════════════════════════════════════════════╝`); +}); diff --git a/Wallette/gateway/package-lock.json b/Wallette/gateway/package-lock.json new file mode 100644 index 0000000..3537176 --- /dev/null +++ b/Wallette/gateway/package-lock.json @@ -0,0 +1,27 @@ +{ + "name": "wallette-gateway", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "wallette-gateway", + "version": "1.0.0", + "dependencies": { + "dotenv": "^16.4.5" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + } + } +} diff --git a/Wallette/gateway/package.json b/Wallette/gateway/package.json new file mode 100644 index 0000000..389391a --- /dev/null +++ b/Wallette/gateway/package.json @@ -0,0 +1,14 @@ +{ + "name": "wallette-gateway", + "version": "1.0.0", + "description": "API Gateway Wallette - Reverse proxy vers les micro-services", + "type": "module", + "main": "gateway.js", + "scripts": { + "start": "node gateway.js", + "dev": "node --watch gateway.js" + }, + "dependencies": { + "dotenv": "^16.4.5" + } +} diff --git a/Wallette/server/package-lock.json b/Wallette/server/package-lock.json index 4ca2e4c..b61ad79 100644 --- a/Wallette/server/package-lock.json +++ b/Wallette/server/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "axios": "^1.13.5", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.18.2", @@ -61,6 +62,12 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, "node_modules/aws-ssl-profiles": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", @@ -70,6 +77,17 @@ "node": ">= 6.0.0" } }, + "node_modules/axios": { + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/base64id": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", @@ -141,6 +159,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -203,6 +233,15 @@ "ms": "2.0.0" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/denque": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", @@ -354,6 +393,21 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -433,6 +487,42 @@ "node": ">= 0.8" } }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -530,6 +620,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -818,6 +923,12 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/qs": { "version": "6.14.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", diff --git a/Wallette/server/package.json b/Wallette/server/package.json index 53e6e86..a342bc8 100644 --- a/Wallette/server/package.json +++ b/Wallette/server/package.json @@ -21,6 +21,7 @@ "author": "Équipe Wall-e-tte", "license": "ISC", "dependencies": { + "axios": "^1.13.5", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.18.2", -- 2.50.1