Pattern Adapter
Faites fonctionner ensemble des signatures de fonctions incompatibles sans modifier aucun des deux côtés.
Le Problème
Vous construisez une app de cartographie (appelons-la ViaMikeline) qui affiche des points d'intérêt depuis deux sources de données ouvertes. L'API IRVE (bornes de recharge) retourne du JSON plat avec { nom_station, consolidated_latitude, consolidated_longitude, puissance_nominale, prise_type_2 }. L'API stations-service retourne du JSON imbriqué avec { geom: { lon, lat }, gazole_prix, sp95_prix, carburants_disponibles }. Votre carte attend un format unique { name, coords: [lat, lng], meta }.
L'approche naïve :
function toMapFeature(source: "charging" | "fuel", raw: unknown) {
if (source === "charging") {
const s = raw as IRVEStation;
return { name: s.nom_station, coords: [s.consolidated_latitude, s.consolidated_longitude], meta: { power: s.puissance_nominale } };
}
if (source === "fuel") {
const f = raw as FuelStation;
return { name: f.adresse, coords: [f.geom.lat, f.geom.lon], meta: { prix: f.gazole_prix } };
}
}
La logique de conversion éparpillée dans l'UI. Ajouter une troisième source implique plus de conditions partout.
La Solution
adapt(source, mapInput, mapOutput) enveloppe une fonction en transformant son input avant l'appel et son output après. Chaque API a sa propre fonction de fetch, son format d'URL et sa forme de réponse. adapt fait le pont pour que le consommateur voie une signature unique et uniforme : (bbox: BBox) => Promise<MapFeature[]>.
import { adapt } from "@pithos/core/eidos/adapter/adapter";
interface MapFeature {
id: string;
name: string;
coords: [number, number];
meta: Record<string, string | number>;
}
// Raw fetch functions — each returns its own API-specific shape
async function rawFetchCharging(url: string): Promise<IRVEStation[]> { /* ... */ }
async function rawFetchFuel(url: string): Promise<FuelStation[]> { /* ... */ }
// adapt() wraps each raw fetch:
// mapInput: BBox → API-specific URL (different query format per API)
// mapOutput: raw records → MapFeature[] (different field mapping per API)
const fetchChargingPOIs = adapt(
rawFetchCharging, // source: (url: string) => Promise<IRVEStation[]>
(bbox: BBox): string => { // mapInput: BBox → IRVE URL format
const where = `in_bbox(coordonneesxy, ${bbox.west}, ${bbox.south}, ${bbox.east}, ${bbox.north})`;
return `https://odre.opendatasoft.com/api/...?where=${where}`;
},
async (records): Promise<MapFeature[]> => // mapOutput: IRVEStation[] → MapFeature[]
(await records).map((s) => ({
id: s.id_station_itinerance,
name: s.nom_station,
coords: [s.consolidated_latitude, s.consolidated_longitude],
meta: { power: `${s.puissance_nominale} kW` },
})),
);
const fetchFuelPOIs = adapt(
rawFetchFuel, // source: (url: string) => Promise<FuelStation[]>
(bbox: BBox): string => { // mapInput: BBox → Fuel URL format
const where = `in_bbox(geom, ${bbox.south}, ${bbox.west}, ${bbox.north}, ${bbox.east})`;
return `https://data.economie.gouv.fr/api/...?where=${where}`;
},
async (records): Promise<MapFeature[]> => // mapOutput: FuelStation[] → MapFeature[]
(await records).map((f) => ({
id: `fuel-${f.id}`,
name: `Station ${f.ville ?? "?"}`,
coords: [f.geom.lat, f.geom.lon],
meta: { gazole: f.gazole_prix ?? "—" },
})),
);
// Consumer code — same signature, doesn't know which API is behind
const charging = await fetchChargingPOIs(bbox); // MapFeature[]
const fuel = await fetchFuelPOIs(bbox); // MapFeature[]
Les deux fonctions adaptées ont la même signature : (bbox: BBox) => Promise<MapFeature[]>. L'API IRVE attend in_bbox(coordonneesxy, west, south, east, north) tandis que l'API carburant attend in_bbox(geom, south, west, north, east) : ordres de paramètres différents, noms de champs différents, formes de réponse différentes. adapt gère les deux côtés : la conversion d'input (BBox → URL) et la conversion d'output (enregistrements bruts → MapFeature). Ajouter une troisième source de données = un nouvel appel adapt(), zéro changement en aval.
Démo
La carte a besoin de MapFeature[] à partir d'une BBox. Chaque API attend un format d'URL différent : IRVE utilise in_bbox(coordonneesxy, west, south, east, north), carburant utilise in_bbox(geom, south, west, north, east). Elles retournent aussi des formes d'enregistrements différentes. adapt() enveloppe chaque fetch brut pour que l'input (BBox → URL) et l'output (JSON brut → MapFeature[]) soient convertis au même endroit. La carte appelle fetchCharging(bbox) et fetchFuel(bbox) avec la même signature, sans connaître les différences d'API derrière.
Dans une vraie application, ces adapters vivraient dans un service backend qui ingère les données de multiples sources, les normalise via des adapters, et stocke les enregistrements MapFeature consolidés en base de données. Le frontend interrogerait une seule API unifiée. Ici, les adapters tournent côté client pour que vous puissiez les voir en action sans infrastructure serveur.
- adapters.ts
- Usage
/**
* Adapter pattern: adapt() bridges two incompatible APIs into one uniform interface.
*
* - IRVE API (EV charging) has its own URL format and response shape
* - Prix-carburants API (fuel) has a different URL format and response shape
* - adapt(source, mapInput, mapOutput) wraps each so both share:
* (bbox: BBox) => Promise<MapFeature[]>
*/
import { adapt } from "@pithos/core/eidos/adapter/adapter";
import { uniqBy } from "@pithos/core/arkhe/array/uniq-by";
import type { BBox, MapFeature } from "./types";
// ── Raw API shapes ──────────────────────────────────────────────────
interface IRVEStation {
id_station_itinerance: string;
nom_station: string;
adresse_station: string;
nom_operateur: string;
nbre_pdc: number;
puissance_nominale: number;
prise_type_2: string | null;
prise_type_combo_ccs: string | null;
prise_type_chademo: string | null;
gratuit: string | null;
horaires: string | null;
condition_acces: string | null;
consolidated_latitude: number;
consolidated_longitude: number;
}
interface FuelStation {
id: number;
adresse: string | null;
ville: string | null;
departement: string | null;
geom: { lon: number; lat: number };
gazole_prix: number | null;
sp95_prix: number | null;
sp98_prix: number | null;
e10_prix: number | null;
e85_prix: number | null;
carburants_disponibles: string[] | null;
horaires_automate_24_24: string | null;
}
// ── Raw fetch ───────────────────────────────────────────────────────
async function rawFetchCharging(url: string): Promise<IRVEStation[]> {
const res = await fetch(url);
if (!res.ok) throw res;
return (await res.json()).results ?? [];
}
async function rawFetchFuel(url: string): Promise<FuelStation[]> {
const res = await fetch(url);
if (!res.ok) throw res;
return (await res.json()).results ?? [];
}
// ── Transformers ────────────────────────────────────────────────────
function plugTypes(s: IRVEStation): string {
const plugs: string[] = [];
if (s.prise_type_2 === "True") plugs.push("Type 2");
if (s.prise_type_combo_ccs === "True") plugs.push("CCS");
if (s.prise_type_chademo === "True") plugs.push("CHAdeMO");
return plugs.join(", ") || "—";
}
function toChargingFeature(s: IRVEStation): MapFeature {
return {
id: s.id_station_itinerance,
name: s.nom_station,
kind: "charging",
coords: [s.consolidated_latitude, s.consolidated_longitude],
meta: {
operator: s.nom_operateur ?? "—",
address: s.adresse_station ?? "—",
plugs: plugTypes(s),
points: s.nbre_pdc,
power: `${s.puissance_nominale} kW`,
free: s.gratuit === "True" ? "Yes" : s.gratuit === "False" ? "No" : "—",
hours: s.horaires ?? "—",
access: s.condition_acces ?? "—",
},
};
}
function toFuelFeature(s: FuelStation): MapFeature {
const prices: Record<string, number> = {};
if (s.gazole_prix != null) prices["Gazole"] = s.gazole_prix;
if (s.sp95_prix != null) prices["SP95"] = s.sp95_prix;
if (s.sp98_prix != null) prices["SP98"] = s.sp98_prix;
if (s.e10_prix != null) prices["E10"] = s.e10_prix;
if (s.e85_prix != null) prices["E85"] = s.e85_prix;
const best = Object.entries(prices).sort((a, b) => a[1] - b[1])[0];
return {
id: `fuel-${s.id}`,
name: `Station ${s.ville ?? "?"}`,
kind: "fuel",
coords: [s.geom.lat, s.geom.lon],
meta: {
address: s.adresse ?? "—",
city: s.ville ?? "—",
department: s.departement ?? "—",
fuels: (s.carburants_disponibles ?? []).join(", ") || "—",
"best price": best ? `${best[1].toFixed(3)}€ (${best[0]})` : "—",
"24/7": s.horaires_automate_24_24 === "Oui" ? "Yes" : "No",
},
};
}
// ── Adapted functions (the pattern) ─────────────────────────────────
export const adaptedFetchCharging = adapt(
rawFetchCharging,
(bbox: BBox): string => {
const where = `in_bbox(coordonneesxy, ${bbox.west}, ${bbox.south}, ${bbox.east}, ${bbox.north})`;
const select = "id_station_itinerance,nom_station,adresse_station,nom_operateur,nbre_pdc,puissance_nominale,prise_type_2,prise_type_combo_ccs,prise_type_chademo,gratuit,horaires,condition_acces,consolidated_latitude,consolidated_longitude";
return `https://odre.opendatasoft.com/api/explore/v2.1/catalog/datasets/bornes-irve/records?where=${encodeURIComponent(where)}&select=${select}&group_by=${select}&limit=100`;
},
async (records): Promise<MapFeature[]> =>
uniqBy((await records).map(toChargingFeature), (f) => f.id),
);
export const adaptedFetchFuel = adapt(
rawFetchFuel,
(bbox: BBox): string => {
const where = `in_bbox(geom, ${bbox.south}, ${bbox.west}, ${bbox.north}, ${bbox.east})`;
return `https://data.economie.gouv.fr/api/explore/v2.1/catalog/datasets/prix-des-carburants-en-france-flux-instantane-v2/records?where=${encodeURIComponent(where)}&limit=100`;
},
async (records): Promise<MapFeature[]> =>
uniqBy((await records).map(toFuelFeature), (f) => f.id),
);
import { useState, useCallback, useRef } from "react";
import { fetchCharging, fetchFuels } from "@/lib/cache";
import { SOURCES } from "@/data/sources";
import type { MapFeature, SourceType, FeatureKind, BBox } from "@/lib/types";
export function useAdapterMap() {
const [charging, setCharging] = useState<MapFeature[]>([]);
const [fuels, setFuels] = useState<MapFeature[]>([]);
const [fallback, setFallback] = useState({ charging: false, fuel: false });
const [loadingCharging, setLoadingCharging] = useState(false);
const [loadingFuel, setLoadingFuel] = useState(false);
const [activeSources, setActiveSources] = useState<Set<SourceType>>(new Set(SOURCES));
const [mobileTab, setMobileTab] = useState<"map" | "list">("map");
const [popupOpen, setPopupOpen] = useState(false);
const [listOpen, setListOpen] = useState(false);
const [listTab, setListTab] = useState<FeatureKind>("charging");
const fetchRef = useRef(0);
const handleBoundsChange = useCallback((bbox: BBox) => {
const id = ++fetchRef.current;
setLoadingCharging(true);
fetchCharging(bbox).then((result) => {
if (id !== fetchRef.current) return;
setCharging(result.features);
setFallback((prev) => ({ ...prev, charging: result.fallback }));
setLoadingCharging(false);
});
setLoadingFuel(true);
fetchFuels(bbox).then((result) => {
if (id !== fetchRef.current) return;
setFuels(result.features);
setFallback((prev) => ({ ...prev, fuel: result.fallback }));
setLoadingFuel(false);
});
}, []);
const toggleSource = useCallback((source: SourceType) => {
setActiveSources((prev) => {
const next = new Set(prev);
if (next.has(source)) next.delete(source);
else next.add(source);
return next;
});
}, []);
const visibleFeatures: MapFeature[] = [
...(activeSources.has("charging") ? charging : []),
...(activeSources.has("fuel") ? fuels : []),
];
return {
charging, fuels, fallback, loadingCharging, loadingFuel,
activeSources, toggleSource, visibleFeatures,
mobileTab, setMobileTab, popupOpen, setPopupOpen,
listOpen, setListOpen, listTab, setListTab,
handleBoundsChange,
};
}
Analogie
Un adaptateur de prise pour les voyages internationaux. Votre laptop a une prise US, la prise murale est européenne. L'adaptateur ne change ni l'un ni l'autre. Il les rend juste compatibles.
Quand l'Utiliser
- Intégrer des APIs tierces avec des formats de réponse incompatibles
- Normaliser plusieurs sources de données vers une forme commune
- Envelopper du code legacy avec une interface moderne
- Isoler la logique de conversion en un seul endroit
Quand NE PAS l'Utiliser
Si vous contrôlez les deux côtés de l'interface, faites-les simplement correspondre. Un adapter entre deux APIs que vous possédez est un signe que l'une d'elles devrait changer.
API
- adapt — Transformer les arguments d'une fonction avant l'appel
- createAdapter — Construire des adapters réutilisables pour la conversion d'interfaces
ViaMichelin est une marque déposée de Michelin. Elle est mentionnée ici uniquement comme exemple concret à des fins éducatives dans le cadre du fair use nominatif. Ce projet n'est ni affilié, ni approuvé, ni sponsorisé par Michelin ou ViaMichelin de quelque manière que ce soit.