Open richardanaya opened 6 years ago
A similar idea is also proposed in #12.
What I said there is just my personal opinion, that I prefer staying away from this style, as I start to feel like the macros hide too many concepts, and make it harder for when people need something the macro cannot do. But if there's enough demand, I could be overruled :)
Interesting! I agree that i think #12 hides the and
s and or
s too much. I think I like the emphasis in my proposal of reducing repetition. What do you think about something like.
let helloWorldRoute = and!(
path!("hello"/String),
warp::header("blah"),
warp::body::json()
).map(|name,blahHeader,body| { ... })
let routes = or!(
helloWorldRoute,
path!("foo").map(|name|{ "bar" }),
path!("abc").map(|name|{ "123" })
)
I think that sort of thing is probably more reasonable. I'd like to ask first, though, what is the goal? That may help influence the direction of improving things. Is the goal to use macros specifically? Is the writing or reading of a.and(b)
bad?
To make a record of a slightly different idea I've tossed around: it might be interesting for filters to implement operators, like BitAnd
and BitOr
. It'd look like this:
let hello_world = (path!("hello" / String) & header("blah") & body::json());
let routes = hello_world | math_sum | foo_bar;
A difficulty in doing this is that coherence doesn't allow a impl<F1: Filter, F2: Filter> BitOr<F2> for F1
, since one crate could technically implement Filter
for a type (the compiler doesn't realize the trait is sealed), and another crate could implement BitOr
, and then they'd conflict. To overcome this difficulty, some sort of macro could probably be used internally to make sure all exported filters and combinators implement the operator traits.
For me, I think the goal is readability. While I agree the fluent interface you have going has little "magic" going on. It appears to me that there is a pattern that is going to go on again and again.
My assumption is that 1. will be just some really high level chain of and
ed filters
My assumption is is that 2. will just be some really long list of or
ed filters
My assumption is that most of these sub-endpoint specific filters will just be a chain of and
ed filters chained together by or
( as in the case of a REST endpoint that may have several branches)
Anyhow, what i'm observing is chains of and/or all over the place, and if its going to be such a common idiom, perhaps it could be of value reducing that repetition.
I'm not sure operators would be the best idea personally. My gut feel is that people are so trained on boolean logic that they might be more confused than help. It is interesting though ... =)
I am very much in favor of the &
and |
operators to combine filters. I looks very clean.
I think it is a great fit since intuitively these operators work on predicates that resolve to boolean values, which is pretty much what filters are.
@seanmonstar Here's my idea for a route macro/DSL. The macro should (in theory) not hide the logic of filters (and, or, and_then, etc.) too much and only act as syntax sugar. In fact, we just map the methods to operators and add a few more to make things run smoothly, and as such, there is no hiding of concepts. This syntax very heavily leans towards the concise side of things, and as such uses a lot of symbols and operators. Implementing this would require proc-macro-hack which require rust 1.30+. This shouldn't be an issue as tokio only supports rust 1.32+ as of writing since the latest is 1.35. This is very long, and I seriously hope you take the time to consider it.
The idea is to replace all the methods in Filter
with some operator (operators will do other things too).
These operators should be concise and might look something like this:
a.and(b): a * b, a & b
a.and_then(b): a -> b
a.map(b): a ->m b
a.or(b): a | b, a + b
a.or_else(b): a ->e b
a.recover(b): a ->r b
a.anything_else(b)`: a <- anything_else(b)
The idea is that the operators remind the developer of what they do and they are short. All operators have the same priority and should be evaluated left to right.
or
As for the or
method, there should be 2 operators, +
and |
. The |
operator should have the same priority as the other operators, whereas the +
operator ought to have lower priority. The rationale for this is that we use or "normally" like so
header("x-real-ip").or(header("x-forwarded-for")).unify()
where we would want everything to be in the order that we write it in or we use it to specify different paths like so
path!(u32)
.map(|x| x + 1)
.and_then(test)
.or(path!("test")
.map(reply));
where we're doing lots of work inside the or statement.
The use of +
is also very natural considering how multiplication comes before addition.
and
operatorsThis raises an interesting question about what to do with the and method. After careful consideration, I think the &
operator ought to have lower priority than *
. Notice how this is the reverse of the or operators. The priority of the operators would be * |
and then & +
where *
and |
have the same priority as all the other operators (we'll call these the chaining operators), and & and + are lower.
First, we need an and
and an or
operator that are the same priority at the highest level. Grouping * +
together would definitely be a bad idea, as it breaks the intuition that multiplication comes before addition. As such, we're left choosing between * |
and & +
as our chaining operators. I simply think * |
looks prettier.
Second, we have to decide whether the other 2 operators will have higher or lower priority than our chaining operators. Since we need a lower priority one for or
, we should choose lower priority.
;
operatorI'm really not sure what to name this, so let's call it the block operator. The block operator is meant to make sure everything before it is evaluated before whatever comes after. I think this is best explained with an example.
path!(u32) & json::<Test>() * get2(); -> some_async_func
should evaluate to
path!(u32).and(
json::<Test>()
.and(get2())
)
.and_then(some_async_func)
Here, we reach &
, evaluate what's after it until we reach another operator of low priority or the break operator, and then evaluate, and then chain everything afterwards. If this hurts your brain like it does mine, just think of it like this:
path!(u32) * (json::<Test>() * get2()) -> some_async_func
So why provide this in the first place if we can just write it like directly above? Because multi line expressions with operators and parenthesis is weird in ways that multi line expressions with methods isn't. Imagine that the statement were bigger and we needed to break it up into multiple lines; how would we do that?
(long and ugly and difficult to read)
path!(u32) *
(json::<Test>() *
get2() * filter2 *
(header("x-real-ip") |
header("x-forwarded-for")
<- unify()
)
) ->m
|test, ip| test.some().long().function().chain(ip)
-> some_async_func
(longer, but less ugly and less difficult)
path!(u32) * {
json::<Test>() *
get2() * filter2 * {
header("x-real-ip") |
header("x-forwarded-for")
<- unify()
}
}
} ->m |test, ip|
test.some().long().function().chain(ip)
-> some_async_func
But this is better. It's shorter, easier to write, and just as clear since the indentation shows how everything is nested. (Just don't forget your semicolon!)
path!(u32) * {
json::<Test>() *
get2() * filter2 &
header("x-real-ip") |
header("x-forwarded-for")
<- unify();
} ->m |test, ip|
test.some().long().function().chain(ip)
-> some_async_func
=>
operatorConsider the following requests:
GET /api/id/32/test, body: Json<Test>, cookie: login=login_token
GET /api/id/45/other, body: Json<Other>, cookie: login=login_token
These requests have a lot in common: they're both get requests, they both go to /api/id/{u32}
, and have a login token stored as a session cookie. The way we'd currently write this is as follows:
let base = get2().and(path!("api"/"id"/u32)).and(/* ... */);
base
.and(path!("test"))
.and(json::<Test>())
.and_then(/*do stuff*/)
.or(base
.and(path!("other"))
.and(json::<Other>())
.and_then(/*do stuff*/));
Obviously, we'd want a shorthand for that in the macro. So here's what that might look like:
get2() * path!("api"/"id"/u32) * /* ... */ => [
path!("test") * json::<Test>() -> /*do stuff*/,
path!("other") * json::<Other>() -> /*do stuff*/
]
path!(u32) * json::<Test>() => [
path!("a") * get2() ->m |n, t| t.number -> |num| num + 1,
path!("b") * post2() -> some_async_func,
(header("x-real-ip") | header("x-forwarded-for") <- unify())
-> |ip: SocketAddr| { // Notice how natural indentation is here with operators
// ...
}
] +
path!("hello") => [
get() ->m reply(),
post2() -> async || { // async await isn't stablized yet, but doesn't this look so cool?!
Ok(serde_json::from_slice(&read!("./something.json").await?)?)
}
]; <- with(log("example")) // use of ; to append with at the very end on the outside
Desugared (oh gosh):
let a = path!(u32).and(json::<Test>());
let a = a.and(path!("a")).and(get2())
.map(|n, t| t.number + n)
.map(|num| num + 1)
.or(a
.and(path!(b))
.and(post2())
.and_then(some_async_func))
.or(a
.and(header("x-real-ip")
.or(header("x-forwarded-for"))
.unify())
.map(|ip: SocketAddr| { /* ... */ });
let b = path!("hello")
.and(get)
.map(reply())
.or(post2()
.and_then(async || { // async await isn't stablized yet, but doesn't this look so cool?!
Ok(serde_json::from_slice(&read!("./something.json").await?)?)
}));
a.or(b).with(log("example"))
I've currently got a lot of free time on my hands, so if you think this is a good idea and will accept a PR, ill implement it!
This looks very interesting, what's holding this back? is it perhaps too complex, or is there som compatibility issue? I can't even tell how you would make the whole -> * & stuff work, if there's an example on how it's done i would love to see it.
I was looking over the routing example, and a part of me was wondering if there might be some standardized macros that could help with very common filter patterns. An example:
routes! would
.or(...)
a chain of filtersroute! would
.and(...)
a chain of filters and.map(...)
to the last param functionPlease keep in mind i'm a Rust noob so I might be missing some capabilities of macros right now :)