-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #112 from xsnippet/new-snippet
Implement the POST /snippets route
- Loading branch information
Showing
9 changed files
with
500 additions
and
3 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1,2 @@ | ||
pub mod snippets; | ||
pub mod syntaxes; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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))) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(",") | ||
)), | ||
)) | ||
} | ||
} | ||
} |
Oops, something went wrong.