Skip to content
Merged
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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ Python-specific:
| `python/pdf-processing` | PDF operations (extract text, metadata, split, merge) via HTTP. |
| `python/mcp` | MCP server exposing basic tools (hello, add_numbers) via Model Context Protocol. |
| `python/mcp-ollama-rag` | RAG via MCP — combines Ollama with Chroma vector DB for document Q&A. Needs Ollama. |
| `python/sqlite` | REST API backed by SQLite. CRUD on tables with zero external dependencies. |
| `python/keycloak-auth` | Validates Keycloak JWT Bearer tokens via OIDC/JWKS. Protects endpoints with auth. |

For contributing to this repo, see [CONTRIBUTING.md](CONTRIBUTING.md).
103 changes: 103 additions & 0 deletions python/sqlite/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# Python HTTP Function - SQLite Database

A Knative Function with a REST API backed by a SQLite database. Shows how
to use persistent storage in a serverless function — no external database
server needed.

## Quick Start

### 1. Create the function

```bash
func create myfunc \
-r https://github.com/functions-dev/templates \
-l python -t sqlite
cd myfunc
```

### 2. Run the function

```bash
func run --builder=host
```

### 3. Try it

```bash
# Function info
curl -s http://localhost:8080/ | jq .

# Create a table
curl -s -X POST http://localhost:8080/tables \
-H "Content-Type: application/json" \
-d '{"table": "tasks", "columns": {"title": "TEXT", "status": "TEXT", "priority": "TEXT"}}' | jq .

# Insert rows
curl -s -X POST http://localhost:8080/tables/tasks \
-H "Content-Type: application/json" \
-d '{"title": "Fix login bug", "status": "open", "priority": "high"}' | jq .

curl -s -X POST http://localhost:8080/tables/tasks \
-H "Content-Type: application/json" \
-d '{"title": "Update docs", "status": "open", "priority": "low"}' | jq .

# Query all rows
curl -s http://localhost:8080/tables/tasks | jq .

# Filter rows
curl -s 'http://localhost:8080/tables/tasks?priority=high' | jq .

# Delete a row
curl -s -X DELETE 'http://localhost:8080/tables/tasks?id=2' | jq .

# List all tables
curl -s http://localhost:8080/tables | jq .

# Table schema
curl -s http://localhost:8080/tables/tasks/schema | jq .
```

## Configuration

| Variable | Required | Description | Default |
|---|---|---|---|
| `SQLITE_DB_PATH` | No | Path to the SQLite database file | `data.db` |

## Endpoints

| Method | Path | Description |
|---|---|---|
| GET | `/` | Function info and list of tables |
| GET | `/tables` | List all tables |
| POST | `/tables` | Create a table |
| GET | `/tables/<name>` | Query rows (`?col=val` to filter, `?limit=N`) |
| POST | `/tables/<name>` | Insert a row |
| DELETE | `/tables/<name>` | Delete rows (`?col=val` to filter, at least one required) |
| GET | `/tables/<name>/schema` | Column info for a table |

## Deploying to a Cluster

SQLite stores data inside the container — it's lost on restart unless you
mount a persistent volume.

Add to `func.yaml`:

```yaml
run:
envs:
- name: SQLITE_DB_PATH
value: /data/data.db
volumes:
- persistentVolumeClaim:
claimName: sqlite-data
path: /data
```

## Development

```bash
pip install -e '.[dev]'
pytest tests/
```

For more, see [the complete documentation](https://github.com/knative/func/tree/main/docs)
1 change: 1 addition & 0 deletions python/sqlite/function/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .func import new
85 changes: 85 additions & 0 deletions python/sqlite/function/database.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
"""SQLite database wrapper.

Provides a class for creating tables, inserting rows, querying data,
and inspecting schema. All operations go through a single SQLite file
on disk so data persists across restarts.
"""

import pysqlite3 as sqlite3

ALLOWED_TYPES = {"TEXT", "INTEGER", "REAL", "BLOB", "NUMERIC"}


def _quote_id(identifier: str) -> str:
"""Quote a SQL identifier to prevent injection."""
return f'"{identifier.replace(chr(34), "")}"'


class Database:
def __init__(self, db_path: str):
self.db_path = db_path
self.conn = sqlite3.connect(db_path)
self.conn.row_factory = sqlite3.Row

def close(self) -> None:
self.conn.close()

def list_tables(self) -> list[str]:
rows = self.conn.execute(
"SELECT name FROM sqlite_master"
" WHERE type='table' AND name NOT LIKE 'sqlite_%'"
" ORDER BY name"
).fetchall()
return [row["name"] for row in rows]

def describe_table(self, table: str) -> list[dict]:
rows = self.conn.execute(
f"PRAGMA table_info({_quote_id(table)})"
).fetchall()
return [
{"name": r["name"], "type": r["type"], "notnull": bool(r["notnull"])}
for r in rows
]

def create_table(self, table: str, columns: dict[str, str]) -> str:
for col_name, col_type in columns.items():
if col_type.upper() not in ALLOWED_TYPES:
raise ValueError(
f"Invalid column type '{col_type}' for '{col_name}'. "
f"Allowed: {', '.join(sorted(ALLOWED_TYPES))}"
)
cols = ", ".join(f"{_quote_id(k)} {v}" for k, v in columns.items())
sql = f"CREATE TABLE IF NOT EXISTS {_quote_id(table)} (id INTEGER PRIMARY KEY AUTOINCREMENT, {cols})"
self.conn.execute(sql)
self.conn.commit()
return f"Table '{table}' created with columns: {', '.join(columns.keys())}"

def insert(self, table: str, data: dict) -> str:
keys = list(data.keys())
placeholders = ", ".join("?" for _ in keys)
cols = ", ".join(_quote_id(k) for k in keys)
sql = f"INSERT INTO {_quote_id(table)} ({cols}) VALUES ({placeholders})"
cursor = self.conn.execute(sql, list(data.values()))
self.conn.commit()
return f"Inserted row {cursor.lastrowid} into '{table}'"

def query(self, table: str, filters: dict | None = None, limit: int = 100) -> list[dict]:
sql = f"SELECT * FROM {_quote_id(table)}"
params: list = []
if filters:
clauses = [f"{_quote_id(k)} = ?" for k in filters]
sql += " WHERE " + " AND ".join(clauses)
params = list(filters.values())
sql += " LIMIT ?"
params.append(limit)
rows = self.conn.execute(sql, params).fetchall()
return [dict(row) for row in rows]

def delete(self, table: str, filters: dict[str, str]) -> str:
if not filters:
return "Error: at least one filter is required for delete"
clauses = [f"{_quote_id(k)} = ?" for k in filters]
sql = f"DELETE FROM {_quote_id(table)} WHERE " + " AND ".join(clauses)
cursor = self.conn.execute(sql, list(filters.values()))
self.conn.commit()
return f"Deleted {cursor.rowcount} row(s) from '{table}'"
197 changes: 197 additions & 0 deletions python/sqlite/function/func.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
"""HTTP function with SQLite persistence.

A Knative Function that provides a REST API backed by a SQLite database.
Shows how to use persistent storage in a serverless function.

Endpoints:
GET / -> function info + list of tables
GET /tables -> list all tables
POST /tables -> create a table
GET /tables/<name> -> query rows (filter via query params)
POST /tables/<name> -> insert a row
DELETE /tables/<name> -> delete rows (filter via query params)
GET /tables/<name>/schema -> column info for a table

Configuration (environment variables):
SQLITE_DB_PATH -> path to SQLite database file (default: data.db)
"""

import json
import logging
from urllib.parse import unquote

from .database import Database


def new():
"""Entry point -- called once by the Knative Functions runtime."""
return Function()


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

async def send_json(send, body, status: int = 200) -> None:
payload = json.dumps(body).encode()
await send({
"type": "http.response.start",
"status": status,
"headers": [[b"content-type", b"application/json"]],
})
await send({
"type": "http.response.body",
"body": payload,
})


async def read_body(receive) -> bytes:
body = b""
while True:
message = await receive()
body += message.get("body", b"")
if not message.get("more_body", False):
break
return body


def parse_path(path: str) -> tuple[str, str, str]:
"""Parse /tables/name/schema into ("tables", "name", "schema")."""
parts = [p for p in path.strip("/").split("/") if p]
while len(parts) < 3:
parts.append("")
return (parts[0], parts[1], parts[2])


# ---------------------------------------------------------------------------
# The Function
# ---------------------------------------------------------------------------

class Function:
def __init__(self):
self.db = None

async def handle(self, scope, receive, send) -> None:
method = scope.get("method", "GET")
path = scope.get("path", "/")
query_string = unquote(scope.get("query_string", b"").decode())
resource, name, sub = parse_path(path)

# GET / -> function info
if path == "/" and method == "GET":
return await send_json(send, {
"name": "sqlite",
"description": "HTTP function with SQLite database",
"database": self.db.db_path,
"tables": self.db.list_tables(),
"endpoints": {
"GET /tables": "List all tables",
"POST /tables": "Create a table",
"GET /tables/<name>": "Query rows (?col=val to filter, ?limit=N)",
"POST /tables/<name>": "Insert a row",
"DELETE /tables/<name>": "Delete rows (?col=val to filter)",
"GET /tables/<name>/schema": "Column info for a table",
},
})

if resource != "tables":
return await send_json(send, {"error": "Not found"}, status=404)

# GET /tables -> list tables
if not name and method == "GET":
return await send_json(send, {"tables": self.db.list_tables()})

# POST /tables -> create table
# Body: {"table": "tasks", "columns": {"title": "TEXT", "done": "INTEGER"}}
if not name and method == "POST":
raw = await read_body(receive)
try:
body = json.loads(raw) if raw else {}
except json.JSONDecodeError:
return await send_json(send, {"error": "Invalid JSON body"}, status=400)
table = body.get("table", "")
columns = body.get("columns", {})
if not table or not columns:
return await send_json(send, {
"error": "Required: {\"table\": \"name\", \"columns\": {\"col\": \"TYPE\"}}"
}, status=400)
try:
result = self.db.create_table(table, columns)
return await send_json(send, {"result": result}, status=201)
except Exception as e:
return await send_json(send, {"error": str(e)}, status=400)

# GET /tables/<name>/schema -> column info
if name and sub == "schema" and method == "GET":
columns = self.db.describe_table(name)
return await send_json(send, {"table": name, "columns": columns})

if sub:
return await send_json(send, {"error": "Not found"}, status=404)

# GET /tables/<name> -> query rows
# Filter via query params: ?done=0&priority=high (equality filters)
# Special param: ?limit=N (default 100)
if name and method == "GET":
params = dict(p.split("=", 1) for p in query_string.split("&") if "=" in p)
try:
limit = int(params.pop("limit", "100"))
except ValueError:
return await send_json(send, {"error": "limit must be an integer"}, status=400)
try:
rows = self.db.query(name, filters=params or None, limit=limit)
return await send_json(send, {
"table": name,
"count": len(rows),
"rows": rows,
})
except Exception as e:
return await send_json(send, {"error": str(e)}, status=400)

# POST /tables/<name> -> insert row
# Body: {"title": "Fix bug", "done": 0}
if name and method == "POST":
raw = await read_body(receive)
try:
body = json.loads(raw) if raw else {}
except json.JSONDecodeError:
return await send_json(send, {"error": "Invalid JSON body"}, status=400)
try:
result = self.db.insert(name, body)
return await send_json(send, {"result": result}, status=201)
except Exception as e:
return await send_json(send, {"error": str(e)}, status=400)

# DELETE /tables/<name>?id=5 -> delete rows
# Filter via query params (at least one required)
if name and method == "DELETE":
params = dict(p.split("=", 1) for p in query_string.split("&") if "=" in p)
if not params:
return await send_json(send, {
"error": "Required: filter params (e.g. ?id=5)"
}, status=400)
try:
result = self.db.delete(name, params)
return await send_json(send, {"result": result})
except Exception as e:
return await send_json(send, {"error": str(e)}, status=400)

return await send_json(send, {"error": "Method not allowed"}, status=405)

def start(self, cfg) -> None:
db_path = cfg.get("SQLITE_DB_PATH", "data.db")
self.db = Database(db_path)
logging.info("SQLite function ready: database=%s", db_path)

def stop(self) -> None:
if self.db:
self.db.close()
logging.info("Function stopping")

def alive(self) -> tuple:
return True, "Alive"

def ready(self) -> tuple:
if self.db is None:
return False, "Database not initialized"
return True, "Ready"
Loading
Loading