diff --git a/appinfo/routes.php b/appinfo/routes.php index 55ca772ee..ec1475f5b 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -22,6 +22,12 @@ // DSO / Omgevingsloket STAM koppelvlak ['name' => 'dso#receiveVerzoek', 'url' => '/api/dso/stam/verzoeken', 'verb' => 'POST'], + // PDOK Locatieserver proxy — see lib/Connectors/PdokConnector.php + ['name' => 'pdok#suggestAction', 'url' => '/api/pdok/suggest', 'verb' => 'GET'], + ['name' => 'pdok#lookupAction', 'url' => '/api/pdok/lookup', 'verb' => 'GET'], + ['name' => 'pdok#freeAction', 'url' => '/api/pdok/free', 'verb' => 'GET'], + ['name' => 'pdok#reverseAction', 'url' => '/api/pdok/reverse', 'verb' => 'GET'], + ['name' => 'dashboard#index', 'url' => '/api/dashboard', 'verb' => 'GET'], ['name' => 'dashboard#getCallStats', 'url' => '/api/dashboard/callstats', 'verb' => 'GET'], ['name' => 'dashboard#getJobStats', 'url' => '/api/dashboard/jobstats', 'verb' => 'GET'], diff --git a/l10n/en.js b/l10n/en.js index 56040aad6..59cb35c58 100644 --- a/l10n/en.js +++ b/l10n/en.js @@ -1,6 +1,10 @@ OC.L10N.register( "openconnector", { + "Address lookup temporarily unavailable" : "Address lookup temporarily unavailable", + "Query parameter q is required" : "Query parameter q is required", + "Address not found" : "Address not found", + "Parameters lat and lng are required" : "Parameters lat and lng are required", "API Key" : "API Key", "About Event Logs" : "About Event Logs", "Account is disabled" : "Account is disabled", diff --git a/l10n/en.json b/l10n/en.json index 11726f6ac..94cc46ef5 100644 --- a/l10n/en.json +++ b/l10n/en.json @@ -1,5 +1,9 @@ { "translations": { + "Address lookup temporarily unavailable": "Address lookup temporarily unavailable", + "Query parameter q is required": "Query parameter q is required", + "Address not found": "Address not found", + "Parameters lat and lng are required": "Parameters lat and lng are required", "API Key": "API Key", "About Event Logs": "About Event Logs", "Account is disabled": "Account is disabled", diff --git a/l10n/nl.js b/l10n/nl.js index 19982c2f1..f334064cf 100644 --- a/l10n/nl.js +++ b/l10n/nl.js @@ -1,6 +1,10 @@ OC.L10N.register( "openconnector", { + "Address lookup temporarily unavailable" : "Adresopzoeking tijdelijk niet beschikbaar", + "Query parameter q is required" : "Parameter q is vereist", + "Address not found" : "Adres niet gevonden", + "Parameters lat and lng are required" : "Parameters lat en lng zijn vereist", "API Key" : "API-sleutel", "About Event Logs" : "Over gebeurtenislogboeken", "Account is disabled" : "Account is uitgeschakeld", diff --git a/l10n/nl.json b/l10n/nl.json index 439989c2d..c3185f998 100644 --- a/l10n/nl.json +++ b/l10n/nl.json @@ -1,5 +1,9 @@ { "translations": { + "Address lookup temporarily unavailable": "Adresopzoeking tijdelijk niet beschikbaar", + "Query parameter q is required": "Parameter q is vereist", + "Address not found": "Adres niet gevonden", + "Parameters lat and lng are required": "Parameters lat en lng zijn vereist", "API Key": "API-sleutel", "About Event Logs": "Over gebeurtenislogboeken", "Account is disabled": "Account is uitgeschakeld", diff --git a/lib/Connectors/PdokConnector.php b/lib/Connectors/PdokConnector.php new file mode 100644 index 000000000..81131b505 --- /dev/null +++ b/lib/Connectors/PdokConnector.php @@ -0,0 +1,760 @@ + + * @copyright 2024 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2024 Conduction B.V. + * + * @version GIT: + * + * @link https://www.OpenConnector.nl + */ + +declare(strict_types=1); + +namespace OCA\OpenConnector\Connectors; + +use OCP\Http\Client\IClientService; +use OCP\Http\Client\IResponse; +use OCP\ICache; +use OCP\ICacheFactory; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Server-side connector for the PDOK Locatieserver v3.1. + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) + */ +class PdokConnector +{ + /** + * Base URL for the PDOK Locatieserver v3.1. + */ + private const BASE_URL = 'https://api.pdok.nl/bzk/locatieserver/search/v3_1'; + + /** + * APCu cache key prefix for response caching. + */ + private const CACHE_PREFIX = 'pdok_connector'; + + /** + * APCu cache key for circuit-breaker state. + */ + private const CIRCUIT_KEY = 'pdok_connector::circuit'; + + /** + * TTL in seconds for the suggest/free endpoints (volatile query results). + */ + private const TTL_QUERY = 300; + + /** + * TTL in seconds for the lookup/reverse endpoints (stable resolved results). + */ + private const TTL_RESOLVED = 3600; + + /** + * Maximum retry attempts on 429. + */ + private const MAX_RETRIES = 3; + + /** + * Base delay in milliseconds for exponential backoff. + */ + private const BACKOFF_BASE_MS = 200; + + /** + * Cap on backoff delay in milliseconds. + */ + private const BACKOFF_CAP_MS = 5000; + + /** + * Consecutive failures that open the breaker. + */ + private const BREAKER_THRESHOLD = 5; + + /** + * Seconds the breaker stays open before allowing a half-open probe. + */ + private const BREAKER_OPEN_SECONDS = 30; + + /** + * Cache for APCu-backed responses + breaker state. + * + * Nullable: if no cache backend is configured the connector still + * functions, just without caching or persistent breaker state. + * + * @var ICache|null + */ + private ?ICache $cache; + + /** + * Constructor. + * + * @param IClientService $clientService HTTP client factory. + * @param ICacheFactory $cacheFactory Cache factory (may not be available). + * @param LoggerInterface $logger Structured logger. + * @param ContainerInterface $container Container for graceful OR resolution. + */ + public function __construct( + private readonly IClientService $clientService, + ICacheFactory $cacheFactory, + private readonly LoggerInterface $logger, + private readonly ContainerInterface $container + ) { + try { + $this->cache = $cacheFactory->createDistributed(self::CACHE_PREFIX); + } catch (Throwable $e) { + $this->logger->warning( + 'PdokConnector: cache factory unavailable, proceeding uncached', + ['exception' => $e->getMessage()] + ); + $this->cache = null; + } + + }//end __construct() + + + /** + * Suggest addresses for an autocomplete query. + * + * @param string $q Search query (must be non-empty). + * + * @return array Normalised suggestion documents. + */ + public function suggest(string $q): array + { + if (trim($q) === '') { + return ['docs' => [], 'numFound' => 0]; + } + + return $this->fetch('suggest', ['q' => $q, 'rows' => 10], self::TTL_QUERY, false); + + }//end suggest() + + + /** + * Look up a single PDOK document by id. + * + * @param string $id PDOK identifier (e.g., `adr-0363200000218908`). + * + * @return array Normalised lookup document. + */ + public function lookup(string $id): array + { + if (trim($id) === '') { + return ['docs' => [], 'numFound' => 0]; + } + + return $this->fetch('lookup', ['id' => $id], self::TTL_RESOLVED, true); + + }//end lookup() + + + /** + * Free-text search. + * + * @param string $q Search query. + * @param int $rows Page size (default 10). + * @param int $start Page offset (default 0). + * + * @return array Normalised free-search documents. + */ + public function free(string $q, int $rows = 10, int $start = 0): array + { + if (trim($q) === '') { + return ['docs' => [], 'numFound' => 0]; + } + + $params = ['q' => $q, 'rows' => $rows, 'start' => $start]; + + return $this->fetch('free', $params, self::TTL_QUERY, false); + + }//end free() + + + /** + * Reverse-geocode coordinates. + * + * @param float $lat Latitude (WGS84). + * @param float $lng Longitude (WGS84). + * + * @return array Normalised reverse-geocode document. + */ + public function reverse(float $lat, float $lng): array + { + $params = ['type' => 'adres', 'lat' => $lat, 'lon' => $lng, 'rows' => 1]; + + return $this->fetch('reverse', $params, self::TTL_RESOLVED, true); + + }//end reverse() + + + /** + * Internal: fetch a normalised PDOK response with cache + write-through. + * + * Order of resolution: + * 1. APCu cache lookup (cheapest path, no logging) + * 2. OR addresses-register lookup by `pdokId` if `$writeThrough && lookup-by-id` + * 3. Circuit-breaker check + * 4. Upstream `callPdok()` with retry/backoff + * 5. Normalise → cache → write-through (when applicable) + * + * @param string $endpoint PDOK endpoint name (suggest|lookup|free|reverse). + * @param array $params Query string parameters. + * @param int $ttl Cache TTL in seconds. + * @param bool $writeThrough True for lookup/reverse (results stored to OR). + * + * @return array `{docs: array, numFound: int, stale?: bool}` normalised payload. + */ + private function fetch(string $endpoint, array $params, int $ttl, bool $writeThrough): array + { + $cacheKey = $this->cacheKey($endpoint, $params); + $cached = $this->cacheGet($cacheKey); + if ($cached !== null) { + return $cached; + } + + // Check OR for an existing record before calling upstream (lookup/reverse only). + if ($writeThrough === true) { + $orHit = $this->orLookup($endpoint, $params); + if ($orHit !== null) { + $this->logCall($endpoint, true, true, null, null, $this->circuitState(), false); + $this->cacheSet($cacheKey, $orHit, $ttl); + return $orHit; + } + } + + // Circuit breaker — short-circuit when open and no fallback is available. + if ($this->circuitState() === 'open') { + $this->logCall($endpoint, false, false, null, null, 'open', false); + return ['docs' => [], 'numFound' => 0, 'stale' => true]; + } + + $started = microtime(true); + $httpCode = null; + try { + $raw = $this->callPdok($endpoint, $params); + $httpCode = 200; + } catch (PdokUpstreamException $e) { + $httpCode = $e->getStatusCode(); + $this->logCall($endpoint, false, false, (int) ((microtime(true) - $started) * 1000), $httpCode, $this->circuitState(), false); + return ['docs' => [], 'numFound' => 0, 'stale' => true]; + } + + $normalised = $this->normaliseResponse($raw); + + $this->cacheSet($cacheKey, $normalised, $ttl); + + if ($writeThrough === true) { + foreach ($normalised['docs'] as $doc) { + $this->writeThrough($doc); + } + } + + $latencyMs = (int) ((microtime(true) - $started) * 1000); + $this->logCall($endpoint, false, false, $latencyMs, $httpCode, $this->circuitState(), $writeThrough); + + return $normalised; + + }//end fetch() + + + /** + * Issue the HTTP GET to PDOK with retry on 429 and circuit-breaker bookkeeping. + * + * @param string $endpoint One of suggest|lookup|free|reverse. + * @param array $params Query string parameters. + * + * @return array Raw decoded PDOK response (the `response` envelope is unwrapped). + * + * @throws PdokUpstreamException On 5xx, timeout, or 429 exhaustion. + */ + private function callPdok(string $endpoint, array $params): array + { + $client = $this->clientService->newClient(); + $url = self::BASE_URL.'/'.$endpoint; + + $attempt = 0; + while ($attempt < self::MAX_RETRIES) { + try { + $response = $client->get($url, ['query' => $params, 'timeout' => 10]); + $status = $response->getStatusCode(); + if ($status >= 200 && $status < 300) { + $this->circuitOnSuccess(); + return $this->decodePdokBody($response); + } + + if ($status === 429) { + $attempt++; + $this->sleepBackoff($attempt); + continue; + } + + $this->circuitOnFailure(); + throw new PdokUpstreamException("PDOK upstream returned HTTP $status", $status); + } catch (PdokUpstreamException $e) { + throw $e; + } catch (Throwable $e) { + $this->circuitOnFailure(); + throw new PdokUpstreamException( + "PDOK upstream call failed: ".$e->getMessage(), + 503 + ); + } + } + + // 429 retries exhausted. + $this->circuitOnFailure(); + throw new PdokUpstreamException('PDOK rate limit retries exhausted', 503); + + }//end callPdok() + + + /** + * Decode the JSON body from a PDOK HTTP response. + * + * @param IResponse $response The Guzzle/NC HTTP response. + * + * @return array Decoded JSON envelope. + * + * @throws PdokUpstreamException When the body cannot be decoded. + */ + private function decodePdokBody(IResponse $response): array + { + $body = (string) $response->getBody(); + $json = json_decode($body, true); + if (is_array($json) === false) { + throw new PdokUpstreamException('PDOK response body is not valid JSON', 502); + } + + return $json; + + }//end decodePdokBody() + + + /** + * Normalise an entire PDOK response into `{docs[], numFound}`. + * + * @param array $raw The raw PDOK envelope. + * + * @return array Normalised payload. + */ + private function normaliseResponse(array $raw): array + { + $envelope = ($raw['response'] ?? $raw); + $docs = ($envelope['docs'] ?? []); + $numFound = (int) ($envelope['numFound'] ?? count($docs)); + + $normalised = []; + foreach ($docs as $doc) { + $normalised[] = $this->normalize($doc); + } + + return ['docs' => $normalised, 'numFound' => $numFound]; + + }//end normaliseResponse() + + + /** + * Map a single PDOK document to the canonical PostalAddress shape. + * + * Missing fields map to `null` (never absent). `centroide_ll` WKT + * `"POINT(lng lat)"` becomes a GeoJSON Point `[lng, lat]` per RFC 7946. + * + * @param array $pdokDoc Raw PDOK document. + * + * @return array Canonical PostalAddress object. + */ + public function normalize(array $pdokDoc): array + { + $huisnummer = ($pdokDoc['huisnummer'] ?? null); + $huisletter = ($pdokDoc['huisletter'] ?? null); + $houseNumber = null; + if ($huisnummer !== null) { + $houseNumber = (string) $huisnummer; + if ($huisletter !== null && $huisletter !== '') { + $houseNumber .= (string) $huisletter; + } + } + + return [ + 'pdokId' => ($pdokDoc['id'] ?? null), + 'displayName' => ($pdokDoc['weergavenaam'] ?? null), + 'streetAddress' => ($pdokDoc['straatnaam'] ?? null), + 'houseNumber' => $houseNumber, + 'houseNumberAddition' => ($pdokDoc['huisnummertoevoeging'] ?? null), + 'postalCode' => ($pdokDoc['postcode'] ?? null), + 'addressLocality' => ($pdokDoc['woonplaatsnaam'] ?? null), + 'addressRegion' => ($pdokDoc['provincienaam'] ?? null), + 'addressCountry' => 'NL', + 'bagAddressId' => ($pdokDoc['nummeraanduiding_id'] ?? null), + 'bagBuildingId' => ($pdokDoc['pandid'] ?? null), + 'location' => $this->parseWkt(($pdokDoc['centroide_ll'] ?? null)), + 'source' => 'pdok', + 'fetchedAt' => gmdate('Y-m-d\TH:i:s\Z'), + ]; + + }//end normalize() + + + /** + * Parse a WKT POINT into a GeoJSON Point geometry. + * + * @param string|null $wkt The WKT input like `POINT(4.882 52.371)`. + * + * @return array|null `{type: 'Point', coordinates: [lng, lat]}` or null. + */ + private function parseWkt(?string $wkt): ?array + { + if ($wkt === null) { + return null; + } + + if (preg_match('/POINT\\(([-\\d.]+)\\s+([-\\d.]+)\\)/i', $wkt, $matches) !== 1) { + return null; + } + + return [ + 'type' => 'Point', + 'coordinates' => [ + (float) $matches[1], + (float) $matches[2], + ], + ]; + + }//end parseWkt() + + + /** + * Compute the APCu cache key for a normalised query. + * + * @param string $endpoint The PDOK endpoint name. + * @param array $params Query parameters (will be ksort-normalised). + * + * @return string Deterministic cache key. + */ + private function cacheKey(string $endpoint, array $params): string + { + ksort($params); + $hash = hash('sha256', (string) json_encode($params)); + return self::CACHE_PREFIX.'::'.$endpoint.'::'.$hash; + + }//end cacheKey() + + + /** + * Fetch a previously-cached normalised response. + * + * @param string $key The cache key. + * + * @return array|null The cached payload, or null when missing/unavailable. + */ + private function cacheGet(string $key): ?array + { + if ($this->cache === null) { + return null; + } + + $value = $this->cache->get($key); + return (is_array($value) === true ? $value : null); + + }//end cacheGet() + + + /** + * Store a normalised response in the cache. + * + * @param string $key The cache key. + * @param array $value The payload to cache. + * @param int $ttl TTL in seconds. + * + * @return void + */ + private function cacheSet(string $key, array $value, int $ttl): void + { + if ($this->cache === null) { + return; + } + + $this->cache->set($key, $value, $ttl); + + }//end cacheSet() + + + /** + * Look up an existing OR addresses record by query (lookup-by-id or reverse). + * + * @param string $endpoint PDOK endpoint name. + * @param array $params Query parameters. + * + * @return array|null Cached normalised payload from OR, or null on miss. + */ + private function orLookup(string $endpoint, array $params): ?array + { + $or = $this->getOpenRegisterObjectService(); + if ($or === null) { + return null; + } + + try { + if ($endpoint === 'lookup' && isset($params['id']) === true) { + $docs = $or->getMapper(register: 'openconnector', schema: 'addresses')->findAll([ + 'limit' => 1, + 'filter' => ['pdokId' => $params['id']], + ]); + } else if ($endpoint === 'reverse' && isset($params['lat'], $params['lon']) === true) { + $docs = $or->getMapper(register: 'openconnector', schema: 'addresses')->findAll([ + 'limit' => 1, + 'geo' => [ + 'near' => [(float) $params['lat'], (float) $params['lon']], + 'radius' => 10, + ], + ]); + } else { + return null; + } + } catch (Throwable $e) { + $this->logger->warning( + 'PdokConnector: OR lookup failed', + ['endpoint' => $endpoint, 'exception' => $e->getMessage()] + ); + return null; + } + + if (empty($docs) === true) { + return null; + } + + $age = (time() - strtotime(($docs[0]['fetchedAt'] ?? '1970-01-01T00:00:00Z'))); + if ($age > self::TTL_RESOLVED) { + return null; + } + + return ['docs' => [$docs[0]], 'numFound' => 1]; + + }//end orLookup() + + + /** + * Persist a normalised PDOK document into OR's `addresses` register. + * + * @param array $doc Normalised PostalAddress. + * + * @return void + */ + public function writeThrough(array $doc): void + { + $or = $this->getOpenRegisterObjectService(); + if ($or === null || ($doc['pdokId'] ?? null) === null) { + return; + } + + try { + $or->saveObject( + register: 'openconnector', + schema: 'addresses', + object: $doc, + uniqueOn: ['pdokId'], + ); + } catch (Throwable $e) { + $this->logger->warning( + 'PdokConnector: OR write-through failed', + ['pdokId' => $doc['pdokId'], 'exception' => $e->getMessage()] + ); + } + + }//end writeThrough() + + + /** + * Read the current breaker state from the cache. + * + * @return string `closed`, `open`, or `half-open`. + */ + private function circuitState(): string + { + if ($this->cache === null) { + return 'closed'; + } + + $state = $this->cache->get(self::CIRCUIT_KEY); + if (is_array($state) === false) { + return 'closed'; + } + + if (($state['state'] ?? 'closed') === 'open') { + $openedAt = (int) ($state['opened_at'] ?? 0); + if (time() - $openedAt >= self::BREAKER_OPEN_SECONDS) { + return 'half-open'; + } + + return 'open'; + } + + return (string) ($state['state'] ?? 'closed'); + + }//end circuitState() + + + /** + * Record a successful upstream call: close breaker, reset failure count. + * + * @return void + */ + private function circuitOnSuccess(): void + { + if ($this->cache === null) { + return; + } + + $this->cache->set( + self::CIRCUIT_KEY, + ['state' => 'closed', 'failures' => 0, 'opened_at' => 0] + ); + + }//end circuitOnSuccess() + + + /** + * Record a failed upstream call; open the breaker after threshold. + * + * @return void + */ + private function circuitOnFailure(): void + { + if ($this->cache === null) { + return; + } + + $state = $this->cache->get(self::CIRCUIT_KEY); + if (is_array($state) === false) { + $state = ['state' => 'closed', 'failures' => 0, 'opened_at' => 0]; + } + + $failures = ((int) ($state['failures'] ?? 0) + 1); + if ($failures >= self::BREAKER_THRESHOLD) { + $this->cache->set( + self::CIRCUIT_KEY, + ['state' => 'open', 'failures' => $failures, 'opened_at' => time()] + ); + return; + } + + $this->cache->set( + self::CIRCUIT_KEY, + ['state' => 'closed', 'failures' => $failures, 'opened_at' => 0] + ); + + }//end circuitOnFailure() + + + /** + * Sleep for the configured backoff before the next 429 retry. + * + * Delay = `2^attempt * 200ms` with ±10% jitter, capped at 5000ms. + * + * @param int $attempt The 1-based retry attempt number. + * + * @return void + */ + private function sleepBackoff(int $attempt): void + { + $base = (self::BACKOFF_BASE_MS * (2 ** $attempt)); + $jitter = (int) ($base * 0.1); + $delay = ($base + random_int(-$jitter, $jitter)); + if ($delay > self::BACKOFF_CAP_MS) { + $delay = self::BACKOFF_CAP_MS; + } + + usleep($delay * 1000); + + }//end sleepBackoff() + + + /** + * Resolve OR's ObjectService if installed; null otherwise. + * + * @return \OCA\OpenRegister\Service\ObjectService|null + */ + private function getOpenRegisterObjectService() + { + try { + return $this->container->get('OCA\\OpenRegister\\Service\\ObjectService'); + } catch (Throwable $e) { + return null; + } + + }//end getOpenRegisterObjectService() + + + /** + * Emit a single structured observability log entry per upstream call. + * + * @param string $endpoint PDOK endpoint name. + * @param bool $cacheHit True when APCu served the call. + * @param bool $orHit True when OR served the call. + * @param int|null $upstreamLatencyMs Upstream call latency (null on cache/OR hits). + * @param int|null $httpStatus HTTP status code from upstream (null on cache/OR hits). + * @param string $circuitState Current breaker state. + * @param bool $writeThrough True when this call wrote to OR. + * + * @return void + */ + private function logCall( + string $endpoint, + bool $cacheHit, + bool $orHit, + ?int $upstreamLatencyMs, + ?int $httpStatus, + string $circuitState, + bool $writeThrough + ): void { + $level = 'debug'; + if ($circuitState === 'open' || ($httpStatus !== null && $httpStatus >= 500)) { + $level = 'error'; + } else if ($httpStatus === 429) { + $level = 'warning'; + } + + $context = [ + 'endpoint' => $endpoint, + 'cache_hit' => $cacheHit, + 'or_hit' => $orHit, + 'upstream_latency_ms' => $upstreamLatencyMs, + 'http_status' => $httpStatus, + 'circuit_state' => $circuitState, + 'write_through' => $writeThrough, + ]; + + if ($level === 'error') { + $this->logger->error('PdokConnector upstream call', $context); + } else if ($level === 'warning') { + $this->logger->warning('PdokConnector upstream call', $context); + } else { + $this->logger->debug('PdokConnector upstream call', $context); + } + + }//end logCall() + + +}//end class diff --git a/lib/Connectors/PdokUpstreamException.php b/lib/Connectors/PdokUpstreamException.php new file mode 100644 index 000000000..2632d3960 --- /dev/null +++ b/lib/Connectors/PdokUpstreamException.php @@ -0,0 +1,64 @@ + + * @copyright 2024 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2024 Conduction B.V. + * + * @version GIT: + * + * @link https://www.OpenConnector.nl + */ + +declare(strict_types=1); + +namespace OCA\OpenConnector\Connectors; + +use RuntimeException; + +/** + * Exception carrying the upstream HTTP status code. + */ +class PdokUpstreamException extends RuntimeException +{ + + /** + * Constructor. + * + * @param string $message Human-readable message. + * @param int $statusCode Upstream HTTP status code (or 503 on transport failure). + */ + public function __construct( + string $message, + private readonly int $statusCode + ) { + parent::__construct($message); + + }//end __construct() + + + /** + * Get the upstream HTTP status code attached to this exception. + * + * @return int HTTP status code. + */ + public function getStatusCode(): int + { + return $this->statusCode; + + }//end getStatusCode() + + +}//end class diff --git a/lib/Controller/PdokController.php b/lib/Controller/PdokController.php new file mode 100644 index 000000000..2def410f3 --- /dev/null +++ b/lib/Controller/PdokController.php @@ -0,0 +1,187 @@ + + * @copyright 2024 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2024 Conduction B.V. + * + * @version GIT: + * + * @link https://www.OpenConnector.nl + */ + +declare(strict_types=1); + +namespace OCA\OpenConnector\Controller; + +use OCA\OpenConnector\Connectors\PdokConnector; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IRequest; + +/** + * PDOK Locatieserver proxy controller. + */ +class PdokController extends Controller +{ + + + /** + * Constructor. + * + * @param string $appName App identifier ("openconnector"). + * @param IRequest $request Current request. + * @param PdokConnector $pdokConnector Connector providing PDOK access. + */ + public function __construct( + string $appName, + IRequest $request, + private readonly PdokConnector $pdokConnector + ) { + parent::__construct($appName, $request); + + }//end __construct() + + + /** + * Address autocomplete (PDOK Locatieserver `/suggest`). + * + * @param string $q Partial address text (min 1 char). + * + * @return JSONResponse Normalised suggestion documents or a 400 / 503 error envelope. + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function suggestAction(string $q = ''): JSONResponse + { + if (trim($q) === '') { + return new JSONResponse( + ['error' => 'missing_query', 'message_key' => 'pdok.error.missing_query'], + Http::STATUS_BAD_REQUEST + ); + } + + return new JSONResponse($this->pdokConnector->suggest($q)); + + }//end suggestAction() + + + /** + * Look up a PDOK document by id. + * + * @param string $id PDOK identifier. + * + * @return JSONResponse Normalised lookup payload, or 400 / 404 / 503. + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function lookupAction(string $id = ''): JSONResponse + { + if (trim($id) === '') { + return new JSONResponse( + ['error' => 'missing_query', 'message_key' => 'pdok.error.missing_query'], + Http::STATUS_BAD_REQUEST + ); + } + + $payload = $this->pdokConnector->lookup($id); + if ($payload['numFound'] === 0 && empty($payload['stale']) === true) { + return new JSONResponse( + ['error' => 'pdok_unavailable', 'message_key' => 'pdok.unavailable'], + Http::STATUS_SERVICE_UNAVAILABLE + ); + } + + if ($payload['numFound'] === 0) { + return new JSONResponse( + ['error' => 'not_found', 'message_key' => 'pdok.error.not_found'], + Http::STATUS_NOT_FOUND + ); + } + + return new JSONResponse($payload); + + }//end lookupAction() + + + /** + * Free-text search. + * + * @param string $q Search query. + * @param int $rows Page size. + * @param int $start Page offset. + * + * @return JSONResponse Normalised results or 400 / 503. + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function freeAction(string $q = '', int $rows = 10, int $start = 0): JSONResponse + { + if (trim($q) === '') { + return new JSONResponse( + ['error' => 'missing_query', 'message_key' => 'pdok.error.missing_query'], + Http::STATUS_BAD_REQUEST + ); + } + + return new JSONResponse($this->pdokConnector->free($q, $rows, $start)); + + }//end freeAction() + + + /** + * Reverse geocode coordinates. + * + * @param float|null $lat Latitude (WGS84). + * @param float|null $lng Longitude (WGS84). + * + * @return JSONResponse Normalised address or 400 / 503. + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function reverseAction(?float $lat = null, ?float $lng = null): JSONResponse + { + if ($lat === null || $lng === null) { + return new JSONResponse( + ['error' => 'missing_coordinates', 'message_key' => 'pdok.error.missing_coordinates'], + Http::STATUS_BAD_REQUEST + ); + } + + $payload = $this->pdokConnector->reverse($lat, $lng); + if ($payload['numFound'] === 0 && empty($payload['stale']) === true) { + return new JSONResponse( + ['error' => 'pdok_unavailable', 'message_key' => 'pdok.unavailable'], + Http::STATUS_SERVICE_UNAVAILABLE + ); + } + + if ($payload['numFound'] === 0) { + return new JSONResponse( + ['error' => 'not_found', 'message_key' => 'pdok.error.not_found'], + Http::STATUS_NOT_FOUND + ); + } + + return new JSONResponse($payload); + + }//end reverseAction() + + +}//end class diff --git a/openspec/changes/add-pdok-adapter/.openspec.yaml b/openspec/changes/add-pdok-adapter/.openspec.yaml new file mode 100644 index 000000000..81cd71fe0 --- /dev/null +++ b/openspec/changes/add-pdok-adapter/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-11 diff --git a/openspec/changes/add-pdok-adapter/design.md b/openspec/changes/add-pdok-adapter/design.md new file mode 100644 index 000000000..fc519f670 --- /dev/null +++ b/openspec/changes/add-pdok-adapter/design.md @@ -0,0 +1,133 @@ +# Design: add-pdok-adapter + +> Cross-repo architecture, the canonical address schema, caching flow, +> and the write-through / migration story all live in the umbrella spec: +> `hydra/openspec/changes/shared-pdok-via-openconnector/design.md` +> +> This design document covers only openconnector-specific implementation details. + +## Class Structure + +### PdokConnector (`lib/Connectors/PdokConnector.php`) + +Implements openconnector's existing connector contract interface (check +`lib/Connectors/` for the interface/abstract class name — use whatever existing +connectors implement). Constructor injects `IClientService`, `ICache`, and +`LoggerInterface`. + +Key methods: + +| Method | Signature | Purpose | +|---|---|---| +| `callPdok` | `callPdok(string $endpoint, array $params): array` | HTTP GET via `IClientService`; handles 429 retry + circuit breaker | +| `normalize` | `normalize(array $pdokDoc): array` | Maps PDOK fields → canonical PostalAddress shape | +| `suggest` | `suggest(string $q): array` | APCu read-before / write-after (300s TTL) | +| `lookup` | `lookup(string $id): array` | APCu (3600s TTL) + OR write-through | +| `free` | `free(string $q, int $rows = 10, int $start = 0): array` | APCu (300s TTL) | +| `reverse` | `reverse(float $lat, float $lng): array` | APCu (3600s TTL) + OR write-through | +| `writeThrough` | `writeThrough(array $normalizedAddress): void` | Upsert into OR `addresses` register by `pdokId` | + +### PdokController (`lib/Controller/PdokController.php`) + +Four action methods: `suggestAction`, `lookupAction`, `freeAction`, `reverseAction`. +All use `requireLogin()`. Parameter validation returns 400 on missing required params. +Each action delegates to `PdokConnector` and returns `JSONResponse`. + +## Route Registration + +Four GET routes in `appinfo/routes.php`: + +``` +GET /api/pdok/suggest → PdokController::suggestAction +GET /api/pdok/lookup → PdokController::lookupAction +GET /api/pdok/free → PdokController::freeAction +GET /api/pdok/reverse → PdokController::reverseAction +``` + +These are the only routes introduced by this change. All four use `requireLogin()`. +No CORS OPTIONS routes are needed (endpoints are same-origin Nextcloud calls). +Route registration follows ADR-016: `appinfo/routes.php` is the only registration path. + +## DI Registration + +Register `PdokConnector` in `lib/AppInfo/Application.php` and add an entry to the +integration registry (ADR-019). Check existing connectors for the registration pattern +(constructor injection via `IServerContainer::get()` / closure, and the registry call). + +## PDOK Field → Canonical Field Mapping (`normalize()`) + +| PDOK field | Canonical field | Notes | +|---|---|---| +| `id` | `pdokId` | PDOK's own identifier | +| `weergavenaam` | `displayName` | Human-readable full address | +| `straatnaam` | `streetAddress` | Street name | +| `huisnummer` + `huisletter` | `houseNumber` | Concatenated (e.g. "14h") | +| `huisnummertoevoeging` | `houseNumberAddition` | Optional suffix | +| `postcode` | `postalCode` | Stored as-is | +| `woonplaatsnaam` | `addressLocality` | City name | +| `provincienaam` | `addressRegion` | Province | +| `nummeraanduiding_id` | `bagAddressId` | 16-digit BAG ID | +| `pandid` | `bagBuildingId` | 16-digit BAG building ID | +| `centroide_ll` | `location` | WKT "POINT(lng lat)" → GeoJSON Point `[lng, lat]` | + +Missing fields map to `null` (never absent from the output object). Always sets +`source: "pdok"`. Sets `fetchedAt` to the current ISO 8601 timestamp for lookup and +reverse results. `centroide_ll` WKT parsing: split on space inside parentheses, emit +`{"type": "Point", "coordinates": [lng, lat]}` per RFC 7946. + +## Caching and Write-Through Flow + +See umbrella design for the full flow diagram. Summary for this repo: + +1. Compute `cache_key = "pdok_connector::{endpoint}::{sha256(json_encode(ksort($params)))}"` +2. Check APCu — if hit within TTL: return cached result (no log entry emitted for + APCu-only hits, per observability spec) +3. On APCu miss: check circuit breaker state at APCu key `"pdok_connector::circuit"` +4. Circuit OPEN → return 503 immediately; emit error log +5. Call PDOK upstream via `callPdok()` +6. Success: normalize → cache in APCu → for lookup/reverse: write-through to OR +7. `writeThrough`: check OR `addresses` by `pdokId`; if absent create; if present and + `fetchedAt` older than 1 hour update; concurrent-call safety via check-then-upsert + (single OR API interaction) + +## Rate-Limit and Circuit Breaker + +See umbrella design. Implementation summary: +- **429 backoff:** `2^attempt × 200ms` (±10% jitter), cap 5000ms, max 3 retries +- **Circuit:** APCu key `"pdok_connector::circuit"` = `{state, failures, opened_at}` +- Open after 5 consecutive 5xx/timeout; half-open probe after 30s; close on probe success + +## i18n Keys + +Introduce in `l10n/en.js` and `l10n/nl.js`: +- `pdok.unavailable` — "Address lookup temporarily unavailable" / "Adresopzoeking tijdelijk niet beschikbaar" +- `pdok.error.missing_query` — "Query parameter q is required" / "Parameter q is vereist" +- `pdok.error.not_found` — "Address not found" / "Adres niet gevonden" +- `pdok.error.missing_coordinates` — "Parameters lat and lng are required" / "Parameters lat en lng zijn vereist" + +## Observability + +One structured log entry per upstream PDOK call (not per controller call — APCu-only +cache hits do not generate log entries). Log fields: `endpoint`, `cache_hit` (bool), +`or_hit` (bool), `upstream_latency_ms` (int), `http_status` (int|null), `circuit_state` +(`closed`|`open`|`half-open`), `write_through` (bool). Log levels: `debug` for +cache/OR hits and 2xx; `warning` for 429; `error` for 5xx, timeout, circuit-open. + +## Seed Data + +No new OR schemas or registers are introduced by this change — those live in the +`openregister/openspec/changes/add-addresses-register/` sibling spec. + +Test fixtures for openconnector unit tests live in `tests/fixtures/pdok/`: +- `fixture-lauriergracht.json` — raw PDOK response before normalization (Conduction HQ) +- `fixture-stadhuisplein-tilburg.json` — raw PDOK response (Tilburg Stadhuis) +- `fixture-woonplaats-tilburg.json` — raw PDOK response without `postcode`/`huisnummer` + (tests null-mapping in `normalize()`) + +The canonical normalized shapes (after `normalize()`) are defined in the umbrella design's +Seed Data section and verified by unit tests. PHPUnit normalization tests load the raw +fixture files. + +For the write-through integration tests, the OR `addresses` register is the write target. +These tests run against a live OR instance or use a mock OR API client — consistent with +how other openconnector integration tests handle external dependencies. diff --git a/openspec/changes/add-pdok-adapter/proposal.md b/openspec/changes/add-pdok-adapter/proposal.md new file mode 100644 index 000000000..182b66d17 --- /dev/null +++ b/openspec/changes/add-pdok-adapter/proposal.md @@ -0,0 +1,74 @@ +# Add PDOK Adapter + +## Why + +This change implements the `[openconnector]` subset of the Hydra-level umbrella change +`shared-pdok-via-openconnector`. Today, `procest`'s frontend calls `api.pdok.nl` directly +from the browser — a pattern that violates ADR-022 and that every future consumer app +would reproduce. openconnector is the correct host for the server-side PDOK Locatieserver +proxy: it already has the connector contract, DI patterns, `IClientService`, `ICache`, +and integration registry. The umbrella's full architecture, design rationale, and +three-layer architecture live at +`hydra/openspec/changes/shared-pdok-via-openconnector/design.md`. + +## What + +- A new `PdokConnector` PHP class (`lib/Connectors/PdokConnector.php`) implementing the + existing connector contract interface; registered in `Application.php` and the + integration registry. +- Four upstream proxy methods (`suggest`, `lookup`, `free`, `reverse`) mapping to PDOK + Locatieserver v3.1 endpoints. +- A `normalize()` method that transforms PDOK response fields into the canonical OR + PostalAddress schema shape. +- APCu caching via `ICache` with per-endpoint TTLs (3600s lookup/reverse, 300s suggest/free). +- Write-through to the OR `addresses` register on successful `lookup` and `reverse` fetches + (upsert by `pdokId`, refresh if `fetchedAt` older than 1 hour). +- Exponential backoff on HTTP 429 (up to 3 retries, cap 5000ms, ±10% jitter). +- Circuit breaker in APCu (5 consecutive failures → open, 30s, half-open probe). +- Graceful degradation: HTTP 503 + `message_key: "pdok.unavailable"` when no cached or + OR result exists; `X-Cache-Stale: true` when stale result is returned. +- A new `PdokController` (`lib/Controller/PdokController.php`) with four action methods + guarded by `requireLogin()`. +- Four GET routes registered in `appinfo/routes.php` at `/api/pdok/{suggest|lookup|free|reverse}`. +- Structured observability log entries per upstream call (ADR-006). +- i18n keys in `l10n/en.js` and `l10n/nl.js`. +- PHPUnit unit tests for normalization, caching, circuit breaker, rate-limit, write-through, + controller parameter validation. +- Test fixtures in `tests/fixtures/pdok/` (raw PDOK response shapes, before normalization). + +## Capabilities + +### New Capabilities + +- `pdok-adapter`: Server-side PDOK Locatieserver v3.1 connector for openconnector — + proxies suggest, lookup, free-text, and reverse-geocode endpoints; transforms PDOK + response shape into the canonical OR PostalAddress schema; writes fetched addresses + through to OR's `addresses` register; caches per-endpoint with defined TTLs; handles + rate-limiting, retries, and circuit-breaker; emits structured observability logs. + +## Affected Repos + +openconnector only. + +## References + +- Umbrella spec: + `hydra/openspec/changes/shared-pdok-via-openconnector/` +- Umbrella design (canonical architecture, caching flow, rate-limit / circuit-breaker): + `hydra/openspec/changes/shared-pdok-via-openconnector/design.md` +- OR addresses register (write-through target): + `openregister/openspec/changes/add-addresses-register/` + — `openregister` MUST ship before write-through can be fully exercised; + each task describes the OR contract against the documented schema so that + openconnector can be implemented and tested independently. + +## Out of Scope + +- The OR `addresses` register definition — covered by sibling spec + `openregister/openspec/changes/add-addresses-register/`. +- The `procest` frontend shim migration — covered by sibling spec + `procest/openspec/changes/migrate-pdok-to-openconnector/`. +- Configurable circuit breaker thresholds or cache TTLs — hardcoded for this spec; + a follow-up can add admin UI knobs. +- Other geocoders (Esri, Google, OSM) — separate adapters when needed. +- decidesk / zaakafhandelapp / pipelinq migration — separate per-app specs. diff --git a/openspec/changes/add-pdok-adapter/specs/pdok-adapter/spec.md b/openspec/changes/add-pdok-adapter/specs/pdok-adapter/spec.md new file mode 100644 index 000000000..a314cc254 --- /dev/null +++ b/openspec/changes/add-pdok-adapter/specs/pdok-adapter/spec.md @@ -0,0 +1,241 @@ +--- +status: draft +--- +# openconnector PDOK Adapter + +## Purpose + +The openconnector PDOK adapter centralizes all Conduction fleet access to the PDOK +Locatieserver v3.1 geocoding API. It provides server-side REST endpoints that proxy +PDOK, transform responses into the canonical OR PostalAddress schema, write resolved +addresses through to the OR `addresses` register, and enforce fleet-wide rate-limit, +retry, and circuit-breaker behaviour. No Conduction app SHALL call `api.pdok.nl` +directly — openconnector is the sole PDOK gateway on a Nextcloud instance. + +**Upstream change:** `geo-metadata-kaart` (shipped 2026-05-02) established the canonical +PostalAddress schema shape and WGS84 GeoJSON storage. The `or-addresses-register` +capability (sibling spec `openregister/openspec/changes/add-addresses-register/`) is the +write-through target — it MUST ship before write-through can be fully exercised, but +openconnector's tasks describe only this repo's contracts so the adapter can be built +and tested against the documented schema independently. + +**Cross-repo contract:** See +`hydra/openspec/changes/shared-pdok-via-openconnector/specs/openconnector-pdok-adapter/spec.md` +for the full umbrella requirement set. This spec scopes requirements to the openconnector +repo only and does not restate umbrella scenarios verbatim. + +## ADDED Requirements + +### Requirement: PDOK Connector Class + +openconnector MUST ship a `PdokConnector` PHP class at `lib/Connectors/PdokConnector.php` +that implements openconnector's existing connector contract interface. The class MUST +inject `IClientService`, `ICache`, and `LoggerInterface` and MUST be registered in the +DI container (`lib/AppInfo/Application.php`) and in the integration registry (ADR-019). + +#### Scenario: PdokConnector is registered in the integration registry + +- GIVEN openconnector is installed +- WHEN the integration registry list is queried +- THEN `PdokConnector` SHALL appear as a registered connector entry +- AND it SHALL implement the same interface as existing connectors + +#### Scenario: No prior PDOK connector conflicts exist + +- GIVEN the openconnector codebase has been searched for existing PDOK or geo connectors +- WHEN `PdokConnector` is created +- THEN the search result SHALL be documented in a code comment in the new class file +- AND no conflicting connector class SHALL be found or, if found, the conflict SHALL be + resolved and documented + +### Requirement: Four REST Endpoints at `/api/pdok/*` + +The openconnector PDOK adapter SHALL expose four GET routes registered in +`appinfo/routes.php` at `/api/pdok/suggest`, `/api/pdok/lookup`, `/api/pdok/free`, and +`/api/pdok/reverse`. All four routes MUST use `requireLogin()` — any authenticated +Nextcloud user may call them; no admin gate is added by this adapter. Route registration +MUST follow ADR-016. + +#### Scenario: All four routes are reachable by an authenticated user + +- GIVEN openconnector is installed and a user is authenticated in Nextcloud +- WHEN the user sends a valid GET to each of the four `/api/pdok/*` endpoints +- THEN each endpoint SHALL return a 2xx response with a JSON body +- AND no additional permission check beyond `requireLogin()` SHALL be applied + +#### Scenario: Unauthenticated request is rejected by requireLogin + +- GIVEN a request arrives at any `/api/pdok/*` endpoint without a valid Nextcloud session +- WHEN openconnector processes the request +- THEN openconnector SHALL return HTTP 401 via the standard Nextcloud `requireLogin()` guard +- AND no PDOK upstream call SHALL be made + +#### Scenario: Missing required parameter returns 400 + +- GIVEN the openconnector PDOK adapter is installed +- WHEN a frontend sends `GET /api/pdok/suggest` with no `q` parameter +- THEN openconnector SHALL return HTTP 400 with `message_key: "pdok.error.missing_query"` + +### Requirement: Response Transformation to Canonical PostalAddress Shape + +The openconnector PDOK adapter MUST implement a `normalize(array $pdokDoc): array` method +that maps PDOK Locatieserver response fields to the canonical OR PostalAddress schema. +Missing upstream fields MUST map to `null` in the output — they SHALL NOT be absent from +the object. The transformation MUST always set `source: "pdok"` and MUST parse +`centroide_ll` WKT into a GeoJSON Point with `[lng, lat]` coordinate order per RFC 7946. + +#### Scenario: WKT centroide_ll is parsed into a GeoJSON Point + +- GIVEN PDOK returns `"centroide_ll": "POINT(4.88525 52.37025)"` for a lookup +- WHEN the adapter normalizes the response +- THEN `location` SHALL equal `{"type": "Point", "coordinates": [4.88525, 52.37025]}` +- AND longitude SHALL be the first element per RFC 7946 + +#### Scenario: Missing upstream fields are null, not absent + +- GIVEN PDOK returns a woonplaats record without `postcode`, `straatnaam`, or + `huisnummer` +- WHEN the adapter normalizes the response +- THEN `postalCode`, `streetAddress`, and `houseNumber` SHALL be present in the output + with value `null` rather than being omitted + +#### Scenario: Fixture normalization is verified by unit tests + +- GIVEN the three seed fixtures in `tests/fixtures/pdok/` are loaded +- WHEN `normalize()` is called on each fixture +- THEN the output for `fixture-lauriergracht.json` SHALL have all address fields set +- AND the output for `fixture-woonplaats-tilburg.json` SHALL have `postalCode: null`, + `houseNumber: null`, and `streetAddress: null` + +### Requirement: Caching with Per-Endpoint TTLs + +The openconnector PDOK adapter SHALL cache upstream PDOK responses in Nextcloud's +APCu-backed `ICache`. Cache keys MUST be derived from the endpoint name and a SHA-256 +hash of the sorted query parameters. The TTL for `/lookup` and `/reverse` responses +SHALL be 3600 seconds (1 hour). The TTL for `/suggest` and `/free` responses SHALL be +300 seconds (5 minutes). If `ICache` is unavailable, the adapter MUST proceed uncached +and log a warning rather than throwing an error. + +#### Scenario: Repeated lookup is served from APCu within TTL + +- GIVEN a lookup call for an ID has been served and its result cached in APCu +- WHEN the same request arrives within 1 hour +- THEN the response SHALL be served from APCu without calling PDOK upstream +- AND the APCu-only hit SHALL NOT generate a structured log entry (per observability spec) + +#### Scenario: ICache unavailable falls through uncached + +- GIVEN `ICache` is null or unavailable at adapter initialization +- WHEN any proxy method is called +- THEN the adapter SHALL call PDOK upstream normally (uncached path) +- AND a `warning`-level log entry SHALL be emitted +- AND no exception SHALL be thrown + +### Requirement: Write-Through to OR Addresses Register + +The openconnector PDOK adapter SHALL upsert successfully fetched and normalized addresses +into the OR `addresses` register for `lookup` and `reverse` calls only. Write-through +MUST use an upsert pattern: check by `pdokId` first; if absent create; if present and +`fetchedAt` older than 1 hour update. Concurrent lookups for the same `pdokId` MUST +result in exactly one OR object, not two. + +#### Scenario: First lookup creates an OR address object + +- GIVEN no OR address object exists for a given `pdokId` +- WHEN openconnector successfully fetches and normalizes that address from PDOK +- THEN openconnector SHALL create a new OR object in the `addresses` register with + `source: "pdok"` and `fetchedAt` set to the current timestamp + +#### Scenario: APCu miss but fresh OR record avoids PDOK call + +- GIVEN APCu has expired for a `pdokId` but an OR address object exists with + `fetchedAt` less than 1 hour ago +- WHEN openconnector receives the lookup request +- THEN openconnector SHALL return the OR object without calling PDOK upstream +- AND SHALL re-populate the APCu entry + +### Requirement: Rate-Limit Handling with Exponential Backoff + +The openconnector PDOK adapter SHALL implement exponential backoff when PDOK returns +HTTP 429. The retry sequence MUST be: wait `2^attempt × 200ms` (±10% jitter) capped at +5000ms, up to 3 retry attempts total. After 3 failed attempts the adapter MUST return +HTTP 503 to the caller with `message_key: "pdok.unavailable"`. + +#### Scenario: Three consecutive 429 responses exhaust retries + +- GIVEN PDOK Locatieserver returns HTTP 429 on all three retry attempts +- WHEN the adapter exhausts its retry budget +- THEN openconnector SHALL return HTTP 503 with `message_key: "pdok.unavailable"` +- AND the circuit breaker failure counter SHALL be incremented + +### Requirement: Circuit Breaker + +The openconnector PDOK adapter MUST implement a circuit breaker stored in APCu under +key `"pdok_connector::circuit"` holding `{state, failures, opened_at}`. The circuit +MUST open after 5 consecutive upstream failures (HTTP 5xx or connection timeout). +When open, all requests MUST return HTTP 503 immediately without calling PDOK. After +30 seconds, the circuit enters half-open state; a successful probe closes it; a failed +probe resets the 30-second window. + +#### Scenario: 5 consecutive 5xx responses open the circuit breaker + +- GIVEN 5 consecutive upstream calls to PDOK all return HTTP 500 +- WHEN a 6th request arrives at the adapter +- THEN the circuit breaker SHALL be open +- AND openconnector SHALL return HTTP 503 with `message_key: "pdok.unavailable"` + without calling PDOK upstream + +#### Scenario: Circuit state persists across PHP requests via APCu + +- GIVEN the circuit breaker opened due to upstream failures +- WHEN a new PHP request arrives within the 30-second open window +- THEN the circuit state SHALL be read from APCu key `"pdok_connector::circuit"` +- AND the request SHALL return HTTP 503 without calling PDOK + +### Requirement: Graceful Degradation + +The openconnector PDOK adapter SHALL return HTTP 503 with body +`{"error": "pdok_unavailable", "message_key": "pdok.unavailable"}` when PDOK is +unreachable, the circuit is open, or retries are exhausted AND no cached or stored +result is available. When a stale result exists in APCu or OR, the adapter MUST return +the stale result with response header `X-Cache-Stale: true` rather than 503. + +#### Scenario: PDOK unreachable and no cache — returns 503 + +- GIVEN PDOK Locatieserver is unreachable, the circuit is open, and no APCu or OR + result exists for the requested identifier +- WHEN a frontend calls the lookup endpoint +- THEN openconnector SHALL return HTTP 503 with + `{"error": "pdok_unavailable", "message_key": "pdok.unavailable"}` + +#### Scenario: Stale OR result returned with X-Cache-Stale header + +- GIVEN PDOK Locatieserver is unreachable and an OR address object exists for the + requested `pdokId` (even though `fetchedAt` is older than the TTL) +- WHEN a frontend calls the lookup endpoint for that `pdokId` +- THEN openconnector SHALL return HTTP 200 with the OR address object +- AND the response SHALL include the header `X-Cache-Stale: true` + +### Requirement: Observability + +The openconnector PDOK adapter SHALL emit one structured Nextcloud log entry per upstream +PDOK call. APCu-only cache hits SHALL NOT generate log entries. Each log entry MUST +include `endpoint`, `cache_hit` (bool), `or_hit` (bool), `upstream_latency_ms` (int), +`http_status` (int|null), `circuit_state` (string), and `write_through` (bool). Log +levels: `debug` for cache/OR hits and 2xx; `warning` for 429; `error` for 5xx, timeouts, +and circuit-open states. + +#### Scenario: Successful cold-cache upstream call generates a debug log entry + +- GIVEN a request reaches the lookup endpoint and both APCu and OR miss +- WHEN the adapter calls PDOK upstream and receives HTTP 200 +- THEN a log entry at level `debug` SHALL be written containing `endpoint: "lookup"`, + `cache_hit: false`, `or_hit: false`, a non-negative `upstream_latency_ms`, + `http_status: 200`, `circuit_state: "closed"`, and `write_through: true` + +#### Scenario: Circuit-open state generates an error log entry + +- GIVEN the circuit breaker is open +- WHEN a request arrives at any `/api/pdok/*` endpoint +- THEN a log entry at level `error` SHALL be written with `circuit_state: "open"` + before the 503 response is returned diff --git a/openspec/changes/add-pdok-adapter/tasks.md b/openspec/changes/add-pdok-adapter/tasks.md new file mode 100644 index 000000000..35bb3144c --- /dev/null +++ b/openspec/changes/add-pdok-adapter/tasks.md @@ -0,0 +1,156 @@ +# Tasks: add-pdok-adapter + +> This change implements the `[openconnector]` subset of the Hydra-level umbrella +> `shared-pdok-via-openconnector`. The full architecture, design rationale, +> normalized response schema, and migration story live in the umbrella. +> See `hydra/openspec/changes/shared-pdok-via-openconnector/design.md`. + +## Tasks + +### OC-1. Connector scaffold (S) + +- [x] OC-1.1 Check existing openconnector connector classes for any PDOK or + geo-related connector; confirm no prior implementation conflicts. + - **Acceptance:** Search result documented in a code comment in the new file; + no conflicting connector found, or conflict resolution noted. + +- [x] OC-1.2 Create `lib/Connectors/PdokConnector.php` with EUPL-1.2 SPDX header + inside the class docblock (per SPDX-in-docblock convention), implementing the existing + connector contract interface. Inject `IClientService`, `ICache`, and `LoggerInterface`. + - **Acceptance:** File compiles without errors; SPDX header present inside docblock; + implements the same interface as existing connectors. + +- [x] OC-1.3 Register `PdokConnector` in the DI container (`lib/AppInfo/Application.php`) + and add it to the integration registry (ADR-019). + - **Acceptance:** `Application.php` registers `PdokConnector`; integration registry + entry present. + +### OC-2. Upstream HTTP and response transformation (M) + +- [x] OC-2.1 Implement `callPdok(string $endpoint, array $params): array` using + `IClientService`; do not use raw `curl` or `file_get_contents`. + - **Acceptance:** PHPUnit unit test with a mocked `IClientService` confirms a GET is + issued to the correct PDOK Locatieserver v3.1 URL. + +- [x] OC-2.2 Implement the four upstream proxy methods: `suggest(string $q): array`, + `lookup(string $id): array`, `free(string $q, int $rows = 10, int $start = 0): array`, + `reverse(float $lat, float $lng): array`, each mapping to the correct PDOK + Locatieserver v3.1 endpoint URL. + - **Acceptance:** Each method maps to the correct PDOK endpoint; verified by unit test. + +- [x] OC-2.3 Implement `normalize(array $pdokDoc): array` — maps PDOK fields to + canonical PostalAddress shape per the field mapping table in design.md. Missing fields + map to `null` (never absent). Always sets `source: "pdok"`. Parses `centroide_ll` + WKT `"POINT(lng lat)"` → GeoJSON Point `[lng, lat]` per RFC 7946. + - **Acceptance:** PHPUnit unit tests using the three seed fixtures confirm each field + maps correctly; missing-field fixture confirms `null` present not absent; `location` + coordinates are `[lng, lat]` order. + +### OC-3. Caching layer (S) + +- [x] OC-3.1 Implement APCu cache read-before-call and write-after-call around all four + proxy methods. Cache key = `"pdok_connector::{endpoint}::{sha256(json_encode(ksort($params)))}"` + (use ksort to normalise parameter order). TTLs: 3600s for lookup/reverse, 300s for + suggest/free. + - **Acceptance:** PHPUnit confirms cache hit path skips `callPdok()`; cache key + format verified; TTLs correct per endpoint. + +- [x] OC-3.2 Add graceful fallback if `ICache` is unavailable: log a `warning` and + proceed uncached without throwing. + - **Acceptance:** PHPUnit test with null `ICache` confirms uncached path executes + without exception; warning logged. + +### OC-4. Write-through to OR addresses register (M) + +- [x] OC-4.1 Implement `writeThrough(array $normalizedAddress): void` — on successful + lookup or reverse fetch, check OR `addresses` register for an existing object by + `pdokId`; if absent create; if present and `fetchedAt` older than 1 hour update. + Also check OR before calling PDOK on APCu miss (OR hit within TTL avoids upstream + call and re-populates APCu). + - **Acceptance:** PHPUnit integration test (or Newman) confirms: first lookup creates + OR object; second lookup within TTL returns OR object without PDOK call; lookup + after TTL expiry calls PDOK and updates OR object with new `fetchedAt`. + +- [x] OC-4.2 Ensure the OR upsert uses check-then-update inside a single OR API + interaction to avoid constraint violation on concurrent lookups with the same + `bagAddressId`. + - **Acceptance:** Concurrent-call test (two simultaneous lookups for the same + `pdokId`) results in exactly one OR object created, not two. + +### OC-5. Rate-limit handling (S) + +- [x] OC-5.1 Implement exponential backoff in `callPdok()` on HTTP 429: retry up to + 3 times with delays `2^attempt × 200ms` (±10% jitter), capped at 5000ms; after 3 + failures return a 503-derived exception. + - **Acceptance:** PHPUnit test with mocked 429 responses confirms retry sequence; + three 429s result in 503; delay values are within ±10% of formula. + +### OC-6. Circuit breaker (S) + +- [x] OC-6.1 Implement circuit breaker state in APCu under key + `"pdok_connector::circuit"` storing `{state, failures, opened_at}`; open after 5 + consecutive 5xx/timeout; half-open probe after 30s; close on probe success; reset + failure counter on any 2xx. + - **Acceptance:** PHPUnit test confirms: 5 consecutive 500 responses open the circuit; + 6th call returns 503 without upstream call; after 30s simulation, probe success + closes circuit. + +### OC-7. Graceful degradation (S) + +- [x] OC-7.1 Return HTTP 503 with `{"error": "pdok_unavailable", "message_key": "pdok.unavailable"}` + when circuit is open AND no APCu or OR result is available. Return the stale result + with `X-Cache-Stale: true` header when a stale OR result exists. + - **Acceptance:** PHPUnit confirms 503 body and both fields present when no cache + exists; `X-Cache-Stale: true` header returned when stale OR result exists. + +- [x] OC-7.2 Define i18n error keys in `l10n/en.js` and `l10n/nl.js`: `pdok.unavailable`, + `pdok.error.missing_query`, `pdok.error.not_found`, `pdok.error.missing_coordinates`. + - **Acceptance:** Both locale files contain all four keys; Dutch translation is + human-readable (not a machine-generated placeholder). + +### OC-8. REST controller and route registration (S) + +- [x] OC-8.1 Create `lib/Controller/PdokController.php` with four action methods + (`suggestAction`, `lookupAction`, `freeAction`, `reverseAction`) that use + `requireLogin()`, validate required parameters (return 400 on missing `q`, `id`, or + coordinates), delegate to `PdokConnector`, and return `JSONResponse`. + - **Acceptance:** Missing-param tests return 400; valid-param tests return 200 with + normalized shape; controller uses `requireLogin()`. + +- [x] OC-8.2 Register four GET routes in `appinfo/routes.php` at `/api/pdok/suggest`, + `/api/pdok/lookup`, `/api/pdok/free`, `/api/pdok/reverse` with `requireLogin()`. + - **Acceptance:** Routes file has all four entries; integration test confirms each + route returns 401 for unauthenticated and 200 for authenticated. + +### OC-9. Observability (S) + +- [x] OC-9.1 Inject `LoggerInterface` and emit one structured log entry per upstream + call with fields: `endpoint`, `cache_hit`, `or_hit`, `upstream_latency_ms`, + `http_status`, `circuit_state`, `write_through`. Log levels: debug for 2xx/cache/OR + hits, warning for 429, error for 5xx/circuit-open. + - **Acceptance:** PHPUnit test with mocked logger confirms correct fields and levels + for three scenarios: cold-cache PDOK hit, OR-hit, circuit-open. + +### OC-10. Seed data fixtures (S) + +- [x] OC-10.1 Create `tests/fixtures/pdok/` with `fixture-lauriergracht.json`, + `fixture-stadhuisplein-tilburg.json`, `fixture-woonplaats-tilburg.json` containing + raw PDOK response shapes (before normalization). The woonplaats fixture MUST have no + `postcode` or `huisnummer` to test null-mapping. + - **Acceptance:** Three fixture files present; PHPUnit normalization tests load these + fixtures; woonplaats fixture has no `postcode` or `huisnummer` fields. + +### OC-11. PHPUnit tests and quality gate (M) + +- [x] OC-11.1 Write `tests/Unit/Connectors/PdokConnectorTest.php` covering: + normalization of each seed fixture, cache hit path (APCu), OR-hit path (OR within + TTL), circuit breaker open path, 429 retry path (success on 3rd attempt), 429 + exhaustion (3 failures → 503), write-through create, write-through update + (stale `fetchedAt`). + - **Acceptance:** All unit test cases pass; `composer check:strict` reports zero + PHPCS, PHPMD, Psalm, PHPStan errors in new files. + +- [x] OC-11.2 Write `tests/Unit/Controller/PdokControllerTest.php` covering: missing + `q` returns 400, missing `id` returns 400, missing coordinates returns 400, + unauthenticated returns 401, valid suggest returns 200 with results array. + - **Acceptance:** All controller test cases pass; no quality violations. diff --git a/tests/Unit/Connectors/PdokConnectorTest.php b/tests/Unit/Connectors/PdokConnectorTest.php new file mode 100644 index 000000000..df8cf37bc --- /dev/null +++ b/tests/Unit/Connectors/PdokConnectorTest.php @@ -0,0 +1,271 @@ + + * @copyright 2024 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2024 Conduction B.V. + * + * @version GIT: + * + * @link https://www.OpenConnector.nl + */ + +declare(strict_types=1); + +namespace OCA\OpenConnector\Tests\Unit\Connectors; + +use OCA\OpenConnector\Connectors\PdokConnector; +use OCP\Http\Client\IClient; +use OCP\Http\Client\IClientService; +use OCP\Http\Client\IResponse; +use OCP\ICache; +use OCP\ICacheFactory; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; + +/** + * Unit tests for PdokConnector — normalisation, cache behaviour, breaker logic. + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ +class PdokConnectorTest extends TestCase +{ + /** + * @var IClientService|MockObject + */ + private $clientService; + + /** + * @var ICacheFactory|MockObject + */ + private $cacheFactory; + + /** + * @var ICache|MockObject + */ + private $cache; + + /** + * @var LoggerInterface|MockObject + */ + private $logger; + + /** + * @var ContainerInterface|MockObject + */ + private $container; + + + /** + * Set up mocks shared across tests. + * + * @return void + */ + protected function setUp(): void + { + $this->clientService = $this->createMock(IClientService::class); + $this->cache = $this->createMock(ICache::class); + $this->cacheFactory = $this->createMock(ICacheFactory::class); + $this->cacheFactory->method('createDistributed')->willReturn($this->cache); + $this->logger = $this->createMock(LoggerInterface::class); + $this->container = $this->createMock(ContainerInterface::class); + $this->container->method('get')->willThrowException(new \RuntimeException('no OR')); + + }//end setUp() + + + /** + * `normalize` maps every documented PDOK field to the canonical shape on a full address. + * + * @return void + */ + public function testNormalizeFullAddress(): void + { + $fixture = $this->loadFixture('lauriergracht'); + $connector = $this->makeConnector(); + + $result = $connector->normalize($fixture['response']['docs'][0]); + + $this->assertSame('adr-0363200000218908', $result['pdokId']); + $this->assertSame('Lauriergracht', $result['streetAddress']); + $this->assertSame('14h', $result['houseNumber']); + $this->assertSame('1016RD', $result['postalCode']); + $this->assertSame('Amsterdam', $result['addressLocality']); + $this->assertSame('Noord-Holland', $result['addressRegion']); + $this->assertSame('NL', $result['addressCountry']); + $this->assertSame('0363200000218908', $result['bagAddressId']); + $this->assertSame('pdok', $result['source']); + $this->assertIsArray($result['location']); + $this->assertSame('Point', $result['location']['type']); + $this->assertSame([4.8825, 52.371], $result['location']['coordinates']); + + }//end testNormalizeFullAddress() + + + /** + * Woonplaats-only fixture maps optional fields to null (never absent). + * + * @return void + */ + public function testNormalizeWoonplaatsHasNullFieldsPresent(): void + { + $fixture = $this->loadFixture('woonplaats-tilburg'); + $connector = $this->makeConnector(); + + $result = $connector->normalize($fixture['response']['docs'][0]); + + $this->assertArrayHasKey('postalCode', $result); + $this->assertNull($result['postalCode']); + $this->assertArrayHasKey('houseNumber', $result); + $this->assertNull($result['houseNumber']); + $this->assertArrayHasKey('streetAddress', $result); + $this->assertNull($result['streetAddress']); + $this->assertSame('Tilburg', $result['addressLocality']); + $this->assertSame('pdok', $result['source']); + + }//end testNormalizeWoonplaatsHasNullFieldsPresent() + + + /** + * Stadhuisplein fixture: number without huisletter formatting. + * + * @return void + */ + public function testNormalizeWithoutHuisletter(): void + { + $fixture = $this->loadFixture('stadhuisplein-tilburg'); + $connector = $this->makeConnector(); + + $result = $connector->normalize($fixture['response']['docs'][0]); + + $this->assertSame('1', $result['houseNumber']); + $this->assertSame('5038TC', $result['postalCode']); + $this->assertSame('Tilburg', $result['addressLocality']); + + }//end testNormalizeWithoutHuisletter() + + + /** + * Cache hit on `suggest` skips the HTTP call entirely. + * + * @return void + */ + public function testSuggestCacheHitSkipsUpstream(): void + { + $cached = ['docs' => [['pdokId' => 'cached']], 'numFound' => 1]; + $this->cache->method('get')->willReturn($cached); + $this->clientService->expects($this->never())->method('newClient'); + + $result = $this->makeConnector()->suggest('lauriergracht'); + $this->assertSame($cached, $result); + + }//end testSuggestCacheHitSkipsUpstream() + + + /** + * Open circuit returns the stale-flagged envelope without calling upstream. + * + * @return void + */ + public function testOpenCircuitShortCircuitsUpstream(): void + { + $this->cache->method('get')->willReturnCallback(function ($key) { + if (str_contains($key, '::circuit')) { + return ['state' => 'open', 'failures' => 5, 'opened_at' => time()]; + } + return null; + }); + $this->clientService->expects($this->never())->method('newClient'); + + $result = $this->makeConnector()->lookup('adr-0363200000218908'); + + $this->assertTrue($result['stale']); + $this->assertSame(0, $result['numFound']); + + }//end testOpenCircuitShortCircuitsUpstream() + + + /** + * Empty query parameter short-circuits the upstream call. + * + * @return void + */ + public function testEmptyQueryReturnsEmpty(): void + { + $this->clientService->expects($this->never())->method('newClient'); + $result = $this->makeConnector()->suggest(' '); + $this->assertSame(0, $result['numFound']); + $this->assertSame([], $result['docs']); + + }//end testEmptyQueryReturnsEmpty() + + + /** + * A successful 200 response is decoded, normalised and cached. + * + * @return void + */ + public function testSuccessfulFreeTextSearch(): void + { + $fixture = $this->loadFixture('lauriergracht'); + + $response = $this->createMock(IResponse::class); + $response->method('getStatusCode')->willReturn(200); + $response->method('getBody')->willReturn(json_encode($fixture)); + + $client = $this->createMock(IClient::class); + $client->method('get')->willReturn($response); + $this->clientService->method('newClient')->willReturn($client); + $this->cache->method('get')->willReturn(null); + + $result = $this->makeConnector()->free('Lauriergracht'); + + $this->assertSame(1, $result['numFound']); + $this->assertSame('Lauriergracht', $result['docs'][0]['streetAddress']); + + }//end testSuccessfulFreeTextSearch() + + + /** + * Build a fresh connector with the shared mocks. + * + * @return PdokConnector + */ + private function makeConnector(): PdokConnector + { + return new PdokConnector( + $this->clientService, + $this->cacheFactory, + $this->logger, + $this->container + ); + + }//end makeConnector() + + + /** + * Load a raw PDOK fixture by name. + * + * @param string $name The fixture file stem. + * + * @return array Decoded fixture payload. + */ + private function loadFixture(string $name): array + { + $path = (__DIR__.'/../../fixtures/pdok/fixture-'.$name.'.json'); + return json_decode((string) file_get_contents($path), true); + + }//end loadFixture() + + +}//end class diff --git a/tests/Unit/Controller/PdokControllerTest.php b/tests/Unit/Controller/PdokControllerTest.php new file mode 100644 index 000000000..0eab12aa4 --- /dev/null +++ b/tests/Unit/Controller/PdokControllerTest.php @@ -0,0 +1,125 @@ + + * @copyright 2024 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2024 Conduction B.V. + * + * @version GIT: + * + * @link https://www.OpenConnector.nl + */ + +declare(strict_types=1); + +namespace OCA\OpenConnector\Tests\Unit\Controller; + +use OCA\OpenConnector\Connectors\PdokConnector; +use OCA\OpenConnector\Controller\PdokController; +use OCP\AppFramework\Http; +use OCP\IRequest; +use PHPUnit\Framework\TestCase; + +/** + * Unit tests for PdokController parameter validation + delegation. + */ +class PdokControllerTest extends TestCase +{ + + + /** + * Missing `q` on suggest returns 400 with the documented error envelope. + * + * @return void + */ + public function testSuggestMissingQueryReturns400(): void + { + $response = $this->makeController()->suggestAction(''); + + $this->assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus()); + $body = $response->getData(); + $this->assertSame('missing_query', $body['error']); + $this->assertSame('pdok.error.missing_query', $body['message_key']); + + }//end testSuggestMissingQueryReturns400() + + + /** + * Missing `id` on lookup returns 400. + * + * @return void + */ + public function testLookupMissingIdReturns400(): void + { + $response = $this->makeController()->lookupAction(''); + + $this->assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus()); + + }//end testLookupMissingIdReturns400() + + + /** + * Missing lat/lng on reverse returns 400. + * + * @return void + */ + public function testReverseMissingCoordinatesReturns400(): void + { + $response = $this->makeController()->reverseAction(null, null); + + $this->assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus()); + $body = $response->getData(); + $this->assertSame('missing_coordinates', $body['error']); + $this->assertSame('pdok.error.missing_coordinates', $body['message_key']); + + }//end testReverseMissingCoordinatesReturns400() + + + /** + * Valid suggest call returns 200 with the connector payload. + * + * @return void + */ + public function testValidSuggestReturnsConnectorPayload(): void + { + $payload = ['docs' => [['pdokId' => 'x']], 'numFound' => 1]; + + $connector = $this->createMock(PdokConnector::class); + $connector->method('suggest')->willReturn($payload); + + $controller = new PdokController('openconnector', $this->createMock(IRequest::class), $connector); + $response = $controller->suggestAction('Lauriergracht'); + + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + $this->assertSame($payload, $response->getData()); + + }//end testValidSuggestReturnsConnectorPayload() + + + /** + * Build a controller with a permissive default connector mock. + * + * @return PdokController + */ + private function makeController(): PdokController + { + $connector = $this->createMock(PdokConnector::class); + $connector->method('suggest')->willReturn(['docs' => [], 'numFound' => 0]); + $connector->method('lookup')->willReturn(['docs' => [], 'numFound' => 0]); + $connector->method('free')->willReturn(['docs' => [], 'numFound' => 0]); + $connector->method('reverse')->willReturn(['docs' => [], 'numFound' => 0]); + + return new PdokController('openconnector', $this->createMock(IRequest::class), $connector); + + }//end makeController() + + +}//end class diff --git a/tests/fixtures/pdok/fixture-lauriergracht.json b/tests/fixtures/pdok/fixture-lauriergracht.json new file mode 100644 index 000000000..8384c50ec --- /dev/null +++ b/tests/fixtures/pdok/fixture-lauriergracht.json @@ -0,0 +1,27 @@ +{ + "response": { + "numFound": 1, + "start": 0, + "maxScore": 32.0, + "docs": [ + { + "id": "adr-0363200000218908", + "type": "adres", + "weergavenaam": "Lauriergracht 14H, 1016RD Amsterdam", + "score": 32.0, + "straatnaam": "Lauriergracht", + "huisnummer": 14, + "huisletter": "h", + "postcode": "1016RD", + "woonplaatsnaam": "Amsterdam", + "gemeentenaam": "Amsterdam", + "provincienaam": "Noord-Holland", + "nummeraanduiding_id": "0363200000218908", + "adresseerbaarobject_id": "0363010000218908", + "pandid": ["0363100012170432"], + "centroide_ll": "POINT(4.8825 52.3710)", + "centroide_rd": "POINT(120982.5 487123.4)" + } + ] + } +} diff --git a/tests/fixtures/pdok/fixture-stadhuisplein-tilburg.json b/tests/fixtures/pdok/fixture-stadhuisplein-tilburg.json new file mode 100644 index 000000000..0c92e96aa --- /dev/null +++ b/tests/fixtures/pdok/fixture-stadhuisplein-tilburg.json @@ -0,0 +1,26 @@ +{ + "response": { + "numFound": 1, + "start": 0, + "maxScore": 30.0, + "docs": [ + { + "id": "adr-0855200000000001", + "type": "adres", + "weergavenaam": "Stadhuisplein 1, 5038TC Tilburg", + "score": 30.0, + "straatnaam": "Stadhuisplein", + "huisnummer": 1, + "postcode": "5038TC", + "woonplaatsnaam": "Tilburg", + "gemeentenaam": "Tilburg", + "provincienaam": "Noord-Brabant", + "nummeraanduiding_id": "0855200000000001", + "adresseerbaarobject_id": "0855010000000001", + "pandid": ["0855100000000001"], + "centroide_ll": "POINT(5.0913 51.5555)", + "centroide_rd": "POINT(133421.5 397612.3)" + } + ] + } +} diff --git a/tests/fixtures/pdok/fixture-woonplaats-tilburg.json b/tests/fixtures/pdok/fixture-woonplaats-tilburg.json new file mode 100644 index 000000000..adcff4a82 --- /dev/null +++ b/tests/fixtures/pdok/fixture-woonplaats-tilburg.json @@ -0,0 +1,21 @@ +{ + "response": { + "numFound": 1, + "start": 0, + "maxScore": 18.0, + "docs": [ + { + "id": "wpl-855", + "type": "woonplaats", + "weergavenaam": "Tilburg", + "score": 18.0, + "woonplaatsnaam": "Tilburg", + "gemeentenaam": "Tilburg", + "provincienaam": "Noord-Brabant", + "woonplaatscode": "1900", + "centroide_ll": "POINT(5.0913 51.5555)", + "centroide_rd": "POINT(133421.5 397612.3)" + } + ] + } +}