# Connecting a Product API

For a merchant whose catalogue lives behind their own JSON API rather than in
Shopify.

---

## 1. The short version

```http
POST /sources/http
Content-Type: application/json

{
  "tenant_id": "org_8c32bf3e-6a18-4739-9b1c-94c0cf11125f",
  "base_url":  "https://api.example.com/products",
  "currency":  "USD",
  "api_key":   "sk_live_...",
  "auth":      {"style": "bearer"}
}
```

Response:

```json
{"kind": "http_api", "external_ref": "api.example.com",
 "status": "active", "mapping": "pending_first_sync", "needs": []}
```

Then `POST /catalog/sync` to pull the products in.

---

## 2. What you must supply

Only two fields are required.

| Field | Required | Purpose |
|---|---|---|
| `tenant_id` | **yes** | Which merchant this belongs to |
| `base_url` | **yes** | The endpoint returning the product list. **HTTPS only** |

**Everything else is optional**, because a merchant should not have to describe
the shape of their own API. The field mapping is inferred on the first sync.

### Strongly recommended

| Field | Why |
|---|---|
| `currency` | **The one thing never inferred.** See §4 |
| `api_key` + `auth` | Unless the API is public |

---

## 3. Authentication

Three styles are supported. The key is stored encrypted and never returned by
any endpoint.

**No auth** — omit `auth` and `api_key`, or:
```json
{"auth": {"style": "none"}}
```

**Bearer token** — sent as `Authorization: Bearer <key>`:
```json
{"api_key": "sk_live_...", "auth": {"style": "bearer"}}
```

**Custom header** — for `X-API-Key` and similar:
```json
{"api_key": "abc123", "auth": {"style": "header", "header": "X-API-Key"}}
```

There is no OAuth path for custom APIs. A merchant whose API needs OAuth should
use the Shopify connector or a CSV upload.

---

## 4. Currency — the one thing you must not skip

**Currency is never inferred, never defaulted, and never copied from another
source.**

A source connected without one still ingests. Its products are simply flagged
incomplete and excluded from recommendations — which from the outside looks
exactly like an empty catalogue.

So the connect response tells you:

```json
{"needs": ["currency"]}
```

`GET /sources` reports the same thing for every connected source, so a merchant
UI can show "this source needs a currency" rather than leaving them to wonder why
nothing is recommended.

This rule exists because a currency fallback was tried once and mislabelled 194
USD products as INR — roughly an 83x pricing error, invisible in the output.

---

## 5. Optional overrides

Supply these only when inference fails.

| Field | Default | When to set it |
|---|---|---|
| `records_path` | `$.products` | The array of products sits elsewhere, e.g. `$.data.items` |
| `pagination` | offset style, see below | The API pages differently |
| `fields` | inferred on first sync | Inference picked the wrong field |
| `url_template` | inferred | Product URLs need a specific shape |

### `records_path`

Where the product array lives in the response body:

```
{"products": [...]}          ->  $.products     (default)
{"data": {"items": [...]}}   ->  $.data.items
[...]                        ->  $
```

### `pagination`

Only offset-style paging is supported:

```json
{"pagination": {"style": "offset", "limit_param": "limit",
                "offset_param": "skip", "page_size": 30,
                "total_path": "$.total"}}
```

Cursor and page-number paging are **not** supported.

### `fields`

The mapping from your JSON to the catalogue schema, using `$.path` syntax:

```json
{"fields": {
   "external_id":  "$.id",
   "title":        "$.title",
   "description":  "$.description",
   "price":        "$.price",
   "brand":        "$.brand",
   "raw_category": "$.category",
   "image_url":    "$.thumbnail",
   "availability": "$.availabilityStatus",
   "rating":       "$.rating",
   "tags":         "$.tags"
 }}
```

Leave it out and the pipeline infers it from a sample of real records on first
sync. Supply it only to correct a specific field.

### `url_template`

Builds the shopper-facing URL from each record:

```json
{"url_template": "https://shop.example.com/products/{external_id}"}
```

Any mapped field can be interpolated. **Without a resolvable product URL a
product is flagged incomplete and never recommended**, so this matters whenever
the API does not return one directly.

---

## 6. A complete example

Against a public test API:

```bash
curl -X POST http://localhost:8001/sources/http \
  -H 'Content-Type: application/json' \
  -d '{
    "tenant_id": "org_8c32bf3e-6a18-4739-9b1c-94c0cf11125f",
    "base_url": "https://dummyjson.com/products",
    "currency": "USD",
    "url_template": "https://dummyjson.com/products/{external_id}"
  }'
```

Then:

```bash
curl -X POST http://localhost:8001/catalog/sync \
  -F "tenant_id=org_8c32bf3e-6a18-4739-9b1c-94c0cf11125f"
```

---

## 7. What the API must return

A JSON body containing an array of product objects. Each product needs at
minimum something that can serve as an **id**, a **title** and a **price**;
everything else improves the recommendations rather than being required.

```json
{"products": [
   {"id": 1, "title": "Essence Mascara", "price": 9.99,
    "description": "...", "brand": "Essence", "category": "beauty",
    "thumbnail": "https://...", "rating": 4.9, "stock": 5}
 ],
 "total": 194}
```

**Fields worth returning if you have them**, because each one improves pairing:

| Field | What it improves |
|---|---|
| `description` | Attribute extraction and similarity — the single most valuable field |
| `category` | Category blocking; a product with none pairs badly |
| `stock` / availability | Out-of-stock products are excluded from serving |
| `brand` | Similarity |
| `rating` | Upsell ranking |
| image | The recommendation card |

---

## 8. After connecting

```
POST /sources/http        connect
POST /catalog/sync        pull products in
POST /catalog/enrich      extract attributes (costs model calls)
POST /catalog/pair        build the recommendation graph
```

None of this is automatic and none of it is scheduled. A catalogue is stale until
`/catalog/sync` is called again.

`GET /sources` shows every connected source with its status, last sync time, and
anything still needed.

`DELETE /sources/http_api/{host}` disconnects. Products already ingested stay
until the next scoped sync removes them.

---

## 9. Safety and limits

**HTTPS only.** A plain `http://` base URL is rejected outright.

**SSRF protection.** The hostname is resolved and the resulting IP checked before
any request, so a URL pointing at a private or loopback address is refused. It
cannot be defeated by a hostname that resolves to an internal address.

**One source per host.** `external_ref` is the hostname, so connecting
`https://api.example.com/products` twice updates the same source rather than
creating a second. Two different hosts are two independent sources.

**Scoped deletes.** Products are tagged with this source and only ever removed by
a sync of this source — a Shopify sync, a CSV upload or a website crawl cannot
touch them.

**Failures are distinguished.** A 401 or 403 marks the source `error`, because the
credential itself has stopped working. A timeout or a 429 does not — it is retried
on the next sync rather than forcing the merchant to reconnect.
