From dc61730601ce2f25e0acd959a56040808ed316da Mon Sep 17 00:00:00 2001 From: Benjamin Bohec Date: Mon, 19 Jun 2023 14:02:37 +0200 Subject: [PATCH] rework gateways --- back/src/_testBuilders/buildTestApp.ts | 4 +- back/src/adapters/primary/config/appConfig.ts | 6 +- .../adapters/primary/config/createGateways.ts | 56 ++++---- ...ateAllPEAgenciesFromPeAgencyReferential.ts | 24 +++- .../core/CachingAccessTokenGateway.ts | 62 -------- .../secondary/core/InMemoryCachingGateway.ts | 52 +++++++ ...ts => InMemoryCachingGateway.unit.test.ts} | 67 ++++++--- .../NotImplementedDocumentGateway.ts | 6 +- .../S3DocumentGateway.ts | 6 +- .../InMemoryAccessTokenGateway.ts | 17 --- .../PoleEmploiAccessTokenGateway.ts | 64 --------- .../inMemoryEstablishmentGroupRepository.ts | 2 +- .../HttpLaBonneBoiteAPI.manual.test.ts | 24 +++- .../laBonneBoite/HttpLaBonneBoiteAPI.ts | 6 +- .../HttpPeAgenciesReferential.manual.test.ts | 25 +++- .../HttpPeAgenciesReferential.ts | 6 +- .../poleEmploi/HttpPoleEmploiGateway.ts | 86 ----------- .../HttpPoleEmploiGateway.manual.test.ts | 23 +-- .../poleEmploi/HttpPoleEmploiGateway.ts | 133 ++++++++++++++++++ .../poleEmploi/InMemoryPoleEmploiGateway.ts | 14 +- .../poleEmploi/PoleEmploi.targets.ts | 2 +- .../convention/ports/PoleEmploiGateway.ts | 8 ++ .../useCases/ConventionsReminder.unit.test.ts | 3 +- ...PoleEmploiOnConventionUpdates.unit.test.ts | 2 +- .../domain/core/ports/AccessTokenGateway.ts | 9 -- 25 files changed, 365 insertions(+), 342 deletions(-) delete mode 100644 back/src/adapters/secondary/core/CachingAccessTokenGateway.ts create mode 100644 back/src/adapters/secondary/core/InMemoryCachingGateway.ts rename back/src/adapters/secondary/core/{CachingAccessTokenGateway.unit.test.ts => InMemoryCachingGateway.unit.test.ts} (63%) rename back/src/adapters/secondary/{ => documentGateway}/NotImplementedDocumentGateway.ts (65%) rename back/src/adapters/secondary/{ => documentGateway}/S3DocumentGateway.ts (84%) delete mode 100644 back/src/adapters/secondary/immersionOffer/InMemoryAccessTokenGateway.ts delete mode 100644 back/src/adapters/secondary/immersionOffer/PoleEmploiAccessTokenGateway.ts delete mode 100644 back/src/adapters/secondary/immersionOffer/poleEmploi/HttpPoleEmploiGateway.ts rename back/src/adapters/secondary/{immersionOffer => }/poleEmploi/HttpPoleEmploiGateway.manual.test.ts (84%) create mode 100644 back/src/adapters/secondary/poleEmploi/HttpPoleEmploiGateway.ts rename back/src/adapters/secondary/{immersionOffer => }/poleEmploi/InMemoryPoleEmploiGateway.ts (59%) rename back/src/adapters/secondary/{immersionOffer => }/poleEmploi/PoleEmploi.targets.ts (89%) delete mode 100644 back/src/domain/core/ports/AccessTokenGateway.ts diff --git a/back/src/_testBuilders/buildTestApp.ts b/back/src/_testBuilders/buildTestApp.ts index 2d512ca50a..5420544c21 100644 --- a/back/src/_testBuilders/buildTestApp.ts +++ b/back/src/_testBuilders/buildTestApp.ts @@ -8,14 +8,14 @@ import { InMemoryAddressGateway } from "../adapters/secondary/addressGateway/InM import { BasicEventCrawler } from "../adapters/secondary/core/EventCrawlerImplementations"; import { CustomTimeGateway } from "../adapters/secondary/core/TimeGateway/CustomTimeGateway"; import { StubDashboardGateway } from "../adapters/secondary/dashboardGateway/StubDashboardGateway"; +import { NotImplementedDocumentGateway } from "../adapters/secondary/documentGateway/NotImplementedDocumentGateway"; import { InMemoryEmailValidationGateway } from "../adapters/secondary/emailValidationGateway/InMemoryEmailValidationGateway"; import { InMemoryLaBonneBoiteAPI } from "../adapters/secondary/immersionOffer/laBonneBoite/InMemoryLaBonneBoiteAPI"; import { InMemoryPassEmploiGateway } from "../adapters/secondary/immersionOffer/passEmploi/InMemoryPassEmploiGateway"; -import { InMemoryPoleEmploiGateway } from "../adapters/secondary/immersionOffer/poleEmploi/InMemoryPoleEmploiGateway"; import { InMemoryInclusionConnectGateway } from "../adapters/secondary/InclusionConnectGateway/InMemoryInclusionConnectGateway"; import type { InMemoryNotificationGateway } from "../adapters/secondary/notificationGateway/InMemoryNotificationGateway"; -import { NotImplementedDocumentGateway } from "../adapters/secondary/NotImplementedDocumentGateway"; import { InMemoryPeConnectGateway } from "../adapters/secondary/PeConnectGateway/InMemoryPeConnectGateway"; +import { InMemoryPoleEmploiGateway } from "../adapters/secondary/poleEmploi/InMemoryPoleEmploiGateway"; import { DeterministShortLinkIdGeneratorGateway } from "../adapters/secondary/shortLinkIdGeneratorGateway/DeterministShortLinkIdGeneratorGateway"; import { InMemorySiretGateway } from "../adapters/secondary/siret/InMemorySiretGateway"; import { diff --git a/back/src/adapters/primary/config/appConfig.ts b/back/src/adapters/primary/config/appConfig.ts index 42e1d69e69..f6577059f4 100644 --- a/back/src/adapters/primary/config/appConfig.ts +++ b/back/src/adapters/primary/config/appConfig.ts @@ -13,8 +13,8 @@ import { } from "shared"; import { DomainTopic } from "../../../domain/core/eventBus/events"; import { InclusionConnectConfig } from "../../../domain/inclusionConnect/useCases/InitiateInclusionConnect"; +import { S3Params } from "../../secondary/documentGateway/S3DocumentGateway"; import { EmailableApiKey } from "../../secondary/emailValidationGateway/EmailableEmailValidationGateway.dto"; -import { S3Params } from "../../secondary/S3DocumentGateway"; export type AccessTokenConfig = { immersionFacileBaseUrl: AbsoluteUrl; @@ -296,9 +296,7 @@ export class AppConfig { peAuthCandidatUrl: this.peAuthCandidatUrl, peEnterpriseUrl: this.peEnterpriseUrl, clientId: this.poleEmploiClientId, - clientSecret: this.throwIfNotDefinedOrDefault( - "POLE_EMPLOI_CLIENT_SECRET", - ), + clientSecret: this.poleEmploiClientSecret, }; } diff --git a/back/src/adapters/primary/config/createGateways.ts b/back/src/adapters/primary/config/createGateways.ts index 3b2f4a47b1..7be03c5f60 100644 --- a/back/src/adapters/primary/config/createGateways.ts +++ b/back/src/adapters/primary/config/createGateways.ts @@ -5,6 +5,7 @@ import { immersionFacileContactEmail, pipeWithValue, } from "shared"; +import { GetAccessTokenResponse } from "../../../domain/convention/ports/PoleEmploiGateway"; import { noRetries } from "../../../domain/core/ports/RetryStrategy"; import { TimeGateway } from "../../../domain/core/ports/TimeGateway"; import { DashboardGateway } from "../../../domain/dashboard/port/DashboardGateway"; @@ -15,35 +16,33 @@ import { createLogger } from "../../../utils/logger"; import { HttpAddressGateway } from "../../secondary/addressGateway/HttpAddressGateway"; import { addressesExternalTargets } from "../../secondary/addressGateway/HttpAddressGateway.targets"; import { InMemoryAddressGateway } from "../../secondary/addressGateway/InMemoryAddressGateway"; -import { CachingAccessTokenGateway } from "../../secondary/core/CachingAccessTokenGateway"; +import { InMemoryCachingGateway } from "../../secondary/core/InMemoryCachingGateway"; import { CustomTimeGateway } from "../../secondary/core/TimeGateway/CustomTimeGateway"; import { RealTimeGateway } from "../../secondary/core/TimeGateway/RealTimeGateway"; import { MetabaseDashboardGateway } from "../../secondary/dashboardGateway/MetabaseDashboardGateway"; import { StubDashboardGateway } from "../../secondary/dashboardGateway/StubDashboardGateway"; +import { NotImplementedDocumentGateway } from "../../secondary/documentGateway/NotImplementedDocumentGateway"; +import { S3DocumentGateway } from "../../secondary/documentGateway/S3DocumentGateway"; import { EmailableEmailValidationGateway } from "../../secondary/emailValidationGateway/EmailableEmailValidationGateway"; import { emailableValidationTargets } from "../../secondary/emailValidationGateway/EmailableEmailValidationGateway.targets"; import { InMemoryEmailValidationGateway } from "../../secondary/emailValidationGateway/InMemoryEmailValidationGateway"; -import { InMemoryAccessTokenGateway } from "../../secondary/immersionOffer/InMemoryAccessTokenGateway"; import { HttpLaBonneBoiteAPI } from "../../secondary/immersionOffer/laBonneBoite/HttpLaBonneBoiteAPI"; import { InMemoryLaBonneBoiteAPI } from "../../secondary/immersionOffer/laBonneBoite/InMemoryLaBonneBoiteAPI"; import { createLbbTargets } from "../../secondary/immersionOffer/laBonneBoite/LaBonneBoiteTargets"; import { HttpPassEmploiGateway } from "../../secondary/immersionOffer/passEmploi/HttpPassEmploiGateway"; import { InMemoryPassEmploiGateway } from "../../secondary/immersionOffer/passEmploi/InMemoryPassEmploiGateway"; -import { HttpPoleEmploiGateway } from "../../secondary/immersionOffer/poleEmploi/HttpPoleEmploiGateway"; -import { InMemoryPoleEmploiGateway } from "../../secondary/immersionOffer/poleEmploi/InMemoryPoleEmploiGateway"; -import { createPoleEmploiTargets } from "../../secondary/immersionOffer/poleEmploi/PoleEmploi.targets"; -import { PoleEmploiAccessTokenGateway } from "../../secondary/immersionOffer/PoleEmploiAccessTokenGateway"; import { HttpInclusionConnectGateway } from "../../secondary/InclusionConnectGateway/HttpInclusionConnectGateway"; import { makeInclusionConnectExternalTargets } from "../../secondary/InclusionConnectGateway/inclusionConnectExternal.targets"; import { InMemoryInclusionConnectGateway } from "../../secondary/InclusionConnectGateway/InMemoryInclusionConnectGateway"; import { BrevoNotificationGateway } from "../../secondary/notificationGateway/BrevoNotificationGateway"; import { brevoNotificationGatewayTargets } from "../../secondary/notificationGateway/BrevoNotificationGateway.targets"; import { InMemoryNotificationGateway } from "../../secondary/notificationGateway/InMemoryNotificationGateway"; -import { NotImplementedDocumentGateway } from "../../secondary/NotImplementedDocumentGateway"; import { HttpPeConnectGateway } from "../../secondary/PeConnectGateway/HttpPeConnectGateway"; import { InMemoryPeConnectGateway } from "../../secondary/PeConnectGateway/InMemoryPeConnectGateway"; import { makePeConnectExternalTargets } from "../../secondary/PeConnectGateway/peConnectApi.targets"; -import { S3DocumentGateway } from "../../secondary/S3DocumentGateway"; +import { HttpPoleEmploiGateway } from "../../secondary/poleEmploi/HttpPoleEmploiGateway"; +import { InMemoryPoleEmploiGateway } from "../../secondary/poleEmploi/InMemoryPoleEmploiGateway"; +import { createPoleEmploiTargets } from "../../secondary/poleEmploi/PoleEmploi.targets"; import { DeterministShortLinkIdGeneratorGateway } from "../../secondary/shortLinkIdGeneratorGateway/DeterministShortLinkIdGeneratorGateway"; import { NanoIdShortLinkIdGeneratorGateway } from "../../secondary/shortLinkIdGeneratorGateway/NanoIdShortLinkIdGeneratorGateway"; import { AnnuaireDesEntreprisesSiretGateway } from "../../secondary/siret/AnnuaireDesEntreprisesSiretGateway"; @@ -95,23 +94,27 @@ export const createGateways = async (config: AppConfig) => { apiAddress: config.apiAddress, }); - const cachingAccessTokenGateway = [ - config.laBonneBoiteGateway, - config.poleEmploiGateway, - ].includes("HTTPS") - ? new CachingAccessTokenGateway( - new PoleEmploiAccessTokenGateway( - config.poleEmploiAccessTokenConfig, - noRetries, - ), - ) - : new InMemoryAccessTokenGateway(); - const timeGateway = config.timeGateway === "CUSTOM" ? new CustomTimeGateway() : new RealTimeGateway(); + const poleEmploiGateway = + config.poleEmploiGateway === "HTTPS" + ? new HttpPoleEmploiGateway( + configureCreateHttpClientForExternalApi( + axios.create({ timeout: config.externalAxiosTimeout }), + )(createPoleEmploiTargets(config.peApiUrl)), + new InMemoryCachingGateway( + timeGateway, + "expires_in", + ), + config.peApiUrl, + config.poleEmploiAccessTokenConfig, + noRetries, + ) + : new InMemoryPoleEmploiGateway(); + return { addressApi: createAddressGateway(config), dashboardGateway: createDashboardGateway(config), @@ -128,7 +131,7 @@ export const createGateways = async (config: AppConfig) => { timeout: config.externalAxiosTimeout, }), )(createLbbTargets(config.peApiUrl)), - cachingAccessTokenGateway, + poleEmploiGateway, config.poleEmploiClientId, ) : new InMemoryLaBonneBoiteAPI(), @@ -137,16 +140,7 @@ export const createGateways = async (config: AppConfig) => { ? new HttpPassEmploiGateway(config.passEmploiUrl, config.passEmploiKey) : new InMemoryPassEmploiGateway(), peConnectGateway: createPoleEmploiConnectGateway(config), - poleEmploiGateway: - config.poleEmploiGateway === "HTTPS" - ? new HttpPoleEmploiGateway( - configureCreateHttpClientForExternalApi( - axios.create({ timeout: config.externalAxiosTimeout }), - )(createPoleEmploiTargets(config.peApiUrl)), - config.peApiUrl, - cachingAccessTokenGateway, - ) - : new InMemoryPoleEmploiGateway(), + poleEmploiGateway, timeGateway, siret: getSiretGateway(config.siretGateway, config, timeGateway), shortLinkGenerator: diff --git a/back/src/adapters/primary/scripts/updateAllPEAgenciesFromPeAgencyReferential.ts b/back/src/adapters/primary/scripts/updateAllPEAgenciesFromPeAgencyReferential.ts index 3463080a11..8f0c052831 100644 --- a/back/src/adapters/primary/scripts/updateAllPEAgenciesFromPeAgencyReferential.ts +++ b/back/src/adapters/primary/scripts/updateAllPEAgenciesFromPeAgencyReferential.ts @@ -1,26 +1,38 @@ +import axios from "axios"; import { Pool } from "pg"; +import { GetAccessTokenResponse } from "../../../domain/convention/ports/PoleEmploiGateway"; import { UpdateAllPeAgencies } from "../../../domain/convention/useCases/agencies/UpdateAllPeAgencies"; import { noRetries } from "../../../domain/core/ports/RetryStrategy"; import { HttpAddressGateway } from "../../secondary/addressGateway/HttpAddressGateway"; import { addressesExternalTargets } from "../../secondary/addressGateway/HttpAddressGateway.targets"; import { ConsoleAppLogger } from "../../secondary/core/ConsoleAppLogger"; +import { InMemoryCachingGateway } from "../../secondary/core/InMemoryCachingGateway"; +import { RealTimeGateway } from "../../secondary/core/TimeGateway/RealTimeGateway"; import { UuidV4Generator } from "../../secondary/core/UuidGeneratorImplementations"; import { HttpPeAgenciesReferential } from "../../secondary/immersionOffer/peAgenciesReferential/HttpPeAgenciesReferential"; -import { PoleEmploiAccessTokenGateway } from "../../secondary/immersionOffer/PoleEmploiAccessTokenGateway"; +import { HttpPoleEmploiGateway } from "../../secondary/poleEmploi/HttpPoleEmploiGateway"; +import { createPoleEmploiTargets } from "../../secondary/poleEmploi/PoleEmploi.targets"; import { AppConfig } from "../config/appConfig"; import { configureCreateHttpClientForExternalApi } from "../config/createHttpClientForExternalApi"; import { createUowPerformer } from "../config/uowConfig"; const updateAllPeAgenciesScript = async () => { const config = AppConfig.createFromEnv(); - const accessTokenGateway = new PoleEmploiAccessTokenGateway( - config.poleEmploiAccessTokenConfig, - noRetries, - ); const httpPeAgenciesReferential = new HttpPeAgenciesReferential( config.peApiUrl, - accessTokenGateway, + new HttpPoleEmploiGateway( + configureCreateHttpClientForExternalApi( + axios.create({ timeout: config.externalAxiosTimeout }), + )(createPoleEmploiTargets(config.peApiUrl)), + new InMemoryCachingGateway( + new RealTimeGateway(), + "expires_in", + ), + config.peApiUrl, + config.poleEmploiAccessTokenConfig, + noRetries, + ), config.poleEmploiClientId, ); diff --git a/back/src/adapters/secondary/core/CachingAccessTokenGateway.ts b/back/src/adapters/secondary/core/CachingAccessTokenGateway.ts deleted file mode 100644 index 2cadb470ac..0000000000 --- a/back/src/adapters/secondary/core/CachingAccessTokenGateway.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { addSeconds } from "date-fns"; -import isAfter from "date-fns/isAfter"; -import { - AccessTokenGateway, - GetAccessTokenResponse, -} from "../../../domain/core/ports/AccessTokenGateway"; -import { TimeGateway } from "../../../domain/core/ports/TimeGateway"; -import { RealTimeGateway } from "./TimeGateway/RealTimeGateway"; - -type Scope = string; -type CacheEntry = { - response: GetAccessTokenResponse; - expirationTime: Date; -}; - -// Minimum lifetime before a token is considered expired. -const minTtlSec = 30; - -// An AccessTokenGateway that provides caching functionality and uses the injected -// AccessTokenGateway to delegate the fetching and refreshing of tokens. -// -// The class caches one token per scope so a single instance can be used to cache all tokens for a -// specific delegate gateway. -// -// Expired tokens are refreshed lazily. -export class CachingAccessTokenGateway implements AccessTokenGateway { - private readonly cache: Record> = {}; - - public constructor( - private readonly delegate: AccessTokenGateway, - private readonly timeGateway: TimeGateway = new RealTimeGateway(), - ) {} - - public async getAccessToken(scope: string): Promise { - const cacheEntryPromise = this.cache[scope]; - if (!cacheEntryPromise || this.isExpired(await cacheEntryPromise)) { - this.cache[scope] = this.refreshToken(scope); - } - - const cacheEntry = await this.cache[scope]; - return cacheEntry.response; - } - - private async refreshToken(scope: Scope): Promise { - const response = await this.delegate.getAccessToken(scope); - - const expirationTime = addSeconds( - this.timeGateway.now(), - response.expires_in - minTtlSec || 0, - ); - - return { - response, - expirationTime, - }; - } - - private isExpired(entry: CacheEntry): boolean { - const now = this.timeGateway.now(); - return isAfter(now, entry.expirationTime); - } -} diff --git a/back/src/adapters/secondary/core/InMemoryCachingGateway.ts b/back/src/adapters/secondary/core/InMemoryCachingGateway.ts new file mode 100644 index 0000000000..43cd2fc959 --- /dev/null +++ b/back/src/adapters/secondary/core/InMemoryCachingGateway.ts @@ -0,0 +1,52 @@ +import { addSeconds } from "date-fns"; +import isAfter from "date-fns/isAfter"; +import { TimeGateway } from "../../../domain/core/ports/TimeGateway"; +import { RealTimeGateway } from "./TimeGateway/RealTimeGateway"; + +export class InMemoryCachingGateway { + public constructor( + private readonly timeGateway: TimeGateway = new RealTimeGateway(), + private responseExpireInSecondsProp: keyof T, + ) {} + + public async caching( + value: string, + onCacheMiss: () => Promise, + ): Promise { + const cache = this.cache[value]; + return cache === undefined || this.isExpired(await cache) + ? this.onBadCache(value, onCacheMiss) + : (await cache).response; + } + + private onBadCache(value: string, onCacheMiss: () => Promise): Promise { + this.cache[value] = this.refreshCache(onCacheMiss); + return this.caching(value, onCacheMiss); + } + + private async refreshCache( + onCacheMiss: () => Promise, + ): Promise> { + const response = await onCacheMiss(); + return { + response, + expirationTime: addSeconds( + this.timeGateway.now(), + Number(response[this.responseExpireInSecondsProp]) - + this.minimumCacheLifetime || 0, + ), + }; + } + + private isExpired(entry: CacheEntry): boolean { + return isAfter(this.timeGateway.now(), entry.expirationTime); + } + + private readonly cache: Partial>>> = {}; + private readonly minimumCacheLifetime = 30; +} + +type CacheEntry = { + response: T; + expirationTime: Date; +}; diff --git a/back/src/adapters/secondary/core/CachingAccessTokenGateway.unit.test.ts b/back/src/adapters/secondary/core/InMemoryCachingGateway.unit.test.ts similarity index 63% rename from back/src/adapters/secondary/core/CachingAccessTokenGateway.unit.test.ts rename to back/src/adapters/secondary/core/InMemoryCachingGateway.unit.test.ts index c2be986970..589b2a5e1f 100644 --- a/back/src/adapters/secondary/core/CachingAccessTokenGateway.unit.test.ts +++ b/back/src/adapters/secondary/core/InMemoryCachingGateway.unit.test.ts @@ -1,7 +1,7 @@ import minutesToSeconds from "date-fns/minutesToSeconds"; -import { GetAccessTokenResponse } from "../../../domain/core/ports/AccessTokenGateway"; +import { GetAccessTokenResponse } from "../../../domain/convention/ports/PoleEmploiGateway"; import { CustomTimeGateway } from "./TimeGateway/CustomTimeGateway"; -import { CachingAccessTokenGateway } from "./CachingAccessTokenGateway"; +import { InMemoryCachingGateway } from "./InMemoryCachingGateway"; const testResponse1: GetAccessTokenResponse = { access_token: "token1", @@ -12,26 +12,27 @@ const testResponse2: GetAccessTokenResponse = { expires_in: minutesToSeconds(10), }; -describe("CachingAccessTokenGateway", () => { +describe("InMemoryCachingGateway with GetAccessTokenResponse", () => { let mockGetAccessTokenFn: jest.Mock; let fakeClock: CustomTimeGateway; - let cachedAccessTokenGateway: CachingAccessTokenGateway; + let cachedAccessTokenGateway: InMemoryCachingGateway; beforeEach(() => { mockGetAccessTokenFn = jest.fn(); fakeClock = new CustomTimeGateway(); - cachedAccessTokenGateway = new CachingAccessTokenGateway( - { - getAccessToken: mockGetAccessTokenFn, - }, - fakeClock, - ); + cachedAccessTokenGateway = + new InMemoryCachingGateway( + fakeClock, + "expires_in", + ); }); it("fetches a new token if none is cached", async () => { mockGetAccessTokenFn.mockReturnValueOnce(testResponse1); - const response = await cachedAccessTokenGateway.getAccessToken("scope"); + const response = await cachedAccessTokenGateway.caching("scope", () => + mockGetAccessTokenFn("scope"), + ); expect(mockGetAccessTokenFn.mock.calls).toHaveLength(1); expect(mockGetAccessTokenFn.mock.calls[0][0]).toBe("scope"); expect(response).toEqual(testResponse1); @@ -42,13 +43,17 @@ describe("CachingAccessTokenGateway", () => { // Initial call caches the token. fakeClock.setNextDateStr("2021-01-01T00:00:00Z"); - const response1 = await cachedAccessTokenGateway.getAccessToken("scope"); + const response1 = await cachedAccessTokenGateway.caching("scope", () => + mockGetAccessTokenFn("scope"), + ); expect(mockGetAccessTokenFn.mock.calls).toHaveLength(1); expect(response1).toEqual(testResponse1); // Subsequent call returns the cached token. fakeClock.setNextDateStr("2021-01-01T00:09:00Z"); - const response2 = await cachedAccessTokenGateway.getAccessToken("scope"); + const response2 = await cachedAccessTokenGateway.caching("scope", () => + mockGetAccessTokenFn("scope"), + ); expect(mockGetAccessTokenFn.mock.calls).toHaveLength(1); expect(response2).toEqual(testResponse1); }); @@ -60,19 +65,25 @@ describe("CachingAccessTokenGateway", () => { // Initial call caches a token. fakeClock.setNextDateStr("2021-01-01T00:00:00Z"); - const response1 = await cachedAccessTokenGateway.getAccessToken("scope"); + const response1 = await cachedAccessTokenGateway.caching("scope", () => + mockGetAccessTokenFn("scope"), + ); expect(mockGetAccessTokenFn.mock.calls).toHaveLength(1); expect(response1).toEqual(testResponse1); // The TTL of the cached token is exceeded so a new one is fetched. fakeClock.setNextDateStr("2021-01-01T00:10:00Z"); - const response2 = await cachedAccessTokenGateway.getAccessToken("scope"); + const response2 = await cachedAccessTokenGateway.caching("scope", () => + mockGetAccessTokenFn("scope"), + ); expect(mockGetAccessTokenFn.mock.calls).toHaveLength(2); expect(response2).toEqual(testResponse2); // Subsequent calls return the refreshed token. fakeClock.setNextDateStr("2021-01-01T00:19:00Z"); - const response3 = await cachedAccessTokenGateway.getAccessToken("scope"); + const response3 = await cachedAccessTokenGateway.caching("scope", () => + mockGetAccessTokenFn("scope"), + ); expect(mockGetAccessTokenFn.mock.calls).toHaveLength(2); expect(response3).toEqual(testResponse2); }); @@ -84,18 +95,24 @@ describe("CachingAccessTokenGateway", () => { // Initial call caches the token. fakeClock.setNextDateStr("2021-01-01T00:00:00Z"); - const response1 = await cachedAccessTokenGateway.getAccessToken("scope"); + const response1 = await cachedAccessTokenGateway.caching("scope", () => + mockGetAccessTokenFn("scope"), + ); expect(mockGetAccessTokenFn.mock.calls).toHaveLength(1); expect(response1).toEqual(testResponse1); // Not expired yet. fakeClock.setNextDateStr("2021-01-01T00:09:30Z"); - const response2 = await cachedAccessTokenGateway.getAccessToken("scope"); + const response2 = await cachedAccessTokenGateway.caching("scope", () => + mockGetAccessTokenFn("scope"), + ); expect(response2).toEqual(testResponse1); // Now it's expired. fakeClock.setNextDateStr("2021-01-01T00:09:31Z"); - const response3 = await cachedAccessTokenGateway.getAccessToken("scope"); + const response3 = await cachedAccessTokenGateway.caching("scope", () => + mockGetAccessTokenFn("scope"), + ); expect(response3).toEqual(testResponse2); }); @@ -103,9 +120,15 @@ describe("CachingAccessTokenGateway", () => { mockGetAccessTokenFn.mockReturnValue(testResponse1); const responses = await Promise.all([ - cachedAccessTokenGateway.getAccessToken("scope"), - cachedAccessTokenGateway.getAccessToken("scope"), - cachedAccessTokenGateway.getAccessToken("scope"), + cachedAccessTokenGateway.caching("scope", () => + mockGetAccessTokenFn("scope"), + ), + cachedAccessTokenGateway.caching("scope", () => + mockGetAccessTokenFn("scope"), + ), + cachedAccessTokenGateway.caching("scope", () => + mockGetAccessTokenFn("scope"), + ), ]); expect(mockGetAccessTokenFn.mock.calls).toHaveLength(1); diff --git a/back/src/adapters/secondary/NotImplementedDocumentGateway.ts b/back/src/adapters/secondary/documentGateway/NotImplementedDocumentGateway.ts similarity index 65% rename from back/src/adapters/secondary/NotImplementedDocumentGateway.ts rename to back/src/adapters/secondary/documentGateway/NotImplementedDocumentGateway.ts index 642b045ec8..3f63173342 100644 --- a/back/src/adapters/secondary/NotImplementedDocumentGateway.ts +++ b/back/src/adapters/secondary/documentGateway/NotImplementedDocumentGateway.ts @@ -1,6 +1,6 @@ -import { StoredFile } from "../../domain/generic/fileManagement/entity/StoredFile"; -import { DocumentGateway } from "../../domain/generic/fileManagement/port/DocumentGateway"; -import { createLogger } from "../../utils/logger"; +import { StoredFile } from "../../../domain/generic/fileManagement/entity/StoredFile"; +import { DocumentGateway } from "../../../domain/generic/fileManagement/port/DocumentGateway"; +import { createLogger } from "../../../utils/logger"; const logger = createLogger(__filename); diff --git a/back/src/adapters/secondary/S3DocumentGateway.ts b/back/src/adapters/secondary/documentGateway/S3DocumentGateway.ts similarity index 84% rename from back/src/adapters/secondary/S3DocumentGateway.ts rename to back/src/adapters/secondary/documentGateway/S3DocumentGateway.ts index 9e0b05d53e..ecf297e41a 100644 --- a/back/src/adapters/secondary/S3DocumentGateway.ts +++ b/back/src/adapters/secondary/documentGateway/S3DocumentGateway.ts @@ -1,7 +1,7 @@ import * as AWS from "aws-sdk"; -import { StoredFile } from "../../domain/generic/fileManagement/entity/StoredFile"; -import { DocumentGateway } from "../../domain/generic/fileManagement/port/DocumentGateway"; -import { createLogger } from "../../utils/logger"; +import { StoredFile } from "../../../domain/generic/fileManagement/entity/StoredFile"; +import { DocumentGateway } from "../../../domain/generic/fileManagement/port/DocumentGateway"; +import { createLogger } from "../../../utils/logger"; const logger = createLogger(__filename); diff --git a/back/src/adapters/secondary/immersionOffer/InMemoryAccessTokenGateway.ts b/back/src/adapters/secondary/immersionOffer/InMemoryAccessTokenGateway.ts deleted file mode 100644 index bfa198a12e..0000000000 --- a/back/src/adapters/secondary/immersionOffer/InMemoryAccessTokenGateway.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { - AccessTokenGateway, - GetAccessTokenResponse, -} from "../../../domain/core/ports/AccessTokenGateway"; -import { createLogger } from "../../../utils/logger"; - -const logger = createLogger(__filename); - -export class InMemoryAccessTokenGateway implements AccessTokenGateway { - public async getAccessToken(scope: string): Promise { - logger.info({ scope }, "getAccessToken"); - return { - access_token: `fake_access_token_for_scope_${scope}`, - expires_in: 600, - }; - } -} diff --git a/back/src/adapters/secondary/immersionOffer/PoleEmploiAccessTokenGateway.ts b/back/src/adapters/secondary/immersionOffer/PoleEmploiAccessTokenGateway.ts deleted file mode 100644 index b292b96c25..0000000000 --- a/back/src/adapters/secondary/immersionOffer/PoleEmploiAccessTokenGateway.ts +++ /dev/null @@ -1,64 +0,0 @@ -import Bottleneck from "bottleneck"; -import { secondsToMilliseconds } from "date-fns"; -import querystring from "querystring"; -import { - AccessTokenGateway, - GetAccessTokenResponse, -} from "../../../domain/core/ports/AccessTokenGateway"; -import { - RetryableError, - RetryStrategy, -} from "../../../domain/core/ports/RetryStrategy"; -import { - createAxiosInstance, - isRetryableError, - logAxiosError, -} from "../../../utils/axiosUtils"; -import { createLogger } from "../../../utils/logger"; -import { AccessTokenConfig } from "../../primary/config/appConfig"; - -const logger = createLogger(__filename); - -const poleEmploiAccessTokenMaxRequestsPerSeconds = 3; - -export class PoleEmploiAccessTokenGateway implements AccessTokenGateway { - public constructor( - private readonly accessTokenConfig: AccessTokenConfig, - private readonly retryStrategy: RetryStrategy, - ) {} - - public async getAccessToken(scope: string): Promise { - return this.retryStrategy.apply(async () => { - try { - const response = await this.limiter.schedule(() => - createAxiosInstance(logger).post( - `${this.accessTokenConfig.peEnterpriseUrl}/connexion/oauth2/access_token?realm=%2Fpartenaire`, - querystring.stringify({ - grant_type: "client_credentials", - client_id: this.accessTokenConfig.clientId, - client_secret: this.accessTokenConfig.clientSecret, - scope, - }), - { - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - timeout: secondsToMilliseconds(10), - }, - ), - ); - return response.data; - } catch (error: any) { - if (isRetryableError(logger, error)) throw new RetryableError(error); - logAxiosError(logger, error); - throw error; - } - }); - } - - private limiter = new Bottleneck({ - reservoir: poleEmploiAccessTokenMaxRequestsPerSeconds, - reservoirRefreshInterval: 1000, // number of ms - reservoirRefreshAmount: poleEmploiAccessTokenMaxRequestsPerSeconds, - }); -} diff --git a/back/src/adapters/secondary/immersionOffer/inMemoryEstablishmentGroupRepository.ts b/back/src/adapters/secondary/immersionOffer/inMemoryEstablishmentGroupRepository.ts index 3c9e76c014..31f4035d80 100644 --- a/back/src/adapters/secondary/immersionOffer/inMemoryEstablishmentGroupRepository.ts +++ b/back/src/adapters/secondary/immersionOffer/inMemoryEstablishmentGroupRepository.ts @@ -45,7 +45,7 @@ export class InMemoryEstablishmentGroupRepository public set groups(groups: EstablishmentGroupEntity[]) { this.groupsByName = groups.reduce( (acc, group) => ({ ...acc, [group.name]: group }), - {} as Record, + {} satisfies Record, ); } diff --git a/back/src/adapters/secondary/immersionOffer/laBonneBoite/HttpLaBonneBoiteAPI.manual.test.ts b/back/src/adapters/secondary/immersionOffer/laBonneBoite/HttpLaBonneBoiteAPI.manual.test.ts index fdf5774b25..45149be25b 100644 --- a/back/src/adapters/secondary/immersionOffer/laBonneBoite/HttpLaBonneBoiteAPI.manual.test.ts +++ b/back/src/adapters/secondary/immersionOffer/laBonneBoite/HttpLaBonneBoiteAPI.manual.test.ts @@ -1,23 +1,35 @@ +import axios from "axios"; +import { GetAccessTokenResponse } from "../../../../domain/convention/ports/PoleEmploiGateway"; import { noRetries } from "../../../../domain/core/ports/RetryStrategy"; import { LaBonneBoiteRequestParams } from "../../../../domain/immersionOffer/ports/LaBonneBoiteAPI"; import { AppConfig } from "../../../primary/config/appConfig"; import { configureCreateHttpClientForExternalApi } from "../../../primary/config/createHttpClientForExternalApi"; -import { PoleEmploiAccessTokenGateway } from "../PoleEmploiAccessTokenGateway"; +import { InMemoryCachingGateway } from "../../core/InMemoryCachingGateway"; +import { RealTimeGateway } from "../../core/TimeGateway/RealTimeGateway"; +import { HttpPoleEmploiGateway } from "../../poleEmploi/HttpPoleEmploiGateway"; +import { createPoleEmploiTargets } from "../../poleEmploi/PoleEmploi.targets"; import { HttpLaBonneBoiteAPI } from "./HttpLaBonneBoiteAPI"; import { createLbbTargets } from "./LaBonneBoiteTargets"; const config = AppConfig.createFromEnv(); -const accessTokenGateway = new PoleEmploiAccessTokenGateway( - config.poleEmploiAccessTokenConfig, - noRetries, -); const getAPI = () => new HttpLaBonneBoiteAPI( configureCreateHttpClientForExternalApi()( createLbbTargets(config.peApiUrl), ), - accessTokenGateway, + new HttpPoleEmploiGateway( + configureCreateHttpClientForExternalApi( + axios.create({ timeout: config.externalAxiosTimeout }), + )(createPoleEmploiTargets(config.peApiUrl)), + new InMemoryCachingGateway( + new RealTimeGateway(), + "expires_in", + ), + config.peApiUrl, + config.poleEmploiAccessTokenConfig, + noRetries, + ), config.poleEmploiClientId, ); diff --git a/back/src/adapters/secondary/immersionOffer/laBonneBoite/HttpLaBonneBoiteAPI.ts b/back/src/adapters/secondary/immersionOffer/laBonneBoite/HttpLaBonneBoiteAPI.ts index 205a08ee74..6259c8634e 100644 --- a/back/src/adapters/secondary/immersionOffer/laBonneBoite/HttpLaBonneBoiteAPI.ts +++ b/back/src/adapters/secondary/immersionOffer/laBonneBoite/HttpLaBonneBoiteAPI.ts @@ -1,6 +1,6 @@ import Bottleneck from "bottleneck"; import { HttpClient } from "http-client"; -import { AccessTokenGateway } from "../../../../domain/core/ports/AccessTokenGateway"; +import { PoleEmploiGateway } from "../../../../domain/convention/ports/PoleEmploiGateway"; import { LaBonneBoiteAPI, LaBonneBoiteRequestParams, @@ -19,7 +19,7 @@ const lbbMaxQueryPerSeconds = 1; export class HttpLaBonneBoiteAPI implements LaBonneBoiteAPI { constructor( private readonly httpClient: HttpClient, - private readonly accessTokenGateway: AccessTokenGateway, + private readonly poleEmploiGateway: PoleEmploiGateway, private readonly poleEmploiClientId: string, ) {} @@ -27,7 +27,7 @@ export class HttpLaBonneBoiteAPI implements LaBonneBoiteAPI { searchParams: LaBonneBoiteRequestParams, ): Promise { const { responseBody } = await this.limiter.schedule(async () => { - const accessToken = await this.accessTokenGateway.getAccessToken( + const accessToken = await this.poleEmploiGateway.getAccessToken( `application_${this.poleEmploiClientId} api_labonneboitev1`, ); diff --git a/back/src/adapters/secondary/immersionOffer/peAgenciesReferential/HttpPeAgenciesReferential.manual.test.ts b/back/src/adapters/secondary/immersionOffer/peAgenciesReferential/HttpPeAgenciesReferential.manual.test.ts index b0976d7211..d2364629d6 100644 --- a/back/src/adapters/secondary/immersionOffer/peAgenciesReferential/HttpPeAgenciesReferential.manual.test.ts +++ b/back/src/adapters/secondary/immersionOffer/peAgenciesReferential/HttpPeAgenciesReferential.manual.test.ts @@ -1,17 +1,30 @@ +import axios from "axios"; +import { GetAccessTokenResponse } from "../../../../domain/convention/ports/PoleEmploiGateway"; import { noRetries } from "../../../../domain/core/ports/RetryStrategy"; import { AppConfig } from "../../../primary/config/appConfig"; -import { PoleEmploiAccessTokenGateway } from "../PoleEmploiAccessTokenGateway"; +import { configureCreateHttpClientForExternalApi } from "../../../primary/config/createHttpClientForExternalApi"; +import { InMemoryCachingGateway } from "../../core/InMemoryCachingGateway"; +import { RealTimeGateway } from "../../core/TimeGateway/RealTimeGateway"; +import { HttpPoleEmploiGateway } from "../../poleEmploi/HttpPoleEmploiGateway"; +import { createPoleEmploiTargets } from "../../poleEmploi/PoleEmploi.targets"; import { HttpPeAgenciesReferential } from "./HttpPeAgenciesReferential"; const config = AppConfig.createFromEnv(); -const accessTokenGateway = new PoleEmploiAccessTokenGateway( - config.poleEmploiAccessTokenConfig, - noRetries, -); const referencielAgencesPE = new HttpPeAgenciesReferential( config.peApiUrl, - accessTokenGateway, + new HttpPoleEmploiGateway( + configureCreateHttpClientForExternalApi( + axios.create({ timeout: config.externalAxiosTimeout }), + )(createPoleEmploiTargets(config.peApiUrl)), + new InMemoryCachingGateway( + new RealTimeGateway(), + "expires_in", + ), + config.peApiUrl, + config.poleEmploiAccessTokenConfig, + noRetries, + ), config.poleEmploiClientId, ); diff --git a/back/src/adapters/secondary/immersionOffer/peAgenciesReferential/HttpPeAgenciesReferential.ts b/back/src/adapters/secondary/immersionOffer/peAgenciesReferential/HttpPeAgenciesReferential.ts index b13e90e7e4..eb14ffd5c1 100644 --- a/back/src/adapters/secondary/immersionOffer/peAgenciesReferential/HttpPeAgenciesReferential.ts +++ b/back/src/adapters/secondary/immersionOffer/peAgenciesReferential/HttpPeAgenciesReferential.ts @@ -1,6 +1,6 @@ import type { AxiosInstance } from "axios"; import { AbsoluteUrl } from "shared"; -import { AccessTokenGateway } from "../../../../domain/core/ports/AccessTokenGateway"; +import { PoleEmploiGateway } from "../../../../domain/convention/ports/PoleEmploiGateway"; import { PeAgenciesReferential, PeAgencyFromReferenciel, @@ -16,7 +16,7 @@ export class HttpPeAgenciesReferential implements PeAgenciesReferential { constructor( peApiUrl: AbsoluteUrl, - private readonly accessTokenGateway: AccessTokenGateway, + private readonly poleEmploiGateway: PoleEmploiGateway, private readonly poleEmploiClientId: string, ) { this.axios = createAxiosInstance(logger); @@ -24,7 +24,7 @@ export class HttpPeAgenciesReferential implements PeAgenciesReferential { } async getPeAgencies(): Promise { - const accessToken = await this.accessTokenGateway.getAccessToken( + const accessToken = await this.poleEmploiGateway.getAccessToken( `application_${this.poleEmploiClientId} api_referentielagencesv1 organisationpe`, ); diff --git a/back/src/adapters/secondary/immersionOffer/poleEmploi/HttpPoleEmploiGateway.ts b/back/src/adapters/secondary/immersionOffer/poleEmploi/HttpPoleEmploiGateway.ts deleted file mode 100644 index 6c4f98f8ab..0000000000 --- a/back/src/adapters/secondary/immersionOffer/poleEmploi/HttpPoleEmploiGateway.ts +++ /dev/null @@ -1,86 +0,0 @@ -import axios from "axios"; -import Bottleneck from "bottleneck"; -import { AbsoluteUrl } from "shared"; -import { HttpClient, HttpResponse } from "http-client"; -import { - PoleEmploiBroadcastResponse, - PoleEmploiConvention, - PoleEmploiGateway, -} from "../../../../domain/convention/ports/PoleEmploiGateway"; -import { AccessTokenGateway } from "../../../../domain/core/ports/AccessTokenGateway"; -import { createLogger } from "../../../../utils/logger"; -import { notifyObjectDiscord } from "../../../../utils/notifyDiscord"; -import { getPeTestPrefix, PoleEmploiTargets } from "./PoleEmploi.targets"; - -const logger = createLogger(__filename); - -const peBroadcastMaxRatePerSecond = 3; - -export class HttpPoleEmploiGateway implements PoleEmploiGateway { - private peTestPrefix: "test" | ""; - - constructor( - private readonly httpClient: HttpClient, - private readonly peApiUrl: AbsoluteUrl, - private readonly accessTokenGateway: AccessTokenGateway, - ) { - this.peTestPrefix = getPeTestPrefix(peApiUrl); - } - - public async notifyOnConventionUpdated( - poleEmploiConvention: PoleEmploiConvention, - ): Promise { - return this.postPoleEmploiConvention(poleEmploiConvention) - .then((response) => ({ status: response.status as 200 | 201 })) - .catch((error) => { - if (!axios.isAxiosError(error) || !error.response) { - throw error; - } - - if (error.response.status === 404) { - return { - status: 404, - message: error.response.data?.message, - }; - } - - const errorObject = { - _title: "PeBroadcastError", - status: "errored", - httpStatus: error.response.status, - message: error.message, - axiosBody: error.response.data, - }; - logger.error(errorObject); - notifyObjectDiscord(errorObject); - - return { - status: error.response.status, - message: error.response.data?.message, - }; - }); - } - - private async postPoleEmploiConvention( - poleEmploiConvention: PoleEmploiConvention, - ): Promise> { - const accessTokenResponse = await this.accessTokenGateway.getAccessToken( - `echangespmsmp api_${this.peTestPrefix}immersion-prov2`, - ); - - return this.limiter.schedule(() => - this.httpClient.broadcastConvention({ - body: poleEmploiConvention, - headers: { - authorization: `Bearer ${accessTokenResponse.access_token}`, - }, - }), - ); - } - - private limiter = new Bottleneck({ - reservoir: peBroadcastMaxRatePerSecond, - reservoirRefreshInterval: 1000, // number of ms - reservoirRefreshAmount: peBroadcastMaxRatePerSecond, - }); -} diff --git a/back/src/adapters/secondary/immersionOffer/poleEmploi/HttpPoleEmploiGateway.manual.test.ts b/back/src/adapters/secondary/poleEmploi/HttpPoleEmploiGateway.manual.test.ts similarity index 84% rename from back/src/adapters/secondary/immersionOffer/poleEmploi/HttpPoleEmploiGateway.manual.test.ts rename to back/src/adapters/secondary/poleEmploi/HttpPoleEmploiGateway.manual.test.ts index dbf262b7e3..9111a463d3 100644 --- a/back/src/adapters/secondary/immersionOffer/poleEmploi/HttpPoleEmploiGateway.manual.test.ts +++ b/back/src/adapters/secondary/poleEmploi/HttpPoleEmploiGateway.manual.test.ts @@ -1,29 +1,34 @@ import { expectToEqual } from "shared"; import { + GetAccessTokenResponse, PoleEmploiBroadcastResponse, PoleEmploiConvention, -} from "../../../../domain/convention/ports/PoleEmploiGateway"; -import { noRetries } from "../../../../domain/core/ports/RetryStrategy"; -import { AppConfig } from "../../../primary/config/appConfig"; -import { configureCreateHttpClientForExternalApi } from "../../../primary/config/createHttpClientForExternalApi"; -import { PoleEmploiAccessTokenGateway } from "../PoleEmploiAccessTokenGateway"; +} from "../../../domain/convention/ports/PoleEmploiGateway"; +import { noRetries } from "../../../domain/core/ports/RetryStrategy"; +import { AppConfig } from "../../primary/config/appConfig"; +import { configureCreateHttpClientForExternalApi } from "../../primary/config/createHttpClientForExternalApi"; +import { InMemoryCachingGateway } from "../core/InMemoryCachingGateway"; +import { RealTimeGateway } from "../core/TimeGateway/RealTimeGateway"; import { HttpPoleEmploiGateway } from "./HttpPoleEmploiGateway"; import { createPoleEmploiTargets } from "./PoleEmploi.targets"; const config = AppConfig.createFromEnv(); -const accessTokenGateway = new PoleEmploiAccessTokenGateway( - config.poleEmploiAccessTokenConfig, - noRetries, +const cachingGateway = new InMemoryCachingGateway( + new RealTimeGateway(), + "expires_in", ); const getAPI = () => { const httpClient = configureCreateHttpClientForExternalApi()( createPoleEmploiTargets(config.peApiUrl), ); + return new HttpPoleEmploiGateway( httpClient, + cachingGateway, config.peApiUrl, - accessTokenGateway, + config.poleEmploiAccessTokenConfig, + noRetries, ); }; diff --git a/back/src/adapters/secondary/poleEmploi/HttpPoleEmploiGateway.ts b/back/src/adapters/secondary/poleEmploi/HttpPoleEmploiGateway.ts new file mode 100644 index 0000000000..ac340fffa2 --- /dev/null +++ b/back/src/adapters/secondary/poleEmploi/HttpPoleEmploiGateway.ts @@ -0,0 +1,133 @@ +import axios from "axios"; +import Bottleneck from "bottleneck"; +import { secondsToMilliseconds } from "date-fns"; +import querystring from "querystring"; +import { AbsoluteUrl } from "shared"; +import { HttpClient, HttpResponse } from "http-client"; +import { + GetAccessTokenResponse, + PoleEmploiBroadcastResponse, + PoleEmploiConvention, + PoleEmploiGateway, +} from "../../../domain/convention/ports/PoleEmploiGateway"; +import { + RetryableError, + RetryStrategy, +} from "../../../domain/core/ports/RetryStrategy"; +import { + createAxiosInstance, + isRetryableError, + logAxiosError, +} from "../../../utils/axiosUtils"; +import { createLogger } from "../../../utils/logger"; +import { notifyObjectDiscord } from "../../../utils/notifyDiscord"; +import { AccessTokenConfig } from "../../primary/config/appConfig"; +import { InMemoryCachingGateway } from "../core/InMemoryCachingGateway"; +import { getPeTestPrefix, PoleEmploiTargets } from "./PoleEmploi.targets"; + +const logger = createLogger(__filename); + +const poleEmploiMaxRequestsPerSeconds = 3; + +export class HttpPoleEmploiGateway implements PoleEmploiGateway { + private peTestPrefix: "test" | ""; + + constructor( + private readonly httpClient: HttpClient, + private readonly caching: InMemoryCachingGateway, + peApiUrl: AbsoluteUrl, + private readonly accessTokenConfig: AccessTokenConfig, + private readonly retryStrategy: RetryStrategy, + ) { + this.peTestPrefix = getPeTestPrefix(peApiUrl); + } + + public async notifyOnConventionUpdated( + poleEmploiConvention: PoleEmploiConvention, + ): Promise { + return this.postPoleEmploiConvention(poleEmploiConvention) + .then((response) => ({ status: response.status as 200 | 201 })) + .catch((error) => { + if (!axios.isAxiosError(error) || !error.response) { + throw error; + } + + if (error.response.status === 404) { + return { + status: 404, + message: error.response.data?.message, + }; + } + + const errorObject = { + _title: "PeBroadcastError", + status: "errored", + httpStatus: error.response.status, + message: error.message, + axiosBody: error.response.data, + }; + logger.error(errorObject); + notifyObjectDiscord(errorObject); + + return { + status: error.response.status, + message: error.response.data?.message, + }; + }); + } + + private async postPoleEmploiConvention( + poleEmploiConvention: PoleEmploiConvention, + ): Promise> { + const accessTokenResponse = await this.getAccessToken( + `echangespmsmp api_${this.peTestPrefix}immersion-prov2`, + ); + + return this.limiter.schedule(() => + this.httpClient.broadcastConvention({ + body: poleEmploiConvention, + headers: { + authorization: `Bearer ${accessTokenResponse.access_token}`, + }, + }), + ); + } + + public async getAccessToken(scope: string): Promise { + return this.caching.caching(scope, () => + this.retryStrategy.apply(() => + this.limiter.schedule(() => + createAxiosInstance(logger) + .post( + `${this.accessTokenConfig.peEnterpriseUrl}/connexion/oauth2/access_token?realm=%2Fpartenaire`, + querystring.stringify({ + grant_type: "client_credentials", + client_id: this.accessTokenConfig.clientId, + client_secret: this.accessTokenConfig.clientSecret, + scope, + }), + { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + timeout: secondsToMilliseconds(10), + }, + ) + .then((response) => response.data) + .catch((error) => { + if (isRetryableError(logger, error)) + throw new RetryableError(error); + logAxiosError(logger, error); + throw error; + }), + ), + ), + ); + } + + private limiter = new Bottleneck({ + reservoir: poleEmploiMaxRequestsPerSeconds, + reservoirRefreshInterval: 1000, // number of ms + reservoirRefreshAmount: poleEmploiMaxRequestsPerSeconds, + }); +} diff --git a/back/src/adapters/secondary/immersionOffer/poleEmploi/InMemoryPoleEmploiGateway.ts b/back/src/adapters/secondary/poleEmploi/InMemoryPoleEmploiGateway.ts similarity index 59% rename from back/src/adapters/secondary/immersionOffer/poleEmploi/InMemoryPoleEmploiGateway.ts rename to back/src/adapters/secondary/poleEmploi/InMemoryPoleEmploiGateway.ts index 820106cb0b..08602a3e3e 100644 --- a/back/src/adapters/secondary/immersionOffer/poleEmploi/InMemoryPoleEmploiGateway.ts +++ b/back/src/adapters/secondary/poleEmploi/InMemoryPoleEmploiGateway.ts @@ -1,8 +1,9 @@ import { + GetAccessTokenResponse, PoleEmploiBroadcastResponse, PoleEmploiConvention, PoleEmploiGateway, -} from "../../../../domain/convention/ports/PoleEmploiGateway"; +} from "../../../domain/convention/ports/PoleEmploiGateway"; export class InMemoryPoleEmploiGateway implements PoleEmploiGateway { constructor(public notifications: PoleEmploiConvention[] = []) {} @@ -16,7 +17,16 @@ export class InMemoryPoleEmploiGateway implements PoleEmploiGateway { return this.nextResponse; } - setNextResponse(response: PoleEmploiBroadcastResponse) { + public async getAccessToken(scope: string): Promise { + return { + access_token: `fake_access_token_for_scope_${scope}`, + expires_in: 600, + }; + } + + //For testing purpose + + public setNextResponse(response: PoleEmploiBroadcastResponse) { this.nextResponse = response; } } diff --git a/back/src/adapters/secondary/immersionOffer/poleEmploi/PoleEmploi.targets.ts b/back/src/adapters/secondary/poleEmploi/PoleEmploi.targets.ts similarity index 89% rename from back/src/adapters/secondary/immersionOffer/poleEmploi/PoleEmploi.targets.ts rename to back/src/adapters/secondary/poleEmploi/PoleEmploi.targets.ts index fe6b885592..b763f9bdd8 100644 --- a/back/src/adapters/secondary/immersionOffer/poleEmploi/PoleEmploi.targets.ts +++ b/back/src/adapters/secondary/poleEmploi/PoleEmploi.targets.ts @@ -1,6 +1,6 @@ import { AbsoluteUrl, withValidateHeadersAuthorization } from "shared"; import { createTarget, createTargets } from "http-client"; -import { PoleEmploiConvention } from "../../../../domain/convention/ports/PoleEmploiGateway"; +import { PoleEmploiConvention } from "../../../domain/convention/ports/PoleEmploiGateway"; export const getPeTestPrefix = (peApiUrl: AbsoluteUrl) => ["https://api.peio.pe-qvr.fr", "https://api-r.es-qvr.fr"].includes(peApiUrl) diff --git a/back/src/domain/convention/ports/PoleEmploiGateway.ts b/back/src/domain/convention/ports/PoleEmploiGateway.ts index 0e6e1ff4dd..dbb12b344b 100644 --- a/back/src/domain/convention/ports/PoleEmploiGateway.ts +++ b/back/src/domain/convention/ports/PoleEmploiGateway.ts @@ -72,8 +72,16 @@ export const isBroadcastResponseOk = ( ): response is PeBroadcastSuccessResponse => [200, 201].includes(response.status); +// https://pole-emploi.io/data/documentation/utilisation-api-pole-emploi/generer-access-token +export type GetAccessTokenResponse = { + access_token: string; + expires_in: number; +}; + export interface PoleEmploiGateway { notifyOnConventionUpdated: ( poleEmploiConvention: PoleEmploiConvention, ) => Promise; + + getAccessToken: (scope: string) => Promise; } diff --git a/back/src/domain/convention/useCases/ConventionsReminder.unit.test.ts b/back/src/domain/convention/useCases/ConventionsReminder.unit.test.ts index 4fae2d74a9..6592235996 100644 --- a/back/src/domain/convention/useCases/ConventionsReminder.unit.test.ts +++ b/back/src/domain/convention/useCases/ConventionsReminder.unit.test.ts @@ -2,6 +2,7 @@ import { addBusinessDays, differenceInBusinessDays } from "date-fns"; import { ConventionDto, ConventionDtoBuilder, + ConventionId, ConventionStatus, conventionStatuses, expectToEqual, @@ -325,5 +326,5 @@ const makeOneConventionOfEachStatuses = ({ const toConventionRepoRecord = (conventions: ConventionDto[]) => conventions.reduce( (acc, item) => ({ ...acc, [item["id"]]: item }), - {} as Record, + {} satisfies Record, ); diff --git a/back/src/domain/convention/useCases/broadcast/BroadcastToPoleEmploiOnConventionUpdates.unit.test.ts b/back/src/domain/convention/useCases/broadcast/BroadcastToPoleEmploiOnConventionUpdates.unit.test.ts index 9a04d42b7a..c533519bdc 100644 --- a/back/src/domain/convention/useCases/broadcast/BroadcastToPoleEmploiOnConventionUpdates.unit.test.ts +++ b/back/src/domain/convention/useCases/broadcast/BroadcastToPoleEmploiOnConventionUpdates.unit.test.ts @@ -8,9 +8,9 @@ import { } from "shared"; import { createInMemoryUow } from "../../../../adapters/primary/config/uowConfig"; import { CustomTimeGateway } from "../../../../adapters/secondary/core/TimeGateway/CustomTimeGateway"; -import { InMemoryPoleEmploiGateway } from "../../../../adapters/secondary/immersionOffer/poleEmploi/InMemoryPoleEmploiGateway"; import { InMemoryFeatureFlagRepository } from "../../../../adapters/secondary/InMemoryFeatureFlagRepository"; import { InMemoryUowPerformer } from "../../../../adapters/secondary/InMemoryUowPerformer"; +import { InMemoryPoleEmploiGateway } from "../../../../adapters/secondary/poleEmploi/InMemoryPoleEmploiGateway"; import { BroadcastToPoleEmploiOnConventionUpdates } from "./BroadcastToPoleEmploiOnConventionUpdates"; const prepareUseCase = async ({ diff --git a/back/src/domain/core/ports/AccessTokenGateway.ts b/back/src/domain/core/ports/AccessTokenGateway.ts deleted file mode 100644 index 2d178d7d9c..0000000000 --- a/back/src/domain/core/ports/AccessTokenGateway.ts +++ /dev/null @@ -1,9 +0,0 @@ -// https://pole-emploi.io/data/documentation/utilisation-api-pole-emploi/generer-access-token -export type GetAccessTokenResponse = { - access_token: string; - expires_in: number; -}; - -export interface AccessTokenGateway { - getAccessToken: (scope: string) => Promise; -}