1
0
Fork 0
mirror of https://github.com/Nutomic/ibis.git synced 2024-11-22 18:01:10 +00:00

federate article updates using diffs

This commit is contained in:
Felix Ableitner 2023-11-21 16:27:18 +01:00
parent 5f7837d843
commit 4e458650b8
15 changed files with 171 additions and 94 deletions

View file

@ -1,10 +1,9 @@
use crate::database::DatabaseHandle; use crate::database::DatabaseHandle;
use crate::error::MyResult; use crate::error::MyResult;
use crate::federation::activities::create_or_update_article::{ use crate::federation::activities::create_article::CreateArticle;
CreateOrUpdateArticle, CreateOrUpdateType, use crate::federation::activities::update_article::UpdateArticle;
};
use crate::federation::objects::article::DbArticle; use crate::federation::objects::article::DbArticle;
use crate::federation::objects::edit::DbEdit;
use crate::federation::objects::instance::DbInstance; use crate::federation::objects::instance::DbInstance;
use activitypub_federation::config::Data; use activitypub_federation::config::Data;
use activitypub_federation::fetch::object_id::ObjectId; use activitypub_federation::fetch::object_id::ObjectId;
@ -15,7 +14,6 @@ use axum::{Form, Json, Router};
use axum_macros::debug_handler; use axum_macros::debug_handler;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use url::Url; use url::Url;
use crate::federation::objects::edit::DbEdit;
pub fn api_routes() -> Router { pub fn api_routes() -> Router {
Router::new() Router::new()
@ -29,7 +27,7 @@ pub fn api_routes() -> Router {
} }
#[derive(Deserialize, Serialize)] #[derive(Deserialize, Serialize)]
pub struct CreateArticle { pub struct CreateArticleData {
pub title: String, pub title: String,
pub text: String, pub text: String,
} }
@ -38,7 +36,7 @@ pub struct CreateArticle {
#[debug_handler] #[debug_handler]
async fn create_article( async fn create_article(
data: Data<DatabaseHandle>, data: Data<DatabaseHandle>,
Form(create_article): Form<CreateArticle>, Form(create_article): Form<CreateArticleData>,
) -> MyResult<Json<DbArticle>> { ) -> MyResult<Json<DbArticle>> {
let local_instance_id = data.local_instance().ap_id; let local_instance_id = data.local_instance().ap_id;
let ap_id = Url::parse(&format!( let ap_id = Url::parse(&format!(
@ -61,27 +59,21 @@ async fn create_article(
articles.insert(article.ap_id.inner().clone(), article.clone()); articles.insert(article.ap_id.inner().clone(), article.clone());
} }
CreateOrUpdateArticle::send_to_local_followers( CreateArticle::send_to_followers(article.clone(), &data).await?;
article.clone(),
CreateOrUpdateType::Create,
&data,
)
.await?;
Ok(Json(article)) Ok(Json(article))
} }
#[derive(Deserialize, Serialize)] #[derive(Deserialize, Serialize)]
pub struct EditArticle { pub struct EditArticleData {
pub ap_id: ObjectId<DbArticle>, pub ap_id: ObjectId<DbArticle>,
pub new_text: String, pub new_text: String,
} }
// TODO: this should create an edit object
#[debug_handler] #[debug_handler]
async fn edit_article( async fn edit_article(
data: Data<DatabaseHandle>, data: Data<DatabaseHandle>,
Form(edit_article): Form<EditArticle>, Form(edit_article): Form<EditArticleData>,
) -> MyResult<Json<DbArticle>> { ) -> MyResult<Json<DbArticle>> {
let original_article = { let original_article = {
let mut lock = data.articles.lock().unwrap(); let mut lock = data.articles.lock().unwrap();
@ -93,28 +85,23 @@ async fn edit_article(
let mut lock = data.articles.lock().unwrap(); let mut lock = data.articles.lock().unwrap();
let article = lock.get_mut(edit_article.ap_id.inner()).unwrap(); let article = lock.get_mut(edit_article.ap_id.inner()).unwrap();
article.text = edit_article.new_text; article.text = edit_article.new_text;
article.edits.push(edit); article.edits.push(edit.clone());
article.clone() article.clone()
}; };
CreateOrUpdateArticle::send_to_local_followers( UpdateArticle::send_to_followers(updated_article.clone(), edit, &data).await?;
updated_article.clone(),
CreateOrUpdateType::Update,
&data,
)
.await?;
Ok(Json(updated_article)) Ok(Json(updated_article))
} }
#[derive(Deserialize, Serialize, Clone)] #[derive(Deserialize, Serialize, Clone)]
pub struct GetArticle { pub struct GetArticleData {
pub title: String, pub title: String,
} }
#[debug_handler] #[debug_handler]
async fn get_article( async fn get_article(
Query(query): Query<GetArticle>, Query(query): Query<GetArticleData>,
data: Data<DatabaseHandle>, data: Data<DatabaseHandle>,
) -> MyResult<Json<DbArticle>> { ) -> MyResult<Json<DbArticle>> {
let articles = data.articles.lock().unwrap(); let articles = data.articles.lock().unwrap();

View file

@ -1,6 +1,6 @@
use crate::error::MyResult; use crate::error::MyResult;
use crate::federation::objects::instance::DbInstance; use crate::federation::objects::instance::DbInstance;
use crate::utils::generate_object_id; use crate::utils::generate_activity_id;
use crate::{database::DatabaseHandle, federation::activities::follow::Follow}; use crate::{database::DatabaseHandle, federation::activities::follow::Follow};
use activitypub_federation::{ use activitypub_federation::{
config::Data, fetch::object_id::ObjectId, kinds::activity::AcceptType, traits::ActivityHandler, config::Data, fetch::object_id::ObjectId, kinds::activity::AcceptType, traits::ActivityHandler,
@ -20,7 +20,7 @@ pub struct Accept {
impl Accept { impl Accept {
pub fn new(actor: ObjectId<DbInstance>, object: Follow) -> MyResult<Accept> { pub fn new(actor: ObjectId<DbInstance>, object: Follow) -> MyResult<Accept> {
let id = generate_object_id(actor.inner())?; let id = generate_activity_id(actor.inner())?;
Ok(Accept { Ok(Accept {
actor, actor,
object, object,

View file

@ -2,7 +2,8 @@ use crate::database::DatabaseHandle;
use crate::error::MyResult; use crate::error::MyResult;
use crate::federation::objects::article::{ApubArticle, DbArticle}; use crate::federation::objects::article::{ApubArticle, DbArticle};
use crate::federation::objects::instance::DbInstance; use crate::federation::objects::instance::DbInstance;
use crate::utils::generate_object_id; use crate::utils::generate_activity_id;
use activitypub_federation::kinds::activity::CreateType;
use activitypub_federation::{ use activitypub_federation::{
config::Data, config::Data,
fetch::object_id::ObjectId, fetch::object_id::ObjectId,
@ -12,57 +13,41 @@ use activitypub_federation::{
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use url::Url; use url::Url;
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
pub enum CreateOrUpdateType {
Create,
Update,
}
// TODO: temporary placeholder, later rework this to send diffs
#[derive(Deserialize, Serialize, Debug)] #[derive(Deserialize, Serialize, Debug)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct CreateOrUpdateArticle { pub struct CreateArticle {
pub actor: ObjectId<DbInstance>, pub actor: ObjectId<DbInstance>,
#[serde(deserialize_with = "deserialize_one_or_many")] #[serde(deserialize_with = "deserialize_one_or_many")]
pub to: Vec<Url>, pub to: Vec<Url>,
pub object: ApubArticle, pub object: ApubArticle,
#[serde(rename = "type")] #[serde(rename = "type")]
pub kind: CreateOrUpdateType, pub kind: CreateType,
pub id: Url, pub id: Url,
} }
impl CreateOrUpdateArticle { impl CreateArticle {
pub async fn send_to_local_followers( pub async fn send_to_followers(
article: DbArticle, article: DbArticle,
kind: CreateOrUpdateType,
data: &Data<DatabaseHandle>, data: &Data<DatabaseHandle>,
) -> MyResult<()> { ) -> MyResult<()> {
let local_instance = data.local_instance(); let local_instance = data.local_instance();
let to = local_instance
.followers
.iter()
.map(|f| f.ap_id.inner().clone())
.collect();
let object = article.clone().into_json(data).await?; let object = article.clone().into_json(data).await?;
let id = generate_object_id(local_instance.ap_id.inner())?; let id = generate_activity_id(local_instance.ap_id.inner())?;
let create_or_update = CreateOrUpdateArticle { let create_or_update = CreateArticle {
actor: local_instance.ap_id.clone(), actor: local_instance.ap_id.clone(),
to, to: local_instance.follower_ids(),
object, object,
kind, kind: Default::default(),
id, id,
}; };
let inboxes = local_instance local_instance
.followers .send_to_followers(create_or_update, data)
.iter() .await?;
.map(|f| f.inbox.clone())
.collect();
local_instance.send(create_or_update, inboxes, data).await?;
Ok(()) Ok(())
} }
} }
#[async_trait::async_trait] #[async_trait::async_trait]
impl ActivityHandler for CreateOrUpdateArticle { impl ActivityHandler for CreateArticle {
type DataType = DatabaseHandle; type DataType = DatabaseHandle;
type Error = crate::error::Error; type Error = crate::error::Error;

View file

@ -1,6 +1,8 @@
use crate::error::MyResult; use crate::error::MyResult;
use crate::federation::objects::instance::DbInstance; use crate::federation::objects::instance::DbInstance;
use crate::{database::DatabaseHandle, federation::activities::accept::Accept, generate_object_id}; use crate::{
database::DatabaseHandle, federation::activities::accept::Accept, generate_activity_id,
};
use activitypub_federation::{ use activitypub_federation::{
config::Data, config::Data,
fetch::object_id::ObjectId, fetch::object_id::ObjectId,
@ -22,7 +24,7 @@ pub struct Follow {
impl Follow { impl Follow {
pub fn new(actor: ObjectId<DbInstance>, object: ObjectId<DbInstance>) -> MyResult<Follow> { pub fn new(actor: ObjectId<DbInstance>, object: ObjectId<DbInstance>) -> MyResult<Follow> {
let id = generate_object_id(actor.inner())?; let id = generate_activity_id(actor.inner())?;
Ok(Follow { Ok(Follow {
actor, actor,
object, object,

View file

@ -1,3 +1,4 @@
pub mod accept; pub mod accept;
pub mod create_or_update_article; pub mod create_article;
pub mod follow; pub mod follow;
pub mod update_article;

View file

@ -0,0 +1,80 @@
use crate::database::DatabaseHandle;
use crate::error::MyResult;
use crate::federation::objects::article::DbArticle;
use crate::federation::objects::edit::{ApubEdit, DbEdit};
use crate::federation::objects::instance::DbInstance;
use crate::utils::generate_activity_id;
use activitypub_federation::kinds::activity::CreateType;
use activitypub_federation::{
config::Data,
fetch::object_id::ObjectId,
protocol::helpers::deserialize_one_or_many,
traits::{ActivityHandler, Object},
};
use diffy::{apply, Patch};
use serde::{Deserialize, Serialize};
use url::Url;
#[derive(Deserialize, Serialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct UpdateArticle {
pub actor: ObjectId<DbInstance>,
#[serde(deserialize_with = "deserialize_one_or_many")]
pub to: Vec<Url>,
pub object: ObjectId<DbArticle>,
pub result: ApubEdit,
#[serde(rename = "type")]
pub kind: CreateType,
pub id: Url,
}
impl UpdateArticle {
pub async fn send_to_followers(
article: DbArticle,
edit: DbEdit,
data: &Data<DatabaseHandle>,
) -> MyResult<()> {
let local_instance = data.local_instance();
let id = generate_activity_id(local_instance.ap_id.inner())?;
let create_or_update = UpdateArticle {
actor: local_instance.ap_id.clone(),
to: local_instance.follower_ids(),
object: article.ap_id,
result: edit.into_json(data).await?,
kind: Default::default(),
id,
};
local_instance
.send_to_followers(create_or_update, data)
.await?;
Ok(())
}
}
#[async_trait::async_trait]
impl ActivityHandler for UpdateArticle {
type DataType = DatabaseHandle;
type Error = crate::error::Error;
fn id(&self) -> &Url {
&self.id
}
fn actor(&self) -> &Url {
self.actor.inner()
}
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
Ok(())
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let edit = DbEdit::from_json(self.result.clone(), data).await?;
let mut lock = data.articles.lock().unwrap();
let article = lock.get_mut(self.object.inner()).unwrap();
article.edits.push(edit);
// TODO: probably better to apply patch inside DbEdit::from_json()
let patch = Patch::from_str(&self.result.diff)?;
article.text = apply(&article.text, &patch)?;
Ok(())
}
}

View file

@ -1,10 +0,0 @@
#[test]
fn test_diff() {
use diffy::create_patch;
let original = "The Way of Kings\nWords of Radiance\n";
let modified = "The Way of Kings\nWords of Radiance\nOathbringer\n";
let patch = create_patch(original, modified);
assert_eq!("--- original\n+++ modified\n@@ -1,2 +1,3 @@\n The Way of Kings\n Words of Radiance\n+Oathbringer\n", patch.to_string());
}

View file

@ -9,7 +9,6 @@ use std::sync::{Arc, Mutex};
use url::Url; use url::Url;
pub mod activities; pub mod activities;
mod diff;
pub mod objects; pub mod objects;
pub mod routes; pub mod routes;

View file

@ -6,8 +6,8 @@ use activitypub_federation::fetch::object_id::ObjectId;
use activitypub_federation::traits::Object; use activitypub_federation::traits::Object;
use diffy::create_patch; use diffy::create_patch;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sha2::Digest;
use sha2::Sha224; use sha2::Sha224;
use sha2::{Digest};
use url::Url; use url::Url;
/// Represents a single change to the article. /// Represents a single change to the article.
@ -28,7 +28,7 @@ impl DbEdit {
Ok(DbEdit { Ok(DbEdit {
id: edit_id, id: edit_id,
diff: diff.to_string(), diff: diff.to_string(),
local: true local: true,
}) })
} }
} }
@ -44,7 +44,7 @@ pub struct ApubEdit {
#[serde(rename = "type")] #[serde(rename = "type")]
kind: EditType, kind: EditType,
id: ObjectId<DbEdit>, id: ObjectId<DbEdit>,
diff: String, pub(crate) diff: String,
} }
#[async_trait::async_trait] #[async_trait::async_trait]

View file

@ -75,7 +75,11 @@ impl Collection for DbEditCollection {
try_join_all(apub.items.into_iter().map(|i| DbEdit::from_json(i, data))).await?; try_join_all(apub.items.into_iter().map(|i| DbEdit::from_json(i, data))).await?;
let mut articles = data.articles.lock().unwrap(); let mut articles = data.articles.lock().unwrap();
let article = articles.get_mut(owner.ap_id.inner()).unwrap(); let article = articles.get_mut(owner.ap_id.inner()).unwrap();
let edit_ids = article.edits.iter().map(|e| e.id.clone()).collect::<Vec<_>>(); let edit_ids = article
.edits
.iter()
.map(|e| e.id.clone())
.collect::<Vec<_>>();
for e in edits.clone() { for e in edits.clone() {
if !edit_ids.contains(&&e.id) { if !edit_ids.contains(&&e.id) {
article.edits.push(e); article.edits.push(e);

View file

@ -1,4 +1,4 @@
use crate::error::Error; use crate::error::{Error, MyResult};
use crate::federation::objects::articles_collection::DbArticleCollection; use crate::federation::objects::articles_collection::DbArticleCollection;
use crate::{database::DatabaseHandle, federation::activities::follow::Follow}; use crate::{database::DatabaseHandle, federation::activities::follow::Follow};
use activitypub_federation::activity_sending::SendActivityTask; use activitypub_federation::activity_sending::SendActivityTask;
@ -40,10 +40,17 @@ pub struct Instance {
} }
impl DbInstance { impl DbInstance {
pub fn followers_url(&self) -> Result<Url, Error> { pub fn followers_url(&self) -> MyResult<Url> {
Ok(Url::parse(&format!("{}/followers", self.ap_id.inner()))?) Ok(Url::parse(&format!("{}/followers", self.ap_id.inner()))?)
} }
pub fn follower_ids(&self) -> Vec<Url> {
self.followers
.iter()
.map(|f| f.ap_id.inner().clone())
.collect()
}
pub async fn follow( pub async fn follow(
&self, &self,
other: &DbInstance, other: &DbInstance,
@ -55,7 +62,26 @@ impl DbInstance {
Ok(()) Ok(())
} }
pub(crate) async fn send<Activity>( pub async fn send_to_followers<Activity>(
&self,
activity: Activity,
data: &Data<DatabaseHandle>,
) -> Result<(), <Activity as ActivityHandler>::Error>
where
Activity: ActivityHandler + Serialize + Debug + Send + Sync,
<Activity as ActivityHandler>::Error: From<activitypub_federation::error::Error>,
{
let local_instance = data.local_instance();
let inboxes = local_instance
.followers
.iter()
.map(|f| f.inbox.clone())
.collect();
local_instance.send(activity, inboxes, data).await?;
Ok(())
}
pub async fn send<Activity>(
&self, &self,
activity: Activity, activity: Activity,
recipients: Vec<Url>, recipients: Vec<Url>,

View file

@ -12,7 +12,8 @@ use activitypub_federation::traits::Object;
use activitypub_federation::traits::{ActivityHandler, Collection}; use activitypub_federation::traits::{ActivityHandler, Collection};
use axum::extract::Path; use axum::extract::Path;
use crate::federation::activities::create_or_update_article::CreateOrUpdateArticle; use crate::federation::activities::create_article::CreateArticle;
use crate::federation::activities::update_article::UpdateArticle;
use crate::federation::objects::article::ApubArticle; use crate::federation::objects::article::ApubArticle;
use crate::federation::objects::articles_collection::{ArticleCollection, DbArticleCollection}; use crate::federation::objects::articles_collection::{ArticleCollection, DbArticleCollection};
use crate::federation::objects::edits_collection::{ApubEditCollection, DbEditCollection}; use crate::federation::objects::edits_collection::{ApubEditCollection, DbEditCollection};
@ -82,7 +83,8 @@ async fn http_get_article_edits(
pub enum InboxActivities { pub enum InboxActivities {
Follow(Follow), Follow(Follow),
Accept(Accept), Accept(Accept),
CreateOrUpdateArticle(CreateOrUpdateArticle), CreateArticle(CreateArticle),
UpdateArticle(UpdateArticle),
} }
#[debug_handler] #[debug_handler]

View file

@ -1,4 +1,4 @@
use crate::utils::generate_object_id; use crate::utils::generate_activity_id;
use activitypub_federation::config::FederationMiddleware; use activitypub_federation::config::FederationMiddleware;
use axum::{Router, Server}; use axum::{Router, Server};

View file

@ -1,9 +1,7 @@
use rand::{distributions::Alphanumeric, thread_rng, Rng}; use rand::{distributions::Alphanumeric, thread_rng, Rng};
use url::{ParseError, Url}; use url::{ParseError, Url};
/// Just generate random url as object id. In a real project, you probably want to use pub fn generate_activity_id(domain: &Url) -> Result<Url, ParseError> {
/// an url which contains the database id for easy retrieval (or store the random id in db).
pub fn generate_object_id(domain: &Url) -> Result<Url, ParseError> {
let port = domain.port().unwrap(); let port = domain.port().unwrap();
let domain = domain.domain().unwrap(); let domain = domain.domain().unwrap();
let id: String = thread_rng() let id: String = thread_rng()
@ -11,5 +9,5 @@ pub fn generate_object_id(domain: &Url) -> Result<Url, ParseError> {
.take(7) .take(7)
.map(char::from) .map(char::from)
.collect(); .collect();
Url::parse(&format!("http://{}:{}/objects/{}", domain,port, id)) Url::parse(&format!("http://{}:{}/objects/{}", domain, port, id))
} }

View file

@ -4,7 +4,7 @@ mod common;
use crate::common::{follow_instance, get_query, patch, post, TestData}; use crate::common::{follow_instance, get_query, patch, post, TestData};
use common::get; use common::get;
use fediwiki::api::{CreateArticle, EditArticle, GetArticle, ResolveObject}; use fediwiki::api::{CreateArticleData, EditArticleData, GetArticleData, ResolveObject};
use fediwiki::error::MyResult; use fediwiki::error::MyResult;
use fediwiki::federation::objects::article::DbArticle; use fediwiki::federation::objects::article::DbArticle;
use fediwiki::federation::objects::instance::DbInstance; use fediwiki::federation::objects::instance::DbInstance;
@ -17,7 +17,7 @@ async fn test_create_and_read_article() -> MyResult<()> {
let data = TestData::start(); let data = TestData::start();
// error on nonexistent article // error on nonexistent article
let get_article = GetArticle { let get_article = GetArticleData {
title: "Manu_Chao".to_string(), title: "Manu_Chao".to_string(),
}; };
let not_found = get_query::<DbArticle, _>( let not_found = get_query::<DbArticle, _>(
@ -29,7 +29,7 @@ async fn test_create_and_read_article() -> MyResult<()> {
assert!(not_found.is_err()); assert!(not_found.is_err());
// create article // create article
let create_article = CreateArticle { let create_article = CreateArticleData {
title: get_article.title.to_string(), title: get_article.title.to_string(),
text: "Lorem ipsum".to_string(), text: "Lorem ipsum".to_string(),
}; };
@ -80,7 +80,7 @@ async fn test_synchronize_articles() -> MyResult<()> {
let data = TestData::start(); let data = TestData::start();
// create article on alpha // create article on alpha
let create_article = CreateArticle { let create_article = CreateArticleData {
title: "Manu_Chao".to_string(), title: "Manu_Chao".to_string(),
text: "Lorem ipsum".to_string(), text: "Lorem ipsum".to_string(),
}; };
@ -89,7 +89,7 @@ async fn test_synchronize_articles() -> MyResult<()> {
assert!(create_res.local); assert!(create_res.local);
// article is not yet on beta // article is not yet on beta
let get_article = GetArticle { let get_article = GetArticleData {
title: "Manu_Chao".to_string(), title: "Manu_Chao".to_string(),
}; };
let get_res = get_query::<DbArticle, _>( let get_res = get_query::<DbArticle, _>(
@ -129,7 +129,7 @@ async fn test_federate_article_changes() -> MyResult<()> {
follow_instance(data.hostname_alpha, data.hostname_beta).await?; follow_instance(data.hostname_alpha, data.hostname_beta).await?;
// create new article // create new article
let create_form = CreateArticle { let create_form = CreateArticleData {
title: "Manu_Chao".to_string(), title: "Manu_Chao".to_string(),
text: "Lorem ipsum".to_string(), text: "Lorem ipsum".to_string(),
}; };
@ -137,7 +137,7 @@ async fn test_federate_article_changes() -> MyResult<()> {
assert_eq!(create_res.title, create_form.title); assert_eq!(create_res.title, create_form.title);
// article should be federated to alpha // article should be federated to alpha
let get_article = GetArticle { let get_article = GetArticleData {
title: create_res.title.clone(), title: create_res.title.clone(),
}; };
let get_res = let get_res =
@ -147,17 +147,20 @@ async fn test_federate_article_changes() -> MyResult<()> {
assert_eq!(create_res.text, get_res.text); assert_eq!(create_res.text, get_res.text);
// edit the article // edit the article
let edit_form = EditArticle { let edit_form = EditArticleData {
ap_id: create_res.ap_id, ap_id: create_res.ap_id,
new_text: "Lorem Ipsum 2".to_string(), new_text: "Lorem Ipsum 2".to_string(),
}; };
let edit_res: DbArticle = patch(data.hostname_beta, "article", &edit_form).await?; let edit_res: DbArticle = patch(data.hostname_beta, "article", &edit_form).await?;
assert_eq!(edit_res.text, edit_form.new_text); assert_eq!(edit_res.text, edit_form.new_text);
assert_eq!(edit_res.edits.len(), 1); assert_eq!(edit_res.edits.len(), 1);
assert!(edit_res.edits[0].id.to_string().starts_with(&edit_res.ap_id.to_string())); assert!(edit_res.edits[0]
.id
.to_string()
.starts_with(&edit_res.ap_id.to_string()));
// edit should be federated to alpha // edit should be federated to alpha
let get_article = GetArticle { let get_article = GetArticleData {
title: edit_res.title.clone(), title: edit_res.title.clone(),
}; };
let get_res = let get_res =