I have a type that represents a persisted record. I want to have a very similar type that represents data that should be POSTed to create a new record.
This is the full type:
data Record = Reading
{ id: UUID
, value: String
...
}
the "new" type is the same minus the "id", which will be auto-generated by the db. How can I define this type? I am using servant to define the API.
My current strategy is to prefix the type and all fields with "new", which works but is reduntant for many-field models. I have also seen the nested strategy where I have a common shared type. I've also thought about making the id optional, but I really don't want to be able to post it.
You can implement this with a Higher Kinded Data like approach.
First, some imports:
{-# LANGUAGE StandaloneDeriving #-}
{-# LANGUAGE UndecidableInstances #-}
module Example where
import Data.Functor.Identity
import Data.Proxy
import Data.UUID
import Data.Aeson
import GHC.Generics
Then, define a record with a higher kinded type parameter:
data Record f = Record { recordId :: f UUID, recordValue :: String } deriving (Generic)
The Identity
Functor gives you a variant on this record which always has an Id.
type RecordWithId = Record Identity
Using Maybe
gives you a variant with an optional id.
type RecordWithOptionalId = Record Maybe
Proxy
can be used as a Functor with a single uninteresting "unit" value. (and no values of the wrapped type). This lets us create a type for a Record
with no ID.
type RecordWithoutId = Record (Proxy)
We can derive Show
for our Record
.
deriving instance (Show (f UUID)) => Show (Record f)
Passing omitNothingFields = True
and allowOmitedFields = True
in the Aeson
instances is required to parse a RecordWithoutId
as you'd expect. This does require a version of Aeson >= 2.2.0.0 (which as of writing is more recent than the latest Stackage Snapshot). You could probably implement the Aeson instances by hand if this version bound doesn't work for you.
instance (ToJSON (f UUID)) => ToJSON (Record f) where
toJSON = genericToJSON defaultOptions { omitNothingFields = True, allowOmitedFields = True }
instance (FromJSON (f UUID)) => FromJSON (Record f) where
parseJSON = genericParseJSON defaultOptions { omitNothingFields = True, allowOmitedFields = True }
Encoding a value with an ID:
ghci> import qualified Data.ByteString.Lazy.Char8 as BL
ghci> BL.putStrLn $ encode (Record {recordId = Identity nil, recordValue = "value" })
{"recordId":"00000000-0000-0000-0000-000000000000","recordValue":"value"}
Encoding a value without an ID:
ghci> BL.putStrLn $ encode (Record {recordId = Proxy, recordValue = "value" })
{"recordValue":"value"}
Decoding a value without an ID
ghci> decode "{\"recordValue\":\"value\"}" :: Maybe RecordWithoutId
Just (Record {recordId = Proxy, recordValue = "value"})