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-rust
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_rust::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, 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
82        .lock()
83        .unwrap_or_else(std::sync::PoisonError::into_inner);
84    let db = guard.get_or_insert_with(|| UserDb {
85        users: HashMap::new(),
86        next_id: 1,
87    });
88    f(db)
89}
90
91// ============================================================================
92// Helpers
93// ============================================================================
94
95fn parse_json_body<T: serde::de::DeserializeOwned>(req: &mut Request) -> Result<T, Response> {
96    let is_json = req
97        .headers()
98        .get("content-type")
99        .is_some_and(|ct| ct.starts_with(b"application/json"));
100
101    if !is_json {
102        return Err(json_error(
103            StatusCode::UNSUPPORTED_MEDIA_TYPE,
104            "Content-Type must be application/json",
105        ));
106    }
107
108    let Body::Bytes(body) = req.take_body() else {
109        return Err(json_error(StatusCode::BAD_REQUEST, "Missing request body"));
110    };
111    serde_json::from_slice(&body)
112        .map_err(|e| json_error(StatusCode::BAD_REQUEST, &format!("Invalid JSON: {e}")))
113}
114
115fn extract_user_id(req: &Request) -> Result<u64, Response> {
116    let path = req.path();
117    let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
118    segments
119        .get(1)
120        .and_then(|s| s.parse::<u64>().ok())
121        .ok_or_else(|| json_error(StatusCode::BAD_REQUEST, "Invalid user ID"))
122}
123
124fn json_error(status: StatusCode, detail: &str) -> Response {
125    let body = serde_json::json!({ "detail": detail });
126    Response::with_status(status)
127        .header("content-type", b"application/json".to_vec())
128        .body(ResponseBody::Bytes(body.to_string().into_bytes()))
129}
130
131fn json_response(status: StatusCode, value: &impl Serialize) -> Response {
132    match serde_json::to_string(value) {
133        Ok(text) => Response::with_status(status)
134            .header("content-type", b"application/json".to_vec())
135            .body(ResponseBody::Bytes(text.into_bytes())),
136        Err(err) => json_error(
137            StatusCode::INTERNAL_SERVER_ERROR,
138            &format!("JSON serialization failed: {err}"),
139        ),
140    }
141}
142
143fn validate_input(input: &UserInput) -> Option<Response> {
144    if input.name.trim().is_empty() {
145        return Some(json_error(
146            StatusCode::BAD_REQUEST,
147            "name must not be empty",
148        ));
149    }
150    if !input.email.contains('@') {
151        return Some(json_error(
152            StatusCode::BAD_REQUEST,
153            "email must contain '@'",
154        ));
155    }
156    None
157}
158
159// ============================================================================
160// Handlers
161// ============================================================================
162
163fn create_user(_ctx: &RequestContext, req: &mut Request) -> std::future::Ready<Response> {
164    let input = match parse_json_body::<UserInput>(req) {
165        Ok(v) => v,
166        Err(r) => return std::future::ready(r),
167    };
168    if let Some(r) = validate_input(&input) {
169        return std::future::ready(r);
170    }
171
172    let user = with_db(|db| {
173        let id = db.next_id;
174        db.next_id += 1;
175        let user = User {
176            id,
177            name: input.name,
178            email: input.email,
179        };
180        db.users.insert(id, user.clone());
181        user
182    });
183
184    std::future::ready(json_response(StatusCode::CREATED, &user))
185}
186
187fn list_users(_ctx: &RequestContext, _req: &mut Request) -> std::future::Ready<Response> {
188    let users = with_db(|db| {
189        let mut v: Vec<User> = db.users.values().cloned().collect();
190        v.sort_by_key(|u| u.id);
191        v
192    });
193    std::future::ready(json_response(StatusCode::OK, &users))
194}
195
196fn get_user(_ctx: &RequestContext, req: &mut Request) -> std::future::Ready<Response> {
197    let id = match extract_user_id(req) {
198        Ok(id) => id,
199        Err(r) => return std::future::ready(r),
200    };
201    let result = with_db(|db| db.users.get(&id).cloned());
202    match result {
203        Some(user) => std::future::ready(json_response(StatusCode::OK, &user)),
204        None => std::future::ready(json_error(
205            StatusCode::NOT_FOUND,
206            &format!("User {id} not found"),
207        )),
208    }
209}
210
211fn update_user(_ctx: &RequestContext, req: &mut Request) -> std::future::Ready<Response> {
212    let id = match extract_user_id(req) {
213        Ok(id) => id,
214        Err(r) => return std::future::ready(r),
215    };
216    let input = match parse_json_body::<UserInput>(req) {
217        Ok(v) => v,
218        Err(r) => return std::future::ready(r),
219    };
220    if let Some(r) = validate_input(&input) {
221        return std::future::ready(r);
222    }
223
224    let result = with_db(|db| {
225        db.users.get_mut(&id).map(|user| {
226            user.name = input.name;
227            user.email = input.email;
228            user.clone()
229        })
230    });
231    match result {
232        Some(user) => std::future::ready(json_response(StatusCode::OK, &user)),
233        None => std::future::ready(json_error(
234            StatusCode::NOT_FOUND,
235            &format!("User {id} not found"),
236        )),
237    }
238}
239
240fn delete_user(_ctx: &RequestContext, req: &mut Request) -> std::future::Ready<Response> {
241    let id = match extract_user_id(req) {
242        Ok(id) => id,
243        Err(r) => return std::future::ready(r),
244    };
245    let removed = with_db(|db| db.users.remove(&id).is_some());
246    if removed {
247        std::future::ready(Response::with_status(StatusCode::NO_CONTENT))
248    } else {
249        std::future::ready(json_error(
250            StatusCode::NOT_FOUND,
251            &format!("User {id} not found"),
252        ))
253    }
254}
255
256// ============================================================================
257// Main
258// ============================================================================
259
260#[allow(clippy::needless_pass_by_value)]
261fn check_eq<T: PartialEq + std::fmt::Debug>(left: T, right: T, message: &str) -> bool {
262    if left == right {
263        true
264    } else {
265        eprintln!("Check failed: {message}. left={left:?} right={right:?}");
266        false
267    }
268}
269
270#[allow(clippy::too_many_lines)]
271fn main() {
272    // Initialize the global store
273    let mut guard = STORE
274        .lock()
275        .unwrap_or_else(std::sync::PoisonError::into_inner);
276    *guard = Some(UserDb {
277        users: HashMap::new(),
278        next_id: 1,
279    });
280
281    println!("fastapi_rust CRUD API Example");
282    println!("=============================\n");
283
284    let app = App::builder()
285        .post("/users", create_user)
286        .get("/users", list_users)
287        .get("/users/{id}", get_user)
288        .put("/users/{id}", update_user)
289        .delete("/users/{id}", delete_user)
290        .build();
291
292    println!("App created with {} route(s)\n", app.route_count());
293    let client = TestClient::new(app);
294
295    // 1. Create users
296    println!("1. Create users");
297    let resp = client
298        .post("/users")
299        .header("content-type", "application/json")
300        .body(r#"{"name": "Alice", "email": "alice@example.com"}"#)
301        .send();
302    println!(
303        "   POST /users -> {} {}",
304        resp.status().as_u16(),
305        resp.text()
306    );
307    if !check_eq(resp.status().as_u16(), 201, "Create user should return 201") {
308        return;
309    }
310
311    let resp = client
312        .post("/users")
313        .header("content-type", "application/json")
314        .body(r#"{"name": "Bob", "email": "bob@example.com"}"#)
315        .send();
316    println!(
317        "   POST /users -> {} {}",
318        resp.status().as_u16(),
319        resp.text()
320    );
321    if !check_eq(resp.status().as_u16(), 201, "Create user should return 201") {
322        return;
323    }
324
325    // 2. List users
326    println!("\n2. List all users");
327    let resp = client.get("/users").send();
328    println!(
329        "   GET /users -> {} {}",
330        resp.status().as_u16(),
331        resp.text()
332    );
333    if !check_eq(resp.status().as_u16(), 200, "List users should return 200") {
334        return;
335    }
336
337    // 3. Get user by ID
338    println!("\n3. Get user by ID");
339    let resp = client.get("/users/1").send();
340    println!(
341        "   GET /users/1 -> {} {}",
342        resp.status().as_u16(),
343        resp.text()
344    );
345    if !check_eq(resp.status().as_u16(), 200, "Get user should return 200") {
346        return;
347    }
348
349    // 4. Get nonexistent user
350    println!("\n4. Get nonexistent user");
351    let resp = client.get("/users/999").send();
352    println!(
353        "   GET /users/999 -> {} {}",
354        resp.status().as_u16(),
355        resp.text()
356    );
357    if !check_eq(
358        resp.status().as_u16(),
359        404,
360        "Missing user should return 404",
361    ) {
362        return;
363    }
364
365    // 5. Update user
366    println!("\n5. Update user");
367    let resp = client
368        .put("/users/1")
369        .header("content-type", "application/json")
370        .body(r#"{"name": "Alice Smith", "email": "alice.smith@example.com"}"#)
371        .send();
372    println!(
373        "   PUT /users/1 -> {} {}",
374        resp.status().as_u16(),
375        resp.text()
376    );
377    if !check_eq(resp.status().as_u16(), 200, "Update user should return 200") {
378        return;
379    }
380
381    // 6. Validation: empty name
382    println!("\n6. Validation error (empty name)");
383    let resp = client
384        .post("/users")
385        .header("content-type", "application/json")
386        .body(r#"{"name": "", "email": "bad@example.com"}"#)
387        .send();
388    println!(
389        "   POST /users -> {} {}",
390        resp.status().as_u16(),
391        resp.text()
392    );
393    if !check_eq(resp.status().as_u16(), 400, "Empty name should return 400") {
394        return;
395    }
396
397    // 7. Validation: invalid email
398    println!("\n7. Validation error (invalid email)");
399    let resp = client
400        .post("/users")
401        .header("content-type", "application/json")
402        .body(r#"{"name": "Charlie", "email": "not-an-email"}"#)
403        .send();
404    println!(
405        "   POST /users -> {} {}",
406        resp.status().as_u16(),
407        resp.text()
408    );
409    if !check_eq(
410        resp.status().as_u16(),
411        400,
412        "Invalid email should return 400",
413    ) {
414        return;
415    }
416
417    // 8. Wrong Content-Type
418    println!("\n8. Wrong Content-Type");
419    let resp = client
420        .post("/users")
421        .header("content-type", "text/plain")
422        .body(r#"{"name": "Dan", "email": "dan@example.com"}"#)
423        .send();
424    println!(
425        "   POST /users -> {} {}",
426        resp.status().as_u16(),
427        resp.text()
428    );
429    if !check_eq(
430        resp.status().as_u16(),
431        415,
432        "Wrong Content-Type should return 415",
433    ) {
434        return;
435    }
436
437    // 9. Delete user
438    println!("\n9. Delete user");
439    let resp = client.delete("/users/2").send();
440    println!("   DELETE /users/2 -> {}", resp.status().as_u16());
441    if !check_eq(resp.status().as_u16(), 204, "Delete user should return 204") {
442        return;
443    }
444
445    // 10. Verify deletion
446    println!("\n10. Verify deletion");
447    let resp = client.get("/users/2").send();
448    println!(
449        "   GET /users/2 -> {} {}",
450        resp.status().as_u16(),
451        resp.text()
452    );
453    if !check_eq(
454        resp.status().as_u16(),
455        404,
456        "Deleted user should return 404",
457    ) {
458        return;
459    }
460
461    let resp = client.get("/users").send();
462    println!(
463        "   GET /users -> {} {}",
464        resp.status().as_u16(),
465        resp.text()
466    );
467    if !check_eq(resp.status().as_u16(), 200, "List users should return 200") {
468        return;
469    }
470
471    println!("\nAll CRUD operations passed!");
472}