diff --git a/api/sample.env b/api/sample.env index 76a608df7e6..11097b42d85 100644 --- a/api/sample.env +++ b/api/sample.env @@ -1043,3 +1043,23 @@ EMAIL_VALIDATION_DEMAND_TEMPORARY_STORAGE_LIFESPAN=3d # default: "PRESCRIPTION|DEVCOMP|JUNIOR|ACCES|CONTENU|CERTIFICATION|EVALUATION" # sample: SEEDS_CONTEXT="EVALUATION|DEVCOMP" # SEEDS_CONTEXT="PRESCRIPTION|DEVCOMP|JUNIOR|ACCES|CONTENU|CERTIFICATION|EVALUATION" + +# ===== +# API_DATA +# ===== +# +# presence: optional +# type: uri +# API_DATA_URL +# +# presence: optional +# type: string +# API_DATA_USERNAME +# +# presence: optional +# type: string +# API_DATA_PASSWORD +# +# presence: optional +# type: json +# API_DATA_QUERIES='{ "coverageRateByAreas": "abc45-67def-89ghi" }' diff --git a/api/src/shared/config.js b/api/src/shared/config.js index d27a6e9c8d5..17cf33c4fa5 100644 --- a/api/src/shared/config.js +++ b/api/src/shared/config.js @@ -69,6 +69,14 @@ const configuration = (function () { anonymous: { accessTokenLifespanMs: ms(process.env.ANONYMOUS_ACCESS_TOKEN_LIFESPAN || '4h'), }, + apiData: { + url: process.env.API_DATA_URL, + credentials: { + username: process.env.API_DATA_USERNAME, + password: process.env.API_DATA_PASSWORD, + }, + queries: parseJSONEnv('API_DATA_QUERIES'), + }, apiManager: { url: process.env.APIM_URL || 'https://gateway.pix.fr', }, @@ -376,6 +384,17 @@ const configuration = (function () { }; if (process.env.NODE_ENV === 'test') { + config.apiData = { + url: 'http://example.net', + credentials: { + username: 'user', + password: 'passowrd', + }, + queries: { + coverageRate: 'coverage-rate-query-id', + }, + }; + config.auditLogger.isEnabled = true; config.auditLogger.baseUrl = 'http://audit-logger.local'; config.auditLogger.clientSecret = 'client-super-secret'; diff --git a/api/src/shared/infrastructure/datasources/ApiData.js b/api/src/shared/infrastructure/datasources/ApiData.js new file mode 100644 index 00000000000..6f4b034a461 --- /dev/null +++ b/api/src/shared/infrastructure/datasources/ApiData.js @@ -0,0 +1,64 @@ +import jsonwebtoken from 'jsonwebtoken'; + +import { httpAgent } from '../../../../lib/infrastructure/http/http-agent.js'; +import { config } from '../../config.js'; + +export const STATUS = { + SUCCESS: 'success', + FAILURE: 'failure', +}; + +export const API_DATA_QUERIES = config.apiData.queries || {}; + +export class ApiData { + #token; + + constructor(apiDataUrl, apiDataCredentials) { + this.apiDataUrl = apiDataUrl; + this.apiDataCredentials = apiDataCredentials; + } + + set token(token) { + this.#token = token; + } + + async getToken() { + if (!this.#token) { + this.#token = await this.#fetchToken(); + return this.#token; + } + + const decodedToken = jsonwebtoken.decode(this.#token); + const preventCloseExpiration = 10; + const isTokenExpired = decodedToken.exp < Date.now() / 1000 + preventCloseExpiration; + + if (isTokenExpired) { + this.#token = await this.#fetchToken(); + } + + return this.#token; + } + + async #fetchToken() { + if (!this.apiDataCredentials?.username || !this.apiDataCredentials?.password) { + throw new Error('ApiData credentials are not set'); + } + const result = await httpAgent.post({ + url: `${this.apiDataUrl}/token`, + payload: this.apiDataCredentials, + }); + return result.data.data.access_token; + } + + async get(queryId, params = []) { + const token = await this.getToken(); + const result = await httpAgent.post({ + url: `${this.apiDataUrl}/query`, + payload: { queryId, params }, + headers: { Authorization: `Bearer ${token}` }, + }); + return result.data; + } +} + +export const apiData = new ApiData(config.apiData.url, config.apiData.credentials); diff --git a/api/src/shared/infrastructure/validate-environment-variables.js b/api/src/shared/infrastructure/validate-environment-variables.js index 6523da85ef3..55ac424f9f1 100644 --- a/api/src/shared/infrastructure/validate-environment-variables.js +++ b/api/src/shared/infrastructure/validate-environment-variables.js @@ -3,6 +3,10 @@ import Joi from 'joi'; const schema = Joi.object({ AUTH_SECRET: Joi.string().required(), AUTONOMOUS_COURSES_ORGANIZATION_ID: Joi.number().required(), + API_DATA_URL: Joi.string().uri().optional(), + API_DATA_USERNAME: Joi.string().optional(), + API_DATA_PASSWORD: Joi.string().optional(), + API_DATA_QUERIES: Joi.string().optional(), BREVO_ACCOUNT_CREATION_TEMPLATE_ID: Joi.number().optional(), BREVO_API_KEY: Joi.string().optional(), BREVO_ORGANIZATION_INVITATION_SCO_TEMPLATE_ID: Joi.number().optional(), diff --git a/api/tests/unit/infrastructure/datasources/ApiData_test.js b/api/tests/unit/infrastructure/datasources/ApiData_test.js new file mode 100644 index 00000000000..78c42120adf --- /dev/null +++ b/api/tests/unit/infrastructure/datasources/ApiData_test.js @@ -0,0 +1,108 @@ +import jsonwebtoken from 'jsonwebtoken'; + +import { ApiData } from '../../../../src/shared/infrastructure/datasources/ApiData.js'; +import { expect, nock } from '../../../test-helper.js'; + +describe('Unit | Infrastructure | Datasources | ApiData', function () { + describe('#getToken', function () { + context('when they are no token', function () { + it('should fetch a token and return it', async function () { + // given + const apiDataCredentials = { username: 'username', password: 'password' }; + const apiDataUrl = 'http://example.net'; + const apiData = new ApiData(apiDataUrl, apiDataCredentials); + + const fetchTokenMock = nock(apiDataUrl) + .post('/token', apiDataCredentials) + .reply(200, { test: 'test', data: { access_token: 'returned-token' } }); + + // when + const token = await apiData.getToken(); + + // then + expect(fetchTokenMock.isDone()).to.be.true; + expect(token).to.equal('returned-token'); + }); + }); + + context('when the token is expired', function () { + it('should fetch a new token and return it', async function () { + // given + const apiDataCredentials = { username: 'username', password: 'password' }; + const apiDataUrl = 'http://example.net'; + const apiData = new ApiData(apiDataUrl, apiDataCredentials); + + const invalidToken = jsonwebtoken.sign({}, 'test-secret', { expiresIn: '1sec' }); + apiData.token = invalidToken; + + const fetchTokenMock = nock(apiDataUrl) + .post('/token', apiDataCredentials) + .reply(200, { test: 'test', data: { access_token: 'returned-token' } }); + + // when + setTimeout(async () => { + return; + }, 100); + + const token = await apiData.getToken(); + + // then + expect(fetchTokenMock.isDone()).to.be.true; + expect(token).to.equal('returned-token'); + }); + }); + + context('when the token is not expired', function () { + it('should return the token', async function () { + // given + const apiDataCredentials = { username: 'username', password: 'password' }; + const apiDataUrl = 'http://example.net'; + const apiData = new ApiData(apiDataUrl, apiDataCredentials); + + const validToken = jsonwebtoken.sign({}, 'test-secret', { expiresIn: '30d' }); + apiData.token = validToken; + + const fetchTokenMock = nock(apiDataUrl) + .post('/token', apiDataCredentials) + .reply(200, { test: 'test', data: { access_token: 'returned-token' } }); + + // when + const token = await apiData.getToken(); + + // then + expect(fetchTokenMock.isDone()).to.be.false; + expect(token).to.equal(validToken); + }); + }); + }); + + describe('#get', function () { + it('should use the token to fetch data', async function () { + // given + const apiDataCredentials = { username: 'username', password: 'password' }; + const apiDataUrl = 'http://example.net'; + + const apiData = new ApiData(apiDataUrl, apiDataCredentials); + + const validToken = jsonwebtoken.sign({}, 'test-secret', { expiresIn: '30d' }); + apiData.token = validToken; + + const queryId = 'queryId'; + const params = [{ param: 'value' }]; + + const expectedData = [{ result: 'result' }]; + const fetchMock = nock(apiDataUrl) + .post('/query', { queryId, params }) + .matchHeader('Content-Type', 'application/json') + .matchHeader('Authorization', `Bearer ${validToken}`) + .reply(200, { status: 'success', data: expectedData }); + + // when + const result = await apiData.get(queryId, params); + + // then + expect(fetchMock.isDone()).to.be.true; + expect(result.data).to.deep.equal(expectedData); + }); + }); +});