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.

  1. The Gql schema
  2. The DB model schema
  3. The TS type derived from the model - used for manipulation
  4. 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 with thing() 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.

Related