Skip to content

Commit

Permalink
feat(api): add api data datasource
Browse files Browse the repository at this point in the history
Co-authored-by: Alexandre-Monney <[email protected]>
  • Loading branch information
VincentHardouin and Alexandre-Monney authored Nov 27, 2024
1 parent 4bf51aa commit a2b1106
Show file tree
Hide file tree
Showing 5 changed files with 215 additions and 0 deletions.
20 changes: 20 additions & 0 deletions api/sample.env
Original file line number Diff line number Diff line change
Expand Up @@ -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" }'
19 changes: 19 additions & 0 deletions api/src/shared/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
Expand Down Expand Up @@ -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';
Expand Down
64 changes: 64 additions & 0 deletions api/src/shared/infrastructure/datasources/ApiData.js
Original file line number Diff line number Diff line change
@@ -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);
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
108 changes: 108 additions & 0 deletions api/tests/unit/infrastructure/datasources/ApiData_test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
});

0 comments on commit a2b1106

Please sign in to comment.