Skip to main content

liboxen/api/client/
workspaces.rs

1pub mod changes;
2pub mod commits;
3pub mod data_frames;
4pub mod files;
5
6use std::path::Path;
7
8pub use commits::commit;
9
10use crate::api;
11use crate::api::client;
12use crate::error::OxenError;
13use crate::model::RemoteRepository;
14use crate::view::workspaces::{ListWorkspaceResponseView, WorkspaceResponseWithStatus};
15use crate::view::workspaces::{NewWorkspace, WorkspaceResponse};
16use crate::view::{StatusMessage, WorkspaceResponseView};
17
18pub async fn list(remote_repo: &RemoteRepository) -> Result<Vec<WorkspaceResponse>, OxenError> {
19    let url = api::endpoint::url_from_repo(remote_repo, "/workspaces")?;
20    let client = client::new_for_url(&url)?;
21    let res = client.get(&url).send().await?;
22    let body = client::parse_json_body(&url, res).await?;
23    let response: Result<ListWorkspaceResponseView, serde_json::Error> =
24        serde_json::from_str(&body);
25    match response {
26        Ok(val) => Ok(val.workspaces),
27        Err(err) => Err(OxenError::basic_str(format!(
28            "error parsing response from {url}\n\nErr {err:?} \n\n{body}"
29        ))),
30    }
31}
32
33pub async fn get(
34    remote_repo: &RemoteRepository,
35    workspace_id: impl AsRef<str>,
36) -> Result<Option<WorkspaceResponse>, OxenError> {
37    let workspace_id = workspace_id.as_ref();
38    let url = api::endpoint::url_from_repo(remote_repo, &format!("/workspaces/{workspace_id}"))?;
39    let client = client::new_for_url(&url)?;
40    let res = client.get(&url).send().await?;
41    let body_result = client::parse_json_body(&url, res).await;
42
43    let workspace = body_result
44        .ok()
45        .and_then(|body| serde_json::from_str::<WorkspaceResponseView>(&body).ok())
46        .map(|val| val.workspace);
47
48    Ok(workspace)
49}
50
51pub async fn get_by_name(
52    remote_repo: &RemoteRepository,
53    name: impl AsRef<str>,
54) -> Result<Option<WorkspaceResponse>, OxenError> {
55    let name = name.as_ref();
56    let url = api::endpoint::url_from_repo(remote_repo, &format!("/workspaces?name={name}"))?;
57    let client = client::new_for_url(&url)?;
58    let res = client.get(&url).send().await?;
59    let body = client::parse_json_body(&url, res).await?;
60    let response: Result<ListWorkspaceResponseView, serde_json::Error> =
61        serde_json::from_str(&body);
62    match response {
63        Ok(val) => {
64            if val.workspaces.len() == 1 {
65                Ok(Some(val.workspaces[0].clone()))
66            } else if val.workspaces.is_empty() {
67                Ok(None)
68            } else {
69                Err(OxenError::basic_str(format!(
70                    "expected 1 workspace, got {}",
71                    val.workspaces.len()
72                )))
73            }
74        }
75        Err(err) => Err(OxenError::basic_str(format!(
76            "error parsing response from {url}\n\nErr {err:?} \n\n{body}"
77        ))),
78    }
79}
80
81pub async fn create(
82    remote_repo: &RemoteRepository,
83    branch_name: impl AsRef<str>,
84    workspace_id: impl AsRef<str>,
85) -> Result<WorkspaceResponseWithStatus, OxenError> {
86    create_with_path(remote_repo, branch_name, workspace_id, Path::new("/"), None).await
87}
88
89pub async fn create_with_name(
90    remote_repo: &RemoteRepository,
91    branch_name: impl AsRef<str>,
92    workspace_id: impl AsRef<str>,
93    workspace_name: impl AsRef<str>,
94) -> Result<WorkspaceResponseWithStatus, OxenError> {
95    let workspace_name = workspace_name.as_ref().to_string();
96    create_with_path(
97        remote_repo,
98        branch_name,
99        workspace_id,
100        Path::new("/"),
101        Some(workspace_name),
102    )
103    .await
104}
105
106pub async fn create_with_path(
107    remote_repo: &RemoteRepository,
108    branch_name: impl AsRef<str>,
109    workspace_id: impl AsRef<str>,
110    path: impl AsRef<Path>,
111    workspace_name: Option<String>,
112) -> Result<WorkspaceResponseWithStatus, OxenError> {
113    let branch_name = branch_name.as_ref();
114    let workspace_id = workspace_id.as_ref();
115    let path = path.as_ref();
116    let url = api::endpoint::url_from_repo(remote_repo, "/workspaces")?;
117    log::debug!("create workspace {url}\n");
118
119    let body = NewWorkspace {
120        branch_name: branch_name.to_string(),
121        workspace_id: workspace_id.to_string(),
122        // These two are needed for the oxen hub right now, ignored by the server
123        resource_path: Some(path.to_str().unwrap().to_string()),
124        entity_type: Some("user".to_string()),
125        name: workspace_name,
126        force: Some(true),
127    };
128
129    let client = client::new_for_url(&url)?;
130    let res = client.put(&url).json(&body).send().await?;
131
132    let body = client::parse_json_body(&url, res).await?;
133    log::debug!("create workspace got body: {body}");
134    let response: Result<WorkspaceResponseView, serde_json::Error> = serde_json::from_str(&body);
135    match response {
136        Ok(val) => Ok(WorkspaceResponseWithStatus {
137            id: val.workspace.id,
138            name: val.workspace.name,
139            commit: val.workspace.commit,
140            status: val.status.status_message,
141        }),
142        Err(err) => Err(OxenError::basic_str(format!(
143            "error parsing response from {url}\n\nErr {err:?} \n\n{body}"
144        ))),
145    }
146}
147
148pub async fn delete(
149    remote_repo: &RemoteRepository,
150    workspace_id: impl AsRef<str>,
151) -> Result<WorkspaceResponse, OxenError> {
152    let workspace_id = workspace_id.as_ref();
153    let url = api::endpoint::url_from_repo(remote_repo, &format!("/workspaces/{workspace_id}"))?;
154    log::debug!("delete workspace {url}\n");
155
156    let client = client::new_for_url(&url)?;
157    let res = client.delete(&url).send().await?;
158
159    let body = client::parse_json_body(&url, res).await?;
160    log::debug!("delete workspace got body: {body}");
161    let response: Result<WorkspaceResponseView, serde_json::Error> = serde_json::from_str(&body);
162    match response {
163        Ok(val) => Ok(val.workspace),
164        Err(err) => Err(OxenError::basic_str(format!(
165            "error parsing response from {url}\n\nErr {err:?} \n\n{body}"
166        ))),
167    }
168}
169
170pub async fn clear(remote_repo: &RemoteRepository) -> Result<(), OxenError> {
171    let url = api::endpoint::url_from_repo(remote_repo, "/workspaces")?;
172    log::debug!("clear workspaces {url}\n");
173
174    let client = client::new_for_url(&url)?;
175    let res = client.delete(&url).send().await?;
176
177    let body = client::parse_json_body(&url, res).await?;
178    log::debug!("delete workspace got body: {body}");
179    let response: Result<StatusMessage, serde_json::Error> = serde_json::from_str(&body);
180    match response {
181        Ok(_) => Ok(()),
182        Err(err) => Err(OxenError::basic_str(format!(
183            "error parsing response from {url}\n\nErr {err:?} \n\n{body}"
184        ))),
185    }
186}
187
188#[cfg(test)]
189mod tests {
190
191    use super::*;
192
193    use crate::api;
194    use crate::command;
195    use crate::constants;
196    use crate::constants::DEFAULT_BRANCH_NAME;
197    use crate::error::OxenError;
198    use crate::model::NewCommitBody;
199    use crate::opts::DFOpts;
200    use crate::repositories;
201    use crate::test;
202
203    #[tokio::test]
204    async fn test_create_workspace() -> Result<(), OxenError> {
205        test::run_readme_remote_repo_test(|_local_repo, remote_repo| async move {
206            let branch_name = "main";
207            let workspace_id = "test_workspace_id";
208            let workspace = create(&remote_repo, branch_name, workspace_id).await?;
209
210            assert_eq!(workspace.id, workspace_id);
211
212            Ok(remote_repo)
213        })
214        .await
215    }
216
217    #[tokio::test]
218    async fn test_create_workspace_with_name() -> Result<(), OxenError> {
219        test::run_readme_remote_repo_test(|_local_repo, remote_repo| async move {
220            let branch_name = "main";
221            let workspace_id = "test_workspace_id";
222            let workspace_name = "test_workspace_name";
223            let workspace =
224                create_with_name(&remote_repo, branch_name, workspace_id, workspace_name).await?;
225
226            assert_eq!(workspace.id, workspace_id);
227            assert_eq!(workspace.name, Some(workspace_name.to_string()));
228
229            let workspace = get(&remote_repo, &workspace_id).await?;
230            assert!(workspace.is_some());
231            assert_eq!(
232                workspace.as_ref().unwrap().name,
233                Some(workspace_name.to_string())
234            );
235            assert_eq!(workspace.as_ref().unwrap().id, workspace_id);
236
237            let workspace = get_by_name(&remote_repo, &workspace_name).await?;
238            assert!(workspace.is_some());
239            assert_eq!(
240                workspace.as_ref().unwrap().name,
241                Some(workspace_name.to_string())
242            );
243            assert_eq!(workspace.as_ref().unwrap().id, workspace_id);
244
245            Ok(remote_repo)
246        })
247        .await
248    }
249
250    #[tokio::test]
251    async fn test_get_workspace_by_name() -> Result<(), OxenError> {
252        test::run_readme_remote_repo_test(|_local_repo, remote_repo| async move {
253            let branch_name = "main";
254            let workspace_id = "test_workspace_id";
255            let workspace_name = "test_workspace_name";
256            create_with_name(&remote_repo, branch_name, workspace_id, workspace_name).await?;
257
258            // Create a second workspace with a different name
259            let workspace_id2 = "test_workspace_id2";
260            let workspace_name2 = "test_workspace_name2";
261            create_with_name(&remote_repo, branch_name, workspace_id2, workspace_name2).await?;
262
263            let workspace = get_by_name(&remote_repo, &workspace_name).await?;
264            assert!(workspace.is_some());
265            assert_eq!(
266                workspace.as_ref().unwrap().name,
267                Some(workspace_name.to_string())
268            );
269            assert_eq!(workspace.as_ref().unwrap().id, workspace_id);
270
271            let workspace2 = get_by_name(&remote_repo, &workspace_name2).await?;
272            assert!(workspace2.is_some());
273            assert_eq!(
274                workspace2.as_ref().unwrap().name,
275                Some(workspace_name2.to_string())
276            );
277            assert_eq!(workspace2.as_ref().unwrap().id, workspace_id2);
278
279            Ok(remote_repo)
280        })
281        .await
282    }
283
284    #[tokio::test]
285    async fn test_get_workspace_by_name_does_not_exist() -> Result<(), OxenError> {
286        test::run_readme_remote_repo_test(|_local_repo, remote_repo| async move {
287            let workspace_name = "name_does_not_exist";
288
289            let workspace = get_by_name(&remote_repo, &workspace_name).await?;
290            assert!(workspace.is_none());
291
292            Ok(remote_repo)
293        })
294        .await
295    }
296
297    #[tokio::test]
298    async fn test_get_workspace_by_id_does_not_exist() -> Result<(), OxenError> {
299        test::run_readme_remote_repo_test(|_local_repo, remote_repo| async move {
300            let workspace_id = "id_does_not_exist";
301
302            let workspace = get(&remote_repo, &workspace_id).await?;
303            assert!(workspace.is_none());
304
305            Ok(remote_repo)
306        })
307        .await
308    }
309
310    #[tokio::test]
311    async fn test_clear_workspaces() -> Result<(), OxenError> {
312        test::run_readme_remote_repo_test(|_local_repo, remote_repo| async move {
313            // Create 10 workspaces
314            for i in 0..10 {
315                create(
316                    &remote_repo,
317                    DEFAULT_BRANCH_NAME,
318                    &format!("test_workspace_{i}"),
319                )
320                .await?;
321            }
322
323            // Clear them
324            clear(&remote_repo).await?;
325
326            // Check they are gone
327            let workspaces = list(&remote_repo).await?;
328            assert_eq!(workspaces.len(), 0);
329
330            Ok(remote_repo)
331        })
332        .await
333    }
334
335    #[tokio::test]
336    async fn test_list_workspaces() -> Result<(), OxenError> {
337        test::run_readme_remote_repo_test(|_local_repo, remote_repo| async move {
338            let branch_name = "main";
339            create(&remote_repo, branch_name, "test_workspace_id").await?;
340            create(&remote_repo, branch_name, "test_workspace_id2").await?;
341
342            let workspaces = list(&remote_repo).await?;
343            assert_eq!(workspaces.len(), 2);
344
345            Ok(remote_repo)
346        })
347        .await
348    }
349
350    #[tokio::test]
351    async fn test_list_empty_workspaces() -> Result<(), OxenError> {
352        test::run_empty_remote_repo_test(|_local_repo, remote_repo| async move {
353            let workspaces = list(&remote_repo).await?;
354            assert_eq!(workspaces.len(), 0);
355
356            Ok(remote_repo)
357        })
358        .await
359    }
360
361    #[tokio::test]
362    async fn test_delete_workspace() -> Result<(), OxenError> {
363        test::run_readme_remote_repo_test(|_local_repo, remote_repo| async move {
364            let branch_name = "main";
365            let workspace_id = "test_workspace_id";
366            let workspace = create(&remote_repo, branch_name, workspace_id).await?;
367
368            assert_eq!(workspace.id, workspace_id);
369
370            let res = delete(&remote_repo, workspace_id).await;
371            assert!(res.is_ok());
372
373            Ok(remote_repo)
374        })
375        .await
376    }
377
378    #[tokio::test]
379    async fn test_remote_commit_fails_if_schema_changed() -> Result<(), OxenError> {
380        // Skip if on windows
381        if std::env::consts::OS == "windows" {
382            return Ok(());
383        }
384
385        test::run_training_data_fully_sync_remote(|_, remote_repo| async move {
386            let remote_repo_copy = remote_repo.clone();
387
388            test::run_empty_dir_test_async(|repo_dir| async move {
389                let cloned_repo =
390                    repositories::clone_url(&remote_repo.remote.url, &repo_dir.join("new_repo"))
391                        .await?;
392
393                // Remote stage row
394                let path = test::test_nlp_classification_csv();
395
396                // Create workspace
397                let workspace_id = "my_workspace";
398                api::client::workspaces::create(&remote_repo, DEFAULT_BRANCH_NAME, &workspace_id)
399                    .await?;
400
401                // Index the dataset
402                repositories::workspaces::df::index(&cloned_repo, workspace_id, &path).await?;
403
404                log::debug!("the path in question is {path:?}");
405                let mut opts = DFOpts::empty();
406
407                opts.add_row =
408                    Some("{\"text\": \"I am a new row\", \"label\": \"neutral\"}".to_string());
409                repositories::workspaces::df(&cloned_repo, workspace_id, &path, opts).await?;
410
411                // Local add col
412                let full_path = cloned_repo.path.join(path);
413                let mut opts = DFOpts::empty();
414                opts.add_col = Some("is_something:n/a:str".to_string());
415                opts.output = Some(full_path.to_path_buf()); // write back to same path
416                command::df(&full_path, opts).await?;
417                repositories::add(&cloned_repo, &full_path).await?;
418
419                // Commit and push the changed schema
420                repositories::commit(&cloned_repo, "Changed the schema 😇")?;
421                repositories::push(&cloned_repo).await?;
422
423                // Try to commit the remote changes, should fail
424                let body = NewCommitBody {
425                    message: "Remotely committing".to_string(),
426                    author: "Test User".to_string(),
427                    email: "test@oxen.ai".to_string(),
428                };
429                let result = api::client::workspaces::commit(
430                    &remote_repo,
431                    DEFAULT_BRANCH_NAME,
432                    workspace_id,
433                    &body,
434                )
435                .await;
436                assert!(result.is_err());
437
438                // Status should have one modified file
439                let remote_status = api::client::workspaces::changes::list(
440                    &remote_repo,
441                    &workspace_id,
442                    Path::new(""),
443                    constants::DEFAULT_PAGE_NUM,
444                    constants::DEFAULT_PAGE_SIZE,
445                )
446                .await?;
447                assert_eq!(remote_status.modified_files.entries.len(), 1);
448
449                Ok(())
450            })
451            .await?;
452
453            Ok(remote_repo_copy)
454        })
455        .await
456    }
457
458    #[tokio::test]
459    async fn test_remote_commit_staging_behind_main() -> Result<(), OxenError> {
460        test::run_remote_repo_test_bounding_box_csv_pushed(|_local_repo, remote_repo| async move {
461            // Create branch behind-main off main
462            let new_branch = "behind-main";
463            let main_branch = "main";
464
465            let main_path = "images/folder";
466            let workspace =
467                api::client::workspaces::create(&remote_repo, main_branch, "test_workspace")
468                    .await?;
469            let identifier = workspace.id;
470
471            api::client::branches::create_from_branch(&remote_repo, new_branch, main_branch)
472                .await?;
473
474            // Advance head on main branch, leave behind-main behind
475            let path = test::test_img_file();
476            let result = api::client::workspaces::files::upload_single_file(
477                &remote_repo,
478                &identifier,
479                main_path,
480                path,
481            )
482            .await;
483            assert!(result.is_ok());
484
485            let body = NewCommitBody {
486                message: "Add to main".to_string(),
487                author: "Test User".to_string(),
488                email: "test@oxen.ai".to_string(),
489            };
490            api::client::workspaces::commit(&remote_repo, main_branch, &identifier, &body).await?;
491
492            let workspace =
493                api::client::workspaces::create(&remote_repo, new_branch, "test_workspace").await?;
494            let identifier = workspace.id;
495
496            // Add a file to behind-main
497            let image_path = test::test_1k_parquet();
498            let result = api::client::workspaces::files::upload_single_file(
499                &remote_repo,
500                &identifier,
501                main_path,
502                image_path,
503            )
504            .await;
505            assert!(result.is_ok());
506
507            // Make a commit to behind-main
508            let body = NewCommitBody {
509                message: "Add behind main".to_string(),
510                author: "Test User".to_string(),
511                email: "test@oxen.ai".to_string(),
512            };
513            api::client::workspaces::commit(&remote_repo, new_branch, &identifier, &body).await?;
514
515            let workspace =
516                api::client::workspaces::create(&remote_repo, new_branch, "test_workspace").await?;
517            let identifier = workspace.id;
518
519            // Add file at images/folder to behind-main, committed to main
520            let image_path = test::test_100_parquet();
521            let result = api::client::workspaces::files::upload_single_file(
522                &remote_repo,
523                &identifier,
524                main_path,
525                image_path,
526            )
527            .await;
528            assert!(result.is_ok());
529
530            // Check status: if valid, there should be an entry here for the file at images/folder
531            let page_num = constants::DEFAULT_PAGE_NUM;
532            let page_size = constants::DEFAULT_PAGE_SIZE;
533            let path = Path::new("");
534
535            let remote_status = api::client::workspaces::changes::list(
536                &remote_repo,
537                &identifier,
538                path,
539                page_num,
540                page_size,
541            )
542            .await?;
543
544            assert_eq!(remote_status.added_files.entries.len(), 1);
545            assert_eq!(remote_status.added_files.total_entries, 1);
546
547            Ok(remote_repo)
548        })
549        .await
550    }
551
552    #[tokio::test]
553    async fn test_not_named_workspaces_closing_after_commit() -> Result<(), OxenError> {
554        test::run_remote_repo_test_bounding_box_csv_pushed(|_local_repo, remote_repo| async move {
555            let workspace_id = "test_workspace_id";
556            api::client::workspaces::create(&remote_repo, DEFAULT_BRANCH_NAME, workspace_id)
557                .await?;
558            let path = test::test_img_file();
559            let result = api::client::workspaces::files::upload_single_file(
560                &remote_repo,
561                &&workspace_id,
562                "",
563                path,
564            )
565            .await;
566            assert!(result.is_ok());
567
568            let body = NewCommitBody {
569                message: "Add to main".to_string(),
570                author: "Test User".to_string(),
571                email: "test@oxen.ai".to_string(),
572            };
573            api::client::workspaces::commit(&remote_repo, DEFAULT_BRANCH_NAME, workspace_id, &body)
574                .await?;
575            let get_result = api::client::workspaces::get(&remote_repo, &workspace_id).await?;
576
577            assert!(get_result.is_none());
578
579            Ok(remote_repo)
580        })
581        .await
582    }
583
584    #[tokio::test]
585    async fn test_named_workspaces_not_closing_after_commit() -> Result<(), OxenError> {
586        test::run_remote_repo_test_bounding_box_csv_pushed(|_local_repo, remote_repo| async move {
587            let workspace_name = "test_workspace_name";
588            let workspace_id = "test_workspace_id";
589            api::client::workspaces::create_with_name(
590                &remote_repo,
591                DEFAULT_BRANCH_NAME,
592                workspace_id,
593                workspace_name,
594            )
595            .await?;
596            let path = test::test_img_file();
597            let result = api::client::workspaces::files::upload_single_file(
598                &remote_repo,
599                &workspace_name,
600                "",
601                path,
602            )
603            .await;
604            assert!(result.is_ok());
605
606            let body = NewCommitBody {
607                message: "Add to main".to_string(),
608                author: "Test User".to_string(),
609                email: "test@oxen.ai".to_string(),
610            };
611            api::client::workspaces::commit(
612                &remote_repo,
613                DEFAULT_BRANCH_NAME,
614                workspace_name,
615                &body,
616            )
617            .await?;
618            let workspace = api::client::workspaces::get(&remote_repo, &workspace_name).await?;
619            assert!(workspace.is_some());
620            assert_eq!(workspace.as_ref().unwrap().id, workspace_id);
621            Ok(remote_repo)
622        })
623        .await
624    }
625}