# Electricarly API

A REST interface for Electricarly data: brands, drives, and full car specs.

**Base URL:** `https://api.electricarly.info`
**Format:** JSON (`Content-Type: application/json`)

---

## Authentication

Most endpoints require an **API key** passed via the `X-API-Key` header:

```bash
curl -H "X-API-Key: your-api-key-here" https://api.electricarly.info/api/brands
```

| Endpoint | Requires API Key |
|----------|------------------|
| `GET /health` | ❌ No |
| `GET /api/brands/*` | ✅ Yes |
| `GET /api/drives/*` | ✅ Yes |
| `GET /api/specs/*` | ✅ Yes |
| `GET /api/images/*` | ❌ No (signed URLs) |
| `POST /admin/reload` | ✅ Yes |

Set the `API_KEYS` environment variable to enable authentication (comma-separated list).

## Requesting API access

If you'd like access to the authenticated API endpoints, please send a request to: **louizo79@gmail.com**. Include a short description of your intended use and any non-production hostnames or IPs that will access the API.

---

## Rate Limiting

All API requests are rate limited per **1-minute window**.

| Scope | Limit | Notes |
|---|---|---|
| `/api/brands`, `/api/drives`, `/api/specs` | Depends on your API key's tier (see below) | Limited per `X-API-Key`, not per IP |
| `/api/images` | 600 requests / minute | Limited per IP (no API key required) |
| `/admin/reload` | 5 requests / minute | Requires API key |
| Everything else | 300 requests / minute | Limited per IP |

### Per-key limits

Each API key has its own independent quota — one key's usage never affects another's.

| Tier | Requests / minute |
|---|---|
| Standard | 100 (default) |
| Custom / partner keys | Varies — see your key's agreement, or ask when requesting access |

### Rate limit headers

Every response includes standard rate limit headers so you can track your usage:

| Header | Meaning |
|---|---|
| `RateLimit-Limit` | Your limit for the current window |
| `RateLimit-Remaining` | Requests remaining in the current window |
| `RateLimit-Reset` | Seconds until the window resets |

### Exceeding the limit

If you exceed your limit, you'll receive a `429 Too Many Requests` response:

```json
{
  "error": "rate_limited",
  "message": "Too many requests, please try again later."
}
```

Wait until the window resets (see `RateLimit-Reset`) before retrying.

---

## Health

### `GET /health`

```json
{
  "ok": true,
  "dataDir": "/path/to/output",
  "loadedAt": "2024-11-01T12:34:56.789Z",
  "counts": { "brands": 47, "drives": 47, "specs": 312 }
}
```

---

## Image URL convention

Every image descriptor returned by the API has three fields:

| Field  | Meaning                                            | Example |
|--------|----------------------------------------------------|---------|
| `path` | On-disk path (POSIX)                               | `data/images/specs/afeela/899_Afeela_1_AWD/main.jpg` |
| `rel`  | Relative to `data/images/`                         | `specs/afeela/899_Afeela_1_AWD/main.jpg` |
| `url`  | API path to fetch the file                         | `/api/images/specs/afeela/899_Afeela_1_AWD/main.jpg` |

**Rule:** always use `url` in clients. `path` and `rel` are for reference / server-side work.

If `ABSOLUTE_IMAGES=1`, every `url` is rewritten to `http(s)://<host>/api/images/...` at serialization time, so cross-origin clients can use them directly.

---

## Brands

### `GET /api/brands`

| Query   | Type    | Default | Description |
|---------|---------|---------|-------------|
| `q`     | string  | —       | Substring match on name or slug |
| `sort`  | string  | `name`  | Sort field |
| `order` | string  | `asc`   | `asc` or `desc` |
| `page`  | integer | `1`     | Page number |
| `limit` | integer | `50`    | Items per page (max 200) |

```json
{
  "total": 47,
  "page": 1,
  "limit": 50,
  "items": [
    {
      "slug": "tesla",
      "brandId": "1",
      "name": "Tesla",
      "models": 17,
      "drivetrains": 76,
      "drivesCount": 76,
      "logoPath": "data/images/brands/tesla.png",
      "logoLocal": "brands/tesla.png",
      "logoUrl": "/api/images/brands/tesla.png"
    }
  ]
}
```

### `GET /api/brands/:slug`

Returns the brand with its `drives` array inlined.

### `GET /api/brands/:slug/drives`

| Query      | Type    | Default | Description |
|------------|---------|---------|-------------|
| `q`        | string  | —       | Substring on display/model name |
| `year`     | string  | —       | Substring match on the year range |
| `mainOnly` | boolean | `false` | Only main variants |
| `page`     | integer | `1`     | Page number |
| `limit`    | integer | `100`   | Items per page (max 500) |

Each drive has:

```json
{
  "driveId": "48",
  "slug": "tesla_model_x_100d_2017",
  "displayName": "Model X 100D",
  "year": "2017 - 2019",
  "isMain": true,
  "thumbPath": "data/images/specs/tesla/48_Tesla_Model_X_100D/thumb.jpg",
  "thumbLocal": "specs/tesla/48_Tesla_Model_X_100D/thumb.jpg",
  "thumbUrl": "/api/images/specs/tesla/48_Tesla_Model_X_100D/thumb.jpg",
  "url": { "slug": "tesla_model_x_100d_2017", "id": "48" }
}
```

### `GET /api/brands/:slug/specs`

Paged list of specs for a brand.

---

## Drives

### `GET /api/drives/:brandSlug/:driveId`

Drive + spec summary.

```json
{
  "drive": { "driveId": "48", "displayName": "Model X 100D", "thumbUrl": "/api/images/…" },
  "specSummary": {
    "fullModelName": "Tesla Model X 100D",
    "driveYear": "2017 - 2019",
    "spotlight": { "power-hl": "386", "range-hl": "523" },
    "thumbUrl": "/api/images/…"
  }
}
```

---

## Specs

### `GET /api/specs/:brandSlug/:driveId`

Full spec object with `spotlight`, `specs`, `sections`, `trims`, `images`, and `localImages`.

```json
{
  "brandSlug": "afeela",
  "driveId": "899",
  "slug": "afeela_1_awd_2026",
  "fullModelName": "Afeela 1 AWD",
  "images": [
    {
      "path": "data/images/specs/afeela/899_Afeela_1_AWD/afeela-1-side-1.jpg",
      "rel":  "specs/afeela/899_Afeela_1_AWD/afeela-1-side-1.jpg",
      "url":  "/api/images/specs/afeela/899_Afeela_1_AWD/afeela-1-side-1.jpg"
    }
  ],
  "localImages": [
    "specs/afeela/899_Afeela_1_AWD/afeela-1-side-1.jpg"
  ]
}
```

### `GET /api/specs/by-slug/:slug`

Keyed by the o slug.

### `GET /api/specs/search`

| Query      | Type    | Default | Description |
|------------|---------|---------|-------------|
| `q`        | string  | —       | Substring on full model name |
| `brand`    | string  | —       | Restrict to one brand slug |
| `minRange` | number  | —       | Minimum WLTP range in km |
| `minPower` | number  | —       | Minimum power in kW |
| `page`     | integer | `1`     | Page number |
| `limit`    | integer | `25`    | Items per page (max 100) |

Each hit includes `thumbUrl` (first image) for quick rendering.

---

## Images

### `GET /api/images/<rel-path>`

`<rel-path>` is relative to `data/images/`:

```
GET /api/images/specs/afeela/899_Afeela_1_AWD/afeela-1-side-1.jpg
GET /api/images/brands/tesla.png
```

Returns the raw file with its content type. Path traversal is rejected (`400`) and missing files return `404`.

---

## Errors

All errors are JSON: `{ "error": "brand_not_found" }`.

| Status | Meaning |
|--------|---------|
| 400 | Bad request (missing/traversal path) |
| 404 | Resource not found |
| 429 | Rate limit exceeded |
| 500 | Internal error |