Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
7 changes: 7 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
vendor/
var/
.git/
.env.local
tmp/
.php-cs-fixer.cache
.phpunit.cache
7 changes: 7 additions & 0 deletions .env
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
APP_ENV=dev
APP_DEBUG=1
APP_SECRET=change_me_in_env_local

CSV_FETCH_URL=https://static.reasig.ro/interview/coffee_shops_exerceise/coffee_shops.csv
CSV_FETCH_TIMEOUT_SECONDS=5
CSV_CACHE_TTL_SECONDS=300
29 changes: 29 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: CI

on:
push:
pull_request:

jobs:
ci:
name: ci
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v5

- name: Start
run: make up

- name: Install
run: make install

- name: Test
run: make test

- name: PHPStan
run: make stan

- name: CS check
run: make cs-check
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/vendor/
/var/
/.env.local
/tmp/
/.php-cs-fixer.cache
/.phpunit.cache
/config/reference.php
12 changes: 12 additions & 0 deletions .php-cs-fixer.dist.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

$finder = PhpCsFixer\Finder::create()
->in([__DIR__ . '/src', __DIR__ . '/tests']);

return (new PhpCsFixer\Config())
->setRules([
'@Symfony' => true,
])
->setFinder($finder);
15 changes: 15 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
FROM php:8.3-cli-bookworm

RUN apt-get update \
&& apt-get install -y --no-install-recommends \
git \
unzip \
libcurl4-openssl-dev \
&& docker-php-ext-install curl \
&& rm -rf /var/lib/apt/lists/*

COPY --from=composer:2 /usr/bin/composer /usr/bin/composer

WORKDIR /app

CMD ["sleep", "infinity"]
45 changes: 45 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
DOCKER_COMPOSE ?= docker compose
DOCKER_USER ?= $(shell id -u):$(shell id -g)
export DOCKER_USER

PHP = $(DOCKER_COMPOSE) exec app php
COMPOSER = $(DOCKER_COMPOSE) exec app composer

.PHONY: up down install shell test stan cs-check cs-fix serve logs logs-error logs-search

up:
$(DOCKER_COMPOSE) up -d --build

down:
$(DOCKER_COMPOSE) down

install:
$(COMPOSER) install

shell:
$(DOCKER_COMPOSE) exec app sh

test:
$(PHP) vendor/bin/phpunit

stan:
$(PHP) vendor/bin/phpstan analyse --memory-limit=512M

cs-check:
$(PHP) vendor/bin/php-cs-fixer check --diff

cs-fix:
$(PHP) vendor/bin/php-cs-fixer fix

serve:
$(PHP) -S 0.0.0.0:8080 -t public/

logs:
$(DOCKER_COMPOSE) exec app sh -c 'tail -f var/log/dev.log'

logs-error:
$(DOCKER_COMPOSE) exec app sh -c 'grep -i "error\|critical\|exception" var/log/dev.log || true'

logs-search:
@test -n "$(q)" || (echo 'Usage: make logs-search q=term' && exit 1)
$(DOCKER_COMPOSE) exec app sh -c 'grep -i "$(q)" var/log/dev.log || true'
125 changes: 89 additions & 36 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,62 +1,115 @@
# Coffee Shops Finder API

REST API that returns the three closest coffee shops for a given user location.
A small REST API that returns the three nearest coffee shops for a given user location. Built with PHP 8.3, Symfony 7, and Docker.

## Problem

The API receives user coordinates and returns the three nearest coffee shops, ordered from closest to farthest, including the distance from the user.

Coffee shops are loaded from a remote CSV file:

```text
Name,X,Y
```

Data quality may vary — malformed rows are skipped gracefully.
Distances are rounded to four decimal places.
All coordinates lie on a plane.

## Example

Input coordinates: `X=47.6`, `Y=-122.4`

Expected result:
## Setup

```text
Starbucks Seattle2
Starbucks Seattle
Starbucks SF
```bash
git clone git@github.com:branzeaaurel/coffee-shops-finder.git
cd coffee-shops-finder
make up
make install
make serve
```

## Endpoint
In another terminal:

```http
GET /api/coffee-shops/nearest?x=47.6&y=-122.4
```bash
curl "http://localhost:8080/api/coffee-shops/nearest?x=47.6&y=-122.4"
```

Response:
Expected response:

```json
[
{
"name": "Starbucks Seattle2",
"location": {
"x": 47.6,
"y": -122.4
"x": 47.5869,
"y": -122.3368
},
"distance": 0.0645
},
{
"name": "Starbucks Seattle",
"location": {
"x": 47.5809,
"y": -122.316
},
"distance": 0.0861
},
{
"name": "Starbucks SF",
"location": {
"x": 37.5209,
"y": -122.334
},
"distance": 0.0
"distance": 10.0793
}
]
```

## Stack
## Manual testing

PHP 8.3 · Symfony 7 · Docker
`docs/requests.http` contains example requests for the endpoint: the happy path, missing parameters, non-numeric input, array-style input, and non-finite values. It works well with JetBrains HTTP Client, VS Code REST Client, or as a copy-paste reference for `curl`.

## Setup
To browse the API spec interactively, run `make serve` and open
http://localhost:8080/api-docs.html in a browser.

Useful log commands:

```bash
make logs # tail dev.log
make logs-error # grep error/critical/exception lines
make logs-search q=fetch # search dev.log for a term
```

## Configuration

Runtime configuration lives in `.env`:

- `CSV_FETCH_URL` — upstream CSV URL.
- `CSV_FETCH_TIMEOUT_SECONDS` — HTTP timeout per attempt, defaults to 5.
- `CSV_CACHE_TTL_SECONDS` — how long the local cache file is considered fresh, defaults to 300.

Use `.env.local` for local overrides.

## Architecture

The code is split into four small layers.

The **domain** contains the generic location logic: coordinates, named locations, distances, the Euclidean distance calculator, and nearest-location selection. It has no Symfony, HTTP, or filesystem concerns. The finder keeps only the current top results instead of sorting the whole dataset.

The **infrastructure** layer owns the CSV integration. It fetches the remote file, caches the raw CSV locally, and parses it row by row with `fgetcsv`. The parser yields `NamedLocation` objects, so it does not need to load the full file into memory. Malformed rows are skipped and logged with a reason code.

The **application** layer contains the use case. `FindNearestCoffeeShopsHandler` gets the coffee shops from the provider, delegates nearest-location selection to the domain service, and maps the result to `NearestCoffeeShop`. Distances stay raw here.

The **HTTP** layer contains the controller and exception subscriber. The controller validates query parameters, creates `Coordinates`, calls the handler, rounds distances to four decimals, and returns a manual `JsonResponse`. The subscriber turns known exceptions into stable JSON errors and hides internal details for unexpected failures.

## Key decisions and trade-offs

The endpoint is small, so I kept request validation and response mapping explicit in the controller. Symfony Form and Serializer would be fine for a larger API, but here they would add more setup than value.

The upstream CSV currently has no header, even though the challenge describes `Name,X,Y`. The parser supports both formats: it skips a `Name,X,Y` header when present, otherwise it treats the first row as data.

Distance is calculated with the Euclidean formula because the challenge treats coordinates as points on a plane. For real geographic distances, I would swap the calculator for a Haversine implementation behind the same `DistanceCalculatorInterface`.

The provider uses a stale cache fallback. If the upstream CSV is temporarily unavailable but an older local file exists, the API can still return results. If there is no cache and the upstream fails, the API returns `503`.

Cache writes are atomic: the new CSV is written to a temporary file and then renamed over the old cache file. That avoids partially written cache files.

## Running checks

```bash
docker compose up -d
make test # PHPUnit
make stan # PHPStan level 8
make cs-check # PHP CS Fixer (Symfony preset)
```

More setup and usage details will follow as the implementation progresses.
## What I would add with more time

- Exponential backoff with jitter between HTTP retries instead of immediate attempts.
- A circuit breaker on the upstream fetch so transient outages don't slow down every request.
- A shared cache (Redis or similar) so multiple instances aren't each maintaining their own local CSV file.
- A Haversine distance implementation, switched in by changing one DI alias.
- A GitHub Actions workflow running `make install && make test && make stan && make cs-check` on every push.
15 changes: 15 additions & 0 deletions bin/console
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
#!/usr/bin/env php
<?php

declare(strict_types=1);

use App\Kernel;
use Symfony\Bundle\FrameworkBundle\Console\Application;

require_once dirname(__DIR__) . '/vendor/autoload_runtime.php';

return static function (array $context): Application {
$kernel = new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);

return new Application($kernel);
};
48 changes: 48 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
{
"name": "coffee-shops-finder/api",
"description": "REST API returning the three nearest coffee shops for a given location",
"type": "project",
"license": "proprietary",
"minimum-stability": "stable",
"prefer-stable": true,
"require": {
"php": ">=8.3",
"ext-curl": "*",
"monolog/monolog": "^3.7",
"symfony/dotenv": "^7.2",
"symfony/framework-bundle": "^7.2",
"symfony/http-client": "^7.2",
"symfony/monolog-bundle": "^3.10",
"symfony/runtime": "^7.2",
"symfony/yaml": "^7.2"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.0",
"phpstan/phpstan": "^2.0",
"phpunit/phpunit": "^11.0"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"App\\Tests\\": "tests/"
}
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true,
"allow-plugins": {
"symfony/runtime": true
}
},
"extra": {
"symfony": {
"allow-contrib": false,
"require": "7.*"
}
}
}
Loading