seanmonstar / warp

A super-easy, composable, web server framework for warp speeds.
https://seanmonstar.com/post/176530511587/warp
MIT License
9.55k stars 718 forks source link

Idea: helper macros #26

Open richardanaya opened 6 years ago

richardanaya commented 6 years ago

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:

fn main() {
    let routes = routes!{
        route!{
            warp::index(),
            ||{
                "Welcome!"
            }
        },
        route!{
            path!("hello"/String),
            |name|{
                format!("Hello {}",name)
            }
        }
        route!{
            warp::any(),
            ||{
                "How'd you get here?"
            }
        }
    };

    warp::serve(routes)
        .run(([0, 0, 0, 0], 3030));
}

routes! would .or(...) a chain of filters

route! would .and(...) a chain of filters and .map(...) to the last param function

Please keep in mind i'm a Rust noob so I might be missing some capabilities of macros right now :)

seanmonstar commented 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 :)

richardanaya commented 6 years ago

Interesting! I agree that i think #12 hides the ands and ors 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" })
)
seanmonstar commented 6 years ago

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.

richardanaya commented 6 years ago

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.

  1. create a top level filter, perhaps for auth, logging etc.
  2. create several filters that will reflect the main route endpoints by path ( /endpoint/myResource )
  3. per endpoint, add even more specific filters depending on if it returns html, acts as a REST endpoint, etc.

My assumption is that 1. will be just some really high level chain of anded filters

My assumption is is that 2. will just be some really long list of ored filters

My assumption is that most of these sub-endpoint specific filters will just be a chain of anded 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 ... =)

obust commented 6 years ago

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.

StevenDoesStuffs commented 5 years ago

@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.

Description:

Overview

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.

Exploring 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.

The and operators

This 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.

The ; operator

I'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

The => operator

Consider 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*/
]

Example

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!

Zerowalker commented 3 years ago

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.