Open cdupont opened 6 years ago
Please open this issue in right repository: https://github.com/haskell-servant/servant-swagger
Hi phadej, I believe this is a generic question... I used the Swagger feature as an example. But this is a very common problem in API design... PS. If you prefer I can remove Swagger from the question.
you can do (google for trees that grow haskell):
data User tag = User
{ userId :: ReadOnly tag Text
, userName :: Text
, userPass :: WriteOnly tag Text
}
type family ReadOnly tag a :: * where
ReadOnly Get a = a
ReadOnly Post a = ()
type family WriteOnly tag a :: * where
WriteOnly Get a = ()
WriteOnly Post a = a
This approach will leave units ()
to be filled. It's still worth while when
you have a lot of things all in few flavours (like yours read-only /
write-only), or when you have few things but modified in a lot ways (syntax
trees in compilers).
Alternataively, the separate types
data GetUser = GetUser
{ guserId :: Text
, guserName :: Text
}
data PostUser = PostUser
{ puserName :: Text
, puserPass :: Text
}
are easier to start with. You can reduce boilerplate with ad-hoc typeclasses:
-- or lensy variant... Google for "classy lenses"
-- https://talks.bfpg.org/talks/2015-06-09.next_level_mtl.html
class HasUserName a where userName :: a -> Text
instance HasUserName GetUser where userName = guserName
instance HasUserName PostUser where userName = puserName
The best way to handle this is at your data model level, not the API level.
We distinguish in our code between UserRegistration
and User
objects.
This has the added benefit of not accidentally leaking the password
in the implementation of the getCurrentUser
function, as it can't even access it.
data UserRegistration = UserRegistration { username :: Text, password :: Text }
data User = User { username :: Text, lastLoggedIn :: Date }
-- | Inserts a new user
registerUser :: UserRegistration -> IO User
getCurrentUser :: IO User
Then our API becomes:
type UserAPI = "users" :> Get '[JSON] [User]
:<|> "users" :> ReqBody '[JSON] UserRegistration :> Post '[JSON] ()
server :: Server UserAPI
server = getUser <|> void . registerUser
Seems @phadej and I gave the exact same answer at the exact same time :) except @phadej 's approach is even a bit more flexible and more closely models what Swagger is doing :P
Ha, was going to suggest the same approach as @phadej . You can add a little more type safety by having a synonym/class with type synonym for Get/ReqBody that checks that it's argument is reasonable.
type IsRead a where
IsRead (f 'Write) = 'False
IsRead (f (g, 'Write)) = 'False
-- etc.
IsRead x = 'True
class GoodGet a b where
type Get a b
instance (IsRead b ~ True) GoodGet a b where
type Get a b = Servant.Get a b
And likewise for Write/ReqBody. Not perfect, and probably not worth the effort, but should make making a mistake (receiving a read object, sending a write object) nearly impossible.
Thanks a lot for your response. Having 2 datatypes, one inside the other as you proposed is usually what I do.
Actually, I have 3 flavors for my data types: read, write, and internal. Read is used for GET operations, write for POST operations, while internal is used when talking with the database. For example, I have a User type:
{
name : "John Doe" -- read, write and internal
date_created : "2018-11-30" -- read and internal
db_link : "XXXX" -- only internal
}
On this example, name
is chosen by the user, stored and output. date_created
is only stored and output. Finally db_link
is used for internal purpose only, it is not input or output.
As of now, I encode it like that:
data User = User {
name :: Text -- read, write and internal
date_created :: Maybe Text -- read and internal
db_link :: Maybe Text -- only internal
}
This is simple but error prone. It also adds unnecessary case analysis: db_link
is mandatory when reading from the database, but I still have to check the Nothing value.
Should I use the tagged approach?
Swagger has "readOnly" and "writeOnly" keywords: https://swagger.io/docs/specification/data-models/data-types/#readonly-writeonly They are very practical when some properties appear in the data returned by GET but not in a POST (or vice-versa). For example:
How to do that in Servant? I could use Maybes:
However, the implementer will be responsible to fill or not the id and password. Is there a way to make this in a more type safe way?