Ajout des vérifications des types dans les formulaires (adresses, email, telephone)

This commit is contained in:
PTA
2026-08-21 13:50:05 +02:00
parent b3e53f83b8
commit 44730eefca
15 changed files with 898 additions and 17 deletions

View 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;
}
}