espace-paie-odentas/lib/upstream.ts

156 lines
No EOL
4.7 KiB
TypeScript

// lib/upstream.ts
// Helper pour proxy côté serveur vers l'API upstream (API Gateway/Lambda).
// Objectif : être résilient aux variantes de chemin (avec /api ou sans),
// centraliser l'URL de base et uniformiser les appels.
//
// Variables d'environnement attendues (côté SERVEUR, non NEXT_PUBLIC) :
// - UPSTREAM_API_BASE ex: https://xxxx.execute-api.eu-west-3.amazonaws.com/default
// - UPSTREAM_API_PREFIX ex: "" (par défaut) ou "/api" pour préférer une des variantes
//
// Option debug : DEBUG_UPSTREAM=1 (en dev) pour logger les URLs tentées.
const BASE = process.env.UPSTREAM_API_BASE ?? "";
const PREFERRED_PREFIX = process.env.UPSTREAM_API_PREFIX ?? "";
const DEBUG_UPSTREAM =
process.env.NODE_ENV !== "production" && process.env.DEBUG_UPSTREAM === "1";
export type UpstreamFetchOpts = {
method?: string;
headers?: HeadersInit;
// Querystring à forwarder (ex: { year: "2025", page: "1" })
searchParams?: URLSearchParams | Record<string, string | number | undefined>;
// Corps brut (JSON stringifié par l'appelant si nécessaire)
body?: BodyInit | null;
};
/**
* Assure un leading slash unique ("/x" plutôt que "x" ou "//x")
*/
function withLeadingSlash(path: string): string {
if (!path) return "/";
return path.startsWith("/") ? path.replace(/\/{2,}/g, "/") : `/${path}`;
}
/**
* Construit une URL absolue à partir d'une base et d'un chemin relatif.
* - Normalise les "/" doublons
* - Ajoute les query params si fournis
*/
function buildUrl(
base: string,
path: string,
query?: UpstreamFetchOpts["searchParams"]
): string {
const baseNormalized = base.endsWith("/") ? base : base + "/";
// On enlève le premier "/" pour laisser URL gérer correctement la concaténation
const relative = withLeadingSlash(path).replace(/^\//, "");
const url = new URL(relative, baseNormalized);
if (query) {
const usp =
query instanceof URLSearchParams
? query
: new URLSearchParams(
Object.entries(query).flatMap(([k, v]) =>
v === undefined ? [] : [[k, String(v)]]
)
);
usp.forEach((v, k) => url.searchParams.set(k, v));
}
return url.toString();
}
/**
* fetchUpstream: tente d'abord la variante préférée (UPSTREAM_API_PREFIX),
* puis retombe sur l'autre (/api ↔︎ "") si 404.
* Pour toute autre erreur (401, 403, 500...), on remonte la réponse immédiatement.
*/
export async function fetchUpstream(
path: string,
opts: UpstreamFetchOpts = {}
): Promise<Response> {
if (!BASE) {
throw new Error(
"UPSTREAM_API_BASE manquant. Définis la variable d'environnement côté serveur."
);
}
// Ordre de préférence pour les préfixes
const prefixes =
PREFERRED_PREFIX === "/api"
? ["/api", ""]
: PREFERRED_PREFIX === ""
? ["", "/api"]
: [PREFERRED_PREFIX, PREFERRED_PREFIX === "/api" ? "" : "/api"];
const attempts = prefixes.map((p) =>
p ? withLeadingSlash(p) + withLeadingSlash(path) : withLeadingSlash(path)
);
let last404: Response | undefined;
for (const candidate of attempts) {
const url = buildUrl(BASE, candidate, opts.searchParams);
if (DEBUG_UPSTREAM) {
// eslint-disable-next-line no-console
console.log("UPSTREAM TRY:", url);
}
const resp = await fetch(url, {
method: opts.method ?? "GET",
headers: opts.headers,
body: opts.body ?? null,
cache: "no-store",
// Important quand l'upstream est externe : ne jamais envoyer automatiquement des cookies serveur
// (l'upstream ne les comprendrait pas). On ne met pas credentials ici.
});
if (resp.ok) return resp;
if (resp.status === 404) {
last404 = resp;
continue; // essaie l'autre variante
}
// Pour les autres codes, on renvoie directement
return resp;
}
// Toutes les tentatives ont renvoyé 404
if (last404) return last404;
// Sécurité : ne devrait pas arriver
return new Response("Not Found", { status: 404 });
}
/**
* Helper pratique : récupère le JSON directement, avec message d'erreur enrichi.
* Laisse remonter les codes HTTP. À utiliser dans les route handlers Next.
*/
export async function upstreamJson<T = unknown>(
path: string,
opts: UpstreamFetchOpts = {}
): Promise<{ data: T; response: Response }> {
const resp = await fetchUpstream(path, opts);
const text = await resp.text();
if (!resp.ok) {
// On renvoie quand même le body texte pour faciliter le debug côté route
throw new Error(
`Upstream error ${resp.status} on ${path}: ${text.slice(0, 500)}`
);
}
try {
const data = text ? (JSON.parse(text) as T) : ({} as T);
return { data, response: resp };
} catch {
throw new Error(
`Upstream JSON parse error on ${path}: ${text.slice(0, 500)}`
);
}
}