This is the first "real" project we're going to tackle, and it has some of the foundational types that will be evolving in the next examples, so before we dive too deep into actix, let's first take a look at the "business" structs we have defined.
The first in line is the Task
struct, this is the bread and butter of our app, after all you can't
have a task management application without tasks. There is nothing fancy here, this is just the
bare minimum of what a task is supposed to be.
Next, we have 2 structs that share a similar reason for existing, InsertTask
and UpdateTask
:
InsertTask
for apost
request (task creation);UpdateTask
for aput
request (updating a task);
AppData
is our in memory database (if you're comfortable stretching the definition of database to
be just a list). It's just a list of tasks wrapped in a
Mutex
, and a
AtomicU64
to track the task
ID generation (more on why these fields have to be thread-safe later).
Time to explore the AppError
enum. First, we're using
thiserror
crate to get a nice
#[derive(Error)]
macro and #[error("")]
attribute, this makes life easier when we want to use
custom errors. But AppError
also implements
ResponseError
.
The ResponseError
trait is how actix will be able to generate responses when a request generates
an error. You must implement two functions:
status_code
: what status code should we respond with, we'll be matching on our error and trying to respond with an appropriate HTTP code;error_response
: theHttpResponse
that we reply with, we'll be just converting our error into a string and putting it inside the body of the response;
Actix provides the HTTP status codes you expect through
actix_web::http::StatusCode
.
There are a bunch of provided by actix itself, and we'll be using those.
With this we're done with our own types, it's time to dive.
As we've seen in the minimal
project, to set up a route we just call App::service
and pass it our route function (index
), but now we have 2 new friends:
App::app_data
,
and
App::configure
.
Things that we put inside App::app_data
are stored at the root level of our app, this means
that we can access it in many places throughout actix (we'll be using it in our request routes, more
about this later).
Recall that HttpServer::new
is handling the creation of our server, and that App
is a recipe
rather than the actual application, so when we put something in App::app_data
, whatever we
want to store there will be in a different state for every new instance of App
that HttpServer
creates (and actix uses multiple worker threads, so you'll end up with many different instances).
We want users to share the same global "database" of tasks, so we must make our AppData
something
that can be shared across multiple threads. This is why we have AtomicU64
, instead of just u64
,
and our task list is wrapped in a Mutex
.
You may have noticed that we're not passing our AppData
struct directly into it though, first we
wrap it with some
Data::new
function. This
will be clearer later when we talk about our routes, but to not leave you hanging, Data
helps
to extract whatever we put in App::app_data
in our routes.
This one is just a helper to allow setting up routes in other places, rather than having to write
everything as a huge chain of .service(index).service(insert)
. You create a function that takes
a &mut ServiceConfig
and just chain the .service()
calls there. We'll be using this approach to
separate different kinds of services, even though we only have task related services, later on we'll
also have user services.
Our services are set up, our App
is configured, now let's explore the routes.
Actix provides a macro for each HTTP method, and we'll be taking advantage of those to keep the
route handling functions really simple. The heart of our app will live on the /tasks
path, and
I'll be using POST
to do insertion, PUT
for update, DELETE
for deleting, and GET
for
the different ways of finding.
If you look at each service function defined, you'll see some common parameters and the same return type:
#[post("/tasks")]
async fn insert(app_data: Data<AppData>, input: Json<InsertTask>) -> Result<HttpResponse, AppError>
#[get("/tasks")]
async fn find_all(app_data: web::Data<AppData>) -> Result<HttpResponse, AppError>
Each HTTP method macro expects a async fn
and returns a HttpResponse
, but you may have noticed
that these functions return Result<HttpResponse, AppError>
instead. Well, you've already seen the
ResponseError
trait that we've implemented for AppError
, and actix will use that trait's
error_response
function to convert Err(...)
into a HttpResponse
.
There is another trait that we're not using explicitly (yet), called
Responder
which is very
similar to ResponseError
, but not error specific. Actix implements this trait for many
types, and
Result<T, E>
happens to be one of those, so it knows how to make a response out of Ok(...)
.
I'm being very explicit in this project with the return types, constructing a HttpResponse
and
returning it as Ok(response)
, but things could be done differently, we could convert the result
of find_all
into a string and return Ok(task_list_string)
for example. In later projects we'll
be implementing Responder
for our types.
Looking at the parameters, we see (_: Data<AppData>, _: Json<InsertTask>)
. You already know what
the inner types are, and I gave a brief explanation about Data<T>
, but now it's time to dive
deeper.
These parameters are called extractors, and they're nifty little helpers to extract data from a
request. If not for them, you would need to define these services with a request: HttpRequest
parameter, and manually take the data from within request
. Not a very productive way of doing
things, check out
HttpRequest
if you
want to learn a bit more about doing it this way.
Data<AppData>
So, back to our extractors, one that is present in every function is Data<T>
, which extracts from
the request whatever we registered in our global App::app_data
, and tries to convert it into T
.
If T
doesn't match a type registered with App::app_data
, then you'll receive a nice 500
error
response for free.
Each request thread will have its own copy of data, and the Data<T>
extractor only holds a
read-only reference, that's why we made the AppData
fields multi-thread "aware", and we register
it with App::app_data
, this is what allows us to have mutable shared access (how we create the
"global database", instead of it being just a "per request database").
Json<InsertTask>
,Json<UpdateTask>
This one is pretty straightforward, it'll extract from the request some type that may be
deserialized from json. We implement serde::Serialize
and serde::Deserialize
for every one of
our types.
You may implement a custom error handler for this kind of extractor with
JsonConfig::error_handler
. There are also custom error_handler
s for the Form
, Path
, and
Query
(config) extractors.
Path<u64>
We use Path<T>
to extract data from the URL, in our case the id
for find_by_id
and delete
services.
#[get("/tasks/{id}")]
#[delete("/tasks/{id}")]
Be careful with {something}
path notation, as this will match on anything (it's the equivalent
to a [^/]+
regex). So in our case we expect a number, but we're not being very explicit about it.
A Path<T>
may also be used to extract into structs that implement Deserialize
, and it'll match
on the struct's fields.
And with this we've covered every extractor used in the in-memory
project, more will be coming
later, but for now this is plenty of information to extract.
#[post("/tasks")]
async fn insert(app_data: Data<AppData>, input: Json<InsertTask>) -> Result<HttpResponse, AppError>
The insert
function takes a Data<AppData>
extractor so that we can insert a new task, fed by the
Json<InsertTask>
extractor, into the shared AppData
database. There is not much more actix
related code in there, it just uses the InsertTask
to create a new Task
by cloning each field.
We need to call clone
here because we access InsertTask
inside Json<T>
through a reference, so
if we don't clone (or copy), then we'll get an error (move out of dereference).
// cannot move out of dereference of `actix_web::web::Json<InsertTask>`
// move occurs because value has type `InsertTask`, which does not implement the `Copy` trait
let insert_task = *input;
We do a quick validation check and return a AppError::EmptyTitle
if input
contains an empty
title
. Later on we'll have a more appropriate validation function. The rest of the function is
just inserting the new Task
in AppData::task_list
and incrementing AppData::id_tracker
.
And in the end, we use the HttpResponse::Ok().json
to build a response by converting new_task
into json and putting it in the body of the response.
#[get("/tasks")]
async fn find_all(app_data: web::Data<AppData>) -> Result<HttpResponse, AppError>
Pretty basic, just grabs the "database" again and returns every Task
in AppData::task_list
.
#[get("/tasks/{id}")]
async fn find_by_id(app_data: web::Data<AppData>, id: web::Path<u64>) -> Result<HttpResponse, AppError>
The first use of the Path<T>
extractor, expecting a u64
representing the Task::id
we want to
fetch.
#[delete("/tasks/{id}")]
async fn delete(app_data: web::Data<AppData>, id: web::Path<u64>) -> Result<HttpResponse, AppError>
Again with Path<u64>
, but this time we remove the Task
from AppData::task_list
.
#[put("/tasks")]
async fn update(app_data: web::Data<AppData> input: web::Json<UpdateTask>) -> Result<HttpResponse, AppError>
Similar to insert
, but this time we use Json<UpdateTask>
, which contains an ID.
in-memory
already shows a lot of actix features, and you can see the structure of an actix web
server taking form.
Set up some routes (either with get
, post
, and friends, or the route
macro), configure the
services, implement the ResponseError
trait (or convert your errors manually into response, do not
recommend), and finally, create and run your HttpServer
.
On the next example (sqlite
) we'll be introducing tests, SQL and more actix, stay
tuned!