Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 69 additions & 37 deletions lib/Service/Integration/ExternalIntegrationRouter.php
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,27 @@ public function callWithMeta(
}//end try
}//end callWithMeta()

/**
* The `configuration` array of the provider's connector source, so a
* provider can ask only what its source allows.
*
* @param IntegrationProvider $provider The provider whose source to read.
*
* @return array<string,mixed> The source's `configuration` (possibly empty).
*
* @throws ProviderUnavailableException When the connector or the source is missing.
*
* @spec openspec/specs/integration-person-lookup/spec.md#requirement-a-call-may-be-recorded-without-its-bodies
*/
public function sourceConfiguration(IntegrationProvider $provider): array {
$this->assertProviderIsExternal(provider: $provider);
$this->assertOpenConnectorAvailable();

$source = $this->loadSource(sourceId: (string)$provider->getOpenConnectorSource(), providerId: $provider->getId());

return $this->readSourceConfiguration(source: $source);
}//end sourceConfiguration()

/**
* Cheap "is the connector reachable at all" check.
*
Expand Down Expand Up @@ -639,84 +660,95 @@ private function callServiceClass(): string {
/**
* Invoke the upstream call via OpenConnector's CallService.
*
* The CallService API has varied across OpenConnector versions;
* this method tries the canonical method names and falls back to
* an exception that the caller wraps as upstream-down.
*
* @param mixed $source Resolved source entity.
* @param string $method HTTP method.
* @param string $path Path relative to source base URL.
* @param array<string,mixed> $options Call options (query / body / headers).
* @param array<string,mixed> $options Call options (query / body / headers / withholdBodies).
*
* @return array<string,mixed> Decoded response body.
*
* @throws \RuntimeException When CallService is unreachable. The
* caller wraps this as ProviderUnavailableException.
*/
private function invoke($source, string $method, string $path, array $options): array {
$callService = $this->container->get($this->callServiceClass());
$options = $this->connectorOptions(options: $options);
$path = $this->connectorEndpoint(source: $source, path: $path);
$response = $this->dispatch(source: $source, method: $method, path: $path, options: $options);

if (method_exists($callService, 'call') === true) {
$response = $callService->call($source, $path, $method, $options);
$this->assertUpstreamOk(response: $response);
return $this->decodeResponse(response: $response);
}

if (method_exists($callService, 'request') === true) {
$response = $callService->request($source, $method, $path, $options);
$this->assertUpstreamOk(response: $response);
return $this->decodeResponse(response: $response);
}

throw new RuntimeException(
'The connector CallService does not expose a known call/request method.'
);
return $this->decodeResponse(response: $response);
}//end invoke()

/**
* Invoke the upstream call like {@see invoke()} but return both the
* decoded body and the extracted response metadata. Keeps the same
* CallService method-name fallback + >= 400 status assertion.
* decoded body and the extracted response metadata.
*
* @param mixed $source Resolved source entity.
* @param string $method HTTP method.
* @param string $path Path relative to source base URL.
* @param array<string,mixed> $options Call options (query / body / headers).
* @param array<string,mixed> $options Call options (query / body / headers / withholdBodies).
*
* @return array{body: array<string,mixed>, meta: array{status: int, durationMs: int, correlationId: ?string, headers: array<string,string>}}
*
* @throws \RuntimeException When CallService is unreachable. The caller
* wraps this as ProviderUnavailableException.
*/
private function invokeWithMeta($source, string $method, string $path, array $options): array {
$response = $this->dispatch(source: $source, method: $method, path: $path, options: $options);

return [
'body' => $this->decodeResponse(response: $response),
'meta' => $this->extractMeta(response: $response),
];
}//end invokeWithMeta()

/**
* Hand the call to the connector's CallService and assert the upstream
* status. The CallService API has varied across OpenConnector versions, so
* this tries `call` and then `request`.
*
* With the `withholdBodies` option the connector records the call without
* its request and response bodies. A connector whose `call` has no such
* parameter is not called at all: PHP refuses an unknown named argument
* before the method runs, and the caller reports that as upstream-down.
*
* @param mixed $source Resolved source entity.
* @param string $method HTTP method.
* @param string $path Path relative to source base URL.
* @param array<string,mixed> $options Call options (query / body / headers / withholdBodies).
*
* @return mixed The raw CallService response.
*
* @throws \RuntimeException When CallService is unreachable.
*
* @spec openspec/specs/integration-person-lookup/spec.md#requirement-a-call-may-be-recorded-without-its-bodies
*/
private function dispatch($source, string $method, string $path, array $options) {
$withholdBodies = (($options['withholdBodies'] ?? false) === true);
unset($options['withholdBodies']);

$callService = $this->container->get($this->callServiceClass());
$options = $this->connectorOptions(options: $options);
$path = $this->connectorEndpoint(source: $source, path: $path);

if (method_exists($callService, 'call') === true) {
$response = $callService->call($source, $path, $method, $options);
$named = [];
if ($withholdBodies === true) {
$named = ['withholdBodies' => true];
}

$response = $callService->call($source, $path, $method, $options, ...$named);
$this->assertUpstreamOk(response: $response);
return [
'body' => $this->decodeResponse(response: $response),
'meta' => $this->extractMeta(response: $response),
];
return $response;
}

if (method_exists($callService, 'request') === true) {
if (method_exists($callService, 'request') === true && $withholdBodies === false) {
$response = $callService->request($source, $method, $path, $options);
$this->assertUpstreamOk(response: $response);
return [
'body' => $this->decodeResponse(response: $response),
'meta' => $this->extractMeta(response: $response),
];
return $response;
}

throw new RuntimeException(
'The connector CallService does not expose a known call/request method.'
);
}//end invokeWithMeta()
}//end dispatch()

/**
* Extract transport metadata from a CallService response (OpenConnector
Expand Down
69 changes: 69 additions & 0 deletions lib/Service/Integration/Providers/BrpPersonProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,21 @@ class BrpPersonProvider extends AbstractIntegrationProvider {
*
* @var array<int,string>
*/
/**
* The source configuration flag that allows the residents count.
*/
public const RESIDENT_COUNT_SETTING = 'residentCount';

/**
* Cause when the source does not allow the residents count.
*/
public const CAUSE_RESIDENT_COUNT_NOT_ENABLED = 'resident-count-not-enabled';

/**
* Cause when an address object id is not 16 digits.
*/
public const CAUSE_INVALID_IDENTIFIER = 'invalid-identifier';

private const DEFAULT_FIELDS = [
'burgerservicenummer',
'naam.voornamen',
Expand Down Expand Up @@ -358,6 +373,60 @@ public function health(): array {
return $this->router->probe(provider: $this);
}//end health()

/**
* Count the people the BRP registers at an address object.
*
* Issues `ZoekMetAdresseerbaarObjectIdentificatie` with one field and
* keeps only the number of results. Asked only when the source carries
* `configuration.residentCount: true`; the call reaches the connector with
* `withholdBodies`, so the residents' numbers are never stored. Neither the
* answer nor the log holds a person or the address object id.
*
* @param string $addressObjectId The 16-digit adresseerbaarObjectIdentificatie.
*
* @return array<string,mixed> `{ total, meta }`, or `{ unavailable, cause }`.
*
* @spec openspec/specs/integration-person-lookup/spec.md#requirement-brp-count-of-residents-at-an-address-object
*/
public function countResidentsAtAddressObject(string $addressObjectId): array {
$addressObjectId = trim($addressObjectId);
if (preg_match('/^\d{16}$/', $addressObjectId) !== 1) {
return ['unavailable' => true, 'cause' => self::CAUSE_INVALID_IDENTIFIER];
}

try {
$configuration = $this->router->sourceConfiguration(provider: $this);
if (($configuration[self::RESIDENT_COUNT_SETTING] ?? null) !== true) {
return ['unavailable' => true, 'cause' => self::CAUSE_RESIDENT_COUNT_NOT_ENABLED];
}

$envelope = $this->router->callWithMeta(
provider: $this,
method: 'POST',
path: 'personen',
options: [
'body' => [
'type' => 'ZoekMetAdresseerbaarObjectIdentificatie',
'adresseerbaarObjectIdentificatie' => $addressObjectId,
'fields' => ['burgerservicenummer'],
],
'headers' => ['Accept' => 'application/hal+json', 'Content-Type' => 'application/json'],
'withholdBodies' => true,
]
);
} catch (ProviderUnavailableException $e) {
return ['unavailable' => true, 'cause' => $e->getCause()];
} catch (Throwable $e) {
$this->logger->warning('BrpPersonProvider::countResidentsAtAddressObject failed (transport).');
return ['unavailable' => true, 'cause' => ProviderUnavailableException::CAUSE_UPSTREAM_SERVICE_DOWN];
}//end try

return [
'total' => count($this->extractPersonen(response: ($envelope['body'] ?? []))),
'meta' => $this->shapeMeta(meta: ($envelope['meta'] ?? [])),
];
}//end countResidentsAtAddressObject()

/**
* Build the `RaadpleegMetBurgerservicenummer` query body for the
* HaalCentraal `/personen` POST. The BSN list + the explicit `fields`
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
kind: code
---

# Proposal: brp-residents-at-address-count

## Summary

Let the BRP provider answer how many people are registered at an address object, as a number and nothing else. Portaliq's "Mijn gegevens" shows a resident that number for their own address, as MijnOverheid and NL Portal (`getBewonersAantalV2`) do.

Rows covered: portaliq `cmp-id-residents-at-address` (REQ-IRD-005). This is OpenRegister's half of portaliq's `identity-registered-details` task T07.

## Why

Portaliq reads the BRP only through `BrpPersonProvider`, which answers `RaadpleegMetBurgerservicenummer` only. Portaliq's section already says "The number of residents at this address is not available" and waits for a method it can call (`method_exists`).

## What changes

- **`BrpPersonProvider::countResidentsAtAddressObject(addressObjectId)`.** Asks Haal Centraal `ZoekMetAdresseerbaarObjectIdentificatie` with the smallest field set and answers `{ total, meta }`. No names, no BSNs and no person object leave the provider.
- **Behind a setting on the source.** The question is asked only when the `brp-haalcentraal` source carries `configuration.residentCount: true`, the way integriq reads BRP relations only behind `configuration.relationFields: true` (decision 220). Without it the answer is `{ unavailable, cause: 'resident-count-not-enabled' }` and the registry is not asked.
- **Recorded without bodies.** The answer holds the residents' numbers, so the call goes to the connector with `withholdBodies`: integriq records that the call happened, not what it carried. A connector that cannot withhold bodies is not called.
- **Router.** `ExternalIntegrationRouter` gains `sourceConfiguration(provider)` and honours a `withholdBodies` call option. Both are generic: any provider may use them.

## Out of scope

- Who may see the count (the resident, for their own address) and how it renders: portaliq.
- Names or any other detail of the residents.
- Seeding `residentCount` on integriq's source: an administrator turns it on.

## Impact

- `lib/Service/Integration/Providers/BrpPersonProvider.php`
- `lib/Service/Integration/ExternalIntegrationRouter.php`
- Tests: `BrpPersonProviderResidentCountTest`, `ExternalIntegrationRouterTest`
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Screens

- No screen: a provider method and a router option. Portaliq's Mijn gegevens shows the number; that screen belongs to portaliq's `identity-registered-details`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
## ADDED Requirements

### Requirement: BRP count of residents at an address object

`BrpPersonProvider::countResidentsAtAddressObject(string $addressObjectId)` SHALL answer how many people the BRP registers at an address object, as a number only. It SHALL ask Haal Centraal `/personen` with a `ZoekMetAdresseerbaarObjectIdentificatie` body carrying the 16-digit `adresseerbaarObjectIdentificatie` and a `fields` list of one field, and answer `{ total, meta }` where `meta` is the Wet-BRP audit metadata `{ correlationId, durationMs, status }`.

The provider SHALL ask only when the `brp-haalcentraal` source carries `configuration.residentCount` set to the boolean `true`. Otherwise it SHALL answer `{ unavailable: true, cause: 'resident-count-not-enabled' }` without calling the registry.

The call SHALL reach the connector with `withholdBodies`, so the call log keeps no request or response body. A connector that cannot withhold bodies SHALL NOT be called, and the answer is `{ unavailable: true, cause: 'upstream-service-down' }`.

The answer SHALL never hold a name, a BSN, a person object or the address object id, and the provider SHALL never log the address object id. A malformed id SHALL answer `{ unavailable: true, cause: 'invalid-identifier' }` without a call. A missing or unreachable source SHALL degrade to `{ unavailable: true, cause }` as `lookupByBsn` does.

#### Scenario: the count of residents at an address object

- GIVEN the `brp-haalcentraal` source carries `configuration.residentCount: true`
- AND the BRP registers three people at address object `0518010000123456`
- WHEN portaliq asks `countResidentsAtAddressObject('0518010000123456')`
- THEN the provider posts `type='ZoekMetAdresseerbaarObjectIdentificatie'`, the id in `adresseerbaarObjectIdentificatie` and one field, with `withholdBodies`
- AND it answers `{ total: 3, meta }` and nothing about the three people
- @e2e exclude Backend provider method, verified by PHPUnit (router mocked); portaliq's Mijn gegevens is the live pass.

#### Scenario: the setting is off

- GIVEN the source carries no `configuration.residentCount`, or a value other than `true`
- WHEN the count is asked
- THEN the answer is `{ unavailable: true, cause: 'resident-count-not-enabled' }` and the registry is not called
- @e2e exclude Backend refusal path, verified by PHPUnit.

#### Scenario: no number and no address in the answer or the log

- GIVEN any count, answered or degraded
- WHEN the provider answers and logs
- THEN the answer holds only `total` and `meta`, or `unavailable` and `cause`
- AND no log line holds the address object id or a burgerservicenummer
- @e2e exclude Backend privacy invariant, verified by PHPUnit (logger asserted).

### Requirement: a call may be recorded without its bodies

`ExternalIntegrationRouter` SHALL honour a `withholdBodies: true` call option by passing `withholdBodies: true` to the connector's `CallService::call`, and SHALL never pass that option on as a request option. When the connector's `call` has no `withholdBodies` parameter, the router SHALL NOT call it and SHALL fail with cause `upstream-service-down`. `sourceConfiguration(provider)` SHALL answer the provider's source `configuration` array and fail like `call()` when the connector or the source is missing.

#### Scenario: the connector records the call without bodies

- GIVEN integriq's CallService, whose `call` takes `withholdBodies`
- WHEN a provider calls with `withholdBodies: true`
- THEN the CallService receives `withholdBodies: true` and the request options do not carry it
- @e2e exclude Backend transport option, verified by PHPUnit (CallService stand-in).

#### Scenario: an older connector is not called

- GIVEN a CallService whose `call` has no `withholdBodies` parameter
- WHEN a provider calls with `withholdBodies: true`
- THEN the CallService is not called and the router fails with `upstream-service-down`
- @e2e exclude Backend refusal path, verified by PHPUnit.
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Tasks: brp-residents-at-address-count

Kind: code. Size S. Row: portaliq `cmp-id-residents-at-address`.

## 1. Router

- [x] 1.1 `ExternalIntegrationRouter::sourceConfiguration()` answers the source's `configuration`, and fails like `call()` when the connector or the source is missing. Verify: `ExternalIntegrationRouterTest`.
- [x] 1.2 A `withholdBodies` call option reaches integriq's `CallService::call(withholdBodies: true)`, never Guzzle; a CallService without that parameter is not called. Verify: `ExternalIntegrationRouterTest`.

## 2. Provider

- [x] 2.1 `BrpPersonProvider::countResidentsAtAddressObject()` posts `ZoekMetAdresseerbaarObjectIdentificatie` with one field, behind `configuration.residentCount: true`, and answers `{ total, meta }` or `{ unavailable, cause }`. Verify: `BrpPersonProviderResidentCountTest` covers the count, the setting off, a malformed id, the degraded paths, and that no number or address id reaches the result or the log.

The administrator-facing note (turn on `residentCount` on the `brp-haalcentraal` source) lives on portaliq's registered-details docs page, next to the sources it already names.
3 changes: 3 additions & 0 deletions openspec/specs/integration-person-lookup/screens.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Screens

- No screen: the BRP person lookup and the residents count are provider methods and an API; the consuming apps (portaliq, pipelinq) own the screens that show them.
Loading
Loading