Skip to content
Open
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
53 changes: 53 additions & 0 deletions AI_CONTEXT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Coffee Shops Finder — AI Assistant Context

## Stack
PHP 8.3, Symfony 7, Docker, PHPUnit, PHPStan level 8, PHP CS Fixer @Symfony ruleset.

## Architecture — 4 layers
- Domain: src/Domain/ — generic location logic, no Symfony
- Infrastructure: src/Infrastructure/ — CSV fetch, cache, parse
- Application: src/Application/ — coffee shop use cases
- HTTP: src/Controller/, src/Http/ — request/response, validation

## Key patterns to follow
- final readonly on value objects and DTOs
- declare(strict_types=1) in every file
- No AbstractController — inject only what you need
- Validation in controller via ->all() not ->get()
- Exceptions mapped in ApiExceptionSubscriber
- Rounding only in controller mapCoffeeShop()
- yield in parser — streaming, never load full CSV in memory
- Atomic write in cache — tempnam + rename

## Existing classes to know
- Coordinates, NamedLocation, LocationWithDistance — Domain value objects
- NearestLocationsFinder — top-N without full sort
- CoffeeShopProviderInterface — in Application, implemented in Infrastructure
- FindNearestCoffeeShopsHandler — orchestrates use case
- NearestCoffeeShopsController — validates x,y, calls handler, rounds distances
- ApiExceptionSubscriber — maps exceptions to JSON errors
- InvalidQueryParameterException — single exception for all invalid HTTP input

## Code style
- No tutorial comments
- No getters on readonly classes — use public properties directly
- No fromArray/toArray unless needed
- Namespace: App\ maps to src/

## Workflow
- Explain approach first. Show code in chat only.
- Wait for explicit "implement" or "ok, apply" before writing files.
- NEVER run git commands — no add, commit, push, checkout, merge.
- NEVER modify files outside src/, tests/, config/, docs/.
- NEVER touch composer.json or composer.lock without asking.
- NEVER add new dependencies without explicit approval.

## General constraints for every task
- Do not change any existing logic unless the task explicitly requires it
- Do not touch tests unless the task adds new behavior
- Do not modify files outside src/, tests/, config/, docs/
- Do not touch composer.json, composer.lock, Dockerfile, docker-compose.yml, Makefile
- NEVER run git commands
- Run make test after implementing and report results
- Show plan first, do not write files until I say "implement" or "ok, apply"
- Keep diffs small — one task at a time
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
"symfony/http-client": "^7.2",
"symfony/monolog-bundle": "^3.10",
"symfony/runtime": "^7.2",
"symfony/yaml": "^7.2"
"symfony/yaml": "^7.2",
"webonyx/graphql-php": "^15.32"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.0",
Expand Down
82 changes: 81 additions & 1 deletion composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions config/routes.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,8 @@ nearest_coffee_shops:
path: /api/coffee-shops/nearest
controller: App\Controller\NearestCoffeeShopsController
methods: [GET]

coffee_shops_graphql:
path: /api/graphql
controller: App\Controller\GraphQlController
methods: [POST]
34 changes: 34 additions & 0 deletions docs/requests.http
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,37 @@ Accept: application/json
### Non-finite x
GET http://localhost:8080/api/coffee-shops/nearest?x=1e309&y=0
Accept: application/json

### GraphQL - nearest coffee shops (acceptance example)
POST http://localhost:8080/api/graphql
Content-Type: application/json

{
"query": "query($x: Float!, $y: Float!) { nearestCoffeeShops(x: $x, y: $y) { name location { x y } distance } }",
"variables": { "x": 4, "y": -122.4 }
}

### GraphQL - inline arguments
POST http://localhost:8080/api/graphql
Content-Type: application/json

{
"query": "{ nearestCoffeeShops(x: 47.6, y: -122.4) { name distance } }"
}

### GraphQL - missing required argument (validation error, HTTP 200)
POST http://localhost:8080/api/graphql
Content-Type: application/json

{
"query": "{ nearestCoffeeShops(x: 47.6) { name } }"
}

### GraphQL - mapped domain error -> errors[0].extensions.code (e.g. COFFEE_SHOPS_UNAVAILABLE / INTERNAL_ERROR)
# HTTP 200 with: { "errors": [{ "message": "...", "extensions": { "code": "..." } }], "data": { "nearestCoffeeShops": null } }
POST http://localhost:8080/api/graphql
Content-Type: application/json

{
"query": "{ nearestCoffeeShops(x: 47.6, y: -122.4) { name } }"
}
68 changes: 68 additions & 0 deletions src/Controller/GraphQlController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?php

declare(strict_types=1);

namespace App\Controller;

use App\GraphQl\CoffeeShopSchemaFactory;
use App\Http\Error\ApiErrorMapper;
use GraphQL\Error\FormattedError;
use GraphQL\GraphQL;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;

final readonly class GraphQlController
{
public function __construct(
private CoffeeShopSchemaFactory $schemaFactory,
private ApiErrorMapper $mapper,
) {
}

public function __invoke(Request $request): JsonResponse
{
try {
/** @var array{query?: string, variables?: array<string, mixed>|null} $input */
$input = json_decode((string) $request->getContent(), true, flags: JSON_THROW_ON_ERROR);
} catch (\JsonException) {
return new JsonResponse([
'errors' => [
[
'message' => 'Invalid JSON request.',
'extensions' => ['code' => 'INVALID_REQUEST'],
],
],
]);
}

$result = GraphQL::executeQuery(
$this->schemaFactory->create(),
$input['query'] ?? '',
variableValues: $input['variables'] ?? null,
);

$result->setErrorFormatter($this->formatError(...));

return new JsonResponse($result->toArray());
}

/**
* @return array{message: string, locations?: array<int, array{line: int, column: int}>, path?: array<int, int|string>, extensions?: array<string, mixed>}
*/
private function formatError(\Throwable $error): array
{
$previous = $error->getPrevious();

// webonyx wraps resolver exceptions as previous; syntax/validation errors have none.
if (!$previous instanceof \Throwable) {
return FormattedError::createFromException($error);
}

$apiError = $this->mapper->map($previous);

return [
'message' => $apiError->message,
'extensions' => ['code' => $apiError->code],
];
}
}
68 changes: 68 additions & 0 deletions src/GraphQl/CoffeeShopSchemaFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?php

declare(strict_types=1);

namespace App\GraphQl;

use App\Application\CoffeeShop\FindNearestCoffeeShopsHandler;
use App\Application\CoffeeShop\NearestCoffeeShop;
use App\Domain\Location\Coordinates;
use GraphQL\Type\Definition\ObjectType;
use GraphQL\Type\Definition\Type;
use GraphQL\Type\Schema;
use GraphQL\Type\SchemaConfig;

final readonly class CoffeeShopSchemaFactory
{
private const RESULT_LIMIT = 3;

public function __construct(
private FindNearestCoffeeShopsHandler $handler,
) {
}

public function create(): Schema
{
$location = new ObjectType([
'name' => 'Location',
'fields' => [
'x' => Type::nonNull(Type::float()),
'y' => Type::nonNull(Type::float()),
],
]);

$coffeeShop = new ObjectType([
'name' => 'CoffeeShop',
'fields' => [
'name' => Type::nonNull(Type::string()),
'location' => [
'type' => Type::nonNull($location),
'resolve' => static fn (NearestCoffeeShop $shop): array => ['x' => $shop->x, 'y' => $shop->y],
],
'distance' => [
'type' => Type::nonNull(Type::float()),
'resolve' => static fn (NearestCoffeeShop $shop): float => round($shop->distance, 4),
],
],
]);

$query = new ObjectType([
'name' => 'Query',
'fields' => [
'nearestCoffeeShops' => [
'type' => Type::nonNull(Type::listOf(Type::nonNull($coffeeShop))),
'args' => [
'x' => Type::nonNull(Type::float()),
'y' => Type::nonNull(Type::float()),
],
'resolve' => fn (mixed $root, array $args): array => $this->handler->handle(
new Coordinates((float) $args['x'], (float) $args['y']),
self::RESULT_LIMIT,
),
],
],
]);

return new Schema((new SchemaConfig())->setQuery($query));
}
}
15 changes: 15 additions & 0 deletions src/Http/Error/ApiError.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace App\Http\Error;

final readonly class ApiError
{
public function __construct(
public string $code,
public string $message,
public int $status,
) {
}
}
Loading