Elm-url-codec: Define both URL parser and builder at once

Hello!

TL;DR: Here are two new modules:

  • Url.SimpleParser as a more featureful and simpler to understand replacement to Url.Parser
  • Url.Codec for defining both URL parser and builder at the same time (at the cost of some boilerplate)

https://package.elm-lang.org/packages/Janiczek/elm-url-codec/latest/

Backstory

Recently two events came together and made me publish a package :see_no_evil:

:one: The first one was a few people asking about the elm/url's Parser type on the Elm Slack – more specifically, why is it confusing and how does it work, how to think about the type arguments etc.

:two: The second one was that I had to write a Route module with its typical toString and fromUrl functions for a toy application yet again, and after implementing the parser I just couldn’t be bothered duplicating that logic for the Route -> String function. Feeling that this can get out of sync over time, I started wanting some different solution. “Surely there must be a better way!”

So I took a look. Could we get :one: clearer type signatures and :two: single source of truth in this problem domain?

In my mind an applicative API was a nice goal to strive for:

parser : Parser Route
parser =
  succeed UserRoute
    |> s "user"
    |> string 

alongside a single-argument Parser type (compared to the Parser (a -> b) b state of the art).

In my first attempt I tried constructing an AST out of that applicative call tree, and then interpreting it in two ways (URL parsing and URL building), but for various reasons that didn’t pan out.

But when I gave up on an AST and went for the all-powerful “just compose functions straight away” approach, I’ve succeeded! :tada:

I present to you two modules:

  • Url.SimpleParser as a more featureful and simpler to understand replacement to Url.Parser
  • Url.Codec for defining both URL parser and builder at the same time (at the cost of some boilerplate)

In the end the usage looks like:

blogParser : Parser Route
blogParser =
    Parser.succeed (\id page tags -> Blog id { page = page, tags = tags })
        |> Parser.s "blog"
        |> Parser.int
        |> Parser.queryInt "page"
        |> Parser.queryStrings "tags"

Url.SimpleParser.parsePath [blogParser] "/blog/123?page=3&tags=foo&tags=elm"
--> Ok (Blog 123 { page = Just 3, tags = ["foo", "elm"] })

and in case of Url.Codec:

blogCodec : Codec Route
blogCodec =
    Codec.succeed (\id page tags -> Blog id { page = page, tags = tags }) isBlogRoute
        |> Codec.s "blog"
        |> Codec.int getBlogId
        |> Codec.queryInt "page" getBlogPage
        |> Codec.queryStrings "tags" getBlogTags

Url.Codec.parsePath [blogCodec] "/blog/123?page=3&tags=foo&tags=elm"
--> Ok (Blog 123 { page = Just 3, tags = ["foo", "elm"] })

Url.Codec.toString [blogCodec] (Blog 999 { page = Nothing, tags = ["hello"] })
--> Just "/blog/999?tags=hello"

FAQ

How are these more featureful?

  • In addition to what elm/url supports, they support query parameters of the type /hello?admin&no-exports – something I call “query flags”; parameters that don’t have an = sign.

How is Url.SimpleParser simpler?

  • The parser will have a type signature Parser Route when finished and eg. Parser (String -> Route) when not finished. Compare to elm/url's Parser (String -> a) a.

What is the boilerplate cost of Url.Codec?

  • You’ll need to define predicates like
isUserRoute : Route -> Bool
isUserRoute route =
    case route of
        User _ ->
            True

        _ ->
            False

and getters like

getUserId : Route -> Maybe String
getUserId route =
    case route of
        User id ->
            Just id

        _ ->
            Nothing

I believe it would be possible to create an elm-review codegen rule that would create these on demand, following the example of lue-bird/elm-review-missing-record-field-lens. Until then, to work with Url.Codec you’ll need to write these yourself.

Links

For more fleshed-out examples, take a look at:

And here’s the package itself :slight_smile:

https://package.elm-lang.org/packages/Janiczek/elm-url-codec/latest/

23 Likes

Very nice work. Thanks.
I like that you pass a list here instead of having to use oneOf

1 Like

This is great. Very happy to be able to define a single source of truth for url building and parsing. It always bothered me that something like this didn’t exist and I felt like maybe I was missing something or doing something wrong because of it. You’ve filled an annoying gap here!

1 Like

This is great. Just looking at this example, it’s so simple and straight-forward, that my current way of parsing paths looks cryptic.

Thank you for the effort you put into this.

1 Like

Hi,
the codec is very needed, sometimes I end up using JSON in URL using miniBill/elm-codec.
And there is my confusion around API. What about just copy most of the elm-codec API?

  • use oneOf instead of list of codecs?
  • can we somehow get rid of CodecInProgress? and use for example custom, variantN?

Thanks for bringing this to the table.

Hey there @pravdomil :slight_smile: I’ve weighed those options when designing this library, and arrived at what I have.

oneOf in particular is not compatible with how the library internally works, without doing another layer of indirection to hold a list of SingleCodecs inside the user-facing Codec. I think having the list explicit is fine for the intended usecase.

What I’m more worried about is lack of map and andMap - I reckon we’ll see with time whether people find situations where those are needed

Hmm, one thing that seems to be missing without oneOf is how do you write codecs for nested routes like this?

type Route
  = Start
  | Admin AdminRoute

type AdminRoute
  = AdminCat CatId
  | AdminDog DogId

Or would you just like “flatten” it somehow maybe?

Thanks for this snippet! True, there is no way to build codecs out of smaller codecs. Currently you’d have to flatten all the options into one list:

allCodecs =
  [ startCodec
  , adminCatCodec
  , adminDogCodec
  ]

One other thing that @SiriusStarr mentioned on Slack is that it would be nice to have a way to fail/validate/andThen these, eg. when you need to only retain UUIDs, not just any strings. I’ll let it marinate in my head for a bit, I think we could do this with some kind of (a -> Result err b) function, without resorting to the fullblown monad.

1 Like