Axum Template
Web API Rust stack
This project is heavily inspired by the brilliant video from Jeremy Chone. Parts of the code are exactly the same. I simply followed the tutorial, adjusted some preferences and later decided to add GraphQL and SurrealDB. Hope this can be helpful to somebody. Also you can let me know if you have any suggestions to improve the code.
Project structure
- rest and gql routes
- services that can resolve both, because they use the same Result type
- resolution of the ApiResult is handled separately by the impls for rest and gql
└─┬ src
├─┬ graphql
│ ├─┬ query_root
│ │ ├── tickets_query.rs
│ │ └── tickets_no_db_query.rs
│ ├─┬ mutation_root
│ │ ├── tickets_no_db_mutation.rs
│ │ └── tickets_mutation.rs
│ ├── query_root.rs
│ └── mutation_root.rs
├─┬ web
│ ├── routes_tickets_no_db.rs
│ ├── routes_tickets.rs
│ ├── routes_login.rs
│ └── routes_hello.rs
├─┬ service
│ ├── ticket_no_db.rs
│ └── ticket.rs
├── error.rs
├── web.rs
├── service.rs
├── mw_req_logger.rs
├── mw_ctx.rs
├── main.rs
├── graphql.rs
└── ctx.rs
Error handling
Goals
- Control over the display of errors to the client, and the debug log of errors on the server
- Presenting errors to the client with the request id, and logging all request ids and occurred errors raw (debug) on the server
Why no error crate?
I don’t use Anyhow or Thiserror here. I think it is clearer what is happening in
the code and easier to maintain with simply writing the Display and From traits
myself. It is possible the same results could be achieved with using Thiserror
and having possibly fewer lines of code, but that is not worth the extra crate
logic for me.
I might change my opinion on this in the future. But I see other project using
even both of these crates at the same time. I simply think that using rust
traits as they are is clearer. Clearer and more maintainable long-term, than
adding an ever increasing number of 3rd party crates, locking down the code,
with good intentions of simplifying error handling, but possibly (likely?)
making it more complicated. Just impl the trait, its not so hard.
Undecided
If all req_id
should always be presented to the client and how. Currently I’m
leaning to the more conservative approach to present them only with errors. But
presenting them always would open the possibility to get rid of the ApiError
wrapper.
// error.rs
pub struct ApiError {
pub error: Error,
pub req_id: Uuid,
}
pub enum Error {
Generic { description: String },
LoginFail,
...
}
pub type ApiResult<T> = core::result::Result<T, ApiError>;
pub type Result<T> = core::result::Result<T, Error>;
ApiError
Original purpose
If errors should include their req_id
, it should be part of the error body.
But if every error variant has to have it, its maybe better to wrap them all
with this higher Error that always has it.
pros
- Type enforced resolution into this type, not just to any that implements Display that satisfies the gql result.
cons
- Another abstraction layer, very unfortunate if it would not be necessary
- Impossible to use a simple into (?) from ocurred errors to ApiError, because
it requires additional information from the context (
req_id
). Requiring an explicit.map_err
at the end of every service function (not elegant).
// service/ticket.rs
pub async fn list_tickets(&self) -> ApiResult<Vec<Ticket>> {
self.db
.select("tickets")
.await
.map_err(ApiError::from(self.ctx)) // From DB error, displaying a generic message, logging the source
}
pub async fn delete_ticket(&self, id: String) -> ApiResult<Ticket> {
self.db
.delete(("tickets", &id))
.await
.map_err(|e| ApiError { // an error picked on the spot
req_id: self.ctx.req_id(),
error: Error::SurrealDbNoResult {
source: e.to_string(),
id,
},
})
}
If no ApiError (only one single crate enum Error)
pros
- One less abstraction layer for the Errors
cons
- Req_id would have to be returned always, in http header and gql body extension. Or another step would have to be introduced that filters responses with Errors, which is probably even more convoluted than the current ApiError solution.
- From Error for gqlError is implemented automatically in the library, so its
not possible to do it manually. The way how the library author intended
extending errors (so I can save the raw error for the logger), is to implement
ErrorExtensions
for the enum Error. That is still not part of the into, it will still not be used with (?), it has to be still called every time on the error with.extend()
making it very similar to the current “non elegant” mapping. Difference is that now the mapping is called on the end of the service when ApiError is created, but the.extend()
would be called only at the end of the gql route, when the gql result is created.
ApiError conclusion
If I would use ErrorExtensions
and .extend()
, it would not be a big progress
and it would loose me the control of optionally not showing the req_id
to the
client. That could include future data connected to all errors that should not
always be returned. Therefore I will keep this structure. But if I would know
that adding the req_id
in every single response always is a good idea, I can
see it would be better to use only one simple Error type, with the added risk of
not having it type-checked if you would forget to add .extend()
at every
response, not logging the error on the server.
Logging
I use the same idea from Jeremy Chone’s video. To build the response using the
client-facing data in the error. Than saving the raw error using axum’s (http)
response extensions, and extracting it back out for logging in one dedicated
middleware layer mw_req_logger
for the server. This way, every single request
has exactly one dedicated log, that can also include errors and further
details.
I wanted to adhere to this even with the gql addition.
To achieve this with Gql, I couldn’t simply use impl IntoResponse for ApiError
as the rest routes are using, because the response is build by the Gql library.
I had to find a way how to give the ApiError to gql, and later add it to the
response extensions, so it gets picked up in the end the same way as the rest
responses in the mw_req_logger
.
Because ApiError doesn’t implement Display, it does not have an automatic From
impl for async_graphql::Error
. By writing it manually, I can in the same step
store the raw error in serialized json inside the gql response structure under
“error extensions”.
// error.rs
impl From<ApiError> for async_graphql::Error {
fn from(value: ApiError) -> Self {
Self::new(value.error.to_string())
.extend_with(|_, e| e.set("req_id", value.req_id.to_string()))
// storing the original as json in the error extension - for the logger
.extend_with(|_, e| e.set(ERROR_SER_KEY, serde_json::to_string(&value.error).unwrap()))
}
}
After the gql response is built, just before it is handed over to it’s axum
IntoResponse
in the graphql_handler
, I extract the error, deserialized it,
than turn it into the axum response with IntoResponse
and than I can store the
extracted Error in the request extensions just like with the rest errors.
// graphql.rs
pub async fn graphql_handler(
schema: Extension<ApiSchema>,
ctx: Ctx,
req: async_graphql_axum::GraphQLRequest,
) -> axum::response::Response {
let mut gql_resp: async_graphql::Response = schema.execute(req.into_inner().data(ctx)).await;
// Remove error extensions and deserialize errors
let mut error: Option<Error> = None;
for gql_error in &mut gql_resp.errors {
let Some(extensions) = &mut gql_error.extensions else { continue };
let Some(value) = extensions.get(ERROR_SER_KEY) else { continue };
let Value::String(s) = value else { continue };
error = Some(serde_json::from_str(s).unwrap_or_else(Error::from));
extensions.unset(ERROR_SER_KEY);
break;
}
let mut response = async_graphql_axum::GraphQLResponse::from(gql_resp).into_response();
// Insert the real Error into the response - for the logger
if let Some(e) = error {
response.extensions_mut().insert(e);
}
response
}
I understand that this serde step is not ideal, but for now I don’t have a better solution and this works.
Gql and DB integration
I am very happy with this.
In my previous company project, where we used MongoDb with Mongoose, Apollo Gql and TypeScript, to provide a table to gql, 4 different implementations of the type were used.
- The Gql schema
- The DB model schema
- The TS type derived from the model - used for manipulation
- A “plain” version of the type, without the id and all of the mongoose methods and metadata - used for arguments and returns of just the data
I expected something similar, but thanks to rust having such a strong type system, allot of this is baked in. For simple objects, it is enough to have just one type. This is incredibly simple to look at, as easy (if not easier) to set up and use as rest, and fully type safe, with all the other gql benefits.
This is the gql route, it resolves in the .list_tickets()
function that is
visible in the firs snippet on this page.
// graphql/query_root/tickets_query.rs
impl TicketsQuery {
async fn list(&self, ctx: &Context<'_>) -> Result<Vec<Ticket>> {
let db = ctx.data::<Db>()?;
let ctx = ctx.data::<Ctx>()?;
Ok(TicketService { db, ctx }.list_tickets().await?)
}
}
And this is the whole Ticket implementation. DB model, Gql schema and data type in one.
// service/tickets.rs
#[derive(Clone, Debug, Serialize, Deserialize, SimpleObject)]
#[graphql(complex)]
pub struct Ticket {
#[graphql(skip)]
pub id: Option<Thing>,
pub creator_id: u64,
pub title: String,
}
#[ComplexObject]
impl Ticket {
async fn id(&self) -> String {
self.id.as_ref().map(|t| &t.id).expect("id").to_raw()
}
}
The same struct can be used for creating the model with a random ID, because it is set as Option. It is possible to manually create a random Id, but I like this much better.
...
pub async fn create_ticket(&self, ct_input: CreateTicketInput) -> ApiResult<Ticket> {
self.db
.create("tickets")
.content(Ticket {
id: None,
creator_id: self.ctx.user_id()?,
title: ct_input.title,
})
.await
.map_err(ApiError::from(self.ctx))
}
SurrealDb Ids
Undecided
The Id in surreal DB has the format table:id
. In the surrealDb library this is
called a Thing
. This has advantages and disadvantages. I am not sure where I
should use the full “Thing format”, and where just the wrapped id
.
Hiding the table name
pros
- Doesn’t unnecessarily expose the table name.
- Prevents mistaken “injection” where you access a record from another table
- Slightly better resource access. The tuple resource
.delete((table, id))
is also how the docs recommend it. Thing format has to be first parsed withthing()
and handled.
cons
- Extracting the table from the DB result
In order to cover both gql and rest results, the mapping would have to be done
for every result on the service level. Alternatively a gql scalar type could
achieve this, but only for gql. With a new-type pattern WThing(Thing)
and a
gql macro
#[Scalar]
for generating the scalar. Returning just the id in to_value
should work, but
It would not make sense to than expect the full format in parse
. This part of
the Trait would have to be left unimplemented, since we don’t want the full Id
on the input.
I am not sure what the right decision here should be. But I think that the returned ids should have the same format as the expected input ids once decided.
Currently, where from here…
I do think that returning and expecting only the inner id is better. But
currently SurrealDB, when queried with structs, always returns the full Thing
.
This was confirmed to me in their discord. I would love if I could query with
pub struct Ticket {
pub id: Option<surrealdb::sql::Id>, // This doesn't work currently!
...
}
But the only way to get that currently is with
SELECT meta::id(table:id), field1, field2 from tickets
I was told. But for the
sake of gql I want to continue using structs.
Gql centric DB access
This is what I would probably do if I used this template onward. I would probably not want to use the rest endpoints for querying the DB anyway. And if I for some reason absolutely needed it, the rest route could have a dedicated service that presents the IDs as desired. So I would just use it as it currently is. Maybe implementing that WThing scalar if writing the ComplexObject id field for every table would become too annoying for some reason. But it is not so bad I think. It also has the tiny benefit that this way, the schema is presented correctly as always returning the id field, id is not optional on return. I find it as one of the nicest parts of this setup, that it needs just one struct to init a whole new api route type.
Rest centric DB access
If you really need to access the DB IDs from many tables through rest and you
need to have them in the inner id format, currently the most correct solution
would be probably to use two objects. One DbTicket
with the full Thing
as
received from the DB, and another Ticket
that would be used for the gql and
rest responses. Impl From and calling into on the db type at the end of the
service.
Conclusion
In conclusion, Axum is very advanced and does seam ready for serious use. The same can be said about async_graphql, even though Im using it for slightly unorthodox experiments in this project (the error json juggling). I really wanted to try SurrealDb and this is hopefully not the last time Im trying it out. At least the versioning suggests that a v1.0 is right around the corner and hopefully its usage will get more standardized, the docs could be a little expanded and I believe it will get further attention in the future. A nice project. I didn’t encounter any strong deal-breakers. But un-surprisingly there are open questions regarding best-practices in such a new experimental Axum, Gql, Surreal, Rust Web Stack.