mirror of https://github.com/Nutomic/ibis.git
allow forking remote article to local instance, add search
This commit is contained in:
parent
782ddd30f3
commit
d0e163ed61
86
src/api.rs
86
src/api.rs
|
@ -25,11 +25,13 @@ pub fn api_routes() -> Router {
|
||||||
"/article",
|
"/article",
|
||||||
get(get_article).post(create_article).patch(edit_article),
|
get(get_article).post(create_article).patch(edit_article),
|
||||||
)
|
)
|
||||||
|
.route("/article/fork", post(fork_article))
|
||||||
.route("/edit_conflicts", get(edit_conflicts))
|
.route("/edit_conflicts", get(edit_conflicts))
|
||||||
.route("/resolve_instance", get(resolve_instance))
|
.route("/resolve_instance", get(resolve_instance))
|
||||||
.route("/resolve_article", get(resolve_article))
|
.route("/resolve_article", get(resolve_article))
|
||||||
.route("/instance", get(get_local_instance))
|
.route("/instance", get(get_local_instance))
|
||||||
.route("/instance/follow", post(follow_instance))
|
.route("/instance/follow", post(follow_instance))
|
||||||
|
.route("/search", get(search_article))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize)]
|
#[derive(Deserialize, Serialize)]
|
||||||
|
@ -42,6 +44,16 @@ async fn create_article(
|
||||||
data: Data<DatabaseHandle>,
|
data: Data<DatabaseHandle>,
|
||||||
Form(create_article): Form<CreateArticleData>,
|
Form(create_article): Form<CreateArticleData>,
|
||||||
) -> MyResult<Json<DbArticle>> {
|
) -> MyResult<Json<DbArticle>> {
|
||||||
|
{
|
||||||
|
let articles = data.articles.lock().unwrap();
|
||||||
|
let title_exists = articles
|
||||||
|
.iter()
|
||||||
|
.any(|a| a.1.local && a.1.title == create_article.title);
|
||||||
|
if title_exists {
|
||||||
|
return Err(anyhow!("A local article with this title already exists").into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let local_instance_id = data.local_instance().ap_id;
|
let local_instance_id = data.local_instance().ap_id;
|
||||||
let ap_id = ObjectId::parse(&format!(
|
let ap_id = ObjectId::parse(&format!(
|
||||||
"http://{}:{}/article/{}",
|
"http://{}:{}/article/{}",
|
||||||
|
@ -204,3 +216,77 @@ async fn edit_conflicts(data: Data<DatabaseHandle>) -> MyResult<Json<Vec<ApiConf
|
||||||
.collect();
|
.collect();
|
||||||
Ok(Json(conflicts))
|
Ok(Json(conflicts))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize, Clone)]
|
||||||
|
pub struct SearchArticleData {
|
||||||
|
pub title: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn search_article(
|
||||||
|
Query(query): Query<SearchArticleData>,
|
||||||
|
data: Data<DatabaseHandle>,
|
||||||
|
) -> MyResult<Json<Vec<DbArticle>>> {
|
||||||
|
let articles = data.articles.lock().unwrap();
|
||||||
|
let article = articles
|
||||||
|
.iter()
|
||||||
|
.filter(|a| a.1.title == query.title)
|
||||||
|
.map(|a| a.1)
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
Ok(Json(article))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize)]
|
||||||
|
pub struct ForkArticleData {
|
||||||
|
// TODO: could add optional param new_title so there is no problem with title collision
|
||||||
|
// in case local article with same title exists
|
||||||
|
pub ap_id: ObjectId<DbArticle>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn fork_article(
|
||||||
|
data: Data<DatabaseHandle>,
|
||||||
|
Form(fork_form): Form<ForkArticleData>,
|
||||||
|
) -> MyResult<Json<DbArticle>> {
|
||||||
|
let article = {
|
||||||
|
let lock = data.articles.lock().unwrap();
|
||||||
|
let article = lock.get(fork_form.ap_id.inner()).unwrap();
|
||||||
|
article.clone()
|
||||||
|
};
|
||||||
|
if article.local {
|
||||||
|
return Err(anyhow!("Cannot fork local article because there cant be multiple local articles with same title").into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let original_article = {
|
||||||
|
let lock = data.articles.lock().unwrap();
|
||||||
|
lock.get(fork_form.ap_id.inner())
|
||||||
|
.expect("article exists")
|
||||||
|
.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
let local_instance_id = data.local_instance().ap_id;
|
||||||
|
let ap_id = ObjectId::parse(&format!(
|
||||||
|
"http://{}:{}/article/{}",
|
||||||
|
local_instance_id.inner().domain().unwrap(),
|
||||||
|
local_instance_id.inner().port().unwrap(),
|
||||||
|
original_article.title
|
||||||
|
))?;
|
||||||
|
let forked_article = DbArticle {
|
||||||
|
title: original_article.title.clone(),
|
||||||
|
text: original_article.text.clone(),
|
||||||
|
ap_id,
|
||||||
|
latest_version: original_article.latest_version.clone(),
|
||||||
|
edits: original_article.edits.clone(),
|
||||||
|
instance: local_instance_id,
|
||||||
|
local: true,
|
||||||
|
};
|
||||||
|
{
|
||||||
|
let mut articles = data.articles.lock().unwrap();
|
||||||
|
articles.insert(forked_article.ap_id.inner().clone(), forked_article.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
CreateArticle::send_to_followers(forked_article.clone(), &data).await?;
|
||||||
|
|
||||||
|
Ok(Json(forked_article))
|
||||||
|
}
|
||||||
|
|
|
@ -15,7 +15,7 @@ use activitypub_federation::{
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct DbArticle {
|
pub struct DbArticle {
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub text: String,
|
pub text: String,
|
||||||
|
@ -91,7 +91,7 @@ impl Object for DbArticle {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn from_json(json: Self::Kind, data: &Data<Self::DataType>) -> Result<Self, Self::Error> {
|
async fn from_json(json: Self::Kind, data: &Data<Self::DataType>) -> Result<Self, Self::Error> {
|
||||||
let article = DbArticle {
|
let mut article = DbArticle {
|
||||||
title: json.name,
|
title: json.name,
|
||||||
text: json.content,
|
text: json.content,
|
||||||
ap_id: json.id,
|
ap_id: json.id,
|
||||||
|
@ -107,7 +107,10 @@ impl Object for DbArticle {
|
||||||
lock.insert(article.ap_id.inner().clone(), article.clone());
|
lock.insert(article.ap_id.inner().clone(), article.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
json.edits.dereference(&article, data).await?;
|
let edits = json.edits.dereference(&article, data).await?;
|
||||||
|
|
||||||
|
// include edits in return value (they are already written to db, no need to do that here)
|
||||||
|
article.edits = edits.0;
|
||||||
|
|
||||||
Ok(article)
|
Ok(article)
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,7 +23,7 @@ impl Default for EditVersion {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Represents a single change to the article.
|
/// Represents a single change to the article.
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct DbEdit {
|
pub struct DbEdit {
|
||||||
pub id: ObjectId<DbEdit>,
|
pub id: ObjectId<DbEdit>,
|
||||||
pub diff: String,
|
pub diff: String,
|
||||||
|
|
|
@ -23,7 +23,7 @@ pub struct ApubEditCollection {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct DbEditCollection(Vec<DbEdit>);
|
pub struct DbEditCollection(pub Vec<DbEdit>);
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
impl Collection for DbEditCollection {
|
impl Collection for DbEditCollection {
|
||||||
|
|
|
@ -140,7 +140,7 @@ where
|
||||||
Ok(alpha_instance)
|
Ok(alpha_instance)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn post<T: Serialize, R>(hostname: &str, endpoint: &str, form: &T) -> MyResult<R>
|
pub async fn post<T: Serialize, R>(hostname: &str, endpoint: &str, form: &T) -> MyResult<R>
|
||||||
where
|
where
|
||||||
R: for<'de> Deserialize<'de>,
|
R: for<'de> Deserialize<'de>,
|
||||||
{
|
{
|
||||||
|
|
|
@ -4,16 +4,17 @@ mod common;
|
||||||
|
|
||||||
use crate::common::{
|
use crate::common::{
|
||||||
create_article, edit_article, edit_article_with_conflict, follow_instance, get_article,
|
create_article, edit_article, edit_article_with_conflict, follow_instance, get_article,
|
||||||
get_query, TestData, TEST_ARTICLE_DEFAULT_TEXT,
|
get_query, post, TestData, TEST_ARTICLE_DEFAULT_TEXT,
|
||||||
};
|
};
|
||||||
use common::get;
|
use common::get;
|
||||||
use fediwiki::api::{ApiConflict, EditArticleData, ResolveObject};
|
use fediwiki::api::{
|
||||||
|
ApiConflict, EditArticleData, ForkArticleData, ResolveObject, SearchArticleData,
|
||||||
|
};
|
||||||
use fediwiki::error::MyResult;
|
use fediwiki::error::MyResult;
|
||||||
use fediwiki::federation::objects::article::DbArticle;
|
use fediwiki::federation::objects::article::DbArticle;
|
||||||
use fediwiki::federation::objects::edit::ApubEdit;
|
use fediwiki::federation::objects::edit::ApubEdit;
|
||||||
use fediwiki::federation::objects::instance::DbInstance;
|
use fediwiki::federation::objects::instance::DbInstance;
|
||||||
use serial_test::serial;
|
use serial_test::serial;
|
||||||
|
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
@ -48,6 +49,31 @@ async fn test_create_read_and_edit_article() -> MyResult<()> {
|
||||||
assert_eq!(edit_form.new_text, edit_res.text);
|
assert_eq!(edit_form.new_text, edit_res.text);
|
||||||
assert_eq!(2, edit_res.edits.len());
|
assert_eq!(2, edit_res.edits.len());
|
||||||
|
|
||||||
|
let search_form = SearchArticleData {
|
||||||
|
title: title.clone(),
|
||||||
|
};
|
||||||
|
let search_res: Vec<DbArticle> =
|
||||||
|
get_query(data.hostname_alpha, "search", Some(search_form)).await?;
|
||||||
|
assert_eq!(1, search_res.len());
|
||||||
|
assert_eq!(edit_res, search_res[0]);
|
||||||
|
|
||||||
|
data.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn test_create_duplicate_article() -> MyResult<()> {
|
||||||
|
let data = TestData::start();
|
||||||
|
|
||||||
|
// create article
|
||||||
|
let title = "Manu_Chao".to_string();
|
||||||
|
let create_res = create_article(data.hostname_alpha, title.clone()).await?;
|
||||||
|
assert_eq!(title, create_res.title);
|
||||||
|
assert!(create_res.local);
|
||||||
|
|
||||||
|
let create_res = create_article(data.hostname_alpha, title.clone()).await;
|
||||||
|
assert!(create_res.is_err());
|
||||||
|
|
||||||
data.stop()
|
data.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -378,3 +404,49 @@ async fn test_overlapping_edits_no_conflict() -> MyResult<()> {
|
||||||
|
|
||||||
data.stop()
|
data.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn test_fork_article() -> MyResult<()> {
|
||||||
|
let data = TestData::start();
|
||||||
|
|
||||||
|
// create article
|
||||||
|
let title = "Manu_Chao".to_string();
|
||||||
|
let create_res = create_article(data.hostname_alpha, title.clone()).await?;
|
||||||
|
assert_eq!(title, create_res.title);
|
||||||
|
assert!(create_res.local);
|
||||||
|
|
||||||
|
// fetch on beta
|
||||||
|
let resolve_object = ResolveObject {
|
||||||
|
id: create_res.ap_id.into_inner(),
|
||||||
|
};
|
||||||
|
let resolved_article =
|
||||||
|
get_query::<DbArticle, _>(data.hostname_beta, "resolve_article", Some(resolve_object))
|
||||||
|
.await?;
|
||||||
|
assert_eq!(create_res.edits.len(), resolved_article.edits.len());
|
||||||
|
|
||||||
|
// fork the article to local instance
|
||||||
|
let fork_form = ForkArticleData {
|
||||||
|
ap_id: resolved_article.ap_id.clone(),
|
||||||
|
};
|
||||||
|
let fork_res: DbArticle = post(data.hostname_beta, "article/fork", &fork_form).await?;
|
||||||
|
assert_eq!(resolved_article.title, fork_res.title);
|
||||||
|
assert_eq!(resolved_article.text, fork_res.text);
|
||||||
|
assert_eq!(resolved_article.edits, fork_res.edits);
|
||||||
|
assert_eq!(resolved_article.latest_version, fork_res.latest_version);
|
||||||
|
assert_ne!(resolved_article.ap_id, fork_res.ap_id);
|
||||||
|
assert!(fork_res.local);
|
||||||
|
|
||||||
|
let beta_instance: DbInstance = get(data.hostname_beta, "instance").await?;
|
||||||
|
assert_eq!(fork_res.instance, beta_instance.ap_id);
|
||||||
|
|
||||||
|
// now search returns two articles for this title (original and forked)
|
||||||
|
let search_form = SearchArticleData {
|
||||||
|
title: title.clone(),
|
||||||
|
};
|
||||||
|
let search_res: Vec<DbArticle> =
|
||||||
|
get_query(data.hostname_beta, "search", Some(search_form)).await?;
|
||||||
|
assert_eq!(2, search_res.len());
|
||||||
|
|
||||||
|
data.stop()
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue