feat: add API key authentication and update tests
Adds `VerifyApiKey` middleware protecting all API routes with `X-Api-Key` header validation. Wraps `/api/stations`, `/api/stats/searches`, and `/api/prediction` in throttled middleware group (60 req/min). Updates StationSearchTest to use `RefreshDatabase`, adds `meta` assertion checks, and validates `fuel_type` in HTTP request assertions. Removes auth routes from API docs and replaces with API key authentication instructions. Adds `api_secret_key` config option.
This commit is contained in:
@@ -118,6 +118,8 @@ return [
|
|||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
'api_secret_key' => env('API_SECRET_KEY'),
|
||||||
|
|
||||||
'maintenance' => [
|
'maintenance' => [
|
||||||
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
|
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
|
||||||
'store' => env('APP_MAINTENANCE_STORE', 'database'),
|
'store' => env('APP_MAINTENANCE_STORE', 'database'),
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
# FuelAlert API Reference
|
# FuelAlert API Reference
|
||||||
|
|
||||||
Base URL: `https://fuel-price.test/api`
|
Base URL: `https://fuel-price.test/api`
|
||||||
All endpoints return JSON. No auth required on public endpoints (same-origin only for now — token auth planned).
|
All endpoints return JSON. All endpoints require an `X-Api-Key` header.
|
||||||
|
|
||||||
|
**Authentication:**
|
||||||
|
|
||||||
|
```
|
||||||
|
X-Api-Key: your-secret-key
|
||||||
|
```
|
||||||
|
|
||||||
|
All requests without a valid key return `403 Forbidden`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -24,9 +32,18 @@ Returns nearby petrol stations with live prices for a given fuel type.
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `fuel_type` | string | — | **Required.** See fuel type aliases below |
|
| `fuel_type` | string | — | **Required.** See fuel type aliases below |
|
||||||
| `radius` | float | `10.0` | Search radius in km (0.1–50) |
|
| `radius` | float | `10.0` | Search radius in km (0.1–50) |
|
||||||
| `sort` | string | `"price"` | `"price"` or `"distance"` |
|
| `sort` | string | `"price"` | `"price"`, `"distance"`, `"updated"`, or `"brand"` |
|
||||||
| `pricing_mode` | string | — | `"pump"` (reserved, no effect yet) |
|
| `pricing_mode` | string | — | `"pump"` (reserved, no effect yet) |
|
||||||
|
|
||||||
|
**Sort values:**
|
||||||
|
|
||||||
|
| Value | Sorts by |
|
||||||
|
|---|---|
|
||||||
|
| `price` | Price ascending (cheapest first) — **default** |
|
||||||
|
| `distance` | Distance ascending (closest first) |
|
||||||
|
| `updated` | Price freshness descending (most recently updated first) |
|
||||||
|
| `brand` | Brand name A–Z |
|
||||||
|
|
||||||
**Fuel type aliases** (`fuel_type` accepts any of these):
|
**Fuel type aliases** (`fuel_type` accepts any of these):
|
||||||
|
|
||||||
| Alias | Maps to |
|
| Alias | Maps to |
|
||||||
@@ -42,6 +59,8 @@ Returns nearby petrol stations with live prices for a given fuel type.
|
|||||||
```
|
```
|
||||||
GET /api/stations?postcode=SW1A1AA&fuel_type=petrol&radius=5&sort=price
|
GET /api/stations?postcode=SW1A1AA&fuel_type=petrol&radius=5&sort=price
|
||||||
GET /api/stations?lat=51.5074&lng=-0.1278&fuel_type=diesel&radius=10&sort=distance
|
GET /api/stations?lat=51.5074&lng=-0.1278&fuel_type=diesel&radius=10&sort=distance
|
||||||
|
GET /api/stations?postcode=M11AE&fuel_type=petrol&sort=updated
|
||||||
|
GET /api/stations?postcode=M11AE&fuel_type=petrol&sort=brand
|
||||||
```
|
```
|
||||||
|
|
||||||
**Response:**
|
**Response:**
|
||||||
@@ -244,96 +263,6 @@ GET /api/prediction?fuel_type=petrol&lat=51.5074&lng=-0.1278
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Auth
|
|
||||||
|
|
||||||
> **Note:** Auth routes are implemented (`AuthController` exists) but not yet wired into `routes/api.php`. Add when token-based access is needed.
|
|
||||||
|
|
||||||
### POST `/api/auth/register`
|
|
||||||
|
|
||||||
Create a new account and receive a Sanctum token.
|
|
||||||
|
|
||||||
**Body (JSON):**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "Jane Smith",
|
|
||||||
"email": "jane@example.com",
|
|
||||||
"password": "secret123",
|
|
||||||
"password_confirmation": "secret123"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response `201`:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"token": "1|abc123...",
|
|
||||||
"user": {
|
|
||||||
"id": 42,
|
|
||||||
"name": "Jane Smith",
|
|
||||||
"email": "jane@example.com",
|
|
||||||
"created_at": "2026-04-05T10:00:00.000000Z"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### POST `/api/auth/login`
|
|
||||||
|
|
||||||
**Body (JSON):**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"email": "jane@example.com",
|
|
||||||
"password": "secret123"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response `200`:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"token": "1|abc123...",
|
|
||||||
"user": { ... }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response `401` (wrong credentials):**
|
|
||||||
```json
|
|
||||||
{ "message": "Invalid credentials." }
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### POST `/api/auth/logout`
|
|
||||||
|
|
||||||
Revokes the current token.
|
|
||||||
|
|
||||||
**Headers:** `Authorization: Bearer {token}`
|
|
||||||
|
|
||||||
**Response `200`:**
|
|
||||||
```json
|
|
||||||
{ "message": "Logged out." }
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### GET `/api/auth/me`
|
|
||||||
|
|
||||||
Returns the authenticated user.
|
|
||||||
|
|
||||||
**Headers:** `Authorization: Bearer {token}`
|
|
||||||
|
|
||||||
**Response `200`:** Full `User` model JSON.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Using the Token (when auth is wired up)
|
|
||||||
|
|
||||||
```
|
|
||||||
Authorization: Bearer 1|abc123...
|
|
||||||
```
|
|
||||||
|
|
||||||
All protected routes must include this header.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Error Shapes
|
## Error Shapes
|
||||||
|
|
||||||
@@ -347,7 +276,7 @@ All protected routes must include this header.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Unauthenticated (401):**
|
**Forbidden (403)** — missing or invalid `X-Api-Key`:
|
||||||
```json
|
```json
|
||||||
{ "message": "Unauthenticated." }
|
{ "message": "Forbidden." }
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -3,8 +3,11 @@
|
|||||||
use App\Http\Controllers\Api\PredictionController;
|
use App\Http\Controllers\Api\PredictionController;
|
||||||
use App\Http\Controllers\Api\StationController;
|
use App\Http\Controllers\Api\StationController;
|
||||||
use App\Http\Controllers\Api\StatsController;
|
use App\Http\Controllers\Api\StatsController;
|
||||||
|
use App\Http\Middleware\VerifyApiKey;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::get('/stations', [StationController::class, 'index']);
|
Route::middleware(['throttle:60,1', VerifyApiKey::class])->group(function (): void {
|
||||||
Route::get('/stats/searches', [StatsController::class, 'searches']);
|
Route::get('/stations', [StationController::class, 'index']);
|
||||||
Route::get('/prediction', [PredictionController::class, 'index']);
|
Route::get('/stats/searches', [StatsController::class, 'searches']);
|
||||||
|
Route::get('/prediction', [PredictionController::class, 'index']);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user