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
22 changes: 13 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,13 +102,15 @@ curl -X POST 'http://localhost/api/whose-name/query/batch' \
{"u":"other@example.org","s":"jira","q":"slack"},
{"u":"unknown","s":"unknown","q":"unknown"}
]}'
[{"username":"U123456"},{"username":"U234567"},{"username":null}]
[{"u":"test@example.org","s":"jira","q":"slack","a":"U123456"},
{"u":"other@example.org","s":"jira","q":"slack","a":"U234567"},
{"u":"unknown","s":"unknown","q":"unknown","a":null}]
```

The response is an array of `{"username": ...}` results in the **same order** as the
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:
The response is an array of results in the **same order** as the queries. Each
result **echoes its query** (`u`, `s`, `q`) and carries the answer in `a` — 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 All @@ -132,15 +134,17 @@ their own file (`pools.yml` by default):
Both pool endpoints take `p` (the pool name) and `q` (the service you ask about).

**Per-member** — `GET /api/whose-name/pool` resolves every member on the asked
service and returns one `{"username": ...}` result per member, **in pool order**
(same shape and status semantics as the batch query — a member may resolve to a
string, an array of names, or `null`):
service and returns one result per member, **in pool order**, in the same
`{u, s, q, a}` shape and with the same status semantics as the batch query. For a
pool, `u` is the member's name, `s` the pool's field, `q` the asked service, and
`a` the answer (a string, an array of names, or `null`):

```
curl 'http://localhost/api/whose-name/pool?p=Everyone&q=jira' \
-H "Accept: application/json" \
-H "Authorization: Bearer <YOURTOKEN>"
[{"username":"jira1"},{"username":["jira2","jira3"]}]
[{"u":"michal@makimo.pl","s":"email","q":"jira","a":"jira1"},
{"u":"alice@makimo.pl","s":"email","q":"jira","a":["jira2","jira3"]}]
```

- `200 OK` when every member resolved,
Expand Down
7 changes: 6 additions & 1 deletion docs/adl/2026-07-06-batch-query-status-and-shape.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
# Batch query returns 200/207 with a lean, index-correlated result array

**Status:** _accepted_.
**Status:** _superseded_ by
[2026-07-08-batch-query-echoes-input.md](2026-07-08-batch-query-echoes-input.md)
for the **response shape** — each result now echoes its query and carries the
answer under `a` instead of a lean `{"username": ...}`. The **status-code and
validation** decisions below (`200`/`207`/`422`, strict `1..100` items) still
hold.

## Context

Expand Down
67 changes: 67 additions & 0 deletions docs/adl/2026-07-08-batch-query-echoes-input.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Batch query echoes each query and returns the answer under `a`

**Status:** _accepted_. Supersedes the response-shape half of
[2026-07-06-batch-query-status-and-shape.md](2026-07-06-batch-query-status-and-shape.md).

## Context

The batch endpoint `POST /api/whose-name/query/batch` returned a **lean array** of
`{"username": ...}` objects, correlated to the input purely by order. The original
ADL called this out as a deliberate, reversible trade: _"an input echo or
client-supplied ids can be added without changing the status semantics"_.

Order-only correlation is fragile for clients: a result carries no record of which
query produced it, so any reordering, filtering, or logging of individual results
loses that link, and a single result is not self-describing.

## Decision

Each result item **echoes its query and carries the answer**:

```json
{"u": "test@example.org", "s": "jira", "q": "slack", "a": "U123456"}
```

- `u`, `s`, `q` are the query's fields, verbatim.
- `a` is the answer — a string, an array of strings (when the asked service holds
several names), or `null` when no match was found. It replaces the old
`username` key.
- The response stays an array in the **same order** as the queries.

Everything else is unchanged from the superseded ADL: `200` when every query
resolved, `207` when at least one `a` is `null`, `422` on a malformed body
(strict `1..100` items, each with non-empty `u`, `s`, `q`). Iteration still lives
in `QueryService::whatAreTheNamesOf`; the route closure zips the validated queries
with their answers.

The single endpoint `GET /query` is untouched — it still returns
`{"username": ...}`.

## Consequences

- Each result is **self-describing**: clients can correlate by content, not just
position, and safely reorder, filter, or log individual items.
- The payload is heavier (four keys per item, echoing input the client already
sent). Acceptable for a bounded batch (`max:100`).
- **Breaking change** for clients: the per-item key changed from `username` to
`a`, and the shape gained `u`/`s`/`q`. Consumers of the batch endpoint must
migrate. The single endpoint's `{"username": ...}` is intentionally left as-is,
so the two endpoints now differ in their result key.
- The pool per-member endpoint (`GET /pool`) adopts the **same** echoed shape,
mapping each member to a query: `u` = member name, `s` = the pool's field,
`q` = the asked service, `a` = the answer. The route reads the pool (for its
members and field) and zips it with the answers from
`PoolService::whatAreTheNamesOf`, which is left returning a plain list so
`whoseNamesAreThere` and the flattened `/pool/names` endpoint are unaffected.

## Alternatives

- **Keep the lean array, add a client-supplied `id`** — order-independent with a
smaller payload, but pushes correlation bookkeeping onto the client and needs a
new input field; rejected in favour of echoing the query the client already has.
- **Echo input but keep `username` for the answer** — rejected: `a` is short and
neutral, and renaming makes the shape change unmistakable at the call site.

## Decision date

> 2026-07-08
34 changes: 24 additions & 10 deletions routes/api.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use Domain\WhoseName\QueryService;
use Domain\WhoseName\PoolService;
use Domain\WhoseName\PoolQueryRepository;

/*
|--------------------------------------------------------------------------
Expand Down Expand Up @@ -88,32 +89,45 @@
'queries.*.q' => 'required|string',
]);

$usernames = $service->whatAreTheNamesOf(array_map(fn ($query) => [
$answers = $service->whatAreTheNamesOf(array_map(fn ($query) => [
'username' => $query['u'],
'service' => $query['s'],
'askedService' => $query['q'],
], $validated['queries']));

$results = array_map(fn ($username) => ['username' => $username], $usernames);
$results = array_map(fn ($query, $answer) => [
'u' => $query['u'],
's' => $query['s'],
'q' => $query['q'],
'a' => $answer,
], $validated['queries'], $answers);

$allResolved = !in_array(null, $usernames, true);
$allResolved = !in_array(null, $answers, true);

return response()->json($results, $allResolved ? 200 : 207);
});

Route::get('/pool', function (Request $request, PoolService $service) {
$responses = $service->whatAreTheNamesOf(
$request->input('p', ''),
$request->input('q', '')
);
Route::get('/pool', function (Request $request, PoolService $service, PoolQueryRepository $pools) {
$poolName = $request->input('p', '');
$askedService = $request->input('q', '');

$pool = $pools->findByName($poolName);
$answers = $service->whatAreTheNamesOf($poolName, $askedService);

$results = array_map(fn ($username) => ['username' => $username], $responses);
// Echo each member as a query (u = member, s = pool field,
// q = asked service) alongside its answer, like the batch endpoint.
$results = array_map(fn ($member, $answer) => [
'u' => $member,
's' => $pool->getField(),
'q' => $askedService,
'a' => $answer,
], $pool->getNames(), $answers);

if (empty($results)) {
return response()->json($results, 404);
}

$allResolved = !in_array(null, $responses, true);
$allResolved = !in_array(null, $answers, true);

return response()->json($results, $allResolved ? 200 : 207);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
uses(RefreshDatabase::class);


gest('usage', 'Batch querying returns 200 with a username per query, in order, when all resolve', function () {
gest('usage', 'Batch querying echoes each query and returns 200 with its answer, in order, when all resolve', function () {
Sanctum::actingAs(
User::factory()->create(),
['whose-name']
Expand All @@ -24,8 +24,8 @@

$response->assertStatus(200);
$response->assertExactJson([
['username' => 'U123456'],
['username' => 'other@example.org'],
['u' => 'test@example.org', 's' => 'jira', 'q' => 'slack', 'a' => 'U123456'],
['u' => 'U234567', 's' => 'slack', 'q' => 'jira', 'a' => 'other@example.org'],
]);
});

Expand All @@ -45,8 +45,8 @@

$response->assertStatus(200);
$response->assertExactJson([
['username' => ['other@example.org', 'new@example.org']],
['username' => 'U234567'],
['u' => 'U234567', 's' => 'slack', 'q' => 'email', 'a' => ['other@example.org', 'new@example.org']],
['u' => 'new@example.org', 's' => 'email', 'q' => 'slack', 'a' => 'U234567'],
]);
});

Expand All @@ -66,8 +66,8 @@

$response->assertStatus(207);
$response->assertExactJson([
['username' => 'U123456'],
['username' => null],
['u' => 'test@example.org', 's' => 'jira', 'q' => 'slack', 'a' => 'U123456'],
['u' => 'unknown', 's' => 'unknown', 'q' => 'unknown', 'a' => null],
]);
});

Expand Down
12 changes: 6 additions & 6 deletions tests/Application/A1_WhoseNameAPI/QueryWhoseNamePoolEndpoint.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@

$response->assertStatus(200);
$response->assertExactJson([
['username' => 'single@example.org'],
['username' => ['other@example.org', 'new@example.org']],
['u' => 'U123456', 's' => 'slack', 'q' => 'email', 'a' => 'single@example.org'],
['u' => 'U234567', 's' => 'slack', 'q' => 'email', 'a' => ['other@example.org', 'new@example.org']],
]);
});

Expand All @@ -34,8 +34,8 @@

$response->assertStatus(200);
$response->assertExactJson([
['username' => 'U123456'],
['username' => 'U234567'],
['u' => 'U123456', 's' => 'slack', 'q' => 'slack', 'a' => 'U123456'],
['u' => 'U234567', 's' => 'slack', 'q' => 'slack', 'a' => 'U234567'],
]);
});

Expand All @@ -50,8 +50,8 @@

$response->assertStatus(207);
$response->assertExactJson([
['username' => 'test@example.org'],
['username' => null],
['u' => 'single@example.org', 's' => 'email', 'q' => 'jira', 'a' => 'test@example.org'],
['u' => 'ghost@example.org', 's' => 'email', 'q' => 'jira', 'a' => null],
]);
});

Expand Down
Loading