Servant server + Halo client

The Haskell-realworld-example Haskell example (Servant based server) and the Real World PureScript React Purescript example (client) combined into Conduit (gothinkster/realworld) — what's described as "The mother of all demo apps" (fullstack Medium.com clone)

Introduction

Haskell-realworld-example has a very nice implementation for JWT authentication, which combines nicely with the Real World PureScript React example (although there are some implementation differences to iron out).

Implementation differences

JWT

Changes to the client

In Conduit.Data.Jwt change username to sub.

1type Jwt =
2{ sub :: Username
3, exp :: Number
4}
5...
6CAR.object "Jwt"
7              { sub: usernameCodec -- username: usernameCodec -- TODO fix
8              , exp: CA.number
9              }

In Conduit.Data.Auth change username to sub.

1{ exp, username } <- hush $ Jwt.decode token
2pure { token, username, expirationTime: fromMilliseconds $ Milliseconds $ exp * 1000.0, user }

Changes to the server

In Conduit.JWT you'll problably want to change mkClaims to use your own names for iss and aud.

 1mkClaims :: Username -> IO ClaimsSet
 2mkClaims name = do
 3    currentTime <- getCurrentTime
 4    let expiredAt = addUTCTime nominalDay currentTime
 5    pure $ emptyClaimsSet
 6        & claimIss ?~ "conduit-server"
 7        & claimAud ?~ Audience ["conduit-client"]
 8        & claimIat ?~ NumericDate currentTime
 9        & claimExp ?~ NumericDate expiredAt
10        & claimSub ?~ (fromString . T.unpack . getUsername) name

Empty image field in login response

The server returns apparently valid JSON — hence weird problem — with an empty image field, but the client reports a decoding error. If the image field is set to a value (by changing it directly in the users table in the database as a workaround) then it functions correctly; even though the bio field is also empty — even weirder.

This may be because an empty field should not be sent by the server (when it's an optional field).

Then again, if the server wants to send an optional empty field then that should be optional?

1{
2   "user" : {
3      "bio" : "",
4      "email" : "arya@winterfell.com",
5      "image" : "",
6      "token" : "eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiJiYWxkci1jbGllbnQiLCJleHAiOjEuNjY3MjA1NjIxMDE1MzQ5MzE5ZTksImlhdCI6MS42NjcxMTkyMjEwMTUzNDkzMTllOSwiaXNzIjoiYmFsZHItc2VydmVyIiwic3ViIjoiQXJ5YSAxIn0.TzhDvT9Mkmj9nalh_q7vGol-IbnRRoxoiGPQwlasaGQ",
7      "username" : "Arya 1"
8   }
9}

Console error logging:

1(DecodeError { body: "{\"user\":{\"email\":\"arya@winterfell.com\",\"password\":\"valar_morghulis\"}}", headers: [(ContentType (MediaType "application/json"))], method: POST, url: "/api/users/login" } { body: "{\"user\":{\"bio\":\"\",\"email\":\"arya@winterfell.com\",\"image\":\"\",\"token\":\"eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiJiYWxkci1jbGllbnQiLCJleHAiOjEuNjY3MjA1NjIxMDE1MzQ5MzE5ZTksImlhdCI6MS42NjcxMTkyMjEwMTUzNDkzMTllOSwiaXNzIjoiYmFsZHItc2VydmVyIiwic3ViIjoiQXJ5YSAxIn0.TzhDvT9Mkmj9nalh_q7vGol-IbnRRoxoiGPQwlasaGQ\",\"username\":\"Arya 1\"}}", headers: [(ResponseHeader "connection" "keep-alive"),(ResponseHeader "content-type" "application/json;charset=utf-8"),(ResponseHeader "date" "Sun, 30 Oct 2022 08:40:21 GMT"),(ResponseHeader "server" "nginx/1.21.6"),(ResponseHeader "transfer-encoding" "chunked")], status: (StatusCode 200) } An error occurred while decoding a JSON value:
2  Under 'Response':
3  At object key user:
4  Under 'User':
5  At object key image:
6  Under 'Maybe':
7  Under 'Avatar':
8  Unexpected value "".)

The client code has different codecs for image and bio

Conduit.Data.Profile

1-- | Codecs
2mkProfileRepCodec :: forall rest. CA.JPropCodec (Record rest) -> CA.JPropCodec { | ProfileRep rest }
3mkProfileRepCodec =
4  CA.recordProp (Proxy :: Proxy "username") usernameCodec
5    <<< CA.recordProp (Proxy :: Proxy "bio") (CAC.maybe CA.string)
6    <<< CA.recordProp (Proxy :: Proxy "image") (CAC.maybe avatarCodec)

but avatarCodec is also a string in the end

Conduit.Data.Avatar

1-- | Codecs
2avatarCodec :: JsonCodec Avatar
3avatarCodec = CA.prismaticCodec "Avatar" fromString toString CA.string

hence weird.

WORKAROUND

In Conduit.Data.Avatar change fromString to the following1:

1fromString :: String -> Maybe Avatar
2fromString str
3  | String.null str = Just (Avatar "")
4  | otherwise = Just (Avatar str)

Beware: now there's no longer no avatar (which might be bad, but still better than the alternative).

1 One could even use fromString str = Just (Avatar str) but that would probably be even more confusing.

Token in User update response

The server doesn't return a token field, but the client expects it. The update user section in the Realworld spec also mentions it (hence issue in server).

Also the password is changed to something you no longer know, hence logging in is impossible after a user update (it's probably a bug).

1(DecodeError { body: "null", headers: [(ContentType (MediaType "application/json")),(RequestHeader "Authorization" "Token eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiJiYWxkci1jbGllbnQiLCJleHAiOjEuNjY3MjA1NjM3MjcwMTAyMzcyZTksImlhdCI6MS42NjcxMTkyMzcyNzAxMDIzNzJlOSwiaXNzIjoiYmFsZHItc2VydmVyIiwic3ViIjoiQXJ5YSAxIn0.tQ4JqPXoBu7RDSjF0gJeBi8NDJFGp7w7D8cxjUXMUno")], method: GET, url: "/api/user" } { body: "{\"user\":{\"bio\":\"\",\"email\":\"arya@winterfell.com\",\"image\":\"smiley-cyrus.09f77aa9.jpg\",\"username\":\"Arya 1\"}}", headers: [(ResponseHeader "connection" "keep-alive"),(ResponseHeader "content-type" "application/json;charset=utf-8"),(ResponseHeader "date" "Sun, 30 Oct 2022 10:42:00 GMT"),(ResponseHeader "server" "nginx/1.21.6"),(ResponseHeader "transfer-encoding" "chunked")], status: (StatusCode 200) } An error occurred while decoding a JSON value:
2  Under 'UserResponse':
3  At object key user:
4  Under 'User':
5  At object key token:
6  No value was found.) index.b98cbc1b.js:1:50285

Forbidden characters in slugs

The server doesn't check if the characters in the title (which are used to create the slug) are valid. This can be fixed by e.g. using the slugger library.

stack.yaml

1resolver: lts-19.31
2...
3extra-deps:
4  - slugger-0.1.0.1@sha256:b6c37b0d4c2d35a49ad20e9978bf0ce308a8c4455fc61d0b446db1f3971bdc28,2035
5
6nix:
7  pure: true
8  enable: true
9  packages: [ postgresql zlib icu]

(trying out resolver: lts-19.31 — the nix section is for use with NixOS)

package.yaml

1...
2  dependencies:
3...      
4  - slugger
5...

(Add slugger to depencies)

Conduit.Core.Article

 1...
 2import Data.Text.Slugger (toSlug)
 3...
 4mkSlug :: MonadIO m => Text -> m Slug
 5mkSlug title = do
 6    rnd <- liftIO $ getRandomBytes 32
 7    let hash = T.pack $ show $ hashWith SHA256 (rnd :: ByteString)
 8    let title' = toSlug title
 9    let slugText = T.intercalate "-" $ (T.words . T.toLower $ title') ++ [T.take 8 hash]
10    return $ Slug slugText
11...

Comments don't work

Completely breaks the client; reload of page and re-login needed.

Posts in this Series