6 changed files with 1062 additions and 0 deletions
@ -0,0 +1,341 @@ |
|||||
|
# NoSQL Databases { #nosql-databases } |
||||
|
|
||||
|
**FastAPI** doesn't require you to use a SQL (relational) database. |
||||
|
|
||||
|
Here we'll see an example using <a href="https://cassandra.apache.org/" class="external-link" target="_blank">Apache Cassandra</a>, a popular distributed NoSQL database. |
||||
|
|
||||
|
We'll also show how <a href="https://www.scylladb.com/" class="external-link" target="_blank">ScyllaDB</a>, a Cassandra-compatible database, works using the exact same code. |
||||
|
|
||||
|
/// tip |
||||
|
|
||||
|
FastAPI doesn't force you to use any specific database. This tutorial demonstrates Cassandra/ScyllaDB, but you can use other databases with their respective Python drivers. |
||||
|
|
||||
|
/// |
||||
|
|
||||
|
## Install Dependencies { #install-dependencies } |
||||
|
|
||||
|
First, make sure you create your [virtual environment](../virtual-environments.md){.internal-link target=_blank}, activate it, and then install the Cassandra driver: |
||||
|
|
||||
|
<div class="termy"> |
||||
|
|
||||
|
```console |
||||
|
$ pip install cassandra-driver |
||||
|
---> 100% |
||||
|
``` |
||||
|
|
||||
|
</div> |
||||
|
|
||||
|
/// note |
||||
|
|
||||
|
The same `cassandra-driver` works with both Apache Cassandra and ScyllaDB. This is what makes ScyllaDB a true drop-in replacement. |
||||
|
|
||||
|
/// |
||||
|
|
||||
|
/// tip | Python 3.12+ |
||||
|
|
||||
|
If you're using Python 3.12 or newer, you'll also need an event loop implementation. Install `gevent` for best compatibility: |
||||
|
|
||||
|
<div class="termy"> |
||||
|
|
||||
|
```console |
||||
|
$ pip install gevent |
||||
|
---> 100% |
||||
|
``` |
||||
|
|
||||
|
</div> |
||||
|
|
||||
|
The `asyncore` module was removed in Python 3.12, so `cassandra-driver` requires an alternative event loop like gevent or libev. |
||||
|
|
||||
|
/// |
||||
|
|
||||
|
## Set Up Docker Containers { #set-up-docker-containers } |
||||
|
|
||||
|
For local development and testing, you can use Docker Compose to run both Cassandra and ScyllaDB. |
||||
|
|
||||
|
Create a `docker-compose.yml` file: |
||||
|
|
||||
|
```yaml |
||||
|
services: |
||||
|
cassandra: |
||||
|
image: cassandra:4.1 |
||||
|
container_name: fastapi-cassandra |
||||
|
ports: |
||||
|
- "9043:9042" |
||||
|
environment: |
||||
|
- CASSANDRA_CLUSTER_NAME=test-cluster |
||||
|
healthcheck: |
||||
|
test: ["CMD-SHELL", "cqlsh -e 'DESCRIBE KEYSPACES'"] |
||||
|
interval: 10s |
||||
|
timeout: 5s |
||||
|
retries: 10 |
||||
|
volumes: |
||||
|
- cassandra_data:/var/lib/cassandra |
||||
|
|
||||
|
scylladb: |
||||
|
image: scylladb/scylla:2025.1.4 |
||||
|
container_name: fastapi-scylladb |
||||
|
command: --reactor-backend epoll --smp 1 --memory 1G --overprovisioned 1 --api-address 0.0.0.0 |
||||
|
ports: |
||||
|
- "9042:9042" |
||||
|
healthcheck: |
||||
|
test: ["CMD-SHELL", "cqlsh -e 'DESCRIBE KEYSPACES'"] |
||||
|
interval: 10s |
||||
|
timeout: 5s |
||||
|
retries: 10 |
||||
|
volumes: |
||||
|
- scylladb_data:/var/lib/scylla |
||||
|
|
||||
|
volumes: |
||||
|
cassandra_data: |
||||
|
scylladb_data: |
||||
|
``` |
||||
|
|
||||
|
Start the containers: |
||||
|
|
||||
|
<div class="termy"> |
||||
|
|
||||
|
```console |
||||
|
$ docker-compose up -d |
||||
|
``` |
||||
|
|
||||
|
</div> |
||||
|
|
||||
|
/// tip |
||||
|
|
||||
|
Notice that both containers expose port 9042 (the CQL native protocol port). We map them to different host ports (9042 and 9043) so both can run simultaneously for comparison. |
||||
|
|
||||
|
/// |
||||
|
|
||||
|
## Create the App { #create-the-app } |
||||
|
|
||||
|
We'll create a task management API that demonstrates CRUD operations with Cassandra. |
||||
|
|
||||
|
### Import and Create Models { #import-and-create-models } |
||||
|
|
||||
|
We use **Pydantic models** for data validation, just like with SQL databases: |
||||
|
|
||||
|
{* ../../docs_src/nosql_databases/tutorial001.py ln[1:17] hl[1:5,8:17] *} |
||||
|
|
||||
|
/// tip |
||||
|
|
||||
|
Unlike SQL databases, Cassandra uses UUIDs for primary keys. This works great in distributed systems because UUIDs can be generated independently on any node without conflicts. |
||||
|
|
||||
|
/// |
||||
|
|
||||
|
### Create the Database Connection { #create-the-database-connection } |
||||
|
|
||||
|
Create a connection class that manages the Cassandra cluster, session, and schema: |
||||
|
|
||||
|
{* ../../docs_src/nosql_databases/tutorial001.py ln[20:59] hl[20:22,24:26,28:35,37:51] *} |
||||
|
|
||||
|
Notice: |
||||
|
|
||||
|
* **Cluster** connects to Cassandra nodes (line 21) |
||||
|
* **Session** executes CQL queries (line 25) |
||||
|
* **Keyspace** is like a database/schema in SQL (line 26) |
||||
|
* **Replication** strategy determines how data is distributed |
||||
|
|
||||
|
/// note |
||||
|
|
||||
|
`SimpleStrategy` with `replication_factor: 1` is for development. In production, use `NetworkTopologyStrategy` with multiple replicas across data centers. |
||||
|
|
||||
|
/// |
||||
|
|
||||
|
### Initialize the Database { #initialize-the-database } |
||||
|
|
||||
|
Set up the application and database connection using the modern `lifespan` context manager: |
||||
|
|
||||
|
{* ../../docs_src/nosql_databases/tutorial001.py ln[65:81] hl[65,68:74,77] *} |
||||
|
|
||||
|
/// tip |
||||
|
|
||||
|
We use the `lifespan` context manager to handle startup and shutdown events. This is the recommended approach in modern FastAPI applications. See the <a href="https://fastapi.tiangolo.com/advanced/events/" class="internal-link" target="_blank">events documentation</a> for more details. |
||||
|
|
||||
|
/// |
||||
|
|
||||
|
### Create a Task { #create-a-task } |
||||
|
|
||||
|
Add a **POST** endpoint to create tasks: |
||||
|
|
||||
|
{* ../../docs_src/nosql_databases/tutorial001.py ln[84:93] hl[84,85,86:92] *} |
||||
|
|
||||
|
Key points: |
||||
|
|
||||
|
* Generate UUID for primary key |
||||
|
* Use CQL parameterized queries (secure against injection) |
||||
|
* Use `toTimestamp(now())` for Cassandra timestamps |
||||
|
|
||||
|
### Read Tasks { #read-tasks } |
||||
|
|
||||
|
Add a **GET** endpoint to list all tasks: |
||||
|
|
||||
|
{* ../../docs_src/nosql_databases/tutorial001.py ln[96:104] hl[96,97,98:99,100:103] *} |
||||
|
|
||||
|
### Read One Task { #read-one-task } |
||||
|
|
||||
|
Add a **GET** endpoint for a specific task: |
||||
|
|
||||
|
{* ../../docs_src/nosql_databases/tutorial001.py ln[107:112] hl[107,108,109:110,111] *} |
||||
|
|
||||
|
### Update a Task { #update-a-task } |
||||
|
|
||||
|
Add a **PUT** endpoint to update tasks: |
||||
|
|
||||
|
{* ../../docs_src/nosql_databases/tutorial001.py ln[115:131] hl[115,116,117:121,123:127] *} |
||||
|
|
||||
|
### Delete a Task { #delete-a-task } |
||||
|
|
||||
|
Add a **DELETE** endpoint: |
||||
|
|
||||
|
{* ../../docs_src/nosql_databases/tutorial001.py ln[134:144] hl[134,135,136:140,142:143] *} |
||||
|
|
||||
|
## Run the App { #run-the-app } |
||||
|
|
||||
|
Save the code to `main.py` and run it: |
||||
|
|
||||
|
<div class="termy"> |
||||
|
|
||||
|
```console |
||||
|
$ fastapi dev main.py |
||||
|
|
||||
|
╭────────── FastAPI CLI - Development mode ───────────╮ |
||||
|
│ │ |
||||
|
│ Serving at: http://127.0.0.1:8000 │ |
||||
|
│ │ |
||||
|
│ API docs: http://127.0.0.1:8000/docs │ |
||||
|
│ │ |
||||
|
│ Running in development mode, for production use: │ |
||||
|
│ │ |
||||
|
│ fastapi run │ |
||||
|
│ │ |
||||
|
╰─────────────────────────────────────────────────────╯ |
||||
|
|
||||
|
INFO: Will watch for changes in these directories: ['/home/user/code'] |
||||
|
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) |
||||
|
INFO: Started reloader process [2248755] using WatchFiles |
||||
|
INFO: Started server process [2248757] |
||||
|
INFO: Waiting for application startup. |
||||
|
INFO: Application startup complete. |
||||
|
``` |
||||
|
|
||||
|
</div> |
||||
|
|
||||
|
## Check the API Docs { #check-the-api-docs } |
||||
|
|
||||
|
Open your browser at <a href="http://127.0.0.1:8000/docs" class="external-link" target="_blank">http://127.0.0.1:8000/docs</a>. |
||||
|
|
||||
|
You will see the automatic interactive API documentation (provided by Swagger UI): |
||||
|
|
||||
|
<img src="/img/tutorial/nosql-databases/image01.png"> |
||||
|
|
||||
|
## Use ScyllaDB Instead { #use-scylladb-instead } |
||||
|
|
||||
|
**ScyllaDB is Cassandra-compatible**, so you can use it with the same code. |
||||
|
|
||||
|
To use ScyllaDB instead of Cassandra, change the **hostname** in the connection: |
||||
|
|
||||
|
```python |
||||
|
# Cassandra version (tutorial001.py) |
||||
|
class CassandraConnection: |
||||
|
def __init__(self, hosts=["cassandra"], port=9042): |
||||
|
# ... rest of the code stays the same |
||||
|
``` |
||||
|
|
||||
|
Change to: |
||||
|
|
||||
|
```python |
||||
|
# ScyllaDB version (tutorial001_scylla.py) |
||||
|
class ScyllaDBConnection: |
||||
|
def __init__(self, hosts=["scylladb"], port=9042): |
||||
|
# ... rest of the code stays the same |
||||
|
``` |
||||
|
|
||||
|
That's it! Everything else is **identical**: |
||||
|
|
||||
|
* ✅ Same Cassandra driver (`cassandra-driver`) |
||||
|
* ✅ Same CQL queries |
||||
|
* ✅ Same data models |
||||
|
* ✅ Same API endpoints |
||||
|
* ✅ Same behavior |
||||
|
|
||||
|
The complete ScyllaDB version is in `docs_src/nosql_databases/tutorial001_scylla.py` - the only difference is the hostname. |
||||
|
|
||||
|
## Production Considerations { #production-considerations } |
||||
|
|
||||
|
### Connection Pooling { #connection-pooling } |
||||
|
|
||||
|
The Cassandra driver automatically manages connection pools. For production, configure: |
||||
|
|
||||
|
```python |
||||
|
from cassandra.cluster import Cluster, ExecutionProfile, EXEC_PROFILE_DEFAULT |
||||
|
from cassandra.policies import DCAwareRoundRobinPolicy, TokenAwarePolicy |
||||
|
|
||||
|
profile = ExecutionProfile( |
||||
|
load_balancing_policy=TokenAwarePolicy(DCAwareRoundRobinPolicy()), |
||||
|
request_timeout=15 |
||||
|
) |
||||
|
|
||||
|
cluster = Cluster( |
||||
|
hosts=['node1', 'node2', 'node3'], |
||||
|
execution_profiles={EXEC_PROFILE_DEFAULT: profile} |
||||
|
) |
||||
|
``` |
||||
|
|
||||
|
### Consistency Levels { #consistency-levels } |
||||
|
|
||||
|
Cassandra allows tuning consistency per query: |
||||
|
|
||||
|
```python |
||||
|
from cassandra.query import SimpleStatement |
||||
|
from cassandra import ConsistencyLevel |
||||
|
|
||||
|
query = SimpleStatement( |
||||
|
"SELECT * FROM tasks WHERE id = %s", |
||||
|
consistency_level=ConsistencyLevel.QUORUM |
||||
|
) |
||||
|
session.execute(query, (task_id,)) |
||||
|
``` |
||||
|
|
||||
|
### Error Handling { #error-handling } |
||||
|
|
||||
|
Add proper error handling for production: |
||||
|
|
||||
|
```python |
||||
|
from cassandra.cluster import NoHostAvailable |
||||
|
from cassandra import OperationTimedOut |
||||
|
|
||||
|
try: |
||||
|
session.execute(query) |
||||
|
except NoHostAvailable: |
||||
|
raise HTTPException(status_code=503, detail="Database unavailable") |
||||
|
except OperationTimedOut: |
||||
|
raise HTTPException(status_code=504, detail="Query timeout") |
||||
|
``` |
||||
|
|
||||
|
### Schema Migrations { #schema-migrations } |
||||
|
|
||||
|
For production, use migration tools: |
||||
|
|
||||
|
* <a href="https://github.com/Cobliteam/cassandra-migrate" class="external-link" target="_blank">cassandra-migrate</a> |
||||
|
* Custom CQL scripts with version tracking |
||||
|
* Application-level schema management |
||||
|
|
||||
|
## Learn More { #learn-more } |
||||
|
|
||||
|
This is a quick introduction. For more advanced topics, see: |
||||
|
|
||||
|
* <a href="https://cassandra.apache.org/doc/latest/" class="external-link" target="_blank">Cassandra Documentation</a> |
||||
|
* <a href="https://opensource.docs.scylladb.com/" class="external-link" target="_blank">ScyllaDB Documentation</a> |
||||
|
* <a href="https://docs.datastax.com/en/developer/python-driver/" class="external-link" target="_blank">DataStax Python Driver Documentation</a> |
||||
|
|
||||
|
## Recap { #recap } |
||||
|
|
||||
|
FastAPI works great with NoSQL databases like Cassandra and ScyllaDB: |
||||
|
|
||||
|
* ✅ Use standard Python drivers |
||||
|
* ✅ Leverage Pydantic for data validation |
||||
|
* ✅ Get automatic API documentation |
||||
|
* ✅ Enjoy type safety and editor support |
||||
|
* ✅ Switch between compatible databases with minimal changes |
||||
|
|
||||
|
FastAPI works well with NoSQL databases, providing the same benefits as with SQL databases. |
||||
@ -0,0 +1,143 @@ |
|||||
|
from contextlib import asynccontextmanager |
||||
|
from typing import List, Union |
||||
|
from uuid import UUID, uuid4 |
||||
|
|
||||
|
from cassandra.cluster import Cluster |
||||
|
from fastapi import Depends, FastAPI, HTTPException |
||||
|
from pydantic import BaseModel, Field |
||||
|
|
||||
|
|
||||
|
class TaskBase(BaseModel): |
||||
|
title: str |
||||
|
description: Union[str, None] = None |
||||
|
status: str = "pending" |
||||
|
|
||||
|
|
||||
|
class TaskCreate(TaskBase): |
||||
|
pass |
||||
|
|
||||
|
|
||||
|
class Task(TaskBase): |
||||
|
id: UUID = Field(default_factory=uuid4) |
||||
|
|
||||
|
|
||||
|
class CassandraConnection: |
||||
|
def __init__(self, hosts=["cassandra"], port=9042): |
||||
|
self.cluster = Cluster(hosts, port=port) |
||||
|
self.session = None |
||||
|
self.keyspace = "task_manager" |
||||
|
|
||||
|
def connect(self): |
||||
|
self.session = self.cluster.connect() |
||||
|
self.create_keyspace() |
||||
|
self.session.set_keyspace(self.keyspace) |
||||
|
self.create_table() |
||||
|
|
||||
|
def create_keyspace(self): |
||||
|
self.session.execute( |
||||
|
f""" |
||||
|
CREATE KEYSPACE IF NOT EXISTS {self.keyspace} |
||||
|
WITH replication = {{'class': 'SimpleStrategy', 'replication_factor': 1}} |
||||
|
""" |
||||
|
) |
||||
|
|
||||
|
def create_table(self): |
||||
|
self.session.execute( |
||||
|
""" |
||||
|
CREATE TABLE IF NOT EXISTS tasks ( |
||||
|
id uuid PRIMARY KEY, |
||||
|
title text, |
||||
|
description text, |
||||
|
status text, |
||||
|
created_at timestamp, |
||||
|
updated_at timestamp |
||||
|
) |
||||
|
""" |
||||
|
) |
||||
|
|
||||
|
def close(self): |
||||
|
if self.session: |
||||
|
self.session.shutdown() |
||||
|
if self.cluster: |
||||
|
self.cluster.shutdown() |
||||
|
|
||||
|
|
||||
|
db = CassandraConnection() |
||||
|
|
||||
|
|
||||
|
@asynccontextmanager |
||||
|
async def lifespan(app: FastAPI): |
||||
|
# Startup |
||||
|
db.connect() |
||||
|
yield |
||||
|
# Shutdown |
||||
|
db.close() |
||||
|
|
||||
|
|
||||
|
app = FastAPI(lifespan=lifespan) |
||||
|
|
||||
|
|
||||
|
def get_db(): |
||||
|
return db.session |
||||
|
|
||||
|
|
||||
|
@app.post("/tasks/", response_model=Task) |
||||
|
def create_task(task: TaskCreate, session=Depends(get_db)): |
||||
|
task_id = uuid4() |
||||
|
query = """ |
||||
|
INSERT INTO tasks (id, title, description, status, created_at, updated_at) |
||||
|
VALUES (%s, %s, %s, %s, toTimestamp(now()), toTimestamp(now())) |
||||
|
""" |
||||
|
session.execute(query, (task_id, task.title, task.description, task.status)) |
||||
|
return Task(id=task_id, **task.model_dump()) |
||||
|
|
||||
|
|
||||
|
@app.get("/tasks/", response_model=List[Task]) |
||||
|
def read_tasks(session=Depends(get_db)): |
||||
|
query = "SELECT id, title, description, status FROM tasks" |
||||
|
rows = session.execute(query) |
||||
|
return [ |
||||
|
Task(id=row.id, title=row.title, description=row.description, status=row.status) |
||||
|
for row in rows |
||||
|
] |
||||
|
|
||||
|
|
||||
|
@app.get("/tasks/{task_id}", response_model=Task) |
||||
|
def read_task(task_id: UUID, session=Depends(get_db)): |
||||
|
query = "SELECT id, title, description, status FROM tasks WHERE id = %s" |
||||
|
row = session.execute(query, (task_id,)).one() |
||||
|
if not row: |
||||
|
raise HTTPException(status_code=404, detail="Task not found") |
||||
|
return Task(id=row.id, title=row.title, description=row.description, status=row.status) |
||||
|
|
||||
|
|
||||
|
@app.put("/tasks/{task_id}", response_model=Task) |
||||
|
def update_task(task_id: UUID, task: TaskCreate, session=Depends(get_db)): |
||||
|
# Check if task exists |
||||
|
check_query = "SELECT id FROM tasks WHERE id = %s" |
||||
|
existing = session.execute(check_query, (task_id,)).one() |
||||
|
if not existing: |
||||
|
raise HTTPException(status_code=404, detail="Task not found") |
||||
|
|
||||
|
update_query = """ |
||||
|
UPDATE tasks |
||||
|
SET title = %s, description = %s, status = %s, updated_at = toTimestamp(now()) |
||||
|
WHERE id = %s |
||||
|
""" |
||||
|
session.execute( |
||||
|
update_query, (task.title, task.description, task.status, task_id) |
||||
|
) |
||||
|
return Task(id=task_id, **task.model_dump()) |
||||
|
|
||||
|
|
||||
|
@app.delete("/tasks/{task_id}") |
||||
|
def delete_task(task_id: UUID, session=Depends(get_db)): |
||||
|
# Check if task exists |
||||
|
check_query = "SELECT id FROM tasks WHERE id = %s" |
||||
|
existing = session.execute(check_query, (task_id,)).one() |
||||
|
if not existing: |
||||
|
raise HTTPException(status_code=404, detail="Task not found") |
||||
|
|
||||
|
delete_query = "DELETE FROM tasks WHERE id = %s" |
||||
|
session.execute(delete_query, (task_id,)) |
||||
|
return {"ok": True} |
||||
@ -0,0 +1,143 @@ |
|||||
|
from contextlib import asynccontextmanager |
||||
|
from typing import List, Union |
||||
|
from uuid import UUID, uuid4 |
||||
|
|
||||
|
from cassandra.cluster import Cluster |
||||
|
from fastapi import Depends, FastAPI, HTTPException |
||||
|
from pydantic import BaseModel, Field |
||||
|
|
||||
|
|
||||
|
class TaskBase(BaseModel): |
||||
|
title: str |
||||
|
description: Union[str, None] = None |
||||
|
status: str = "pending" |
||||
|
|
||||
|
|
||||
|
class TaskCreate(TaskBase): |
||||
|
pass |
||||
|
|
||||
|
|
||||
|
class Task(TaskBase): |
||||
|
id: UUID = Field(default_factory=uuid4) |
||||
|
|
||||
|
|
||||
|
class ScyllaDBConnection: |
||||
|
def __init__(self, hosts=["scylladb"], port=9042): |
||||
|
self.cluster = Cluster(hosts, port=port) |
||||
|
self.session = None |
||||
|
self.keyspace = "task_manager" |
||||
|
|
||||
|
def connect(self): |
||||
|
self.session = self.cluster.connect() |
||||
|
self.create_keyspace() |
||||
|
self.session.set_keyspace(self.keyspace) |
||||
|
self.create_table() |
||||
|
|
||||
|
def create_keyspace(self): |
||||
|
self.session.execute( |
||||
|
f""" |
||||
|
CREATE KEYSPACE IF NOT EXISTS {self.keyspace} |
||||
|
WITH replication = {{'class': 'SimpleStrategy', 'replication_factor': 1}} |
||||
|
""" |
||||
|
) |
||||
|
|
||||
|
def create_table(self): |
||||
|
self.session.execute( |
||||
|
""" |
||||
|
CREATE TABLE IF NOT EXISTS tasks ( |
||||
|
id uuid PRIMARY KEY, |
||||
|
title text, |
||||
|
description text, |
||||
|
status text, |
||||
|
created_at timestamp, |
||||
|
updated_at timestamp |
||||
|
) |
||||
|
""" |
||||
|
) |
||||
|
|
||||
|
def close(self): |
||||
|
if self.session: |
||||
|
self.session.shutdown() |
||||
|
if self.cluster: |
||||
|
self.cluster.shutdown() |
||||
|
|
||||
|
|
||||
|
db = ScyllaDBConnection() |
||||
|
|
||||
|
|
||||
|
@asynccontextmanager |
||||
|
async def lifespan(app: FastAPI): |
||||
|
# Startup |
||||
|
db.connect() |
||||
|
yield |
||||
|
# Shutdown |
||||
|
db.close() |
||||
|
|
||||
|
|
||||
|
app = FastAPI(lifespan=lifespan) |
||||
|
|
||||
|
|
||||
|
def get_db(): |
||||
|
return db.session |
||||
|
|
||||
|
|
||||
|
@app.post("/tasks/", response_model=Task) |
||||
|
def create_task(task: TaskCreate, session=Depends(get_db)): |
||||
|
task_id = uuid4() |
||||
|
query = """ |
||||
|
INSERT INTO tasks (id, title, description, status, created_at, updated_at) |
||||
|
VALUES (%s, %s, %s, %s, toTimestamp(now()), toTimestamp(now())) |
||||
|
""" |
||||
|
session.execute(query, (task_id, task.title, task.description, task.status)) |
||||
|
return Task(id=task_id, **task.model_dump()) |
||||
|
|
||||
|
|
||||
|
@app.get("/tasks/", response_model=List[Task]) |
||||
|
def read_tasks(session=Depends(get_db)): |
||||
|
query = "SELECT id, title, description, status FROM tasks" |
||||
|
rows = session.execute(query) |
||||
|
return [ |
||||
|
Task(id=row.id, title=row.title, description=row.description, status=row.status) |
||||
|
for row in rows |
||||
|
] |
||||
|
|
||||
|
|
||||
|
@app.get("/tasks/{task_id}", response_model=Task) |
||||
|
def read_task(task_id: UUID, session=Depends(get_db)): |
||||
|
query = "SELECT id, title, description, status FROM tasks WHERE id = %s" |
||||
|
row = session.execute(query, (task_id,)).one() |
||||
|
if not row: |
||||
|
raise HTTPException(status_code=404, detail="Task not found") |
||||
|
return Task(id=row.id, title=row.title, description=row.description, status=row.status) |
||||
|
|
||||
|
|
||||
|
@app.put("/tasks/{task_id}", response_model=Task) |
||||
|
def update_task(task_id: UUID, task: TaskCreate, session=Depends(get_db)): |
||||
|
# Check if task exists |
||||
|
check_query = "SELECT id FROM tasks WHERE id = %s" |
||||
|
existing = session.execute(check_query, (task_id,)).one() |
||||
|
if not existing: |
||||
|
raise HTTPException(status_code=404, detail="Task not found") |
||||
|
|
||||
|
update_query = """ |
||||
|
UPDATE tasks |
||||
|
SET title = %s, description = %s, status = %s, updated_at = toTimestamp(now()) |
||||
|
WHERE id = %s |
||||
|
""" |
||||
|
session.execute( |
||||
|
update_query, (task.title, task.description, task.status, task_id) |
||||
|
) |
||||
|
return Task(id=task_id, **task.model_dump()) |
||||
|
|
||||
|
|
||||
|
@app.delete("/tasks/{task_id}") |
||||
|
def delete_task(task_id: UUID, session=Depends(get_db)): |
||||
|
# Check if task exists |
||||
|
check_query = "SELECT id FROM tasks WHERE id = %s" |
||||
|
existing = session.execute(check_query, (task_id,)).one() |
||||
|
if not existing: |
||||
|
raise HTTPException(status_code=404, detail="Task not found") |
||||
|
|
||||
|
delete_query = "DELETE FROM tasks WHERE id = %s" |
||||
|
session.execute(delete_query, (task_id,)) |
||||
|
return {"ok": True} |
||||
@ -0,0 +1,12 @@ |
|||||
|
# Mock cassandra module to avoid Python 3.14+ import issues |
||||
|
# (asyncore removed in 3.12, cassandra-driver needs event loop) |
||||
|
import sys |
||||
|
from types import ModuleType |
||||
|
from unittest.mock import MagicMock |
||||
|
|
||||
|
mock_cassandra = ModuleType('cassandra') |
||||
|
mock_cassandra_cluster = ModuleType('cassandra.cluster') |
||||
|
mock_cassandra_cluster.Cluster = MagicMock |
||||
|
|
||||
|
sys.modules['cassandra'] = mock_cassandra |
||||
|
sys.modules['cassandra.cluster'] = mock_cassandra_cluster |
||||
@ -0,0 +1,423 @@ |
|||||
|
import importlib |
||||
|
from typing import Any |
||||
|
from unittest.mock import MagicMock, patch |
||||
|
from uuid import UUID |
||||
|
|
||||
|
import pytest |
||||
|
from dirty_equals import IsDict, IsStr, IsUUID |
||||
|
from fastapi.testclient import TestClient |
||||
|
from inline_snapshot import snapshot |
||||
|
|
||||
|
|
||||
|
class MockRow: |
||||
|
def __init__(self, id: UUID, title: str, description: str, status: str): |
||||
|
self.id = id |
||||
|
self.title = title |
||||
|
self.description = description |
||||
|
self.status = status |
||||
|
|
||||
|
|
||||
|
class MockResult: |
||||
|
def __init__(self, rows: list[MockRow]): |
||||
|
self._rows = rows |
||||
|
self._iter = iter(rows) |
||||
|
|
||||
|
def __iter__(self): |
||||
|
return self._iter |
||||
|
|
||||
|
def one(self): |
||||
|
if self._rows: |
||||
|
return self._rows[0] |
||||
|
return None |
||||
|
|
||||
|
|
||||
|
@pytest.fixture( |
||||
|
name="client", |
||||
|
params=["tutorial001", "tutorial001_scylla"], |
||||
|
) |
||||
|
def get_client(request: pytest.FixtureRequest): |
||||
|
mock_session = MagicMock() |
||||
|
mock_cluster_instance = MagicMock() |
||||
|
mock_cluster_instance.connect.return_value = mock_session |
||||
|
|
||||
|
tasks_store: dict[UUID, dict[str, Any]] = {} |
||||
|
|
||||
|
with patch("cassandra.cluster.Cluster") as mock_cluster: |
||||
|
mock_cluster.return_value = mock_cluster_instance |
||||
|
|
||||
|
def mock_execute(query: str, params: tuple = ()): |
||||
|
if "DELETE FROM tasks" in query: |
||||
|
task_id = params[0] |
||||
|
if task_id in tasks_store: |
||||
|
del tasks_store[task_id] |
||||
|
return None |
||||
|
|
||||
|
if "UPDATE tasks" in query: |
||||
|
title, description, status, task_id = params |
||||
|
if task_id in tasks_store: |
||||
|
tasks_store[task_id].update( |
||||
|
{"title": title, "description": description, "status": status} |
||||
|
) |
||||
|
return None |
||||
|
return None |
||||
|
|
||||
|
if "INSERT INTO tasks" in query: |
||||
|
task_id, title, description, status = params |
||||
|
tasks_store[task_id] = { |
||||
|
"id": task_id, |
||||
|
"title": title, |
||||
|
"description": description, |
||||
|
"status": status, |
||||
|
} |
||||
|
return None |
||||
|
|
||||
|
if "SELECT" in query and "WHERE" not in query: |
||||
|
rows = [ |
||||
|
MockRow( |
||||
|
id=task["id"], |
||||
|
title=task["title"], |
||||
|
description=task["description"], |
||||
|
status=task["status"], |
||||
|
) |
||||
|
for task in tasks_store.values() |
||||
|
] |
||||
|
return MockResult(rows) |
||||
|
|
||||
|
if "SELECT" in query and "WHERE id = %s" in query: |
||||
|
task_id = params[0] |
||||
|
if task_id in tasks_store: |
||||
|
task = tasks_store[task_id] |
||||
|
return MockResult( |
||||
|
[ |
||||
|
MockRow( |
||||
|
id=task["id"], |
||||
|
title=task["title"], |
||||
|
description=task["description"], |
||||
|
status=task["status"], |
||||
|
) |
||||
|
] |
||||
|
) |
||||
|
return MockResult([]) |
||||
|
|
||||
|
return None |
||||
|
|
||||
|
mock_session.execute.side_effect = mock_execute |
||||
|
|
||||
|
mod = importlib.import_module(f"docs_src.nosql_databases.{request.param}") |
||||
|
importlib.reload(mod) |
||||
|
|
||||
|
with TestClient(mod.app) as c: |
||||
|
yield c |
||||
|
|
||||
|
|
||||
|
def test_crud_app(client: TestClient): |
||||
|
response = client.get("/tasks/") |
||||
|
assert response.status_code == 200, response.text |
||||
|
assert response.json() == [] |
||||
|
|
||||
|
response = client.post( |
||||
|
"/tasks/", |
||||
|
json={ |
||||
|
"title": "Buy groceries", |
||||
|
"description": "Milk, eggs, bread", |
||||
|
"status": "pending", |
||||
|
}, |
||||
|
) |
||||
|
assert response.status_code == 200, response.text |
||||
|
data = response.json() |
||||
|
assert "id" in data |
||||
|
assert data["title"] == "Buy groceries" |
||||
|
assert data["description"] == "Milk, eggs, bread" |
||||
|
assert data["status"] == "pending" |
||||
|
|
||||
|
task_id = data["id"] |
||||
|
response = client.get(f"/tasks/{task_id}") |
||||
|
assert response.status_code == 200, response.text |
||||
|
assert response.json() == snapshot( |
||||
|
{ |
||||
|
"id": IsUUID(4), |
||||
|
"title": "Buy groceries", |
||||
|
"description": "Milk, eggs, bread", |
||||
|
"status": "pending", |
||||
|
} |
||||
|
) |
||||
|
|
||||
|
response = client.post( |
||||
|
"/tasks/", |
||||
|
json={"title": "Walk the dog", "description": "In the park", "status": "pending"}, |
||||
|
) |
||||
|
assert response.status_code == 200, response.text |
||||
|
|
||||
|
response = client.post( |
||||
|
"/tasks/", |
||||
|
json={"title": "Write code", "description": None, "status": "in_progress"}, |
||||
|
) |
||||
|
assert response.status_code == 200, response.text |
||||
|
|
||||
|
response = client.get("/tasks/") |
||||
|
assert response.status_code == 200, response.text |
||||
|
assert len(response.json()) == 3 |
||||
|
|
||||
|
response = client.put( |
||||
|
f"/tasks/{task_id}", |
||||
|
json={ |
||||
|
"title": "Buy groceries (Updated)", |
||||
|
"description": "Milk, eggs, bread, cheese", |
||||
|
"status": "completed", |
||||
|
}, |
||||
|
) |
||||
|
assert response.status_code == 200, response.text |
||||
|
assert response.json() == snapshot( |
||||
|
{ |
||||
|
"id": IsUUID(4), |
||||
|
"title": "Buy groceries (Updated)", |
||||
|
"description": "Milk, eggs, bread, cheese", |
||||
|
"status": "completed", |
||||
|
} |
||||
|
) |
||||
|
|
||||
|
response = client.delete(f"/tasks/{task_id}") |
||||
|
assert response.status_code == 200, response.text |
||||
|
assert response.json() == snapshot({"ok": True}) |
||||
|
|
||||
|
response = client.get(f"/tasks/{task_id}") |
||||
|
assert response.status_code == 404, response.text |
||||
|
|
||||
|
response = client.delete(f"/tasks/{task_id}") |
||||
|
assert response.status_code == 404, response.text |
||||
|
assert response.json() == snapshot({"detail": "Task not found"}) |
||||
|
|
||||
|
|
||||
|
def test_openapi_schema(client: TestClient): |
||||
|
response = client.get("/openapi.json") |
||||
|
assert response.status_code == 200, response.text |
||||
|
assert response.json() == snapshot( |
||||
|
{ |
||||
|
"openapi": "3.1.0", |
||||
|
"info": {"title": "FastAPI", "version": "0.1.0"}, |
||||
|
"paths": { |
||||
|
"/tasks/": { |
||||
|
"post": { |
||||
|
"summary": "Create Task", |
||||
|
"operationId": "create_task_tasks__post", |
||||
|
"requestBody": { |
||||
|
"required": True, |
||||
|
"content": { |
||||
|
"application/json": { |
||||
|
"schema": {"$ref": "#/components/schemas/TaskCreate"} |
||||
|
} |
||||
|
}, |
||||
|
}, |
||||
|
"responses": { |
||||
|
"200": { |
||||
|
"description": "Successful Response", |
||||
|
"content": { |
||||
|
"application/json": { |
||||
|
"schema": {"$ref": "#/components/schemas/Task"} |
||||
|
} |
||||
|
}, |
||||
|
}, |
||||
|
"422": { |
||||
|
"description": "Validation Error", |
||||
|
"content": { |
||||
|
"application/json": { |
||||
|
"schema": { |
||||
|
"$ref": "#/components/schemas/HTTPValidationError" |
||||
|
} |
||||
|
} |
||||
|
}, |
||||
|
}, |
||||
|
}, |
||||
|
}, |
||||
|
"get": { |
||||
|
"summary": "Read Tasks", |
||||
|
"operationId": "read_tasks_tasks__get", |
||||
|
"responses": { |
||||
|
"200": { |
||||
|
"description": "Successful Response", |
||||
|
"content": { |
||||
|
"application/json": { |
||||
|
"schema": { |
||||
|
"type": "array", |
||||
|
"items": {"$ref": "#/components/schemas/Task"}, |
||||
|
"title": "Response Read Tasks Tasks Get", |
||||
|
} |
||||
|
} |
||||
|
}, |
||||
|
} |
||||
|
}, |
||||
|
}, |
||||
|
}, |
||||
|
"/tasks/{task_id}": { |
||||
|
"get": { |
||||
|
"summary": "Read Task", |
||||
|
"operationId": "read_task_tasks__task_id__get", |
||||
|
"parameters": [ |
||||
|
{ |
||||
|
"name": "task_id", |
||||
|
"in": "path", |
||||
|
"required": True, |
||||
|
"schema": {"type": "string", "format": "uuid", "title": "Task Id"}, |
||||
|
} |
||||
|
], |
||||
|
"responses": { |
||||
|
"200": { |
||||
|
"description": "Successful Response", |
||||
|
"content": { |
||||
|
"application/json": { |
||||
|
"schema": {"$ref": "#/components/schemas/Task"} |
||||
|
} |
||||
|
}, |
||||
|
}, |
||||
|
"422": { |
||||
|
"description": "Validation Error", |
||||
|
"content": { |
||||
|
"application/json": { |
||||
|
"schema": { |
||||
|
"$ref": "#/components/schemas/HTTPValidationError" |
||||
|
} |
||||
|
} |
||||
|
}, |
||||
|
}, |
||||
|
}, |
||||
|
}, |
||||
|
"put": { |
||||
|
"summary": "Update Task", |
||||
|
"operationId": "update_task_tasks__task_id__put", |
||||
|
"parameters": [ |
||||
|
{ |
||||
|
"name": "task_id", |
||||
|
"in": "path", |
||||
|
"required": True, |
||||
|
"schema": {"type": "string", "format": "uuid", "title": "Task Id"}, |
||||
|
} |
||||
|
], |
||||
|
"requestBody": { |
||||
|
"required": True, |
||||
|
"content": { |
||||
|
"application/json": { |
||||
|
"schema": {"$ref": "#/components/schemas/TaskCreate"} |
||||
|
} |
||||
|
}, |
||||
|
}, |
||||
|
"responses": { |
||||
|
"200": { |
||||
|
"description": "Successful Response", |
||||
|
"content": { |
||||
|
"application/json": { |
||||
|
"schema": {"$ref": "#/components/schemas/Task"} |
||||
|
} |
||||
|
}, |
||||
|
}, |
||||
|
"422": { |
||||
|
"description": "Validation Error", |
||||
|
"content": { |
||||
|
"application/json": { |
||||
|
"schema": { |
||||
|
"$ref": "#/components/schemas/HTTPValidationError" |
||||
|
} |
||||
|
} |
||||
|
}, |
||||
|
}, |
||||
|
}, |
||||
|
}, |
||||
|
"delete": { |
||||
|
"summary": "Delete Task", |
||||
|
"operationId": "delete_task_tasks__task_id__delete", |
||||
|
"parameters": [ |
||||
|
{ |
||||
|
"name": "task_id", |
||||
|
"in": "path", |
||||
|
"required": True, |
||||
|
"schema": {"type": "string", "format": "uuid", "title": "Task Id"}, |
||||
|
} |
||||
|
], |
||||
|
"responses": { |
||||
|
"200": { |
||||
|
"description": "Successful Response", |
||||
|
"content": {"application/json": {"schema": {}}}, |
||||
|
}, |
||||
|
"422": { |
||||
|
"description": "Validation Error", |
||||
|
"content": { |
||||
|
"application/json": { |
||||
|
"schema": { |
||||
|
"$ref": "#/components/schemas/HTTPValidationError" |
||||
|
} |
||||
|
} |
||||
|
}, |
||||
|
}, |
||||
|
}, |
||||
|
}, |
||||
|
}, |
||||
|
}, |
||||
|
"components": { |
||||
|
"schemas": { |
||||
|
"HTTPValidationError": { |
||||
|
"properties": { |
||||
|
"detail": { |
||||
|
"items": {"$ref": "#/components/schemas/ValidationError"}, |
||||
|
"type": "array", |
||||
|
"title": "Detail", |
||||
|
} |
||||
|
}, |
||||
|
"type": "object", |
||||
|
"title": "HTTPValidationError", |
||||
|
}, |
||||
|
"Task": { |
||||
|
"properties": { |
||||
|
"title": {"type": "string", "title": "Title"}, |
||||
|
"description": IsDict( |
||||
|
{ |
||||
|
"anyOf": [{"type": "string"}, {"type": "null"}], |
||||
|
"title": "Description", |
||||
|
} |
||||
|
) |
||||
|
| IsDict( |
||||
|
# TODO: remove when deprecating Pydantic v1 |
||||
|
{"type": "string", "title": "Description"} |
||||
|
), |
||||
|
"status": {"type": "string", "default": "pending", "title": "Status"}, |
||||
|
"id": {"type": "string", "format": "uuid", "title": "Id"}, |
||||
|
}, |
||||
|
"type": "object", |
||||
|
"required": ["title"], |
||||
|
"title": "Task", |
||||
|
}, |
||||
|
"TaskCreate": { |
||||
|
"properties": { |
||||
|
"title": {"type": "string", "title": "Title"}, |
||||
|
"description": IsDict( |
||||
|
{ |
||||
|
"anyOf": [{"type": "string"}, {"type": "null"}], |
||||
|
"title": "Description", |
||||
|
} |
||||
|
) |
||||
|
| IsDict( |
||||
|
# TODO: remove when deprecating Pydantic v1 |
||||
|
{"type": "string", "title": "Description"} |
||||
|
), |
||||
|
"status": {"type": "string", "default": "pending", "title": "Status"}, |
||||
|
}, |
||||
|
"type": "object", |
||||
|
"required": ["title"], |
||||
|
"title": "TaskCreate", |
||||
|
}, |
||||
|
"ValidationError": { |
||||
|
"properties": { |
||||
|
"loc": { |
||||
|
"items": {"anyOf": [{"type": "string"}, {"type": "integer"}]}, |
||||
|
"type": "array", |
||||
|
"title": "Location", |
||||
|
}, |
||||
|
"msg": {"type": "string", "title": "Message"}, |
||||
|
"type": {"type": "string", "title": "Error Type"}, |
||||
|
}, |
||||
|
"type": "object", |
||||
|
"required": ["loc", "msg", "type"], |
||||
|
"title": "ValidationError", |
||||
|
}, |
||||
|
} |
||||
|
}, |
||||
|
} |
||||
|
) |
||||
Loading…
Reference in new issue