Post creation API (#307)
This commit is contained in:
parent
fdfeeed6d9
commit
4ec2480f50
File diff suppressed because it is too large
Load Diff
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "plume-api"
|
name = "plume-api"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
authors = ["Plume contributors"]
|
authors = ["Plume contributors"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|
|
@ -17,3 +17,8 @@ macro_rules! api {
|
||||||
|
|
||||||
pub mod apps;
|
pub mod apps;
|
||||||
pub mod posts;
|
pub mod posts;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Api {
|
||||||
|
pub posts: posts::PostEndpoint,
|
||||||
|
}
|
|
@ -1,11 +1,19 @@
|
||||||
use canapi::Endpoint;
|
use canapi::Endpoint;
|
||||||
|
|
||||||
#[derive(Default, Serialize, Deserialize)]
|
#[derive(Clone, Default, Serialize, Deserialize)]
|
||||||
pub struct PostEndpoint {
|
pub struct PostEndpoint {
|
||||||
pub id: Option<i32>,
|
pub id: Option<i32>,
|
||||||
pub title: Option<String>,
|
pub title: Option<String>,
|
||||||
pub subtitle: Option<String>,
|
pub subtitle: Option<String>,
|
||||||
pub content: Option<String>
|
pub content: Option<String>,
|
||||||
|
pub source: Option<String>,
|
||||||
|
pub author: Option<String>,
|
||||||
|
pub blog_id: Option<i32>,
|
||||||
|
pub published: Option<bool>,
|
||||||
|
pub creation_date: Option<String>,
|
||||||
|
pub license: Option<String>,
|
||||||
|
pub tags: Option<Vec<String>>,
|
||||||
|
pub cover_id: Option<i32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
api!("/api/v1/posts" => PostEndpoint);
|
api!("/api/v1/posts" => PostEndpoint);
|
||||||
|
|
|
@ -16,6 +16,7 @@ lazy_static = "*"
|
||||||
openssl = "0.10.15"
|
openssl = "0.10.15"
|
||||||
rocket = "0.4.0"
|
rocket = "0.4.0"
|
||||||
reqwest = "0.9"
|
reqwest = "0.9"
|
||||||
|
scheduled-thread-pool = "0.2.0"
|
||||||
serde = "1.0"
|
serde = "1.0"
|
||||||
serde_derive = "1.0"
|
serde_derive = "1.0"
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
|
|
|
@ -18,6 +18,7 @@ extern crate plume_api;
|
||||||
extern crate plume_common;
|
extern crate plume_common;
|
||||||
extern crate reqwest;
|
extern crate reqwest;
|
||||||
extern crate rocket;
|
extern crate rocket;
|
||||||
|
extern crate scheduled_thread_pool;
|
||||||
extern crate serde;
|
extern crate serde;
|
||||||
#[macro_use]
|
#[macro_use]
|
||||||
extern crate serde_derive;
|
extern crate serde_derive;
|
||||||
|
|
|
@ -8,26 +8,27 @@ use canapi::{Error, Provider};
|
||||||
use chrono::{NaiveDateTime, TimeZone, Utc};
|
use chrono::{NaiveDateTime, TimeZone, Utc};
|
||||||
use diesel::{self, BelongingToDsl, ExpressionMethods, QueryDsl, RunQueryDsl};
|
use diesel::{self, BelongingToDsl, ExpressionMethods, QueryDsl, RunQueryDsl};
|
||||||
use heck::{CamelCase, KebabCase};
|
use heck::{CamelCase, KebabCase};
|
||||||
|
use scheduled_thread_pool::ScheduledThreadPool as Worker;
|
||||||
use serde_json;
|
use serde_json;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
use blogs::Blog;
|
|
||||||
use instance::Instance;
|
|
||||||
use medias::Media;
|
|
||||||
use mentions::Mention;
|
|
||||||
use plume_api::posts::PostEndpoint;
|
use plume_api::posts::PostEndpoint;
|
||||||
use plume_common::{
|
use plume_common::{
|
||||||
activity_pub::{
|
activity_pub::{
|
||||||
inbox::{Deletable, FromActivity},
|
inbox::{Deletable, FromActivity},
|
||||||
Hashtag, Id, IntoId, Licensed, Source, PUBLIC_VISIBILTY,
|
broadcast, Hashtag, Id, IntoId, Licensed, Source, PUBLIC_VISIBILTY,
|
||||||
},
|
},
|
||||||
utils::md_to_html,
|
utils::md_to_html,
|
||||||
};
|
};
|
||||||
|
use blogs::Blog;
|
||||||
|
use instance::Instance;
|
||||||
|
use medias::Media;
|
||||||
|
use mentions::Mention;
|
||||||
use post_authors::*;
|
use post_authors::*;
|
||||||
use safe_string::SafeString;
|
use safe_string::SafeString;
|
||||||
use search::Searcher;
|
use search::Searcher;
|
||||||
use schema::posts;
|
use schema::posts;
|
||||||
use std::collections::HashSet;
|
use tags::*;
|
||||||
use tags::Tag;
|
|
||||||
use users::User;
|
use users::User;
|
||||||
use {ap_url, Connection, BASE_URL};
|
use {ap_url, Connection, BASE_URL};
|
||||||
|
|
||||||
|
@ -66,11 +67,11 @@ pub struct NewPost {
|
||||||
pub cover_id: Option<i32>,
|
pub cover_id: Option<i32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> Provider<(&'a Connection, &'a Searcher, Option<i32>)> for Post {
|
impl<'a> Provider<(&'a Connection, &'a Worker, &'a Searcher, Option<i32>)> for Post {
|
||||||
type Data = PostEndpoint;
|
type Data = PostEndpoint;
|
||||||
|
|
||||||
fn get(
|
fn get(
|
||||||
(conn, _search, user_id): &(&'a Connection, &Searcher, Option<i32>),
|
(conn, _worker, _search, user_id): &(&Connection, &Worker, &Searcher, Option<i32>),
|
||||||
id: i32,
|
id: i32,
|
||||||
) -> Result<PostEndpoint, Error> {
|
) -> Result<PostEndpoint, Error> {
|
||||||
if let Some(post) = Post::get(conn, id) {
|
if let Some(post) = Post::get(conn, id) {
|
||||||
|
@ -79,12 +80,19 @@ impl<'a> Provider<(&'a Connection, &'a Searcher, Option<i32>)> for Post {
|
||||||
"You are not authorized to access this post yet.".to_string(),
|
"You are not authorized to access this post yet.".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(PostEndpoint {
|
Ok(PostEndpoint {
|
||||||
id: Some(post.id),
|
id: Some(post.id),
|
||||||
title: Some(post.title.clone()),
|
title: Some(post.title.clone()),
|
||||||
subtitle: Some(post.subtitle.clone()),
|
subtitle: Some(post.subtitle.clone()),
|
||||||
content: Some(post.content.get().clone()),
|
content: Some(post.content.get().clone()),
|
||||||
|
source: Some(post.source.clone()),
|
||||||
|
author: Some(post.get_authors(conn)[0].username.clone()),
|
||||||
|
blog_id: Some(post.blog_id),
|
||||||
|
published: Some(post.published),
|
||||||
|
creation_date: Some(post.creation_date.format("%Y-%m-%d").to_string()),
|
||||||
|
license: Some(post.license.clone()),
|
||||||
|
tags: Some(Tag::for_post(conn, post.id).into_iter().map(|t| t.tag).collect()),
|
||||||
|
cover_id: post.cover_id,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
Err(Error::NotFound("Request post was not found".to_string()))
|
Err(Error::NotFound("Request post was not found".to_string()))
|
||||||
|
@ -92,7 +100,7 @@ impl<'a> Provider<(&'a Connection, &'a Searcher, Option<i32>)> for Post {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn list(
|
fn list(
|
||||||
(conn, _searcher, user_id): &(&'a Connection, &Searcher, Option<i32>),
|
(conn, _worker, _search, user_id): &(&Connection, &Worker, &Searcher, Option<i32>),
|
||||||
filter: PostEndpoint,
|
filter: PostEndpoint,
|
||||||
) -> Vec<PostEndpoint> {
|
) -> Vec<PostEndpoint> {
|
||||||
let mut query = posts::table.into_boxed();
|
let mut query = posts::table.into_boxed();
|
||||||
|
@ -106,47 +114,133 @@ impl<'a> Provider<(&'a Connection, &'a Searcher, Option<i32>)> for Post {
|
||||||
query = query.filter(posts::content.eq(content));
|
query = query.filter(posts::content.eq(content));
|
||||||
}
|
}
|
||||||
|
|
||||||
query
|
query.get_results::<Post>(*conn).map(|ps| ps.into_iter()
|
||||||
.get_results::<Post>(*conn)
|
.filter(|p| p.published || user_id.map(|u| p.is_author(conn, u)).unwrap_or(false))
|
||||||
.map(|ps| {
|
|
||||||
ps.into_iter()
|
|
||||||
.filter(|p| {
|
|
||||||
p.published || user_id.map(|u| p.is_author(conn, u)).unwrap_or(false)
|
|
||||||
})
|
|
||||||
.map(|p| PostEndpoint {
|
.map(|p| PostEndpoint {
|
||||||
id: Some(p.id),
|
id: Some(p.id),
|
||||||
title: Some(p.title.clone()),
|
title: Some(p.title.clone()),
|
||||||
subtitle: Some(p.subtitle.clone()),
|
subtitle: Some(p.subtitle.clone()),
|
||||||
content: Some(p.content.get().clone()),
|
content: Some(p.content.get().clone()),
|
||||||
|
source: Some(p.source.clone()),
|
||||||
|
author: Some(p.get_authors(conn)[0].username.clone()),
|
||||||
|
blog_id: Some(p.blog_id),
|
||||||
|
published: Some(p.published),
|
||||||
|
creation_date: Some(p.creation_date.format("%Y-%m-%d").to_string()),
|
||||||
|
license: Some(p.license.clone()),
|
||||||
|
tags: Some(Tag::for_post(conn, p.id).into_iter().map(|t| t.tag).collect()),
|
||||||
|
cover_id: p.cover_id,
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
})
|
).unwrap_or(vec![])
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn create(
|
|
||||||
(_conn, _searcher, _user_id): &(&'a Connection, &Searcher, Option<i32>),
|
|
||||||
_query: PostEndpoint,
|
|
||||||
) -> Result<PostEndpoint, Error> {
|
|
||||||
unimplemented!()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(
|
fn update(
|
||||||
(_conn, _searcher, _user_id): &(&'a Connection, &Searcher, Option<i32>),
|
(_conn, _worker, _search, _user_id): &(&Connection, &Worker, &Searcher, Option<i32>),
|
||||||
_id: i32,
|
_id: i32,
|
||||||
_new_data: PostEndpoint,
|
_new_data: PostEndpoint,
|
||||||
) -> Result<PostEndpoint, Error> {
|
) -> Result<PostEndpoint, Error> {
|
||||||
unimplemented!()
|
unimplemented!()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn delete((conn, searcher, user_id): &(&'a Connection, &Searcher, Option<i32>), id: i32) {
|
fn delete((conn, _worker, search, user_id): &(&Connection, &Worker, &Searcher, Option<i32>), id: i32) {
|
||||||
let user_id = user_id.expect("Post as Provider::delete: not authenticated");
|
let user_id = user_id.expect("Post as Provider::delete: not authenticated");
|
||||||
if let Some(post) = Post::get(conn, id) {
|
if let Some(post) = Post::get(conn, id) {
|
||||||
if post.is_author(conn, user_id) {
|
if post.is_author(conn, user_id) {
|
||||||
post.delete(&(conn, searcher));
|
post.delete(&(conn, search));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn create(
|
||||||
|
(conn, worker, search, user_id): &(&Connection, &Worker, &Searcher, Option<i32>),
|
||||||
|
query: PostEndpoint,
|
||||||
|
) -> Result<PostEndpoint, Error> {
|
||||||
|
if user_id.is_none() {
|
||||||
|
return Err(Error::Authorization("You are not authorized to create new articles.".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let title = query.title.clone().expect("No title for new post in API");
|
||||||
|
let slug = query.title.unwrap().to_kebab_case();
|
||||||
|
|
||||||
|
let date = query.creation_date.clone()
|
||||||
|
.and_then(|d| NaiveDateTime::parse_from_str(format!("{} 00:00:00", d).as_ref(), "%Y-%m-%d %H:%M:%S").ok());
|
||||||
|
|
||||||
|
let domain = &Instance::get_local(&conn).expect("posts::update: Error getting local instance").public_domain;
|
||||||
|
let (content, mentions, hashtags) = md_to_html(query.source.clone().unwrap_or(String::new()).clone().as_ref(), domain);
|
||||||
|
|
||||||
|
let author = User::get(conn, user_id.expect("<Post as Provider>::create: no user_id error"))?;
|
||||||
|
let blog = query.blog_id.unwrap_or_else(|| Blog::find_for_author(conn, &author)[0].id);
|
||||||
|
|
||||||
|
if Post::find_by_slug(conn, &slug, blog).is_some() {
|
||||||
|
// Not an actual authorization problem, but we have nothing better for now…
|
||||||
|
// TODO: add another error variant to canapi and add it there
|
||||||
|
return Err(Error::Authorization("A post with the same slug already exists".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let post = Post::insert(conn, NewPost {
|
||||||
|
blog_id: blog,
|
||||||
|
slug: slug,
|
||||||
|
title: title,
|
||||||
|
content: SafeString::new(content.as_ref()),
|
||||||
|
published: query.published.unwrap_or(true),
|
||||||
|
license: query.license.unwrap_or(Instance::get_local(conn)
|
||||||
|
.map(|i| i.default_license)
|
||||||
|
.unwrap_or(String::from("CC-BY-SA"))),
|
||||||
|
creation_date: date,
|
||||||
|
ap_url: String::new(),
|
||||||
|
subtitle: query.subtitle.unwrap_or(String::new()),
|
||||||
|
source: query.source.expect("Post API::create: no source error"),
|
||||||
|
cover_id: query.cover_id,
|
||||||
|
}, search);
|
||||||
|
post.update_ap_url(conn);
|
||||||
|
|
||||||
|
PostAuthor::insert(conn, NewPostAuthor {
|
||||||
|
author_id: author.id,
|
||||||
|
post_id: post.id
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(tags) = query.tags {
|
||||||
|
for tag in tags {
|
||||||
|
Tag::insert(conn, NewTag {
|
||||||
|
tag: tag,
|
||||||
|
is_hashtag: false,
|
||||||
|
post_id: post.id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for hashtag in hashtags {
|
||||||
|
Tag::insert(conn, NewTag {
|
||||||
|
tag: hashtag.to_camel_case(),
|
||||||
|
is_hashtag: true,
|
||||||
|
post_id: post.id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if post.published {
|
||||||
|
for m in mentions.into_iter() {
|
||||||
|
Mention::from_activity(&*conn, &Mention::build_activity(&*conn, &m), post.id, true, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
let act = post.create_activity(&*conn);
|
||||||
|
let dest = User::one_by_instance(&*conn);
|
||||||
|
worker.execute(move || broadcast(&author, act, dest));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(PostEndpoint {
|
||||||
|
id: Some(post.id),
|
||||||
|
title: Some(post.title.clone()),
|
||||||
|
subtitle: Some(post.subtitle.clone()),
|
||||||
|
content: Some(post.content.get().clone()),
|
||||||
|
source: Some(post.source.clone()),
|
||||||
|
author: Some(post.get_authors(conn)[0].username.clone()),
|
||||||
|
blog_id: Some(post.blog_id),
|
||||||
|
published: Some(post.published),
|
||||||
|
creation_date: Some(post.creation_date.format("%Y-%m-%d").to_string()),
|
||||||
|
license: Some(post.license.clone()),
|
||||||
|
tags: Some(Tag::for_post(conn, post.id).into_iter().map(|t| t.tag).collect()),
|
||||||
|
cover_id: post.cover_id,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Post {
|
impl Post {
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
use canapi::Provider;
|
use canapi::{Error as ApiError, Provider};
|
||||||
use rocket::http::uri::Origin;
|
use rocket::http::uri::Origin;
|
||||||
use rocket_contrib::json::Json;
|
use rocket_contrib::json::Json;
|
||||||
|
use scheduled_thread_pool::ScheduledThreadPool;
|
||||||
use serde_json;
|
use serde_json;
|
||||||
use serde_qs;
|
use serde_qs;
|
||||||
|
|
||||||
|
@ -12,17 +13,35 @@ use plume_models::{
|
||||||
search::Searcher as UnmanagedSearcher,
|
search::Searcher as UnmanagedSearcher,
|
||||||
};
|
};
|
||||||
use api::authorization::*;
|
use api::authorization::*;
|
||||||
use Searcher;
|
use {Searcher, Worker};
|
||||||
|
|
||||||
#[get("/posts/<id>")]
|
#[get("/posts/<id>")]
|
||||||
pub fn get(id: i32, conn: DbConn, auth: Option<Authorization<Read, Post>>, search: Searcher) -> Json<serde_json::Value> {
|
pub fn get(id: i32, conn: DbConn, worker: Worker, auth: Option<Authorization<Read, Post>>, search: Searcher) -> Json<serde_json::Value> {
|
||||||
let post = <Post as Provider<(&Connection, &UnmanagedSearcher, Option<i32>)>>::get(&(&*conn, &search, auth.map(|a| a.0.user_id)), id).ok();
|
let post = <Post as Provider<(&Connection, &ScheduledThreadPool, &UnmanagedSearcher, Option<i32>)>>
|
||||||
|
::get(&(&*conn, &worker, &search, auth.map(|a| a.0.user_id)), id).ok();
|
||||||
Json(json!(post))
|
Json(json!(post))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/posts")]
|
#[get("/posts")]
|
||||||
pub fn list(conn: DbConn, uri: &Origin, auth: Option<Authorization<Read, Post>>, search: Searcher) -> Json<serde_json::Value> {
|
pub fn list(conn: DbConn, uri: &Origin, worker: Worker, auth: Option<Authorization<Read, Post>>, search: Searcher) -> Json<serde_json::Value> {
|
||||||
let query: PostEndpoint = serde_qs::from_str(uri.query().unwrap_or("")).expect("api::list: invalid query error");
|
let query: PostEndpoint = serde_qs::from_str(uri.query().unwrap_or("")).expect("api::list: invalid query error");
|
||||||
let post = <Post as Provider<(&Connection, &UnmanagedSearcher, Option<i32>)>>::list(&(&*conn, &search, auth.map(|a| a.0.user_id)), query);
|
let post = <Post as Provider<(&Connection, &ScheduledThreadPool, &UnmanagedSearcher, Option<i32>)>>
|
||||||
|
::list(&(&*conn, &worker, &search, auth.map(|a| a.0.user_id)), query);
|
||||||
Json(json!(post))
|
Json(json!(post))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[post("/posts", data = "<payload>")]
|
||||||
|
pub fn create(conn: DbConn, payload: Json<PostEndpoint>, worker: Worker, auth: Authorization<Write, Post>, search: Searcher) -> Json<serde_json::Value> {
|
||||||
|
let new_post = <Post as Provider<(&Connection, &ScheduledThreadPool, &UnmanagedSearcher, Option<i32>)>>
|
||||||
|
::create(&(&*conn, &worker, &search, Some(auth.0.user_id)), (*payload).clone());
|
||||||
|
Json(new_post.map(|p| json!(p)).unwrap_or_else(|e| json!({
|
||||||
|
"error": "Invalid data, couldn't create new post",
|
||||||
|
"details": match e {
|
||||||
|
ApiError::Fetch(msg) => msg,
|
||||||
|
ApiError::SerDe(msg) => msg,
|
||||||
|
ApiError::NotFound(msg) => msg,
|
||||||
|
ApiError::Authorization(msg) => msg,
|
||||||
|
}
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
@ -176,6 +176,7 @@ fn main() {
|
||||||
|
|
||||||
api::posts::get,
|
api::posts::get,
|
||||||
api::posts::list,
|
api::posts::list,
|
||||||
|
api::posts::create,
|
||||||
])
|
])
|
||||||
.register(catchers![
|
.register(catchers![
|
||||||
routes::errors::not_found,
|
routes::errors::not_found,
|
||||||
|
|
Loading…
Reference in New Issue