Files
pta/app/Services/AddressApiService.php

179 lines
6.8 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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;
}
}