From de1f84b7927eafd653c1cb8439bae7d4c7089ee5 Mon Sep 17 00:00:00 2001 From: xphoniex Date: Tue, 22 Nov 2022 13:16:34 +0000 Subject: [PATCH] Migrate `/projects`: `root`, `project`, `commit`, `activity` handlers Signed-off-by: xphoniex --- radicle-httpd/Cargo.toml | 8 +- radicle-httpd/src/api.rs | 39 ++++++++ radicle-httpd/src/api/error.rs | 14 ++- radicle-httpd/src/api/v1.rs | 4 +- radicle-httpd/src/api/v1/delegates.rs | 25 ++---- radicle-httpd/src/api/v1/projects.rs | 123 ++++++++++++++++++++++++++ 6 files changed, 190 insertions(+), 23 deletions(-) create mode 100644 radicle-httpd/src/api/v1/projects.rs diff --git a/radicle-httpd/Cargo.toml b/radicle-httpd/Cargo.toml index 69b6b7f6b..bfd912590 100644 --- a/radicle-httpd/Cargo.toml +++ b/radicle-httpd/Cargo.toml @@ -18,6 +18,7 @@ logfmt = [ anyhow = { version = "1" } axum = { version = "0.5.16", default-features = false, features = ["json", "headers", "query"] } axum-server = { version = "0.4.2", default-features = false } +chrono = { version = "0.4.22" } ethers-core = { version = "1.0" } fastrand = { version = "1.7.0" } flate2 = { version = "1" } @@ -29,7 +30,7 @@ siwe = { version = "0.5" } thiserror = { version = "1" } time = { version = "0.3.17" } tokio = { version = "1.21", default-features = false, features = ["macros", "rt-multi-thread"] } -tower-http = { version = "0.3.4", default-features = false, features = ["trace", "cors"] } +tower-http = { version = "0.3.4", default-features = false, features = ["trace", "cors", "set-header"] } tracing = { version = "0.1.37", default-features = false, features = ["std", "log"] } tracing-logfmt = { version = "0.2", optional = true } tracing-subscriber = { version = "0.3", default-features = false, features = ["std", "ansi", "fmt"] } @@ -37,3 +38,8 @@ tracing-subscriber = { version = "0.3", default-features = false, features = ["s [dependencies.radicle] path = "../radicle" version = "0.2.0" + +[dependencies.radicle-surf] +git = "https://github.com/radicle-dev/radicle-git" +features = ["serialize"] +rev = "d3115a22158c8395705babefdc89049f7510d32d" diff --git a/radicle-httpd/src/api.rs b/radicle-httpd/src/api.rs index 215650f36..8650460e3 100644 --- a/radicle-httpd/src/api.rs +++ b/radicle-httpd/src/api.rs @@ -16,6 +16,9 @@ use tower_http::cors::{self, CorsLayer}; use tower_http::trace::TraceLayer; use tracing::Span; +use radicle::cob::issue::Issues; +use radicle::identity::{Doc, Id}; +use radicle::storage::{ReadRepository, WriteStorage}; use radicle::Profile; mod auth; @@ -41,6 +44,22 @@ impl Context { sessions: Default::default(), } } + + pub fn project_info(&self, id: Id) -> Result { + let storage = &self.profile.storage; + let repo = storage.repository(id)?; + let (_, head) = repo.head()?; + let Doc { payload, .. } = repo.project_of(self.profile.id())?; + let issues = (Issues::open(self.profile.public_key, &repo)?).count()?; + + Ok(project::Info { + payload, + head, + issues, + patches: 0, + id, + }) + } } pub fn router(ctx: Context) -> Router { @@ -115,3 +134,23 @@ pub struct PaginationQuery { pub page: Option, pub per_page: Option, } + +mod project { + use radicle::git::Oid; + use radicle::identity::project::Payload; + use radicle::identity::Id; + use serde::Serialize; + + /// Project info. + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + pub struct Info { + /// Project metadata. + #[serde(flatten)] + pub payload: Payload, + pub head: Oid, + pub patches: usize, + pub issues: usize, + pub id: Id, + } +} diff --git a/radicle-httpd/src/api/error.rs b/radicle-httpd/src/api/error.rs index b8c5e1d17..7100b1a5d 100644 --- a/radicle-httpd/src/api/error.rs +++ b/radicle-httpd/src/api/error.rs @@ -42,7 +42,19 @@ pub enum Error { /// Storage error. #[error(transparent)] - StorageError(#[from] radicle::storage::Error), + Storage(#[from] radicle::storage::Error), + + /// Cob store error. + #[error(transparent)] + CobStore(#[from] radicle::cob::store::Error), + + /// Git project error. + #[error(transparent)] + GitProject(#[from] radicle::storage::git::ProjectError), + + /// Surf error. + #[error(transparent)] + SurfCommit(#[from] radicle_surf::commit::Error), } impl Error { diff --git a/radicle-httpd/src/api/v1.rs b/radicle-httpd/src/api/v1.rs index b39bc609f..543d4dc4f 100644 --- a/radicle-httpd/src/api/v1.rs +++ b/radicle-httpd/src/api/v1.rs @@ -1,5 +1,6 @@ mod delegates; mod node; +mod projects; mod sessions; use axum::Router; @@ -10,7 +11,8 @@ pub fn router(ctx: Context) -> Router { let routes = Router::new() .merge(node::router(ctx.clone())) .merge(sessions::router(ctx.clone())) - .merge(delegates::router(ctx)); + .merge(delegates::router(ctx.clone())) + .merge(projects::router(ctx)); Router::new().nest("/v1", routes) } diff --git a/radicle-httpd/src/api/v1/delegates.rs b/radicle-httpd/src/api/v1/delegates.rs index 05abb9d52..c28385b0b 100644 --- a/radicle-httpd/src/api/v1/delegates.rs +++ b/radicle-httpd/src/api/v1/delegates.rs @@ -1,31 +1,17 @@ use axum::response::IntoResponse; use axum::routing::get; use axum::{Extension, Json, Router}; -use serde::Serialize; -use radicle::cob::store::Store; -use radicle::git::Oid; -use radicle::identity::project::Payload; +use radicle::cob::issue::Issues; use radicle::identity::{Did, Doc}; use radicle::storage::{ReadRepository, WriteStorage}; use crate::api::axum_extra::{Path, Query}; use crate::api::error::Error; +use crate::api::project::Info; use crate::api::Context; use crate::api::PaginationQuery; -/// Project info. -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -pub struct Info { - /// Project metadata. - #[serde(flatten)] - pub payload: Payload, - pub head: Oid, - pub patches: usize, - pub issues: usize, -} - pub fn router(ctx: Context) -> Router { Router::new() .route( @@ -58,15 +44,14 @@ async fn delegates_projects_handler( return None; } - let Ok(cobs) = Store::open(ctx.profile.public_key, &repo) else { return None }; - let Ok(issues) = cobs.issues().count() else { return None }; - let Ok(patches) = cobs.patches().count() else { return None }; + let Ok(issues) = Issues::open(ctx.profile.public_key, &repo) else { return None }; + let Ok(issues) = (*issues).count() else { return None }; Some(Info { payload, head, issues, - patches, + patches: 0, id, }) }) diff --git a/radicle-httpd/src/api/v1/projects.rs b/radicle-httpd/src/api/v1/projects.rs new file mode 100644 index 000000000..1ab121253 --- /dev/null +++ b/radicle-httpd/src/api/v1/projects.rs @@ -0,0 +1,123 @@ +use axum::handler::Handler; +use axum::http::{header, HeaderValue}; +use axum::response::IntoResponse; +use axum::routing::get; +use axum::{Extension, Json, Router}; +use hyper::StatusCode; +use serde_json::json; +use tower_http::set_header::SetResponseHeaderLayer; + +use radicle::cob::issue::Issues; +use radicle::identity::{Doc, Id}; +use radicle::storage::{Oid, ReadRepository, WriteRepository, WriteStorage}; +use radicle_surf::git::History; + +use crate::api::axum_extra::{Path, Query}; +use crate::api::error::Error; +use crate::api::project::Info; +use crate::api::{Context, PaginationQuery}; + +const CACHE_1_HOUR: &str = "public, max-age=3600, must-revalidate"; + +pub fn router(ctx: Context) -> Router { + Router::new() + .route("/projects", get(project_root_handler)) + .route("/projects/:project", get(project_handler)) + .route("/projects/:project/commits/:sha", get(commit_handler)) + .route( + "/projects/:project/activity", + get( + activity_handler.layer(SetResponseHeaderLayer::if_not_present( + header::CACHE_CONTROL, + HeaderValue::from_static(CACHE_1_HOUR), + )), + ), + ) + .layer(Extension(ctx)) +} + +/// List all projects. +/// `GET /projects` +async fn project_root_handler( + Extension(ctx): Extension, + Query(qs): Query, +) -> impl IntoResponse { + let PaginationQuery { page, per_page } = qs; + let page = page.unwrap_or(0); + let per_page = per_page.unwrap_or(10); + let storage = &ctx.profile.storage; + let projects = storage + .projects()? + .into_iter() + .filter_map(|id| { + let Ok(repo) = storage.repository(id) else { return None }; + let Ok((_, head)) = repo.head() else { return None }; + let Ok(Doc { payload, .. }) = repo.project_of(ctx.profile.id()) else { return None }; + let Ok(issues) = Issues::open(ctx.profile.public_key, &repo) else { return None }; + let Ok(issues) = (*issues).count() else { return None }; + + Some(Info { + payload, + head, + issues, + patches: 0, + id, + }) + }) + .skip(page * per_page) + .take(per_page) + .collect::>(); + + Ok::<_, Error>(Json(projects)) +} + +/// Get project metadata. +/// `GET /projects/:project` +async fn project_handler( + Extension(ctx): Extension, + Path(id): Path, +) -> impl IntoResponse { + let info = ctx.project_info(id)?; + + Ok::<_, Error>(Json(info)) +} + +/// Get project commit. +/// `GET /projects/:project/commits/:sha` +async fn commit_handler( + Extension(ctx): Extension, + Path((project, sha)): Path<(Id, Oid)>, +) -> impl IntoResponse { + let storage = &ctx.profile.storage; + let repo = storage.repository(project)?; + let commit = radicle_surf::commit(&repo.raw().into(), sha)?; + + Ok::<_, Error>(Json(json!(commit))) +} + +/// Get project activity for the past year. +/// `GET /projects/:project/activity` +async fn activity_handler( + Extension(ctx): Extension, + Path(project): Path, +) -> impl IntoResponse { + let current_date = chrono::Utc::now().timestamp(); + let one_year_ago = chrono::Duration::weeks(52); + let storage = &ctx.profile.storage; + let repo = storage.repository(project)?; + let (_, head) = repo.head()?; + let timestamps = History::new(repo.raw().into(), head) + .unwrap() + .filter_map(|a| { + if let Ok(a) = a { + let seconds = a.committer.time.seconds(); + if seconds > current_date - one_year_ago.num_seconds() { + return Some(seconds); + } + } + None + }) + .collect::>(); + + Ok::<_, Error>((StatusCode::OK, Json(json!({ "activity": timestamps })))) +}