packages feed

freckle-app-1.20.3.0: library/Freckle/App/TextualEnum.hs

{-# LANGUAGE UndecidableInstances #-}

-- | Typical instances for enumerated data types with textual representation
module Freckle.App.TextualEnum
  ( TextualEnum (..)
  , EnumValue (..)
  , EnumName (..)
  , enums
  , fromText
  , prop_roundTripEnumText
  ) where

import Freckle.App.Prelude

import Autodocodec
import Control.Lens hiding (elements)
import Control.Monad (mzero)
import Data.Aeson
import Data.Csv qualified as CSV
import Data.Dynamic (Typeable)
import Data.List.NonEmpty qualified as NE
import Data.OpenApi
import Data.Text.Encoding qualified as T
import Database.Persist.Sql
  ( PersistField (..)
  , PersistFieldSql (..)
  , SqlType (..)
  )
import Servant
import Test.QuickCheck (Arbitrary (..), elements)
import Web.PathPieces

class EnumValue a where
  -- | Convert a 'TextualEnum' to 'Text'
  toText :: a -> Text

class EnumName a where
  -- | Name of a 'TextualEnum', used for naming schemas
  enumName :: Proxy a -> Text

-- | Wrapper around enums
--
-- N.B. This should not be used for "enormous" enumerations. It's primary purpose
-- is to provide standard instances for discriminated-union-defined enums.
newtype TextualEnum a = TextualEnum {enumValue :: a}
  deriving newtype (Eq, Show, Ord, Generic)

-- | All values of a 'TextualEnum'
enums :: (Bounded a, Enum a) => NonEmpty (TextualEnum a)
enums = fmap TextualEnum $ minBound NE.:| drop 1 [minBound .. maxBound]

-- | Parse a 'TextualEnum' from 'Text'
fromText :: (EnumValue a, Bounded a, Enum a) => Text -> Maybe (TextualEnum a)
fromText txt = find ((== txt) . toText . enumValue) enums

instance EnumValue a => ToJSON (TextualEnum a) where
  toJSON = toJSON . toText . enumValue
  toEncoding = toEncoding . toText . enumValue

instance (EnumValue a, Bounded a, Enum a) => FromJSON (TextualEnum a) where
  parseJSON = withText "TextualEnum" $ maybe mzero pure . fromText

instance (Bounded a, Enum a) => Arbitrary (TextualEnum a) where
  arbitrary = elements $ toList enums

instance (EnumValue a, Bounded a, Enum a) => PathPiece (TextualEnum a) where
  toPathPiece = toPathPiece . toText . enumValue
  fromPathPiece = fromText

instance EnumValue a => CSV.ToField (TextualEnum a) where
  toField = CSV.toField . toText . enumValue

instance (EnumValue a, Bounded a, Enum a) => CSV.FromField (TextualEnum a) where
  parseField = maybe mzero pure . fromText . T.decodeUtf8

instance
  (Bounded a, Enum a, EnumValue a, Typeable a, EnumName a)
  => ToSchema (TextualEnum a)
  where
  declareNamedSchema = pure . NamedSchema (Just $ enumName $ Proxy @a) . enumOptions
   where
    enumOptions _ =
      mempty
        & type_ ?~ OpenApiString
        & enum_ ?~ (toJSON . toText . enumValue <$> toList (enums @a))
        & example ?~ (toJSON . toText . enumValue $ NE.head (enums @a))

instance (Bounded a, Enum a, EnumValue a) => ToParamSchema (TextualEnum a) where
  toParamSchema _ =
    mempty
      & type_ ?~ OpenApiString
      & enum_ ?~ (toJSON . toText . enumValue <$> toList (enums @a))
      & example ?~ (toJSON . toText . enumValue $ NE.head (enums @a))

instance (Bounded a, Enum a, EnumValue a, Eq a) => HasCodec (TextualEnum a) where
  codec = stringConstCodec $ (id &&& (toText . enumValue)) <$> enums @a

instance (Bounded a, Enum a, EnumValue a) => PersistField (TextualEnum a) where
  toPersistValue = toPersistValue . toText . enumValue
  fromPersistValue =
    maybe (Left "Not member of enumeration") Right . fromText <=< fromPersistValue

instance (Bounded a, Enum a, EnumValue a) => PersistFieldSql (TextualEnum a) where
  sqlType _ = SqlString

instance (Bounded a, Enum a, EnumValue a) => FromHttpApiData (TextualEnum a) where
  parseUrlPiece =
    maybe (Left "Not member of enumeration") Right . fromText

instance EnumValue a => ToHttpApiData (TextualEnum a) where
  toUrlPiece = toText . enumValue

-- | Test that enum instances are coherent
prop_roundTripEnumText
  :: (Bounded a, Enum a, EnumValue a, Eq a) => TextualEnum a -> Bool
prop_roundTripEnumText a = fromText (toText $ enumValue a) == Just a