Skip to content

Commit 3d6c36f

Browse files
committed
feat: add endpoints for webhooks
Add endpoints on /projects/<RID>/webhooks: - GET endpoint returns a list of webhooks configured on the repo - POST endpoint creates a new webhook for a repo - DELETE endpoint deletes webhook(s) for a repo, optionally based on the url
1 parent 2cb681f commit 3d6c36f

File tree

15 files changed

+581
-66
lines changed

15 files changed

+581
-66
lines changed

src/api.rs

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ mod v1;
3131

3232
use crate::api::error::Error;
3333
use crate::cache::Cache;
34-
use crate::session::store::{DbSession, SessionStoreError};
34+
use crate::store::{DbStore, DbStoreError, DbStoreTrait, WebhookDb};
3535
use crate::Options;
3636

3737
pub const RADICLE_VERSION: &str = env!("RADICLE_VERSION");
@@ -91,7 +91,7 @@ impl Context {
9191
}
9292

9393
/// Get a repository by RID, checking to make sure we're allowed to view it.
94-
pub fn repo(&self, rid: RepoId) -> Result<(Repository, DocAt), error::Error> {
94+
pub fn repo(&self, rid: RepoId) -> Result<(Repository, DocAt), Error> {
9595
let repo = self.profile.storage.repository(rid)?;
9696
let doc = repo.identity_doc()?;
9797
// Don't allow accessing private repos.
@@ -101,20 +101,32 @@ impl Context {
101101
Ok((repo, doc))
102102
}
103103

104-
pub fn open_session_db(&self) -> Result<DbSession, error::Error> {
105-
Ok(DbSession::open(self.get_session_db_path()?)?)
104+
pub fn open_session_db(&self) -> Result<DbStore, Error> {
105+
Ok(DbStore::open(self.get_session_db_path()?)?)
106106
}
107107

108-
pub fn read_session_db(&self) -> Result<DbSession, error::Error> {
109-
Ok(DbSession::reader(self.get_session_db_path()?)?)
108+
pub fn read_session_db(&self) -> Result<DbStore, Error> {
109+
Ok(DbStore::reader(self.get_session_db_path()?)?)
110110
}
111111

112-
fn get_session_db_path(&self) -> Result<std::path::PathBuf, SessionStoreError> {
112+
fn get_session_db_path(&self) -> Result<std::path::PathBuf, DbStoreError> {
113113
let dir = self.profile.home.path().join(HTTPD_DIR);
114114
if !dir.exists() {
115115
fs::create_dir_all(&dir)?;
116116
}
117-
Ok(dir.join(crate::session::store::SESSIONS_DB_FILE))
117+
Ok(dir.join(crate::store::SESSIONS_DB_FILE))
118+
}
119+
120+
pub fn open_webhooks_db(&self) -> Result<WebhookDb, Error> {
121+
Ok(WebhookDb::open(self.get_webhook_db_path()?)?)
122+
}
123+
124+
fn get_webhook_db_path(&self) -> Result<std::path::PathBuf, DbStoreError> {
125+
let dir = self.profile.home.path().join(HTTPD_DIR);
126+
if !dir.exists() {
127+
fs::create_dir_all(&dir)?;
128+
}
129+
Ok(dir.join(crate::store::WEBHOOKS_DB_FILE))
118130
}
119131

120132
#[cfg(test)]

src/api/auth.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ use radicle::node::Alias;
1010

1111
use crate::api::error::Error;
1212
use crate::api::Context;
13-
use crate::session::store::SessionStoreError;
13+
use crate::store::DbStoreError;
1414

1515
pub const UNAUTHORIZED_SESSIONS_EXPIRATION: Duration = Duration::seconds(60);
1616
pub const DEFAULT_AUTHORIZED_SESSIONS_EXPIRATION: Duration = Duration::weeks(1);
@@ -82,14 +82,14 @@ impl Session {
8282
&mut self,
8383
expiry: Duration,
8484
current_time: OffsetDateTime,
85-
) -> Result<(), SessionStoreError> {
85+
) -> Result<(), DbStoreError> {
8686
// zero or negative expiration duration means that the session does not expire
8787
self.expires_at = if expiry.is_zero() || expiry.is_negative() {
88-
OffsetDateTime::from_unix_timestamp(0).map_err(SessionStoreError::InvalidTimestamp)?
88+
OffsetDateTime::from_unix_timestamp(0).map_err(DbStoreError::InvalidTimestamp)?
8989
} else {
9090
current_time
9191
.checked_add(expiry)
92-
.ok_or(SessionStoreError::InvalidTimestampOperation)?
92+
.ok_or(DbStoreError::InvalidTimestampOperation)?
9393
};
9494

9595
Ok(())

src/api/error.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ pub enum Error {
100100

101101
/// Session store error.
102102
#[error(transparent)]
103-
SessionStore(#[from] crate::session::store::SessionStoreError),
103+
SessionStore(#[from] crate::store::DbStoreError),
104104

105105
/// Session expiration time error
106106
#[error(transparent)]

src/api/json.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ pub(crate) fn patch(
152152
patch_comment(id, c, aliases)
153153
}).collect::<Vec<_>>(),
154154
"timestamp": rev.timestamp().as_secs(),
155-
"reviews": rev.reviews().into_iter().map(move |(_, r)| {
155+
"reviews": rev.reviews().map(move |(_, r)| {
156156
review(r, aliases)
157157
}).collect::<Vec<_>>(),
158158
})

src/api/v1.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ mod profile;
55
mod projects;
66
mod sessions;
77
mod stats;
8+
mod webhooks;
89

910
use axum::extract::State;
1011
use axum::response::{IntoResponse, Json};
@@ -27,7 +28,8 @@ pub fn router(ctx: Context) -> Router {
2728
.merge(oauth::router(ctx.clone()))
2829
.merge(delegates::router(ctx.clone()))
2930
.merge(projects::router(ctx.clone()))
30-
.merge(stats::router(ctx));
31+
.merge(stats::router(ctx.clone()))
32+
.merge(webhooks::router(ctx));
3133

3234
Router::new().nest("/v1", routes)
3335
}

src/api/v1/projects.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1217,7 +1217,7 @@ mod routes {
12171217
async fn test_search_projects() {
12181218
let tmp = tempfile::tempdir().unwrap();
12191219
let app = super::router(seed(tmp.path()));
1220-
let response = get(&app, format!("/projects/search?q=hello")).await;
1220+
let response = get(&app, "/projects/search?q=hello".to_string()).await;
12211221

12221222
assert_eq!(response.status(), StatusCode::OK);
12231223
assert_eq!(
@@ -1257,7 +1257,7 @@ mod routes {
12571257
async fn test_search_projects_pagination() {
12581258
let tmp = tempfile::tempdir().unwrap();
12591259
let app = super::router(seed(tmp.path()));
1260-
let response = get(&app, format!("/projects/search?q=hello&perPage=1")).await;
1260+
let response = get(&app, "/projects/search?q=hello&perPage=1".to_string()).await;
12611261

12621262
assert_eq!(response.status(), StatusCode::OK);
12631263
assert_eq!(

src/api/v1/sessions.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ async fn session_delete_handler(
172172
}
173173

174174
#[cfg(test)]
175-
mod sessions {
175+
mod sessions_tests {
176176
use crate::api::auth;
177177
use axum::body::Body;
178178
use axum::http::StatusCode;

src/api/v1/webhooks.rs

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
use crate::api;
2+
use crate::api::error::Error;
3+
use crate::api::Context;
4+
use crate::axum_extra::{Path, Query};
5+
use crate::store::Webhook;
6+
use axum::extract::State;
7+
use axum::response::IntoResponse;
8+
use axum::routing::post;
9+
use axum::{Json, Router};
10+
use axum_auth::AuthBearer;
11+
use radicle::identity::RepoId;
12+
use serde::{Deserialize, Serialize};
13+
use serde_json::json;
14+
15+
pub fn router(ctx: Context) -> Router {
16+
Router::new()
17+
.route(
18+
"/projects/:repo_id/webhooks",
19+
post(webhooks_post_handler)
20+
.delete(webhooks_delete_handler)
21+
.get(webhooks_get_handler),
22+
)
23+
.with_state(ctx)
24+
}
25+
26+
/// Return the webhooks for the repo.
27+
/// `GET /projects/:repo_id/webhooks`
28+
async fn webhooks_get_handler(
29+
State(ctx): State<Context>,
30+
AuthBearer(token): AuthBearer,
31+
Path(repo_id): Path<RepoId>,
32+
) -> impl IntoResponse {
33+
api::auth::validate(&ctx, &token).await?;
34+
let (_, _) = ctx.repo(repo_id)?;
35+
let mut db = ctx.open_webhooks_db()?;
36+
let webhooks = db.get(repo_id.to_string())?;
37+
Ok::<_, Error>(Json(json!(webhooks)))
38+
}
39+
40+
/// Creates a webhook for the repo.
41+
/// `POST /projects/:repo_id/webhooks`
42+
async fn webhooks_post_handler(
43+
State(ctx): State<Context>,
44+
AuthBearer(token): AuthBearer,
45+
Path(repo_id): Path<RepoId>,
46+
Json(mut webhook): Json<Webhook>,
47+
) -> impl IntoResponse {
48+
api::auth::validate(&ctx, &token).await?;
49+
let (repo, _) = ctx.repo(repo_id)?;
50+
webhook.repo_id = repo.id.to_string();
51+
let mut db = ctx.open_webhooks_db()?;
52+
db.insert(&webhook)?;
53+
Ok::<_, Error>(Json(json!(webhook)))
54+
}
55+
56+
#[derive(Serialize, Deserialize, Clone)]
57+
#[serde(rename_all = "camelCase")]
58+
pub struct QueryUrl {
59+
pub url: Option<String>,
60+
}
61+
62+
/// Deletes the webhooks for the repo, or the webhook with the specific url, if-provided
63+
/// `DELETE /projects/:repo_id/webhooks`
64+
async fn webhooks_delete_handler(
65+
State(ctx): State<Context>,
66+
AuthBearer(token): AuthBearer,
67+
Path(repo_id): Path<RepoId>,
68+
Query(qs): Query<QueryUrl>,
69+
) -> impl IntoResponse {
70+
api::auth::validate(&ctx, &token).await?;
71+
let (_, _) = ctx.repo(repo_id)?;
72+
let mut db = ctx.open_webhooks_db()?;
73+
db.remove(repo_id.to_string(), qs.url)?;
74+
// returning OK without checking if we actually deleted anything
75+
76+
Ok::<_, Error>(Json(json!({"repo_id": repo_id})))
77+
}
78+
79+
#[cfg(test)]
80+
mod webhooks_api_tests {
81+
use crate::store::Webhook;
82+
use crate::test::{create_session, delete, get_auth, post, seed, Response, RID, SESSION_ID};
83+
use axum::body::Body;
84+
use axum::http::StatusCode;
85+
use axum::Router;
86+
use serde_json::json;
87+
88+
#[tokio::test]
89+
async fn test_webhooks() {
90+
let tmp = tempfile::tempdir().unwrap();
91+
let ctx = seed(tmp.path());
92+
let app = super::router(ctx.to_owned());
93+
94+
create_session(ctx).await;
95+
96+
let webhook = gen_webhook(1);
97+
98+
let response = create_webhook(&app, &webhook).await;
99+
assert_eq!(response.status(), StatusCode::OK);
100+
101+
// get webhooks, we should find the one created
102+
let mut get_whs = get_webhooks(&app).await;
103+
assert_eq!(get_whs.as_array_mut().unwrap().len(), 1);
104+
assert_eq!(get_whs.as_array_mut().unwrap()[0], json!(webhook));
105+
106+
// delete all under repo
107+
let del_resp = delete(
108+
&app,
109+
format!("/projects/{RID}/webhooks"),
110+
None,
111+
Some(SESSION_ID.to_string()),
112+
)
113+
.await;
114+
assert_eq!(del_resp.status(), StatusCode::OK);
115+
116+
// get should return empty array now
117+
let mut get_whs = get_webhooks(&app).await;
118+
assert_eq!(get_whs.as_array_mut().unwrap().len(), 0);
119+
}
120+
121+
#[tokio::test]
122+
async fn test_multiple_webhooks() {
123+
let tmp = tempfile::tempdir().unwrap();
124+
let ctx = seed(tmp.path());
125+
let app = super::router(ctx.to_owned());
126+
127+
create_session(ctx).await;
128+
129+
let webhook1 = gen_webhook(1);
130+
let response = create_webhook(&app, &webhook1).await;
131+
assert_eq!(response.status(), StatusCode::OK);
132+
133+
let webhook2 = gen_webhook(2);
134+
let response = create_webhook(&app, &webhook2).await;
135+
assert_eq!(response.status(), StatusCode::OK);
136+
137+
// get webhooks, we should find both created
138+
let mut get_whs = get_webhooks(&app).await;
139+
assert_eq!(get_whs.as_array_mut().unwrap().len(), 2);
140+
141+
// add webhook with same url as webhook1 again, it should be "ignored"
142+
let response = create_webhook(&app, &webhook1).await;
143+
assert_eq!(response.status(), StatusCode::OK);
144+
145+
let mut get_whs = get_webhooks(&app).await;
146+
assert_eq!(get_whs.as_array_mut().unwrap().len(), 2);
147+
148+
// delete by url
149+
let url1 = webhook1.url;
150+
let del_resp = delete(
151+
&app,
152+
format!("/projects/{RID}/webhooks?url={url1}"),
153+
None,
154+
Some(SESSION_ID.to_string()),
155+
)
156+
.await;
157+
assert_eq!(del_resp.status(), StatusCode::OK);
158+
159+
//verify we only have webhook2 now
160+
let mut get_whs = get_webhooks(&app).await;
161+
assert_eq!(get_whs.as_array_mut().unwrap().len(), 1);
162+
assert_eq!(get_whs.as_array_mut().unwrap()[0], json!(webhook2));
163+
164+
// add multiple other webhooks again
165+
for i in 0..5 {
166+
let wh = gen_webhook(10 + i);
167+
create_webhook(&app, &wh).await;
168+
}
169+
170+
// we should have webhook2 + the 5 new ones
171+
let mut get_whs = get_webhooks(&app).await;
172+
assert_eq!(get_whs.as_array_mut().unwrap().len(), 1 + 5);
173+
174+
// delete all webhooks in repo
175+
let del_resp = delete(
176+
&app,
177+
format!("/projects/{RID}/webhooks"),
178+
None,
179+
Some(SESSION_ID.to_string()),
180+
)
181+
.await;
182+
assert_eq!(del_resp.status(), StatusCode::OK);
183+
184+
// verify we have 0 webhooks now
185+
let mut get_whs = get_webhooks(&app).await;
186+
assert_eq!(get_whs.as_array_mut().unwrap().len(), 0);
187+
}
188+
189+
fn gen_webhook(id: u64) -> Webhook {
190+
Webhook {
191+
repo_id: RID.to_string(),
192+
url: format!("test_url_{id}"),
193+
secret: "test_secret".to_string(),
194+
content_type: "content type".to_string(),
195+
}
196+
}
197+
198+
async fn create_webhook(app: &Router, webhook: &Webhook) -> Response {
199+
let body = Some(Body::from(json!(webhook).to_string()));
200+
let s = Some(SESSION_ID.to_string());
201+
post(app, format!("/projects/{RID}/webhooks"), body, s).await
202+
}
203+
204+
async fn get_webhooks(app: &Router) -> serde_json::Value {
205+
let s = Some(SESSION_ID.to_string());
206+
let get_resp = get_auth(app, format!("/projects/{RID}/webhooks"), s).await;
207+
assert_eq!(get_resp.status(), StatusCode::OK);
208+
get_resp.json().await
209+
}
210+
}

src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
#![recursion_limit = "256"]
44
pub mod api;
55
pub mod error;
6-
pub mod session;
6+
pub mod store;
77

88
use std::collections::HashMap;
99
use std::net::SocketAddr;

0 commit comments

Comments
 (0)