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
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <YOURTOKEN>" \
-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.
Expand Down
55 changes: 55 additions & 0 deletions docs/adl/2026-07-06-batch-query-status-and-shape.md
Original file line number Diff line number Diff line change
@@ -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
18 changes: 18 additions & 0 deletions domain/WhoseName/QueryService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
}
}
23 changes: 22 additions & 1 deletion routes/api.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
76 changes: 76 additions & 0 deletions tests/Application/A1_WhoseNameAPI/QueryWhoseNameBatchEndpoint.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<?php

use App\Models\User;
use Laravel\Sanctum\Sanctum;

use Illuminate\Foundation\Testing\RefreshDatabase;


uses(RefreshDatabase::class);


gest('usage', 'Batch querying returns 200 with a username per query, in order, when all resolve', 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' => '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);
});
56 changes: 56 additions & 0 deletions tests/Domain/AskQueryServiceForIdentities.php
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
});
Loading