diff --git a/samples/ai-app-claude-code/CLAUDE.md b/samples/ai-app-claude-code/CLAUDE.md new file mode 100644 index 000000000..8cf61aaf1 --- /dev/null +++ b/samples/ai-app-claude-code/CLAUDE.md @@ -0,0 +1,48 @@ +# Reading List Agent + +You are a reading list assistant with access to a governed REST API running behind a WSO2 API Gateway. + +## What you can do + +You can manage a personal reading list. The available operations are: + +| Action | Function | +|--------|----------| +| List all books | `api_client.books_list()` | +| Add a book | `api_client.books_add(title, author, status="to_read")` | +| Get a single book | `api_client.books_get(book_id)` | +| Update a book's status | `api_client.books_update(book_id, status)` | +| Remove a book | `api_client.books_delete(book_id)` | + +**Status values:** `"to_read"` · `"reading"` · `"read"` + +## Rules + +1. **Always use `api_client.py`** — never call the gateway URL directly. +2. Import the module at the top of every Python snippet: `import api_client` +3. When updating a book, only the `status` field can be changed. You cannot update the title or author. +4. IDs are UUIDs returned by the API — never invent them. +5. If an operation fails, show the error and ask the user what to do next. + +## Example session + +```python +import api_client + +# List everything +books = api_client.books_list() +print(books) + +# Add a new book (status defaults to "to_read") +new_book = api_client.books_add("The Pragmatic Programmer", "David Thomas") +print(new_book) # {"id": "...", "title": "...", "author": "...", "status": "to_read", ...} + +# Start reading it +api_client.books_update(new_book["id"], status="reading") + +# Mark it as finished +api_client.books_update(new_book["id"], status="read") + +# Clean up +api_client.books_delete(new_book["id"]) +``` diff --git a/samples/ai-app-claude-code/README.md b/samples/ai-app-claude-code/README.md new file mode 100644 index 000000000..5f85c081d --- /dev/null +++ b/samples/ai-app-claude-code/README.md @@ -0,0 +1,199 @@ +# Build an AI App with Claude Code that Calls Governed Backend APIs + + +## Overview + +This sample demonstrates how to build an AI app with Claude Code that calls a Reading List API governed by a self-hosted WSO2 API Gateway. The gateway enforces API key authentication on every request the agent makes — no cloud account required. + +``` +┌─────────────────┐ X-API-Key ┌────────────────────────┐ HTTPS ┌─────────────────────────────────────────┐ +│ Claude Code │ ─────────────► │ WSO2 API Gateway │ ────────► │ Reading List API (live backend) │ +│ (AI agent) │ │ (Docker, port 8080) │ │ apis.bijira.dev/.../reading-list-api │ +│ │ │ │ │ │ +│ uses │ │ • API key auth │ │ OpenAPI spec: │ +│ api_client.py │ │ • access logs │ │ github.com/wso2/bijira-samples │ +└─────────────────┘ └────────────────────────┘ └─────────────────────────────────────────┘ +``` + +## What You Will Learn + +- How to deploy a REST API proxy on a self-hosted WSO2 gateway +- How to enforce API key authentication at the gateway layer (zero backend changes) +- How to configure Claude Code to call APIs through a governed gateway using `CLAUDE.md` and `api_client.py` +- How every AI-generated API call is authenticated and logged under a named identity + +## Prerequisites + +- Docker and Docker Compose +- `curl` and `unzip` on your host +- Python 3.8+ (stdlib only — no extra packages needed) +- Claude Code CLI — [install guide](https://docs.anthropic.com/en/docs/claude-code) + +## Files + +``` +reading-list-api.yaml REST API proxy definition (deployed to the gateway) +api_client.py Helper: attaches X-API-Key to all requests +CLAUDE.md Claude Code briefing: available operations and rules +setup.sh Automated setup (download → start → deploy API → generate key → write settings) +test.sh End-to-end test suite (run after setup, before Claude Code) +teardown.sh Automated teardown +``` + +## Setup + +```bash +./setup.sh +``` + +The script performs these steps in order: + +1. Downloads `wso2apip-api-gateway-1.1.0.zip` +2. Starts the Docker Compose gateway stack +3. Waits for the gateway to become healthy +4. Deploys the Reading List API proxy via the management API +5. Writes `.claude/settings.json` with `API_BASE_URL` (API key is generated on first use) + +### Endpoints after setup + +| | URL | +|---|---| +| Reading List API (via gateway) | `http://localhost:8080/reading-list/v1/books` | +| Gateway health | `http://localhost:9094/health` | +| Management API | `http://localhost:9090/api/management/v0.9` | + +## Verify the Setup + +Before starting Claude Code, run the test suite to confirm the gateway, API deployment, and API key are all working correctly: + +```bash +./test.sh +``` + +### Expected Results + +``` +--- Pre-flight checks --- +[INFO] API key loaded from settings.json. +[INFO] Gateway is healthy. + +--- Authentication enforcement --- +[PASS] GET /books (no API key) → HTTP 401 +[PASS] GET /books (wrong API key) → HTTP 401 + +--- GET /books --- +[PASS] GET /books (valid key) → HTTP 200 + +--- POST /books --- +[PASS] POST /books → HTTP 201 +[PASS] POST /books returned id: + +--- GET /books/{id} --- +[PASS] GET /books/ → HTTP 200 + +--- PUT /books/{id} --- +[PASS] PUT /books/ (status=reading) → HTTP 200 + +--- DELETE /books/{id} --- +[PASS] DELETE /books/ → HTTP 200 + +--- api_client.py smoke test --- +[PASS] api_client.py executed successfully + +============================================================ + Results: 9 passed, 0 failed +============================================================ +``` + +> **Note:** The live backend at `apis.bijira.dev` does not persist changes between sessions. Added/updated/deleted books will not be visible on the next run — this is expected. + +## Using Claude Code + +Start Claude Code from this directory: + +```bash +claude +``` + +Because `CLAUDE.md` is present, Claude Code is automatically briefed on the API. Try these prompts: + +``` +Add "The Lord of the Rings" by J. R. R. Tolkien to my reading list. +``` + +``` +List all my books. +``` + +``` +I just started reading The Lord of the Rings — update its status. +``` + +``` +Show me everything I haven't started yet. +``` + +Claude Code will generate and execute Python code using `api_client.py`. On first use, `api_client.py` automatically calls the gateway management API to generate an API key and saves it to `.claude/settings.json`. From that point on, the saved key is reused. + +## How It Works + +### API key bootstrap + +`api_client.py` self-bootstraps on first import: + +1. Reads `.claude/settings.json` for `API_BASE_URL` and `API_KEY` +2. If `API_KEY` is empty, calls `POST /api/management/v0.9/rest-apis/reading-list-api/api-keys` to generate a key +3. Writes the new key back to `.claude/settings.json` so it persists across sessions +4. Attaches `X-API-Key: ` to every subsequent request + +### reading-list-api.yaml + +```yaml +spec: + context: /reading-list/v1 + upstream: + main: + url: https://apis.bijira.dev/samples/reading-list-api-service/v1.0 + policies: + - name: api-key-auth # Rejects requests without a valid key + version: v1 + params: + key: X-API-Key + in: header +``` + +The gateway validates `X-API-Key` and forwards authenticated requests to the live upstream. Invalid or missing keys receive HTTP 401 before reaching the backend. + +### API contract + +The Reading List API follows the public OpenAPI spec at +`github.com/wso2/bijira-samples/blob/main/reading-list-api/openapi.yaml`. + +| Operation | Function | Notes | +|---|---|---| +| `GET /books` | `books_list()` | Returns `{"books": [...]}` | +| `POST /books` | `books_add(title, author, status)` | `status`: `to_read` · `reading` · `read` | +| `GET /books/{id}` | `books_get(id)` | | +| `PUT /books/{id}` | `books_update(id, status)` | Only `status` can be changed | +| `DELETE /books/{id}` | `books_delete(id)` | | + +## Teardown + +```bash +# Stop the stack +./teardown.sh + +# Also remove the extracted directory, downloaded zip, and settings +./teardown.sh --clean +``` + +## Troubleshooting + +| Symptom | Likely cause | +|---|---| +| `setup.sh` fails at health check | Docker images still pulling — wait and retry | +| `test.sh` reports HTTP 401 for valid key | API key mismatch — delete `.claude/settings.json` and re-run `setup.sh` | +| `test.sh` reports HTTP 404 on `/books` | API not yet deployed — re-run `setup.sh` | +| `test.sh` exits with "Gateway is not healthy" | Gateway not running — run `setup.sh` first | +| `api_client.py` exits with "settings not found" | Run `setup.sh` first | +| API key generation fails | Gateway may not be healthy — check `http://localhost:9094/health` | diff --git a/samples/ai-app-claude-code/api_client.py b/samples/ai-app-claude-code/api_client.py new file mode 100644 index 000000000..5507185f0 --- /dev/null +++ b/samples/ai-app-claude-code/api_client.py @@ -0,0 +1,134 @@ +""" +Reading List API client for Claude Code. + +Reads API_BASE_URL and API_KEY from .claude/settings.json (written by setup.sh). + +API contract: + https://raw.githubusercontent.com/wso2/bijira-samples/refs/heads/main/reading-list-api/openapi.yaml + +Usage: + import api_client + + api_client.books_list() + api_client.books_add("Clean Code", "Robert C. Martin", status="to_read") + api_client.books_get("some-uuid") + api_client.books_update("some-uuid", status="read") + api_client.books_delete("some-uuid") + +Book status values: "to_read" | "reading" | "read" +""" + +from __future__ import annotations + +import json +import sys +import urllib.request +import urllib.error +from pathlib import Path + +# --------------------------------------------------------------------------- +# Load settings written by setup.sh +# --------------------------------------------------------------------------- + +_SETTINGS_PATH = Path(".claude") / "settings.json" + +if not _SETTINGS_PATH.exists(): + sys.exit( + f"[api_client] {_SETTINGS_PATH} not found.\n" + "Run ./setup.sh first to start the gateway and configure the API key." + ) + +with _SETTINGS_PATH.open() as _f: + _env = json.load(_f).get("env", {}) + +_BASE_URL = _env.get("API_BASE_URL", "").rstrip("/") +_API_KEY = _env.get("API_KEY", "") + +if not _BASE_URL or not _API_KEY: + sys.exit( + "[api_client] API_BASE_URL or API_KEY missing in .claude/settings.json.\n" + "Run ./setup.sh first." + ) + + +# --------------------------------------------------------------------------- +# HTTP helper +# --------------------------------------------------------------------------- + +def _request(method: str, path: str, body: dict | None = None) -> dict | list | None: + url = f"{_BASE_URL}{path}" + data = json.dumps(body).encode() if body is not None else None + headers = { + "X-API-Key": _API_KEY, + "Content-Type": "application/json", + "Accept": "application/json", + } + req = urllib.request.Request(url, data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(req) as resp: + raw = resp.read() + return json.loads(raw) if raw else None + except urllib.error.HTTPError as e: + detail = e.read().decode(errors="replace") + print(f"[api_client] HTTP {e.code} {method} {url}: {detail}", file=sys.stderr) + raise + + +# --------------------------------------------------------------------------- +# Public API — mirrors the OpenAPI contract +# --------------------------------------------------------------------------- + +def books_list() -> list[dict]: + """Return all books in the reading list. + + Status values: "to_read" | "reading" | "read" + """ + result = _request("GET", "/books") + return result.get("books", []) if isinstance(result, dict) else result + + +def books_add(title: str, author: str, status: str = "to_read") -> dict: + """Add a new book. + + Args: + title: Book title. + author: Author name. + status: Initial reading status — "to_read" (default), "reading", or "read". + + Returns: + The created book dict including its ``id``. + """ + return _request("POST", "/books", {"title": title, "author": author, "status": status}) + + +def books_get(book_id: str) -> dict: + """Fetch a single book by its UUID.""" + return _request("GET", f"/books/{book_id}") + + +def books_update(book_id: str, status: str) -> dict: + """Update the reading status of a book. + + Args: + book_id: UUID of the book to update. + status: New status — "to_read", "reading", or "read". + + Returns: + The updated book dict. + """ + return _request("PUT", f"/books/{book_id}", {"status": status}) + + +def books_delete(book_id: str) -> dict: + """Delete a book by its UUID. Returns {"id": "...", "note": "..."}.""" + return _request("DELETE", f"/books/{book_id}") + + +# --------------------------------------------------------------------------- +# Quick smoke-test when run directly +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + import pprint + print("Books in reading list:") + pprint.pprint(books_list()) diff --git a/samples/ai-app-claude-code/reading-list-api.yaml b/samples/ai-app-claude-code/reading-list-api.yaml new file mode 100644 index 000000000..d66f88986 --- /dev/null +++ b/samples/ai-app-claude-code/reading-list-api.yaml @@ -0,0 +1,28 @@ +apiVersion: gateway.api-platform.wso2.com/v1alpha1 +kind: RestApi +metadata: + name: reading-list-api +spec: + displayName: Reading List API + version: v1.0 + context: /reading-list/v1 + upstream: + main: + url: https://apis.bijira.dev/samples/reading-list-api-service/v1.0 + policies: + - name: api-key-auth + version: v1 + params: + key: X-API-Key + in: header + operations: + - method: GET + path: /books + - method: POST + path: /books + - method: GET + path: /books/{id} + - method: PUT + path: /books/{id} + - method: DELETE + path: /books/{id} diff --git a/samples/ai-app-claude-code/setup.sh b/samples/ai-app-claude-code/setup.sh new file mode 100755 index 000000000..0382ed912 --- /dev/null +++ b/samples/ai-app-claude-code/setup.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +set -euo pipefail + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- +DIST_VERSION="1.1.0" +DIST_NAME="wso2apip-api-gateway-${DIST_VERSION}" +DIST_ZIP="${DIST_NAME}.zip" +DIST_URL="https://github.com/wso2/api-platform/releases/download/gateway/v${DIST_VERSION}/${DIST_ZIP}" + +GATEWAY_MGMT_URL="http://localhost:9090/api/management/v0.9" +GATEWAY_HEALTH_URL="http://localhost:9094/health" +AUTH_HEADER="Authorization: Basic YWRtaW46YWRtaW4=" # admin:admin + +API_NAME="reading-list-api" +API_KEY_NAME="claude-code-key" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +API_YAML="${SCRIPT_DIR}/reading-list-api.yaml" + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +info() { echo "[INFO] $*"; } +success() { echo "[OK] $*"; } +error() { echo "[ERROR] $*" >&2; exit 1; } + +wait_for_health() { + local url="$1" + local max_attempts=30 + local interval=5 + info "Waiting for gateway to be healthy at ${url} ..." + for i in $(seq 1 "${max_attempts}"); do + if curl -sf "${url}" > /dev/null 2>&1; then + success "Gateway is healthy." + return 0 + fi + echo " attempt ${i}/${max_attempts} — retrying in ${interval}s ..." + sleep "${interval}" + done + error "Gateway did not become healthy after $((max_attempts * interval))s." +} + +# --------------------------------------------------------------------------- +# Step 1 — Download gateway distribution +# --------------------------------------------------------------------------- +info "Downloading ${DIST_ZIP} ..." +if [[ -f "${SCRIPT_DIR}/${DIST_ZIP}" ]]; then + info "Archive already exists, skipping download." +else + curl -fL --progress-bar "${DIST_URL}" -o "${SCRIPT_DIR}/${DIST_ZIP}" + success "Downloaded ${DIST_ZIP}." +fi + +# --------------------------------------------------------------------------- +# Step 2 — Unzip +# --------------------------------------------------------------------------- +if [[ -d "${SCRIPT_DIR}/${DIST_NAME}" ]]; then + info "Distribution directory '${DIST_NAME}' already exists, skipping unzip." +else + info "Unzipping ${DIST_ZIP} ..." + unzip -q "${SCRIPT_DIR}/${DIST_ZIP}" -d "${SCRIPT_DIR}" + success "Extracted to ${DIST_NAME}/." +fi + +# --------------------------------------------------------------------------- +# Step 3 — Start the gateway stack +# --------------------------------------------------------------------------- +info "Starting Docker Compose stack in ${DIST_NAME}/ ..." +# Bring down any previous instance to avoid stale network/port conflicts. +(cd "${SCRIPT_DIR}/${DIST_NAME}" && docker compose down -v --remove-orphans 2>/dev/null || true) +(cd "${SCRIPT_DIR}/${DIST_NAME}" && docker compose up -d) +success "Docker Compose stack started." + +# --------------------------------------------------------------------------- +# Step 4 — Health check +# --------------------------------------------------------------------------- +wait_for_health "${GATEWAY_HEALTH_URL}" + +# --------------------------------------------------------------------------- +# Step 5 — Deploy the Reading List API +# --------------------------------------------------------------------------- +info "Deploying Reading List API from ${API_YAML} ..." +[[ -f "${API_YAML}" ]] || error "reading-list-api.yaml not found at ${API_YAML}" + +HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ + -X POST "${GATEWAY_MGMT_URL}/rest-apis" \ + -H "Content-Type: application/yaml" \ + -H "${AUTH_HEADER}" \ + --data-binary "@${API_YAML}") + +if [[ "${HTTP_STATUS}" == "201" ]]; then + success "Reading List API deployed (HTTP ${HTTP_STATUS})." +elif [[ "${HTTP_STATUS}" == "409" ]]; then + info "Reading List API already exists, skipping." +else + error "Failed to deploy Reading List API (HTTP ${HTTP_STATUS})." +fi + +# --------------------------------------------------------------------------- +# Step 6 — Generate API key +# --------------------------------------------------------------------------- +info "Generating API key '${API_KEY_NAME}' ..." + +KEY_RESPONSE=$(curl -s \ + -X POST "${GATEWAY_MGMT_URL}/rest-apis/${API_NAME}/api-keys" \ + -H "Content-Type: application/json" \ + -H "${AUTH_HEADER}" \ + -d "{\"name\": \"${API_KEY_NAME}\"}") + +HTTP_STATUS=$(echo "${KEY_RESPONSE}" | python3 -c \ + "import sys,json; d=json.load(sys.stdin); print('ok' if d.get('status')=='success' else 'fail')" \ + 2>/dev/null || echo "fail") + +if [[ "${HTTP_STATUS}" == "fail" ]]; then + error "Failed to generate API key: ${KEY_RESPONSE}" +fi + +API_KEY_VALUE=$(echo "${KEY_RESPONSE}" | python3 -c \ + "import sys,json; print(json.load(sys.stdin)['apiKey']['apiKey'])") +success "API key generated." + +# --------------------------------------------------------------------------- +# Step 7 — Write .claude/settings.json +# --------------------------------------------------------------------------- +CLAUDE_DIR="${SCRIPT_DIR}/.claude" +SETTINGS_FILE="${CLAUDE_DIR}/settings.json" + +mkdir -p "${CLAUDE_DIR}" + +cat > "${SETTINGS_FILE}" </dev/null || echo "000") + +if [[ "${HTTP_STATUS}" =~ ^2 ]]; then + success "API deleted (HTTP ${HTTP_STATUS})." +else + info "Could not delete API (HTTP ${HTTP_STATUS}) — gateway may already be down." +fi + +# --------------------------------------------------------------------------- +# Step 2 — Stop Docker Compose stack +# --------------------------------------------------------------------------- +COMPOSE_FILE="${SCRIPT_DIR}/${DIST_NAME}/docker-compose.yaml" +[[ -f "${COMPOSE_FILE}" ]] || COMPOSE_FILE="${SCRIPT_DIR}/${DIST_NAME}/docker-compose.yml" + +if [[ -f "${COMPOSE_FILE}" ]]; then + info "Stopping Docker Compose stack ..." + (cd "${SCRIPT_DIR}/${DIST_NAME}" && docker compose down --volumes) + success "Stack stopped." +else + info "docker-compose file not found — stack may already be stopped." +fi + +# --------------------------------------------------------------------------- +# Step 3 — Optional deep clean (--clean flag) +# --------------------------------------------------------------------------- +if [[ "${1:-}" == "--clean" ]]; then + info "Removing extracted distribution and downloaded zip ..." + rm -rf "${SCRIPT_DIR}/${DIST_NAME}" + rm -f "${SCRIPT_DIR}/${DIST_ZIP}" + rm -f "${SCRIPT_DIR}/.claude/settings.json" + success "Clean complete." +fi + +echo "" +echo "============================================================" +echo " Teardown complete." +echo " Run './setup.sh' to start fresh." +echo "============================================================" diff --git a/samples/ai-app-claude-code/test.sh b/samples/ai-app-claude-code/test.sh new file mode 100755 index 000000000..46b6dab29 --- /dev/null +++ b/samples/ai-app-claude-code/test.sh @@ -0,0 +1,184 @@ +#!/usr/bin/env bash +set -euo pipefail + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- +GATEWAY_BASE_URL="http://localhost:8080/reading-list/v1" +GATEWAY_HEALTH_URL="http://localhost:9094/health" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SETTINGS_FILE="${SCRIPT_DIR}/.claude/settings.json" + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +PASS=0 +FAIL=0 + +pass() { echo "[PASS] $*"; PASS=$((PASS + 1)); } +fail() { echo "[FAIL] $*" >&2; FAIL=$((FAIL + 1)); } +info() { echo "[INFO] $*"; } +section() { echo ""; echo "--- $* ---"; } + +assert_status() { + local label="$1" + local expected="$2" + local actual="$3" + if [[ "${actual}" == "${expected}" ]]; then + pass "${label} → HTTP ${actual}" + else + fail "${label} → expected HTTP ${expected}, got HTTP ${actual}" + fi +} + +# --------------------------------------------------------------------------- +# Pre-flight: settings.json and gateway health +# --------------------------------------------------------------------------- +section "Pre-flight checks" + +if [[ ! -f "${SETTINGS_FILE}" ]]; then + echo "[ERROR] ${SETTINGS_FILE} not found. Run ./setup.sh first." >&2 + exit 1 +fi + +API_KEY=$(python3 -c " +import json, sys +d = json.load(open('${SETTINGS_FILE}')) +k = d.get('env', {}).get('API_KEY', '') +if not k: + sys.exit('API_KEY missing in settings.json') +print(k) +") +info "API key loaded from settings.json." + +if ! curl -sf "${GATEWAY_HEALTH_URL}" > /dev/null 2>&1; then + echo "[ERROR] Gateway is not healthy at ${GATEWAY_HEALTH_URL}. Run ./setup.sh first." >&2 + exit 1 +fi +info "Gateway is healthy." + +# --------------------------------------------------------------------------- +# 1. Authentication enforcement +# --------------------------------------------------------------------------- +section "Authentication enforcement" + +# 1a. No key → 401 +STATUS=$(curl -s -o /dev/null -w "%{http_code}" "${GATEWAY_BASE_URL}/books") +assert_status "GET /books (no API key)" "401" "${STATUS}" + +# 1b. Wrong key → 401 +STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ + -H "X-API-Key: invalid-key-00000000" \ + "${GATEWAY_BASE_URL}/books") +assert_status "GET /books (wrong API key)" "401" "${STATUS}" + +# --------------------------------------------------------------------------- +# 2. GET /books — list; grab a seeded book id for later +# --------------------------------------------------------------------------- +section "GET /books" + +_TMP=$(mktemp) +STATUS=$(curl -s -w "%{http_code}" -o "${_TMP}" \ + -H "X-API-Key: ${API_KEY}" \ + "${GATEWAY_BASE_URL}/books") +LIST_RESPONSE=$(cat "${_TMP}"); rm -f "${_TMP}" +assert_status "GET /books (valid key)" "200" "${STATUS}" + +SEEDED_ID=$(echo "${LIST_RESPONSE}" | python3 -c " +import sys, json +books = json.load(sys.stdin).get('books', []) +print(books[0]['id'] if books else '') +" 2>/dev/null || echo "") + +# --------------------------------------------------------------------------- +# 3. POST /books — add +# --------------------------------------------------------------------------- +section "POST /books" + +_TMP=$(mktemp) +HTTP_STATUS=$(curl -s -w "%{http_code}" -o "${_TMP}" \ + -X POST "${GATEWAY_BASE_URL}/books" \ + -H "Content-Type: application/json" \ + -H "X-API-Key: ${API_KEY}" \ + -d '{"title":"Test Book","author":"Test Author","status":"to_read"}') +ADD_RESPONSE=$(cat "${_TMP}"); rm -f "${_TMP}" +assert_status "POST /books" "201" "${HTTP_STATUS}" + +BOOK_ID=$(echo "${ADD_RESPONSE}" | python3 -c " +import sys, json +d = json.load(sys.stdin) +print(d.get('uuid', d.get('id', ''))) +" 2>/dev/null || echo "") + +if [[ -z "${BOOK_ID}" ]]; then + fail "POST /books did not return an id" +else + pass "POST /books returned id: ${BOOK_ID}" +fi + +# --------------------------------------------------------------------------- +# 4. GET /books/{id} — fetch a seeded book (newly added books are not persisted) +# --------------------------------------------------------------------------- +section "GET /books/{id}" + +if [[ -n "${SEEDED_ID}" ]]; then + STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ + -H "X-API-Key: ${API_KEY}" \ + "${GATEWAY_BASE_URL}/books/${SEEDED_ID}") + assert_status "GET /books/${SEEDED_ID}" "200" "${STATUS}" +else + fail "GET /books/{id} skipped — no seeded book id from GET /books" +fi + +# --------------------------------------------------------------------------- +# 5. PUT /books/{id} — update status +# --------------------------------------------------------------------------- +section "PUT /books/{id}" + +if [[ -n "${BOOK_ID}" ]]; then + STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ + -X PUT "${GATEWAY_BASE_URL}/books/${BOOK_ID}" \ + -H "Content-Type: application/json" \ + -H "X-API-Key: ${API_KEY}" \ + -d '{"status":"reading"}') + assert_status "PUT /books/${BOOK_ID} (status=reading)" "200" "${STATUS}" +else + fail "PUT /books/{id} skipped — no book id from POST" +fi + +# --------------------------------------------------------------------------- +# 6. DELETE /books/{id} +# --------------------------------------------------------------------------- +section "DELETE /books/{id}" + +if [[ -n "${BOOK_ID}" ]]; then + STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ + -X DELETE "${GATEWAY_BASE_URL}/books/${BOOK_ID}" \ + -H "X-API-Key: ${API_KEY}") + assert_status "DELETE /books/${BOOK_ID}" "200" "${STATUS}" +else + fail "DELETE /books/{id} skipped — no book id from POST" +fi + +# --------------------------------------------------------------------------- +# 7. api_client.py smoke test +# --------------------------------------------------------------------------- +section "api_client.py smoke test" + +SMOKE_OUTPUT=$(cd "${SCRIPT_DIR}" && python3 api_client.py 2>&1) +if echo "${SMOKE_OUTPUT}" | grep -q "Books in reading list"; then + pass "api_client.py executed successfully" +else + fail "api_client.py smoke test failed: ${SMOKE_OUTPUT}" +fi + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- +echo "" +echo "============================================================" +echo " Results: ${PASS} passed, ${FAIL} failed" +echo "============================================================" + +[[ "${FAIL}" -eq 0 ]]