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
48 changes: 48 additions & 0 deletions samples/ai-app-claude-code/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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"])
```
199 changes: 199 additions & 0 deletions samples/ai-app-claude-code/README.md
Original file line number Diff line number Diff line change
@@ -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: <uuid>

--- GET /books/{id} ---
[PASS] GET /books/<uuid> → HTTP 200

--- PUT /books/{id} ---
[PASS] PUT /books/<uuid> (status=reading) → HTTP 200

--- DELETE /books/{id} ---
[PASS] DELETE /books/<uuid> → 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: <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` |
134 changes: 134 additions & 0 deletions samples/ai-app-claude-code/api_client.py
Original file line number Diff line number Diff line change
@@ -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())
Loading