midas
midas copied to clipboard
Routing/Endpoint DSL
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
- To implement both require handlers as curried functions. it's probably possible to have a curry helper but I think they would need to be the function
curry2curry3etc. - Does the user need to be able to specify the difference between, this didn't match check next route vs this didn't match return an error. My opinion is it would be ok to have defaults for this. i.e. Segmenet match failure will look at next branch, Header failure doesnt
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, [])
]
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.
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.