Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Generic client #640

Merged
merged 10 commits into from
Jan 17, 2017
Merged

Generic client #640

merged 10 commits into from
Jan 17, 2017

Conversation

fierce-katie
Copy link
Contributor

This PR is related to #344 and adds the class that matches client functions generated using client with data structure (its representation as sum of products).
The structure itself has to be written manually (no TH used here), but we avoid pattern-matching, which is replaced with just one line of code.

Copy link
Member

@jkarni jkarni left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is really cool! Thanks a lot for it!

--
-- Example:
--
-- type API
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you make this a code section (start each line with >)?

-- type API
-- = "foo" :> Capture "x" Int :> Get '[JSON] Int
-- :<|> "bar" :> QueryParam "a" Char :> QueryParam "b" String :> Post '[JSON] [Int]
-- :<|> Captre "nested" Int :> NestedAPI
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Captre -> Capture.

-- } deriving GHC.Generic
--
-- instance Generic.SOP.Generic
-- instance (Client NestedAPI ~ client) => ClientLike client NestedAPI
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the second NestedAPI should be NestedClient

-- mkAPIClient :: APIClient
-- mkAPIClient = mkClient (client (Proxy :: Proxy API))
class ClientLike client custom where
mkClient :: client -> custom
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It make be that I'm missing something, but if we had:

class (HasClient api) => ClientLike api custom where
   mkClient :: Client client -> custom

Then wouldn't we be able to say:

instance ClientLike NestedAPI NestedClient

Instead of:

instance (Client NestedAPI ~ client) => ClientLike client NestedClient

And maybe even get away with:

data NestedClient = ... deriving (ClientLike NestedAPI)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, GHC simply does not allow type family synonym instances (even with TypeSynonymInstances enabled). So we work around that with type equality constraint. Since in practice client structure (e.g. NestedClient) will only have one instance, I don't see this as a problem (only as an inconvenience).
I am not sure why that's not allowed, but I didn't investigate it.

We have also explored the possibility of having ClientLike parametrized with an API type rather than client type. But that leads to many more problems (e.g. you now have to know API type for every recursing mkClient call and also have separate instances for API combinators).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair enough - thanks for the explanation!

@fizruk
Copy link
Member

fizruk commented Nov 21, 2016

A minor warning: for some APIs this might cause GHC panic (@fierce-katie can provide the error message). We've noticed this when tried to refactor our API a bit and haven't yet come up with a reason/solution (might be unrelated).

@fierce-katie
Copy link
Contributor Author

The error is:

ghc: panic! (the 'impossible' happened)
  (GHC version 8.0.1 for x86_64-unknown-linux):
	pprIfaceCo

Please report this as a GHC bug:  http://www.haskell.org/ghc/reportabug

It may appear if you have a more complicated client structure.

@jkarni
Copy link
Member

jkarni commented Nov 21, 2016

Yeah, that's a bug I've been hitting in a different project too. Presumably you're using stack? I don't think it's triggered without ddump-hi (which stack turns out by default).

GHC 8.0.2 has the fix, but it hasn't been released yet. If it's only triggered in client code that uses mkClient, in my opinion that's okay.

@alpmestan
Copy link
Contributor

That's terrific! In my opinion this is even blog-post worthy (either on servant's or on your own).

@fierce-katie
Copy link
Contributor Author

Also, I played with TH trying to generate client structures (or at least separate functions) declarations, as proposed in the mentioned issue, but encountered a problem getting TH types representations. TH does not expand type synonyms, so runQ [t| Client API|] results in an obvious AppT (ConT Client) (ConT API). There's th-expand-syns, but it doesn't support type families now.
My current code is very awkward and error-prone, but if I come up with something better, this could be in a separate PR and most probably be an Experimental module. Any suggestions are highly appreciated :)

-- >
-- > mkAPIClient :: APIClient
-- > mkAPIClient = mkClient (client (Proxy :: Proxy API))
class ClientLike client custom where
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd add a note why there isn't | client -> xs functional dependency.

With very quick reasoning

type family FlatClient client where
  FlatClient (a :<|> b)  = FlatClient a ++ FlatClient b
  FlatClient (a -> b)    = Map ((->) a) (FlatClient b)
  FlatClient (ClientM a) = '[ClientM a]

or alternative single-type-class definition could work too. That relies on the fact that atm there is always a type constructor to match on.

I have used type level ++ and Map successfully.


Haven't noticed that this PR doesn't flatten the client. That might be useful too. Anyhow, might be a good idea to mention the fact in the documentation / blog post.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd add a note why there isn't | client -> xs functional dependency.

Sure. I would say that there is more that one way to turn Client api into another data type.

One example we're working on is for APIs like (a :<|> b) :<|> c (note the parentheses!). Currently this code will only match a client for this API with a structure like this:

data TopClient = TopClient
  { mkNestedClient :: NestedClient
  , getC :: ... }

data NestedClient = NestedClient
  { getA :: ...
  , getB :: ... }

In practice sometimes it is reasonable to embed NestedClient in TopClient:

data TopClient = TopClient
  { getA :: ...
  , getB :: ...
  , getC :: ... }

But I'm not sure how to allow both structures.

Haven't noticed that this PR doesn't flatten the client. That might be useful too.

Sure, that can be handy too. But that could be a different transformation that can be combined with an approach shown here.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had also been thinking about flattening clients, but I agree that we can add that later - it seems to be just a type family away.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jkarni it's a class with type family — you have to flatten on term-level too.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why? Shouldn't

type family Flatten client where ...
mkAPIClient = mkClient (client (Proxy :: Proxy (Flatten API)))

Just work?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I'd intuitively prefer accepting the non-nested definition for left-nested occurrences of :<|> instead of requiring the nested one. This would be easy to do, and seems a bit more symmetric to me.

That's actually not that easy to do. Now we take only the head of the type level list, but if we go left and right, we'd have to use xs ++ ys which can't be used in an instance head. So we'd need to define a separate class with associated type family to split zs into xs and ys based on a :<|> b client. This is doable, of course, just messy.

This approach also makes a :<|> b decide how the custom client should be split... which is probably not a good idea, although I am not 100% sure. One concern is that a :<|> b :<|> c client can be represented by either 3 fields in a client record, or 1 field (client sub-record). I suppose that means that if we flatten the nested :<|> on the left, sometimes GHC wouldn't be able to resolve the types. To eliminate this issue we could add instance TypeError ... => ClientLike (a :<|> b) '[x] so that :<|> would have to be flattened always...

So I guess, we can do it, but I am not sure yet that we should.

I don't understand the argument about N-tuples here. I think @jkarni is right. Flattening is an orthogonal operation. If you have a type-level computation Flatten that turns your API into flattened form, then you can do the entire construction on Flatten API, and then successfully map to a flat datatype.

Here's a simple example of a client and flattened client. Which instances should I add to ClientLike to allow mkClient :: Client -> FlattenedClient? I doesn't work out of the box right now and I don't see an immediate solution here (accept the one that is essentially its own type class for flattening, such as one here)

type Client = Maybe Int -> Maybe String -> (ClientM Int :<|> ClientM String)

type FlattenedClient
    = (Maybe Int -> Maybe String -> ClientM Int)
 :<|> (Maybe Int -> Maybe String -> ClientM String)

Copy link
Contributor

@kosmikus kosmikus Nov 23, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Associativity:

You don't need (++) if you use an accumulator / continuation. I know this because I implemented the whole thing myself and then compared to your version, and this is the only real difference.

I agree that it becomes difficult if you want to allow both the nested and un-nested variants, but I think not nesting the left-associative occurrences of :<|> has the nice property the nesting only occurs if there's nesting via :>.

Flattening:

You don't need to go from Client to FlattenedClient, because you compute the client for FlattenedClient in the first place. Then you only need to go from FlattenedClient to a custom datatype corresponding to FlattenedClient.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You don't need (++) if you use an accumulator / continuation. I know this because I implemented the whole thing myself and then compared to your version, and this is the only real difference.

Interesting! Can you share your version?

I agree that it becomes difficult if you want to allow both the nested and un-nested variants, but I think not nesting the left-associative occurrences of :<|> has the nice property the nesting only occurs if there's nesting via :>.

Actually there has to be a nesting via -> (we are talking client, not API). And not every :> results in an extra parameter.
But yeah, I like the idea of having only one option for matching :<|>.

You don't need to go from Client to FlattenedClient, because you compute the client for FlattenedClient in the first place. Then you only need to go from FlattenedClient to a custom datatype corresponding to FlattenedClient.

Yeah, that part I understand. It's just that @jkarni mentioned (in #640 (comment)) that Flatten type family suffice whereas I think it should be a class with associated type family and a flatten method since you can't use mkClient to flatten.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My version is here: https://gist.github.com/kosmikus/108b4bb11a17a9955495fc3db8b40661

And I still also think that a Flatten type family suffices. Why would you want to convert at the term level?

Copy link
Member

@fizruk fizruk Nov 23, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I've finally understood what I've been missing about flattening: one doesn't have to flatten the client on the term-level since we can generate already flattened client for a flattened API! Sorry everyone! :)

Copy link
Member

@jkarni jkarni left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mind adding an entry into the changelog?

@kosmikus
Copy link
Contributor

Looks nice. I'll try to take a more detailed look tonight. But if you want to merge already, don't let that stop you.

@@ -42,6 +43,7 @@ library
, base64-bytestring >= 1.0.0.1 && < 1.1
, bytestring >= 0.10 && < 0.11
, exceptions >= 0.8 && < 0.9
, generics-sop
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dependency should have bounds.

-- > , postBaz :: Maybe Char -> ClientM ()
-- > } deriving GHC.Generic
-- >
-- > instance Generic.SOP.Generic
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks incomplete.

-- > mkAPIClient = mkClient (client (Proxy :: Proxy API))
class ClientLike client custom where
mkClient :: client -> custom
default mkClient :: (Generic custom, GClientLikeP client xs, SOP I '[xs] ~ Rep custom)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I prefer Code custom ~ '[ xs ] over SOP I '[ xs ] ~ Rep custom, but both conditions are equivalent.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree, Code looks nicer!

@jkarni
Copy link
Member

jkarni commented Nov 23, 2016

Here's the Canonicalize that @alpmestan mentioned. Note that there was a bug in this! I don't remember what it was, but @alpmestan might - he worked on it, if I recall correctly:

-type family Canonicalize api :: * where
 -- requires UndecidableInstances
 Canonicalize (a :> (b :<|> c)) = a :> Canonicalize b :<|> a :> Canonicalize c
 Canonicalize ((a :<|> b) :> c) = a :> Canonicalize c :<|> b :> Canonicalize c
 Canonicalize (a :> b)          = Redex b (Canonicalize b) a
 Canonicalize (a :<|> b)        = Canonicalize a :<|> Canonicalize b
 Canonicalize a                 = a

type family Redex a b c :: * where
  Redex a a first = Canonicalize first :> a
  Redex a b first = Canonicalize (first :> b)

@alpmestan
Copy link
Contributor

alpmestan commented Nov 23, 2016

@jkarni Are you thinking of #558 (comment) and #558 (comment) ?

@alpmestan
Copy link
Contributor

By the way, this all makes me think we really ought to put Canonicalize (with another name) somewhere in one of the core packages. Duplicating it (with possibly different bugs in different implementations, that haven't been as battle-tested) is not the best option.

@kosmikus
Copy link
Contributor

@alpmestan Agreed.

@jkarni
Copy link
Member

jkarni commented Dec 7, 2016

@fierce-katie could you address @kosmikus' comments?

@fierce-katie
Copy link
Contributor Author

I've implemented what @kosmikus suggests, but I think it's reasonable to have both implementations, use one of them as default and manually declare instance ClientLike if you want to use the other one. We've tried this on some real APIs, and it turned out to be rather convenient to have separate clients for logically separated nested APIs. So the idea is, depending on how instance ClientLike is declared for client data structure, its nested clients are either expanded or not.

If there are no objections, I'll add it to this PR.

instance ClientLike (ClientM a) (ClientM a) where
mkClient = id

-- GClientLikeP
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment is not helpful.

instance ClientLike a x => GClientLikeP a '[x] where
gMkClientP a = I (mkClient a) :* Nil

-- GClientLikeL
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And this one too.

@phadej
Copy link
Contributor

phadej commented Jan 17, 2017

I see that everyone is happy with this, can @fierce-katie rebase this so we can merge?

@fierce-katie
Copy link
Contributor Author

@phadej rebased

@phadej phadej merged commit c50fdef into haskell-servant:master Jan 17, 2017
@phadej
Copy link
Contributor

phadej commented Jan 17, 2017

Thanks, merged!

@fierce-katie fierce-katie deleted the generic-client branch June 2, 2017 15:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants