Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: 記事を公開するかどうか選べるように #199

Merged
merged 6 commits into from
Jul 2, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/toy-blog-endpoint-model/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,16 @@ pub struct Article {
pub created_at: DateTime<Local>,
pub updated_at: DateTime<Local>,
pub content: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub visibility: Option<Visibility>
}

#[derive(Deserialize, Serialize, Copy, Clone, Eq, PartialEq, Debug)]
#[serde(rename_all = "snake_case")]
pub enum Visibility {
Public,
Restricted,
Private,
}

#[derive(Serialize, Clone, Eq, PartialEq, Debug)]
Expand Down
24 changes: 21 additions & 3 deletions packages/toy-blog/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

mod extension;
mod service;
mod migration;

use std::fs::File;
use std::io::{BufReader, Read, stdin};
Expand All @@ -20,11 +21,12 @@ use actix_web_httpauth::extractors::bearer::Config as BearerAuthConfig;
use clap::{Parser, Subcommand};
use fern::colors::ColoredLevelConfig;
use log::{debug, info};
use serde_json::Value;
use service::http::auth::WRITE_TOKEN;

use crate::service::http::api::{article, meta};
use crate::service::http::cors::middleware_factory as cors_middleware_factory;
use toy_blog_endpoint_model::ArticleId;
use toy_blog_endpoint_model::{ArticleId, Visibility};
use crate::service::http::api::list::{article_id_list, article_id_list_by_year, article_id_list_by_year_and_month};
use crate::service::http::repository::GLOBAL_FILE;
use crate::service::persistence::ArticleRepository;
Expand Down Expand Up @@ -95,7 +97,22 @@ async fn main() -> Result<()> {
stdin().read_line(&mut buf).expect("failed to read from stdin");
buf.trim_end().to_string()
};
GLOBAL_FILE.set(ArticleRepository::new("data/article.json").await).expect("unreachable!");

const PATH: &str = "data/article.json";

// migration
{
#[allow(unused_qualifications)]
let migrated_data = crate::migration::migrate_article_repr(
serde_json::from_reader::<_, Value>(File::open(PATH).expect("ow, failed!")).expect("ow, failed!")
);

info!("migrated");

serde_json::to_writer(File::options().write(true).truncate(true).open(PATH).expect("ow, failed!"), &migrated_data)
.expect("ow, failed!");
}
GLOBAL_FILE.set(ArticleRepository::new(PATH).await).expect("unreachable!");

WRITE_TOKEN.set(bearer_token).unwrap();

Expand Down Expand Up @@ -137,6 +154,7 @@ async fn main() -> Result<()> {
.wrap(cors_middleware_factory())
});

println!("running!");
http_server
.bind((http_host, http_port))?
.run()
Expand Down Expand Up @@ -167,7 +185,7 @@ async fn main() -> Result<()> {

match content {
Ok(content) => {
GLOBAL_FILE.get().expect("must be fully-initialized").create_entry(&article_id, content).await?;
GLOBAL_FILE.get().expect("must be fully-initialized").create_entry(&article_id, content, Visibility::Private).await?;
info!("Successfully imported as {article_id}.");
Ok(())
}
Expand Down
77 changes: 77 additions & 0 deletions packages/toy-blog/src/migration.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
//! data migration

use serde_json::{Map, Value};

trait SerdeJsonValueMoveExtension {
///
/// # Example
///
/// ```rust
/// use serde_json::{Map, Value};
///
/// # fn main() {
/// let mut m = Map::new();
/// m.insert("a".to_string(), Value::Null);
/// m.insert("b".to_string(), Value::String("hi".to_string()));
///
/// assert_eq!(Value::Object(m.clone()).into_object(), Ok(m));
/// assert_eq!(Value::Number(42).into_object(), Err(Value::Number(42)));
/// # }
/// ```
fn into_object(self) -> Result<Map<String, Value>, Value>;
}

impl SerdeJsonValueMoveExtension for Value {
fn into_object(self) -> Result<Map<String, Value>, Value> {
match self {
Value::Object(map) => Ok(map),
other => Err(other),
}
}
}

trait ArticleMigration {
fn migrate(&self, raw_config: Value) -> Value;
}

struct AddTagVersion;

impl ArticleMigration for AddTagVersion {
fn migrate(&self, raw_config: Value) -> Value {
let mut m = raw_config.into_object().expect("top level must be an object");
m.insert("version".to_string(), Value::from("1"));

Value::from(m)
}
}

struct AddAccessLevel;

impl ArticleMigration for AddAccessLevel {
fn migrate(&self, raw_config: Value) -> Value {
let mut top = raw_config
.into_object().expect("top level must be an object");

if top["version"].as_str().expect("version must be string").parse::<i32>().expect("schema version must be fit in i32") >= 2 {
return Value::from(top)
}

top.insert("version".to_string(), Value::from(2));

let mut article_table = top
.get_mut("data").expect("article must exist")
.as_object_mut().expect("article table must be object");

article_table.values_mut().for_each(|article| {
article.as_object_mut().expect("article").insert("access".to_string(), Value::from("public"));
});

Value::from(top)
}
}

pub(crate) fn migrate_article_repr(raw_article_table: Value) -> Value {
let raw_article_table = AddTagVersion.migrate(raw_article_table);

AddAccessLevel.migrate(raw_article_table)
}
Empty file.
54 changes: 33 additions & 21 deletions packages/toy-blog/src/service/http/api/article.rs
Original file line number Diff line number Diff line change
@@ -1,25 +1,14 @@

use actix_web::{HttpRequest, Responder};
use actix_web::{HttpRequest, HttpResponse, Responder};
use actix_web::{delete, get, post, put};

use actix_web::http::header::USER_AGENT;
use actix_web::http::StatusCode;
use actix_web::web::{Bytes, Path};
use actix_web_httpauth::extractors::bearer::BearerAuth;
use log::info;
use log::{error, info};
use once_cell::unsync::Lazy;
use toy_blog_endpoint_model::{
ArticleContent,
ArticleCreatedNotice,
ArticleCreateWarning,
ArticleId,
ArticleSnapshot,
ArticleSnapshotMetadata,
CreateArticleError,
DeleteArticleError,
GetArticleError,
OwnedMetadata,
UpdateArticleError
};
use toy_blog_endpoint_model::{ArticleContent, ArticleCreatedNotice, ArticleCreateWarning, ArticleId, ArticleSnapshot, ArticleSnapshotMetadata, CreateArticleError, DeleteArticleError, GetArticleError, OwnedMetadata, UpdateArticleError, Visibility};
use crate::service::http::auth::is_wrong_token;
use crate::service::http::inner_no_leak::{UnhandledError};
use crate::service::http::repository::GLOBAL_FILE;
Expand Down Expand Up @@ -49,7 +38,7 @@ pub async fn create(path: Path<String>, data: Bytes, bearer: BearerAuth, request
let Ok(text) = plain_text else { return Ok(Err(CreateArticleError::InvalidUtf8)) };

info!("valid utf8");
let res = x_get().create_entry(&path, text.clone()).await;
let res = x_get().create_entry(&path, text.clone(), Visibility::Private).await;
match res {
Ok(_) => {}
Err(err) => return Err(UnhandledError::new(err))
Expand Down Expand Up @@ -80,28 +69,42 @@ pub async fn create(path: Path<String>, data: Bytes, bearer: BearerAuth, request

#[get("/{article_id}")]
pub async fn fetch(path: Path<String>) -> impl Responder {

enum Res {
Internal(UnhandledError),
General(GetArticleError),
Ok(OwnedMetadata<ArticleSnapshotMetadata, ArticleSnapshot>),
}

let res = || async {
let article_id = ArticleId::new(path.into_inner());

let exists = match x_get().exists(&article_id).await {
Ok(exists) => exists,
Err(e) => return Err(UnhandledError::new(e))
Err(e) => return Res::Internal(UnhandledError::new(e))
};

if !exists {
return Ok(Err(GetArticleError::NoSuchArticleFoundById))
return Res::General(GetArticleError::NoSuchArticleFoundById)
}

let content = match x_get().read_snapshot(&article_id).await {
Ok(content) => content,
Err(e) => return Err(UnhandledError::new(e))
Err(e) => return Res::Internal(UnhandledError::new(e))
};

match content.visibility {
Some(x) if x != Visibility::Public => {
return Res::General(GetArticleError::NoSuchArticleFoundById)
}
_ => {}
}

let u = content.updated_at;
let uo = u.offset();
let uu = u.with_timezone(uo);

Ok(Ok(OwnedMetadata {
(Res::Ok(OwnedMetadata {
metadata: ArticleSnapshotMetadata {
updated_at: uu
},
Expand All @@ -111,7 +114,16 @@ pub async fn fetch(path: Path<String>) -> impl Responder {
}))
};

EndpointRepresentationCompiler::from_value(res().await).into_plain_text()
let x = match res().await {
Res::Internal(sre) => {
error!("{sre:?}");
return HttpResponse::new(StatusCode::INTERNAL_SERVER_ERROR)
}
Res::General(e) => Err(e),
Res::Ok(v) => Ok(v)
};

EndpointRepresentationCompiler::from_value(x).into_plain_text().map_into_boxed_body()
}

#[put("/{article_id}")]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ mod tests;

use std::fmt::{Display, Formatter};
use std::iter::{Empty, empty, once, Once};
use actix_web::body::BoxBody;
use actix_web::HttpResponse;
use actix_web::http::header::{CONTENT_TYPE, HeaderName, HeaderValue, LAST_MODIFIED, WARNING};
use actix_web::http::StatusCode;
Expand Down Expand Up @@ -146,7 +147,6 @@ impl<T: Serialize + HttpStatusCode + ContainsHeaderMap> EndpointRepresentationCo
}
}


pub struct HttpFormattedDate(chrono::DateTime<FixedOffset>);

impl HttpFormattedDate {
Expand Down
4 changes: 4 additions & 0 deletions packages/toy-blog/src/service/http/repository.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
use std::fs::File;
use log::info;
use once_cell::sync::OnceCell;
use serde_json::Value;
use crate::service::persistence::ArticleRepository;

// FIXME: OnceCell
pub static GLOBAL_FILE: OnceCell<ArticleRepository> = OnceCell::new();
28 changes: 21 additions & 7 deletions packages/toy-blog/src/service/persistence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ use fs2::FileExt;
use log::{debug, error, info, trace};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use toy_blog_endpoint_model::{ArticleId, FlatId, ListArticleResponse};
use toy_blog_endpoint_model::{ArticleId, FlatId, ListArticleResponse, Visibility};

#[derive(Debug)]
pub struct ArticleRepository {
Expand Down Expand Up @@ -71,16 +71,18 @@ impl ArticleRepository {
Ok(())
}

pub async fn create_entry(&self, article_id: &ArticleId, article_content: String) -> Result<(), PersistenceError> {
pub async fn create_entry(&self, article_id: &ArticleId, article_content: String, visibility: Visibility) -> Result<(), PersistenceError> {
self.invalidate();

let current_date = Local::now();
self.cache.write().expect("lock is poisoned").data.insert(article_id.clone(), Article {
created_at: current_date,
updated_at: current_date,
// visible: false,
content: article_content,
});
created_at: current_date,
updated_at: current_date,
// visible: false,
content: article_content,
visibility: Some(visibility),
});


self.save()?;
Ok(())
Expand Down Expand Up @@ -114,6 +116,18 @@ impl ArticleRepository {
Ok(())
}

pub async fn change_visibility(&self, article_id: &ArticleId, new_visibility: Visibility) -> Result<(), PersistenceError> {
info!("calling change_visibility");
self.invalidate();

self.cache.write().expect("poisoned").deref_mut().data.get_mut(article_id)
.ok_or(PersistenceError::AbsentValue)?.visibility = Some(new_visibility);

self.save()?;

Ok(())
}

pub async fn read_snapshot(&self, article_id: &ArticleId) -> Result<Article, PersistenceError> {
self.reconstruct_cache();

Expand Down