-
-
Notifications
You must be signed in to change notification settings - Fork 414
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
Generic client #640
Conversation
There was a problem hiding this 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 |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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!
62a0cba
to
ab8bff2
Compare
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). |
The error is:
It may appear if you have a more complicated client structure. |
Yeah, that's a bug I've been hitting in a different project too. Presumably you're using GHC 8.0.2 has the fix, but it hasn't been released yet. If it's only triggered in client code that uses |
That's terrific! In my opinion this is even blog-post worthy (either on servant's or on your own). |
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 |
-- > | ||
-- > mkAPIClient :: APIClient | ||
-- > mkAPIClient = mkClient (client (Proxy :: Proxy API)) | ||
class ClientLike client custom where |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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
.
There was a problem hiding this comment.
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
toFlattenedClient
, because you compute the client forFlattenedClient
in the first place. Then you only need to go fromFlattenedClient
to a custom datatype corresponding toFlattenedClient
.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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! :)
There was a problem hiding this 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?
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. |
93f1569
to
ea16c30
Compare
@@ -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 |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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!
Here's the -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) |
@jkarni Are you thinking of #558 (comment) and #558 (comment) ? |
By the way, this all makes me think we really ought to put |
@alpmestan Agreed. |
@fierce-katie could you address @kosmikus' comments? |
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 If there are no objections, I'll add it to this PR. |
e6d3709
to
0876ac9
Compare
instance ClientLike (ClientM a) (ClientM a) where | ||
mkClient = id | ||
|
||
-- GClientLikeP |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
And this one too.
I see that everyone is happy with this, can @fierce-katie rebase this so we can merge? |
bc11a35
to
b623bd4
Compare
b623bd4
to
af1b267
Compare
@phadej rebased |
Thanks, merged! |
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.