Skip to main content

commit_bridge/
handler.rs

1//! Axum route handlers.
2
3// Needed to bypass a warning raised inside the `#[rovo]` macro.
4#![allow(missing_docs, clippy::missing_docs_in_private_items)]
5
6use crate::error::HandlerError;
7use crate::model::{
8    CreateSubscription, HalLink, SubscriptionHal, SubscriptionLinks, SubscriptionPage,
9    SubscriptionPageLinks, SubscriptionWithBranch, UpdateSubscription,
10};
11use crate::repository::subscription::SubscriptionRepository;
12
13use crate::state::AppState;
14use axum::{
15    Json,
16    extract::{Path, Query, State},
17};
18use rovo::rovo;
19use serde::Deserialize;
20use tracing::info;
21
22/// Maps a [`SubscriptionWithBranch`] to its HAL representation.
23fn map_to_hal(sub_with_branch: SubscriptionWithBranch) -> SubscriptionHal {
24    let id = sub_with_branch.subscription.id;
25    SubscriptionHal {
26        subscription: sub_with_branch.subscription,
27        source_branch: sub_with_branch.source_branch,
28        links: SubscriptionLinks {
29            self_link: HalLink {
30                href: format!("/subscriptions/{}", id),
31            },
32            update: HalLink {
33                href: format!("/subscriptions/{}", id),
34            },
35            delete: HalLink {
36                href: format!("/subscriptions/{}", id),
37            },
38        },
39    }
40}
41
42/// Create a new subscription mapping.
43///
44/// Creates a new subscription mapping between a source branch and a target repository.
45///
46/// # Responses
47///
48/// 201: Json<SubscriptionHal> - Subscription created successfully
49/// 401: () - Unauthorized
50/// 408: () - Request timeout
51/// 422: () - Validation error
52/// 500: () - Internal server error
53///
54/// # Metadata
55///
56/// @tag subscriptions
57#[allow(rustdoc::invalid_html_tags)]
58#[rovo]
59pub async fn create_subscription(
60    state: State<AppState>,
61    payload: Json<CreateSubscription>,
62) -> Result<Json<SubscriptionHal>, HandlerError> {
63    create_subscription_inner(state, payload).await
64}
65
66/// Internal implementation of [`create_subscription`].
67async fn create_subscription_inner(
68    State(state): State<AppState>,
69    Json(payload): Json<CreateSubscription>,
70) -> Result<Json<SubscriptionHal>, HandlerError> {
71    let sub_with_branch = state.repository.create(&payload).await?;
72
73    info!(
74        "Registered new subscription for branch ID {} (repo: {}, branch: {}): {:?}",
75        sub_with_branch.subscription.branch_id,
76        sub_with_branch.source_branch.repo_url,
77        sub_with_branch.source_branch.name,
78        sub_with_branch.subscription
79    );
80
81    Ok(Json(map_to_hal(sub_with_branch)))
82}
83
84/// Query parameters for listing subscriptions.
85#[derive(Debug, Deserialize, rovo::schemars::JsonSchema)]
86pub struct ListSubscriptionsQuery {
87    /// Maximum number of subscriptions to return.
88    pub limit: Option<usize>,
89    /// The ID of the last subscription in the previous page.
90    pub last_id: Option<i64>,
91}
92
93/// List subscriptions.
94///
95/// Returns a paginated list of all subscription mappings in the system.
96///
97/// # Query Parameters
98///
99/// - `limit`: The maximum number of subscriptions to return.
100/// - `last_id`: The ID of the last subscription in the previous page.
101///
102/// # Responses
103///
104/// 200: Json<SubscriptionPage> - Paginated list of subscriptions
105/// 401: () - Unauthorized
106/// 408: () - Request timeout
107/// 500: () - Internal server error
108///
109/// # Metadata
110///
111/// @tag subscriptions
112#[allow(rustdoc::invalid_html_tags)]
113#[rovo]
114pub async fn list_subscriptions(
115    state: State<AppState>,
116    query: Query<ListSubscriptionsQuery>,
117) -> Result<Json<SubscriptionPage>, HandlerError> {
118    list_subscriptions_inner(state, query).await
119}
120
121/// Internal implementation of [`list_subscriptions`].
122async fn list_subscriptions_inner(
123    State(state): State<AppState>,
124    Query(query): Query<ListSubscriptionsQuery>,
125) -> Result<Json<SubscriptionPage>, HandlerError> {
126    let limit = query
127        .limit
128        .unwrap_or(state.config.database.subscriptions_list_limit)
129        .min(state.config.database.subscriptions_list_limit_cap);
130    let last_id = query.last_id.unwrap_or_default();
131
132    let subscriptions = state
133        .repository
134        .list_paginated_with_branches(last_id, limit as i64)
135        .await?;
136
137    let data: Vec<SubscriptionHal> = subscriptions.into_iter().map(map_to_hal).collect();
138
139    let next_id = data.last().map(|s| s.subscription.id).unwrap_or(last_id);
140    let remaining_count = state.repository.count_remaining(next_id).await?;
141
142    let next_link = data
143        .last()
144        .filter(|_| remaining_count > 0)
145        .map(|s| HalLink {
146            href: format!(
147                "/subscriptions?limit={}&last_id={}",
148                limit, s.subscription.id
149            ),
150        });
151
152    Ok(Json(SubscriptionPage {
153        data,
154        remaining_count,
155        links: SubscriptionPageLinks { next: next_link },
156    }))
157}
158
159/// Get a single subscription.
160///
161/// Retrieve a subscription mapping by its ID.
162///
163/// # Path Parameters
164///
165/// id: The unique identifier of the subscription
166///
167/// # Responses
168///
169/// 200: Json<SubscriptionHal> - Successfully retrieved the subscription
170/// 401: () - Unauthorized
171/// 404: () - Subscription was not found
172/// 408: () - Request timeout
173/// 500: () - Internal server error
174///
175/// # Metadata
176///
177/// @tag subscriptions
178#[allow(rustdoc::invalid_html_tags)]
179#[rovo]
180pub async fn get_subscription(
181    state: State<AppState>,
182    Path(id): Path<i64>,
183) -> Result<Json<SubscriptionHal>, HandlerError> {
184    get_subscription_inner(state, Path(id)).await
185}
186
187/// Internal implementation of [`get_subscription`].
188async fn get_subscription_inner(
189    State(state): State<AppState>,
190    Path(id): Path<i64>,
191) -> Result<Json<SubscriptionHal>, HandlerError> {
192    let sub_with_branch = state
193        .repository
194        .get_by_id_with_branch(id)
195        .await?
196        .ok_or(HandlerError::NotFound)?;
197    Ok(Json(map_to_hal(sub_with_branch)))
198}
199
200/// Update an existing subscription.
201///
202/// Updates the target repository, event type, and/or GitHub App installation ID of a subscription.
203///
204/// # Path Parameters
205///
206/// id: The unique identifier of the subscription to update
207///
208/// # Responses
209///
210/// 200: Json<SubscriptionHal> - Subscription updated successfully
211/// 401: () - Unauthorized
212/// 404: () - Subscription was not found
213/// 408: () - Request timeout
214/// 422: () - Validation error
215/// 500: () - Internal server error
216///
217/// # Metadata
218///
219/// @tag subscriptions
220#[allow(rustdoc::invalid_html_tags)]
221#[rovo]
222pub async fn update_subscription(
223    state: State<AppState>,
224    Path(id): Path<i64>,
225    payload: Json<UpdateSubscription>,
226) -> Result<Json<SubscriptionHal>, HandlerError> {
227    update_subscription_inner(state, Path(id), payload).await
228}
229
230/// Internal implementation of [`update_subscription`].
231async fn update_subscription_inner(
232    State(state): State<AppState>,
233    Path(id): Path<i64>,
234    Json(payload): Json<UpdateSubscription>,
235) -> Result<Json<SubscriptionHal>, HandlerError> {
236    state.repository.update(id, &payload).await?;
237    let sub_with_branch = state
238        .repository
239        .get_by_id_with_branch(id)
240        .await?
241        .ok_or(HandlerError::NotFound)?;
242
243    Ok(Json(map_to_hal(sub_with_branch)))
244}
245
246/// Delete a subscription.
247///
248/// Permanently deletes a subscription mapping by its ID.
249///
250/// # Path Parameters
251///
252/// id: The unique identifier of the subscription to delete
253///
254/// # Responses
255///
256/// 204: () - Subscription deleted successfully
257/// 401: () - Unauthorized
258/// 404: () - Subscription was not found
259/// 408: () - Request timeout
260/// 500: () - Internal server error
261///
262/// # Metadata
263///
264/// @tag subscriptions
265#[allow(rustdoc::invalid_html_tags)]
266#[rovo]
267pub async fn delete_subscription(
268    state: State<AppState>,
269    Path(id): Path<i64>,
270) -> Result<(), HandlerError> {
271    delete_subscription_inner(state, Path(id)).await
272}
273
274/// Internal implementation of [`delete_subscription`].
275async fn delete_subscription_inner(
276    State(state): State<AppState>,
277    Path(id): Path<i64>,
278) -> Result<(), HandlerError> {
279    state.repository.delete_subscription_and_cascade(id).await?;
280    Ok(())
281}
282
283#[cfg(test)]
284mod tests {
285    #![allow(
286        clippy::panic,
287        clippy::expect_used,
288        clippy::todo,
289        clippy::unimplemented,
290        clippy::indexing_slicing
291    )]
292
293    use super::*;
294    use crate::domain::{BranchName, EventType, RepoUrl, TargetRepo};
295    use crate::model::CreateSubscription;
296    use crate::state::AppState;
297    use crate::test_utils::create_test_db;
298    use axum::Json;
299    use axum::extract::State;
300
301    #[tokio::test]
302    async fn test_crud_subscription() {
303        let pool = create_test_db().await;
304        let config = crate::test_utils::create_test_config();
305        let state = AppState {
306            config: std::sync::Arc::new(config),
307            repository: std::sync::Arc::new(crate::repository::SqliteRepository::new(pool.clone())),
308            db_pool: pool.clone(),
309        };
310        let payload = CreateSubscription {
311            source_repo_url: RepoUrl::new("https://github.com/org/repo".to_string()).unwrap(),
312            source_branch_name: BranchName::new("main".to_string()).unwrap(),
313            target_repo: TargetRepo::new("org/target".to_string()).unwrap(),
314            event_type: EventType::new("dispatch".to_string()).unwrap(),
315            gh_app_installation_id: 1,
316        };
317
318        // Create
319        let res = create_subscription_inner(State(state.clone()), Json(payload))
320            .await
321            .unwrap();
322        let id = res.subscription.id;
323        assert_eq!(res.links.self_link.href, format!("/subscriptions/{}", id));
324
325        // List
326        let list = list_subscriptions_inner(
327            State(state.clone()),
328            Query(ListSubscriptionsQuery {
329                limit: None,
330                last_id: None,
331            }),
332        )
333        .await
334        .unwrap();
335        assert_eq!(list.data.len(), 1);
336        assert_eq!(
337            list.data[0].links.self_link.href,
338            format!("/subscriptions/{}", id)
339        );
340        assert_eq!(list.remaining_count, 0);
341
342        // Get
343        let get = get_subscription_inner(State(state.clone()), Path(id))
344            .await
345            .unwrap();
346        assert_eq!(get.subscription.id, id);
347        assert_eq!(get.links.self_link.href, format!("/subscriptions/{}", id));
348
349        // Update
350        let update_payload = UpdateSubscription {
351            target_repo: Some(TargetRepo::new("org/new-target".to_string()).unwrap()),
352            event_type: None,
353            gh_app_installation_id: None,
354        };
355        let updated =
356            update_subscription_inner(State(state.clone()), Path(id), Json(update_payload))
357                .await
358                .unwrap();
359        assert_eq!(
360            updated.subscription.target_repo,
361            TargetRepo::new("org/new-target".to_string()).unwrap()
362        );
363
364        assert_eq!(
365            updated.links.self_link.href,
366            format!("/subscriptions/{}", id)
367        );
368
369        // Delete
370        delete_subscription_inner(State(state.clone()), Path(id))
371            .await
372            .unwrap();
373
374        // Verify delete
375        let get_after_delete = get_subscription_inner(State(state.clone()), Path(id)).await;
376        assert!(get_after_delete.is_err());
377    }
378
379    #[tokio::test]
380    async fn test_non_existent_subscription_returns_not_found() {
381        let pool = create_test_db().await;
382        let config = crate::test_utils::create_test_config();
383        let state = AppState {
384            config: std::sync::Arc::new(config),
385            repository: std::sync::Arc::new(crate::repository::SqliteRepository::new(pool.clone())),
386            db_pool: pool.clone(),
387        };
388
389        // Try getting a non-existent subscription
390        let get_res = get_subscription_inner(State(state.clone()), Path(999)).await;
391        assert!(matches!(get_res, Err(HandlerError::NotFound)));
392
393        // Try updating a non-existent subscription
394        let update_payload = UpdateSubscription {
395            target_repo: Some(TargetRepo::new("org/new-target".to_string()).unwrap()),
396            event_type: None,
397            gh_app_installation_id: None,
398        };
399        let update_res =
400            update_subscription_inner(State(state.clone()), Path(999), Json(update_payload)).await;
401        assert!(matches!(update_res, Err(HandlerError::NotFound)));
402
403        // Try deleting a non-existent subscription
404        let delete_res = delete_subscription_inner(State(state.clone()), Path(999)).await;
405        assert!(matches!(delete_res, Err(HandlerError::NotFound)));
406    }
407
408    #[tokio::test]
409    async fn test_list_subscriptions_pagination() {
410        let pool = create_test_db().await;
411        let config = crate::test_utils::create_test_config();
412        let state = AppState {
413            config: std::sync::Arc::new(config),
414            repository: std::sync::Arc::new(crate::repository::SqliteRepository::new(pool.clone())),
415            db_pool: pool.clone(),
416        };
417
418        // Create 3 subscriptions
419        //
420        // Lint needs to be silenced here because the `#[tokio::test]` macro
421        // probably interferes with the nesting count.
422        #[allow(clippy::excessive_nesting)]
423        for i in 0..3 {
424            let payload = CreateSubscription {
425                source_repo_url: RepoUrl::new(format!("https://github.com/org/repo{}", i)).unwrap(),
426                source_branch_name: BranchName::new("main".to_string()).unwrap(),
427                target_repo: TargetRepo::new("org/target".to_string()).unwrap(),
428                event_type: EventType::new("dispatch".to_string()).unwrap(),
429                gh_app_installation_id: 1,
430            };
431            let _ = create_subscription_inner(State(state.clone()), Json(payload))
432                .await
433                .unwrap();
434        }
435
436        // Fetch first page (limit 2)
437        let page1 = list_subscriptions_inner(
438            State(state.clone()),
439            Query(ListSubscriptionsQuery {
440                limit: Some(2),
441                last_id: None,
442            }),
443        )
444        .await
445        .unwrap();
446        assert_eq!(page1.data.len(), 2);
447        assert_eq!(page1.remaining_count, 1);
448        assert!(page1.links.next.is_some());
449
450        // Fetch second page
451        let last_id = page1.data.last().unwrap().subscription.id;
452        let page2 = list_subscriptions_inner(
453            State(state.clone()),
454            Query(ListSubscriptionsQuery {
455                limit: Some(2),
456                last_id: Some(last_id),
457            }),
458        )
459        .await
460        .unwrap();
461        assert_eq!(page2.data.len(), 1);
462        assert_eq!(page2.remaining_count, 0);
463        assert!(page2.links.next.is_none());
464    }
465
466    #[tokio::test]
467    async fn test_cascading_branch_cleanup() {
468        let pool = create_test_db().await;
469        let config = crate::test_utils::create_test_config();
470        let state = AppState {
471            config: std::sync::Arc::new(config),
472            repository: std::sync::Arc::new(crate::repository::SqliteRepository::new(pool.clone())),
473            db_pool: pool.clone(),
474        };
475        let payload = CreateSubscription {
476            source_repo_url: RepoUrl::new("https://github.com/org/repo".to_string()).unwrap(),
477            source_branch_name: BranchName::new("main".to_string()).unwrap(),
478            target_repo: TargetRepo::new("org/target".to_string()).unwrap(),
479            event_type: EventType::new("dispatch".to_string()).unwrap(),
480            gh_app_installation_id: 1,
481        };
482
483        // Create two subscriptions for the same branch
484        let sub1 = create_subscription_inner(State(state.clone()), Json(payload.clone()))
485            .await
486            .unwrap();
487        let sub2 = create_subscription_inner(State(state.clone()), Json(payload))
488            .await
489            .unwrap();
490
491        let branch_id = sub1.subscription.branch_id;
492
493        // Verify branch exists
494        let branch: Option<(i64,)> = sqlx::query_as("SELECT id FROM branches WHERE id = ?")
495            .bind(branch_id)
496            .fetch_optional(&pool)
497            .await
498            .unwrap();
499        assert!(branch.is_some());
500
501        // Delete first subscription
502        delete_subscription_inner(State(state.clone()), Path(sub1.subscription.id))
503            .await
504            .unwrap();
505
506        // Branch should still exist
507        let branch_still_exists: Option<(i64,)> =
508            sqlx::query_as("SELECT id FROM branches WHERE id = ?")
509                .bind(branch_id)
510                .fetch_optional(&pool)
511                .await
512                .unwrap();
513        assert!(branch_still_exists.is_some());
514
515        // Delete second subscription
516        delete_subscription_inner(State(state.clone()), Path(sub2.subscription.id))
517            .await
518            .unwrap();
519
520        // Branch should be gone
521        let branch_gone: Option<(i64,)> = sqlx::query_as("SELECT id FROM branches WHERE id = ?")
522            .bind(branch_id)
523            .fetch_optional(&pool)
524            .await
525            .unwrap();
526        assert!(branch_gone.is_none());
527    }
528}