From 1b35934419bb410517005e831195c69b4db05902 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Moroz?= Date: Wed, 8 Jul 2026 20:55:44 +0200 Subject: [PATCH] feat: support multiple names per field in an identity A service field may now hold a list of names, not just a scalar. Lookups match any of the listed names, and answers return the whole list. - `QueryService::whatIsTheNameOf` and `Identity::username` now return `string|array|null`: a string for single-name fields, an array when the asked service holds several, `null` for a miss. - `YamlFileRepository` indexes every name under a field so a query by any one of them resolves the identity; a scalar is treated as a one-element list, keeping single-name data unchanged. - The HTTP layer keeps its `{"username": ...}` envelope and 200/404 and 200/207 status semantics; the value simply widens to include a JSON array. Backward compatible for single-name data. Documented in the README and a new ADL. Note: `username` is now string-or-array, a widening of the API contract that string-only clients must handle. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 29 ++++++- .../2026-07-08-multiple-names-per-field.md | 83 +++++++++++++++++++ domain/WhoseName/Identity.php | 23 ++++- domain/WhoseName/QueryService.php | 11 ++- .../WhoseName/YamlFileRepository.php | 8 +- .../QueryWhoseNameBatchEndpoint.php | 21 +++++ .../QueryWhoseNameEndpoint.php | 29 +++++++ tests/Domain/AskQueryServiceForIdentities.php | 40 +++++++++ .../YamlFileRepositoryHandlesYamlFiles.php | 25 ++++++ tests/whosename.yml | 4 + 10 files changed, 262 insertions(+), 11 deletions(-) create mode 100644 docs/adl/2026-07-08-multiple-names-per-field.md diff --git a/README.md b/README.md index 1bbf454..5803758 100644 --- a/README.md +++ b/README.md @@ -9,16 +9,30 @@ Given a file of the following structure: - slack: U234567 jira: other@example.org + email: + - other@example.org + - new@example.org ``` This service answers questions of the following form: > For one that calls themselves `test@example.org` on `jira`, what is their username on Slack? (Answer: `U123456`). +A field may hold **more than one name** for the same person — write it as a +list, as `email` above. Then: + +- A lookup matches **any** of the listed names. Asking about the one who is + `other@example.org` _or_ `new@example.org` on `email` finds the same identity. +- Asking _for_ a multi-name field returns the whole list. The Slack user + `U234567`'s `email` resolves to `["other@example.org", "new@example.org"]`. + ## Glossary A set of usernames related to a person is called an **identity**. +A field maps a service to one **name**, or to a list of names when a person +uses several on that service. + ## Installation Run: @@ -58,6 +72,17 @@ curl 'http://localhost/api/whose-name/query?u=test@example.org&s=jira&q=slack' \ Note: the `Accept` header is important for all requests. +The `username` field is a **string** when the asked service holds one name, an +**array of strings** when it holds several, and `null` (with a `404`) when there +is no match: + +``` +curl 'http://localhost/api/whose-name/query?u=U234567&s=slack&q=email' \ + -H "Accept: application/json" \ + -H "Authorization: Bearer " +{"username":["other@example.org","new@example.org"]} +``` + See the [whose-name-client](https://github.com/makimo/whose-name-client) repository for a client of this API. ### Batch query @@ -81,7 +106,9 @@ curl -X POST 'http://localhost/api/whose-name/query/batch' \ ``` The response is an array of `{"username": ...}` results in the **same order** as the -queries, where `null` means no match was found. The endpoint returns: +queries. As with the single endpoint, each `username` is a string, an array of +strings (when the asked service holds several names), or `null` when no match was +found. The endpoint returns: - `200 OK` when every query resolved to a username, - `207 Multi-Status` when at least one query returned `null`, diff --git a/docs/adl/2026-07-08-multiple-names-per-field.md b/docs/adl/2026-07-08-multiple-names-per-field.md new file mode 100644 index 0000000..7c8bd5a --- /dev/null +++ b/docs/adl/2026-07-08-multiple-names-per-field.md @@ -0,0 +1,83 @@ +# A field may hold multiple names; lookups match any, answers return all + +**Status:** _accepted_. + +## Context + +An identity mapped each service to exactly one name (`slack: U123456`). In +practice one person often uses several names on the same service — most commonly +several email addresses. There was no way to record that, and therefore no way to +resolve an identity from a secondary address. + +Two things had to change together: + +- **Search** had to find an identity by _any_ of the names a person uses on a + service, not just a single canonical one. +- **The answer** to "what is their name on service X" had to be able to carry + more than one name. + +## Decision + +- A field's YAML value may be either a scalar (one name) or a list (several + names). The shapes coexist in the same file: + + ```yml + - + slack: U234567 + email: + - other@example.org + - new@example.org + ``` + +- `Domain\WhoseName\QueryService::whatIsTheNameOf` — and the underlying + `Identity::username` — now return `string|array|null`: + - a **string** when the asked service holds one name, + - an **array** of names when it holds several, + - `null` when the identity or the asked service is unknown. + + `whatAreTheNamesOf` (batch) returns an index-aligned list of those same + `string|array|null` answers. + +- **Lookups match any listed name.** The reverse index built by + `YamlFileRepository::transformIdentityListToIndex` maps _every_ name under a + field to its identity (`foreach ((array) $value as $name)`), so a query by any + one of them resolves the identity. A scalar is treated as a one-element list, so + single-name fields are unchanged. + +- **The HTTP layer is unchanged in shape.** Both the single (`GET + /api/whose-name/query`) and batch (`POST /api/whose-name/query/batch`) endpoints + keep the `{"username": ...}` envelope and their existing status semantics + (`200`/`404`, and `200`/`207` for batch). The `username` value simply widens to + include a JSON array. `null` remains the sole trigger for a miss, so the status + logic did not change. + +## Consequences + +- The API is **backward compatible for single-name data**: existing files, + queries, and responses behave exactly as before. A response only becomes an + array when the data for the asked service is itself a list. +- Clients must now accept `username` as either a string or an array of strings. + This is a widening of the response contract and existing string-only clients + need updating before they consume multi-name fields. +- Names are indexed per service, so the same string under two services (e.g. an + address used as both `jira` and `email`) does not collide — it maps each service + to its identity independently. +- Duplicate names under one service across identities still collide (last write + wins), exactly as single-name data did; this remains a data-quality concern, not + something the index resolves. + +## Alternatives + +- **Keep `?string` and pick one "primary" name** — rejected: it discards the very + information the change exists to record and makes the choice of primary + arbitrary. +- **Always return an array** (wrap single names in a one-element list) — cleaner + types, but a breaking change for every existing client and every existing + response; rejected in favour of returning what the field holds. +- **A separate `aliases` field instead of a list value** — rejected: it splits one + concept (the names on a service) across two keys and complicates both lookup and + answering. + +## Decision date + +> 2026-07-08 diff --git a/domain/WhoseName/Identity.php b/domain/WhoseName/Identity.php index 9889ad1..82e5f02 100644 --- a/domain/WhoseName/Identity.php +++ b/domain/WhoseName/Identity.php @@ -10,16 +10,25 @@ * of any other details of the person in question. * * A real world example can be represented as the following map: - * + * * github: dragonee * jira: michal@makimo.pl * slack: U042M5ZRK - * + * + * A single service may hold more than one name for the same person. + * In that case the value is a list rather than a scalar: + * + * slack: U042M5ZRK + * email: + * - michal@makimo.pl + * - michal@example.org + * */ class Identity { protected /** - * A [service => username] map. + * A [service => name(s)] map, where each value is either a + * single name (string) or a list of names (array of strings). */ $accounts = []; @@ -27,7 +36,13 @@ public function __construct(array $accounts) { $this->accounts = $accounts; } - public function username(string $service): ?string { + /** + * The name(s) held under a service. + * + * @return string|array|null A single name, a list of names, or + * null when the service is unknown. + */ + public function username(string $service): string|array|null { return isset($this->accounts[$service]) ? $this->accounts[$service] : null; diff --git a/domain/WhoseName/QueryService.php b/domain/WhoseName/QueryService.php index b6fc0cf..40f3e07 100644 --- a/domain/WhoseName/QueryService.php +++ b/domain/WhoseName/QueryService.php @@ -8,7 +8,14 @@ public function __construct(IdentityQueryRepository $repository) { $this->repository = $repository; } - public function whatIsTheNameOf(string $username, string $service, string $askedService): ?string { + /** + * Resolve the name(s) an identity uses on another service. + * + * @return string|array|null A single name, a list of names when the + * asked service holds several, or null when + * the identity or asked service is unknown. + */ + public function whatIsTheNameOf(string $username, string $service, string $askedService): string|array|null { return $this->repository ->findByServiceAndUsername($service, $username) ->username($askedService); @@ -19,7 +26,7 @@ public function whatIsTheNameOf(string $username, string $service, string $asked * * @param array $queries A list of ['username' => , 'service' => , 'askedService' => ] triples. * - * @return array An index-aligned list of ?string usernames (null where unknown). + * @return array An index-aligned list of string|array|null answers (null where unknown). */ public function whatAreTheNamesOf(array $queries): array { return array_map( diff --git a/infrastructure/WhoseName/YamlFileRepository.php b/infrastructure/WhoseName/YamlFileRepository.php index 96ea2b9..6f8ceac 100644 --- a/infrastructure/WhoseName/YamlFileRepository.php +++ b/infrastructure/WhoseName/YamlFileRepository.php @@ -22,10 +22,10 @@ protected static function transformIdentityListToIndex(array $list): array { $mapping = []; foreach($list as $index => $identity) { - foreach($identity as $service => $username) { - if(!isset($mapping[$service])) { - $mapping[$service] = [$username => $index]; - } else { + foreach($identity as $service => $usernames) { + // A service may hold a single name or a list of names. + // Index every name so a lookup matches any of them. + foreach((array) $usernames as $username) { $mapping[$service][$username] = $index; } } diff --git a/tests/Application/A1_WhoseNameAPI/QueryWhoseNameBatchEndpoint.php b/tests/Application/A1_WhoseNameAPI/QueryWhoseNameBatchEndpoint.php index 156e0bc..f6d07be 100644 --- a/tests/Application/A1_WhoseNameAPI/QueryWhoseNameBatchEndpoint.php +++ b/tests/Application/A1_WhoseNameAPI/QueryWhoseNameBatchEndpoint.php @@ -30,6 +30,27 @@ }); +gest('usage', 'Batch querying returns a list of names where a service holds several', function () { + Sanctum::actingAs( + User::factory()->create(), + ['whose-name'] + ); + + $response = $this->postJson('/api/whose-name/query/batch', [ + 'queries' => [ + ['u' => 'U234567', 's' => 'slack', 'q' => 'email'], + ['u' => 'new@example.org', 's' => 'email', 'q' => 'slack'], + ], + ]); + + $response->assertStatus(200); + $response->assertExactJson([ + ['username' => ['other@example.org', 'new@example.org']], + ['username' => 'U234567'], + ]); +}); + + gest('edge', 'Batch querying returns 207 with null for unknown entries alongside found ones', function () { Sanctum::actingAs( User::factory()->create(), diff --git a/tests/Application/A1_WhoseNameAPI/QueryWhoseNameEndpoint.php b/tests/Application/A1_WhoseNameAPI/QueryWhoseNameEndpoint.php index c938a3c..73eeb48 100644 --- a/tests/Application/A1_WhoseNameAPI/QueryWhoseNameEndpoint.php +++ b/tests/Application/A1_WhoseNameAPI/QueryWhoseNameEndpoint.php @@ -27,6 +27,35 @@ ]); +gest('usage', 'Querying a service with several names returns them as an array', function () { + Sanctum::actingAs( + User::factory()->create(), + ['whose-name'] + ); + + $response = $this->get('/api/whose-name/query?u=U234567&s=slack&q=email'); + + $response->assertStatus(200); + $response->assertExactJson(['username' => ['other@example.org', 'new@example.org']]); +}); + + +gest('usage', 'An identity can be looked up by any one of its several names', function ($u) { + Sanctum::actingAs( + User::factory()->create(), + ['whose-name'] + ); + + $response = $this->get("/api/whose-name/query?u=$u&s=email&q=slack"); + + $response->assertStatus(200); + $response->assertExactJson(['username' => 'U234567']); +})->with([ + 'other@example.org', + 'new@example.org', +]); + + gest('edge', 'Querying the WhoseName API with not known data returns a null value', function () { Sanctum::actingAs( User::factory()->create(), diff --git a/tests/Domain/AskQueryServiceForIdentities.php b/tests/Domain/AskQueryServiceForIdentities.php index 3b48345..3428bd0 100644 --- a/tests/Domain/AskQueryServiceForIdentities.php +++ b/tests/Domain/AskQueryServiceForIdentities.php @@ -34,6 +34,46 @@ }); +gest('usage', 'Querying a service that holds several names returns all of them as a list', function () { + $identity = new Identity([ + 'slack' => 'U234567', + 'email' => ['other@example.org', 'new@example.org'], + ]); + + $repo = Mockery::mock(IdentityQueryRepository::class); + + $service = new QueryService($repo); + + $repo->shouldReceive('findByServiceAndUsername') + ->with('slack', 'U234567') + ->andReturn($identity); + + $emails = $service->whatIsTheNameOf('U234567', 'slack', 'email'); + expect($emails)->toBeArray()->toEqual(['other@example.org', 'new@example.org']); +}); + + +gest('usage', 'An identity queried by one of its many names resolves other services', function () { + $identity = new Identity([ + 'slack' => 'U234567', + 'email' => ['other@example.org', 'new@example.org'], + ]); + + $repo = Mockery::mock(IdentityQueryRepository::class); + + $service = new QueryService($repo); + + // The repository is what knows every name maps to this identity; + // the service simply asks by whichever name it was given. + $repo->shouldReceive('findByServiceAndUsername') + ->with('email', 'new@example.org') + ->andReturn($identity); + + $slackUsername = $service->whatIsTheNameOf('new@example.org', 'email', 'slack'); + expect($slackUsername)->toBeString()->toEqual('U234567'); +}); + + gest('edge', 'Querying an Identity for a not known service returns a null value', function () { $identity = new Identity([ 'jira' => 'test@makimo.pl', diff --git a/tests/Infrastructure/YamlFileRepositoryHandlesYamlFiles.php b/tests/Infrastructure/YamlFileRepositoryHandlesYamlFiles.php index 30f3415..c8cc08c 100644 --- a/tests/Infrastructure/YamlFileRepositoryHandlesYamlFiles.php +++ b/tests/Infrastructure/YamlFileRepositoryHandlesYamlFiles.php @@ -23,6 +23,31 @@ }); +gest('usage', 'A service holding several names returns them as a list', function () { + $repo = new YamlFileRepository($this->file); + + $value = $repo->findByServiceAndUsername('slack', 'U234567'); + + expect($value) + ->toBeInstanceOf(Identity::class) + ->username('email')->toEqual(['other@example.org', 'new@example.org']); +}); + + +gest('usage', 'An Identity can be found by any one of the several names under a field', function () { + $repo = new YamlFileRepository($this->file); + + foreach (['other@example.org', 'new@example.org'] as $email) { + $value = $repo->findByServiceAndUsername('email', $email); + + expect($value) + ->toBeInstanceOf(Identity::class) + ->username('slack')->toEqual('U234567') + ->username('jira')->toEqual('other@example.org'); + } +}); + + gest('edge', 'If there\'s no matching service/username, an empty Identity is returned', function () { $repo = new YamlFileRepository($this->file); diff --git a/tests/whosename.yml b/tests/whosename.yml index 66c6262..20e0f49 100644 --- a/tests/whosename.yml +++ b/tests/whosename.yml @@ -2,6 +2,10 @@ - slack: U123456 jira: test@example.org + email: single@example.org - slack: U234567 jira: other@example.org + email: + - other@example.org + - new@example.org