diff --git a/README.md b/README.md index 59ef280..1bbf454 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,34 @@ Note: the `Accept` header is important for all requests. See the [whose-name-client](https://github.com/makimo/whose-name-client) repository for a client of this API. +### Batch query + +To resolve many identities in a single request, `POST` a list of queries to the +`/api/whose-name/query/batch` endpoint. Each query is a `{"u","s","q"}` triple with +the same meaning as the single query endpoint (`u` = known username, `s` = its service, +`q` = the service you ask about). + +``` +curl -X POST 'http://localhost/api/whose-name/query/batch' \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + -H "Authorization: Bearer " \ + -d '{"queries":[ + {"u":"test@example.org","s":"jira","q":"slack"}, + {"u":"other@example.org","s":"jira","q":"slack"}, + {"u":"unknown","s":"unknown","q":"unknown"} + ]}' +[{"username":"U123456"},{"username":"U234567"},{"username":null}] +``` + +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: + +- `200 OK` when every query resolved to a username, +- `207 Multi-Status` when at least one query returned `null`, +- `422 Unprocessable Entity` when the request body is malformed (each query must + provide non-empty `u`, `s` and `q`; a batch may contain between 1 and 100 queries). + ### Changing the Yaml file By default, the project uses the `tests/whosename.yml` file. The file contains two users and is not suited for more extensive work or running a working copy of the API. 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 new file mode 100644 index 0000000..598eb28 --- /dev/null +++ b/docs/adl/2026-07-06-batch-query-status-and-shape.md @@ -0,0 +1,55 @@ +# Batch query returns 200/207 with a lean, index-correlated result array + +**Status:** _accepted_. + +## Context + +The single lookup endpoint `GET /api/whose-name/query` resolves one `{u, s, q}` triple +and returns `{"username": ...}` with `200` when found or `404` when not. Clients that +need to resolve many identities at once had to issue one request per lookup. + +A batch endpoint `POST /api/whose-name/query/batch` accepts a list of queries. Two +design questions do not carry over cleanly from the single endpoint: + +- A batch is usually **partial** (some queries match, some do not), so a single `404` + cannot describe the outcome. +- Clients need to correlate each result back to the query that produced it. + +## Decision + +- The response is a **lean JSON array** of `{"username": ...}` objects, in the **same + order** as the incoming `queries`. Item _i_ is the answer to `queries[i]`; a `null` + username means no match. No input echo — order is the correlation key. +- Status codes: + - `200 OK` — every query resolved to a username. + - `207 Multi-Status` — at least one query resolved to `null` (partial or total misses). + - `422 Unprocessable Entity` — malformed body (validation failure). +- Validation is **strict**: `queries` is required, `1..100` items, and each item must + provide non-empty `u`, `s` and `q`. Unlike the single endpoint (which defaults missing + params to an empty string and resolves them to `null`), empty/missing fields are rejected. +- Iteration lives in the domain: `Domain\WhoseName\QueryService::whatAreTheNamesOf` + loops the existing `whatIsTheNameOf`, keeping the route closure a thin HTTP↔domain map, + consistent with "the framework is a client of the domain." + +## Consequences + +- `null` is treated as a legitimate answer, not an HTTP error — so a mixed batch is a + successful response (`207`) that clients parse per item, rather than a failure. +- The lean array is order-dependent: clients must preserve query order to map results. + If order-independent correlation is later required, an input echo or client-supplied + ids can be added without changing the status semantics. +- Reusing the per-request `YamlFileRepository` cache means a batch costs ~one file read + regardless of size; the `max:100` limit bounds work per request. + +## Alternatives + +- Always `200` with per-item `null` (no `207`) — simpler, but hides that some queries missed. +- `404` only when every query misses — conflates HTTP "not found" with a valid `null` answer. +- Echo each input triple alongside its username / key results by a client-supplied id — + order-independent but heavier; rejected in favour of the lean array for now. +- Loop in the route closure instead of the domain — rejected to keep iteration testable + framework-free and consistent with the domain-first convention. + +## Decision date + +> 2026-07-06 diff --git a/domain/WhoseName/QueryService.php b/domain/WhoseName/QueryService.php index ef8108a..b6fc0cf 100644 --- a/domain/WhoseName/QueryService.php +++ b/domain/WhoseName/QueryService.php @@ -13,4 +13,22 @@ public function whatIsTheNameOf(string $username, string $service, string $asked ->findByServiceAndUsername($service, $username) ->username($askedService); } + + /** + * Resolve many queries at once. + * + * @param array $queries A list of ['username' => , 'service' => , 'askedService' => ] triples. + * + * @return array An index-aligned list of ?string usernames (null where unknown). + */ + public function whatAreTheNamesOf(array $queries): array { + return array_map( + fn ($query) => $this->whatIsTheNameOf( + $query['username'], + $query['service'], + $query['askedService'] + ), + $queries + ); + } } diff --git a/routes/api.php b/routes/api.php index aedc29d..ca4cd41 100644 --- a/routes/api.php +++ b/routes/api.php @@ -74,8 +74,29 @@ ); return response()->json( - ['username' => $username], + ['username' => $username], $username === null ? 404: 200 ); }); + + Route::post('/query/batch', function (Request $request, QueryService $service) { + $validated = $request->validate([ + 'queries' => 'required|array|min:1|max:100', + 'queries.*.u' => 'required|string', + 'queries.*.s' => 'required|string', + 'queries.*.q' => 'required|string', + ]); + + $usernames = $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); + + $allResolved = !in_array(null, $usernames, true); + + return response()->json($results, $allResolved ? 200 : 207); + }); }); diff --git a/tests/Application/A1_WhoseNameAPI/QueryWhoseNameBatchEndpoint.php b/tests/Application/A1_WhoseNameAPI/QueryWhoseNameBatchEndpoint.php new file mode 100644 index 0000000..156e0bc --- /dev/null +++ b/tests/Application/A1_WhoseNameAPI/QueryWhoseNameBatchEndpoint.php @@ -0,0 +1,76 @@ +create(), + ['whose-name'] + ); + + $response = $this->postJson('/api/whose-name/query/batch', [ + 'queries' => [ + ['u' => 'test@example.org', 's' => 'jira', 'q' => 'slack'], + ['u' => 'U234567', 's' => 'slack', 'q' => 'jira'], + ], + ]); + + $response->assertStatus(200); + $response->assertExactJson([ + ['username' => 'U123456'], + ['username' => 'other@example.org'], + ]); +}); + + +gest('edge', 'Batch querying returns 207 with null for unknown entries alongside found ones', function () { + Sanctum::actingAs( + User::factory()->create(), + ['whose-name'] + ); + + $response = $this->postJson('/api/whose-name/query/batch', [ + 'queries' => [ + ['u' => 'test@example.org', 's' => 'jira', 'q' => 'slack'], + ['u' => 'unknown', 's' => 'unknown', 'q' => 'unknown'], + ], + ]); + + $response->assertStatus(207); + $response->assertExactJson([ + ['username' => 'U123456'], + ['username' => null], + ]); +}); + + +gest('edge', 'Batch querying with a missing or empty queries array is rejected', function () { + Sanctum::actingAs( + User::factory()->create(), + ['whose-name'] + ); + + $this->postJson('/api/whose-name/query/batch', [])->assertStatus(422); + $this->postJson('/api/whose-name/query/batch', ['queries' => []])->assertStatus(422); +}); + + +gest('edge', 'Batch querying with a malformed query item is rejected', function () { + Sanctum::actingAs( + User::factory()->create(), + ['whose-name'] + ); + + $this->postJson('/api/whose-name/query/batch', [ + 'queries' => [ + ['u' => 'test@example.org', 's' => 'jira'], // missing q + ], + ])->assertStatus(422); +}); diff --git a/tests/Domain/AskQueryServiceForIdentities.php b/tests/Domain/AskQueryServiceForIdentities.php index 2a7c3f5..3b48345 100644 --- a/tests/Domain/AskQueryServiceForIdentities.php +++ b/tests/Domain/AskQueryServiceForIdentities.php @@ -84,3 +84,59 @@ $jiraUsername = $service->whatIsTheNameOf('test@makimo.pl', 'jira', 'jira'); expect($jiraUsername)->toBeString()->toEqual('test@makimo.pl'); }); + + +gest('usage', 'Querying multiple identities at once returns a username for each, in order', function () { + $identity = new Identity([ + 'jira' => 'test@makimo.pl', + 'slack' => 'U12345', + ]); + + $repo = Mockery::mock(IdentityQueryRepository::class); + + $service = new QueryService($repo); + + $repo->shouldReceive('findByServiceAndUsername') + ->with('jira', 'test@makimo.pl') + ->andReturn($identity); + $repo->shouldReceive('findByServiceAndUsername') + ->with('slack', 'U12345') + ->andReturn($identity); + + $results = $service->whatAreTheNamesOf([ + ['username' => 'test@makimo.pl', 'service' => 'jira', 'askedService' => 'slack'], + ['username' => 'U12345', 'service' => 'slack', 'askedService' => 'jira'], + ]); + + expect($results)->toEqual(['U12345', 'test@makimo.pl']); +}); + + +gest('edge', 'Batch querying preserves order and returns null for unknown identities', function () { + $repo = Mockery::mock(IdentityQueryRepository::class); + + $service = new QueryService($repo); + + $repo->shouldReceive('findByServiceAndUsername') + ->with('jira', 'test@makimo.pl') + ->andReturn(new Identity(['jira' => 'test@makimo.pl', 'slack' => 'U12345'])); + $repo->shouldReceive('findByServiceAndUsername') + ->with('unknown', 'unknown') + ->andReturn(new Identity([])); + + $results = $service->whatAreTheNamesOf([ + ['username' => 'test@makimo.pl', 'service' => 'jira', 'askedService' => 'slack'], + ['username' => 'unknown', 'service' => 'unknown', 'askedService' => 'unknown'], + ]); + + expect($results)->toEqual(['U12345', null]); +}); + + +gest('edge', 'Batch querying an empty list returns an empty result set', function () { + $repo = Mockery::mock(IdentityQueryRepository::class); + + $service = new QueryService($repo); + + expect($service->whatAreTheNamesOf([]))->toEqual([]); +});