diff --git a/README.md b/README.md index 31e8928..b20cbc4 100644 --- a/README.md +++ b/README.md @@ -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`, @@ -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 " -[{"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, diff --git a/docs/adl/2026-07-06-batch-query-status-and-shape.md b/docs/adl/2026-07-06-batch-query-status-and-shape.md index 598eb28..20599fc 100644 --- a/docs/adl/2026-07-06-batch-query-status-and-shape.md +++ b/docs/adl/2026-07-06-batch-query-status-and-shape.md @@ -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 diff --git a/docs/adl/2026-07-08-batch-query-echoes-input.md b/docs/adl/2026-07-08-batch-query-echoes-input.md new file mode 100644 index 0000000..d3b1f05 --- /dev/null +++ b/docs/adl/2026-07-08-batch-query-echoes-input.md @@ -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 diff --git a/routes/api.php b/routes/api.php index 586861e..c3f1f5e 100644 --- a/routes/api.php +++ b/routes/api.php @@ -6,6 +6,7 @@ use Domain\WhoseName\QueryService; use Domain\WhoseName\PoolService; +use Domain\WhoseName\PoolQueryRepository; /* |-------------------------------------------------------------------------- @@ -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); }); diff --git a/tests/Application/A1_WhoseNameAPI/QueryWhoseNameBatchEndpoint.php b/tests/Application/A1_WhoseNameAPI/QueryWhoseNameBatchEndpoint.php index f6d07be..5a91f84 100644 --- a/tests/Application/A1_WhoseNameAPI/QueryWhoseNameBatchEndpoint.php +++ b/tests/Application/A1_WhoseNameAPI/QueryWhoseNameBatchEndpoint.php @@ -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'] @@ -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'], ]); }); @@ -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'], ]); }); @@ -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], ]); }); diff --git a/tests/Application/A1_WhoseNameAPI/QueryWhoseNamePoolEndpoint.php b/tests/Application/A1_WhoseNameAPI/QueryWhoseNamePoolEndpoint.php index d53dae3..c64d688 100644 --- a/tests/Application/A1_WhoseNameAPI/QueryWhoseNamePoolEndpoint.php +++ b/tests/Application/A1_WhoseNameAPI/QueryWhoseNamePoolEndpoint.php @@ -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']], ]); }); @@ -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'], ]); }); @@ -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], ]); });