mockforge_collab/
api.rs

1//! REST API endpoints for collaboration
2
3use crate::auth::{AuthService, Credentials};
4use crate::error::{CollabError, Result};
5use crate::history::VersionControl;
6use crate::middleware::{auth_middleware, AuthUser};
7use crate::models::UserRole;
8use crate::user::UserService;
9use crate::workspace::WorkspaceService;
10use axum::{
11    extract::{Path, Query, State},
12    http::StatusCode,
13    middleware,
14    response::{IntoResponse, Response},
15    routing::{delete, get, post, put},
16    Extension, Json, Router,
17};
18use serde::{Deserialize, Serialize};
19use std::sync::Arc;
20use uuid::Uuid;
21
22/// API state
23#[derive(Clone)]
24pub struct ApiState {
25    pub auth: Arc<AuthService>,
26    pub user: Arc<UserService>,
27    pub workspace: Arc<WorkspaceService>,
28    pub history: Arc<VersionControl>,
29}
30
31/// Create API router
32pub fn create_router(state: ApiState) -> Router {
33    // Public routes (no authentication required)
34    let public_routes = Router::new()
35        .route("/auth/register", post(register))
36        .route("/auth/login", post(login))
37        .route("/health", get(health_check))
38        .route("/ready", get(readiness_check));
39
40    // Protected routes (authentication required)
41    let protected_routes = Router::new()
42        // Workspaces
43        .route("/workspaces", post(create_workspace))
44        .route("/workspaces", get(list_workspaces))
45        .route("/workspaces/:id", get(get_workspace))
46        .route("/workspaces/:id", put(update_workspace))
47        .route("/workspaces/:id", delete(delete_workspace))
48        // Members
49        .route("/workspaces/:id/members", post(add_member))
50        .route("/workspaces/:id/members/:user_id", delete(remove_member))
51        .route("/workspaces/:id/members/:user_id/role", put(change_role))
52        .route("/workspaces/:id/members", get(list_members))
53        // Version Control - Commits
54        .route("/workspaces/:id/commits", post(create_commit))
55        .route("/workspaces/:id/commits", get(list_commits))
56        .route("/workspaces/:id/commits/:commit_id", get(get_commit))
57        .route("/workspaces/:id/restore/:commit_id", post(restore_to_commit))
58        // Version Control - Snapshots
59        .route("/workspaces/:id/snapshots", post(create_snapshot))
60        .route("/workspaces/:id/snapshots", get(list_snapshots))
61        .route("/workspaces/:id/snapshots/:name", get(get_snapshot))
62        .route_layer(middleware::from_fn_with_state(
63            state.auth.clone(),
64            auth_middleware,
65        ));
66
67    // Combine routes
68    Router::new().merge(public_routes).merge(protected_routes).with_state(state)
69}
70
71// ===== Request/Response Types =====
72
73#[derive(Debug, Deserialize)]
74pub struct RegisterRequest {
75    pub username: String,
76    pub email: String,
77    pub password: String,
78}
79
80#[derive(Debug, Serialize)]
81pub struct AuthResponse {
82    pub access_token: String,
83    pub token_type: String,
84    pub expires_at: String,
85}
86
87#[derive(Debug, Deserialize)]
88pub struct CreateWorkspaceRequest {
89    pub name: String,
90    pub description: Option<String>,
91}
92
93#[derive(Debug, Deserialize)]
94pub struct UpdateWorkspaceRequest {
95    pub name: Option<String>,
96    pub description: Option<String>,
97}
98
99#[derive(Debug, Deserialize)]
100pub struct AddMemberRequest {
101    pub user_id: Uuid,
102    pub role: UserRole,
103}
104
105#[derive(Debug, Deserialize)]
106pub struct ChangeRoleRequest {
107    pub role: UserRole,
108}
109
110#[derive(Debug, Deserialize)]
111pub struct CreateCommitRequest {
112    pub message: String,
113    pub changes: serde_json::Value,
114}
115
116#[derive(Debug, Deserialize)]
117pub struct CreateSnapshotRequest {
118    pub name: String,
119    pub description: Option<String>,
120    pub commit_id: Uuid,
121}
122
123#[derive(Debug, Deserialize)]
124pub struct PaginationQuery {
125    #[serde(default = "default_limit")]
126    pub limit: i32,
127    #[serde(default)]
128    pub offset: i32,
129}
130
131fn default_limit() -> i32 {
132    50
133}
134
135// ===== Error Handling =====
136
137impl IntoResponse for CollabError {
138    fn into_response(self) -> Response {
139        let (status, message) = match self {
140            CollabError::AuthenticationFailed(msg) => (StatusCode::UNAUTHORIZED, msg),
141            CollabError::AuthorizationFailed(msg) => (StatusCode::FORBIDDEN, msg),
142            CollabError::WorkspaceNotFound(msg) => (StatusCode::NOT_FOUND, msg),
143            CollabError::UserNotFound(msg) => (StatusCode::NOT_FOUND, msg),
144            CollabError::InvalidInput(msg) => (StatusCode::BAD_REQUEST, msg),
145            CollabError::AlreadyExists(msg) => (StatusCode::CONFLICT, msg),
146            CollabError::Timeout(msg) => (StatusCode::REQUEST_TIMEOUT, msg),
147            _ => (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error".to_string()),
148        };
149
150        (status, Json(serde_json::json!({ "error": message }))).into_response()
151    }
152}
153
154// ===== Handler Functions =====
155
156/// Register a new user
157async fn register(
158    State(state): State<ApiState>,
159    Json(payload): Json<RegisterRequest>,
160) -> Result<Json<AuthResponse>> {
161    // Create user
162    let user = state
163        .user
164        .create_user(payload.username, payload.email, payload.password)
165        .await?;
166
167    // Generate token
168    let token = state.auth.generate_token(&user)?;
169
170    Ok(Json(AuthResponse {
171        access_token: token.access_token,
172        token_type: token.token_type,
173        expires_at: token.expires_at.to_rfc3339(),
174    }))
175}
176
177/// Login user
178async fn login(
179    State(state): State<ApiState>,
180    Json(payload): Json<Credentials>,
181) -> Result<Json<AuthResponse>> {
182    // Authenticate user
183    let user = state.user.authenticate(&payload.username, &payload.password).await?;
184
185    // Generate token
186    let token = state.auth.generate_token(&user)?;
187
188    Ok(Json(AuthResponse {
189        access_token: token.access_token,
190        token_type: token.token_type,
191        expires_at: token.expires_at.to_rfc3339(),
192    }))
193}
194
195/// Create a new workspace
196async fn create_workspace(
197    State(state): State<ApiState>,
198    Extension(auth_user): Extension<AuthUser>,
199    Json(payload): Json<CreateWorkspaceRequest>,
200) -> Result<Json<serde_json::Value>> {
201    // Create workspace
202    let workspace = state
203        .workspace
204        .create_workspace(payload.name, payload.description, auth_user.user_id)
205        .await?;
206
207    Ok(Json(serde_json::to_value(workspace)?))
208}
209
210/// List user's workspaces
211async fn list_workspaces(
212    State(state): State<ApiState>,
213    Extension(auth_user): Extension<AuthUser>,
214) -> Result<Json<serde_json::Value>> {
215    // List workspaces
216    let workspaces = state.workspace.list_user_workspaces(auth_user.user_id).await?;
217
218    Ok(Json(serde_json::to_value(workspaces)?))
219}
220
221/// Get workspace by ID
222async fn get_workspace(
223    State(state): State<ApiState>,
224    Path(id): Path<Uuid>,
225    Extension(auth_user): Extension<AuthUser>,
226) -> Result<Json<serde_json::Value>> {
227    // Verify user is a member
228    let _member = state.workspace.get_member(id, auth_user.user_id).await?;
229
230    // Get workspace
231    let workspace = state.workspace.get_workspace(id).await?;
232
233    Ok(Json(serde_json::to_value(workspace)?))
234}
235
236/// Update workspace
237async fn update_workspace(
238    State(state): State<ApiState>,
239    Path(id): Path<Uuid>,
240    Extension(auth_user): Extension<AuthUser>,
241    Json(payload): Json<UpdateWorkspaceRequest>,
242) -> Result<Json<serde_json::Value>> {
243    // Update workspace (permission check inside)
244    let workspace = state
245        .workspace
246        .update_workspace(id, auth_user.user_id, payload.name, payload.description, None)
247        .await?;
248
249    Ok(Json(serde_json::to_value(workspace)?))
250}
251
252/// Delete workspace
253async fn delete_workspace(
254    State(state): State<ApiState>,
255    Path(id): Path<Uuid>,
256    Extension(auth_user): Extension<AuthUser>,
257) -> Result<StatusCode> {
258    // Delete workspace (permission check inside)
259    state.workspace.delete_workspace(id, auth_user.user_id).await?;
260
261    Ok(StatusCode::NO_CONTENT)
262}
263
264/// Add member to workspace
265async fn add_member(
266    State(state): State<ApiState>,
267    Path(workspace_id): Path<Uuid>,
268    Extension(auth_user): Extension<AuthUser>,
269    Json(payload): Json<AddMemberRequest>,
270) -> Result<Json<serde_json::Value>> {
271    // Add member (permission check inside)
272    let member = state
273        .workspace
274        .add_member(workspace_id, auth_user.user_id, payload.user_id, payload.role)
275        .await?;
276
277    Ok(Json(serde_json::to_value(member)?))
278}
279
280/// Remove member from workspace
281async fn remove_member(
282    State(state): State<ApiState>,
283    Path((workspace_id, member_user_id)): Path<(Uuid, Uuid)>,
284    Extension(auth_user): Extension<AuthUser>,
285) -> Result<StatusCode> {
286    // Remove member (permission check inside)
287    state
288        .workspace
289        .remove_member(workspace_id, auth_user.user_id, member_user_id)
290        .await?;
291
292    Ok(StatusCode::NO_CONTENT)
293}
294
295/// Change member role
296async fn change_role(
297    State(state): State<ApiState>,
298    Path((workspace_id, member_user_id)): Path<(Uuid, Uuid)>,
299    Extension(auth_user): Extension<AuthUser>,
300    Json(payload): Json<ChangeRoleRequest>,
301) -> Result<Json<serde_json::Value>> {
302    // Change role (permission check inside)
303    let member = state
304        .workspace
305        .change_role(workspace_id, auth_user.user_id, member_user_id, payload.role)
306        .await?;
307
308    Ok(Json(serde_json::to_value(member)?))
309}
310
311/// List workspace members
312async fn list_members(
313    State(state): State<ApiState>,
314    Path(workspace_id): Path<Uuid>,
315    Extension(auth_user): Extension<AuthUser>,
316) -> Result<Json<serde_json::Value>> {
317    // Verify user is a member
318    let _member = state.workspace.get_member(workspace_id, auth_user.user_id).await?;
319
320    // List all members
321    let members = state.workspace.list_members(workspace_id).await?;
322
323    Ok(Json(serde_json::to_value(members)?))
324}
325
326/// Health check endpoint
327async fn health_check() -> impl IntoResponse {
328    StatusCode::OK
329}
330
331/// Readiness check endpoint
332async fn readiness_check() -> impl IntoResponse {
333    // TODO: Check database connection
334    StatusCode::OK
335}
336
337// ===== Validation Helpers =====
338
339/// Validate commit message
340fn validate_commit_message(message: &str) -> Result<()> {
341    if message.is_empty() {
342        return Err(CollabError::InvalidInput("Commit message cannot be empty".to_string()));
343    }
344    if message.len() > 500 {
345        return Err(CollabError::InvalidInput(
346            "Commit message cannot exceed 500 characters".to_string(),
347        ));
348    }
349    Ok(())
350}
351
352/// Validate snapshot name
353fn validate_snapshot_name(name: &str) -> Result<()> {
354    if name.is_empty() {
355        return Err(CollabError::InvalidInput("Snapshot name cannot be empty".to_string()));
356    }
357    if name.len() > 100 {
358        return Err(CollabError::InvalidInput(
359            "Snapshot name cannot exceed 100 characters".to_string(),
360        ));
361    }
362    // Allow alphanumeric, hyphens, underscores, and dots
363    if !name.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.') {
364        return Err(CollabError::InvalidInput(
365            "Snapshot name can only contain alphanumeric characters, hyphens, underscores, and dots".to_string(),
366        ));
367    }
368    Ok(())
369}
370
371// ===== Version Control Handlers =====
372
373/// Create a commit in the workspace.
374///
375/// Creates a new commit capturing the current state of the workspace along with
376/// a description of changes. This is similar to `git commit`.
377///
378/// # Requirements
379/// - User must be a workspace member with Editor or Admin role
380/// - Commit message must be 1-500 characters
381///
382/// # Request Body
383/// - `message`: Commit message describing the changes (required, 1-500 chars)
384/// - `changes`: JSON object describing what changed
385///
386/// # Response
387/// Returns the created Commit object with:
388/// - `id`: Unique commit ID
389/// - `workspace_id`: ID of the workspace
390/// - `author_id`: ID of the user who created the commit
391/// - `message`: Commit message
392/// - `parent_id`: ID of the parent commit (null for first commit)
393/// - `version`: Version number (auto-incremented)
394/// - `snapshot`: Full workspace state at this commit
395/// - `changes`: Description of what changed
396/// - `created_at`: Timestamp
397///
398/// # Errors
399/// - `401 Unauthorized`: Not authenticated
400/// - `403 Forbidden`: User is not Editor or Admin
401/// - `404 Not Found`: Workspace not found or user not a member
402/// - `400 Bad Request`: Invalid commit message
403async fn create_commit(
404    State(state): State<ApiState>,
405    Path(workspace_id): Path<Uuid>,
406    Extension(auth_user): Extension<AuthUser>,
407    Json(payload): Json<CreateCommitRequest>,
408) -> Result<Json<serde_json::Value>> {
409    // Validate input
410    validate_commit_message(&payload.message)?;
411
412    // Verify user has permission (Editor or Admin)
413    let member = state.workspace.get_member(workspace_id, auth_user.user_id).await?;
414    if !matches!(member.role, UserRole::Admin | UserRole::Editor) {
415        return Err(CollabError::AuthorizationFailed(
416            "Only Admins and Editors can create commits".to_string(),
417        ));
418    }
419
420    // Get current workspace state
421    let workspace = state.workspace.get_workspace(workspace_id).await?;
422
423    // Get parent commit (latest)
424    let parent = state.history.get_latest_commit(workspace_id).await?;
425    let parent_id = parent.as_ref().map(|c| c.id);
426    let version = parent.as_ref().map(|c| c.version + 1).unwrap_or(1);
427
428    // Create snapshot of current state
429    let snapshot = serde_json::to_value(&workspace)?;
430
431    // Create commit
432    let commit = state
433        .history
434        .create_commit(
435            workspace_id,
436            auth_user.user_id,
437            payload.message,
438            parent_id,
439            version,
440            snapshot,
441            payload.changes,
442        )
443        .await?;
444
445    Ok(Json(serde_json::to_value(commit)?))
446}
447
448/// List commits for a workspace.
449///
450/// Returns the commit history for a workspace in reverse chronological order
451/// (most recent first). Supports pagination via query parameters.
452///
453/// # Requirements
454/// - User must be a workspace member (any role)
455///
456/// # Query Parameters
457/// - `limit`: Number of commits to return (default: 50, max: 100)
458/// - `offset`: Number of commits to skip (default: 0)
459///
460/// # Response
461/// Returns a JSON object with:
462/// - `commits`: Array of Commit objects
463/// - `pagination`: Object with `limit` and `offset` values
464///
465/// # Example
466/// ```
467/// GET /workspaces/{id}/commits?limit=20&offset=0
468/// ```
469///
470/// # Errors
471/// - `401 Unauthorized`: Not authenticated
472/// - `404 Not Found`: Workspace not found or user not a member
473async fn list_commits(
474    State(state): State<ApiState>,
475    Path(workspace_id): Path<Uuid>,
476    Extension(auth_user): Extension<AuthUser>,
477    Query(pagination): Query<PaginationQuery>,
478) -> Result<Json<serde_json::Value>> {
479    // Verify user is a member
480    let _member = state.workspace.get_member(workspace_id, auth_user.user_id).await?;
481
482    // Validate pagination params
483    let limit = pagination.limit.clamp(1, 100);
484
485    // Get commit history
486    let commits = state.history.get_history(workspace_id, Some(limit)).await?;
487
488    // Return with pagination metadata
489    Ok(Json(serde_json::json!({
490        "commits": commits,
491        "pagination": {
492            "limit": limit,
493            "offset": pagination.offset,
494        }
495    })))
496}
497
498/// Get a specific commit by ID.
499///
500/// Retrieves detailed information about a specific commit, including the full
501/// workspace state snapshot at that point in time.
502///
503/// # Requirements
504/// - User must be a workspace member (any role)
505/// - Commit must belong to the specified workspace
506///
507/// # Errors
508/// - `401 Unauthorized`: Not authenticated
509/// - `404 Not Found`: Commit or workspace not found
510/// - `400 Bad Request`: Commit doesn't belong to this workspace
511async fn get_commit(
512    State(state): State<ApiState>,
513    Path((workspace_id, commit_id)): Path<(Uuid, Uuid)>,
514    Extension(auth_user): Extension<AuthUser>,
515) -> Result<Json<serde_json::Value>> {
516    // Verify user is a member
517    let _member = state.workspace.get_member(workspace_id, auth_user.user_id).await?;
518
519    // Get commit
520    let commit = state.history.get_commit(commit_id).await?;
521
522    // Verify commit belongs to this workspace
523    if commit.workspace_id != workspace_id {
524        return Err(CollabError::InvalidInput(
525            "Commit does not belong to this workspace".to_string(),
526        ));
527    }
528
529    Ok(Json(serde_json::to_value(commit)?))
530}
531
532/// Restore workspace to a specific commit.
533///
534/// Reverts the workspace to the state captured in the specified commit.
535/// This is a destructive operation that should be used carefully.
536///
537/// # Requirements
538/// - User must be a workspace member with Editor or Admin role
539/// - Commit must exist and belong to the workspace
540///
541/// # Response
542/// Returns an object with:
543/// - `workspace_id`: ID of the restored workspace
544/// - `commit_id`: ID of the commit that was restored
545/// - `restored_state`: The workspace state from the commit
546///
547/// # Errors
548/// - `401 Unauthorized`: Not authenticated
549/// - `403 Forbidden`: User is not Editor or Admin
550/// - `404 Not Found`: Commit or workspace not found
551async fn restore_to_commit(
552    State(state): State<ApiState>,
553    Path((workspace_id, commit_id)): Path<(Uuid, Uuid)>,
554    Extension(auth_user): Extension<AuthUser>,
555) -> Result<Json<serde_json::Value>> {
556    // Verify user has permission (Editor or Admin)
557    let member = state.workspace.get_member(workspace_id, auth_user.user_id).await?;
558    if !matches!(member.role, UserRole::Admin | UserRole::Editor) {
559        return Err(CollabError::AuthorizationFailed(
560            "Only Admins and Editors can restore workspaces".to_string(),
561        ));
562    }
563
564    // Restore to commit
565    let restored_state = state.history.restore_to_commit(workspace_id, commit_id).await?;
566
567    Ok(Json(serde_json::json!({
568        "workspace_id": workspace_id,
569        "commit_id": commit_id,
570        "restored_state": restored_state
571    })))
572}
573
574/// Create a named snapshot.
575///
576/// Creates a named reference to a specific commit, similar to a git tag.
577/// Snapshots are useful for marking important states like releases.
578///
579/// # Requirements
580/// - User must be a workspace member with Editor or Admin role
581/// - Snapshot name must be 1-100 characters, alphanumeric with -, _, or .
582/// - Commit must exist
583///
584/// # Request Body
585/// - `name`: Name for the snapshot (required, 1-100 chars, alphanumeric)
586/// - `description`: Optional description
587/// - `commit_id`: ID of the commit to snapshot
588///
589/// # Errors
590/// - `401 Unauthorized`: Not authenticated
591/// - `403 Forbidden`: User is not Editor or Admin
592/// - `404 Not Found`: Workspace or commit not found
593/// - `400 Bad Request`: Invalid snapshot name
594async fn create_snapshot(
595    State(state): State<ApiState>,
596    Path(workspace_id): Path<Uuid>,
597    Extension(auth_user): Extension<AuthUser>,
598    Json(payload): Json<CreateSnapshotRequest>,
599) -> Result<Json<serde_json::Value>> {
600    // Validate input
601    validate_snapshot_name(&payload.name)?;
602
603    // Verify user has permission (Editor or Admin)
604    let member = state.workspace.get_member(workspace_id, auth_user.user_id).await?;
605    if !matches!(member.role, UserRole::Admin | UserRole::Editor) {
606        return Err(CollabError::AuthorizationFailed(
607            "Only Admins and Editors can create snapshots".to_string(),
608        ));
609    }
610
611    // Create snapshot
612    let snapshot = state
613        .history
614        .create_snapshot(
615            workspace_id,
616            payload.name,
617            payload.description,
618            payload.commit_id,
619            auth_user.user_id,
620        )
621        .await?;
622
623    Ok(Json(serde_json::to_value(snapshot)?))
624}
625
626/// List snapshots for a workspace.
627///
628/// Returns all named snapshots for the workspace in reverse chronological order.
629///
630/// # Requirements
631/// - User must be a workspace member (any role)
632///
633/// # Errors
634/// - `401 Unauthorized`: Not authenticated
635/// - `404 Not Found`: Workspace not found or user not a member
636async fn list_snapshots(
637    State(state): State<ApiState>,
638    Path(workspace_id): Path<Uuid>,
639    Extension(auth_user): Extension<AuthUser>,
640) -> Result<Json<serde_json::Value>> {
641    // Verify user is a member
642    let _member = state.workspace.get_member(workspace_id, auth_user.user_id).await?;
643
644    // List snapshots
645    let snapshots = state.history.list_snapshots(workspace_id).await?;
646
647    Ok(Json(serde_json::to_value(snapshots)?))
648}
649
650/// Get a specific snapshot by name.
651///
652/// Retrieves details about a named snapshot, including which commit it references.
653///
654/// # Requirements
655/// - User must be a workspace member (any role)
656///
657/// # Errors
658/// - `401 Unauthorized`: Not authenticated
659/// - `404 Not Found`: Snapshot, workspace not found, or user not a member
660async fn get_snapshot(
661    State(state): State<ApiState>,
662    Path((workspace_id, name)): Path<(Uuid, String)>,
663    Extension(auth_user): Extension<AuthUser>,
664) -> Result<Json<serde_json::Value>> {
665    // Verify user is a member
666    let _member = state.workspace.get_member(workspace_id, auth_user.user_id).await?;
667
668    // Get snapshot
669    let snapshot = state.history.get_snapshot(workspace_id, &name).await?;
670
671    Ok(Json(serde_json::to_value(snapshot)?))
672}
673
674#[cfg(test)]
675mod tests {
676    use super::*;
677
678    #[test]
679    fn test_router_creation() {
680        // Just ensure router can be created
681        let state = ApiState {
682            auth: Arc::new(AuthService::new("test".to_string())),
683            user: Arc::new(UserService::new(
684                todo!(),
685                Arc::new(AuthService::new("test".to_string())),
686            )),
687            workspace: Arc::new(WorkspaceService::new(todo!())),
688            history: Arc::new(VersionControl::new(todo!())),
689        };
690        let _router = create_router(state);
691    }
692}