Post creation API (#307)

This commit is contained in:
Baptiste Gelez 2018-12-24 16:42:40 +01:00 committed by GitHub
parent fdfeeed6d9
commit 4ec2480f50
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 683 additions and 530 deletions

994
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -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]

View File

@ -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,
}

View File

@ -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);

View File

@ -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"

View File

@ -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;

View File

@ -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 {

View File

@ -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,
}
})))
}

View File

@ -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,