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
117 changes: 93 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
# Smartphone Catalog API

A Laravel 12 backend application that serves a smartphone product catalog with real-time currency conversion (USD to UAH) using the National Bank of Ukraine (NBU) exchange rates.
A Laravel 12 backend application that serves a smartphone product catalog with real-time currency conversion (USD/EUR/UAH) using exchange rates from the National Bank of Ukraine (NBU) with automatic fallback to Open Exchange Rates API.

## Features

- Browse smartphone catalog sourced from [DummyJSON](https://dummyjson.com/)
- View prices in USD (original) or UAH (converted via NBU rates)
- Retrieve the current USD/UAH exchange rate from the NBU API
- Modular domain-driven architecture for the Currency module
- View prices in **USD** (original), **UAH** or **EUR** (converted via exchange rates)
- Retrieve current exchange rates (USD/UAH, EUR/UAH)
- Modular domain-driven architecture with SOLID principles and design patterns

## Tech Stack

Expand All @@ -22,9 +22,9 @@ A Laravel 12 backend application that serves a smartphone product catalog with r

Returns a list of smartphones with prices in the requested currency.

| Parameter | Type | Allowed Values | Description |
|------------|--------|----------------|--------------------------------------|
| `currency` | string | `usd`, `uah` | Currency for product prices |
| Parameter | Type | Allowed Values | Description |
|------------|--------|-------------------|--------------------------------------|
| `currency` | string | `usd`, `uah`, `eur` | Currency for product prices |

**Response (200):**

Expand All @@ -42,11 +42,19 @@ Returns a list of smartphones with prices in the requested currency.
}
```

Unsupported currencies return **404**.
**Notes:**
- USD returns original prices
- UAH and EUR return converted prices based on current exchange rates
- Unsupported currencies return **404**
- Each request dispatches an asynchronous job to track currency usage

### `GET /api/exchangeRate/{currency}`

### `GET /api/exchangeRate/USD`
Returns the current exchange rate for the specified currency to UAH.

Returns the current USD to UAH exchange rate from the NBU.
| Parameter | Type | Allowed Values | Description |
|------------|--------|----------------|--------------------------------------|
| `currency` | string | `USD`, `EUR` | Currency code (case-insensitive) |

**Response (200):**

Expand All @@ -60,35 +68,58 @@ Returns the current USD to UAH exchange rate from the NBU.
}
```

**Notes:**
- Exchange rates are cached for 10 minutes
- Cache is automatically invalidated on a new calendar day
- Falls back to secondary provider if primary fails

## Project Structure

```
app/
├── Http/
│ ├── Controllers/
│ │ └── CatalogController.php # Smartphone catalog endpoint
│ │ └── CatalogController.php # Smartphone catalog endpoint
│ └── Requests/
│ └── CatalogRequest.php # Validates currency parameter
│ └── CatalogRequest.php # Validates currency parameter
├── Jobs/
│ └── TrackCatalogCurrencyUsage.php # Async job for tracking
├── Models/
│ ├── CatalogCurrencyUsage.php # Currency usage model
│ └── User.php
└── Modules/
└── Currency/
├── Application/
│ ├── Facades/
│ │ └── CurrencyExchangeFacade.php # Entry point for currency operations
│ │ └── CurrencyExchangeFacade.php # Simplified interface
│ └── Http/
│ ├── Controllers/
│ │ └── ExchangeRateController.php
│ │ └── ExchangeRateController.php # Exchange rate endpoint
│ └── Requests/
│ └── ExchangeRateRequest.php
│ └── ExchangeRateRequest.php # Validates currency code
├── Domain/
│ ├── ConvertedPrice.php # DTO for converted price data
│ ├── CurrencyExchangeService.php # Core exchange logic
│ └── CurrencyRate.php # DTO for rate data
│ ├── Contracts/
│ │ └── CurrencyRateProviderInterface.php # Provider abstraction
│ ├── ConvertedPrice.php # Value object
│ ├── CurrencyExchangeService.php # Domain service
│ └── CurrencyRate.php # Value object
├── Infrastructure/
│ └── NbuApiCurrencyRepository.php # NBU API integration
│ ├── ChainProviders/
│ │ └── ChainOfResponsibilityProvider.php # Fallback mechanism
│ ├── Decorators/
│ │ └── CachedCurrencyRateProvider.php # Caching decorator
│ └── Providers/
│ ├── NbuCurrencyRateProvider.php # NBU API integration
│ └── OpenExchangeRateProvider.php # Open ER API integration
└── Providers/
└── CurrencyServiceProvider.php # DI bindings
└── CurrencyServiceProvider.php # DI container bindings

config/
└── currency.php # Currency module configuration

database/
└── migrations/
└── 2026_03_12_183729_create_catalog_currency_usage_table.php
```

## Getting Started
Expand Down Expand Up @@ -129,6 +160,27 @@ composer dev

This starts the Laravel dev server, queue worker, log watcher (Pail), and Vite concurrently.

## Configuration

The currency module can be configured via `config/currency.php` or environment variables:

```bash
# Primary exchange rate provider (nbu or open_exchange)
CURRENCY_PROVIDER=nbu

# Fallback provider (used if primary fails)
CURRENCY_FALLBACK_PROVIDER=open_exchange

# Enable/disable caching
CURRENCY_CACHE_ENABLED=true

# Cache TTL in minutes
CURRENCY_CACHE_TTL=10

# Cache driver (redis recommended for production)
CACHE_STORE=redis
```

## Running Tests

```bash
Expand All @@ -141,17 +193,34 @@ Or directly with PHPUnit:
php artisan test
```

Tests use `Http::fake()` to mock external API calls (DummyJSON and NBU), so no network access is needed.
Or using Docker:

```bash
./vendor/bin/sail artisan test
```

Tests use `Http::fake()` to mock external API calls (DummyJSON, NBU, Open Exchange Rates), so no network access is needed.

### Test Coverage

- **Catalog endpoint** — USD/UAH responses, price conversion, currency validation, HTTP method restrictions
- **Exchange rate endpoint** — JSON structure, rate values, HTTP method restrictions
**Unit Tests (29):**
- `NbuCurrencyRateProvider` — API integration, error handling
- `OpenExchangeRateProvider` — API integration, rate conversion
- `ChainOfResponsibilityProvider` — Fallback mechanism
- `CachedCurrencyRateProvider` — Caching with TTL and daily invalidation
- `CurrencyExchangeService` — Domain logic for rate retrieval

**Feature Tests (26):**
- **Catalog endpoint** — USD/UAH/EUR responses, price conversion, currency validation, HTTP method restrictions
- **Exchange rate endpoint** — USD/EUR rates, JSON structure, rate values, HTTP method restrictions
- **Fallback mechanism** — Provider failover, graceful degradation
- **Currency tracking** — Job dispatching, database persistence
- Both endpoints verified to not require authentication

## External APIs

| API | Purpose | URL |
|-----|---------|-----|
| DummyJSON | Smartphone product data | `https://dummyjson.com/products/category/smartphones` |
| NBU | USD/UAH exchange rate | `https://bank.gov.ua/NBUStatService/v1/statdirectory/exchange` |
| NBU (Primary) | Exchange rates (USD, EUR → UAH) | `https://bank.gov.ua/NBUStatService/v1/statdirectory/exchange` |
| Open Exchange Rates (Fallback) | Exchange rates when NBU fails | `https://open.er-api.com/v6/latest/UAH` |
32 changes: 10 additions & 22 deletions app/Http/Controllers/CatalogController.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,39 +3,27 @@
namespace App\Http\Controllers;

use App\Http\Requests\CatalogRequest;
use App\Jobs\TrackCatalogCurrencyUsage;
use App\Services\Catalog\CatalogServiceInterface;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Http;
use Modules\Currency\Application\Facades\CurrencyExchangeFacade;

class CatalogController extends Controller
{
public function __construct(
private readonly CurrencyExchangeFacade $currencyExchange,
private readonly CatalogServiceInterface $catalogService,
) {}

public function __invoke(CatalogRequest $request): JsonResponse
{
$currency = $request->route('currency');
$response = Http::get('https://dummyjson.com/products/category/smartphones', [
'limit' => 5,
]);
$currency = strtoupper($request->route('currency'));

$products = collect($response->json('products'))->map(function (array $product) use ($currency) {
$price = $product['price'];
TrackCatalogCurrencyUsage::dispatch(
$currency,
$request->ip(),
$request->userAgent()
);

if (strtoupper($currency) === 'UAH') {
$converted = $this->currencyExchange->convertFromUsdToUah((int) $price);
$price = $converted->convertedPrice;
}

return [
'id' => $product['id'],
'title' => $product['title'],
'price' => $price,
'rating' => $product['rating'],
'thumbnail' => $product['thumbnail'],
];
});
$products = $this->catalogService->getProducts($currency);

return response()->json(['data' => $products]);
}
Expand Down
9 changes: 7 additions & 2 deletions app/Http/Requests/CatalogRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,13 @@ class CatalogRequest extends FormRequest
{
public function rules(): array
{
$supportedCurrencies = array_map(
'strtolower',
config('currency.supported_currencies')
);

return [
'currency' => ['required', 'string', Rule::in(['uah', 'usd'])],
'currency' => ['required', 'string', Rule::in($supportedCurrencies)],
];
}

Expand All @@ -25,6 +30,6 @@ public function validationData(): array

protected function failedValidation(Validator $validator): void
{
throw new NotFoundHttpException();
throw new NotFoundHttpException;
}
}
47 changes: 47 additions & 0 deletions app/Jobs/TrackCatalogCurrencyUsage.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

namespace App\Jobs;

use App\Models\CatalogCurrencyUsage;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;

class TrackCatalogCurrencyUsage implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;

public function __construct(
private readonly string $currency,
private readonly ?string $ipAddress = null,
private readonly ?string $userAgent = null,
) {}

public function handle(): void
{
try {
CatalogCurrencyUsage::create([
'currency' => strtoupper($this->currency),
'used_at' => now(),
'ip_address' => $this->ipAddress,
'user_agent' => $this->userAgent,
]);

Log::info('Catalog currency usage tracked', [
'currency' => $this->currency,
'ip' => $this->ipAddress,
]);
} catch (\Exception $e) {
Log::error('Failed to track catalog currency usage', [
'currency' => $this->currency,
'error' => $e->getMessage(),
]);
}
}
}
21 changes: 21 additions & 0 deletions app/Models/CatalogCurrencyUsage.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class CatalogCurrencyUsage extends Model
{
protected $table = 'catalog_currency_usage';

protected $fillable = [
'currency',
'used_at',
'ip_address',
'user_agent',
];

protected $casts = [
'used_at' => 'datetime',
];
}
45 changes: 25 additions & 20 deletions app/Modules/Currency/Application/Facades/CurrencyExchangeFacade.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,35 +3,40 @@
namespace Modules\Currency\Application\Facades;

use Modules\Currency\Domain\ConvertedPrice;
use Modules\Currency\Domain\CurrencyExchangeService;
use Modules\Currency\Domain\Contracts\CurrencyConverterInterface;
use Modules\Currency\Domain\Contracts\CurrencyExchangeServiceInterface;
use Modules\Currency\Domain\CurrencyRate;

class CurrencyExchangeFacade
class CurrencyExchangeFacade implements CurrencyExchangeFacadeInterface
{
private const string USD_CODE = 'USD';
private const string UAH_CODE = 'UAH';

public function __construct(
private readonly CurrencyExchangeService $service,
private readonly CurrencyExchangeServiceInterface $exchangeService,
private readonly CurrencyConverterInterface $currencyConverter,
) {}

public function getUsdRate(): CurrencyRate
/**
* Get exchange rate for specific currency code.
*
* @param string $currencyCode Currency code (USD, EUR, etc.)
* @return CurrencyRate Currency rate information
* @throws \RuntimeException If currency rate not found
*/
public function getRateByCurrencyCode(string $currencyCode): CurrencyRate
{
return $this->service->findUsdRate();
return $this->exchangeService->findRateByCurrencyCode($currencyCode);
}

public function convertFromUsdToUah(int $price): ConvertedPrice
/**
* Convert price from one currency to another.
*
* @param int $priceInCents Original price in cents
* @param string $fromCurrency Source currency code
* @param string $toCurrency Target currency code
* @return ConvertedPrice Conversion result with details
* @throws \RuntimeException If currency rate not found
*/
public function convert(int $priceInCents, string $fromCurrency, string $toCurrency): ConvertedPrice
{
$usdRate = $this->service->findUsdRate();

$convertedPrice = $price * $usdRate->rate;

return new ConvertedPrice(
originalPrice: $price,
convertedPrice: round($convertedPrice, 2),
fromCurrency: self::USD_CODE,
toCurrency: self::UAH_CODE,
rate: $usdRate->rate,
);
return $this->currencyConverter->convert($priceInCents, $fromCurrency, $toCurrency);
}
}
Loading