diff --git a/AI_CONTEXT.md b/AI_CONTEXT.md new file mode 100644 index 0000000..fbdeeda --- /dev/null +++ b/AI_CONTEXT.md @@ -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 \ No newline at end of file diff --git a/composer.json b/composer.json index ec1e9c1..9bc5199 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/composer.lock b/composer.lock index 00bc934..106f4e4 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ae1e1074cdcfa60afb5de4dd4aeea7c7", + "content-hash": "686859b17391dd911275c6208d83642c", "packages": [ { "name": "monolog/monolog", @@ -2804,6 +2804,86 @@ } ], "time": "2026-05-25T06:06:12+00:00" + }, + { + "name": "webonyx/graphql-php", + "version": "v15.32.3", + "source": { + "type": "git", + "url": "https://github.com/webonyx/graphql-php.git", + "reference": "993bf0bea17f870412ad8a90f60c41cb8d5f1145" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webonyx/graphql-php/zipball/993bf0bea17f870412ad8a90f60c41cb8d5f1145", + "reference": "993bf0bea17f870412ad8a90f60c41cb8d5f1145", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "php": "^7.4 || ^8" + }, + "require-dev": { + "amphp/amp": "^2.6 || ^3", + "amphp/http-server": "^2.1 || ^3", + "dms/phpunit-arraysubset-asserts": "dev-master", + "ergebnis/composer-normalize": "^2.28", + "friendsofphp/php-cs-fixer": "3.95.1", + "mll-lab/php-cs-fixer-config": "5.13.0", + "nyholm/psr7": "^1.5", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "2.1.51", + "phpstan/phpstan-phpunit": "2.0.16", + "phpstan/phpstan-strict-rules": "2.0.10", + "phpunit/phpunit": "^9.5 || ^10.5.21 || ^11", + "psr/http-message": "^1 || ^2", + "react/http": "^1.6", + "react/promise": "^2.0 || ^3.0", + "rector/rector": "^2.0", + "symfony/polyfill-php81": "^1.23", + "symfony/var-exporter": "^5 || ^6 || ^7 || ^8", + "thecodingmachine/safe": "^1.3 || ^2 || ^3", + "ticketswap/phpstan-error-formatter": "1.3.0" + }, + "suggest": { + "amphp/amp": "To leverage async resolving on AMPHP platform (v3 with AmpFutureAdapter, v2 with AmpPromiseAdapter)", + "amphp/http-server": "To leverage async resolving with webserver on AMPHP platform", + "psr/http-message": "To use standard GraphQL server", + "react/promise": "To leverage async resolving on React PHP platform" + }, + "type": "library", + "autoload": { + "psr-4": { + "GraphQL\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A PHP port of GraphQL reference implementation", + "homepage": "https://github.com/webonyx/graphql-php", + "keywords": [ + "api", + "graphql" + ], + "support": { + "issues": "https://github.com/webonyx/graphql-php/issues", + "source": "https://github.com/webonyx/graphql-php/tree/v15.32.3" + }, + "funding": [ + { + "url": "https://github.com/spawnia", + "type": "github" + }, + { + "url": "https://opencollective.com/webonyx-graphql-php", + "type": "open_collective" + } + ], + "time": "2026-04-24T13:49:35+00:00" } ], "packages-dev": [ diff --git a/config/routes.yaml b/config/routes.yaml index 975b71b..a84e914 100644 --- a/config/routes.yaml +++ b/config/routes.yaml @@ -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] diff --git a/docs/requests.http b/docs/requests.http index db04142..a649b50 100644 --- a/docs/requests.http +++ b/docs/requests.http @@ -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 } }" +} diff --git a/src/Controller/GraphQlController.php b/src/Controller/GraphQlController.php new file mode 100644 index 0000000..be167ed --- /dev/null +++ b/src/Controller/GraphQlController.php @@ -0,0 +1,68 @@ +|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, path?: array, extensions?: array} + */ + 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], + ]; + } +} diff --git a/src/GraphQl/CoffeeShopSchemaFactory.php b/src/GraphQl/CoffeeShopSchemaFactory.php new file mode 100644 index 0000000..408263f --- /dev/null +++ b/src/GraphQl/CoffeeShopSchemaFactory.php @@ -0,0 +1,68 @@ + '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)); + } +} diff --git a/src/Http/Error/ApiError.php b/src/Http/Error/ApiError.php new file mode 100644 index 0000000..0ab443f --- /dev/null +++ b/src/Http/Error/ApiError.php @@ -0,0 +1,15 @@ + new ApiError( + 'INVALID_COORDINATES', + $exception->getMessage(), + Response::HTTP_BAD_REQUEST, + ), + $exception instanceof CsvFetchException => new ApiError( + 'COFFEE_SHOPS_UNAVAILABLE', + 'Coffee shop data is temporarily unavailable. Please try again later.', + Response::HTTP_SERVICE_UNAVAILABLE, + ), + $exception instanceof NoValidLocationsException => new ApiError( + 'NO_VALID_LOCATIONS', + 'No valid coffee shop locations are available.', + Response::HTTP_UNPROCESSABLE_ENTITY, + ), + default => new ApiError( + 'INTERNAL_ERROR', + 'An unexpected error occurred. Please try again later.', + Response::HTTP_INTERNAL_SERVER_ERROR, + ), + }; + } +} diff --git a/src/Http/Subscriber/ApiExceptionSubscriber.php b/src/Http/Subscriber/ApiExceptionSubscriber.php index c95dc39..fb3e7b8 100644 --- a/src/Http/Subscriber/ApiExceptionSubscriber.php +++ b/src/Http/Subscriber/ApiExceptionSubscriber.php @@ -4,19 +4,17 @@ namespace App\Http\Subscriber; -use App\Exception\CsvFetchException; -use App\Exception\InvalidCoordinatesException; -use App\Exception\NoValidLocationsException; +use App\Http\Error\ApiErrorMapper; use Psr\Log\LoggerInterface; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpFoundation\JsonResponse; -use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Event\ExceptionEvent; use Symfony\Component\HttpKernel\KernelEvents; final readonly class ApiExceptionSubscriber implements EventSubscriberInterface { public function __construct( + private ApiErrorMapper $mapper, private LoggerInterface $logger, ) { } @@ -31,53 +29,17 @@ public static function getSubscribedEvents(): array public function onKernelException(ExceptionEvent $event): void { $exception = $event->getThrowable(); + $error = $this->mapper->map($exception); - if ($exception instanceof InvalidCoordinatesException) { - $event->setResponse($this->errorResponse( - 'INVALID_COORDINATES', - $exception->getMessage(), - Response::HTTP_BAD_REQUEST, - )); - - return; - } - - if ($exception instanceof CsvFetchException) { - $event->setResponse($this->errorResponse( - 'COFFEE_SHOPS_UNAVAILABLE', - 'Coffee shop data is temporarily unavailable. Please try again later.', - Response::HTTP_SERVICE_UNAVAILABLE, - )); - - return; + if ('INTERNAL_ERROR' === $error->code) { + $this->logger->critical('Unhandled exception.', ['exception' => $exception]); } - if ($exception instanceof NoValidLocationsException) { - $event->setResponse($this->errorResponse( - 'NO_VALID_LOCATIONS', - 'No valid coffee shop locations are available.', - Response::HTTP_UNPROCESSABLE_ENTITY, - )); - - return; - } - - $this->logger->critical('Unhandled exception.', ['exception' => $exception]); - - $event->setResponse($this->errorResponse( - 'INTERNAL_ERROR', - 'An unexpected error occurred. Please try again later.', - Response::HTTP_INTERNAL_SERVER_ERROR, - )); - } - - private function errorResponse(string $code, string $message, int $status): JsonResponse - { - return new JsonResponse([ + $event->setResponse(new JsonResponse([ 'error' => [ - 'code' => $code, - 'message' => $message, + 'code' => $error->code, + 'message' => $error->message, ], - ], $status); + ], $error->status)); } } diff --git a/tests/Functional/Controller/GraphQlControllerTest.php b/tests/Functional/Controller/GraphQlControllerTest.php new file mode 100644 index 0000000..332af89 --- /dev/null +++ b/tests/Functional/Controller/GraphQlControllerTest.php @@ -0,0 +1,178 @@ +replaceProviderLocations([ + self::location('Closest', 0.1, 0.5), + self::location('Second', 0.5, 0.5), + self::location('Third', 1.0, 1.0), + self::location('Far', 10.0, 10.0), + self::location('Beyond', 100.0, 100.0), + ]); + + $response = $this->graphql( + $kernel, + 'query($x: Float!, $y: Float!) { + nearestCoffeeShops(x: $x, y: $y) { + name location { x y } distance + } + }', + ['x' => 0.0, 'y' => 0.0], + ); + + self::assertSame(Response::HTTP_OK, $response->getStatusCode()); + $shops = self::json($response)['data']['nearestCoffeeShops']; + + self::assertCount(3, $shops); + self::assertSame('Closest', $shops[0]['name']); + self::assertSame('Second', $shops[1]['name']); + self::assertSame('Third', $shops[2]['name']); + self::assertSame(['x' => 0.1, 'y' => 0.5], $shops[0]['location']); + self::assertSame(0.5099, $shops[0]['distance']); + } + + public function testDistanceIsRoundedToFourDecimalPlaces(): void + { + $kernel = self::bootKernel(); + $this->replaceProviderLocations([ + self::location('Diagonal', 1.0, 1.0), + ]); + + $response = $this->graphql( + $kernel, + '{ nearestCoffeeShops(x: 0.0, y: 0.0) { distance } }', + ); + + self::assertSame(1.4142, self::json($response)['data']['nearestCoffeeShops'][0]['distance']); + } + + public function testMissingRequiredArgumentReturnsGraphQlError(): void + { + $kernel = self::bootKernel(); + + $response = $this->graphql($kernel, '{ nearestCoffeeShops(x: 0.0) { name } }'); + + self::assertSame(Response::HTTP_OK, $response->getStatusCode()); + $data = self::json($response); + self::assertArrayHasKey('errors', $data); + self::assertArrayNotHasKey('data', $data); + } + + public function testCsvFetchExceptionIsMappedToCoffeeShopsUnavailable(): void + { + $kernel = self::bootKernel(); + $this->failProviderWith(new CsvFetchException('upstream down')); + + $response = $this->graphql($kernel, '{ nearestCoffeeShops(x: 0.0, y: 0.0) { name } }'); + + self::assertSame(Response::HTTP_OK, $response->getStatusCode()); + self::assertSame([ + 'errors' => [ + [ + 'message' => 'Coffee shop data is temporarily unavailable. Please try again later.', + 'extensions' => ['code' => 'COFFEE_SHOPS_UNAVAILABLE'], + ], + ], + ], self::json($response)); + } + + public function testUnexpectedThrowableIsMaskedAsInternalError(): void + { + $kernel = self::bootKernel(); + $this->failProviderWith(new \RuntimeException('Private internal detail.')); + + $response = $this->graphql($kernel, '{ nearestCoffeeShops(x: 0.0, y: 0.0) { name } }'); + + $data = self::json($response); + self::assertSame( + 'An unexpected error occurred. Please try again later.', + $data['errors'][0]['message'], + ); + self::assertSame('INTERNAL_ERROR', $data['errors'][0]['extensions']['code']); + self::assertStringNotContainsString('Private internal detail.', (string) $response->getContent()); + } + + public function testMalformedJsonBodyReturnsInvalidRequestError(): void + { + $kernel = self::bootKernel(); + + $response = $kernel->handle(Request::create( + self::ENDPOINT, + Request::METHOD_POST, + content: '{ not valid json', + )); + + self::assertSame(Response::HTTP_OK, $response->getStatusCode()); + self::assertSame([ + 'errors' => [ + [ + 'message' => 'Invalid JSON request.', + 'extensions' => ['code' => 'INVALID_REQUEST'], + ], + ], + ], self::json($response)); + } + + /** + * @param array $variables + */ + private function graphql(KernelInterface $kernel, string $query, array $variables = []): Response + { + return $kernel->handle(Request::create( + self::ENDPOINT, + Request::METHOD_POST, + content: (string) json_encode(['query' => $query, 'variables' => $variables]), + )); + } + + /** + * @param iterable $locations + */ + private function replaceProviderLocations(iterable $locations): void + { + $provider = $this->createMock(CoffeeShopProviderInterface::class); + $provider->method('getAll')->willReturn($locations); + self::getContainer()->set(CoffeeShopProviderInterface::class, $provider); + } + + private function failProviderWith(\Throwable $exception): void + { + $provider = $this->createMock(CoffeeShopProviderInterface::class); + $provider->method('getAll')->willThrowException($exception); + self::getContainer()->set(CoffeeShopProviderInterface::class, $provider); + } + + /** + * @return array + */ + private static function json(Response $response): array + { + $data = json_decode((string) $response->getContent(), true, flags: JSON_THROW_ON_ERROR); + self::assertIsArray($data); + + return $data; + } + + private static function location(string $name, float $x, float $y): NamedLocation + { + return new NamedLocation($name, new Coordinates($x, $y)); + } +}