cursor stuff
This commit is contained in:
parent
192a952ae8
commit
97498d3c65
7 changed files with 327 additions and 36 deletions
|
@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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(
|
||||||
|
|
88
src/main.rs
88
src/main.rs
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>;
|
||||||
|
|
|
@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue