haskell-servant / servant

Servat is a Haskell DSL for describing, serving, querying, mocking, documenting web applications and more!
https://docs.servant.dev/
1.82k stars 411 forks source link

Improved composability for Generic APIs #1315

Open gdeest opened 4 years ago

gdeest commented 4 years ago

There has been complaints in the past regarding the usability of APIs defined via nested generic products (see #1211 for example).

The problem with generic APIs is that one constantly needs to switch back and forth between generic and “vanilla” servant, at both term- and type-level. This is not an issue for flat APIs that correspond to a tuple of routes, but quickly becomes cumbersome when dealing with deeply nested APIs.

It occurred to me that the conversion machinery could be entirely hidden from the user, by allowing direct embedding of generic APIs via a new Servant combinator:

data GenericAPI (routes :: * -> *)

For which suitable instances could be defined, directly declaring clients and servers for GenericApi MyRoutes as records:

instance ( … ) => HasServer (GenericApi routes) context where
  type ServerT (GenericApi routes) m = routes (AsServerT m)
  …

instance ( … ) => HasClient m (GenericApi routes) where
  type Client m (GenericApi routes) = routes (AsClientT m)
  …

I have taken a stab at it in this gist. Please disregard the quality of the code, this has been thrown together in a hurry :)

There is some ugly machinery there, but it works fine with servers. I haven't been able to write the hoistClientMonad function for clients though (it looks like we would need additional constraints on the monad type constructors for this to work), but clients in vanilla ClientM should work.

Do you think this idea is worth pursuing ? Has this been tried before, or this somehow in violation with the Servant philosophy ?

jkarni commented 4 years ago

I think this is a really great idea!

(it looks like we would need additional constraints on the monad type constructors for this to work),

If you mean a RunClient constraint, I don't think we can have it - if we have it, we don't need the method at all! I'm not really sure what to do about that method though.

gdeest commented 4 years ago

If you mean a RunClient constraint, I don't think we can have it - if we have it, we don't need the method at all!

That makes sense.

I'm not really sure what to do about that method though.

Do you mean that removing hoistClientMonad is under consideration ?

Alternatively, we might be able to derive this function automatically with datatype-generic programming. I haven't given it much thought yet.

jkarni commented 4 years ago

Do you mean that removing hoistClientMonad is under consideration ? I'm no longer sufficiently involved with servant to be making such big decisions!

But what about changing the type of hoistClientMonad to have the constraints?

hoistClientMonad :: RunClient mon' => Proxy m -> Proxy api -> (forall x. mon x -> mon' x) -> Client mon api -> Client mon' api 

(I don't think we need the constraint on RunClient mon for anything?).

gdeest commented 4 years ago

Hmmm, I'll check if that works.

gdeest commented 4 years ago

It seems to work just fine, I'll prepare a PR. Thanks for the suggestion !

jkarni commented 4 years ago

One thing that seems like it'll be trouble is the forall context here:

  dict :: forall context m. Dict (GServerConstraints routes context m)

Some combinators (such as servant-multipart and, soon most combinators), require specific contexts. I think just changing the class to:

class GServer (routes :: Type -> Type) context where
  dict :: forall m. Dict (GServerConstraints routes context m)
  default dict ::
    GServerConstraints routes context m =>
    Dict (GServerConstraints routes context m)
  dict = Dict

works

gdeest commented 3 years ago

@jkarni Would you consider giving your opinion on #1388 ? I am not sure that you are still a servant maintainer, but since your interest was picked by this idea, I would appreciate your feedback.