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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,5 @@ yarn-error.log
/.idea
/.vscode
whosename.ignored.yml
pools.ignored.yml
.DS_Store
54 changes: 54 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,53 @@ found. The endpoint returns:
- `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).

### Pools

A **pool** is a named list of names sharing a single field (service). Pools live in
their own file (`pools.yml` by default):

```yml
-
name: Everyone
field: email
names:
- michal@makimo.pl
- alice@makimo.pl
```

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`):

```
curl 'http://localhost/api/whose-name/pool?p=Everyone&q=jira' \
-H "Accept: application/json" \
-H "Authorization: Bearer <YOURTOKEN>"
[{"username":"jira1"},{"username":["jira2","jira3"]}]
```

- `200 OK` when every member resolved,
- `207 Multi-Status` when at least one member resolved to `null`,
- `404 Not Found` (with `[]`) when the pool is unknown or has no members.

When `q` is the pool's own field, the pool's names are returned verbatim.

**Flattened** — `GET /api/whose-name/pool/names` returns the same answers flattened
into a single, **distinct** list of names (arrays spread in, `null`s and duplicates
dropped):

```
curl 'http://localhost/api/whose-name/pool/names?p=Everyone&q=jira' \
-H "Accept: application/json" \
-H "Authorization: Bearer <YOURTOKEN>"
{"names":["jira1","jira2","jira3"]}
```

Returns `200 OK`, or `404 Not Found` (with `{"names":[]}`) when nothing was found.

### 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 All @@ -125,6 +172,13 @@ If you'd like to change the path, modify the following entry in your `.env` file
WHOSENAME_YAML=tests/whosename.yml
```

Pools are read from a separate file, configured the same way (defaults to
`tests/pools.yml`):

```
WHOSENAME_POOLS_YAML=tests/pools.yml
```

Because of Docker containers, the file must be located inside the repository.

### Creating users and requesting tokens
Expand Down
3 changes: 3 additions & 0 deletions app/Providers/AppServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@

use Illuminate\Support\ServiceProvider;
use Domain\WhoseName\IdentityQueryRepository;
use Domain\WhoseName\PoolQueryRepository;
use Infrastructure\WhoseName\YamlFileRepository;
use Infrastructure\WhoseName\PoolYamlFileRepository;

class AppServiceProvider extends ServiceProvider
{

public $bindings = [
IdentityQueryRepository::class => YamlFileRepository::class,
PoolQueryRepository::class => PoolYamlFileRepository::class,
];

/**
Expand Down
3 changes: 2 additions & 1 deletion config/whosename.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<?php

return [
'yaml_file' => env('WHOSENAME_YAML', 'tests/whosename.yml')
'yaml_file' => env('WHOSENAME_YAML', 'tests/whosename.yml'),
'pools_file' => env('WHOSENAME_POOLS_YAML', 'tests/pools.yml'),
];
76 changes: 76 additions & 0 deletions docs/adl/2026-07-08-pool-endpoints-status-and-shape.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Pool endpoints: a per-member view and a flattened view

**Status:** _accepted_.

## Context

A pool is a named list of names sharing one field (see
`Domain\WhoseName\Pool`). `PoolService` exposes two ways to read it:

- `whatAreTheNamesOf($pool, $service)` — one answer per member, index-aligned
with the pool's names (`string|array|null` each), mirroring
`QueryService::whatAreTheNamesOf`.
- `whoseNamesAreThere($pool, $service)` — those answers flattened into a single
distinct list of names.

Both needed HTTP exposure. The single (`GET /query`) and batch
(`POST /query/batch`) endpoints already set two precedents that pull in
different directions: the single GET is **lenient** (missing params default to
`''`, an unresolved query is `404` with `{"username":null}`), while the batch
POST is **strict** (`422` on a malformed body, `207` on partial results).

## Decision

Two `GET` endpoints under the `whose-name` group, both taking `p` (pool name)
and `q` (asked service):

- `GET /pool` returns the **per-member** view: a JSON array of `{"username": ...}`
in pool order, one per member — the same envelope and order-as-correlation-key
contract as the batch endpoint. Status:
- `200 OK` when every member resolved,
- `207 Multi-Status` when at least one member resolved to `null`,
- `404 Not Found` (body `[]`) when the pool is unknown or empty.
- `GET /pool/names` returns the **flattened** view: `{"names": [...]}`, a distinct
flat list. Status `200 OK`, or `404 Not Found` (body `{"names": []}`) when empty.

Both endpoints are **lenient** like the single GET: `p` and `q` default to `''`
and simply resolve to an empty result (→ `404`) rather than `422`. Strict
`422`-style validation is reserved for the body-carrying batch endpoint.

The route closures stay thin HTTP↔domain maps: `/pool` maps each answer to
`{"username": ...}` and picks `200`/`207` from the presence of `null` (reusing
the batch endpoint's `in_array(null, …, true)` check); `/pool/names` returns the
service's flat list as-is. All iteration, flattening, de-duplication and the
same-field shortcut live in `PoolService`.

## Consequences

- `/pool` is a drop-in parallel of the batch endpoint — a client that already
handles `{"username": string|array|null}` items and `200`/`207` needs no new
parsing; only the request shape (a pool name instead of an explicit query list)
differs.
- `404` on an empty result conflates "unknown pool" with "known pool, no members
resolved". This matches the single endpoint's "nothing found → 404" and keeps
`null`/`[]` a valid, non-error answer inside a `207`; distinguishing the two
cases would need an extra signal and is deferred until a client needs it.
- `/pool/names` drops `null`s and duplicates, so it cannot be correlated back to
members — that is the explicit trade of the flattened view. Use `/pool` when
per-member correlation or `207` semantics matter.
- Leniency means a typo in `p` or `q` yields `404`, not `422`. Consistent with the
single GET, at the cost of not flagging malformed input.

## Alternatives

- **One endpoint with a `flat=true` flag** — rejected: two response shapes
(`[{...}]` vs `{"names":[…]}`) and two status tables behind one URL is harder to
document and cache than two intent-named routes.
- **`POST /pool/batch`** for many pools at once — deferred; no need yet, and it
can be added later exactly like `query/batch` without disturbing these routes.
- **Strict `422` validation of `p`/`q`** — rejected for parity with the single
GET; can be tightened later if clients prefer explicit rejection.
- **`200` with `[]` for an unknown pool** — rejected: hides misses behind a
success, unlike the rest of the API.

## Decision date

> 2026-07-08
35 changes: 35 additions & 0 deletions domain/WhoseName/Pool.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php namespace Domain\WhoseName;

/**
* A named list of names sharing a single field (service).
*
* A Pool groups people together under a key (its name) and records,
* for one field, the names that belong to the pool:
*
* name: Everyone
* field: email
* names:
* - michal@makimo.pl
* - alice@makimo.pl
*
* The name is the lookup key; the Pool itself only carries the field
* its names belong to and the names themselves.
*/
class Pool {
protected
$field,
$names;

public function __construct(string $field, array $names) {
$this->field = $field;
$this->names = $names;
}

public function getField(): string {
return $this->field;
}

public function getNames(): array {
return $this->names;
}
}
12 changes: 12 additions & 0 deletions domain/WhoseName/PoolQueryRepository.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php namespace Domain\WhoseName;

interface PoolQueryRepository {
/**
* Fetch a Pool given its name.
*
* @param string $name A pool name (its lookup key).
*
* @return Pool (an empty one if no match was found).
*/
public function findByName(string $name): Pool;
}
60 changes: 60 additions & 0 deletions domain/WhoseName/PoolService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php namespace Domain\WhoseName;

class PoolService {
protected
$pools,
$queryService;

public function __construct(PoolQueryRepository $pools, QueryService $queryService) {
$this->pools = $pools;
$this->queryService = $queryService;
}

/**
* Resolve each pool member's name(s) on a given service.
*
* Returns one response per member, index-aligned with the pool's names
* and mirroring QueryService::whatAreTheNamesOf, so callers can map the
* results one-to-one (e.g. onto a batch HTTP response with 207
* semantics). Each response is string|array|null, exactly as
* QueryService::whatIsTheNameOf returns it. When the asked service is the
* pool's own field, the pool's names are returned verbatim.
*
* @return array A list of string|array|null answers, one per member.
*/
public function whatAreTheNamesOf(string $poolName, string $askedService): array {
$pool = $this->pools->findByName($poolName);

if ($pool->getField() === $askedService) {
return $pool->getNames();
}

return array_map(
fn ($name) => $this->queryService->whatIsTheNameOf(
$name,
$pool->getField(),
$askedService
),
$pool->getNames()
);
}

/**
* The flat list of distinct names present in a pool on a given service.
*
* Flattens whatAreTheNamesOf: a member resolving to several names is
* spread in, a member resolving to none (null) is dropped, and any name
* reached through more than one member appears only once. Order follows
* first occurrence.
*
* @return array A flat list of unique names.
*/
public function whoseNamesAreThere(string $poolName, string $askedService): array {
return array_values(array_unique(
array_merge([], ...array_map(
fn ($response) => (array) $response,
$this->whatAreTheNamesOf($poolName, $askedService)
))
));
}
}
98 changes: 98 additions & 0 deletions infrastructure/WhoseName/PoolYamlFileRepository.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<?php namespace Infrastructure\WhoseName;

use Domain\WhoseName\PoolQueryRepository;
use Domain\WhoseName\Pool;

use Cache;

use Symfony\Component\Yaml\Yaml;

class PoolYamlFileRepository implements PoolQueryRepository {
protected
$prefix,
$sourceFilePath,
$poolMap = null,
$poolLookupMap = null;

protected static function loadYamlFile(string $path): array {
return Yaml::parse(file_get_contents($path));
}

protected static function transformPoolListToIndex(array $list): array {
$mapping = [];

foreach($list as $index => $pool) {
$mapping[$pool['name']] = $index;
}

return $mapping;
}

public function __construct(?string $path = null) {
if(!$path) {
$path = config('whosename.pools_file');
}

if(str_starts_with($path, '/')) {
$this->sourceFilePath = $path;
} else {
$this->sourceFilePath = base_path($path);
}

$this->prefix = hash("crc32b", $this->sourceFilePath);
}

protected function load(): void {
$modificationTime = filemtime($this->sourceFilePath);

if(!$modificationTime) {
throw new \RuntimeException("Yaml file not found!");
}

$cachedModificationTime = Cache::get($this->cacheKey('timestamp'), -1);

$sourceFileNeedsReload =
$modificationTime > $cachedModificationTime
|| !Cache::has($this->cacheKey('pools'));

if($sourceFileNeedsReload) {
$this->poolMap = static::loadYamlFile($this->sourceFilePath);
$this->poolLookupMap = static::transformPoolListToIndex(
$this->poolMap
);

Cache::put($this->cacheKey('pools'), [
$this->poolMap,
$this->poolLookupMap,
]);

Cache::put($this->cacheKey('timestamp'), $modificationTime);

return;
}

list(
$this->poolMap,
$this->poolLookupMap
) = Cache::get($this->cacheKey('pools'));
}

protected function cacheKey($property) {
return "whosename.pools.$this->prefix.$property";
}

public function findByName(string $name): Pool {
if(!$this->poolMap) {
$this->load();
}

if(!isset($this->poolLookupMap[$name])) {
return new Pool('', []);
}

$pool = $this->poolMap[$this->poolLookupMap[$name]];

return new Pool($pool['field'], $pool['names']);
}

}
Loading
Loading