midas-framework / midas

A framework for Gleam, Midas makes shiny things.
https://hex.pm/packages/midas
170 stars 5 forks source link

Routing/Endpoint DSL #19

Open CrowdHailer opened 4 years ago

CrowdHailer commented 4 years ago

I think there is value in always having a simple interface to the server which consists of a single request-> response function.

However there is a lot of boiler plate in writing API's so what would a DSL look like.

Routing Tree suggestion

  Match(
    "users",
    [
      Get(fn() { "All users" }),
      Param(
        string([MinLength(14), MaxLength(14)]),
        [
          Get(fn(user_id) { "user 1" }),
          Match("update", [Post(fn(user_id) { "update user 1" })]),
          Match(
            "posts",
            [
              Param(
                integer([Max(999)]),
                [
                  QueryParam(
                    "published",
                    boolean,
                    [
                      Get(fn(user_id: String) {fn(post_id: Int) { fn(published: Bool) { todo } } } ),
                    ],
                  ),
                ],
              ),
            ],
          ),
        ],
      ),
    ],
  )

Key feature of this one is every new extraction, e.g. Param/QueryParam takes a list of next steps, so branching can be introduced at any point, this reduces duplication.

List of endpoints suggestion.

  Choice(
    [
      Segment("users", Get(fn() { "All users" })),
      Segment("users", Param(string([MinLength(14), MaxLength(14)], Get(fn(user_id) { "user 1" })))),
      Segment("users", Param(string([MinLength(14), MaxLength(14)], Match("update", Post(fn(user_id) { "update user 1" }))))),
      Segment("users", Param(string([MinLength(14), MaxLength(14)], Match("posts", Param(integer([Max(999)]), QueryParam("published", boolean, Get(fn(user_id: String) {fn(post_id: Int) { fn(published: Bool) { todo } } } ))))))),
    ],
  )

Formatter makes mess of above but assuming you reuse user_id declaration and handlers are defined elsewhere, routing can be simplified

  user_id = string([MinLength(14), MaxLength(14)]
  post_id = integer([Max(999)])
  get_user = fn(user_id) { "user 1" }
  update_user = fn(user_id) { "update user 1" }
  get_published_posts = fn(user_id: String) {fn(post_id: Int) { fn(published: Bool) { todo } } } 

  Choice(
    [
      Segment("users", Get(get_users)),
      Segment("users", Param(user_id, Get(get_user)))),
      Segment("users", Param(user_id, Match("update", Post(update_user))))),
      Segment("users", Param(user_id, Match("posts", Param(post_id, QueryParam("published", boolean, Get(get_published_posts))))))),
    ],
  )

Need to use an explicit Choice type when more than one route is available. Has more duplication but the behaviour of reach route is clearer.

Notes

I think it's possible to have controller files.

//get_posts.gleam
fn handle(user_id: String, post_id: Int, published: Bool) {
  todo
}

fn endpoint(){
  Segment("users", Param(user_id, Match("posts", Param(post_id, QueryParam("published", boolean, Get(curry3(handle)))))))
}

// router.gleam
import my_app/get_posts

Choice[
  get_posts.endpoint
]

In summary the DSL is a bunch of error composition the second option (particularly if grouped by controller) might as well be the clear fn call approach.

  fn get_published_posts(){
      try tuple(user_id, post_id) = web.match(request, Segment("users", Param(uuid, Segment("posts", Param(int, Done(tuple2))))))
      try published = web.query_param(request, "published", integer([Max(999)]))
  }

Can have a Response(serializer: fn(x) -> r, Get(return x)) // Could serialize just OK value

The best thing to do is to practise a parsing API on a smaller section of the problem, e.g. form/query params, where all raw values are strings, entries come as a list not map, etc. And then expand it to requests if working well

  [
    Required("blah", int),
    Optional()
    Many("foo", int, [])
  ]
nono commented 4 years ago

I don't have a lot of experiences with functional programming, but the router makes me think to pattern matching:

// users.gleam
import midas

fn list() -> midas.Response {
  // TODO return a midas.Response with the list of users
}

fn get_post(user_id: String, post_id: String) -> midas.Response {
  let published = midas.QueryParam(req, "published")
  // TODO return a midas.Response with the given post

pub fn router(req: midas.Request, path: List(String)) -> midas.Response {
  let is_user_id = midas.String([midas.MinLength(14), midas.MaxLength(14)])
  let is_post_id = midas.Integer([midas.Max(999)])
  case [req.Verb, path] {
    [midas.Get, []] -> list(req, res)
    [midas.Get, [user_id, "posts", post_id]] if is_user_id(user_id) && is_post_id(post_id) -> get_post(user_id, post_id)
    _ -> midas.error404(req)
  }
}

// router.gleam
import midas
import my_app/users

pub fn router(req: midas.Request) -> midas.Response {
  case req.SplittedPath {
    ["users" | rest] -> users.router(req, rest)
    _ -> midas.error404(req)
  }
}

A limitation is that I don't know how to make post_id an Int (and not just a String to parse).

Maybe it can give you some ideas.

Update: I've read again the README, and I feel stupid to suggest what is already there. Let's just say that I prefer what is in the README to the other suggestions.

CrowdHailer commented 4 years ago

The pattern matching solution is nice, it's certainly the most simple. You hit the nail on the head about the integers though. The DSL suggested here gives you more type safety but potentially not worth the overhead.

As mentioned, it can exist as an experimental API for a while because the option of a simple case statement and matching will always exist.