Ajout des vérifications des types dans les formulaires (adresses, email, telephone)
This commit is contained in:
178
app/Services/AddressApiService.php
Normal file
178
app/Services/AddressApiService.php
Normal file
@@ -0,0 +1,178 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user