jj workspaces over the network
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(server): add REST API - repos, changes, bookmarks endpoints

+280
+55
crates/tandem-server/src/handlers/bookmarks.rs
··· 1 + use axum::{extract::{Path, State}, http::StatusCode, Json}; 2 + use serde::{Deserialize, Serialize}; 3 + use crate::{AppState, auth::AuthenticatedUser, authz}; 4 + 5 + #[derive(Serialize)] 6 + pub struct BookmarkResponse { 7 + pub name: String, 8 + pub target: String, 9 + } 10 + 11 + #[derive(Deserialize)] 12 + pub struct MoveBookmarkRequest { 13 + pub name: String, 14 + pub target: String, 15 + } 16 + 17 + pub async fn list_bookmarks( 18 + State(state): State<AppState>, 19 + Path(repo_id): Path<String>, 20 + user: AuthenticatedUser, 21 + ) -> Result<Json<Vec<BookmarkResponse>>, StatusCode> { 22 + authz::require_read(&state, &user, &repo_id).await?; 23 + 24 + let doc = state.docs.get_or_load(&repo_id).await 25 + .map_err(|_| StatusCode::NOT_FOUND)?; 26 + 27 + let doc = doc.read().await; 28 + let bookmarks = doc.get_all_bookmarks(); 29 + 30 + Ok(Json(bookmarks.into_iter().map(|(name, target)| BookmarkResponse { 31 + name, 32 + target: target.to_string(), 33 + }).collect())) 34 + } 35 + 36 + pub async fn move_bookmark( 37 + State(state): State<AppState>, 38 + Path(repo_id): Path<String>, 39 + user: AuthenticatedUser, 40 + Json(req): Json<MoveBookmarkRequest>, 41 + ) -> Result<StatusCode, StatusCode> { 42 + authz::can_move_bookmark(&state, &user, &repo_id, &req.name).await?; 43 + 44 + let doc = state.docs.get_or_load(&repo_id).await 45 + .map_err(|_| StatusCode::NOT_FOUND)?; 46 + 47 + // Parse target change_id 48 + let target: tandem_core::types::ChangeId = req.target.parse() 49 + .map_err(|_| StatusCode::BAD_REQUEST)?; 50 + 51 + let doc = doc.read().await; 52 + doc.set_bookmark(&req.name, &target); 53 + 54 + Ok(StatusCode::OK) 55 + }
+90
crates/tandem-server/src/handlers/changes.rs
··· 1 + use axum::{extract::{Path, State}, http::StatusCode, Json}; 2 + use serde::Serialize; 3 + use std::collections::HashMap; 4 + use crate::{AppState, auth::AuthenticatedUser, authz}; 5 + 6 + #[derive(Serialize)] 7 + pub struct ChangeResponse { 8 + pub change_id: String, 9 + pub tree: String, 10 + pub parents: Vec<String>, 11 + pub description: String, 12 + pub author_email: String, 13 + pub author_name: Option<String>, 14 + pub timestamp: String, 15 + pub divergent: bool, 16 + } 17 + 18 + pub async fn list_changes( 19 + State(state): State<AppState>, 20 + Path(repo_id): Path<String>, 21 + user: AuthenticatedUser, 22 + ) -> Result<Json<Vec<ChangeResponse>>, StatusCode> { 23 + authz::require_read(&state, &user, &repo_id).await?; 24 + 25 + let doc = state.docs.get_or_load(&repo_id).await 26 + .map_err(|_| StatusCode::NOT_FOUND)?; 27 + 28 + let doc = doc.read().await; 29 + let records = doc.get_all_change_records(); 30 + 31 + // Group by change_id to detect divergence 32 + let mut by_id: HashMap<String, Vec<_>> = HashMap::new(); 33 + for record in records { 34 + if record.visible { 35 + by_id.entry(record.change_id.to_string()) 36 + .or_default() 37 + .push(record); 38 + } 39 + } 40 + 41 + let changes: Vec<ChangeResponse> = by_id.into_iter().map(|(id, records)| { 42 + let record = &records[0]; 43 + ChangeResponse { 44 + change_id: id, 45 + tree: record.tree.to_string(), 46 + parents: record.parents.iter().map(|p| p.to_string()).collect(), 47 + description: record.description.clone(), 48 + author_email: record.author.email.clone(), 49 + author_name: record.author.name.clone(), 50 + timestamp: record.timestamp.to_rfc3339(), 51 + divergent: records.len() > 1, 52 + } 53 + }).collect(); 54 + 55 + Ok(Json(changes)) 56 + } 57 + 58 + pub async fn get_change( 59 + State(state): State<AppState>, 60 + Path((repo_id, change_id)): Path<(String, String)>, 61 + user: AuthenticatedUser, 62 + ) -> Result<Json<ChangeResponse>, StatusCode> { 63 + authz::require_read(&state, &user, &repo_id).await?; 64 + 65 + let doc = state.docs.get_or_load(&repo_id).await 66 + .map_err(|_| StatusCode::NOT_FOUND)?; 67 + 68 + let doc = doc.read().await; 69 + 70 + // Parse change_id 71 + let cid: tandem_core::types::ChangeId = change_id.parse() 72 + .map_err(|_| StatusCode::BAD_REQUEST)?; 73 + 74 + let records = doc.get_change_records(&cid); 75 + if records.is_empty() { 76 + return Err(StatusCode::NOT_FOUND); 77 + } 78 + 79 + let record = &records[0]; 80 + Ok(Json(ChangeResponse { 81 + change_id: record.change_id.to_string(), 82 + tree: record.tree.to_string(), 83 + parents: record.parents.iter().map(|p| p.to_string()).collect(), 84 + description: record.description.clone(), 85 + author_email: record.author.email.clone(), 86 + author_name: record.author.name.clone(), 87 + timestamp: record.timestamp.to_rfc3339(), 88 + divergent: records.len() > 1, 89 + })) 90 + }
+20
crates/tandem-server/src/handlers/content.rs
··· 1 + use axum::{extract::{Path, State}, http::StatusCode, body::Bytes}; 2 + use crate::{AppState, auth::AuthenticatedUser, authz}; 3 + 4 + pub async fn get_content( 5 + State(state): State<AppState>, 6 + Path((repo_id, hash)): Path<(String, String)>, 7 + user: AuthenticatedUser, 8 + ) -> Result<Bytes, StatusCode> { 9 + authz::require_read(&state, &user, &repo_id).await?; 10 + 11 + let doc = state.docs.get_or_load(&repo_id).await 12 + .map_err(|_| StatusCode::NOT_FOUND)?; 13 + 14 + let doc = doc.read().await; 15 + 16 + let content = doc.get_content(&hash) 17 + .ok_or(StatusCode::NOT_FOUND)?; 18 + 19 + Ok(Bytes::from(content)) 20 + }
+5
crates/tandem-server/src/handlers/mod.rs
··· 1 + pub mod repos; 2 + pub mod changes; 3 + pub mod bookmarks; 4 + pub mod presence; 5 + pub mod content;
+32
crates/tandem-server/src/handlers/presence.rs
··· 1 + use axum::{extract::{Path, State}, http::StatusCode, Json}; 2 + use serde::Serialize; 3 + use crate::{AppState, auth::AuthenticatedUser, authz}; 4 + 5 + #[derive(Serialize)] 6 + pub struct PresenceResponse { 7 + pub user_id: String, 8 + pub change_id: String, 9 + pub device: String, 10 + pub timestamp: String, 11 + } 12 + 13 + pub async fn get_presence( 14 + State(state): State<AppState>, 15 + Path(repo_id): Path<String>, 16 + user: AuthenticatedUser, 17 + ) -> Result<Json<Vec<PresenceResponse>>, StatusCode> { 18 + authz::require_read(&state, &user, &repo_id).await?; 19 + 20 + let doc = state.docs.get_or_load(&repo_id).await 21 + .map_err(|_| StatusCode::NOT_FOUND)?; 22 + 23 + let doc = doc.read().await; 24 + let presence_list = doc.get_all_presence(); 25 + 26 + Ok(Json(presence_list.into_iter().map(|p| PresenceResponse { 27 + user_id: p.user_id, 28 + change_id: p.change_id.to_string(), 29 + device: p.device, 30 + timestamp: p.timestamp.to_rfc3339(), 31 + }).collect())) 32 + }
+78
crates/tandem-server/src/handlers/repos.rs
··· 1 + use axum::{extract::{Path, State}, http::StatusCode, Json}; 2 + use serde::{Deserialize, Serialize}; 3 + use crate::{AppState, auth::AuthenticatedUser, authz}; 4 + 5 + #[derive(Serialize)] 6 + pub struct RepoResponse { 7 + pub id: String, 8 + pub name: String, 9 + pub org: String, 10 + pub created_at: String, 11 + } 12 + 13 + #[derive(Deserialize)] 14 + pub struct CreateRepoRequest { 15 + pub name: String, 16 + pub org: String, 17 + } 18 + 19 + pub async fn list_repos( 20 + State(state): State<AppState>, 21 + user: AuthenticatedUser, 22 + ) -> Result<Json<Vec<RepoResponse>>, StatusCode> { 23 + // Filter by user access 24 + let repos = state.db.list_repos_for_user(&user.id).await 25 + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; 26 + 27 + Ok(Json(repos.into_iter().map(|r| RepoResponse { 28 + id: r.id, 29 + name: r.name, 30 + org: r.org, 31 + created_at: r.created_at.to_rfc3339(), 32 + }).collect())) 33 + } 34 + 35 + pub async fn create_repo( 36 + State(state): State<AppState>, 37 + user: AuthenticatedUser, 38 + Json(req): Json<CreateRepoRequest>, 39 + ) -> Result<Json<RepoResponse>, StatusCode> { 40 + let id = uuid::Uuid::new_v4().to_string(); 41 + 42 + let repo = state.db.create_repo(&id, &req.name, &req.org).await 43 + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; 44 + 45 + // Grant admin access to creator 46 + state.db.set_user_role(&user.id, &id, "admin").await 47 + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; 48 + 49 + // Create empty doc for repo 50 + state.docs.create(&id).await 51 + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; 52 + 53 + Ok(Json(RepoResponse { 54 + id: repo.id, 55 + name: repo.name, 56 + org: repo.org, 57 + created_at: repo.created_at.to_rfc3339(), 58 + })) 59 + } 60 + 61 + pub async fn get_repo( 62 + State(state): State<AppState>, 63 + Path(id): Path<String>, 64 + user: AuthenticatedUser, 65 + ) -> Result<Json<RepoResponse>, StatusCode> { 66 + authz::require_read(&state, &user, &id).await?; 67 + 68 + let repo = state.db.get_repo(&id).await 69 + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? 70 + .ok_or(StatusCode::NOT_FOUND)?; 71 + 72 + Ok(Json(RepoResponse { 73 + id: repo.id, 74 + name: repo.name, 75 + org: repo.org, 76 + created_at: repo.created_at.to_rfc3339(), 77 + })) 78 + }