axum icon indicating copy to clipboard operation
axum copied to clipboard

Enable Routing by Content-Type Header

Open dawid-nowak opened this issue 2 years ago • 41 comments

  • [v ] I have looked for existing issues (including closed) about this

Feature Request

Enable Content Based Routing

Motivation

Currently Axum routes by path only so in order to handle different types of content an approch shown in example has to be implemented in all handlers resulting in extra boilerplate and complexity that could be handled by the framework.

Other frameworks can route by path and content type. See examples for dotnet and springboot

Proposal

One approach would be to extend the existing Router API to allow the user to pass the content type that the handler is processing. For example:

let app = Router::new()
    .route("/path", post(path_handler_for_json, "application/json")
    .route("/path", post(path_handler_for_xml,"application/xml")

Alternatives

Create a completely separate ContentRouter service

dawid-nowak avatar Dec 19 '22 11:12 dawid-nowak

I think this is definitely something we could explore inside axum-extra. However, that would require first making .route more generic.

@davidpdrsn wdyt about axum growing its own Service & Layer trait equivalents with blanket implementations for any type implementing the tower ones? These could be generic over the state type so we can get rid of the {route,nest,..} vs. {route,nest,..}_service distinction in the public API again. Then axum-extra could provide MethodAndContentTypeRouter.

jplatte avatar Dec 19 '22 11:12 jplatte

I think it's worth exploring!

davidpdrsn avatar Dec 19 '22 11:12 davidpdrsn

Looking through the code i can see how this is can be "E-hard" :)

Perhaps another option would be to do something like this ?

let app = Router::new()
    .route(("/path", "application/json"), post(path_handler_for_json)
    .route("/path"), post(path_handler_for_default))

I think that would only require changes to Node and instead of str we woudl have a tuple (str, mime)

dawid-nowak avatar Dec 19 '22 18:12 dawid-nowak

There are definitely ways to solve this that aren't that hard, but if it's going to be part of axum-extra and not a thirdparty project, I want it to be very close to the existing API and not something else just because that might make the implementation simpler 😉 (and I'm sure David agrees)

jplatte avatar Dec 19 '22 18:12 jplatte

Yeah I agree it should feel very close to the existing API. I also think how axum doesn't allow overlapping routes is an important feature and any additional routing we support should do the same.

I have prototyped different solutions in the past but never found anything that was strictly better than just using a match statement inside the handler, or an extractor like https://github.com/tokio-rs/axum/blob/main/examples/parse-body-based-on-content-type/src/main.rs#L54

davidpdrsn avatar Jan 05 '23 10:01 davidpdrsn

I have been playing around with the routing code and opened a draft pull request 1679 for custom router PoC.

I see it more as brainstorming opportunity than merge request so the feedback will be greatly appreciated.

Generally, I am trying to make Router generic so I could instantiate a router with that takes anything that implements newly added RouterResolver trait (for example MethodRouter). This should enable us to keep different/future/bespoke RouterResolver implementations in axum-extra.

While it works, there are certain issues:

  1. Passing state around. In the original code it looks like state is defaulted to () and then a new instance of Router is returned if we want to use a different state here. It looks like this won't work with a trait so then it means that state has to be stored inside of whatever implements RouteResolver and unfortunately once Router is instantiated the type of state can't change.
  2. The RouteResolver interface. It is not the prettiest or intutive. I think this is because of very tight coupling between existing Router and MethodRouter and ability to nest Routers etc.

dawid-nowak avatar Jan 05 '23 18:01 dawid-nowak

I haven't read the code very closely but my impression is that it introduces a lot of new complexity that I don't think is worth while.

I'd be more interested to hear what you need content-type based routing for and think if we can come up with a simpler solution to that problem, instead of immediately assuming we need generalized "route by anything".

so in order to handle different types of content an approch shown in example has to be implemented in all handlers resulting in extra boilerplate and complexity that could be handled by the framework

Feels like you're misunderstanding the example. The extractor doesn't have to be an enum. You could also do struct JsonOrForm<T>(T);. Then all the branching will be done in the extractor. Perhaps we should change the example to that to make it more clear.

davidpdrsn avatar Jan 07 '23 10:01 davidpdrsn

The use case is pretty simple, as mentioned at the top SpringBoot and dotnet hide the content type resolution from the business logic and you can specify that a given handler is only going to accept or produce specific content based on headers (Content-Type, Accept, Host ) from a request.

For example in SpringBoot you can do something like this:

import org.springframework.http.MediaType;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;

@Controller
@RequestMapping("/users")
public class UserControllerConsume {

    @RequestMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
    public String handleJson(@RequestBody String s) {
        System.out.println("json body : " + s);
        return "";
    }

    @RequestMapping(consumes = MediaType.APPLICATION_XML_VALUE)
    public String handleXML(@RequestBody String s) {
        System.out.println("xml body " + s);
        return "";
    }
}

The obvious advantage here is that the framework selects an endpoint by method, content-type and accept and other headers and as a developer i don't have to repeat this code in my handlers.

The ask here would be to provide such a Router that we don't have to write JsonOrForm<T>(T) or JsonOrTextOrXml<T>() as part of the application and instead we could have something like :

fn path_handler_for_json(Json(payload):Json<Payload>){
}

fn path_handler_for_xml(Xml(payload):Xml<OtherPayload>){
}

fn path_handler_for_text(payload:String){
}

let app = Router::new()
    .route("/path", post(path_handler_for_json).consumes("application/json").produces("application/json"))
    .route("/path", post(path_handler_for_xml).consumes("application/xml").produces("application/xml"))
    .route("/path"), "post(path_handler_for_text).consumes("text/plain").produces("text/plain"))

I appreciate that implementing this new functionality will be quite hard.

I do think that the complexity stems from the fact that MethodRouter and Router are very tightly coupled. Router resolves the path part and then MethodRouter looks at the HTTP method part, but Router is strongly dependent on MethodRouter.

If we wanted to use something like MethodAndContentType router and keep it in axum-extern as suggested by @jplatte then we need to loosen the coupling between Router and MethodRouter.

If there is an easier way to achieve the goal that would be great.

dawid-nowak avatar Jan 08 '23 14:01 dawid-nowak

I'm still not convinced that making the routing more complicated is worth it for this use case.

Using an extractor to handle different content-types still feels like the right approach to me. We could also investigate adding helpers to make it easier to write such extractors. Feels to me like that would be easier.

davidpdrsn avatar Jan 08 '23 14:01 davidpdrsn

Ok, let's close it.

I think this is more of how you see Axum evolving in the future. Wheter it is going to stay as a pretty low level library where ultimately the responsibility falls on the user to implment a lot of generally common logic. Or it is going to evolve into something richer and hide the common scenarios from the user.

It would be nice to hava a mechanism for extending routing in Axum, but it looks like without breaking changes to Router and MethodRouting I don't think this is possible.

dawid-nowak avatar Jan 31 '23 09:01 dawid-nowak

I think we should keep this issue open as this is a use case that makes sense for axum to support. The hard part is figuring out how to best do that.

I think this is more of how you see Axum evolving in the future. Wheter it is going to stay as a pretty low level library where ultimately the responsibility falls on the user to implment a lot of generally common logic. Or it is going to evolve into something richer and hide the common scenarios from the user.

There are more axis than high and low level. Minimal and full featured is another. Personally I consider axum to be a high level but minimal framework. By high level I mean the code you write doesn't have to care about low level transport level details and by minimal I mean we're quite conservative with which features are built into the axum crate itself.

This is all very subjective and depends on your goals and what you're comparing to.

My goal is for axum to provide APIs that people can use to implement whatever it is they need for their app, without having opinions about the exact implementation. For example axum should make it easy to share a database connection pool throughout your app, but shouldn't have an opinion on which database or pooling library you use.

davidpdrsn avatar Jan 31 '23 09:01 davidpdrsn

Perhaps for the purpose of keeping axum minimalistic, routing as a whole should be pulled out of axum.

Kinrany avatar Jan 31 '23 17:01 Kinrany

@Kinrany something like that has been suggested before but I'm not sure it would provide much value and probably make things harder to use. I think needing routing is pretty common so it makes sense to build in.

davidpdrsn avatar Jan 31 '23 18:01 davidpdrsn

I would argue that some default routing should be present. But at the same time it should be easy to plug in a different routing mechanim without having to change the code in axum core.

At the moment the only way to handle content-type is to implement necessary routines in a handler. This sort of leads to a situation where we repeat the same logic potentially in many handlers and sort of creating additional layer of sub handlers. If (and this is a big if) the api needs to handle many different mime types, we could end up with something like this:

endpoint1_handler_get(request){
switch(request.content_type){
case application/json: endpoint1_sub_handler_get_json
case application/xml endpoint1_sub_handler_get_xml
case application/form endpoint1_sub_handler_get_xml
}
}

endpoint2_handler_get(request){
switch(request.content_type){
case application/json: endpoint2_sub_handler_get_json
case application/xml endpoint2_sub_handler_get_xml
case application/form endpoint2_sub_handler_get_xml
}
}

It doesn't look nice, there is a lot of repetitions.

On top of that we esort of need to put all logic into the handler or risk being inefficient pariculalry if we could/should make a routing decision before we need to process the body.

An example here would be handling bodies which have been compressed. In this case it would be more beneficial to check the content type and respond with 406 Not Acceptable before de-compressing the body. Ideally, the whole process would be in a layer/service so the handler doesn't have to be aware of the process.

Another potential use case would be authorization. We would put authorization in a layer/service so the handler can get a principal etc. At the same time, we don't want to execute a costly authorization process if the handler doesn't know what to do with the body. In the existing version we can only make this decision in the handler where ideally we should be returning 406 Not Acceptable at the routing stage.

An alternative would be to move the routing by content type logic into the framework itself. And it is a tradeoff where we sacrifice simplicity of the framework and speed for user ergonomics and ease of use.

In the absence of data whether people are using endpoints with different mime types, it is hard to justify one approach over the other.

What would be nice though is to make routing more pluggable (similar to services/layers/middlewares) so it is easy for end user to plug their own or to select different routing policies based on their intended usage patterns.

dawid-nowak avatar Feb 07 '23 20:02 dawid-nowak

I've been thinking that you can use Handler::or from axum-extra to get something pretty similar to the top comment:

let app = Router::new().route(
    "/",
    get(with_content_type::<ApplictionJson, _>(json)
        .or(with_content_type::<TextPlain, _>(text))
        .or(fallback)),
);

async fn json() { println!("its json!") }
async fn text() { println!("its text!") }
async fn fallback() { println!("fallback") }

The code for that is here.

@dawid-nowak what do you think? It's not perfect but maybe it's better than having to roll your own? We could put it in axum-extra so users can try it out.

davidpdrsn avatar Mar 04 '23 15:03 davidpdrsn

It is a tradeoff but this approach could work. And it does remove the boilerplate.

I suppose my question would be whether you can use all other Axum goodies with this approach. For example not sure the below would work but I am guessing that is more the property of or operator.

get(with_content_type::<ApplictionJson, _>(json)
            .or(with_content_type::<TextPlain, _>(
text.layer(ConcurrencyLimitLayer::new(64))
))
            .or(fallback)) 

dawid-nowak avatar Mar 10 '23 20:03 dawid-nowak

I don't believe you can add middleware to handlers in an "or" because reasons :P you need to know which extractors something needs to know if one of them reject but middleware require the whole request.

Is that something you can work around, probably.

davidpdrsn avatar Mar 10 '23 21:03 davidpdrsn

Actually, there is another use case that sort of breaks it from the usability perspective :(

let app = Router::new().route(
        "/",
        get(with_content_type::<ApplictionJson, _>(json)
            .or(with_content_type::<TextPlain, _>(text))
            .or(fallback)).route_layer(ValidateRequestHeaderLayer::accept("application/json"))
    );

My understanding is, that In this case, we will validate for accept header and then route to json or text handler. But since the endpoint could be processing either text or json, I would expect that accept header could be either text or json.

It looks like the way to solve the problem would be to add another helper with_content_type_and_accept or perhaps more even more future proof it and provide something like with_headers(Map<Header, Vec<HeaderValues>>).

But even with this approach, things are suboptimal if we start using compression or authorization layers (or any other layer that takes long to execute before the handler).

In the case below, we are trying to de-compress payload even if we could infer at a routing phase that there is no handler do so:

post(with_content_type::<ApplictionJson, _>(json)
            .or(with_content_type::<TextPlain, _>(text))
            .or(fallback)).route_layer(RequestDecompressionLayer::new())

Would something like this perhaps work ?

let handler_builder = HandlerBuilder::new();
handler_builder = handler_builder.add_handler(json).with_content_type(ApplictionJson).with_accept(ApplicationJson).with_decompression().with_ccompression().with_authorization();
handler_builder = handler_builder.add_handler(text).with_content_type(TextPlain).with_accept(TextPlain).with_request_decompression().with_response_compression().with_authorization();
post(handler_builder.build())

dawid-nowak avatar Mar 11 '23 17:03 dawid-nowak

Well yes of course, if you wanna check multiple headers then you gotta do that. I just didn't do that in my example but the exact same technique works.

In the case below, we are trying to de-compress payload even if we could infer at a routing phase that there is no handler do so

I don't see the problem. Compressing "nothing" is basically free.

Would something like this perhaps work ?

You could make such an api I think so yes. or is implemented in axum-extra and doesn't require special treatment from axum. It just implements the Handler trait. So you could imagine other ways to do that.

davidpdrsn avatar Mar 11 '23 18:03 davidpdrsn

I don't see the problem. Compressing "nothing" is basically free.

We have a post with payload, we are going to de-compress the payload, and then we are going to realize that we have no handler to handle the content-type. Does it sound plausible or am i missing something ?

dawid-nowak avatar Mar 11 '23 18:03 dawid-nowak

Ah sorry. I thought you meant compress the response.

Decompression should work fine as well. Decompression doesn't do anything unless you actually read the body. It doesn't eagerly decompress it.

davidpdrsn avatar Mar 11 '23 18:03 davidpdrsn

Ahh, cool. Little bit out of topic, but will this work ? We want to de-compress request , do something in a handler and then compress response body. Is the order of route_layer relevant here?

post(json).route_layer(RequestDecompressionLayer::new()).route_layer(CompressionLayer::new())

dawid-nowak avatar Mar 11 '23 19:03 dawid-nowak

Nope the order shouldn't matter.

You can also use a tuple of layers (that's a layer as well)

post(json).route_layer((RequestDecompressionLayer::new(), CompressionLayer::new()))

davidpdrsn avatar Mar 11 '23 19:03 davidpdrsn

@dawid-nowak Why not have the handlers take an already-deserialised form of the request body regardless of content type, and put content type specific deserialisation code in an extractor? This way the code will live in only one place.

lecanard539 avatar May 04 '23 05:05 lecanard539

We could put it in axum-extra so users can try it out.

Yes, please! ❤️

... Actually, I'd want this for the Accept header, which I see has an interesting backstory. That may be a sign that the functionality may want to be a bit more composable, to allow for arbitrary headers.

shepmaster avatar Jun 26 '23 01:06 shepmaster

I also ran into this problem today. The client may initiate different content-types, and I need to decide whether to make a streaming response or a simple json response based on this content-type.

It seems impossible to accomplish this task with the same handler?

ttys3 avatar Aug 02 '23 18:08 ttys3

You can extract the headers with axum::http::HeaderMap and do whatever you need in the handler.

davidpdrsn avatar Aug 02 '23 19:08 davidpdrsn

For my case, I implemented FromRequestParts and can then do different things in my handler. It’s definitely possible to do today.

shepmaster avatar Aug 02 '23 20:08 shepmaster

I'd argue that exposing what the server accepts is important for hypermedia scenarios or other scenarios (as described here).

For a valid router to chose the correct handler based on complex Accept header rules (priorities and fallbacks like q=0.8), it maybe needs to know ahead of time of all the routes constraints, and not let the decision be made by just cascading failing handlers.

If you let handlers take care of this, will introspection still be possible to help generators like https://github.com/jakobhellermann/axum_openapi?

It could make sense to let the axum router handle this complexity. The same applies to other kinds of server-side negotiations, like Accept-Encoding or Accept-Language.

docteurklein avatar Nov 21 '23 12:11 docteurklein

If you let handlers take care of this, will introspection still be possible to help generators like https://github.com/jakobhellermann/axum_openapi?

Support for that could be built into the openapi library. I'm not sure this means it needs to be built into axum and even if it was the openapi lib would have to have code to handle such routes.

davidpdrsn avatar Nov 21 '23 12:11 davidpdrsn