Files
Starseed/frontend/shared/composables/useAddressAutocomplete.ts
T
tristan 8376236a3c feat(front) : util httpExternal + autocomplete adresse BAN (ERP-66)
- httpExternal : client dedie aux API publiques externes (URL absolue,
  sans cookie de session, timeout), seul point d'entree autorise pour un
  $fetch externe (regle frontend n°4).
- useAddressAutocomplete : implementation BAN (api-adresse.data.gouv.fr),
  recherche ville (type=municipality) et adresse, mapping GeoJSON, throw
  en cas d'erreur/timeout (mode degrade cote composant). La recherche
  d'adresse n'impose pas type=housenumber (sinon 0 resultat tant qu'aucun
  numero n'est saisi) — spec-front mise a jour en consequence.
- Tests Vitest : httpExternal, useAddressAutocomplete, et cas limites
  supplementaires pour formatPhoneFR.
2026-06-03 13:29:45 +02:00

118 lines
4.5 KiB
TypeScript

import { httpExternal } from '~/shared/utils/httpExternal'
// Autocompletion d'adresse branchee sur la Base Adresse Nationale (BAN),
// `api-adresse.data.gouv.fr` — service public francais, gratuit, CORS ouvert.
//
// Appel HTTP DIRECT depuis le front (pas de proxy back), conformement a la spec
// M1 (§ API adresse postale). On passe par `httpExternal` et NON `useApi()` :
// la BAN est un domaine externe, sans cookie de session ni enveloppe Hydra.
//
// Contrat (fige) :
// searchCity(postalCode) -> liste { city, postalCode }
// searchAddress(query, cp?) -> liste { label, street, postalCode, city }
// En cas d'erreur/timeout, la methode THROW une AddressAutocompleteUnavailableError.
// Le composant consommateur catch, affiche un toast d'avertissement et bascule
// en saisie libre (MalioInputText).
/** URL de l'endpoint de recherche BAN. */
const BAN_SEARCH_URL = 'https://api-adresse.data.gouv.fr/search/'
/** Une suggestion de ville renvoyee a partir d'un code postal. */
export interface CitySuggestion {
city: string
postalCode: string
}
/** Une suggestion d'adresse complete (saisie assistee du champ « Adresse »). */
export interface AddressSuggestion {
label: string
street: string
postalCode: string
city: string
}
export interface AddressAutocomplete {
searchCity(postalCode: string): Promise<CitySuggestion[]>
searchAddress(query: string, postalCode?: string): Promise<AddressSuggestion[]>
}
/** Erreur signalant que le service d'autocompletion BAN n'est pas disponible. */
export class AddressAutocompleteUnavailableError extends Error {
constructor() {
// Message technique (non affiche tel quel) : le composant remonte son
// propre libelle i18n. Sert au debug / aux logs uniquement.
super('Address autocomplete (BAN) is not available.')
this.name = 'AddressAutocompleteUnavailableError'
}
}
/** Proprietes d'une « feature » GeoJSON renvoyee par la BAN (champs utilises). */
interface BanFeatureProperties {
label?: string
name?: string
street?: string
postcode?: string
city?: string
}
/** Reponse GeoJSON FeatureCollection de la BAN. */
interface BanResponse {
features?: { properties?: BanFeatureProperties }[]
}
export function useAddressAutocomplete(): AddressAutocomplete {
return {
async searchCity(postalCode: string): Promise<CitySuggestion[]> {
let res: BanResponse
try {
res = await httpExternal<BanResponse>(BAN_SEARCH_URL, {
query: { q: postalCode, type: 'municipality' },
})
} catch {
// Reseau coupe, 5xx, timeout... -> mode degrade cote composant.
throw new AddressAutocompleteUnavailableError()
}
return (res.features ?? []).map((feature) => {
const props = feature.properties ?? {}
return {
city: props.city ?? props.name ?? '',
postalCode: props.postcode ?? '',
}
})
},
async searchAddress(query: string, postalCode?: string): Promise<AddressSuggestion[]> {
// IMPORTANT : pas de `type=housenumber` ici. La BAN ne renvoie un
// resultat de ce type qu'une fois un numero saisi → une recherche par
// nom de rue (« boulevard du port ») renverrait 0 resultat pendant
// toute la frappe. Sans filtre `type`, la BAN classe rues + numeros
// par pertinence (comportement d'autocompletion attendu).
// On n'ajoute `postcode` que s'il est fourni (sinon recherche large).
const banQuery: Record<string, string> = { q: query }
if (postalCode) {
banQuery.postcode = postalCode
}
let res: BanResponse
try {
res = await httpExternal<BanResponse>(BAN_SEARCH_URL, { query: banQuery })
} catch {
throw new AddressAutocompleteUnavailableError()
}
return (res.features ?? []).map((feature) => {
const props = feature.properties ?? {}
return {
label: props.label ?? '',
// `name` porte la ligne d'adresse complete (numero + voie) ;
// `street` ne contient que la voie. On privilegie `name`.
street: props.name ?? props.street ?? '',
postalCode: props.postcode ?? '',
city: props.city ?? '',
}
})
},
}
}