Implement lookup and redirect of articles by slugs
This commit is contained in:
parent
ada70b7671
commit
e1d823d22e
8 changed files with 243 additions and 36 deletions
|
@ -0,0 +1,64 @@
|
||||||
|
CREATE TABLE article_revisions_copy (
|
||||||
|
article_id INTEGER NOT NULL,
|
||||||
|
revision INTEGER NOT NULL,
|
||||||
|
created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY (article_id, revision),
|
||||||
|
FOREIGN KEY (article_id) REFERENCES articles(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO article_revisions_copy SELECT * FROM article_revisions;
|
||||||
|
|
||||||
|
DROP TABLE article_revisions;
|
||||||
|
|
||||||
|
CREATE TABLE article_revisions (
|
||||||
|
sequence_number INTEGER PRIMARY KEY NOT NULL,
|
||||||
|
|
||||||
|
article_id INTEGER NOT NULL,
|
||||||
|
revision INTEGER NOT NULL,
|
||||||
|
created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
slug TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
|
||||||
|
-- Actually a synthetic property, namely revision = MAX(revision)
|
||||||
|
-- GROUP BY article_id, but SQLite makes that so hard to work with:
|
||||||
|
latest BOOLEAN NOT NULL,
|
||||||
|
|
||||||
|
FOREIGN KEY (article_id) REFERENCES articles(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX unique_revision_per_article_id ON article_revisions
|
||||||
|
(article_id, revision);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX unique_latest_revision_per_article_id ON article_revisions
|
||||||
|
(article_id) WHERE latest=1;
|
||||||
|
|
||||||
|
CREATE INDEX slug_lookup ON article_revisions
|
||||||
|
(slug, revision);
|
||||||
|
|
||||||
|
|
||||||
|
INSERT INTO article_revisions SELECT
|
||||||
|
ROWID,
|
||||||
|
article_id,
|
||||||
|
revision,
|
||||||
|
created,
|
||||||
|
CAST(article_id AS TEXT) AS slug,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
0
|
||||||
|
FROM article_revisions_copy;
|
||||||
|
|
||||||
|
UPDATE article_revisions
|
||||||
|
SET latest = 1
|
||||||
|
WHERE (article_id, revision) IN (
|
||||||
|
SELECT article_id, MAX(revision) AS revision
|
||||||
|
FROM article_revisions
|
||||||
|
GROUP BY article_id
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX slugs_index ON article_revisions (slug, article_id) WHERE latest=1;
|
51
src/article_redirect_resource.rs
Normal file
51
src/article_redirect_resource.rs
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
use futures::{self, Future};
|
||||||
|
use hyper;
|
||||||
|
use hyper::header::Location;
|
||||||
|
use hyper::server::*;
|
||||||
|
|
||||||
|
use web::{Resource, ResponseFuture};
|
||||||
|
|
||||||
|
pub struct ArticleRedirectResource {
|
||||||
|
slug: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ArticleRedirectResource {
|
||||||
|
pub fn new(slug: String) -> Self {
|
||||||
|
// Hack to let redirects to "" work:
|
||||||
|
// TODO Calculate absolute Location URLs to conform to spec
|
||||||
|
// This would also remove the need for this hack
|
||||||
|
Self {
|
||||||
|
slug: if slug == "" { ".".to_owned() } else { slug }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Resource for ArticleRedirectResource {
|
||||||
|
fn allow(&self) -> Vec<hyper::Method> {
|
||||||
|
use hyper::Method::*;
|
||||||
|
vec![Options, Head, Get, Put]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn head(&self) -> ResponseFuture {
|
||||||
|
Box::new(futures::finished(Response::new()
|
||||||
|
.with_status(hyper::StatusCode::TemporaryRedirect)
|
||||||
|
.with_header(Location::new(self.slug.clone()))
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get(self: Box<Self>) -> ResponseFuture {
|
||||||
|
Box::new(self.head()
|
||||||
|
.and_then(move |head| {
|
||||||
|
Ok(head
|
||||||
|
.with_body(format!("Moved to {}", self.slug)))
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn put(self: Box<Self>, _body: hyper::Body) -> ResponseFuture {
|
||||||
|
Box::new(self.head()
|
||||||
|
.and_then(move |head| {
|
||||||
|
Ok(head
|
||||||
|
.with_body(format!("Moved to {}", self.slug)))
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
|
@ -7,7 +7,6 @@ use serde_json;
|
||||||
use serde_urlencoded;
|
use serde_urlencoded;
|
||||||
|
|
||||||
use assets::{StyleCss, ScriptJs};
|
use assets::{StyleCss, ScriptJs};
|
||||||
use models;
|
|
||||||
use site::Layout;
|
use site::Layout;
|
||||||
use state::State;
|
use state::State;
|
||||||
use web::{Resource, ResponseFuture};
|
use web::{Resource, ResponseFuture};
|
||||||
|
@ -54,12 +53,13 @@ fn render_markdown(src: &str) -> String {
|
||||||
|
|
||||||
pub struct ArticleResource {
|
pub struct ArticleResource {
|
||||||
state: State,
|
state: State,
|
||||||
data: models::ArticleRevision,
|
article_id: i32,
|
||||||
|
revision: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ArticleResource {
|
impl ArticleResource {
|
||||||
pub fn new(state: State, data: models::ArticleRevision) -> Self {
|
pub fn new(state: State, article_id: i32, revision: i32) -> Self {
|
||||||
Self { state, data }
|
Self { state, article_id, revision }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -93,22 +93,27 @@ impl Resource for ArticleResource {
|
||||||
script_js_checksum: &'a str,
|
script_js_checksum: &'a str,
|
||||||
}
|
}
|
||||||
|
|
||||||
Box::new(self.head().map(move |head|
|
let data = self.state.get_article_revision(self.article_id, self.revision)
|
||||||
head
|
.map(|x| x.expect("Data model guarantees that this exists"));
|
||||||
|
let head = self.head();
|
||||||
|
|
||||||
|
Box::new(data.join(head)
|
||||||
|
.and_then(move |(data, head)| {
|
||||||
|
Ok(head
|
||||||
.with_body(Layout {
|
.with_body(Layout {
|
||||||
title: &self.data.title,
|
title: &data.title,
|
||||||
body: &Template {
|
body: &Template {
|
||||||
article_id: self.data.article_id,
|
article_id: data.article_id,
|
||||||
revision: self.data.revision,
|
revision: data.revision,
|
||||||
created: &Local.from_utc_datetime(&self.data.created),
|
created: &Local.from_utc_datetime(&data.created),
|
||||||
title: &self.data.title,
|
title: &data.title,
|
||||||
raw: &self.data.body,
|
raw: &data.body,
|
||||||
rendered: render_markdown(&self.data.body),
|
rendered: render_markdown(&data.body),
|
||||||
script_js_checksum: ScriptJs::checksum(),
|
script_js_checksum: ScriptJs::checksum(),
|
||||||
},
|
},
|
||||||
style_css_checksum: StyleCss::checksum(),
|
style_css_checksum: StyleCss::checksum(),
|
||||||
}.to_string())
|
}.to_string()))
|
||||||
))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn put(self: Box<Self>, body: hyper::Body) -> ResponseFuture {
|
fn put(self: Box<Self>, body: hyper::Body) -> ResponseFuture {
|
||||||
|
@ -138,7 +143,7 @@ impl Resource for ArticleResource {
|
||||||
.map_err(Into::into)
|
.map_err(Into::into)
|
||||||
})
|
})
|
||||||
.and_then(move |update: UpdateArticle| {
|
.and_then(move |update: UpdateArticle| {
|
||||||
self.state.update_article(self.data.article_id, update.base_revision, update.body)
|
self.state.update_article(self.article_id, update.base_revision, update.body)
|
||||||
})
|
})
|
||||||
.and_then(|updated| {
|
.and_then(|updated| {
|
||||||
futures::finished(Response::new()
|
futures::finished(Response::new()
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
#![recursion_limit="128"] // for diesel's infer_schema!
|
||||||
|
|
||||||
#[macro_use] extern crate bart_derive;
|
#[macro_use] extern crate bart_derive;
|
||||||
#[macro_use] extern crate diesel;
|
#[macro_use] extern crate diesel;
|
||||||
#[macro_use] extern crate diesel_codegen;
|
#[macro_use] extern crate diesel_codegen;
|
||||||
|
@ -18,6 +20,7 @@ extern crate serde_urlencoded;
|
||||||
|
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
|
mod article_redirect_resource;
|
||||||
mod article_resource;
|
mod article_resource;
|
||||||
mod assets;
|
mod assets;
|
||||||
mod db;
|
mod db;
|
||||||
|
|
|
@ -2,10 +2,15 @@ use chrono;
|
||||||
|
|
||||||
#[derive(Debug, Queryable)]
|
#[derive(Debug, Queryable)]
|
||||||
pub struct ArticleRevision {
|
pub struct ArticleRevision {
|
||||||
|
pub sequence_number: i32,
|
||||||
|
|
||||||
pub article_id: i32,
|
pub article_id: i32,
|
||||||
pub revision: i32,
|
pub revision: i32,
|
||||||
pub created: chrono::NaiveDateTime,
|
pub created: chrono::NaiveDateTime,
|
||||||
|
|
||||||
|
pub slug: String,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub body: String,
|
pub body: String,
|
||||||
|
|
||||||
|
pub latest: bool,
|
||||||
}
|
}
|
||||||
|
|
85
src/state.rs
85
src/state.rs
|
@ -17,6 +17,15 @@ pub struct State {
|
||||||
|
|
||||||
pub type Error = Box<std::error::Error + Send + Sync>;
|
pub type Error = Box<std::error::Error + Send + Sync>;
|
||||||
|
|
||||||
|
pub enum SlugLookup {
|
||||||
|
Miss,
|
||||||
|
Hit {
|
||||||
|
article_id: i32,
|
||||||
|
revision: i32,
|
||||||
|
},
|
||||||
|
Redirect(String),
|
||||||
|
}
|
||||||
|
|
||||||
impl State {
|
impl State {
|
||||||
pub fn new(connection_pool: Pool<ConnectionManager<SqliteConnection>>, cpu_pool: futures_cpupool::CpuPool) -> State {
|
pub fn new(connection_pool: Pool<ConnectionManager<SqliteConnection>>, cpu_pool: futures_cpupool::CpuPool) -> State {
|
||||||
State {
|
State {
|
||||||
|
@ -25,7 +34,7 @@ impl State {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_article_revision_by_id(&self, article_id: i32) -> CpuFuture<Option<models::ArticleRevision>, Error> {
|
pub fn get_article_revision(&self, article_id: i32, revision: i32) -> CpuFuture<Option<models::ArticleRevision>, Error> {
|
||||||
let connection_pool = self.connection_pool.clone();
|
let connection_pool = self.connection_pool.clone();
|
||||||
|
|
||||||
self.cpu_pool.spawn_fn(move || {
|
self.cpu_pool.spawn_fn(move || {
|
||||||
|
@ -33,13 +42,61 @@ impl State {
|
||||||
|
|
||||||
Ok(article_revisions::table
|
Ok(article_revisions::table
|
||||||
.filter(article_revisions::article_id.eq(article_id))
|
.filter(article_revisions::article_id.eq(article_id))
|
||||||
.order(article_revisions::revision.desc())
|
.filter(article_revisions::revision.eq(revision))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.load::<models::ArticleRevision>(&*connection_pool.get()?)?
|
.load::<models::ArticleRevision>(&*connection_pool.get()?)?
|
||||||
.pop())
|
.pop())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn lookup_slug(&self, slug: String) -> CpuFuture<SlugLookup, Error> {
|
||||||
|
#[derive(Queryable)]
|
||||||
|
struct ArticleRevisionStub {
|
||||||
|
article_id: i32,
|
||||||
|
revision: i32,
|
||||||
|
latest: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
let connection_pool = self.connection_pool.clone();
|
||||||
|
|
||||||
|
self.cpu_pool.spawn_fn(move || {
|
||||||
|
let conn = connection_pool.get()?;
|
||||||
|
|
||||||
|
conn.transaction(|| {
|
||||||
|
use schema::article_revisions;
|
||||||
|
|
||||||
|
Ok(match article_revisions::table
|
||||||
|
.filter(article_revisions::slug.eq(slug))
|
||||||
|
.order(article_revisions::sequence_number.desc())
|
||||||
|
.limit(1)
|
||||||
|
.select((
|
||||||
|
article_revisions::article_id,
|
||||||
|
article_revisions::revision,
|
||||||
|
article_revisions::latest,
|
||||||
|
))
|
||||||
|
.load::<ArticleRevisionStub>(&*conn)?
|
||||||
|
.pop()
|
||||||
|
{
|
||||||
|
None => SlugLookup::Miss,
|
||||||
|
Some(ref stub) if stub.latest => SlugLookup::Hit {
|
||||||
|
article_id: stub.article_id,
|
||||||
|
revision: stub.revision,
|
||||||
|
},
|
||||||
|
Some(stub) => SlugLookup::Redirect(
|
||||||
|
article_revisions::table
|
||||||
|
.filter(article_revisions::latest.eq(true))
|
||||||
|
.filter(article_revisions::article_id.eq(stub.article_id))
|
||||||
|
.limit(1)
|
||||||
|
.select(article_revisions::slug)
|
||||||
|
.load::<String>(&*conn)?
|
||||||
|
.pop()
|
||||||
|
.expect("Data model requires this to exist")
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn update_article(&self, article_id: i32, base_revision: i32, body: String) -> CpuFuture<models::ArticleRevision, Error> {
|
pub fn update_article(&self, article_id: i32, base_revision: i32, body: String) -> CpuFuture<models::ArticleRevision, Error> {
|
||||||
let connection_pool = self.connection_pool.clone();
|
let connection_pool = self.connection_pool.clone();
|
||||||
|
|
||||||
|
@ -49,12 +106,17 @@ impl State {
|
||||||
conn.transaction(|| {
|
conn.transaction(|| {
|
||||||
use schema::article_revisions;
|
use schema::article_revisions;
|
||||||
|
|
||||||
let (latest_revision, title) = article_revisions::table
|
// TODO: Get title and slug as parameters to update_article, so we can... update those
|
||||||
|
let (latest_revision, title, slug) = article_revisions::table
|
||||||
.filter(article_revisions::article_id.eq(article_id))
|
.filter(article_revisions::article_id.eq(article_id))
|
||||||
.order(article_revisions::revision.desc())
|
.order(article_revisions::revision.desc())
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.select((article_revisions::revision, article_revisions::title))
|
.select((
|
||||||
.load::<(i32, String)>(&*conn)?
|
article_revisions::revision,
|
||||||
|
article_revisions::title,
|
||||||
|
article_revisions::slug,
|
||||||
|
))
|
||||||
|
.load::<(i32, String, String)>(&*conn)?
|
||||||
.pop()
|
.pop()
|
||||||
.unwrap_or_else(|| unimplemented!("TODO Missing an error type"));
|
.unwrap_or_else(|| unimplemented!("TODO Missing an error type"));
|
||||||
|
|
||||||
|
@ -65,20 +127,33 @@ impl State {
|
||||||
}
|
}
|
||||||
let new_revision = base_revision + 1;
|
let new_revision = base_revision + 1;
|
||||||
|
|
||||||
|
|
||||||
#[derive(Insertable)]
|
#[derive(Insertable)]
|
||||||
#[table_name="article_revisions"]
|
#[table_name="article_revisions"]
|
||||||
struct NewRevision<'a> {
|
struct NewRevision<'a> {
|
||||||
article_id: i32,
|
article_id: i32,
|
||||||
revision: i32,
|
revision: i32,
|
||||||
|
slug: &'a str,
|
||||||
title: &'a str,
|
title: &'a str,
|
||||||
body: &'a str,
|
body: &'a str,
|
||||||
|
latest: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
diesel::update(
|
||||||
|
article_revisions::table
|
||||||
|
.filter(article_revisions::article_id.eq(article_id))
|
||||||
|
.filter(article_revisions::revision.eq(base_revision))
|
||||||
|
)
|
||||||
|
.set(article_revisions::latest.eq(false))
|
||||||
|
.execute(&*conn)?;
|
||||||
|
|
||||||
diesel::insert(&NewRevision {
|
diesel::insert(&NewRevision {
|
||||||
article_id,
|
article_id,
|
||||||
revision: new_revision,
|
revision: new_revision,
|
||||||
|
slug: &slug,
|
||||||
title: &title,
|
title: &title,
|
||||||
body: &body,
|
body: &body,
|
||||||
|
latest: true,
|
||||||
})
|
})
|
||||||
.into(article_revisions::table)
|
.into(article_revisions::table)
|
||||||
.execute(&*conn)?;
|
.execute(&*conn)?;
|
||||||
|
|
|
@ -4,6 +4,7 @@ use futures::{Future, finished};
|
||||||
|
|
||||||
use assets::*;
|
use assets::*;
|
||||||
use article_resource::ArticleResource;
|
use article_resource::ArticleResource;
|
||||||
|
use article_redirect_resource::ArticleRedirectResource;
|
||||||
use state::State;
|
use state::State;
|
||||||
use web::{Lookup, Resource};
|
use web::{Lookup, Resource};
|
||||||
|
|
||||||
|
@ -69,15 +70,18 @@ impl Lookup for WikiLookup {
|
||||||
return Box::new(finished(None));
|
return Box::new(finished(None));
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Ok(article_id) = slug.parse() {
|
|
||||||
let state = self.state.clone();
|
let state = self.state.clone();
|
||||||
Box::new(self.state.get_article_revision_by_id(article_id)
|
|
||||||
.and_then(|x| Ok(x.map(move |article|
|
use state::SlugLookup;
|
||||||
Box::new(ArticleResource::new(state, article)) as Box<Resource + Sync + Send>
|
Box::new(self.state.lookup_slug(slug.to_owned())
|
||||||
)))
|
.and_then(|x| Ok(match x {
|
||||||
)
|
SlugLookup::Miss => None,
|
||||||
} else {
|
SlugLookup::Hit { article_id, revision } =>
|
||||||
Box::new(finished(None))
|
Some(Box::new(ArticleResource::new(state, article_id, revision))
|
||||||
}
|
as Box<Resource + Sync + Send>),
|
||||||
|
SlugLookup::Redirect(slug) =>
|
||||||
|
Some(Box::new(ArticleRedirectResource::new(slug))
|
||||||
|
as Box<Resource + Sync + Send>)
|
||||||
|
})))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue