Simple Affjax requests that allow for HTTP errors.
Affjax is a library taking advantage of
Affto enable pain-free asynchronous AJAX requests and response handling.
Although the library is very easy to use, it suffers from one major drawback: it cannot be fully used in the presence of HTTP errors. I will go over why in the following section.
Please note that the maintainers of Affjax are already planning a major update that will fix this
problem. At the time of writing this document, the version is 5.0.0.
At the core of the library, we have the following function:
type Affjax e a = Aff (ajax :: AJAX | e) (AffjaxResponse a)
affjax
:: forall e a b
. Requestable a
=> Respondable b
=> AffjaxRequest a
-> Affjax e bAs long as we stay on the happy path, this is all trivially easy to use. We create
AffjaxRequest and DencodeJson instances for whatever it is we're expecting from the API, and we
get back the result.
However, regardless of the status code returned by the request, affjax will attempt to parse the
content and will fail. See the implementation of
affjax,
specifically its fromResponse' helper.
We could bypass this entire problem by not asking affjax to do any Json decoding. If we ask it for
a plain String, then we can check for the StatusCode (which is an Int newtype)ourselves and decide
whether we return an error or if we try to parse the string through Json to a well-formed type.
The problem is then moved to, how do we best express HTTP errors. This repository presents two alternatives: a simple version which uses sum types and a slightly more elaborate version dubbed variant which uses the purescript-variant library.
We start with the observation that we need a way of transforming from a StatusCode to an error
sum type:
class MapStatusCode a where
mapStatusCode :: StatusCode -> a
mapParserError :: String -> aMapStatusCode allows us to map a StatusCode to any type with an instance, as well as map parsing
errors (whose errors are expressed as String in
purescript-argonaut.
Having all of this, we can write a generic AffjaxResponse String to Either errorType resultType
function:
decodeWithError :: forall a b.
MapStatusCode a
=> DecodeJson b
=> AffjaxResponse String
-> Either a b
decodeWithError res
| statusOk res.status = lmap mapParserError (decodeJson <=< jsonParser $ res.response)
| otherwise = Left $ mapStatusCode res.statusThe implementation is simple: if the status is ok (in the [200, 300) range), we attempt to parse it as json. Otherwise, we map the status code to the error type.
Using this method is equally simple. We need to define an error type and its MapStatusCode
instance:
data BasicError = Unauthorized | ServerError | ParseError
instance basicErrorMapStatusCode :: MapStatusCode BasicError where
mapStatusCode (StatusCode n)
| n == 401 = Unauthorized
| otherwise = ServerError
mapParserError _ = ParseErrorAnd then we can implement an API function that uses affjax:
getFile :: forall eff. String -> Aff (ajax :: AJAX | eff) (Either BasicError String)
getFile s = do
res <- affjax $ defaultRequest
{ url = "simpleAPI/" <> s
, method = Left GET
}
pure $ S.decodeWithError resThe only real problem with this approach is building on top of it. If we need "something like BasicError, but with NotFound", we need to create an entirely new type:
data SomeError = NotFound | SomeBasicError BasicError
instance someErrorMapStatusCode :: MapStatusCode SomeError where
mapStatusCode sc@(StatusCode n)
| n == 404 = NotFound
| otherwise = SomeBasicError $ mapStatusCode sc
mapParserError = SomeBasicError <<< mapParserErrorWe can reuse BasicErrors MapStatusCode instance, but we need to wrap it into a constructor.
An additional problem is when we need to match the error, we end up with something like:
res <- getFilePlus "data.json"
let str = case res of
Left err -> case err of
NotFound -> "not found"
SomeBasicError Unauthorized -> "unauthorized"
SomeBasicError ServerError -> "server error"
SomeBasicError ParseError -> "parse error"
Right x -> xIf that doesn't look bad, imagine having to add a 490 Conflict on top of NotFound.
If you are not already familiar with the excellent purescript-variant library, please go check it out. It has an excellent readme.
If we use Variant for our error type, then our decodeWithError function becomes:
decodeWithError
:: forall a i p o
. DecodeJson a
=> Union i p o
=> Union p i o
=> (StatusCode -> Variant i)
-> (String -> Variant p)
-> AffjaxResponse String
-> Either (Variant o) a
decodeWithError errorMapper peMapper response
| statusOk response.status = lmap (expand <<< peMapper) (decodeJson <=< jsonParser $ response.response)
| otherwise = Left <<< expand <<< errorMapper $ response.statusOur function now takes two additional parameters, which map to the class we ditched. Basically, we
need something to map fail status codes to a Variant type, and something to map parser errors to
a different Variant type. This is an important note, the two Variants need to have no row in common,
which is expressed by the double Union constraint.
We can go one step further and create an even simpler-to-use helper:
runAffjaxWithError
:: forall a b i p o eff
. Requestable a
=> DecodeJson b
=> Union i p o
=> Union p i o
=> (StatusCode → Variant i)
-> (String → Variant p)
-> AffjaxRequest a
-> Aff (ajax ∷ AJAX | eff) (Either (Variant o) b)
runAffjaxWithError statusCodeMap parseErrorMap req = do
res <- affjax req
pure <<< decodeWithError statusCodeMap parseErrorMap $ resThis is similar to decodeWithError except we are going a bit further and start from the Request
itself.
We can now create a new BasicError' type as a Variant, and write out the two required functions:
mapBasicError and parseError:
_unAuthorized = SProxy :: SProxy "unAuthorized"
_serverError = SProxy :: SProxy "serverError"
_parseError = SProxy :: SProxy "parseError"
type BasicError' e =
Variant
( unAuthorized :: Unit
, serverError :: Unit
| e
)
mapBasicError :: StatusCode -> BasicError' ()
mapBasicError (StatusCode n)
| n == 401 = inj _unAuthorized unit
| otherwise = inj _serverError unit
parseError :: Variant (parseError :: Unit)
parseError = inj _parseError unitWe can now create the same getFile' API method, but using the Variant alternative:
getFile' :: forall eff. String -> Aff (ajax :: AJAX | eff) (Either (BasicError' ParseError) String)
getFile' s =
runAffjaxWithError mapBasicError (const parseError) $ defaultRequest
{ url = "simpleAPI/" <> s
, method = Left GET
}As before, adding the NotFound row is trivial:
_notFound = SProxy :: SProxy "notFound"
type SomeError' e = BasicError' (notFound :: Unit | e)
mapNotFound :: StatusCode -> SomeError' ()
mapNotFound sc@(StatusCode n)
| n == 404 = inj _notFound unit
| otherwise = expand $ mapBasicError sc
getFilePlus' :: forall eff. String -> Aff (ajax :: AJAX | eff) (Either (SomeError' ParseError) String)
getFilePlus' s =
runAffjaxWithError mapNotFound (const parseError) $ defaultRequest
{ url = s
, method = Left GET
}And the best part, matching the error is not layered:
res' <- getFilePlus' "data.json"
let str' = case res' of
Left err ->
case_
# on _notFound (const "not found")
# on _unAuthorized (const "unauthorized")
# on _serverError (const "server error")
# on _parseError (const "parse error")
$ err
Right x -> x