cursor stuff

This commit is contained in:
Hamcha 2025-01-25 15:21:38 +01:00
parent 192a952ae8
commit 97498d3c65
Signed by: hamcha
GPG key ID: 1669C533B8CF6D89
7 changed files with 327 additions and 36 deletions
src
domain/entities
inbound/renderer
main.rs
outbound
repository
services

View file

@ -5,3 +5,21 @@ pub struct Paginated<DataType, CursorType> {
pub data: Vec<DataType>, pub data: Vec<DataType>,
pub next: Option<CursorType>, pub next: Option<CursorType>,
} }
pub struct CursorOptions<CursorType> {
pub limit: u32,
pub after: Option<CursorType>,
}
impl<T> CursorOptions<T> {
pub fn none(limit: u32) -> Self {
Self { limit, after: None }
}
pub fn after(cursor: T, limit: u32) -> Self {
Self {
limit,
after: Some(cursor),
}
}
}

View file

@ -2,7 +2,7 @@ use dioxus::prelude::*;
use crate::domain::entities::site::{Block, PageContent, Post}; use crate::domain::entities::site::{Block, PageContent, Post};
use super::meta; use super::{meta, server::get_posts};
#[component] #[component]
pub fn Page() -> Element { pub fn Page() -> Element {
@ -18,7 +18,7 @@ pub fn Page() -> Element {
PageContent::Collection { kind: _, collection_id } => { PageContent::Collection { kind: _, collection_id } => {
rsx! { rsx! {
SuspenseBoundary { fallback: |_context: SuspenseContext| rsx! { "..." }, SuspenseBoundary { fallback: |_context: SuspenseContext| rsx! { "..." },
//Collection { collection_id } Collection { collection_id }
} }
} }
} }
@ -26,7 +26,6 @@ pub fn Page() -> Element {
} }
} }
/*
#[component] #[component]
pub fn Collection(collection_id: String) -> Element { pub fn Collection(collection_id: String) -> Element {
let site = meta::site(); let site = meta::site();
@ -48,7 +47,7 @@ pub fn Collection(collection_id: String) -> Element {
}; };
result result
}*/ }
#[component] #[component]
pub fn PostElement(post: Post) -> Element { pub fn PostElement(post: Post) -> Element {

View file

@ -32,7 +32,6 @@ pub async fn get_page(domain: String, page: String) -> Result<Page, ServerFnErro
Ok(service.get_page(&domain, &page).await?) Ok(service.get_page(&domain, &page).await?)
} }
/*
#[server] #[server]
pub async fn get_posts( pub async fn get_posts(
domain: String, domain: String,
@ -41,8 +40,8 @@ pub async fn get_posts(
use crate::outbound::services::site::SiteService; use crate::outbound::services::site::SiteService;
let FromContext::<SiteService>(service) = extract().await?; let FromContext::<SiteService>(service) = extract().await?;
Ok(service.get_posts(&domain, &collection_id).await?) Ok(service.get_posts(&domain, &collection_id, None).await?)
}*/ }
#[server] #[server]
pub async fn get_post( pub async fn get_post(

View file

@ -18,33 +18,71 @@ fn main() {
use outbound::repository::adapters::memory::InMemoryStore; use outbound::repository::adapters::memory::InMemoryStore;
use outbound::services::site::SiteService; use outbound::services::site::SiteService;
builder = builder builder = builder.with_context_provider(|| {
.with_context_provider( let mut store = InMemoryStore::new();
|| { store.add_site(outbound::repository::site::SiteMetadata {
let mut store = InMemoryStore::new(); domain: "localhost".to_string(),
store.add_site(outbound::repository::site::SiteMetadata { title: "Test site".to_string(),
domain: "localhost".to_string(), });
title: "Test site".to_string(), store.add_page(
}); "localhost",
store.add_page("localhost", domain::entities::site::Page{ domain::entities::site::Page {
info: domain::entities::site::PageInfo { info: domain::entities::site::PageInfo {
title: "Home".to_string(), title: "Home".to_string(),
name: "/".to_string(), name: "/".to_string(),
order: 0, order: 0,
},
content: domain::entities::site::PageContent::Single {
content: domain::entities::site::Post {
blocks: vec![domain::entities::site::Block::Text {
text: "Hello, world!".to_string(),
}],
}, },
content: domain::entities::site::PageContent::Single { },
content: domain::entities::site::Post { },
blocks: vec![{
domain::entities::site::Block::Text {
text: "Hello, world!".to_string(),
}
}],
},
}
});
Box::new(SiteService::new(store))
}
); );
store.add_page(
"localhost",
domain::entities::site::Page {
info: domain::entities::site::PageInfo {
title: "Cool page".to_string(),
name: "cool".to_string(),
order: 10,
},
content: domain::entities::site::PageContent::Collection {
kind: domain::entities::site::CollectionKind::Blog,
collection_id: "cool-posts".to_string(),
},
},
);
store.add_post(
"localhost",
"cool-posts",
"test_id",
domain::entities::site::Post {
blocks: vec![domain::entities::site::Block::Text {
text: "This is a cool post!".to_string(),
}],
},
);
store.add_post(
"localhost",
"cool-posts",
"test_id_2",
domain::entities::site::Post {
blocks: vec![
domain::entities::site::Block::Text {
text: "This is another cool post!".to_string(),
},
domain::entities::site::Block::Text {
text: "With two blocks!".to_string(),
},
],
},
);
Box::new(SiteService::new(store))
});
} }
builder.launch(inbound::renderer::App); builder.launch(inbound::renderer::App);
} }

View file

@ -3,7 +3,10 @@ use std::collections::HashMap;
use async_trait::async_trait; use async_trait::async_trait;
use crate::{ use crate::{
domain::entities::site::{Page, PageInfo, Post}, domain::entities::{
cursor::{CursorOptions, Paginated},
site::{Page, PageInfo, Post},
},
outbound::repository::{ outbound::repository::{
self, self,
site::{Result, SiteMetadata, SiteRepository}, site::{Result, SiteMetadata, SiteRepository},
@ -97,6 +100,17 @@ impl InMemoryStore {
}, },
); );
store.add_post(
"example.com",
"home_posts",
"post_id2",
Post {
blocks: vec![Block::Text {
text: "Hello again!".to_string(),
}],
},
);
store store
} }
} }
@ -137,12 +151,65 @@ impl SiteRepository for InMemoryStore {
.cloned() .cloned()
.ok_or(repository::site::Error::NotFound) .ok_or(repository::site::Error::NotFound)
} }
async fn get_posts_for_collection(
&self,
domain: &str,
collection_id: &str,
cursor: CursorOptions<String>,
) -> Result<Paginated<Post, String>> {
let posts = self
.posts
.get(&(domain.to_string(), collection_id.to_string()))
.ok_or(repository::site::Error::NotFound)
.cloned()?;
// Sort by post_id
let mut posts: Vec<(&String, &Post)> = posts.iter().collect();
posts.sort_by_key(|(id, _)| id.to_string());
// This entire thing is very inefficient but uuuuh it's in-memory
// and mostly for testing
if let Some(after) = cursor.after {
// Skip posts before and including the cursor post_id
posts = posts
.iter()
.skip_while(|(id, _)| id.to_owned() <= &after)
.cloned()
.collect();
}
// Limit the number of posts returned
if posts.len() > cursor.limit as usize {
posts = posts.iter().take(cursor.limit as usize).cloned().collect();
Ok(Paginated {
data: posts
.iter()
.map(|(_, post)| post.to_owned())
.cloned()
.collect(),
next: posts.last().map(|(id, _)| id.to_owned()).cloned(),
})
} else {
Ok(Paginated {
data: posts
.iter()
.map(|(_, post)| post.to_owned())
.cloned()
.collect(),
next: None,
})
}
}
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::{ use crate::{
domain::entities::site::{Block, Page, PageContent, PageInfo, Post}, domain::entities::{
cursor::CursorOptions,
site::{Block, Page, PageContent, PageInfo, Post},
},
outbound::repository::{ outbound::repository::{
self, self,
site::{SiteMetadata, SiteRepository}, site::{SiteMetadata, SiteRepository},
@ -297,4 +364,110 @@ mod tests {
repository::site::Error::NotFound repository::site::Error::NotFound
)); ));
} }
#[tokio::test]
async fn get_posts_for_page_works() {
let mut store = InMemoryStore::new();
store.add_post(
"example.com",
"home_posts",
"test_id",
Post {
blocks: vec![Block::Text {
text: "Hello, world!".to_string(),
}],
},
);
let posts = store
.get_posts_for_collection("example.com", "home_posts", CursorOptions::none(10))
.await
.unwrap();
assert_eq!(posts.data.len(), 1);
assert_eq!(posts.next, None);
let Post { blocks } = &posts.data[0];
let Block::Text { text } = &blocks[0] else {
panic!("page content must be a single text block")
};
assert_eq!(text, "Hello, world!");
}
#[tokio::test]
async fn get_posts_for_page_with_cursor_works() {
let mut store = InMemoryStore::new();
store.add_post(
"example.com",
"home_posts",
"test_id",
Post {
blocks: vec![Block::Text {
text: "Hello, world!".to_string(),
}],
},
);
store.add_post(
"example.com",
"home_posts",
"test_id_2",
Post {
blocks: vec![Block::Text {
text: "Hello, world!".to_string(),
}],
},
);
let posts = store
.get_posts_for_collection(
"example.com",
"home_posts",
CursorOptions::after("test_id".to_string(), 10),
)
.await
.unwrap();
assert_eq!(posts.data.len(), 1);
assert_eq!(posts.next, None);
}
#[tokio::test]
async fn get_posts_for_page_returns_cursor() {
let mut store = InMemoryStore::new();
store.add_post(
"example.com",
"home_posts",
"test_id",
Post {
blocks: vec![Block::Text {
text: "Hello, world!".to_string(),
}],
},
);
store.add_post(
"example.com",
"home_posts",
"test_id_2",
Post {
blocks: vec![Block::Text {
text: "Hello, world!".to_string(),
}],
},
);
let posts = store
.get_posts_for_collection("example.com", "home_posts", CursorOptions::none(1))
.await
.unwrap();
assert_eq!(posts.data.len(), 1);
assert_eq!(posts.next, Some("test_id".to_string()));
}
#[tokio::test]
async fn get_posts_for_page_for_nonexistent_fails() {
let store = InMemoryStore::new();
let result = store
.get_posts_for_collection("example.com", "home_posts", CursorOptions::none(10))
.await;
assert!(result.is_err());
assert!(matches!(
result.err().unwrap(),
repository::site::Error::NotFound
));
}
} }

View file

@ -1,7 +1,10 @@
use async_trait::async_trait; use async_trait::async_trait;
use thiserror::Error; use thiserror::Error;
use crate::domain::entities::site::{Page, PageInfo, Post}; use crate::domain::entities::{
cursor::{CursorOptions, Paginated},
site::{Page, PageInfo, Post},
};
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct SiteMetadata { pub struct SiteMetadata {
@ -15,6 +18,12 @@ pub trait SiteRepository: Send + Sync + 'static {
async fn get_pages_for_site(&self, domain: &str) -> Result<Vec<PageInfo>>; async fn get_pages_for_site(&self, domain: &str) -> Result<Vec<PageInfo>>;
async fn get_page(&self, domain: &str, name: &str) -> Result<Page>; async fn get_page(&self, domain: &str, name: &str) -> Result<Page>;
async fn get_post(&self, domain: &str, collection_id: &str, id: &str) -> Result<Post>; async fn get_post(&self, domain: &str, collection_id: &str, id: &str) -> Result<Post>;
async fn get_posts_for_collection(
&self,
domain: &str,
collection_id: &str,
cursor_options: CursorOptions<String>,
) -> Result<Paginated<Post, String>>;
} }
pub type Result<T> = std::result::Result<T, Error>; pub type Result<T> = std::result::Result<T, Error>;

View file

@ -3,7 +3,10 @@ use std::sync::Arc;
use thiserror::Error; use thiserror::Error;
use crate::{ use crate::{
domain::entities::site::{Page, Post, SiteInfo}, domain::entities::{
cursor::{CursorOptions, Paginated},
site::{Page, Post, SiteInfo},
},
outbound::repository::{self, site::SiteRepository}, outbound::repository::{self, site::SiteRepository},
}; };
@ -49,6 +52,28 @@ impl SiteService {
Ok(post) Ok(post)
} }
pub async fn get_posts(
&self,
domain: &str,
collection_id: &str,
cursor: Option<String>,
) -> Result<Paginated<Post, String>> {
let info = self.site_repository.get_site_by_domain(domain).await?;
let posts = self
.site_repository
.get_posts_for_collection(
&info.domain,
collection_id,
CursorOptions {
after: cursor,
limit: 10,
},
)
.await?;
Ok(posts)
}
} }
pub type Result<T> = std::result::Result<T, Error>; pub type Result<T> = std::result::Result<T, Error>;
@ -151,4 +176,34 @@ mod tests {
assert!(result.is_err()); assert!(result.is_err());
assert!(matches!(result.err().unwrap(), Error::NotFound)); assert!(matches!(result.err().unwrap(), Error::NotFound));
} }
#[tokio::test]
async fn gets_posts() {
let service = SiteService::new(InMemoryStore::with_test_data());
let posts = service
.get_posts("example.com", "home_posts", None)
.await
.unwrap();
assert_eq!(posts.data.len(), 2);
assert_eq!(posts.next, None);
}
#[tokio::test]
async fn gets_posts_with_cursor() {
let service = SiteService::new(InMemoryStore::with_test_data());
let posts = service
.get_posts("example.com", "home_posts", Some("post_id".to_string()))
.await
.unwrap();
assert_eq!(posts.data.len(), 1);
assert_eq!(posts.next, None);
}
#[tokio::test]
async fn gets_nonexistent_posts() {
let service = SiteService::new(InMemoryStore::with_test_data());
let result = service.get_posts("example.com", "nonexistent", None).await;
assert!(result.is_err());
assert!(matches!(result.err().unwrap(), Error::NotFound));
}
} }