Repository: importantimport/hatsu (language: rust)

Issue:
Write utoipa documentation for apub posts
As per the discussion in [PR #65](https://github.com/importantimport/hatsu/pull/65) at [this comment](https://github.com/importantimport/hatsu/pull/65#discussion_r1759737618), @kwaa has requested to write utoipa documentation for the commented-out routes in `crates/api_apub/src/posts/mod.rs`.


Discussion / hints:

Fix patch:
diff --git a/crates/api_apub/src/lib.rs b/crates/api_apub/src/lib.rs
index e0058d03..b589d170 100644
--- a/crates/api_apub/src/lib.rs
+++ b/crates/api_apub/src/lib.rs
@@ -1,8 +1,11 @@
 use hatsu_apub::{
     actors::{PublicKeySchema, User, UserAttachment, UserImage},
+    collections::{Collection, CollectionOrPage, CollectionPage},
     links::{Emoji, EmojiIcon, Hashtag, Mention, Tag},
     objects::Note,
 };
+use serde_json::Value;
+use url::Url;
 use utoipa::OpenApi;
 use utoipa_axum::router::OpenApiRouter;
 
@@ -14,11 +17,19 @@ pub const TAG: &str = "apub";
 
 #[derive(OpenApi)]
 #[openapi(
+    paths(
+        posts::notice::notice,
+        posts::post::post,
+    ),
     components(schemas(
         PublicKeySchema,
         User,
         UserAttachment,
         UserImage,
+        Collection,
+        CollectionOrPage,
+        CollectionPage<Url>,
+        CollectionPage<Value>,
         Emoji,
         EmojiIcon,
         Hashtag,
diff --git a/crates/api_apub/src/posts/mod.rs b/crates/api_apub/src/posts/mod.rs
index 291205e5..7559caef 100644
--- a/crates/api_apub/src/posts/mod.rs
+++ b/crates/api_apub/src/posts/mod.rs
@@ -4,12 +4,11 @@ use utoipa_axum::router::OpenApiRouter;
 
 use crate::ApubApi;
 
-mod notice;
+pub mod notice;
 pub mod post;
 
 pub fn routes() -> OpenApiRouter {
     OpenApiRouter::with_openapi(ApubApi::openapi())
-        // TODO: writing utoipa docs
         .route("/notice/*notice", get(notice::notice))
         .route("/posts/*post", get(post::post))
         .route("/p/*post", get(post::redirect))
diff --git a/crates/api_apub/src/posts/notice.rs b/crates/api_apub/src/posts/notice.rs
index 6f7dc523..2997800e 100644
--- a/crates/api_apub/src/posts/notice.rs
+++ b/crates/api_apub/src/posts/notice.rs
@@ -1,6 +1,21 @@
 use axum::{debug_handler, extract::Path, response::Redirect};
 use hatsu_utils::AppError;
 
+use crate::TAG;
+
+/// Get post by base64 url
+#[utoipa::path(
+    get,
+    tag = TAG,
+    path = "/notice/{notice}",
+    responses(
+        (status = OK, description = "Post", body = Note),
+        (status = NOT_FOUND, description = "Post does not exist", body = AppError)
+    ),
+    params(
+        ("notice" = String, Path, description = "Base64 Post Url")
+    )
+)]
 #[debug_handler]
 pub async fn notice(Path(base64_url): Path<String>) -> Result<Redirect, AppError> {
     let base64 = base64_simd::URL_SAFE;
diff --git a/crates/api_apub/src/users/mod.rs b/crates/api_apub/src/users/mod.rs
index 269d056d..140ee386 100644
--- a/crates/api_apub/src/users/mod.rs
+++ b/crates/api_apub/src/users/mod.rs
@@ -1,5 +1,6 @@
 use axum::routing::{get, post};
-use utoipa::OpenApi;
+use serde::Deserialize;
+use utoipa::{IntoParams, OpenApi};
 use utoipa_axum::{router::OpenApiRouter, routes};
 
 use crate::ApubApi;
@@ -9,14 +10,18 @@ mod user_following;
 mod user_inbox;
 mod user_outbox;
 
+#[derive(Deserialize, IntoParams)]
+pub struct Pagination {
+    page: Option<u64>,
+}
+
 pub fn routes() -> OpenApiRouter {
     OpenApiRouter::with_openapi(ApubApi::openapi())
         .routes(routes!(user::handler))
-        // TODO: writing utoipa docs
-        .route("/users/:user/followers", get(user_followers::handler))
-        .route("/users/:user/following", get(user_following::handler))
-        .route("/users/:user/outbox", get(user_outbox::handler))
-        .route("/users/:user/inbox", post(user_inbox::handler))
+        .routes(routes!(user_followers::handler))
+        .routes(routes!(user_following::handler))
+        .routes(routes!(user_inbox::handler))
+        .routes(routes!(user_outbox::handler))
         // fallback routes
         .route("/u/:user", get(user::redirect))
         .route("/u/:user/followers", get(user_followers::redirect))
diff --git a/crates/api_apub/src/users/user_followers.rs b/crates/api_apub/src/users/user_followers.rs
index 1d53c24d..fc193197 100644
--- a/crates/api_apub/src/users/user_followers.rs
+++ b/crates/api_apub/src/users/user_followers.rs
@@ -11,28 +11,35 @@ use axum::{
 };
 use hatsu_apub::{
     actors::ApubUser,
-    collections::{Collection, CollectionPage},
+    collections::{Collection, CollectionOrPage, CollectionPage},
 };
 use hatsu_db_schema::{prelude::ReceivedFollow, received_follow};
 use hatsu_utils::{AppData, AppError};
 use sea_orm::{ModelTrait, PaginatorTrait, QueryOrder};
-use serde::Deserialize;
-use serde_json::Value;
 use url::Url;
 
-#[derive(Default, Deserialize)]
-pub struct Pagination {
-    page: Option<u64>,
-}
+use crate::{users::Pagination, TAG};
 
+/// Get user followers
+#[utoipa::path(
+    get,
+    tag = TAG,
+    path = "/users/{user}/followers",
+    responses(
+        (status = OK, description = "Followers", body = CollectionOrPage),
+        (status = NOT_FOUND, description = "User does not exist", body = AppError)
+    ),
+    params(
+        ("user" = String, Path, description = "The Domain of the User in the database."),
+        Pagination
+    )
+)]
 #[debug_handler]
 pub async fn handler(
     Path(name): Path<String>,
-    pagination: Option<Query<Pagination>>,
+    pagination: Query<Pagination>,
     data: Data<AppData>,
-) -> Result<FederationJson<WithContext<Value>>, AppError> {
-    let Query(pagination) = pagination.unwrap_or_default();
-
+) -> Result<FederationJson<WithContext<CollectionOrPage>>, AppError> {
     let user_id: ObjectId<ApubUser> =
         hatsu_utils::url::generate_user_url(data.domain(), &name)?.into();
     let user = user_id.dereference_local(&data).await?;
@@ -48,12 +55,12 @@ pub async fn handler(
 
     match pagination.page {
         None => Ok(FederationJson(WithContext::new_default(
-            serde_json::to_value(Collection::new(
+            CollectionOrPage::Collection(Collection::new(
                 &hatsu_utils::url::generate_user_url(data.domain(), &name)?
                     .join(&format!("{name}/followers"))?,
                 total.number_of_items,
-                Some(total.number_of_pages),
-            )?)?,
+                total.number_of_pages,
+            )?),
         ))),
         Some(page) =>
             if page > 1 && page > total.number_of_pages {
@@ -63,7 +70,7 @@ pub async fn handler(
                 ))
             } else {
                 Ok(FederationJson(WithContext::new_default(
-                    serde_json::to_value(CollectionPage::<Url>::new(
+                    CollectionOrPage::CollectionPageUrl(CollectionPage::<Url>::new(
                         hatsu_utils::url::generate_user_url(data.domain(), &name)?
                             .join(&format!("{name}/followers"))?,
                         total.number_of_items,
@@ -76,7 +83,7 @@ pub async fn handler(
                             .collect(),
                         total.number_of_pages,
                         page,
-                    )?)?,
+                    )?),
                 )))
             },
     }
diff --git a/crates/api_apub/src/users/user_following.rs b/crates/api_apub/src/users/user_following.rs
index 1c13dfad..97e69c4c 100644
--- a/crates/api_apub/src/users/user_following.rs
+++ b/crates/api_apub/src/users/user_following.rs
@@ -8,43 +8,50 @@ use axum::{
     extract::{Path, Query},
     response::Redirect,
 };
-use hatsu_apub::collections::{Collection, CollectionPage};
+use hatsu_apub::collections::{Collection, CollectionOrPage, CollectionPage};
 use hatsu_utils::{AppData, AppError};
-use serde::Deserialize;
-use serde_json::Value;
 use url::Url;
 
-#[derive(Default, Deserialize)]
-pub struct Pagination {
-    page: Option<u64>,
-}
+use crate::{users::Pagination, TAG};
 
+/// Get user following
+#[utoipa::path(
+    get,
+    tag = TAG,
+    path = "/users/{user}/following",
+    responses(
+        (status = OK, description = "Following", body = CollectionOrPage),
+        (status = NOT_FOUND, description = "User does not exist", body = AppError)
+    ),
+    params(
+        ("user" = String, Path, description = "The Domain of the User in the database."),
+        Pagination
+    )
+)]
 #[debug_handler]
 pub async fn handler(
     Path(name): Path<String>,
-    pagination: Option<Query<Pagination>>,
+    pagination: Query<Pagination>,
     data: Data<AppData>,
-) -> Result<FederationJson<WithContext<Value>>, AppError> {
-    let Query(pagination) = pagination.unwrap_or_default();
-
+) -> Result<FederationJson<WithContext<CollectionOrPage>>, AppError> {
     match pagination.page {
         None => Ok(FederationJson(WithContext::new_default(
-            serde_json::to_value(Collection::new(
+            CollectionOrPage::Collection(Collection::new(
                 &hatsu_utils::url::generate_user_url(data.domain(), &name)?
                     .join(&format!("{name}/following"))?,
                 0,
-                Some(0),
-            )?)?,
+                0,
+            )?),
         ))),
         Some(page) => Ok(FederationJson(WithContext::new_default(
-            serde_json::to_value(CollectionPage::<Url>::new(
+            CollectionOrPage::CollectionPageUrl(CollectionPage::<Url>::new(
                 hatsu_utils::url::generate_user_url(data.domain(), &name)?
                     .join(&format!("{name}/following"))?,
                 0,
                 vec![],
                 0,
                 page,
-            )?)?,
+            )?),
         ))),
     }
 }
diff --git a/crates/api_apub/src/users/user_inbox.rs b/crates/api_apub/src/users/user_inbox.rs
index 7d77f581..46d18ee5 100644
--- a/crates/api_apub/src/users/user_inbox.rs
+++ b/crates/api_apub/src/users/user_inbox.rs
@@ -7,6 +7,22 @@ use axum::{debug_handler, response::IntoResponse};
 use hatsu_apub::{activities::UserInboxActivities, actors::ApubUser};
 use hatsu_utils::AppData;
 
+use crate::TAG;
+
+/// User inbox
+#[utoipa::path(
+    post,
+    tag = TAG,
+    path = "/users/{user}/inbox",
+    responses(
+        (status = OK),
+        (status = NOT_FOUND, body = AppError),
+        (status = INTERNAL_SERVER_ERROR, body = AppError)
+    ),
+    params(
+        ("user" = String, Path, description = "The Domain of the User in the database.")
+    )
+)]
 #[debug_handler]
 pub async fn handler(data: Data<AppData>, activity_data: ActivityData) -> impl IntoResponse {
     receive_activity::<WithContext<UserInboxActivities>, ApubUser, AppData>(activity_data, &data)
diff --git a/crates/api_apub/src/users/user_outbox.rs b/crates/api_apub/src/users/user_outbox.rs
index a0c3ae2b..8530e46b 100644
--- a/crates/api_apub/src/users/user_outbox.rs
+++ b/crates/api_apub/src/users/user_outbox.rs
@@ -12,27 +12,35 @@ use axum::{
 use hatsu_apub::{
     activities::ApubActivity,
     actors::ApubUser,
-    collections::{Collection, CollectionPage},
+    collections::{Collection, CollectionOrPage, CollectionPage},
 };
 use hatsu_db_schema::{activity, prelude::Activity};
 use hatsu_utils::{AppData, AppError};
 use sea_orm::{ColumnTrait, ModelTrait, PaginatorTrait, QueryFilter, QueryOrder};
-use serde::Deserialize;
 use serde_json::Value;
 
-#[derive(Default, Deserialize)]
-pub struct Pagination {
-    page: Option<u64>,
-}
+use crate::{users::Pagination, TAG};
 
+/// Get user outbox
+#[utoipa::path(
+    get,
+    tag = TAG,
+    path = "/users/{user}/outbox",
+    responses(
+        (status = OK, description = "Outbox", body = CollectionOrPage),
+        (status = NOT_FOUND, description = "User does not exist", body = AppError)
+    ),
+    params(
+        ("user" = String, Path, description = "The Domain of the User in the database."),
+        Pagination
+    )
+)]
 #[debug_handler]
 pub async fn handler(
     Path(name): Path<String>,
-    pagination: Option<Query<Pagination>>,
+    pagination: Query<Pagination>,
     data: Data<AppData>,
-) -> Result<FederationJson<WithContext<Value>>, AppError> {
-    let Query(pagination) = pagination.unwrap_or_default();
-
+) -> Result<FederationJson<WithContext<CollectionOrPage>>, AppError> {
     let user_id: ObjectId<ApubUser> =
         hatsu_utils::url::generate_user_url(data.domain(), &name)?.into();
     let user = user_id.dereference_local(&data).await?;
@@ -50,12 +58,12 @@ pub async fn handler(
 
     match pagination.page {
         None => Ok(FederationJson(WithContext::new_default(
-            serde_json::to_value(Collection::new(
+            CollectionOrPage::Collection(Collection::new(
                 &hatsu_utils::url::generate_user_url(data.domain(), &name)?
                     .join(&format!("{name}/outbox"))?,
                 total.number_of_items,
-                Some(total.number_of_pages),
-            )?)?,
+                total.number_of_pages,
+            )?),
         ))),
         Some(page) =>
             if page > 1 && page > total.number_of_pages {
@@ -65,7 +73,7 @@ pub async fn handler(
                 ))
             } else {
                 Ok(FederationJson(WithContext::new_default(
-                    serde_json::to_value(CollectionPage::<Value>::new(
+                    CollectionOrPage::CollectionPageValue(CollectionPage::<Value>::new(
                         hatsu_utils::url::generate_user_url(data.domain(), &name)?
                             .join(&format!("{name}/outbox"))?,
                         total.number_of_items,
@@ -81,7 +89,7 @@ pub async fn handler(
                             .collect(),
                         total.number_of_pages,
                         page,
-                    )?)?,
+                    )?),
                 )))
             },
     }
diff --git a/crates/apub/src/collections/collection.rs b/crates/apub/src/collections/collection.rs
new file mode 100644
index 00000000..95daaec4
--- /dev/null
+++ b/crates/apub/src/collections/collection.rs
@@ -0,0 +1,37 @@
+use activitypub_federation::kinds::collection::OrderedCollectionType;
+use hatsu_utils::AppError;
+use serde::{Deserialize, Serialize};
+use url::Url;
+use utoipa::ToSchema;
+
+use crate::collections::generate_collection_page_url;
+
+#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct Collection {
+    #[serde(rename = "type")]
+    pub kind: OrderedCollectionType,
+    // example: https://hatsu.local/users/example.com/collection
+    pub id: Url,
+    // example: https://hatsu.local/users/example.com/collection?page=1
+    pub first: Url,
+    // example: https://hatsu.local/users/example.com/collection?page=64
+    pub last: Url,
+    // collection count
+    pub total_items: u64,
+}
+
+impl Collection {
+    pub fn new(collection_id: &Url, total_items: u64, total_pages: u64) -> Result<Self, AppError> {
+        Ok(Self {
+            kind: OrderedCollectionType::OrderedCollection,
+            id: collection_id.clone(),
+            first: generate_collection_page_url(collection_id, 1)?,
+            last: generate_collection_page_url(collection_id, match total_pages {
+                page if page > 0 => page,
+                _ => 1,
+            })?,
+            total_items,
+        })
+    }
+}
diff --git a/crates/apub/src/collections/collection_page.rs b/crates/apub/src/collections/collection_page.rs
new file mode 100644
index 00000000..f70510c3
--- /dev/null
+++ b/crates/apub/src/collections/collection_page.rs
@@ -0,0 +1,57 @@
+use activitypub_federation::kinds::collection::OrderedCollectionPageType;
+use hatsu_utils::AppError;
+use serde::{Deserialize, Serialize};
+use url::Url;
+use utoipa::ToSchema;
+
+use crate::collections::generate_collection_page_url;
+
+#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct CollectionPage<T> {
+    #[serde(rename = "type")]
+    kind: OrderedCollectionPageType,
+    // example: https://hatsu.local/users/example.com/collection?page=2
+    id: Url,
+    // example: https://hatsu.local/users/example.com/collection?page=1
+    #[serde(skip_serializing_if = "Option::is_none")]
+    prev: Option<Url>,
+    // example: https://hatsu.local/users/example.com/collection?page=3
+    #[serde(skip_serializing_if = "Option::is_none")]
+    next: Option<Url>,
+    // example: https://hatsu.local/users/example.com/collection
+    part_of: Url,
+    // collection item list
+    ordered_items: Vec<T>,
+    // collection count
+    total_items: u64,
+}
+
+impl<T> CollectionPage<T> {
+    pub fn new(
+        collection_id: Url,
+        total_items: u64,
+        ordered_items: Vec<T>,
+        total_pages: u64,
+        page: u64,
+    ) -> Result<Self, AppError> {
+        Ok(Self {
+            kind: OrderedCollectionPageType::OrderedCollectionPage,
+            id: Url::parse_with_params(collection_id.as_ref(), &[("page", page.to_string())])?,
+            // 如果当前页数大于 1，则提供上一页
+            prev: match page {
+                page if page > 1 => Some(generate_collection_page_url(&collection_id, page - 1)?),
+                _ => None,
+            },
+            // 如果当前页数小于总页数，则提供下一页
+            next: match page {
+                page if page < total_pages =>
+                    Some(generate_collection_page_url(&collection_id, page + 1)?),
+                _ => None,
+            },
+            part_of: collection_id,
+            ordered_items,
+            total_items,
+        })
+    }
+}
diff --git a/crates/apub/src/collections/mod.rs b/crates/apub/src/collections/mod.rs
index fe95d0c3..705c2cbb 100644
--- a/crates/apub/src/collections/mod.rs
+++ b/crates/apub/src/collections/mod.rs
@@ -1,7 +1,14 @@
-use activitypub_federation::kinds::collection::{OrderedCollectionPageType, OrderedCollectionType};
 use hatsu_utils::AppError;
 use serde::{Deserialize, Serialize};
+use serde_json::Value;
 use url::Url;
+use utoipa::ToSchema;
+
+mod collection;
+mod collection_page;
+
+pub use collection::Collection;
+pub use collection_page::CollectionPage;
 
 pub fn generate_collection_page_url(collection_id: &Url, page: u64) -> Result<Url, AppError> {
     Ok(Url::parse_with_params(collection_id.as_ref(), &[(
@@ -10,95 +17,10 @@ pub fn generate_collection_page_url(collection_id: &Url, page: u64) -> Result<Ur
     )])?)
 }
 
-#[derive(Clone, Debug, Deserialize, Serialize)]
-#[serde(rename_all = "camelCase")]
-pub struct Collection {
-    #[serde(rename = "type")]
-    kind: OrderedCollectionType,
-    // example: https://hatsu.local/users/example.com/collection
-    id: Url,
-
-    // example: https://hatsu.local/users/example.com/collection?page=1
-    first: Url,
-    // example: https://hatsu.local/users/example.com/collection?page=64
-    #[serde(skip_serializing_if = "Option::is_none")]
-    last: Option<Url>,
-
-    // collection count
-    total_items: u64,
-}
-
-#[derive(Clone, Debug, Deserialize, Serialize)]
-#[serde(rename_all = "camelCase")]
-pub struct CollectionPage<T> {
-    #[serde(rename = "type")]
-    kind: OrderedCollectionPageType,
-    // example: https://hatsu.local/users/example.com/collection?page=2
-    id: Url,
-    // example: https://hatsu.local/users/example.com/collection?page=1
-    #[serde(skip_serializing_if = "Option::is_none")]
-    prev: Option<Url>,
-    // example: https://hatsu.local/users/example.com/collection?page=3
-    #[serde(skip_serializing_if = "Option::is_none")]
-    next: Option<Url>,
-    // example: https://hatsu.local/users/example.com/collection
-    part_of: Url,
-    // collection item list
-    ordered_items: Vec<T>,
-    // collection count
-    total_items: u64,
-}
-
-impl Collection {
-    pub fn new(
-        collection_id: &Url,
-        total_items: u64,
-        total_pages: Option<u64>,
-    ) -> Result<Self, AppError> {
-        Ok(Self {
-            kind: OrderedCollectionType::OrderedCollection,
-            id: collection_id.clone(),
-            first: generate_collection_page_url(collection_id, 1)?,
-            last: match total_pages {
-                Some(total_pages) => Some(generate_collection_page_url(
-                    collection_id,
-                    match total_pages {
-                        page if total_pages > 0 => page + 1,
-                        _ => 1,
-                    },
-                )?),
-                None => None,
-            },
-            total_items,
-        })
-    }
-}
-
-impl<T> CollectionPage<T> {
-    pub fn new(
-        collection_id: Url,
-        total_items: u64,
-        ordered_items: Vec<T>,
-        total_pages: u64,
-        page: u64,
-    ) -> Result<Self, AppError> {
-        Ok(Self {
-            kind: OrderedCollectionPageType::OrderedCollectionPage,
-            id: Url::parse_with_params(collection_id.as_ref(), &[("page", page.to_string())])?,
-            // 如果当前页数大于 1，则提供上一页
-            prev: match page {
-                page if page > 1 => Some(generate_collection_page_url(&collection_id, page - 1)?),
-                _ => None,
-            },
-            // 如果当前页数小于总页数，则提供下一页
-            next: match page {
-                page if page < total_pages =>
-                    Some(generate_collection_page_url(&collection_id, page + 1)?),
-                _ => None,
-            },
-            part_of: collection_id,
-            ordered_items,
-            total_items,
-        })
-    }
+#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)]
+#[serde(untagged)]
+pub enum CollectionOrPage {
+    Collection(Collection),
+    CollectionPageUrl(CollectionPage<Url>),
+    CollectionPageValue(CollectionPage<Value>),
 }
diff --git a/crates/backend/src/openapi.rs b/crates/backend/src/openapi.rs
index 1b6d8e4f..c9510379 100644
--- a/crates/backend/src/openapi.rs
+++ b/crates/backend/src/openapi.rs
@@ -1,9 +1,5 @@
 use utoipa::OpenApi;
 
 #[derive(OpenApi)]
-#[openapi(
-    info(title = "Hatsu"),
-    paths(hatsu_api_apub::posts::post::post),
-    components(schemas(hatsu_utils::AppError))
-)]
+#[openapi(info(title = "Hatsu"), components(schemas(hatsu_utils::AppError)))]
 pub struct ApiDoc;


Test patch:
diff --git a/crates/apub/tests/collections_local.rs b/crates/apub/tests/collections_local.rs
new file mode 100644
index 00000000..e54b7cbd
--- /dev/null
+++ b/crates/apub/tests/collections_local.rs
@@ -0,0 +1,34 @@
+use hatsu_apub::collections::{
+    Collection,
+    // TODO: test collection page
+    // CollectionPage
+};
+use hatsu_utils::AppError;
+use url::Url;
+
+#[test]
+fn new_collection() -> Result<(), AppError> {
+    let collection_id = Url::parse("https://hatsu.local/collections/test1")?;
+    let collection = Collection::new(&collection_id, 100, 10)?;
+
+    let expected_first_url = Url::parse("https://hatsu.local/collections/test1?page=1")?;
+    let expected_last_url = Url::parse("https://hatsu.local/collections/test1?page=10")?;
+
+    assert_eq!(collection.first, expected_first_url);
+    assert_eq!(collection.last, expected_last_url);
+
+    Ok(())
+}
+
+#[test]
+fn new_empty_collection() -> Result<(), AppError> {
+    let collection_id = Url::parse("https://hatsu.local/collections/test2")?;
+    let collection = Collection::new(&collection_id, 0, 0)?;
+
+    let expected_url = Url::parse("https://hatsu.local/collections/test2?page=1")?;
+
+    assert_eq!(collection.first, expected_url);
+    assert_eq!(collection.last, expected_url);
+
+    Ok(())
+}