As we've brought up a Twirp-based API, we've found a need to convert
between several different "views" of the same data, such as position
or span information. Because all protobuf fields are optional, we have
to juggle the `Maybe` values associated with the protobuf fields that
we are converting. While I think this approach has merit, there is a
complexity overhead associated with these conversions: we currently
have around ten ad-hoc functions that perform these conversions, often
containing superfluous `Maybe`s for the sake of convenience.
I've replaced these ad-hoc functions with two classes: `APIBridge` and
`APIConvert`. An instance of `APIBridge` between types `a` and `b`
means that we can convert between `a` and `b` and vice versa
losslessly; in other words, there is an isomorphism between them.
`APIConvert` means that you can convert from an `a` to a `b`, but you
may not be able to convert from all `b`s to an `a` (such as in the
case of missing fields); in other words, there is a partial isomorphism.
These are implemented with concepts from `lens`, namely an `Iso` for
`APIBridge` and a `Prism` for `APIConvert`.
Advantages of this approach:
* Lawful API. We can now clearly delineate the fact that converting a
native data type to an API data type always succeeds, but the
reverse may fail: an API `Span` may have missing position
information, and we want to handle that explicitly, rather than
paper over it with these helper functions. Both the APIBridge and
APIConvert typeclasses provide a set of strong laws re. behavior,
since they provide a lens-y interface.
* Unified API. No longer do we have to juggle a set of functions in
our heads - no need to choose between `spanToSpan`,
`spanToLegacySpan`, or `apiSpanToSpan`. `converting` and `bridging`
do the work for you. Everything is much cleaner.
* Fewer partial functions. The converter from API blob pairs to native
blob pairs no longer calls `error`, which is definitely a good
thing.
* Historical precedent. Prisms and isomorphisms are a fluent way to
express data transformations; the team behind Minecraft uses
isomorphisms and prisms [to transfer data between versions][minecraft].
Disadvantages:
* Complexity overhead. You have to learn about prisms, reviews,
isomorphisms, neither of which is the world's hardest concept but
which take a little while to internalize.
* This might be polymorphism for polymorphism's sake.
Something we could do is postpone this patch until I have a chance to
give a lens tutorial in a Codex.
[minecraft]: https://github.com/Mojang/DataFixerUpper
Now that we're on GHC 8.6, we can use `-XDerivingVia` in many cases
where we previously had to write instances by hand. If you're not
familiar with `-XDerivingVia`, the [GHC proposal][ghc] is a good place
to start.
[ghc]: https://github.com/ghc-proposals/ghc-proposals/blob/master/proposals/0023-deriving-via.rst
Thanks to the `generic-monoid` package, we can derive a `Semigroup`
instance for any product type whose members are `Semigroups`, and the
same goes for `Monoid`. This entails an extra dependency, but it is
better than the `generic-deriving` package, which is way too much overhead.
I've also switched some trivial definitions to newtype-deriving.
Please be aware that this bumps `hlint` and `haskell-src-exts` so that
`hlint` doesn't choke on the `DerivingVia` extension. You'll need to
`stack install hlint` to get it on your `PATH`. Apologies!