Radish alpha
r
Radicle web interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
http: Add repo search by name
Sebastian Martinez committed 1 year ago
commit 05632a15d7ff200650268a646e4f65c6b92b3830
parent 06e33f4a7943d6a4efadd5db8ee6f210c331015b
6 files changed +382 -34
modified http-server/src/api.rs
@@ -224,6 +224,98 @@ impl PatchState {
    }
}

+
mod search {
+
    use std::cmp::Ordering;
+

+
    use nonempty::NonEmpty;
+
    use serde::{Deserialize, Serialize};
+
    use serde_json::json;
+

+
    use radicle::crypto::Verified;
+
    use radicle::identity::{Project, RepoId};
+
    use radicle::node::routing::Store;
+
    use radicle::node::AliasStore;
+
    use radicle::node::Database;
+
    use radicle::profile::Aliases;
+
    use radicle::storage::RepositoryInfo;
+

+
    #[derive(Serialize, Deserialize)]
+
    #[serde(rename_all = "camelCase")]
+
    pub struct SearchQueryString {
+
        pub q: Option<String>,
+
        pub page: Option<usize>,
+
        pub per_page: Option<usize>,
+
    }
+

+
    #[derive(Serialize, Deserialize, Eq, Debug)]
+
    pub struct SearchResult {
+
        pub rid: RepoId,
+
        #[serde(flatten)]
+
        pub payload: Project,
+
        pub delegates: NonEmpty<serde_json::Value>,
+
        pub seeds: usize,
+
        #[serde(skip)]
+
        pub index: usize,
+
    }
+

+
    impl SearchResult {
+
        pub fn new(
+
            q: &str,
+
            info: RepositoryInfo<Verified>,
+
            db: &Database,
+
            aliases: &Aliases,
+
        ) -> Option<Self> {
+
            if info.doc.visibility.is_private() {
+
                return None;
+
            }
+
            let payload = info.doc.project().ok()?;
+
            let index = payload.name().find(q)?;
+
            let seeds = db.count(&info.rid).unwrap_or_default();
+
            let delegates = info.doc.delegates.map(|did| match aliases.alias(&did) {
+
                Some(alias) => json!({
+
                    "id": did,
+
                    "alias": alias,
+
                }),
+
                None => json!({
+
                    "id": did,
+
                }),
+
            });
+

+
            Some(SearchResult {
+
                rid: info.rid,
+
                payload,
+
                delegates,
+
                seeds,
+
                index,
+
            })
+
        }
+
    }
+

+
    impl Ord for SearchResult {
+
        fn cmp(&self, other: &Self) -> Ordering {
+
            match (self.index, other.index) {
+
                (0, 0) => self.seeds.cmp(&other.seeds),
+
                (0, _) => std::cmp::Ordering::Less,
+
                (_, 0) => std::cmp::Ordering::Greater,
+
                (ai, bi) if ai == bi => self.seeds.cmp(&other.seeds),
+
                (_, _) => self.seeds.cmp(&other.seeds),
+
            }
+
        }
+
    }
+

+
    impl PartialOrd for SearchResult {
+
        fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
+
            Some(self.cmp(other))
+
        }
+
    }
+

+
    impl PartialEq for SearchResult {
+
        fn eq(&self, other: &Self) -> bool {
+
            self.rid == other.rid
+
        }
+
    }
+
}
+

mod project {
    use serde::Serialize;
    use serde_json::Value;
modified http-server/src/api/v1/delegates.rs
@@ -118,7 +118,7 @@ mod routes {
    use axum::http::StatusCode;
    use serde_json::json;

-
    use crate::test::{self, get, HEAD, RID};
+
    use crate::test::{self, get, CONTRIBUTOR_ALIAS, DID, HEAD, RID};

    #[tokio::test]
    async fn test_delegates_projects() {
@@ -147,8 +147,8 @@ mod routes {
                "defaultBranch": "master",
                "delegates": [
                  {
-
                    "id": "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
-
                    "alias": "seed"
+
                    "id": DID,
+
                    "alias": CONTRIBUTOR_ALIAS
                  }
                ],
                "threshold": 1,
@@ -169,6 +169,34 @@ mod routes {
                "id": RID,
                "seeding": 0,
              },
+
              {
+
                "name": "again-hello-world",
+
                "description": "Rad repository for sorting",
+
                "defaultBranch": "master",
+
                "delegates": [
+
                  {
+
                    "id": DID,
+
                    "alias": CONTRIBUTOR_ALIAS
+
                  },
+
                ],
+
                "threshold": 1,
+
                "visibility": {
+
                  "type": "public"
+
                },
+
                "head": "344dcd184df5bf37aab6c107fa9371a1c5b3321a",
+
                "patches": {
+
                  "open": 0,
+
                  "draft": 0,
+
                  "archived": 0,
+
                  "merged": 0,
+
                },
+
                "issues": {
+
                  "open": 0,
+
                  "closed": 0,
+
                },
+
                "id": "rad:z4GypKmh1gkEfmkXtarcYnkvtFUfE",
+
                "seeding": 0,
+
              }
            ])
        );

@@ -197,8 +225,8 @@ mod routes {
                "defaultBranch": "master",
                "delegates": [
                  {
-
                    "id": "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
-
                    "alias": "seed"
+
                    "id": DID,
+
                    "alias": CONTRIBUTOR_ALIAS
                  }
                ],
                "threshold": 1,
@@ -218,6 +246,34 @@ mod routes {
                },
                "id": RID,
                "seeding": 0,
+
              },
+
              {
+
                "name": "again-hello-world",
+
                "description": "Rad repository for sorting",
+
                "defaultBranch": "master",
+
                "delegates": [
+
                  {
+
                    "id": DID,
+
                    "alias": CONTRIBUTOR_ALIAS
+
                  },
+
                ],
+
                "threshold": 1,
+
                "visibility": {
+
                  "type": "public"
+
                },
+
                "head": "344dcd184df5bf37aab6c107fa9371a1c5b3321a",
+
                "patches": {
+
                  "open": 0,
+
                  "draft": 0,
+
                  "archived": 0,
+
                  "merged": 0,
+
                },
+
                "issues": {
+
                  "open": 0,
+
                  "closed": 0,
+
                },
+
                "id": "rad:z4GypKmh1gkEfmkXtarcYnkvtFUfE",
+
                "seeding": 0,
              }
            ])
        );
modified http-server/src/api/v1/projects.rs
@@ -1,17 +1,16 @@
-
use std::collections::{BTreeMap, HashMap};
+
use std::collections::{BTreeMap, BTreeSet, HashMap};

use axum::extract::{DefaultBodyLimit, State};
-
use axum::handler::Handler;
-
use axum::http::{header, HeaderValue};
-
use axum::response::{IntoResponse, Response};
+
use axum::http::header;
+
use axum::response::IntoResponse;
use axum::routing::{get, patch, post};
use axum::{Json, Router};
use axum_auth::AuthBearer;
use hyper::StatusCode;
use radicle_surf::blob::BlobRef;
+
use radicle_surf::{diff, Glob, Oid, Repository};
use serde::{Deserialize, Serialize};
use serde_json::json;
-
use tower_http::set_header::SetResponseHeaderLayer;

use radicle::cob::{
    issue, issue::cache::Issues as _, patch, patch::cache::Patches as _, resolve_embed, Author,
@@ -21,37 +20,24 @@ use radicle::identity::{Did, RepoId};
use radicle::node::routing::Store;
use radicle::node::{AliasStore, Node, NodeId};
use radicle::storage::{ReadRepository, ReadStorage, RemoteRepository, WriteRepository};
-
use radicle_surf::{diff, Glob, Oid, Repository};

use crate::api::error::Error;
use crate::api::project::Info;
+
use crate::api::search::{SearchQueryString, SearchResult};
use crate::api::{self, announce_refs, CobsQuery, Context, PaginationQuery, ProjectQuery};
-
use crate::axum_extra::{immutable_response, Path, Query};
+
use crate::axum_extra::{cached_response, immutable_response, Path, Query};

-
const CACHE_1_HOUR: &str = "public, max-age=3600, must-revalidate";
const MAX_BODY_LIMIT: usize = 4_194_304;

pub fn router(ctx: Context) -> Router {
    Router::new()
        .route("/projects", get(project_root_handler))
+
        .route("/projects/search", get(project_search_handler))
        .route("/projects/:project", get(project_handler))
        .route("/projects/:project/commits", get(history_handler))
        .route("/projects/:project/commits/:sha", get(commit_handler))
        .route("/projects/:project/diff/:base/:oid", get(diff_handler))
-
        .route(
-
            "/projects/:project/activity",
-
            get(
-
                activity_handler.layer(SetResponseHeaderLayer::if_not_present(
-
                    header::CACHE_CONTROL,
-
                    |response: &Response| {
-
                        response
-
                            .status()
-
                            .is_success()
-
                            .then_some(HeaderValue::from_static(CACHE_1_HOUR))
-
                    },
-
                )),
-
            ),
-
        )
+
        .route("/projects/:project/activity", get(activity_handler))
        .route("/projects/:project/tree/:sha/", get(tree_handler_root))
        .route("/projects/:project/tree/:sha/*path", get(tree_handler))
        .route(
@@ -169,6 +155,42 @@ async fn project_root_handler(
    Ok::<_, Error>(Json(infos))
}

+
/// Search repositories by name.
+
/// `GET /projects/search?q=<query>`
+
///
+
/// We obtain the byte index of the first character of the query that matches the repo name.
+
/// And skip if the query doesn't match the repo name.
+
///
+
/// Sorting algorithm:
+
/// If both byte indices are 0, compare by seeding count.
+
/// A repo name with a byte index of 0 should come before non-zero indices.
+
/// If both indices are non-zero and equal, then compare by seeding count.
+
/// If none of the above, all non-zero indices are compared by their seeding count primarily.
+
async fn project_search_handler(
+
    State(ctx): State<Context>,
+
    Query(SearchQueryString { q, per_page, page }): Query<SearchQueryString>,
+
) -> impl IntoResponse {
+
    let q = q.unwrap_or_default();
+
    let page = page.unwrap_or(0);
+
    let per_page = per_page.unwrap_or(10);
+
    let storage = &ctx.profile.storage;
+
    let aliases = &ctx.profile.aliases();
+
    let db = &ctx.profile.database()?;
+
    let found_repos = storage
+
        .repositories()?
+
        .into_iter()
+
        .filter_map(|info| SearchResult::new(&q, info, db, aliases))
+
        .collect::<BTreeSet<SearchResult>>();
+

+
    let found_repos = found_repos
+
        .into_iter()
+
        .skip(page * per_page)
+
        .take(per_page)
+
        .collect::<Vec<_>>();
+

+
    Ok::<_, Error>(cached_response(found_repos, 600).into_response())
+
}
+

/// Get project metadata.
/// `GET /projects/:project`
async fn project_handler(State(ctx): State<Context>, Path(rid): Path<RepoId>) -> impl IntoResponse {
@@ -407,7 +429,7 @@ async fn activity_handler(
        })
        .collect::<Vec<i64>>();

-
    Ok::<_, Error>((StatusCode::OK, Json(json!({ "activity": timestamps }))))
+
    Ok::<_, Error>(cached_response(json!({ "activity": timestamps }), 3600))
}

/// Get project source tree for '/' path.
@@ -1034,8 +1056,8 @@ mod routes {
                "delegates": [
                  {
                    "id": DID,
-
                    "alias": "seed"
-
                  }
+
                    "alias": CONTRIBUTOR_ALIAS
+
                  },
                ],
                "threshold": 1,
                "visibility": {
@@ -1055,6 +1077,34 @@ mod routes {
                "id": RID,
                "seeding": 0,
              },
+
              {
+
                "name": "again-hello-world",
+
                "description": "Rad repository for sorting",
+
                "defaultBranch": "master",
+
                "delegates": [
+
                  {
+
                    "id": DID,
+
                    "alias": CONTRIBUTOR_ALIAS
+
                  }
+
                ],
+
                "threshold": 1,
+
                "visibility": {
+
                  "type": "public"
+
                },
+
                "head": "344dcd184df5bf37aab6c107fa9371a1c5b3321a",
+
                "patches": {
+
                  "open": 0,
+
                  "draft": 0,
+
                  "archived": 0,
+
                  "merged": 0,
+
                },
+
                "issues": {
+
                  "open": 0,
+
                  "closed": 0,
+
                },
+
                "id": "rad:z4GypKmh1gkEfmkXtarcYnkvtFUfE",
+
                "seeding": 0,
+
              },
            ])
        );

@@ -1075,7 +1125,7 @@ mod routes {
                "delegates": [
                  {
                    "id": DID,
-
                    "alias": "seed"
+
                    "alias": CONTRIBUTOR_ALIAS
                  }
                ],
                "threshold": 1,
@@ -1095,7 +1145,35 @@ mod routes {
                },
                "id": RID,
                "seeding": 0,
-
              }
+
              },
+
              {
+
                "name": "again-hello-world",
+
                "description": "Rad repository for sorting",
+
                "defaultBranch": "master",
+
                "delegates": [
+
                  {
+
                    "id": DID,
+
                    "alias": CONTRIBUTOR_ALIAS
+
                  },
+
                ],
+
                "threshold": 1,
+
                "visibility": {
+
                  "type": "public"
+
                },
+
                "head": "344dcd184df5bf37aab6c107fa9371a1c5b3321a",
+
                "patches": {
+
                  "open": 0,
+
                  "draft": 0,
+
                  "archived": 0,
+
                  "merged": 0,
+
                },
+
                "issues": {
+
                  "open": 0,
+
                  "closed": 0,
+
                },
+
                "id": "rad:z4GypKmh1gkEfmkXtarcYnkvtFUfE",
+
                "seeding": 0,
+
              },
            ])
        );
    }
@@ -1116,7 +1194,7 @@ mod routes {
               "delegates": [
                 {
                   "id": DID,
-
                   "alias": "seed"
+
                   "alias": CONTRIBUTOR_ALIAS,
                 }
               ],
               "threshold": 1,
@@ -1141,6 +1219,73 @@ mod routes {
    }

    #[tokio::test]
+
    async fn test_search_projects() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let app = super::router(seed(tmp.path()));
+
        let response = get(&app, format!("/projects/search?q=hello")).await;
+

+
        assert_eq!(response.status(), StatusCode::OK);
+
        assert_eq!(
+
            response.json().await,
+
            json!([
+
              {
+
                "rid": "rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp",
+
                "name": "hello-world",
+
                "description": "Rad repository for tests",
+
                "defaultBranch": "master",
+
                "delegates": [
+
                  {
+
                    "id": DID,
+
                    "alias": CONTRIBUTOR_ALIAS
+
                  }
+
                ],
+
                "seeds": 0,
+
              },
+
              {
+
                "rid": "rad:z4GypKmh1gkEfmkXtarcYnkvtFUfE",
+
                "name": "again-hello-world",
+
                "description": "Rad repository for sorting",
+
                "defaultBranch": "master",
+
                "delegates": [
+
                  {
+
                    "id": DID,
+
                    "alias": CONTRIBUTOR_ALIAS
+
                  },
+
                ],
+
                "seeds": 0,
+
              },
+
            ])
+
        );
+
    }
+

+
    #[tokio::test]
+
    async fn test_search_projects_pagination() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let app = super::router(seed(tmp.path()));
+
        let response = get(&app, format!("/projects/search?q=hello&perPage=1")).await;
+

+
        assert_eq!(response.status(), StatusCode::OK);
+
        assert_eq!(
+
            response.json().await,
+
            json!([
+
              {
+
                "rid": "rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp",
+
                "name": "hello-world",
+
                "description": "Rad repository for tests",
+
                "defaultBranch": "master",
+
                "delegates": [
+
                  {
+
                    "id": DID,
+
                    "alias": CONTRIBUTOR_ALIAS,
+
                  }
+
                ],
+
                "seeds": 0,
+
              },
+
            ])
+
        );
+
    }
+

+
    #[tokio::test]
    async fn test_projects_not_found() {
        let tmp = tempfile::tempdir().unwrap();
        let app = super::router(seed(tmp.path()));
modified http-server/src/api/v1/stats.rs
@@ -37,6 +37,6 @@ mod routes {
        let response = get(&app, "/stats").await;

        assert_eq!(response.status(), StatusCode::OK);
-
        assert_eq!(response.json().await, json!({ "repos": { "total": 2 } }));
+
        assert_eq!(response.json().await, json!({ "repos": { "total": 3 } }));
    }
}
modified http-server/src/axum_extra.rs
@@ -97,3 +97,15 @@ pub fn immutable_response(data: impl serde::Serialize) -> impl IntoResponse {
        Json(data),
    )
}
+

+
/// Add a Cache-Control header that marks the response as must-revalidate and
+
/// instructs clients to cache the response for `max_age_seconds` .
+
pub fn cached_response(data: impl serde::Serialize, max_age_in_seconds: u64) -> impl IntoResponse {
+
    (
+
        [(
+
            header::CACHE_CONTROL,
+
            format!("public, max-age={max_age_in_seconds}, must-revalidate"),
+
        )],
+
        Json(data),
+
    )
+
}
modified http-server/src/test.rs
@@ -265,6 +265,49 @@ fn seed_with_signer<G: Signer>(dir: &Path, profile: radicle::Profile, signer: &G
        .unwrap();
    tracing::debug!(target: "test", "Contributor patch: {}", patch.id());

+
    let workdir = dir.join("again-hello-world");
+

+
    env::set_var(env::GIT_COMMITTER_DATE, TIMESTAMP.to_string());
+

+
    fs::create_dir_all(&workdir).unwrap();
+

+
    // add commits to workdir (repo)
+
    let mut opts = git2::RepositoryInitOptions::new();
+
    opts.initial_head(DEFAULT_BRANCH);
+
    let repo = git2::Repository::init_opts(&workdir, &opts).unwrap();
+
    let tree = radicle::git::write_tree(
+
        Path::new("README"),
+
        "Hello World Again!\n".as_bytes(),
+
        &repo,
+
    )
+
    .unwrap();
+

+
    let sig_time = git2::Time::new(1673001014, 0);
+
    let sig = git2::Signature::new("Alice Liddell", "alice@radicle.xyz", &sig_time).unwrap();
+

+
    repo.commit(Some("HEAD"), &sig, &sig, "Initial commit\n", &tree, &[])
+
        .unwrap();
+

+
    repo.checkout_tree(tree.as_object(), None).unwrap();
+

+
    // rad init
+
    let repo = git2::Repository::open(&workdir).unwrap();
+
    let name = "again-hello-world".to_string();
+
    let description = "Rad repository for sorting".to_string();
+
    let branch = RefString::try_from(DEFAULT_BRANCH).unwrap();
+
    let visibility = Visibility::default();
+
    let (rid, _, _) = radicle::rad::init(
+
        &repo,
+
        &name,
+
        &description,
+
        branch,
+
        visibility,
+
        signer,
+
        &profile.storage,
+
    )
+
    .unwrap();
+
    policies.seed(&rid, node::policy::Scope::All).unwrap();
+

    let options = crate::Options {
        aliases: std::collections::HashMap::new(),
        listen: std::net::SocketAddr::from(([0, 0, 0, 0], 8080)),