feat: courses and students CRUD endpoints
This commit is contained in:
@@ -5,7 +5,7 @@ edition = "2024"
|
||||
rust-version = "1.95.0"
|
||||
|
||||
[dependencies]
|
||||
axum = { version = "0.8", features = ["macros"] }
|
||||
axum = { version = "0.8", features = ["macros", "multipart"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio", "macros", "migrate"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
265
backend/src/routes/courses.rs
Normal file
265
backend/src/routes/courses.rs
Normal file
@@ -0,0 +1,265 @@
|
||||
use axum::{
|
||||
extract::{Multipart, Path, State},
|
||||
http::StatusCode,
|
||||
routing::{delete, get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::{
|
||||
auth::TutorClaims,
|
||||
error::AppError,
|
||||
models::{Course, CreateCourse, CreateStudent, Student},
|
||||
};
|
||||
|
||||
async fn list_courses(
|
||||
_claims: TutorClaims,
|
||||
State(pool): State<SqlitePool>,
|
||||
) -> Result<Json<Vec<Course>>, AppError> {
|
||||
let courses = sqlx::query_as::<_, Course>("SELECT id, name, semester FROM courses ORDER BY id")
|
||||
.fetch_all(&pool)
|
||||
.await?;
|
||||
Ok(Json(courses))
|
||||
}
|
||||
|
||||
async fn create_course(
|
||||
_claims: TutorClaims,
|
||||
State(pool): State<SqlitePool>,
|
||||
Json(req): Json<CreateCourse>,
|
||||
) -> Result<(StatusCode, Json<Value>), AppError> {
|
||||
let id = sqlx::query("INSERT INTO courses (name, semester) VALUES (?, ?)")
|
||||
.bind(&req.name)
|
||||
.bind(&req.semester)
|
||||
.execute(&pool)
|
||||
.await?
|
||||
.last_insert_rowid();
|
||||
Ok((StatusCode::CREATED, Json(json!({"id": id, "name": req.name, "semester": req.semester}))))
|
||||
}
|
||||
|
||||
async fn list_students(
|
||||
_claims: TutorClaims,
|
||||
State(pool): State<SqlitePool>,
|
||||
Path(course_id): Path<i64>,
|
||||
) -> Result<Json<Vec<Student>>, AppError> {
|
||||
let students = sqlx::query_as::<_, Student>(
|
||||
"SELECT id, course_id, name FROM students WHERE course_id = ? ORDER BY id",
|
||||
)
|
||||
.bind(course_id)
|
||||
.fetch_all(&pool)
|
||||
.await?;
|
||||
Ok(Json(students))
|
||||
}
|
||||
|
||||
async fn add_student(
|
||||
_claims: TutorClaims,
|
||||
State(pool): State<SqlitePool>,
|
||||
Path(course_id): Path<i64>,
|
||||
Json(req): Json<CreateStudent>,
|
||||
) -> Result<(StatusCode, Json<Value>), AppError> {
|
||||
let id = sqlx::query("INSERT INTO students (course_id, name) VALUES (?, ?)")
|
||||
.bind(course_id)
|
||||
.bind(&req.name)
|
||||
.execute(&pool)
|
||||
.await?
|
||||
.last_insert_rowid();
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(json!({"id": id, "name": req.name, "course_id": course_id})),
|
||||
))
|
||||
}
|
||||
|
||||
async fn import_students(
|
||||
_claims: TutorClaims,
|
||||
State(pool): State<SqlitePool>,
|
||||
Path(course_id): Path<i64>,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<(StatusCode, Json<Value>), AppError> {
|
||||
let mut count = 0i64;
|
||||
|
||||
while let Some(field) = multipart.next_field().await.map_err(|e| {
|
||||
AppError::BadRequest(format!("multipart error: {e}"))
|
||||
})? {
|
||||
let text = field.text().await.map_err(|e| {
|
||||
AppError::BadRequest(format!("field read error: {e}"))
|
||||
})?;
|
||||
|
||||
let mut lines = text.lines();
|
||||
// Skip header row
|
||||
lines.next();
|
||||
|
||||
for line in lines {
|
||||
let name = line.trim();
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
sqlx::query("INSERT INTO students (course_id, name) VALUES (?, ?)")
|
||||
.bind(course_id)
|
||||
.bind(name)
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
Ok((StatusCode::CREATED, Json(json!({"imported": count}))))
|
||||
}
|
||||
|
||||
async fn delete_student(
|
||||
_claims: TutorClaims,
|
||||
State(pool): State<SqlitePool>,
|
||||
Path(student_id): Path<i64>,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
let result = sqlx::query("DELETE FROM students WHERE id = ?")
|
||||
.bind(student_id)
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub fn router() -> Router<SqlitePool> {
|
||||
Router::new()
|
||||
.route("/api/admin/courses", get(list_courses).post(create_course))
|
||||
.route(
|
||||
"/api/admin/courses/{id}/students",
|
||||
get(list_students).post(add_student),
|
||||
)
|
||||
.route("/api/admin/courses/{id}/students/import", post(import_students))
|
||||
.route("/api/admin/students/{id}", delete(delete_student))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_helpers::{build_test_app, get, post_json};
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn create_and_list_courses(pool: SqlitePool) {
|
||||
let (app, auth) = build_test_app(pool.clone()).await;
|
||||
let (status, body) = post_json(
|
||||
app.clone(),
|
||||
"/api/admin/courses",
|
||||
&auth,
|
||||
json!({"name":"FP","semester":"SS2026"}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::CREATED);
|
||||
let id = serde_json::from_slice::<Value>(&body).unwrap()["id"]
|
||||
.as_i64()
|
||||
.unwrap();
|
||||
|
||||
let (status, body) = get(app, "/api/admin/courses", &auth).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(serde_json::from_slice::<Value>(&body)
|
||||
.unwrap()
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|c| c["id"] == id));
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn create_and_list_students(pool: SqlitePool) {
|
||||
let (app, auth) = build_test_app(pool.clone()).await;
|
||||
// Create course
|
||||
let (_, body) = post_json(
|
||||
app.clone(),
|
||||
"/api/admin/courses",
|
||||
&auth,
|
||||
json!({"name":"FP","semester":"SS2026"}),
|
||||
)
|
||||
.await;
|
||||
let course_id = serde_json::from_slice::<Value>(&body).unwrap()["id"]
|
||||
.as_i64()
|
||||
.unwrap();
|
||||
|
||||
// Add student
|
||||
let path = format!("/api/admin/courses/{course_id}/students");
|
||||
let (status, body) =
|
||||
post_json(app.clone(), &path, &auth, json!({"name":"Alice"})).await;
|
||||
assert_eq!(status, StatusCode::CREATED);
|
||||
let student_id = serde_json::from_slice::<Value>(&body).unwrap()["id"]
|
||||
.as_i64()
|
||||
.unwrap();
|
||||
|
||||
// List students
|
||||
let (status, body) = get(app, &path, &auth).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(serde_json::from_slice::<Value>(&body)
|
||||
.unwrap()
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|s| s["id"] == student_id));
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn create_course_requires_auth(pool: SqlitePool) {
|
||||
let (app, _) = build_test_app(pool.clone()).await;
|
||||
let (status, _) = post_json(
|
||||
app,
|
||||
"/api/admin/courses",
|
||||
"",
|
||||
json!({"name":"FP","semester":"SS2026"}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn delete_student_returns_204(pool: SqlitePool) {
|
||||
let (app, auth) = build_test_app(pool.clone()).await;
|
||||
|
||||
// Create course and student
|
||||
let (_, body) = post_json(
|
||||
app.clone(),
|
||||
"/api/admin/courses",
|
||||
&auth,
|
||||
json!({"name":"FP","semester":"SS2026"}),
|
||||
)
|
||||
.await;
|
||||
let course_id = serde_json::from_slice::<Value>(&body).unwrap()["id"]
|
||||
.as_i64()
|
||||
.unwrap();
|
||||
|
||||
let path = format!("/api/admin/courses/{course_id}/students");
|
||||
let (_, body) = post_json(app.clone(), &path, &auth, json!({"name":"Bob"})).await;
|
||||
let student_id = serde_json::from_slice::<Value>(&body).unwrap()["id"]
|
||||
.as_i64()
|
||||
.unwrap();
|
||||
|
||||
// Delete student
|
||||
use tower::ServiceExt;
|
||||
use axum::http::Request;
|
||||
let req = Request::builder()
|
||||
.method("DELETE")
|
||||
.uri(format!("/api/admin/students/{student_id}"))
|
||||
.header("Authorization", &auth)
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap();
|
||||
let res = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(res.status(), StatusCode::NO_CONTENT);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn delete_student_not_found(pool: SqlitePool) {
|
||||
let (app, auth) = build_test_app(pool.clone()).await;
|
||||
|
||||
use tower::ServiceExt;
|
||||
use axum::http::Request;
|
||||
let req = Request::builder()
|
||||
.method("DELETE")
|
||||
.uri("/api/admin/students/9999")
|
||||
.header("Authorization", &auth)
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap();
|
||||
let res = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(res.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,11 @@ use axum::Router;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
mod auth_routes;
|
||||
mod courses;
|
||||
|
||||
pub fn build(pool: SqlitePool) -> Router {
|
||||
Router::new()
|
||||
.merge(auth_routes::router())
|
||||
.merge(courses::router())
|
||||
.with_state(pool)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user