Skip to content

Commit

Permalink
Merge pull request #112 from xsnippet/new-snippet
Browse files Browse the repository at this point in the history
Implement the POST /snippets route
  • Loading branch information
malor committed Jan 30, 2021
2 parents f48f4da + aca8c77 commit f35af01
Show file tree
Hide file tree
Showing 9 changed files with 500 additions and 3 deletions.
2 changes: 0 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,4 @@ rand = "0.7.3"
rocket = "0.4.5"
rocket_contrib = {version = "0.4.5", features = ["json"]}
serde = { version = "1.0", features = ["derive"] }

[dev-dependencies]
serde_json = "1.0"
6 changes: 5 additions & 1 deletion src/application.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,12 @@ pub fn create_app() -> Result<rocket::Rocket, Box<dyn Error>> {
};
let storage: Box<dyn Storage> = Box::new(SqlStorage::new(&database_url)?);

let routes = routes![
routes::snippets::create_snippet,
routes::syntaxes::get_syntaxes,
];
Ok(app
.manage(Config { syntaxes })
.manage(storage)
.mount("/v1", routes![routes::syntaxes::get_syntaxes]))
.mount("/v1", routes))
}
88 changes: 88 additions & 0 deletions src/errors.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
use std::convert::From;
use std::io::Cursor;

use rocket::http;
use rocket::request::Request;
use rocket::response::{self, Responder, Response};
use serde::{ser::SerializeStruct, Serialize, Serializer};

use crate::storage::StorageError;

/// All possible unsuccessful outcomes of an API request.
///
/// Allows to handle application errors in the unified manner:
///
/// 1) all errors are serialized to JSON messages like {"message": "..."};
/// the HTTP status code is set accordingly
///
/// 2) implements conversions from internal errors types (e.g. the errors
/// returned by the Storage trait)
#[derive(Debug)]
pub enum ApiError {
BadRequest(String),
NotFound(String),
InternalError(String),
UnsupportedMediaType(String),
}

impl ApiError {
/// Reason why the request failed.
pub fn reason(&self) -> &str {
match self {
ApiError::BadRequest(msg) => &msg,
ApiError::NotFound(msg) => &msg,
ApiError::InternalError(msg) => &msg,
ApiError::UnsupportedMediaType(msg) => &msg,
}
}

/// HTTP status code.
pub fn status(&self) -> http::Status {
match self {
ApiError::BadRequest(_) => http::Status::BadRequest,
ApiError::NotFound(_) => http::Status::NotFound,
ApiError::UnsupportedMediaType(_) => http::Status::UnsupportedMediaType,
ApiError::InternalError(_) => http::Status::InternalServerError,
}
}
}

impl From<StorageError> for ApiError {
fn from(value: StorageError) -> Self {
match value {
StorageError::NotFound { id: _ } => ApiError::NotFound(value.to_string()),
_ => ApiError::InternalError(value.to_string()),
}
}
}

impl Serialize for ApiError {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let mut s = serializer.serialize_struct("", 1)?;
s.serialize_field("message", self.reason())?;
s.end()
}
}

impl<'r> Responder<'r> for ApiError {
fn respond_to(self, _request: &Request) -> response::Result<'r> {
let mut response = Response::build();

if let ApiError::InternalError(_) = self {
// do not give away any details for internal server errors
// TODO: integrate with Rocket contextual logging when 0.5 is released
eprintln!("Internal server error: {}", self.reason());
response.status(http::Status::InternalServerError).ok()
} else {
// otherwise, present the error as JSON and set the status code accordingly
response
.status(self.status())
.header(http::ContentType::JSON)
.sized_body(Cursor::new(json!(self).to_string()))
.ok()
}
}
}
2 changes: 2 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ extern crate rocket;
extern crate rocket_contrib;

mod application;
mod errors;
mod routes;
mod storage;
mod util;

fn main() {
let app = match application::create_app() {
Expand Down
1 change: 1 addition & 0 deletions src/routes/mod.rs
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
pub mod snippets;
pub mod syntaxes;
83 changes: 83 additions & 0 deletions src/routes/snippets.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
use std::collections::BTreeSet;
use std::path::PathBuf;

use rocket::http::uri::Origin;
use rocket::response::status::Created;
use rocket::State;
use rocket_contrib::json::JsonValue;
use serde::Deserialize;

use crate::application::Config;
use crate::errors::ApiError;
use crate::storage::{Changeset, Snippet, Storage};
use crate::util::Input;

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub struct NewSnippet {
/// Snippet title. May be omitted in the user request.
pub title: Option<String>,
/// Snippet syntax. May be omitted in the user request.
pub syntax: Option<String>,
/// Snippet content. Must be specified in the user request. Can't be empty.
pub content: String,
/// List of tags attached to the snippet. May be omitted in the user
/// request.
pub tags: Option<Vec<String>>,
}

impl NewSnippet {
pub fn validate(
self,
allowed_syntaxes: Option<&BTreeSet<String>>,
) -> Result<Snippet, ApiError> {
if self.content.is_empty() {
return Err(ApiError::BadRequest(String::from(
"`content` - empty values not allowed.",
)));
}
if let Some(syntax) = &self.syntax {
if let Some(allowed_syntaxes) = &allowed_syntaxes {
if !allowed_syntaxes.contains(syntax) {
return Err(ApiError::BadRequest(format!(
"`syntax` - unallowed value {}.",
syntax
)));
}
}
}

Ok(Snippet::new(
self.title,
self.syntax,
vec![Changeset::new(0, self.content)],
self.tags.unwrap_or_default(),
))
}
}

fn build_location(origin: &Origin, relative_path: &str) -> Result<String, ApiError> {
// TODO(malor): verify that this works correctly on other systems
let new_path = PathBuf::from(origin.path()).join(relative_path);
Ok(new_path
.to_str()
.ok_or_else(|| {
ApiError::InternalError(format!("Could not construct Location from: {:?}", new_path))
})?
.to_owned())
}

#[post("/snippets", data = "<body>")]
pub fn create_snippet(
origin: &Origin,
config: State<Config>,
storage: State<Box<dyn Storage>>,
body: Result<Input<NewSnippet>, ApiError>,
) -> Result<Created<JsonValue>, ApiError> {
let new_snippet = storage.create(&body?.0.validate(config.syntaxes.as_ref())?)?;

let location = build_location(origin, &new_snippet.id)?;
let response = json!(new_snippet);

Ok(Created(location, Some(response)))
}
88 changes: 88 additions & 0 deletions src/util.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
use std::io::Read;

use serde::de::Deserialize;

use rocket::data::{Data, FromData, Outcome, Transform, Transform::*, Transformed};
use rocket::http::{ContentType, Status};
use rocket::outcome::Outcome::*;
use rocket::request::Request;

use crate::errors::ApiError;

/// The default limit for the request size (to prevent DoS attacks). Can be
/// overriden in the config by setting `max_request_size` to a different value
const MAX_REQUEST_SIZE: u64 = 1024 * 1024;
/// The list of supported formats. When changed, the implementation of
/// Input::from_data() must be updated accordingly.
const SUPPORTED_MEDIA_TYPES: [ContentType; 1] = [ContentType::JSON];

/// A wrapper struct that implements [`FromData`], allowing to accept data
/// serialized into different formats. The value of the Content-Type request
/// header is used to choose the deserializer. The default (and the only
/// supported at the moment) content type is application/json.
///
/// 400 Bad Request is returned if data deserialization fails for any reason.
/// 415 Unsupported Media Type is returned if a client has requested an
/// unsupported format.
///
/// All errors are reported as ApiError.
#[derive(Debug)]
pub struct Input<T>(pub T);

impl<'a, T> FromData<'a> for Input<T>
where
T: Deserialize<'a>,
{
type Error = ApiError;
type Owned = String;
type Borrowed = str;

fn transform(r: &Request, d: Data) -> Transform<Outcome<Self::Owned, Self::Error>> {
let size_limit = r
.limits()
.get("max_request_size")
.unwrap_or(MAX_REQUEST_SIZE);

let mut buf = String::with_capacity(8192);
match d.open().take(size_limit).read_to_string(&mut buf) {
Ok(_) => Borrowed(Success(buf)),
Err(e) => Borrowed(Failure((
Status::BadRequest,
ApiError::BadRequest(e.to_string()),
))),
}
}

fn from_data(request: &Request, o: Transformed<'a, Self>) -> Outcome<Self, Self::Error> {
let data = o.borrowed()?;

let content_type = request.content_type().unwrap_or(&ContentType::JSON);
if content_type == &ContentType::JSON {
match serde_json::from_str(&data) {
Ok(v) => Success(Input(v)),
Err(e) => {
if e.is_syntax() {
Failure((
Status::BadRequest,
ApiError::BadRequest("Invalid JSON".to_string()),
))
} else {
Failure((Status::BadRequest, ApiError::BadRequest(e.to_string())))
}
}
}
} else {
Failure((
Status::UnsupportedMediaType,
ApiError::UnsupportedMediaType(format!(
"Support media types: {}",
SUPPORTED_MEDIA_TYPES
.iter()
.map(|v| v.to_string())
.collect::<Vec<String>>()
.join(",")
)),
))
}
}
}

0 comments on commit f35af01

Please sign in to comment.