179 lines
6.8 KiB
PHP
179 lines
6.8 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Services;
|
||
|
||
use RuntimeException;
|
||
|
||
/**
|
||
* Recherche d'adresses postales sur l'API publique Base Adresse Nationale
|
||
* (api-adresse.data.gouv.fr), service officiel data.gouv.fr, sans clé ni quota
|
||
* déclaré. Adresses françaises uniquement.
|
||
*
|
||
* POURQUOI CÔTÉ SERVEUR : la CSP de PTA impose `connect-src 'self'` — le
|
||
* navigateur ne peut appeler aucun domaine tiers. Le service est donc appelé
|
||
* depuis PHP, comme SchoolHolidayApiService, et le conteneur php a besoin d'un
|
||
* accès réseau sortant. Ne pas contourner en élargissant la CSP.
|
||
*
|
||
* Ce service ne PROPOSE que : il ne touche jamais la base, et l'adresse reste
|
||
* saisissable librement. Une adresse absente de la BAN (lieu-dit, logement de
|
||
* fonction, résidence non référencée) doit rester enregistrable — d'où
|
||
* l'absence de tout contrôle bloquant côté écriture.
|
||
*/
|
||
final class AddressApiService
|
||
{
|
||
private const ENDPOINT = 'https://api-adresse.data.gouv.fr/search/';
|
||
|
||
/** Longueur minimale exigée par l'API distante. */
|
||
public const MIN_QUERY_LENGTH = 3;
|
||
|
||
/** Plafond de propositions renvoyées, aligné sur ce que la liste affiche. */
|
||
private const MAX_LIMIT = 8;
|
||
|
||
/**
|
||
* Propositions d'adresses pour une saisie partielle.
|
||
*
|
||
* `autocomplete=1` demande à la BAN son mode « saisie en cours », qui
|
||
* tolère un dernier mot tronqué — c'est ce qui permet de rafraîchir la
|
||
* liste à chaque frappe plutôt qu'à chaque adresse complète.
|
||
*
|
||
* @return array<int, array<string, mixed>> Propositions normalisées, les
|
||
* plus pertinentes d'abord (ordre de score rendu par la BAN).
|
||
*
|
||
* @throws RuntimeException Saisie trop courte, ou API injoignable.
|
||
*/
|
||
public function search(string $query, int $limit = 5): array
|
||
{
|
||
$query = trim(preg_replace('/\s+/u', ' ', $query) ?? $query);
|
||
$length = function_exists('mb_strlen') ? mb_strlen($query, 'UTF-8') : strlen($query);
|
||
if ($length < self::MIN_QUERY_LENGTH) {
|
||
throw new RuntimeException('Saisissez au moins ' . self::MIN_QUERY_LENGTH . ' caractères.');
|
||
}
|
||
|
||
$limit = max(1, min(self::MAX_LIMIT, $limit));
|
||
$url = self::ENDPOINT . '?' . http_build_query([
|
||
'q' => $query,
|
||
'limit' => $limit,
|
||
'autocomplete' => 1,
|
||
], '', '&', PHP_QUERY_RFC3986);
|
||
|
||
$payload = $this->fetchJson($url);
|
||
$features = $payload['features'] ?? null;
|
||
if (!is_array($features)) {
|
||
throw new RuntimeException('Réponse inattendue de l’API Adresse.');
|
||
}
|
||
|
||
$suggestions = [];
|
||
foreach ($features as $feature) {
|
||
$suggestion = $this->normalizeFeature($feature);
|
||
if ($suggestion !== null) {
|
||
$suggestions[] = $suggestion;
|
||
}
|
||
}
|
||
|
||
return $suggestions;
|
||
}
|
||
|
||
/**
|
||
* Convertit une « feature » GeoJSON de la BAN en proposition exploitable.
|
||
*
|
||
* `label` est l'adresse complète telle qu'elle sera écrite dans la fiche
|
||
* agent ; `nom` et `contexte` ne servent qu'à l'affichage de la liste.
|
||
* Une feature sans label est ignorée : sans elle il n'y a rien à insérer.
|
||
*
|
||
* @return array<string, mixed>|null
|
||
*/
|
||
private function normalizeFeature(mixed $feature): ?array
|
||
{
|
||
if (!is_array($feature) || !is_array($feature['properties'] ?? null)) {
|
||
return null;
|
||
}
|
||
$properties = $feature['properties'];
|
||
$label = trim((string) ($properties['label'] ?? ''));
|
||
if ($label === '') {
|
||
return null;
|
||
}
|
||
|
||
return [
|
||
'label' => $label,
|
||
'nom' => trim((string) ($properties['name'] ?? '')),
|
||
'code_postal' => trim((string) ($properties['postcode'] ?? '')),
|
||
'commune' => trim((string) ($properties['city'] ?? '')),
|
||
'contexte' => trim((string) ($properties['context'] ?? '')),
|
||
// housenumber | street | locality | municipality : une proposition
|
||
// « municipality » n'est qu'une commune, pas une adresse précise.
|
||
'type' => trim((string) ($properties['type'] ?? '')),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Requête HTTP + décodage JSON, sur le modèle de SchoolHolidayApiService :
|
||
* cURL quand l'extension est là, repli sur file_get_contents() sinon.
|
||
*
|
||
* Délais VOLONTAIREMENT COURTS (4 s de connexion, 8 s au total) : l'appel
|
||
* est déclenché à la frappe, un utilisateur ne patiente pas devant une
|
||
* liste de suggestions. Mieux vaut renoncer que faire attendre.
|
||
*
|
||
* @return array<mixed>
|
||
*
|
||
* @throws RuntimeException Erreur réseau, code HTTP >= 400, ou JSON invalide.
|
||
*/
|
||
private function fetchJson(string $url): array
|
||
{
|
||
$body = false;
|
||
$status = 0;
|
||
|
||
if (function_exists('curl_init')) {
|
||
$curl = curl_init($url);
|
||
if ($curl === false) {
|
||
throw new RuntimeException('Impossible d’initialiser cURL.');
|
||
}
|
||
curl_setopt_array($curl, [
|
||
CURLOPT_RETURNTRANSFER => true,
|
||
CURLOPT_FOLLOWLOCATION => true,
|
||
CURLOPT_CONNECTTIMEOUT => 4,
|
||
CURLOPT_TIMEOUT => 8,
|
||
CURLOPT_ENCODING => '',
|
||
CURLOPT_HTTPHEADER => ['Accept: application/json', 'User-Agent: PTA/7.26'],
|
||
]);
|
||
$body = curl_exec($curl);
|
||
$status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
|
||
$error = curl_error($curl);
|
||
curl_close($curl);
|
||
if ($body === false) {
|
||
throw new RuntimeException($error ?: 'Erreur réseau cURL.');
|
||
}
|
||
} else {
|
||
$context = stream_context_create([
|
||
'http' => [
|
||
'method' => 'GET',
|
||
'timeout' => 8,
|
||
'ignore_errors' => true,
|
||
'header' => "Accept: application/json\r\nUser-Agent: PTA/7.26\r\n",
|
||
],
|
||
]);
|
||
$body = @file_get_contents($url, false, $context);
|
||
if ($body === false) {
|
||
throw new RuntimeException('La lecture d’URL distante est désactivée sur le serveur PHP. Activez cURL ou allow_url_fopen.');
|
||
}
|
||
foreach ($http_response_header ?? [] as $header) {
|
||
if (preg_match('/^HTTP\/\S+\s+(\d{3})/', $header, $matches)) {
|
||
$status = (int) $matches[1];
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
if ($status >= 400) {
|
||
throw new RuntimeException('L’API Adresse a répondu avec le code HTTP ' . $status . '.');
|
||
}
|
||
|
||
$data = json_decode((string) $body, true);
|
||
if (!is_array($data)) {
|
||
throw new RuntimeException('Le contenu reçu depuis l’API Adresse n’est pas un JSON valide.');
|
||
}
|
||
return $data;
|
||
}
|
||
}
|