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
29 changes: 28 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 <YOURTOKEN>"
{"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
Expand All @@ -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`,
Expand Down
83 changes: 83 additions & 0 deletions docs/adl/2026-07-08-multiple-names-per-field.md
Original file line number Diff line number Diff line change
@@ -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
23 changes: 19 additions & 4 deletions domain/WhoseName/Identity.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,24 +10,39 @@
* 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 = [];

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;
Expand Down
11 changes: 9 additions & 2 deletions domain/WhoseName/QueryService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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(
Expand Down
8 changes: 4 additions & 4 deletions infrastructure/WhoseName/YamlFileRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Expand Down
21 changes: 21 additions & 0 deletions tests/Application/A1_WhoseNameAPI/QueryWhoseNameBatchEndpoint.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
29 changes: 29 additions & 0 deletions tests/Application/A1_WhoseNameAPI/QueryWhoseNameEndpoint.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
40 changes: 40 additions & 0 deletions tests/Domain/AskQueryServiceForIdentities.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
25 changes: 25 additions & 0 deletions tests/Infrastructure/YamlFileRepositoryHandlesYamlFiles.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
4 changes: 4 additions & 0 deletions tests/whosename.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading