Rocket and Multipart forms
source link: https://blog.krruzic.xyz/rocket-multipart/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
Recently I've been working on a rust web service written using Rocket and Diesel.
A big issue I came across was handling muitpart forms (AKA content-type: multipart/form-data
).
Rocket doesn't support them currently (although it is planned!) but you can
still get it working right now.
Setup
Create a new binary project with cargo: cargo init multipart_demo
then add
dependencies to your cargo.toml using cargo-edit
cargo install cargo-edit cargo add rocket cargo add rocket-multipart-form-data cargo add serde_json # next you have to add serde manually to the cargo file echo 'serde = { version = "*", features = ["derive"]}' >> Cargo.toml
Dependencies explained
Rocket : should be obvious if you're here, rocket is the webserver powering the whole app. It handles incoming requests and routes them to the proper page and does everything in between.
Rocket-Multipart-Form-Data : this is where the magic happens. This package handles the parsing of the multipart form.
Serde : JSON serialization/deserialization.
Getting started
Now that we have our environment set up, it's time to start building something.
To keep things simple, let's make a JSON api endpoint for creating new user
profiles. This endpoint should take two fields, avatar
an image file to use as
their profile picture, and data
a JSON object containing their name and age.
To do this, create a new file in the multipart_demo/src
directory called middleware.rs
this is where we will intercept the request and use
the multipart parser to validate the submission. Instead of just bolting on
support however, let's integrate it with Rocket's powerful request guard
feature
.
First we need to set up the Error type in case something goes wrong in parsing,
the User
struct that we deserialize the JSON to, as well as the NewUser
struct representing the passed in form.
// our dependencies use rocket::data::{FromDataSimple, Outcome}; use rocket::http::Status; use rocket::{Data, Outcome::*, Request}; use rocket_multipart_form_data::{ mime, MultipartFormData, MultipartFormDataField, MultipartFormDataOptions, RawField, TextField, }; use serde::{Deserialize, Serialize}; // first we need to create a custom error type, as the FromDataSimple guard // needs to return one #[derive(Debug, Clone)] pub struct MultipartError { pub reason: String, } impl MultipartError { fn new(reason: String) -> MultipartError { MultipartError { reason } } } impl std::fmt::Display for MultipartError { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { write!(f, "{}", self.reason) } } /// simple representation of a user #[derive(Serialize, Deserialize)] pub struct User { pub name: String, pub age: i32, } /// multipart form is loaded into this struct /// this is what's passed through to the route we'll create later pub struct NewUser { /// the submitted image pub avatar: Vec<u8>, /// we'll deserialize the json into a User pub user: User, }
Request Guard Implementation
Now that we have the basic data structures we need for our app, let's create the
parser. As mentioned above, we'll do this by implementing the Rocket FromDataSimple
trait as a request guard. What this does is intercept a request
BEFORE it reaches a route, and verifies / modifies / parses the data received.
We take advantage of this to verify our multipart form ahead of time, to keep
our routes clean and enable code reuse. Implement the trait below in middleware.rs
impl FromDataSimple for NewUser { type Error = MultipartError; fn from_data(request: &Request, data: Data) -> Outcome<Self, Self::Error> { let image_bytes; let post_obj; let mut options = MultipartFormDataOptions::new(); // setup the multipart parser, this creates a parser // that checks for two fields: an image of any mime type // and a data field containining json representing a User options.allowed_fields.push( MultipartFormDataField::raw("avatar") .size_limit(8 * 1024 * 1024) // 8 MB .content_type_by_string(Some(mime::IMAGE_STAR)) .unwrap(), ); options .allowed_fields .push(MultipartFormDataField::text("data").content_type(Some(mime::STAR_STAR))); // check if the content type is set properly let ct = match request.content_type() { Some(ct) => ct, _ => { return Failure(( Status::BadRequest, MultipartError::new(format!( "Incorrect contentType, should be 'multipart/form-data" )), )) } }; // do the form parsing and return on error let multipart_form = match MultipartFormData::parse(&ct, data, options) { Ok(m) => m, Err(e) => { return Failure((Status::BadRequest, MultipartError::new(format!("{:?}", e)))) } }; // check if the form has the json field `data` let post_json_part = match multipart_form.texts.get("data") { Some(post_json_part) => post_json_part, _ => { return Failure(( Status::BadRequest, MultipartError::new(format!("Missing field 'data'")), )) } }; // check if the form has the avatar image let image_part: &RawField = match multipart_form.raw.get("avatar") { Some(image_part) => image_part, _ => { return Failure(( Status::BadRequest, MultipartError::new(format!("Missing field 'avatar'")), )) } }; // verify only the data we want is being passed, one text field and one binary match post_json_part { TextField::Single(text) => { let json_string = &text.text.replace('\'', "\""); post_obj = match serde_json::from_str::<User>(json_string) { Ok(insert) => insert, Err(e) => { return Failure(( Status::BadRequest, MultipartError::new(format!("{:?}", e)), )) } }; } TextField::Multiple(_text) => { return Failure(( Status::BadRequest, MultipartError::new(format!("Extra text fields supplied")), )) } }; match image_part { RawField::Single(raw) => { image_bytes = raw.raw.clone(); } RawField::Multiple(_raw) => { return Failure(( Status::BadRequest, MultipartError::new(format!("Extra image fields supplied")), )) } }; Success(NewUser { user: post_obj, avatar: image_bytes, }) } }
This is a lot of code, but it's actually fairly simple. All we do is verify the various parts of the form data, and return a relevant error message if something goes wrong. If you were verifying a more complex request, you'd just need to add additional match statements for each field supplied once.
Finishing Up
Finally we can implement a route that takes in a NewUser
multipart form! Open
up src/main.rs
and replace its contents with the below code:
#![feature(proc_macro_hygiene, decl_macro)] #[macro_use] extern crate serde; #[macro_use] extern crate rocket; extern crate rocket_multipart_form_data; mod middleware; use crate::middleware::MultipartError; use crate::middleware::NewUser; type Result<T> = std::result::Result<T, MultipartError>; // create a route called create_user that expects a NewUser or an error. // If the post was successful, print off the user's information, otherwise // print off the error message. #[post("/create_user", data = "<multipart>")] fn new_user(multipart: Result<NewUser>) -> String { match multipart { Ok(m) => format!("Hello, {} year old named {}!", m.user.age, m.user.name), Err(e) => format!("Error: {}", e.reason), } } fn main() { // this launches the webserver with the above route mounted on /. // To access it go to localhost:8000/create_user once launched rocket::ignite().mount("/", routes![new_user]).launch(); }
Testing our code
See how easy that was? We now have an error-proof route that takes in a
multipart form, with no messy code. Since this is a simple tutorial, I won't
show how to make a frontend where you can submit / view / manage users, instead
we can test out this code with good old curl
. First launch the server with cargo run
. You should see some output showing where the route is mounted and
how to access it. By default it should be localhost:8000/create_user
. If for
some reason its different, just replace the references in the curl command.
You'll also want to replace demo.png
with some png file on your system.
curl -X POST "localhost:8000/create_user" -H "accept: */*" -H \ "Content-Type: multipart/form-data" \ -F data="{'name': 'kristopher', 'age': 25}" -F "[email protected];type=image/png"
If everything went well, you should see Hello, 25 year old named kristopher!
.
Experiment to see how you can break it! What happens when you leave out the name
field in the JSON? How about when one of the multipart fields is left out
entirely?
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK