Search code examples
purescriptargonaut

Purescript Reuse Argonaut JSON Decoding for Affjax Respondeable


I'm trying to fetch some JSON data from a Haskell server, but I'm having trouble with the Respondeable instance, as well as just Affjax in general. I've defined EncodeJson + DecodeJson with Data.Argonaut.Generic.Aeson (GA), but I can't figure out how to fit that in with the Respondeable instance and it's fromResponse function.

It gives me the error "Could not match type Foreign with type Json" but is it possible to reuse my decodeJson instance without having to create anything else by hand? Maybe by creating an IsForeign instance, but using GA.decodeJson in that? I'm just not sure how to go about doing it. I've seen how it's done in https://github.com/purescript/purescript-foreign/blob/master/examples/Complex.purs by hand, but I have complex types that need to match up with my Haskell JSON output, and it's going to be a huge pain to do it manually.

I'm using purescript 10.7, Affjax 3.02, and argonaut 2.0.0, and argonaut-generic-codecs 5.1.0. Thanks!

testAffjax :: forall eff. Aff (ajax :: AJAX | eff) (Answer)
testAffjax = launchAff do
  res <- affjax $ defaultRequest { url = "/", method = Left GET }
  pure res.response


data Answer = Answer {
  _answer :: String
, _isCorrect :: Boolean
, _hint :: String
}

{- PROBLEM -}
instance respondableAnswer :: Respondable Answer where
  responseType = Tuple Nothing JSONResponse
  fromResponse = GA.decodeJson {- Error here -}

derive instance genericAnswer :: Generic Answer
instance showAnswer :: Show Answer where
  show = gShow
instance encodeAnswer :: EncodeJson Answer where
  encodeJson = GA.encodeJson
instance decodeAnswer :: DecodeJson Answer where
  decodeJson = GA.decodeJson

Solution

  • What you're looking for is a function that adapts a JSON decoder:

    decodeJson :: forall a. Json -> Either String a
    

    To return using F rather than Either. F is a synonym defined in Data.Foreign for Except MultipleErrors a. To do that we need to:

    1. Translate our String error into a MultipleErrors
    2. Convert from Either to Except

    MultipleErrors is another synonym defined in Data.Foreign, this time for NonEmptyList ForeignError. Looking at ForeignError there's a constructor also called ForeignError that lets us provide some string message. That leaves us with the need to create a NonEmptyList, which is pretty easy:

    remapError = pure <<< ForeignError
    

    NonEmptyList is Applicative, so we can create a one-element list with pure.

    To go from Either to Except is also straightforward. Again looking at the definitions in Pursuit we can see:

    newtype ExceptT m e a = ExceptT (m (Either e a))
    type Except = ExceptT Identity
    

    So ExceptT is just a fancy Either already, giving us:

    eitherToExcept = ExceptT <<< pure
    

    The pure here is to lift Either e a into m (Either e a), which for Except m ~ Identity.

    So now we can take this stuff, and make a general "decode JSON for Affjax responses" function:

    decodeJsonResponse :: forall a. DecodeJson a => Json -> F a
    decodeJsonResponse =
      ExceptT <<< pure <<< lmap (pure <<< ForeignError) <<< decodeJson
    

    The only other thing that happened in here is we used lmap to map over the left part of the Either, to do the error-message-type-conversion bit.

    We can now use Kleisli composition ((<=<)) to chain this decodeJsonResponse together with the original fromResponse that will do the initial ResponseContent -> F Json:

    instance respondableAnswer :: Respondable Answer where
      responseType = Tuple (Just applicationJSON) JSONResponse
      fromResponse = decodeJsonResponse <=< fromResponse
    

    Here's the full example using your Answer type:

    module Main where
    
    import Prelude
    
    import Control.Monad.Aff (Aff)
    import Control.Monad.Except (ExceptT(..))
    
    import Data.Argonaut (class DecodeJson, class EncodeJson, Json, decodeJson)
    import Data.Argonaut.Generic.Argonaut as GA
    import Data.Bifunctor (lmap)
    import Data.Foreign (F, ForeignError(..))
    import Data.Generic (class Generic, gShow)
    import Data.Maybe (Maybe(..))
    import Data.MediaType.Common as MediaType
    import Data.Tuple (Tuple(..))
    
    import Network.HTTP.Affjax as AX
    import Network.HTTP.Affjax.Response as AXR
    
    testAffjax :: forall eff. Aff (ajax :: AX.AJAX | eff) Answer
    testAffjax = _.response <$> AX.get "/"
    
    newtype Answer = Answer
      { _answer :: String
      , _isCorrect :: Boolean
      , _hint :: String
      }
    
    derive instance genericAnswer :: Generic Answer
    
    instance showAnswer :: Show Answer where
      show = gShow
    
    instance encodeAnswer :: EncodeJson Answer where
      encodeJson = GA.encodeJson
    
    instance decodeAnswer :: DecodeJson Answer where
      decodeJson = GA.decodeJson
    
    instance respondableAnswer :: AXR.Respondable Answer where
      responseType = Tuple (Just MediaType.applicationJSON) AXR.JSONResponse
      fromResponse = decodeJsonResponse <=< AXR.fromResponse
    
    decodeJsonResponse :: forall a. DecodeJson a => Json -> F a
    decodeJsonResponse =
      ExceptT <<< pure <<< lmap (pure <<< ForeignError) <<< decodeJson