feat: courses and students CRUD endpoints

This commit is contained in:
2026-04-28 01:42:22 +02:00
parent a351c442d3
commit abf0ebcce2
3 changed files with 268 additions and 1 deletions

View File

@@ -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"] }

View 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);
}
}

View File

@@ -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)
}