Skip to main content

crud_api/
crud_api.rs

1//! CRUD API Example - In-Memory User Management
2//!
3//! This example demonstrates a full CRUD (Create, Read, Update, Delete) API:
4//! - `POST /users` - Create a new user
5//! - `GET /users` - List all users
6//! - `GET /users/{id}` - Get a user by ID
7//! - `PUT /users/{id}` - Update a user by ID
8//! - `DELETE /users/{id}` - Delete a user by ID
9//!
10//! Features demonstrated:
11//! - In-memory storage with `Mutex<HashMap>`
12//! - JSON request/response handling
13//! - Path parameter extraction (manual)
14//! - Proper HTTP status codes (200, 201, 204, 404, 400, 415)
15//! - Input validation
16//! - Error responses matching FastAPI style
17//!
18//! # Running This Example
19//!
20//! ```bash
21//! cargo run --example crud_api -p fastapi
22//! ```
23//!
24//! # Equivalent curl Commands
25//!
26//! ```bash
27//! # Create a user
28//! curl -X POST http://localhost:8000/users \
29//!   -H "Content-Type: application/json" \
30//!   -d '{"name": "Alice", "email": "alice@example.com"}'
31//!
32//! # List all users
33//! curl http://localhost:8000/users
34//!
35//! # Get a user by ID
36//! curl http://localhost:8000/users/1
37//!
38//! # Update a user
39//! curl -X PUT http://localhost:8000/users/1 \
40//!   -H "Content-Type: application/json" \
41//!   -d '{"name": "Alice Smith", "email": "alice.smith@example.com"}'
42//!
43//! # Delete a user
44//! curl -X DELETE http://localhost:8000/users/1
45//! ```
46
47use fastapi::core::{
48    App, Body, Request, RequestContext, Response, ResponseBody, StatusCode, TestClient,
49};
50use serde::{Deserialize, Serialize};
51use std::collections::HashMap;
52use std::sync::Mutex;
53
54// ============================================================================
55// Models
56// ============================================================================
57
58#[derive(Debug, Clone, Deserialize)]
59struct UserInput {
60    name: String,
61    email: String,
62}
63
64#[derive(Debug, Clone, Serialize)]
65struct User {
66    id: u64,
67    name: String,
68    email: String,
69}
70
71struct UserDb {
72    users: HashMap<u64, User>,
73    next_id: u64,
74}
75
76// Global in-memory store. In a real app, you would use State<T> with
77// a database connection pool instead.
78static STORE: Mutex<Option<UserDb>> = Mutex::new(None);
79
80fn with_db<R>(f: impl FnOnce(&mut UserDb) -> R) -> R {
81    let mut guard = STORE.lock().unwrap();
82    let db = guard.as_mut().expect("store initialized");
83    f(db)
84}
85
86// ============================================================================
87// Helpers
88// ============================================================================
89
90fn parse_json_body<T: serde::de::DeserializeOwned>(req: &mut Request) -> Result<T, Response> {
91    let is_json = req
92        .headers()
93        .get("content-type")
94        .is_some_and(|ct| ct.starts_with(b"application/json"));
95
96    if !is_json {
97        return Err(json_error(
98            StatusCode::UNSUPPORTED_MEDIA_TYPE,
99            "Content-Type must be application/json",
100        ));
101    }
102
103    let Body::Bytes(body) = req.take_body() else {
104        return Err(json_error(StatusCode::BAD_REQUEST, "Missing request body"));
105    };
106    serde_json::from_slice(&body)
107        .map_err(|e| json_error(StatusCode::BAD_REQUEST, &format!("Invalid JSON: {e}")))
108}
109
110fn extract_user_id(req: &Request) -> Result<u64, Response> {
111    let path = req.path();
112    let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
113    segments
114        .get(1)
115        .and_then(|s| s.parse::<u64>().ok())
116        .ok_or_else(|| json_error(StatusCode::BAD_REQUEST, "Invalid user ID"))
117}
118
119fn json_error(status: StatusCode, detail: &str) -> Response {
120    let body = serde_json::json!({ "detail": detail });
121    Response::with_status(status)
122        .header("content-type", b"application/json".to_vec())
123        .body(ResponseBody::Bytes(body.to_string().into_bytes()))
124}
125
126fn json_response(status: StatusCode, value: &impl Serialize) -> Response {
127    Response::with_status(status)
128        .header("content-type", b"application/json".to_vec())
129        .body(ResponseBody::Bytes(
130            serde_json::to_string(value).unwrap().into_bytes(),
131        ))
132}
133
134fn validate_input(input: &UserInput) -> Option<Response> {
135    if input.name.trim().is_empty() {
136        return Some(json_error(
137            StatusCode::BAD_REQUEST,
138            "name must not be empty",
139        ));
140    }
141    if !input.email.contains('@') {
142        return Some(json_error(
143            StatusCode::BAD_REQUEST,
144            "email must contain '@'",
145        ));
146    }
147    None
148}
149
150// ============================================================================
151// Handlers
152// ============================================================================
153
154fn create_user(_ctx: &RequestContext, req: &mut Request) -> std::future::Ready<Response> {
155    let input = match parse_json_body::<UserInput>(req) {
156        Ok(v) => v,
157        Err(r) => return std::future::ready(r),
158    };
159    if let Some(r) = validate_input(&input) {
160        return std::future::ready(r);
161    }
162
163    let user = with_db(|db| {
164        let id = db.next_id;
165        db.next_id += 1;
166        let user = User {
167            id,
168            name: input.name,
169            email: input.email,
170        };
171        db.users.insert(id, user.clone());
172        user
173    });
174
175    std::future::ready(json_response(StatusCode::CREATED, &user))
176}
177
178fn list_users(_ctx: &RequestContext, _req: &mut Request) -> std::future::Ready<Response> {
179    let users = with_db(|db| {
180        let mut v: Vec<User> = db.users.values().cloned().collect();
181        v.sort_by_key(|u| u.id);
182        v
183    });
184    std::future::ready(json_response(StatusCode::OK, &users))
185}
186
187fn get_user(_ctx: &RequestContext, req: &mut Request) -> std::future::Ready<Response> {
188    let id = match extract_user_id(req) {
189        Ok(id) => id,
190        Err(r) => return std::future::ready(r),
191    };
192    let result = with_db(|db| db.users.get(&id).cloned());
193    match result {
194        Some(user) => std::future::ready(json_response(StatusCode::OK, &user)),
195        None => std::future::ready(json_error(
196            StatusCode::NOT_FOUND,
197            &format!("User {id} not found"),
198        )),
199    }
200}
201
202fn update_user(_ctx: &RequestContext, req: &mut Request) -> std::future::Ready<Response> {
203    let id = match extract_user_id(req) {
204        Ok(id) => id,
205        Err(r) => return std::future::ready(r),
206    };
207    let input = match parse_json_body::<UserInput>(req) {
208        Ok(v) => v,
209        Err(r) => return std::future::ready(r),
210    };
211    if let Some(r) = validate_input(&input) {
212        return std::future::ready(r);
213    }
214
215    let result = with_db(|db| {
216        db.users.get_mut(&id).map(|user| {
217            user.name = input.name;
218            user.email = input.email;
219            user.clone()
220        })
221    });
222    match result {
223        Some(user) => std::future::ready(json_response(StatusCode::OK, &user)),
224        None => std::future::ready(json_error(
225            StatusCode::NOT_FOUND,
226            &format!("User {id} not found"),
227        )),
228    }
229}
230
231fn delete_user(_ctx: &RequestContext, req: &mut Request) -> std::future::Ready<Response> {
232    let id = match extract_user_id(req) {
233        Ok(id) => id,
234        Err(r) => return std::future::ready(r),
235    };
236    let removed = with_db(|db| db.users.remove(&id).is_some());
237    if removed {
238        std::future::ready(Response::with_status(StatusCode::NO_CONTENT))
239    } else {
240        std::future::ready(json_error(
241            StatusCode::NOT_FOUND,
242            &format!("User {id} not found"),
243        ))
244    }
245}
246
247// ============================================================================
248// Main
249// ============================================================================
250
251#[allow(clippy::too_many_lines)]
252fn main() {
253    // Initialize the global store
254    *STORE.lock().unwrap() = Some(UserDb {
255        users: HashMap::new(),
256        next_id: 1,
257    });
258
259    println!("fastapi_rust CRUD API Example");
260    println!("=============================\n");
261
262    let app = App::builder()
263        .post("/users", create_user)
264        .get("/users", list_users)
265        .get("/users/{id}", get_user)
266        .put("/users/{id}", update_user)
267        .delete("/users/{id}", delete_user)
268        .build();
269
270    println!("App created with {} route(s)\n", app.route_count());
271    let client = TestClient::new(app);
272
273    // 1. Create users
274    println!("1. Create users");
275    let resp = client
276        .post("/users")
277        .header("content-type", "application/json")
278        .body(r#"{"name": "Alice", "email": "alice@example.com"}"#)
279        .send();
280    println!(
281        "   POST /users -> {} {}",
282        resp.status().as_u16(),
283        resp.text()
284    );
285    assert_eq!(resp.status().as_u16(), 201);
286
287    let resp = client
288        .post("/users")
289        .header("content-type", "application/json")
290        .body(r#"{"name": "Bob", "email": "bob@example.com"}"#)
291        .send();
292    println!(
293        "   POST /users -> {} {}",
294        resp.status().as_u16(),
295        resp.text()
296    );
297    assert_eq!(resp.status().as_u16(), 201);
298
299    // 2. List users
300    println!("\n2. List all users");
301    let resp = client.get("/users").send();
302    println!(
303        "   GET /users -> {} {}",
304        resp.status().as_u16(),
305        resp.text()
306    );
307    assert_eq!(resp.status().as_u16(), 200);
308
309    // 3. Get user by ID
310    println!("\n3. Get user by ID");
311    let resp = client.get("/users/1").send();
312    println!(
313        "   GET /users/1 -> {} {}",
314        resp.status().as_u16(),
315        resp.text()
316    );
317    assert_eq!(resp.status().as_u16(), 200);
318
319    // 4. Get nonexistent user
320    println!("\n4. Get nonexistent user");
321    let resp = client.get("/users/999").send();
322    println!(
323        "   GET /users/999 -> {} {}",
324        resp.status().as_u16(),
325        resp.text()
326    );
327    assert_eq!(resp.status().as_u16(), 404);
328
329    // 5. Update user
330    println!("\n5. Update user");
331    let resp = client
332        .put("/users/1")
333        .header("content-type", "application/json")
334        .body(r#"{"name": "Alice Smith", "email": "alice.smith@example.com"}"#)
335        .send();
336    println!(
337        "   PUT /users/1 -> {} {}",
338        resp.status().as_u16(),
339        resp.text()
340    );
341    assert_eq!(resp.status().as_u16(), 200);
342
343    // 6. Validation: empty name
344    println!("\n6. Validation error (empty name)");
345    let resp = client
346        .post("/users")
347        .header("content-type", "application/json")
348        .body(r#"{"name": "", "email": "bad@example.com"}"#)
349        .send();
350    println!(
351        "   POST /users -> {} {}",
352        resp.status().as_u16(),
353        resp.text()
354    );
355    assert_eq!(resp.status().as_u16(), 400);
356
357    // 7. Validation: invalid email
358    println!("\n7. Validation error (invalid email)");
359    let resp = client
360        .post("/users")
361        .header("content-type", "application/json")
362        .body(r#"{"name": "Charlie", "email": "not-an-email"}"#)
363        .send();
364    println!(
365        "   POST /users -> {} {}",
366        resp.status().as_u16(),
367        resp.text()
368    );
369    assert_eq!(resp.status().as_u16(), 400);
370
371    // 8. Wrong Content-Type
372    println!("\n8. Wrong Content-Type");
373    let resp = client
374        .post("/users")
375        .header("content-type", "text/plain")
376        .body(r#"{"name": "Dan", "email": "dan@example.com"}"#)
377        .send();
378    println!(
379        "   POST /users -> {} {}",
380        resp.status().as_u16(),
381        resp.text()
382    );
383    assert_eq!(resp.status().as_u16(), 415);
384
385    // 9. Delete user
386    println!("\n9. Delete user");
387    let resp = client.delete("/users/2").send();
388    println!("   DELETE /users/2 -> {}", resp.status().as_u16());
389    assert_eq!(resp.status().as_u16(), 204);
390
391    // 10. Verify deletion
392    println!("\n10. Verify deletion");
393    let resp = client.get("/users/2").send();
394    println!(
395        "   GET /users/2 -> {} {}",
396        resp.status().as_u16(),
397        resp.text()
398    );
399    assert_eq!(resp.status().as_u16(), 404);
400
401    let resp = client.get("/users").send();
402    println!(
403        "   GET /users -> {} {}",
404        resp.status().as_u16(),
405        resp.text()
406    );
407    assert_eq!(resp.status().as_u16(), 200);
408
409    println!("\nAll CRUD operations passed!");
410}