From a8ea8d2a0e04416d88fbba297f225b9bbd0777e0 Mon Sep 17 00:00:00 2001 From: Pierre-Gilles Leymarie Date: Mon, 2 Sep 2024 16:52:28 +0200 Subject: [PATCH] Add RTE Tempo API (#166) --- .eslintrc.json | 3 +- core/api/routes.js | 3 + core/api/tempo/tempo.controller.js | 18 ++ core/api/tempo/tempo.model.js | 118 +++++++++++++ core/index.js | 4 + test/core/api/tempo/tempo.controller.test.js | 169 +++++++++++++++++++ 6 files changed, 314 insertions(+), 1 deletion(-) create mode 100644 core/api/tempo/tempo.controller.js create mode 100644 core/api/tempo/tempo.model.js create mode 100644 test/core/api/tempo/tempo.controller.test.js diff --git a/.eslintrc.json b/.eslintrc.json index 6cb53d8..9c4ff9a 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -88,6 +88,7 @@ { "allowSingleLine": false } - ] + ], + "newline-per-chained-call": "off" } } diff --git a/core/api/routes.js b/core/api/routes.js index 0975d81..5e9802e 100644 --- a/core/api/routes.js +++ b/core/api/routes.js @@ -53,6 +53,9 @@ module.exports.load = function Routes(app, io, controllers, middlewares) { // ecowatt api app.get('/ecowatt/v4/signals', asyncMiddleware(controllers.ecowattController.getEcowattSignals)); + // EDF tempo api + app.get('/edf/tempo/today', asyncMiddleware(controllers.tempoController.getTempoToday)); + // OpenAI ask app.post( '/openai/ask', diff --git a/core/api/tempo/tempo.controller.js b/core/api/tempo/tempo.controller.js new file mode 100644 index 0000000..3509250 --- /dev/null +++ b/core/api/tempo/tempo.controller.js @@ -0,0 +1,18 @@ +module.exports = function EcowattController(logger, tempoModel) { + /** + * @api {get} /edf/tempo/today Get tempo data today + * @apiName Get tempo data + * @apiGroup Tempo + */ + async function getTempoToday(req, res) { + logger.info(`Tempo.getDataToday`); + const response = await tempoModel.getDataWithRetry(); + const cachePeriodInSecond = 60 * 60; + res.set('Cache-control', `public, max-age=${cachePeriodInSecond}`); + res.json(response); + } + + return { + getTempoToday, + }; +}; diff --git a/core/api/tempo/tempo.model.js b/core/api/tempo/tempo.model.js new file mode 100644 index 0000000..aa57cd7 --- /dev/null +++ b/core/api/tempo/tempo.model.js @@ -0,0 +1,118 @@ +const axios = require('axios'); +const dayjs = require('dayjs'); +const retry = require('async-retry'); + +const utc = require('dayjs/plugin/utc'); +const timezone = require('dayjs/plugin/timezone'); +const customParseFormat = require('dayjs/plugin/customParseFormat'); + +dayjs.extend(utc); +dayjs.extend(timezone); +dayjs.extend(customParseFormat); + +const TEMPO_CACHE_KEY = 'tempo'; +const TEMPO_REDIS_EXPIRY_IN_SECONDS = 5 * 24 * 60 * 60; // 5 days + +module.exports = function TempoModel(logger, redisClient) { + // the key is the same as ecowatt + const { ECOWATT_BASIC_HTTP } = process.env; + + async function getDataFromCache(date) { + return redisClient.get(`${TEMPO_CACHE_KEY}:${date}`); + } + + async function getDataLiveOrFromCache() { + const todayStartDate = dayjs().tz('Europe/Paris').startOf('day').format('YYYY-MM-DDTHH:mm:ssZ'); + const tomorrowStartDate = dayjs().tz('Europe/Paris').add(1, 'day').startOf('day').format('YYYY-MM-DDTHH:mm:ssZ'); + const tomorrowEndDate = dayjs().tz('Europe/Paris').add(2, 'day').startOf('day').format('YYYY-MM-DDTHH:mm:ssZ'); + + // Get today data from cache + let todayData = await getDataFromCache(todayStartDate); + let tomorrowData = await getDataFromCache(tomorrowStartDate); + + let accessToken; + + if (!todayData || !tomorrowData) { + // Get new access token + const { data: dataToken } = await axios.post('https://digital.iservices.rte-france.com/token/oauth/', null, { + headers: { + authorization: `Basic ${ECOWATT_BASIC_HTTP}`, + }, + }); + accessToken = dataToken.access_token; + } + + if (!todayData) { + try { + const { data: todayLiveData } = await axios.get( + 'https://digital.iservices.rte-france.com/open_api/tempo_like_supply_contract/v1/tempo_like_calendars', + { + params: { + start_date: todayStartDate, + end_date: tomorrowStartDate, + }, + headers: { + authorization: `Bearer ${accessToken}`, + }, + }, + ); + todayData = todayLiveData.tempo_like_calendars.values[0].value.toLowerCase(); + // Set cache + await redisClient.set(`${TEMPO_CACHE_KEY}:${todayStartDate}`, todayData, { + EX: TEMPO_REDIS_EXPIRY_IN_SECONDS, + }); + } catch (e) { + logger.debug(e); + todayData = 'unknown'; + } + } + + if (!tomorrowData) { + try { + const { data: tomorrowLiveData } = await axios.get( + 'https://digital.iservices.rte-france.com/open_api/tempo_like_supply_contract/v1/tempo_like_calendars', + { + params: { + start_date: tomorrowStartDate, + end_date: tomorrowEndDate, + }, + headers: { + authorization: `Bearer ${accessToken}`, + }, + }, + ); + tomorrowData = tomorrowLiveData.tempo_like_calendars.values[0].value.toLowerCase(); + // Set cache + await redisClient.set(`${TEMPO_CACHE_KEY}:${tomorrowStartDate}`, tomorrowData, { + EX: TEMPO_REDIS_EXPIRY_IN_SECONDS, + }); + } catch (e) { + logger.debug(e); + tomorrowData = 'unknown'; + // Set cache for 30 minutes to avoid querying to much the API + await redisClient.set(`${TEMPO_CACHE_KEY}:${tomorrowStartDate}`, tomorrowData, { + EX: 30 * 60, // null set to 30 minutes + }); + } + } + + return { + today: todayData, + tomorrow: tomorrowData, + }; + } + + async function getDataWithRetry() { + const options = { + retries: 3, + factor: 2, + minTimeout: 50, + }; + return retry(async () => getDataLiveOrFromCache(), options); + } + + return { + getDataLiveOrFromCache, + getDataWithRetry, + }; +}; diff --git a/core/index.js b/core/index.js index f81bb08..be168cf 100644 --- a/core/index.js +++ b/core/index.js @@ -32,6 +32,7 @@ const GoogleModel = require('./api/google/google.model'); const AlexaModel = require('./api/alexa/alexa.model'); const EnedisModel = require('./api/enedis/enedis.model'); const EcowattModel = require('./api/ecowatt/ecowatt.model'); +const TempoModel = require('./api/tempo/tempo.model'); // Controllers const PingController = require('./api/ping/ping.controller'); @@ -51,6 +52,7 @@ const GoogleController = require('./api/google/google.controller'); const AlexaController = require('./api/alexa/alexa.controller'); const EnedisController = require('./api/enedis/enedis.controller'); const EcowattController = require('./api/ecowatt/ecowatt.controller'); +const TempoController = require('./api/tempo/tempo.controller'); const CameraController = require('./api/camera/camera.controller'); const TTSController = require('./api/tts/tts.controller'); @@ -178,6 +180,7 @@ module.exports = async (port) => { alexaModel: AlexaModel(logger, db, redisClient, services.jwtService), enedisModel: EnedisModel(logger, db, redisClient), ecowattModel: EcowattModel(logger, redisClient), + tempoModel: TempoModel(logger, redisClient), }; const controllers = { @@ -214,6 +217,7 @@ module.exports = async (port) => { ), enedisController: EnedisController(logger, models.enedisModel), ecowattController: EcowattController(logger, models.ecowattModel), + tempoController: TempoController(logger, models.tempoModel), cameraController: CameraController( logger, models.userModel, diff --git a/test/core/api/tempo/tempo.controller.test.js b/test/core/api/tempo/tempo.controller.test.js new file mode 100644 index 0000000..7860a18 --- /dev/null +++ b/test/core/api/tempo/tempo.controller.test.js @@ -0,0 +1,169 @@ +const request = require('supertest'); +const { expect } = require('chai'); +const nock = require('nock'); + +describe('GET /edf/tempo/today', () => { + it('should return tempo data', async () => { + nock('https://digital.iservices.rte-france.com') + .post('/token/oauth/', () => true) + .reply(200, { + access_token: 'access_token', + expires_in: 100, + }); + nock('https://digital.iservices.rte-france.com') + .get('/open_api/tempo_like_supply_contract/v1/tempo_like_calendars') + .query(() => true) + .reply(200, { + tempo_like_calendars: { + start_date: '2024-09-02T00:00:00+02:00', + end_date: '2024-09-03T00:00:00+02:00', + values: [ + { + start_date: '2024-09-02T00:00:00+02:00', + end_date: '2024-09-03T00:00:00+02:00', + value: 'BLUE', + updated_date: '2024-09-01T10:20:00+02:00', + }, + ], + }, + }); + nock('https://digital.iservices.rte-france.com') + .get('/open_api/tempo_like_supply_contract/v1/tempo_like_calendars') + .query(() => true) + .reply(400, { + error: 'TMPLIKSUPCON_TMPLIKCAL_F04', + error_description: + 'The value of "end_date" field is incorrect. It is not possible to recover data to this term.', + error_uri: '', + error_details: { + transaction_id: 'Id-2fc9d566cff9ded9d39d0ee7', + }, + }); + const response = await request(TEST_BACKEND_APP) + .get('/edf/tempo/today') + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .expect(200); + expect(response.headers).to.have.property('cache-control', 'public, max-age=3600'); + expect(response.body).to.deep.equal({ + today: 'blue', + tomorrow: 'unknown', + }); + // From cache + const responseFromCache = await request(TEST_BACKEND_APP) + .get('/edf/tempo/today') + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .expect(200); + expect(responseFromCache.headers).to.have.property('cache-control', 'public, max-age=3600'); + expect(responseFromCache.body).to.deep.equal({ + today: 'blue', + tomorrow: 'unknown', + }); + }); + it('should return tempo data with 2 unknown', async () => { + nock('https://digital.iservices.rte-france.com') + .post('/token/oauth/', () => true) + .reply(200, { + access_token: 'access_token', + expires_in: 100, + }); + nock('https://digital.iservices.rte-france.com') + .get('/open_api/tempo_like_supply_contract/v1/tempo_like_calendars') + .query(() => true) + .reply(400, { + error: 'TMPLIKSUPCON_TMPLIKCAL_F04', + error_description: + 'The value of "end_date" field is incorrect. It is not possible to recover data to this term.', + error_uri: '', + error_details: { + transaction_id: 'Id-2fc9d566cff9ded9d39d0ee7', + }, + }); + nock('https://digital.iservices.rte-france.com') + .get('/open_api/tempo_like_supply_contract/v1/tempo_like_calendars') + .query(() => true) + .reply(400, { + error: 'TMPLIKSUPCON_TMPLIKCAL_F04', + error_description: + 'The value of "end_date" field is incorrect. It is not possible to recover data to this term.', + error_uri: '', + error_details: { + transaction_id: 'Id-2fc9d566cff9ded9d39d0ee7', + }, + }); + const response = await request(TEST_BACKEND_APP) + .get('/edf/tempo/today') + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .expect(200); + expect(response.headers).to.have.property('cache-control', 'public, max-age=3600'); + expect(response.body).to.deep.equal({ + today: 'unknown', + tomorrow: 'unknown', + }); + }); + it('should return tempo data with 2 blue', async () => { + nock('https://digital.iservices.rte-france.com') + .post('/token/oauth/', () => true) + .reply(200, { + access_token: 'access_token', + expires_in: 100, + }); + nock('https://digital.iservices.rte-france.com') + .get('/open_api/tempo_like_supply_contract/v1/tempo_like_calendars') + .query(() => true) + .reply(200, { + tempo_like_calendars: { + start_date: '2024-09-02T00:00:00+02:00', + end_date: '2024-09-03T00:00:00+02:00', + values: [ + { + start_date: '2024-09-02T00:00:00+02:00', + end_date: '2024-09-03T00:00:00+02:00', + value: 'BLUE', + updated_date: '2024-09-01T10:20:00+02:00', + }, + ], + }, + }); + nock('https://digital.iservices.rte-france.com') + .get('/open_api/tempo_like_supply_contract/v1/tempo_like_calendars') + .query(() => true) + .reply(200, { + tempo_like_calendars: { + start_date: '2024-09-02T00:00:00+02:00', + end_date: '2024-09-03T00:00:00+02:00', + values: [ + { + start_date: '2024-09-02T00:00:00+02:00', + end_date: '2024-09-03T00:00:00+02:00', + value: 'BLUE', + updated_date: '2024-09-01T10:20:00+02:00', + }, + ], + }, + }); + const response = await request(TEST_BACKEND_APP) + .get('/edf/tempo/today') + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .expect(200); + expect(response.headers).to.have.property('cache-control', 'public, max-age=3600'); + expect(response.body).to.deep.equal({ + today: 'blue', + tomorrow: 'blue', + }); + // From cache + const responseFromCache = await request(TEST_BACKEND_APP) + .get('/edf/tempo/today') + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .expect(200); + expect(responseFromCache.headers).to.have.property('cache-control', 'public, max-age=3600'); + expect(responseFromCache.body).to.deep.equal({ + today: 'blue', + tomorrow: 'blue', + }); + }); +});