packages feed

typesafe-hs-0.1.0.0: src/TypeSafe/Wire/Request.hs

-- | Ordered request codecs. Decoding objects does not preserve declaration order.
module TypeSafe.Wire.Request
  ( SystemOneRequest (..),
    Question (..),
    NoulQuestion (..),
    NoulCriteria (..),
    ChoiceQuestion (..),
    ScoreQuestion (..),
    BoxQuestion (..),
    Asset (..),
    ImageFormat (..),
    encodeSystemOneRequest,
    duplicateQuestionNames,
    duplicateChoiceLabels,
  )
where

import Control.Monad (unless)
import Data.Aeson
import Data.Aeson.Encoding qualified as Encoding
import Data.Aeson.Key qualified as Key
import Data.Aeson.KeyMap qualified as KeyMap
import Data.Aeson.Types (Parser)
import Data.ByteString.Lazy (ByteString)
import Data.List.NonEmpty (NonEmpty)
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Vector (Vector)
import Data.Vector qualified as Vector
import GHC.Generics (Generic)
import TypeSafe.Wire.Common

-- | Questions retain caller order when encoding; decoded order is unspecified.
data SystemOneRequest = SystemOneRequest
  { model :: !ModelName,
    state :: !Content,
    questions :: !(Vector (QuestionName, Question)),
    assets :: !(Map Text Asset)
  }
  deriving stock (Generic, Eq, Show)

-- | Known variants and complete unknown objects for forward compatibility.
data Question
  = QuestionNoul !NoulQuestion
  | QuestionChoice !ChoiceQuestion
  | QuestionScore !ScoreQuestion
  | QuestionBox !BoxQuestion
  | QuestionOther !Text !Object
  deriving stock (Generic, Eq, Show)

-- | A yes/no question with optional instructions and criteria.
data NoulQuestion = NoulQuestion
  {instructions :: !(Maybe Content), criteria :: !(Maybe NoulCriteria)}
  deriving stock (Generic, Eq, Show)

-- | Optional descriptions for the true and false outcomes.
data NoulCriteria = NoulCriteria
  {true :: !(Maybe Content), false :: !(Maybe Content)}
  deriving stock (Generic, Eq, Show)

-- | Criteria retain vector order on encode; decoded order is unspecified.
data ChoiceQuestion = ChoiceQuestion
  {instructions :: !(Maybe Content), criteria :: !(Vector (Text, Maybe Content))}
  deriving stock (Generic, Eq, Show)

-- | A rating question with ordered, nonempty score levels.
data ScoreQuestion = ScoreQuestion
  {instructions :: !(Maybe Content), criteria :: !(NonEmpty Content)}
  deriving stock (Generic, Eq, Show)

-- | An image localization question with optional instructions.
data BoxQuestion = BoxQuestion {instructions :: !(Maybe Content)}
  deriving stock (Generic, Eq, Show)

-- | Image formats declared by the specification.
data ImageFormat = ImageFormatPng | ImageFormatJpg | ImageFormatJpeg
  deriving stock (Generic, Eq, Show, Enum, Bounded)

-- | Inline JSON assets; multipart transport is outside the initial release.
data Asset = Asset
  {format :: !(Maybe ImageFormat), dataBase64 :: !(Maybe Text), part :: !(Maybe Text)}
  deriving stock (Generic, Eq, Show)

optionalPair :: (ToJSON a) => Key -> Maybe a -> Encoding.Series
optionalPair k = maybe mempty (k .=)

optionalValue :: (ToJSON a) => Key -> Maybe a -> [Pair]
optionalValue k = maybe [] (\v -> [k .= v])

type Pair = (Key, Value)

requireTag :: Text -> Object -> Parser ()
requireTag expected o = do
  tag <- o .: "type"
  unless (tag == expected) (fail "unexpected question discriminator")

instance ToJSON SystemOneRequest where
  toJSON r =
    object $
      [ "model" .= r.model,
        "state" .= r.state,
        "questions" .= Object (KeyMap.fromList [(Key.fromText k, toJSON q) | (QuestionName k, q) <- Vector.toList r.questions])
      ]
        <> ["assets" .= r.assets | not (Map.null r.assets)]
  toEncoding r =
    pairs $
      "model" .= r.model
        <> "state" .= r.state
        <> Encoding.pair "questions" (pairs (foldMap (\(QuestionName k, q) -> Key.fromText k .= q) r.questions))
        <> if Map.null r.assets then mempty else "assets" .= r.assets

instance FromJSON SystemOneRequest where
  parseJSON = withObject "SystemOneRequest" $ \o ->
    SystemOneRequest
      <$> o .: "model"
      <*> o .: "state"
      <*> (Vector.fromList . Map.toList <$> o .: "questions")
      <*> o .:! "assets" .!= Map.empty

instance ToJSON Question where
  toJSON (QuestionNoul q) = toJSON q
  toJSON (QuestionChoice q) = toJSON q
  toJSON (QuestionScore q) = toJSON q
  toJSON (QuestionBox q) = toJSON q
  toJSON (QuestionOther tag o) = Object (KeyMap.insert "type" (String tag) o)
  toEncoding (QuestionNoul q) = toEncoding q
  toEncoding (QuestionChoice q) = toEncoding q
  toEncoding (QuestionScore q) = toEncoding q
  toEncoding (QuestionBox q) = toEncoding q
  toEncoding (QuestionOther tag o) = toEncoding (KeyMap.insert "type" (String tag) o)

instance FromJSON Question where
  parseJSON v =
    withObject
      "Question"
      ( \o -> do
          tag <- o .: "type"
          case tag of
            "noul" -> QuestionNoul <$> parseJSON v
            "choice" -> QuestionChoice <$> parseJSON v
            "score" -> QuestionScore <$> parseJSON v
            "box" -> QuestionBox <$> parseJSON v
            _ -> pure (QuestionOther tag o)
      )
      v

instance ToJSON NoulQuestion where
  toJSON q = object $ ["type" .= ("noul" :: Text)] <> optionalValue "instructions" q.instructions <> optionalValue "criteria" q.criteria
  toEncoding q = pairs $ "type" .= ("noul" :: Text) <> optionalPair "instructions" q.instructions <> optionalPair "criteria" q.criteria

instance FromJSON NoulQuestion where
  parseJSON = withObject "NoulQuestion" $ \o -> requireTag "noul" o *> (NoulQuestion <$> o .:? "instructions" <*> o .:? "criteria")

instance ToJSON NoulCriteria where
  toJSON c = object $ optionalValue "true" c.true <> optionalValue "false" c.false
  toEncoding c = pairs $ optionalPair "true" c.true <> optionalPair "false" c.false

instance FromJSON NoulCriteria where
  parseJSON = withObject "NoulCriteria" $ \o -> NoulCriteria <$> o .:? "true" <*> o .:? "false"

instance ToJSON ChoiceQuestion where
  toJSON q =
    object $
      ["type" .= ("choice" :: Text)]
        <> optionalValue "instructions" q.instructions
        <> ["criteria" .= Object (KeyMap.fromList [(Key.fromText k, toJSON v) | (k, v) <- Vector.toList q.criteria])]
  toEncoding q =
    pairs $
      "type" .= ("choice" :: Text)
        <> optionalPair "instructions" q.instructions
        <> Encoding.pair "criteria" (pairs (foldMap (\(k, v) -> Key.fromText k .= v) q.criteria))

instance FromJSON ChoiceQuestion where
  parseJSON = withObject "ChoiceQuestion" $ \o ->
    requireTag "choice" o
      *> ( ChoiceQuestion
             <$> o .:? "instructions"
             <*> (Vector.fromList . Map.toList <$> o .: "criteria")
         )

instance ToJSON ScoreQuestion where
  toJSON q = object $ ["type" .= ("score" :: Text)] <> optionalValue "instructions" q.instructions <> ["criteria" .= q.criteria]
  toEncoding q = pairs $ "type" .= ("score" :: Text) <> optionalPair "instructions" q.instructions <> "criteria" .= q.criteria

instance FromJSON ScoreQuestion where
  parseJSON = withObject "ScoreQuestion" $ \o -> requireTag "score" o *> (ScoreQuestion <$> o .:? "instructions" <*> o .: "criteria")

instance ToJSON BoxQuestion where
  toJSON q = object $ ["type" .= ("box" :: Text)] <> optionalValue "instructions" q.instructions
  toEncoding q = pairs $ "type" .= ("box" :: Text) <> optionalPair "instructions" q.instructions

instance FromJSON BoxQuestion where
  parseJSON = withObject "BoxQuestion" $ \o -> requireTag "box" o *> (BoxQuestion <$> o .:? "instructions")

instance ToJSON ImageFormat where
  toJSON = String . imageFormatText
  toEncoding = toEncoding . imageFormatText

instance FromJSON ImageFormat where
  parseJSON = withText "ImageFormat" $ \case
    "png" -> pure ImageFormatPng
    "jpg" -> pure ImageFormatJpg
    "jpeg" -> pure ImageFormatJpeg
    _ -> fail "expected png, jpg or jpeg"

imageFormatText :: ImageFormat -> Text
imageFormatText ImageFormatPng = "png"
imageFormatText ImageFormatJpg = "jpg"
imageFormatText ImageFormatJpeg = "jpeg"

instance ToJSON Asset where
  toJSON a =
    object $
      ["type" .= ("image" :: Text)]
        <> optionalValue "format" a.format
        <> optionalValue "data_base64" a.dataBase64
        <> optionalValue "part" a.part
  toEncoding a =
    pairs $
      "type" .= ("image" :: Text)
        <> optionalPair "format" a.format
        <> optionalPair "data_base64" a.dataBase64
        <> optionalPair "part" a.part

instance FromJSON Asset where
  parseJSON = withObject "Asset" $ \o -> do
    case KeyMap.lookup "type" o of
      Nothing -> pure ()
      Just (String "image") -> pure ()
      _ -> fail "expected image asset"
    Asset <$> o .:! "format" <*> o .:! "data_base64" <*> o .:! "part"

-- | Exact deterministic bytes used by aeson and servant's JSON renderer (R3).
encodeSystemOneRequest :: SystemOneRequest -> ByteString
encodeSystemOneRequest = Encoding.encodingToLazyByteString . toEncoding

-- | Repeated names once each, in order of first repetition; encoding keeps them.
duplicateQuestionNames :: SystemOneRequest -> [QuestionName]
duplicateQuestionNames r = duplicates (map fst (Vector.toList r.questions))

-- | Repeated choice labels once each, in order of first repetition.
duplicateChoiceLabels :: ChoiceQuestion -> [Text]
duplicateChoiceLabels q = duplicates (map fst (Vector.toList q.criteria))

duplicates :: (Ord a) => [a] -> [a]
duplicates = go Set.empty Set.empty
  where
    go _ _ [] = []
    go seen reported (x : xs)
      | Set.member x seen && Set.notMember x reported = x : go seen (Set.insert x reported) xs
      | otherwise = go (Set.insert x seen) reported xs