-
Notifications
You must be signed in to change notification settings - Fork 18
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Where to put Guards? #45
Comments
After our short discussion in this issue and seeing another related issue in the Axum repository, I'm more and more becoming a fan of something I would call exact match based routing. I'm not sure if this is the best way to do it or if even it is feasible to implement this for us, but just wanted to share this here with you so we have it somewhere written down. The main idea is to have a routing model that can be easily reasoned about, without much ambiguity. At the same time provide composability and powerful middleware. This would be the rules: 1. Static before dynamic on the same nesting levelThe routing macro on the same level should follow the common pattern (in other frameworks) of sorting routes, so that first we try the "more static" ones. E.g. router! {
"/:name/:age" => h1,
"/test/34" => h2,
"/:name/34" => h3,
}) should be attempted to match in the order of:
2. No fallback for sub-routes / handlersOnce a sub-route or handler is taken it can't fall through anymore. The implications of this are:
In this case entering a sub-route would be irreversible (like a match statement) and it would be safe to call the middleware as we can't end up in another. The middleware would still be called, even we end up not matching any sub-routes and return 404. 3. Middleware, one method with a
|
I agree with this, though it might be a little more complicated than initially expected since subroutes can be kind of mixed up in the trie if I'm not mistaken.
I think it would simplify things if we didnt allow fallback/follow through routing when extractors fail (including
This could be useful, though I don't know if rewriting paths could lead to confusing behaviour.
I agree with this one! 💯
I'm not actually against this idea, but I do think extractors can be very appealing. I read through the issue axum has about tokio-rs/axum#1116, and I think that's indeed a problem we'd likely have to solve too if we stick with extractors. I wonder if we could let middleware handle the errors differently perhaps.
The biggest drawback I see with guards, is that you cannot use any of the data they might compute. For example parsing a json body or session object to check for a role. Whereas with middleware, we could do this and insert it in the request as an extension. router! {
"/admin" use IsRoleMiddleware("admin") => {
// ...
}
} With our current approach with extractors, middleware and guards, it seems like they overlap too much and guards arent very useful siunce both extractors and middleware can be used as guards. |
My position on this hasn't really changed because I think that just as you would add explicit matches in a So if you have a match expression this works: match name {
"bob" => handle_bob
user => handler_user
} But this doesn't: match name {
user => handler_user
"bob" => handle_bob
} and so I think since we're mimicking the match expression syntactically it makes sense to stay consistent with it's matching logic. So a case like this would already work now and stay consistent with the logic of router! {
"/test/34" => h2,
"/:name/34" => h3,
"/:name/:age" => h1,
}
What is the implication of this though? Because List Routers require fall-through, does this mean we get rid of list routers? I've been a fan of strict matching since the beginning but it limits our feature set while making the routing more obvious to the user.
I like this because it again mimics a
This won't be so simple I'm afraid because if we keep the two-function middleware trait we need to have an explicit call to fn mid1(&self, req: Request, next: NextFn) -> Response {
// ... do stuff before handler
let res = next(req);
// ... do stuff after handler
res
}
let ROUTER = router! {
use mid1;
GET "/hello" => hello
}
// what would this expand to?
let ROUTER = |req, reader, params| {
// execute middleware here?
mid1(req); // -> returns a response?
if reader.peek(6) == "/hello" {
reader.read(6);
return HandlerFn::handle(hello, ...);
}
} I hope this example explains my concerns and I think that we still need to collect middleware one way or another. I also don't know if the whole manipulating the path is a good idea. Handling dangling slashes isn't that hard, we already do it and we can add a config param to configure this behaviour globally later if users want this. I don't really see why you would want to manipulate the path. The handler should do any "mapping" of the request to the apps logic and the framework should not give mutable access to the path and params at all because that's exactly the "bad" part of middleware imo :)
I like it, but it has to fit into the big picture since there are concerns I mentioned above.
I don't mind this but I think there's some value in having a "cool" handler definition 🙂 I would suggest that since parsing is sometimes really repetitive and most of the time we just want to parse and fail with a 400 (or other) code if it failed for most of the app we could either keep extractors or add decorators that just the function with a parsing statement. It could look like this: #[swagger]
#[json]
fn hello_handler(Json(hello): Json<String>) -> Json<HelloYourself> {
// .. do only handler related logic
}
// =======================
// this expands into
fn hello_handler(req: Request) -> Response {
match Json::parse(request) {
Err(e) => {} // handle error in a default way
Ok(json) => {
let res_json = |Json(hello): Json<String>) -> Json<HelloYourSelf> {}(json);
Response::from(res_json)
}
} And the
Again I'm fine with this although it really gives it an even more "match" like feeling. It would have to fit it with all the other parts. |
We just need to take into account that Rust does a lot of hand holding here. For example this example will result in a warning of the compiler:
It would be hard for us to do this kind of analysis? Rust is also super strict. If you don't list all possible values, the code will not compile. This is necessary because people keep extending structures and adding new matching arms, so it's easy to accidentally miss something if you have 100+ routes and sub-routes. You add a new route, and now you can't reach another one a bit down, but when you test the new route everything works fine until you deploy to production. My reasoning here was, minimise the amount of accidental errors. The only reason, why someone would put a more specific route after a general one would be by accident. If we can't warn them during compile time ("look, this is never going to match"), the best next thing would be to reorder automatically the routes so that it matches a more specific one. However, if we can offer the same DX as Rust here, compile time warnings on impossible routes, this would be great and I don't mind keeping the current behaviour.
I think dangling slashes was a bad example. The general idea behind this was to trigger middleware without a match (e.g. requests to // Calls middleware Redirect
router! {
use Redirect;
"/test" => test,
_ => handler_404
}
// Doesn't call middleware Redirect?
router! {
use Redirect;
"/test" => test,
} Or we could always trigger top level middleware, even on 404? Also, the "first collect all middleware" approach moves us away a bit from the a match arm is irreversibly taken. Currently, my main goal is just to wrap my head around our rules. I like the current behaviour, but some parts of it are accidental and the result of some implementation details. So, I'm trying to "formalise" some of the rules and am thinking about edge cases and how we could reduce them. For example, it's hard for me to answer the question "When is the All this questions have different implications. Just to give an example: router! {
"/admin" if Admin => {
"/super_secret_url"
}
} If someone accesses Talking about it and hearing your preferred behaviour was already super helpful <3. I think the general consensus is, both the Would having two kinds of middleware be useful?
This would also communicate clearly to users what middleware can abort the request. |
After working with the library a few more days, I think we honestly shouldn't reorder anything, nor should we error if two routes match the same path. That's why I think the checkpointing implementation I added makes the most sense, since if a route return
The if part of a route is checked at the same time as checking the path. Eg Middleware runs when a route is fully matched and just about to be executed. It all gets aggregated to the very last moment. Though, they currently run before extractors, which makes sense I think. Middlewares & handlers have the ability to return fn update_process_local_state(req: Request, next: impl Next) -> Result<Response, RouteError> {
let value = PLS.get();
PLS.set(10);
let res = next(req);
if let Err(RouteError::RouteNotMatch(_)) = res {
// child handler wants to continue to next route,
// so we undo side effects in this middleware
PLS.set(value);
}
res
}
My opinion at this point would be that we remove if guards completely, and let middleware do that job since it can simply return |
Since I'm fresh back from my vacation I'm going to try and take a step back here :) So that would look something like this: {
"/users/:user_id/settings" if UserIsAdmin => admin_settings_handler
"/users/:user_id/settings" => user_settings_handler
} which is pretty much what we have already. And to be honest, even without if guards this is awesome and enough to start building cool projects, especially if you have the safety of each request to run in its own memory space and do async I/O out of the box without any async rust. First, we need to start with what kind of framework we want to build: something larger/bloated like Django, spring boot, .NET, Nest.JS, Laravel, Phoenix or a micro-framework like flask, express.js or sugar(elixir).
We talk a lot about extractors and middleware etc but we're really just trying to give the user a way to
And since we want routing to a be central feature to our "micro-framework" we have the liberty to ask ourselves some interesting questions like:
If, of course, we want to create a larger framework like Phoenix or Spring Boot we will need to treat routing as just one of the features and ask ourselves a different set of questions first. Because these frameworks are usually implemented with a certain way of developing in mind and mostly prioritise ease of development in a multi-developer setup where the business logic is growing so rapidly that devs require multiple "crutches" from the framework that help avoid or delay the implementation and invokation of a footgun 🔫 But they also help larger projects use less boilerplate code and provide good ways to do integration tests and handle multiple communication protocols and ways of interacting with a service, like AMQP, gRPC etc. So, in total there's quite a lot of things that a larger framework contains and I think that we should first settle for a |
This is a summary of today's discussion on discord. Feel free to add stuff if I left something out.
As @SquattingSocrates discovered, the main issue is that you usually want to add a custom response to a
Guard
(e.g.Permission denied
) and this is currently not possible. This also sparked a bigger discussion, what is actually a Guard and do we need an explicitif
syntax for it?There are 3 places a guard can live:
1. Extractors as Guards
We are using Rocket and Axum inspired extractors in handlers, this means that we can turn each extractor into a
Guard
by returning a "rejection" value that implementsIntoResponse
. So we have already a way of expressing a "Guard" on the handler level.In my opinion, this is not always that great, especially if you want to guard a whole sub-route:
If someone else on your team adds a handler "c", they need to remember to add an explicit guard to it or this might become a security issue.
2. Middleware as Guards
Another place where we can add guards is middleware. Middleware wrap handlers and they could be used to short-circuit a response:
that way we give middleware more power, but simplify the mental model around it. You don't have anymore two different concepts Guards and Middleware. I think this simplifies the internals, because we don't need a dedicated
if
syntax. And developers are already used to using middleware that way in other web frameworks.3. Explicit Guard syntax
The last option is an explicit guard, but with the option to return custom responses:
The biggest benefit here is that the guarding part is explicit. Opposite of middleware, where some middleware could act as a guard but other could just be doing some logging.
Early returns
The question mostly boils down to, do we want only one way to end a request early (guard the handler)? Or do we want to be able to do it from multiple places. One extreme would be to allow each part to return an early response (middleware, extractor and guard). On the other side we could for example only allow middleware to early return, remove
if
guards and remove custom responses from extractors. Of course, any in-between combination of these 3 would be an option too.The text was updated successfully, but these errors were encountered: