Add fourmolu support & update config.

This commit is contained in:
Qiao Wang 2023-05-19 07:06:51 +00:00
parent f791b8d7a7
commit 0c89cc9b83
44 changed files with 1274 additions and 1128 deletions

View File

@ -8,14 +8,15 @@
"ghcVersion": "9.2.7",
"installHLS": true,
"installStack": true,
"installStackGHCupHook": true
// "globalPackages": ""
"installStackGHCupHook": true,
"globalPackages": "fourmolu"
}
},
"customizations": {
"vscode": {
"extensions": [
"haskell.haskell"
"haskell.haskell",
"streetsidesoftware.code-spell-checker"
]
}
},

View File

@ -21,5 +21,7 @@
"uncurry",
"unfollow",
"varchar"
]
],
"haskell.plugin.fourmolu.config.external": true,
"haskell.formattingProvider": "fourmolu"
}

View File

@ -1,9 +1,8 @@
module Main where
import RIO
import Conduit
import Conduit.Config
import RIO
main :: IO ()
main = do

16
fourmolu.yaml Normal file
View File

@ -0,0 +1,16 @@
indentation: 4
column-limit: 120
function-arrows: trailing
comma-style: leading
import-export-style: leading
indent-wheres: true
record-brace-space: true
newlines-between-decls: 1
haddock-style: single-line
haddock-style-module: null
let-style: inline
in-style: right-align
single-constraint-parens: always
unicode: never
respectful: true
fixities: []

View File

@ -58,6 +58,7 @@ default-extensions:
library:
source-dirs: src
language: GHC2021
executables:
conduit-server-exe:
@ -69,6 +70,7 @@ executables:
- -with-rtsopts=-N
dependencies:
- conduit-server
language: GHC2021
tests:
conduit-server-test:
@ -84,3 +86,4 @@ tests:
- hspec-wai
- QuickCheck
- conduit-server
language: GHC2021

View File

@ -1,35 +1,34 @@
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE UnicodeSyntax #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE CPP #-}
{-# LANGUAGE TypeOperators #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE TypeOperators #-}
{-# LANGUAGE UnicodeSyntax #-}
module Conduit where
import RIO
import Prelude (putStrLn)
import Data.Default
import Conduit.Api
import Conduit.App
import Conduit.Auth
import Conduit.Config
import Conduit.Core.User
import Conduit.Db
import Control.Monad.Extra
import Crypto.JOSE.JWK hiding (Context)
import Data.Default
import GHC.Generics (Generic)
import Servant hiding (runHandler)
import Servant.Server.Experimental.Auth
import Hasql.Pool (acquire)
import Network.Wai (Middleware, Request, requestHeaders)
import qualified Network.Wai.Handler.Warp as Warp
import Network.Wai.Middleware.RequestLogger
import Network.Wai.Middleware.RequestLogger.JSON
import Crypto.JOSE.JWK hiding (Context)
import Hasql.Pool (acquire)
import Conduit.Config
import Conduit.Auth
import Conduit.Api
import Conduit.Core.User
import Conduit.Db
import Conduit.App
import RIO
import Servant hiding (runHandler)
import Servant.Server.Experimental.Auth
import Prelude (putStrLn)
type AuthContext = AuthHandler Request User ': AuthHandler Request (Maybe User) ': '[]
@ -43,6 +42,7 @@ mkApp env =
serverAuthContext :: Context AuthContext
serverAuthContext = handleAuthentication env :. handleOptionalAuthentication env :. EmptyContext
hoistedServer :: ServerT ConduitApi Servant.Handler
hoistedServer = hoistServerWithContext api (Proxy :: Proxy AuthContext) (runHandler env) conduitServer
runConduit :: Config -> IO ()
@ -52,7 +52,8 @@ runConduit cfg = do
result <- autoMigrate pool
whenJust result $ \e -> do
error $ show e
let env = AppEnv
let env =
AppEnv
{ envDbPool = pool
, envJwtKey = jwtKey
}
@ -61,11 +62,12 @@ runConduit cfg = do
runApplication :: Int -> AppEnv -> IO ()
runApplication port env = do
warpLogger <- jsonRequestLogger
let warpSettings = Warp.defaultSettings
& Warp.setPort port
& Warp.setTimeout 60
let warpSettings =
Warp.defaultSettings
& Warp.setPort port
& Warp.setTimeout 60
Warp.runSettings warpSettings $ warpLogger $ mkApp env
jsonRequestLogger :: IO Middleware
jsonRequestLogger =
mkRequestLogger $ def { outputFormat = CustomOutputFormatWithDetails formatAsJSON }
mkRequestLogger $ def {outputFormat = CustomOutputFormatWithDetails formatAsJSON}

View File

@ -1,31 +1,34 @@
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE TypeOperators #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE TypeOperators #-}
module Conduit.Api where
import Conduit.Api.Article
import Conduit.Api.Auth
import Conduit.Api.Comment
import Conduit.Api.Profile
import Conduit.Api.Tag
import Conduit.Api.User
import Conduit.App
import RIO
import Servant
import Conduit.App
import Conduit.Api.Auth
import Conduit.Api.User
import Conduit.Api.Profile
import Conduit.Api.Article
import Conduit.Api.Comment
import Conduit.Api.Tag
type ConduitApi = "api" :> ( AuthApi
:<|> UserApi
:<|> ProfileApi
:<|> ArticleApi
:<|> CommentApi
:<|> TagApi
)
type ConduitApi =
"api"
:> ( AuthApi
:<|> UserApi
:<|> ProfileApi
:<|> ArticleApi
:<|> CommentApi
:<|> TagApi
)
conduitServer :: ServerT ConduitApi AppM
conduitServer = authServer
:<|> userServer
:<|> profileServer
:<|> articleServer
:<|> commentServer
:<|> tagServer
conduitServer =
authServer
:<|> userServer
:<|> profileServer
:<|> articleServer
:<|> commentServer
:<|> tagServer

View File

@ -1,30 +1,31 @@
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE TypeOperators #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE TypeOperators #-}
module Conduit.Api.Article where
import RIO
import Data.Aeson
import Servant
import Data.Time
import Control.Monad.Trans.Maybe
import Conduit.Api.Common
import Conduit.App
import Conduit.Core.User
import Conduit.Core.Article
import Conduit.Core.User
import qualified Conduit.Repository.Article as ArticleRepository
import qualified Conduit.Repository.User as UserRepository
import Conduit.Api.Common
import Conduit.Util
import Control.Monad.Trans.Maybe
import Data.Aeson
import Data.Time
import RIO
import Servant
data NewArticleData = NewArticleData
{ newArticleTitle :: Text
{ newArticleTitle :: Text
, newArticleDescription :: Text
, newArticleBody :: Text
, newArticleTagList :: [Text]
} deriving (Eq, Show, Generic)
, newArticleBody :: Text
, newArticleTagList :: [Text]
}
deriving (Eq, Show, Generic)
instance FromJSON NewArticleData where
parseJSON = genericParseJSON $ toJsonOptions 10
@ -33,27 +34,29 @@ instance ToJSON NewArticleData where
toJSON = genericToJSON $ toJsonOptions 10
data UpdateArticleData = UpdateArticleData
{ updateArticleTitle :: Maybe Text
{ updateArticleTitle :: Maybe Text
, updateArticleDescription :: Maybe Text
, updateArticleBody :: Maybe Text
, updateArticleTagList :: Maybe [Text]
} deriving (Show, Generic)
, updateArticleBody :: Maybe Text
, updateArticleTagList :: Maybe [Text]
}
deriving (Show, Generic)
instance FromJSON UpdateArticleData where
parseJSON = genericParseJSON $ toJsonOptions 13
data ArticleData = ArticleData
{ articleDataSlug :: Text
, articleDataTitle :: Text
, articleDataDescription :: Text
, articleDataBody :: Text
, articleDataTagList :: [Text]
, articleDataCreatedAt :: UTCTime
, articleDataUpdatedAt :: UTCTime
, articleDataFavorited :: Bool
{ articleDataSlug :: Text
, articleDataTitle :: Text
, articleDataDescription :: Text
, articleDataBody :: Text
, articleDataTagList :: [Text]
, articleDataCreatedAt :: UTCTime
, articleDataUpdatedAt :: UTCTime
, articleDataFavorited :: Bool
, articleDataFavoritesCount :: Int64
, articleDataAuthor :: UserProfile
} deriving (Eq, Show, Generic)
, articleDataAuthor :: UserProfile
}
deriving (Eq, Show, Generic)
instance ToJSON ArticleData where
toJSON = genericToJSON $ toJsonOptions 11
@ -62,7 +65,8 @@ instance FromJSON ArticleData where
parseJSON = genericParseJSON $ toJsonOptions 11
newtype BoxedArticle a = BoxedArticle
{ boxedArticle :: a } deriving (Eq, Show, Generic)
{boxedArticle :: a}
deriving (Eq, Show, Generic)
instance ToJSON a => ToJSON (BoxedArticle a) where
toJSON = genericToJSON $ toJsonOptions 5
@ -72,8 +76,9 @@ instance FromJSON a => FromJSON (BoxedArticle a) where
data ArticlesResponse = ArticlesResponse
{ articlesRespArticlesCount :: Int64
, articlesRespArticles :: [ArticleData]
} deriving (Eq, Show, Generic)
, articlesRespArticles :: [ArticleData]
}
deriving (Eq, Show, Generic)
instance ToJSON ArticlesResponse where
toJSON = genericToJSON $ toJsonOptions 12
@ -84,65 +89,67 @@ instance FromJSON ArticlesResponse where
mapArticleToArticleData :: Article -> UserProfile -> Bool -> Int64 -> ArticleData
mapArticleToArticleData article authorProfile favorited favoritedCount =
ArticleData
{ articleDataSlug = getSlug $ articleSlug article
, articleDataTitle = articleTitle article
, articleDataDescription = articleDescription article
, articleDataBody = articleBody article
, articleDataTagList = articleTags article
, articleDataCreatedAt = articleCreatedAt article
, articleDataUpdatedAt = articleUpdatedAt article
, articleDataFavorited = favorited
{ articleDataSlug = getSlug $ articleSlug article
, articleDataTitle = articleTitle article
, articleDataDescription = articleDescription article
, articleDataBody = articleBody article
, articleDataTagList = articleTags article
, articleDataCreatedAt = articleCreatedAt article
, articleDataUpdatedAt = articleUpdatedAt article
, articleDataFavorited = favorited
, articleDataFavoritesCount = favoritedCount
, articleDataAuthor = authorProfile
, articleDataAuthor = authorProfile
}
mapEnrichedArticleToArticleData :: ArticleRepository.EnrichedArticle -> ArticleData
mapEnrichedArticleToArticleData (ArticleRepository.EnrichedArticle article author followingAuthor favorited favoritedCount) =
mapArticleToArticleData article (mapUserToUserProfile author followingAuthor) favorited favoritedCount
type ArticleApi = AuthProtect "Optional"
:> "articles"
:> QueryParam "page" Int64
:> QueryParam "pageSize" Int64
:> QueryParam "author" Text
:> QueryParam "favorited" Text
:> QueryParam "tag" Text
:> Get '[JSON] ArticlesResponse
:<|> AuthProtect "Optional"
:> "articles"
:> Capture "slug" Slug
:> Get '[JSON] (BoxedArticle ArticleData)
:<|> AuthProtect "Required"
:> "articles"
:> ReqBody '[JSON] (BoxedArticle NewArticleData)
:> Post '[JSON] (BoxedArticle ArticleData)
:<|> AuthProtect "Required"
:> "articles"
:> Capture "slug" Slug
:> ReqBody '[JSON] (BoxedArticle UpdateArticleData)
:> Put '[JSON] (BoxedArticle ArticleData)
:<|> AuthProtect "Required"
:> "articles"
:> Capture "slug" Slug
:> Delete '[JSON] NoContent
:<|> AuthProtect "Required"
:> "articles"
:> Capture "slug" Slug
:> "favorite"
:> Post '[JSON] (BoxedArticle ArticleData)
:<|> AuthProtect "Required"
:> "articles"
:> Capture "slug" Slug
:> "favorite"
:> Delete '[JSON] (BoxedArticle ArticleData)
type ArticleApi =
AuthProtect "Optional"
:> "articles"
:> QueryParam "page" Int64
:> QueryParam "pageSize" Int64
:> QueryParam "author" Text
:> QueryParam "favorited" Text
:> QueryParam "tag" Text
:> Get '[JSON] ArticlesResponse
:<|> AuthProtect "Optional"
:> "articles"
:> Capture "slug" Slug
:> Get '[JSON] (BoxedArticle ArticleData)
:<|> AuthProtect "Required"
:> "articles"
:> ReqBody '[JSON] (BoxedArticle NewArticleData)
:> Post '[JSON] (BoxedArticle ArticleData)
:<|> AuthProtect "Required"
:> "articles"
:> Capture "slug" Slug
:> ReqBody '[JSON] (BoxedArticle UpdateArticleData)
:> Put '[JSON] (BoxedArticle ArticleData)
:<|> AuthProtect "Required"
:> "articles"
:> Capture "slug" Slug
:> Delete '[JSON] NoContent
:<|> AuthProtect "Required"
:> "articles"
:> Capture "slug" Slug
:> "favorite"
:> Post '[JSON] (BoxedArticle ArticleData)
:<|> AuthProtect "Required"
:> "articles"
:> Capture "slug" Slug
:> "favorite"
:> Delete '[JSON] (BoxedArticle ArticleData)
getArticlesHandler :: Maybe User
-> Maybe Int64
-> Maybe Int64
-> Maybe Text
-> Maybe Text
-> Maybe Text
-> AppM ArticlesResponse
getArticlesHandler ::
Maybe User ->
Maybe Int64 ->
Maybe Int64 ->
Maybe Text ->
Maybe Text ->
Maybe Text ->
AppM ArticlesResponse
getArticlesHandler mbUser mbPage mbPageSize mbAuthorName mbFavorited mbTag = do
(pagedResults, total) <- do
tagId <- maybe (return Nothing) ArticleRepository.getTagId mbTag
@ -159,40 +166,44 @@ getArticlesHandler mbUser mbPage mbPageSize mbAuthorName mbFavorited mbTag = do
where
isValidParam :: Maybe a -> Maybe b -> Bool
isValidParam (Just _) Nothing = False
isValidParam _ _ = True
isValidParam _ _ = True
withValidFilter :: forall a b c . Maybe a -> Maybe b -> AppM ([c], Int64) -> AppM ([c], Int64)
withValidFilter :: forall a b c. Maybe a -> Maybe b -> AppM ([c], Int64) -> AppM ([c], Int64)
withValidFilter a b m
| isValidParam a b = m
| otherwise = return ([], 0)
| otherwise = return ([], 0)
getArticleBySlugHandler :: Maybe User -> Slug -> AppM (BoxedArticle ArticleData)
getArticleBySlugHandler mbUser slug = do
ArticleRepository.getEnrichedArticleBySlug mbUser slug
>>= maybe (throwIO err404)
(return . BoxedArticle . mapEnrichedArticleToArticleData)
>>= maybe
(throwIO err404)
(return . BoxedArticle . mapEnrichedArticleToArticleData)
getArticleByIdHandler :: Maybe User -> ArticleId -> AppM (BoxedArticle ArticleData)
getArticleByIdHandler mbUser articleId =
ArticleRepository.getEnrichedArticleById mbUser articleId
>>= maybe (throwIO err404)
(return . BoxedArticle . mapEnrichedArticleToArticleData)
>>= maybe
(throwIO err404)
(return . BoxedArticle . mapEnrichedArticleToArticleData)
createNewArticleHandler :: User -> BoxedArticle NewArticleData -> AppM (BoxedArticle ArticleData)
createNewArticleHandler user (BoxedArticle newArticle) = do
currentTime <- liftIO getCurrentTime
slug <- mkSlug title
article <- ArticleRepository.createArticle $ Article
{ articleAuthorId = userId user
, articleId = ArticleId 0
, articleTitle = title
, articleSlug = slug
, articleDescription = description
, articleBody = body
, articleTags = tags
, articleCreatedAt = currentTime
, articleUpdatedAt = currentTime
}
article <-
ArticleRepository.createArticle $
Article
{ articleAuthorId = userId user
, articleId = ArticleId 0
, articleTitle = title
, articleSlug = slug
, articleDescription = description
, articleBody = body
, articleTags = tags
, articleCreatedAt = currentTime
, articleUpdatedAt = currentTime
}
flipMaybe article (throwIO err400) $ \article' ->
return $ BoxedArticle $ mapArticleToArticleData article' (mapUserToUserProfile user False) False 0
where
@ -207,20 +218,20 @@ updateArticleHandler user slug (BoxedArticle updateData) =
where
updateArticle article =
if articleAuthorId article /= userId user
then
throwIO err403
else do
currentTime <- liftIO getCurrentTime
let updatedArticle = article
{ articleTitle = fromMaybe (articleTitle article) (updateArticleTitle updateData)
, articleDescription = fromMaybe (articleDescription article) (updateArticleDescription updateData)
, articleBody = fromMaybe (articleBody article) (updateArticleBody updateData)
, articleTags = fromMaybe (articleTags article) (updateArticleTagList updateData)
, articleUpdatedAt = currentTime
}
ArticleRepository.updateArticle updatedArticle
favoritedCount <- ArticleRepository.getArticleFavoritedCount (articleId article)
return $ BoxedArticle $ mapArticleToArticleData updatedArticle (mapUserToUserProfile user False) False favoritedCount
then throwIO err403
else do
currentTime <- liftIO getCurrentTime
let updatedArticle =
article
{ articleTitle = fromMaybe (articleTitle article) (updateArticleTitle updateData)
, articleDescription = fromMaybe (articleDescription article) (updateArticleDescription updateData)
, articleBody = fromMaybe (articleBody article) (updateArticleBody updateData)
, articleTags = fromMaybe (articleTags article) (updateArticleTagList updateData)
, articleUpdatedAt = currentTime
}
ArticleRepository.updateArticle updatedArticle
favoritedCount <- ArticleRepository.getArticleFavoritedCount (articleId article)
return $ BoxedArticle $ mapArticleToArticleData updatedArticle (mapUserToUserProfile user False) False favoritedCount
deleteArticleHandler :: User -> Slug -> AppM NoContent
deleteArticleHandler user slug =
@ -228,11 +239,10 @@ deleteArticleHandler user slug =
where
deleteArticle article =
if articleAuthorId article /= userId user
then
throwIO err403
else do
ArticleRepository.deleteArticleById (articleId article)
return NoContent
then throwIO err403
else do
ArticleRepository.deleteArticleById (articleId article)
return NoContent
favoriteArticleHandler :: User -> Slug -> AppM (BoxedArticle ArticleData)
favoriteArticleHandler user slug =
@ -240,18 +250,16 @@ favoriteArticleHandler user slug =
where
favoriteArticle article =
if articleAuthorId article == userId user
then
throwIO err403
else do
isFavorited <- ArticleRepository.checkFavorite user (articleId article)
if isFavorited
then
throwIO err400
then throwIO err403
else do
success <- ArticleRepository.addFavorite user (articleId article)
if success then
getArticleByIdHandler (Just user) (articleId article)
else throwIO err400
isFavorited <- ArticleRepository.checkFavorite user (articleId article)
if isFavorited
then throwIO err400
else do
success <- ArticleRepository.addFavorite user (articleId article)
if success
then getArticleByIdHandler (Just user) (articleId article)
else throwIO err400
unFavoriteArticleHandler :: User -> Slug -> AppM (BoxedArticle ArticleData)
unFavoriteArticleHandler user slug =
@ -260,19 +268,19 @@ unFavoriteArticleHandler user slug =
unFavoriteArticle article = do
isFavorited <- ArticleRepository.checkFavorite user (articleId article)
if not isFavorited
then
throwIO err400
else do
success <- ArticleRepository.removeFavorite user (articleId article)
if success then
getArticleByIdHandler (Just user) (articleId article)
else throwIO err400
then throwIO err400
else do
success <- ArticleRepository.removeFavorite user (articleId article)
if success
then getArticleByIdHandler (Just user) (articleId article)
else throwIO err400
articleServer :: ServerT ArticleApi AppM
articleServer = getArticlesHandler
:<|> getArticleBySlugHandler
:<|> createNewArticleHandler
:<|> updateArticleHandler
:<|> deleteArticleHandler
:<|> favoriteArticleHandler
:<|> unFavoriteArticleHandler
articleServer =
getArticlesHandler
:<|> getArticleBySlugHandler
:<|> createNewArticleHandler
:<|> updateArticleHandler
:<|> deleteArticleHandler
:<|> favoriteArticleHandler
:<|> unFavoriteArticleHandler

View File

@ -1,26 +1,27 @@
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE TypeOperators #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE TypeOperators #-}
module Conduit.Api.Auth where
import RIO
import Data.Aeson
import Data.Aeson.Types
import Servant
import Conduit.App
import Conduit.Api.Common
import qualified Conduit.Repository.User as UserRepository
import Conduit.App
import Conduit.Core.Password
import Conduit.Core.User
import Conduit.JWT
import Conduit.Util
import Conduit.Environment
import Conduit.JWT
import qualified Conduit.Repository.User as UserRepository
import Conduit.Util
import Data.Aeson
import Data.Aeson.Types
import RIO
import Servant
data LoginUser = LoginUser
{ loginEmail :: Text
, loginPassword :: Text
} deriving (Show, Generic)
}
deriving (Show, Generic)
instance ToJSON LoginUser where
toJSON = genericToJSON $ toJsonOptions 5
@ -30,9 +31,10 @@ instance FromJSON LoginUser where
data NewUser = NewUser
{ newUserUsername :: Text
, newUserEmail :: Text
, newUserEmail :: Text
, newUserPassword :: Text
} deriving (Show, Generic)
}
deriving (Show, Generic)
instance ToJSON NewUser where
toJSON = genericToJSON $ toJsonOptions 7
@ -42,11 +44,12 @@ instance FromJSON NewUser where
data LoginResponse = LoginResponse
{ loginRespUsername :: Text
, loginRespEmail :: Text
, loginRespToken :: Text
, loginRespBio :: Text
, loginRespImage :: Text
} deriving (Eq, Show, Generic)
, loginRespEmail :: Text
, loginRespToken :: Text
, loginRespBio :: Text
, loginRespImage :: Text
}
deriving (Eq, Show, Generic)
instance ToJSON LoginResponse where
toJSON = genericToJSON $ toJsonOptions 9
@ -55,23 +58,25 @@ instance FromJSON LoginResponse where
parseJSON = genericParseJSON $ toJsonOptions 9
mapUserToLoginResponse :: User -> Text -> LoginResponse
mapUserToLoginResponse user token = LoginResponse
{ loginRespUsername = getUsername $ userName user
, loginRespEmail = getEmailAddress $ userEmail user
, loginRespToken = token
, loginRespBio = userBio user
, loginRespImage = userImage user
}
mapUserToLoginResponse user token =
LoginResponse
{ loginRespUsername = getUsername $ userName user
, loginRespEmail = getEmailAddress $ userEmail user
, loginRespToken = token
, loginRespBio = userBio user
, loginRespImage = userImage user
}
{---------------------------------------------------------------------------------------}
type AuthApi = "users"
:> "login"
:> ReqBody '[JSON] (UserData LoginUser)
:> Post '[JSON] (UserData LoginResponse)
:<|> "users"
:> ReqBody '[JSON] (UserData NewUser)
:> Post '[JSON] (UserData LoginResponse)
type AuthApi =
"users"
:> "login"
:> ReqBody '[JSON] (UserData LoginUser)
:> Post '[JSON] (UserData LoginResponse)
:<|> "users"
:> ReqBody '[JSON] (UserData NewUser)
:> Post '[JSON] (UserData LoginResponse)
loginHandler :: UserData LoginUser -> AppM (UserData LoginResponse)
loginHandler (UserData u) =
@ -93,8 +98,9 @@ genUserResponse user = do
let username = userName user
jwtKey <- getJwtKey'
claims <- liftIO $ mkClaims username
liftIO $ signJwt jwtKey claims
>>= either (\_ -> throwIO err422) (return . mapUserToLoginResponse user)
liftIO $
signJwt jwtKey claims
>>= either (\_ -> throwIO err422) (return . mapUserToLoginResponse user)
authServer :: ServerT AuthApi AppM
authServer = loginHandler :<|> registerHandler

View File

@ -1,33 +1,33 @@
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE TypeOperators #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE TypeOperators #-}
module Conduit.Api.Comment where
import RIO
import Control.Monad.Trans.Except
import Data.Aeson
import Data.UUID
import Data.Time
import Servant
import Conduit.App
import Conduit.Core.Comment
import Conduit.Core.Article
import Conduit.Core.User
import Conduit.Api.Common
import Conduit.App
import Conduit.Core.Article
import Conduit.Core.Comment
import Conduit.Core.User
import qualified Conduit.Repository.Article as ArticleRepository
import qualified Conduit.Repository.Comment as CommentRepository
import Conduit.Util
import Control.Monad.Trans.Except
import Data.Aeson
import Data.Time
import Data.UUID
import RIO
import Servant
data CommentData = CommentData
{ commentDataId :: UUID
, commentDataBody :: Text
, commentDataCreatedAt :: UTCTime
, commentDataUpdatedAt :: UTCTime
, commentDataAuthor :: UserProfile
} deriving (Eq, Show, Generic)
{ commentDataId :: UUID
, commentDataBody :: Text
, commentDataCreatedAt :: UTCTime
, commentDataUpdatedAt :: UTCTime
, commentDataAuthor :: UserProfile
}
deriving (Eq, Show, Generic)
instance ToJSON CommentData where
toJSON = genericToJSON $ toJsonOptions 11
@ -36,7 +36,8 @@ instance FromJSON CommentData where
parseJSON = genericParseJSON $ toJsonOptions 11
newtype NewCommentData = NewCommentData
{ newCommentBody :: Text } deriving (Generic)
{newCommentBody :: Text}
deriving (Generic)
instance ToJSON NewCommentData where
toJSON = genericToJSON $ toJsonOptions 10
@ -45,7 +46,8 @@ instance FromJSON NewCommentData where
parseJSON = genericParseJSON $ toJsonOptions 10
newtype BoxedComment a = BoxedComment
{ unBoxedComment :: a } deriving (Eq, Show, Generic)
{unBoxedComment :: a}
deriving (Eq, Show, Generic)
instance FromJSON a => FromJSON (BoxedComment a) where
parseJSON = genericParseJSON $ toJsonOptions 7
@ -54,7 +56,8 @@ instance ToJSON a => ToJSON (BoxedComment a) where
toJSON = genericToJSON $ toJsonOptions 7
newtype CommentsResponse = CommentsResponse
{ cmtRespComments :: [CommentData] } deriving (Generic)
{cmtRespComments :: [CommentData]}
deriving (Generic)
instance ToJSON CommentsResponse where
toJSON = genericToJSON $ toJsonOptions 7
@ -62,75 +65,88 @@ instance ToJSON CommentsResponse where
mapEnrichedCommentToCommentData :: (Comment, User, Bool) -> CommentData
mapEnrichedCommentToCommentData (comment, author, followingAuthor) =
CommentData
{ commentDataId = commentUUID comment
, commentDataBody = commentBody comment
{ commentDataId = commentUUID comment
, commentDataBody = commentBody comment
, commentDataCreatedAt = commentCreatedAt comment
, commentDataUpdatedAt = commentUpdatedAt comment
, commentDataAuthor = mapUserToUserProfile author followingAuthor
, commentDataAuthor = mapUserToUserProfile author followingAuthor
}
type CommentApi = "articles"
:> Capture "slug" Slug
:> "comments"
:> (
AuthProtect "Optional"
:> Get '[JSON] CommentsResponse
:<|> AuthProtect "Required"
:> ReqBody '[JSON] (BoxedComment NewCommentData)
:> Post '[JSON] (BoxedComment CommentData)
:<|> AuthProtect "Required"
:> Capture "commentId" UUID
:> Delete '[JSON] NoContent
)
type CommentApi =
"articles"
:> Capture "slug" Slug
:> "comments"
:> ( AuthProtect "Optional"
:> Get '[JSON] CommentsResponse
:<|> AuthProtect "Required"
:> ReqBody '[JSON] (BoxedComment NewCommentData)
:> Post '[JSON] (BoxedComment CommentData)
:<|> AuthProtect "Required"
:> Capture "commentId" UUID
:> Delete '[JSON] NoContent
)
getAllComments :: Slug -> Maybe User -> AppM CommentsResponse
getAllComments slug mbUser =
ArticleRepository.getArticleBySlug slug
>>= maybe (throwIO err404)
(\article -> do
comments <- CommentRepository.getEnrichedCommentsByArticleId mbUser (articleId article)
return $ CommentsResponse $ map mapEnrichedCommentToCommentData comments)
>>= maybe
(throwIO err404)
( \article -> do
comments <- CommentRepository.getEnrichedCommentsByArticleId mbUser (articleId article)
return $ CommentsResponse $ map mapEnrichedCommentToCommentData comments
)
createComment :: Slug
-> User
-> BoxedComment NewCommentData
-> AppM (BoxedComment CommentData)
createComment ::
Slug ->
User ->
BoxedComment NewCommentData ->
AppM (BoxedComment CommentData)
createComment slug user (BoxedComment newComment) =
ArticleRepository.getArticleBySlug slug >>= maybe (throwIO err404) createComment
where
createComment article = do
currentTime <- liftIO getCurrentTime
uuid <- liftIO newUUID
comment <- CommentRepository.addComment $ Comment
{ commentId = CommentId 0
, commentUUID = uuid
, commentBody = newCommentBody newComment
, commentArticleId = articleId article
, commentAuthorId = userId user
, commentCreatedAt = currentTime
, commentUpdatedAt = currentTime
}
comment <-
CommentRepository.addComment $
Comment
{ commentId = CommentId 0
, commentUUID = uuid
, commentBody = newCommentBody newComment
, commentArticleId = articleId article
, commentAuthorId = userId user
, commentCreatedAt = currentTime
, commentUpdatedAt = currentTime
}
flipMaybe comment (throwIO err404) $ \comment' ->
return $ BoxedComment $ mapEnrichedCommentToCommentData (comment', user, False)
deleteComment :: Slug
-> User
-> UUID
-> AppM NoContent
deleteComment ::
Slug ->
User ->
UUID ->
AppM NoContent
deleteComment slug user uuid =
ArticleRepository.getArticleBySlug slug >>= maybe (throwIO err404) (\article -> do
CommentRepository.getCommentByUUID uuid >>= maybe (throwIO err404) (\comment -> do
if (commentAuthorId comment /= userId user) || (commentArticleId comment /= articleId article)
then
throwIO err403
else do
success <- CommentRepository.deleteCommentById (commentId comment)
if success
then return NoContent
else throwIO err400
))
ArticleRepository.getArticleBySlug slug
>>= maybe
(throwIO err404)
( \article -> do
CommentRepository.getCommentByUUID uuid
>>= maybe
(throwIO err404)
( \comment -> do
if (commentAuthorId comment /= userId user) || (commentArticleId comment /= articleId article)
then throwIO err403
else do
success <- CommentRepository.deleteCommentById (commentId comment)
if success
then return NoContent
else throwIO err400
)
)
commentServer :: ServerT CommentApi AppM
commentServer slug = getAllComments slug
:<|> createComment slug
:<|> deleteComment slug
commentServer slug =
getAllComments slug
:<|> createComment slug
:<|> deleteComment slug

View File

@ -1,13 +1,13 @@
{-# LANGUAGE DeriveGeneric #-}
module Conduit.Api.Common where
import RIO
import Data.Aeson
import Conduit.Util
import Conduit.Core.User
import Conduit.Util
import Data.Aeson
import RIO
newtype UserData a = UserData { userData :: a }
newtype UserData a = UserData {userData :: a}
deriving (Eq, Show, Generic)
instance ToJSON a => ToJSON (UserData a) where
@ -18,7 +18,7 @@ instance FromJSON a => FromJSON (UserData a) where
a <- o .: "user"
return (UserData a)
newtype Profile a = Profile { profile :: a }
newtype Profile a = Profile {profile :: a}
deriving (Show, Generic)
instance ToJSON a => ToJSON (Profile a) where
@ -30,11 +30,12 @@ instance FromJSON a => FromJSON (Profile a) where
return (Profile a)
data UserProfile = UserProfile
{ profileUsername :: Text
, profileBio :: Text
, profileImage :: Text
{ profileUsername :: Text
, profileBio :: Text
, profileImage :: Text
, profileFollowing :: Bool
} deriving (Eq, Show, Generic)
}
deriving (Eq, Show, Generic)
instance ToJSON UserProfile where
toJSON = genericToJSON $ toJsonOptions 7
@ -43,9 +44,10 @@ instance FromJSON UserProfile where
parseJSON = genericParseJSON $ toJsonOptions 7
mapUserToUserProfile :: User -> Bool -> UserProfile
mapUserToUserProfile user following = UserProfile
{ profileUsername = getUsername $ userName user
, profileBio = userBio user
, profileImage = userImage user
, profileFollowing = following
}
mapUserToUserProfile user following =
UserProfile
{ profileUsername = getUsername $ userName user
, profileBio = userBio user
, profileImage = userImage user
, profileFollowing = following
}

View File

@ -1,28 +1,34 @@
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE TypeOperators #-}
module Conduit.Api.Profile where
import RIO
import Data.Aeson
import Servant
import qualified Data.Text as T
import Data.Maybe
import Conduit.Api.Common
import Conduit.App
import Conduit.Core.User
import Conduit.Api.Common
import qualified Conduit.Repository.User as UserRepository
import Conduit.Util
import Data.Aeson
import Data.Maybe
import qualified Data.Text as T
import RIO
import Servant
type ProfileApi = AuthProtect "Optional"
:> "profiles" :> Capture "username" Text
:> Get '[JSON] (Profile UserProfile)
:<|> AuthProtect "Required"
:> "profiles" :> Capture "username" Text :> "follow"
:> Post '[JSON] (Profile UserProfile)
:<|> AuthProtect "Required"
:> "profiles" :> Capture "username" Text :> "follow"
:> Delete '[JSON] (Profile UserProfile)
type ProfileApi =
AuthProtect "Optional"
:> "profiles"
:> Capture "username" Text
:> Get '[JSON] (Profile UserProfile)
:<|> AuthProtect "Required"
:> "profiles"
:> Capture "username" Text
:> "follow"
:> Post '[JSON] (Profile UserProfile)
:<|> AuthProtect "Required"
:> "profiles"
:> Capture "username" Text
:> "follow"
:> Delete '[JSON] (Profile UserProfile)
getProfileHandler :: Maybe User -> Text -> AppM (Profile UserProfile)
getProfileHandler mbUser targetUsername
@ -32,10 +38,12 @@ getProfileHandler mbUser targetUsername
return $ Profile $ mapUserToUserProfile user False
| otherwise =
UserRepository.getUserByName (Username targetUsername)
>>= maybe (throwIO err404)
(\targetUser -> do
following <- flipMaybe mbUser (return False) $ \user -> UserRepository.checkFollowship user (userId targetUser)
return $ Profile $ mapUserToUserProfile targetUser following)
>>= maybe
(throwIO err404)
( \targetUser -> do
following <- flipMaybe mbUser (return False) $ \user -> UserRepository.checkFollowship user (userId targetUser)
return $ Profile $ mapUserToUserProfile targetUser following
)
followUserHandler :: User -> Text -> AppM (Profile UserProfile)
followUserHandler user targetUsername
@ -43,10 +51,12 @@ followUserHandler user targetUsername
| (getUsername . userName) user == targetUsername = throwIO err403
| otherwise = do
UserRepository.getUserByName (Username targetUsername)
>>= maybe (throwIO err404)
(\targetUser -> do
_ <- UserRepository.followUser user (userId targetUser)
return $ Profile $ mapUserToUserProfile targetUser True)
>>= maybe
(throwIO err404)
( \targetUser -> do
_ <- UserRepository.followUser user (userId targetUser)
return $ Profile $ mapUserToUserProfile targetUser True
)
unFollowUserHandler :: User -> Text -> AppM (Profile UserProfile)
unFollowUserHandler user targetUsername
@ -54,12 +64,15 @@ unFollowUserHandler user targetUsername
| (getUsername . userName) user == targetUsername = throwIO err403
| otherwise =
UserRepository.getUserByName (Username targetUsername)
>>= maybe (throwIO err404)
(\targetUser -> do
_ <- UserRepository.unfollowUser user (userId targetUser)
return $ Profile $ mapUserToUserProfile targetUser False)
>>= maybe
(throwIO err404)
( \targetUser -> do
_ <- UserRepository.unfollowUser user (userId targetUser)
return $ Profile $ mapUserToUserProfile targetUser False
)
profileServer :: ServerT ProfileApi AppM
profileServer = getProfileHandler
:<|> followUserHandler
:<|> unFollowUserHandler
profileServer =
getProfileHandler
:<|> followUserHandler
:<|> unFollowUserHandler

View File

@ -1,22 +1,21 @@
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE TypeOperators #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE TypeOperators #-}
module Conduit.Api.Tag where
import RIO
import Rel8
import Conduit.App
import Conduit.Db
import Data.Aeson
import Hasql.Transaction (statement)
import RIO
import Rel8
import Servant
import Conduit.Db
import Conduit.App
newtype TagsResponse = TagsResponse
{ tags :: [Text] } deriving (Generic)
{tags :: [Text]}
deriving (Generic)
instance ToJSON TagsResponse where
toJSON (TagsResponse a) = object ["tags" .= a]

View File

@ -1,25 +1,26 @@
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE TypeOperators #-}
{-# LANGUAGE DeriveGeneric #-}
module Conduit.Api.User where
{-# LANGUAGE TypeOperators #-}
import RIO
import Data.Aeson
import Servant
module Conduit.Api.User where
import Conduit.Api.Common
import Conduit.App
import Conduit.Core.User
import Conduit.Core.Password
import Conduit.Core.User
import qualified Conduit.Repository.User as UserRepository
import Conduit.Util
import Data.Aeson
import RIO
import Servant
data UserResponse = UserResponse
{ urUsername :: Text
, urEmail :: Text
, urBio :: Text
, urImage :: Text
} deriving (Show, Eq, Generic)
, urEmail :: Text
, urBio :: Text
, urImage :: Text
}
deriving (Show, Eq, Generic)
instance ToJSON UserResponse where
toJSON = genericToJSON $ toJsonOptions 2
@ -29,30 +30,33 @@ instance FromJSON UserResponse where
data UpdateUserRequest = UpdateUserRequest
{ uurUsername :: Maybe Text
, uurEmail :: Maybe Text
, uurEmail :: Maybe Text
, uurPassword :: Maybe Text
, uurBio :: Maybe Text
, uurImage :: Maybe Text
} deriving (Show, Generic)
, uurBio :: Maybe Text
, uurImage :: Maybe Text
}
deriving (Show, Generic)
instance FromJSON UpdateUserRequest where
parseJSON = genericParseJSON $ toJsonOptions 3
mapUserToUserResponse :: User -> UserResponse
mapUserToUserResponse user = UserResponse
{ urUsername = getUsername $ userName user
, urEmail = getEmailAddress $ userEmail user
, urBio = userBio user
, urImage = userImage user
}
mapUserToUserResponse user =
UserResponse
{ urUsername = getUsername $ userName user
, urEmail = getEmailAddress $ userEmail user
, urBio = userBio user
, urImage = userImage user
}
type UserApi = AuthProtect "Required"
:> "user"
:> Get '[JSON] (UserData UserResponse)
:<|> AuthProtect "Required"
:> "user"
:> ReqBody '[JSON] (UserData UpdateUserRequest)
:> Put '[JSON] (UserData UserResponse)
type UserApi =
AuthProtect "Required"
:> "user"
:> Get '[JSON] (UserData UserResponse)
:<|> AuthProtect "Required"
:> "user"
:> ReqBody '[JSON] (UserData UpdateUserRequest)
:> Put '[JSON] (UserData UserResponse)
getUserHandler :: User -> AppM (UserData UserResponse)
getUserHandler = return . UserData . mapUserToUserResponse
@ -60,22 +64,22 @@ getUserHandler = return . UserData . mapUserToUserResponse
updateUserHandler :: User -> UserData UpdateUserRequest -> AppM (UserData UserResponse)
updateUserHandler user (UserData user') = do
succeed <- UserRepository.updateUser updatedUser newPassword
if succeed then
return $ UserData $ mapUserToUserResponse updatedUser
else
throwIO err400
if succeed
then return $ UserData $ mapUserToUserResponse updatedUser
else throwIO err400
where
newName = maybe (userName user) Username (uurUsername user')
newEmail = maybe (userEmail user) EmailAddress (uurUsername user')
newPassword = Password <$> uurPassword user'
newBio = fromMaybe (userBio user) (uurBio user')
newImage = fromMaybe (userImage user) (uurImage user')
updatedUser = user
{ userName = newName
, userEmail = newEmail
, userBio = newBio
, userImage = newImage
}
updatedUser =
user
{ userName = newName
, userEmail = newEmail
, userBio = newBio
, userImage = newImage
}
userServer :: ServerT UserApi AppM
userServer = getUserHandler :<|> updateUserHandler

View File

@ -1,16 +1,16 @@
{-# LANGUAGE UnicodeSyntax #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE CPP #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE UnicodeSyntax #-}
module Conduit.App where
import RIO
import Hasql.Pool (Pool)
import Crypto.JOSE.JWK ( JWK )
import Control.Monad.Trans.Except ( ExceptT(ExceptT) )
import qualified Servant
import Conduit.Environment
import Control.Monad.Trans.Except (ExceptT (ExceptT))
import Crypto.JOSE.JWK (JWK)
import Hasql.Pool (Pool)
import RIO
import qualified Servant
data AppEnv = AppEnv
{ envDbPool :: Pool
@ -19,7 +19,7 @@ data AppEnv = AppEnv
type AppM = RIO AppEnv
runHandler AppEnv AppM a Servant.Handler a
runHandler :: AppEnv -> AppM a -> Servant.Handler a
runHandler env app = Servant.Handler $ ExceptT $ try $ runRIO env app
instance HasDbPool AppEnv where

View File

@ -1,53 +1,52 @@
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TypeFamilies #-}
module Conduit.Auth where
import RIO
import RIO.ByteString as B (stripPrefix)
import Conduit.App (AppEnv (envJwtKey))
import Conduit.Core.User (User (..), Username (..))
import Conduit.JWT (getSubject, verifyJwt)
import Conduit.Repository.User (getUserByName)
import Conduit.Util (hoistMaybe)
import Control.Monad.Trans.Maybe (MaybeT (..))
import Crypto.JOSE.JWK (JWK)
import Data.Either.Extra (eitherToMaybe)
import qualified Data.Text as T
import Data.Text qualified as T
import Network.Wai (Request, requestHeaders)
import RIO
import RIO.ByteString as B (stripPrefix)
import Servant (AuthProtect, err401, errBody, throwError)
import Servant.Server.Experimental.Auth
( AuthHandler,
mkAuthHandler,
)
import Conduit.App (AppEnv (envJwtKey))
import Conduit.Core.User (User (..), Username (..))
import Conduit.Repository.User (getUserByName)
import Conduit.JWT (getSubject, verifyJwt)
import Conduit.Util ( hoistMaybe )
import Servant.Server.Experimental.Auth (AuthHandler, mkAuthHandler)
handleOptionalAuthentication :: AppEnv -> AuthHandler Request (Maybe User)
handleOptionalAuthentication env =
let handler req = liftIO $ getUserFromJwtToken env req
in mkAuthHandler handler
let handler req = liftIO $ getUserFromJwtToken env req
in mkAuthHandler handler
handleAuthentication :: AppEnv -> AuthHandler Request User
handleAuthentication env =
let handler req = do
user <- liftIO $ getUserFromJwtToken env req
case user of
Just user' -> return user'
_ -> throwError $ err401 { errBody = "Invalid JWT Token" }
in mkAuthHandler handler
let handler req = do
user <- liftIO $ getUserFromJwtToken env req
case user of
Just user' -> return user'
_ -> throwError $ err401 {errBody = "Invalid JWT Token"}
in mkAuthHandler handler
getUserFromJwtToken :: AppEnv -> Request -> IO (Maybe User)
getUserFromJwtToken env req = runMaybeT $ do
token <- hoistMaybe $ lookup "Authorization" (requestHeaders req)
username <- decodeToken (envJwtKey env) token
MaybeT $ liftIO $ runRIO env $ getUserByName username
getUserFromJwtToken env req = runMaybeT $
do
token <- hoistMaybe $ lookup "Authorization" (requestHeaders req)
username <- decodeToken (envJwtKey env) token
MaybeT $ liftIO $ runRIO env $ getUserByName username
decodeToken :: JWK -> ByteString -> MaybeT IO Username
decodeToken jwk authToken = do
token <- hoistMaybe $ B.stripPrefix "Token " authToken
claimSet <- MaybeT $ eitherToMaybe <$> verifyJwt jwk token
subject <- hoistMaybe $ getSubject claimSet
if T.length subject > 0
then return $ Username subject
else hoistMaybe Nothing
token <-
hoistMaybe $
B.stripPrefix "Token " authToken <|> B.stripPrefix "Bearer " authToken
claimSet <- MaybeT $ eitherToMaybe <$> verifyJwt jwk token
subject <- hoistMaybe $ getSubject claimSet
if T.length subject > 0
then return $ Username subject
else hoistMaybe Nothing

View File

@ -2,17 +2,18 @@
module Conduit.Config where
import RIO
import Data.Char (toLower)
import System.Environment ( getEnv )
import Conduit.Util (exitWithErrorMessage)
import Data.Char (toLower)
import RIO
import System.Environment (getEnv)
data Config = Config
{ cfgPort :: Int
, cfgJwk :: Text
, cfgConnectString :: ByteString
, cfgPoolSize :: Int
} deriving (Generic, Show)
}
deriving (Generic, Show)
loadConfigFromEnv :: IO Config
loadConfigFromEnv = do
@ -21,16 +22,18 @@ loadConfigFromEnv = do
connectString <- getEnv' "" "POSTGRES_CONNECT_STRING"
poolSize <- getEnv' "1" "POSTGRES_POOL_SIZE"
if null jwk then
exitWithErrorMessage "Environment variable JWK_STRING is missing"
else if null connectString then
exitWithErrorMessage "Environment variable POSTGRES_CONNECT_STRING is missing"
else
return $ Config
(readInt 8080 port)
(fromString jwk)
(fromString connectString)
(readInt 1 poolSize)
if null jwk
then exitWithErrorMessage "Environment variable JWK_STRING is missing"
else
if null connectString
then exitWithErrorMessage "Environment variable POSTGRES_CONNECT_STRING is missing"
else
return $
Config
(readInt 8080 port)
(fromString jwk)
(fromString connectString)
(readInt 1 poolSize)
readInt :: Int -> String -> Int
readInt optional value = fromMaybe optional $ readMaybe value

View File

@ -1,37 +1,37 @@
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
module Conduit.Core.Article where
import Conduit.Core.User
import Crypto.Hash (SHA256 (SHA256), hashWith)
import Crypto.Random (MonadRandom (getRandomBytes))
import Data.Aeson
import qualified Data.Text as T
import Data.Time
import RIO
import Rel8
import Data.Aeson
import Data.Time
import qualified Data.Text as T
import Servant
import Crypto.Random ( MonadRandom(getRandomBytes) )
import Crypto.Hash ( hashWith, SHA256(SHA256) )
import Conduit.Core.User
data Article = Article
{ articleId :: ArticleId
, articleAuthorId :: UserId
, articleTitle :: Text
, articleSlug :: Slug
{ articleId :: ArticleId
, articleAuthorId :: UserId
, articleTitle :: Text
, articleSlug :: Slug
, articleDescription :: Text
, articleBody :: Text
, articleTags :: [Text]
, articleCreatedAt :: UTCTime
, articleUpdatedAt :: UTCTime
, articleBody :: Text
, articleTags :: [Text]
, articleCreatedAt :: UTCTime
, articleUpdatedAt :: UTCTime
}
newtype ArticleId = ArticleId { getArticleId :: Int64 }
newtype ArticleId = ArticleId {getArticleId :: Int64}
deriving newtype (Eq, Show, Read, FromJSON, ToJSON, DBEq, DBType)
newtype Slug = Slug { getSlug :: Text }
newtype Slug = Slug {getSlug :: Text}
deriving newtype (Eq, Show, Read, FromJSON, ToJSON, FromHttpApiData, DBEq, DBType)
newtype TagId = TagId { getTagId :: Int64 }
newtype TagId = TagId {getTagId :: Int64}
deriving newtype (Eq, Show, Read, FromJSON, ToJSON, DBEq, DBType)
mkSlug :: MonadIO m => Text -> m Slug

View File

@ -1,25 +1,25 @@
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
module Conduit.Core.Comment where
import RIO
import Rel8
import Conduit.Core.Article
import Conduit.Core.User
import Data.Aeson
import Data.Time
import Data.UUID
import Conduit.Core.User
import Conduit.Core.Article
import RIO
import Rel8
data Comment = Comment
{ commentId :: CommentId
, commentUUID :: UUID
, commentBody :: Text
, commentArticleId :: ArticleId
, commentAuthorId :: UserId
, commentCreatedAt :: UTCTime
, commentUpdatedAt :: UTCTime
{ commentId :: CommentId
, commentUUID :: UUID
, commentBody :: Text
, commentArticleId :: ArticleId
, commentAuthorId :: UserId
, commentCreatedAt :: UTCTime
, commentUpdatedAt :: UTCTime
}
newtype CommentId = CommentId { getCommentId :: Int64 }
newtype CommentId = CommentId {getCommentId :: Int64}
deriving newtype (Eq, Show, Read, FromJSON, ToJSON, DBEq, DBType)

View File

@ -1,23 +1,24 @@
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
module Conduit.Core.Password where
import RIO
import Rel8 ( DBEq, DBType )
import Data.Aeson ( FromJSON, ToJSON )
import Crypto.Random ( MonadRandom(getRandomBytes) )
import Crypto.Hash ( hashWith, SHA256(SHA256) )
import Conduit.Util (fromBytesToText, fromTextToBytes)
import Crypto.Hash (SHA256 (SHA256), hashWith)
import Crypto.KDF.BCrypt as Bcrypt (bcrypt, validatePassword)
import qualified Data.Text as T
import Crypto.Random (MonadRandom (getRandomBytes))
import Data.Aeson (FromJSON, ToJSON)
import Data.ByteArray (Bytes, convert)
import Conduit.Util (fromTextToBytes, fromBytesToText)
import qualified Data.Text as T
import RIO
import Rel8 (DBEq, DBType)
newtype Salt = Salt Bytes
newtype HashedPassword = HashedPassword { getHashedPasswd :: Text }
newtype HashedPassword = HashedPassword {getHashedPasswd :: Text}
deriving newtype (Eq, Show, Read, DBEq, DBType)
newtype Password = Password { getPassword :: Text }
newtype Password = Password {getPassword :: Text}
deriving newtype (Eq, Show, Read, FromJSON, ToJSON, DBEq, DBType)
unsafePassword :: Text -> Password
@ -25,8 +26,9 @@ unsafePassword = Password
mkPassword :: Text -> Maybe Password
mkPassword rawText =
if T.length rawText <= 6 then Nothing
else Just $ Password rawText
if T.length rawText <= 6
then Nothing
else Just $ Password rawText
newSalt :: MonadIO m => m Salt
newSalt = liftIO $ Salt <$> getRandomBytes 16
@ -34,7 +36,7 @@ newSalt = liftIO $ Salt <$> getRandomBytes 16
hashPasswordWithSalt :: Password -> Salt -> HashedPassword
hashPasswordWithSalt (Password password) (Salt salt) =
let hash = Bcrypt.bcrypt 10 salt (fromTextToBytes password)
in HashedPassword $ fromBytesToText hash
in HashedPassword $ fromBytesToText hash
hashPassword :: MonadIO m => Password -> m HashedPassword
hashPassword password = hashPasswordWithSalt password <$> newSalt

View File

@ -1,37 +1,40 @@
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE TypeFamilies #-}
module Conduit.Core.User where
module Conduit.Core.User where
import RIO
import Data.Aeson (FromJSON, ToJSON)
import qualified Data.Text as T
import Rel8 ( DBType, DBEq )
import Data.Aeson ( FromJSON, ToJSON )
import Servant.Server.Experimental.Auth
import RIO
import Rel8 (DBEq, DBType)
import Servant
import Servant.Server.Experimental.Auth
data User = User
{ userId :: UserId
, userName :: Username
, userEmail :: EmailAddress
, userBio :: Text
, userImage :: Text
} deriving (Eq, Show, Read, Generic)
data User = User
{ userId :: UserId
, userName :: Username
, userEmail :: EmailAddress
, userBio :: Text
, userImage :: Text
}
deriving (Eq, Show, Read, Generic)
newtype UserId = UserId { getUserId :: Int64 }
newtype UserId = UserId {getUserId :: Int64}
deriving newtype (Eq, Show, Read, FromJSON, ToJSON, DBEq, DBType)
newtype Username = Username { getUsername :: Text }
newtype Username = Username {getUsername :: Text}
deriving newtype (Eq, Show, Read, FromJSON, ToJSON, DBEq, DBType)
newtype EmailAddress = EmailAddress { getEmailAddress :: Text }
newtype EmailAddress = EmailAddress {getEmailAddress :: Text}
deriving newtype (Eq, Show, Read, FromJSON, ToJSON, DBEq, DBType)
instance ToJSON User
instance FromJSON User
type instance AuthServerData (AuthProtect "Required") = User
type instance AuthServerData (AuthProtect "Optional") = (Maybe User)
type instance AuthServerData (AuthProtect "Optional") = (Maybe User)

View File

@ -1,10 +1,10 @@
module Conduit.Db
( module Conduit.Db.Migration
, module Conduit.Db.Schema
, module Conduit.Db.Transaction
) where
import Conduit.Db.Schema
( module Conduit.Db.Migration
, module Conduit.Db.Schema
, module Conduit.Db.Transaction
)
where
import Conduit.Db.Migration
import Conduit.Db.Schema
import Conduit.Db.Transaction

View File

@ -1,14 +1,14 @@
{-# LANGUAGE TemplateHaskell #-}
module Conduit.Db.Migration where
import RIO
import Conduit.Db.Transaction
import Data.FileEmbed
import Hasql.Migration
import Hasql.Migration.Util
import Hasql.Transaction
import Hasql.Pool
import Data.FileEmbed
import Conduit.Db.Transaction
import Hasql.Transaction
import RIO
scripts :: [(FilePath, ByteString)]
scripts = $(embedDir "sql")
@ -22,21 +22,22 @@ autoMigrate pool = do
runTransactionWithPool pool $ runMigrations migrationCommands
runMigrations :: [MigrationCommand] -> Transaction (Maybe MigrationError)
runMigrations [] = pure Nothing
runMigrations (x:xs) = do
runMigrations [] = pure Nothing
runMigrations (x : xs) = do
err <- runMigration x
case err of
Nothing -> runMigrations xs
Just _ -> return err
Just _ -> return err
initializeMigrationSchema :: Transaction ()
initializeMigrationSchema = do
exist <- existsTable "schema_migrations"
unless exist $ do
sql $ mconcat
[ "create table if not exists schema_migrations "
, "( filename varchar(512) not null"
, ", checksum varchar(32) not null"
, ", executed_at timestamp without time zone not null default now() "
, ");"
]
sql $
mconcat
[ "create table if not exists schema_migrations "
, "( filename varchar(512) not null"
, ", checksum varchar(32) not null"
, ", executed_at timestamp without time zone not null default now() "
, ");"
]

View File

@ -1,6 +1,7 @@
module Conduit.Db.Schema
( module Schema
) where
( module Schema
)
where
import Conduit.Db.Schema.Article as Schema
import Conduit.Db.Schema.ArticleTag as Schema

View File

@ -1,28 +1,28 @@
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE StandaloneDeriving #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE StandaloneDeriving #-}
{-# LANGUAGE TypeFamilies #-}
module Conduit.Db.Schema.Article where
import Conduit.Core.Article
import Conduit.Core.User
import Data.Time
import RIO
import Rel8
import Rel8.Expr.Time
import Data.Time
import Conduit.Core.User
import Conduit.Core.Article
data ArticleEntity f = ArticleEntity
{ entityArticleId :: Column f ArticleId
, entityArticleAuthorId :: Column f UserId
, entityArticleTitle :: Column f Text
, entityArticleSlug :: Column f Slug
{ entityArticleId :: Column f ArticleId
, entityArticleAuthorId :: Column f UserId
, entityArticleTitle :: Column f Text
, entityArticleSlug :: Column f Slug
, entityArticleDescription :: Column f Text
, entityArticleBody :: Column f Text
, entityArticleCreatedAt :: Column f UTCTime
, entityArticleUpdatedAt :: Column f UTCTime
, entityArticleBody :: Column f Text
, entityArticleCreatedAt :: Column f UTCTime
, entityArticleUpdatedAt :: Column f UTCTime
}
deriving stock (Generic)
deriving anyclass (Rel8able)
@ -30,65 +30,73 @@ data ArticleEntity f = ArticleEntity
deriving stock instance f ~ Result => Show (ArticleEntity f)
articleSchema :: TableSchema (ArticleEntity Name)
articleSchema = TableSchema
{ name = "articles"
, schema = Nothing
, columns = ArticleEntity
{ entityArticleId = "article_id"
, entityArticleAuthorId = "article_user_id"
, entityArticleTitle = "article_title"
, entityArticleSlug = "article_slug"
, entityArticleDescription = "article_description"
, entityArticleBody = "article_body"
, entityArticleCreatedAt = "article_createdat"
, entityArticleUpdatedAt = "article_updatedat"
articleSchema =
TableSchema
{ name = "articles"
, schema = Nothing
, columns =
ArticleEntity
{ entityArticleId = "article_id"
, entityArticleAuthorId = "article_user_id"
, entityArticleTitle = "article_title"
, entityArticleSlug = "article_slug"
, entityArticleDescription = "article_description"
, entityArticleBody = "article_body"
, entityArticleCreatedAt = "article_createdat"
, entityArticleUpdatedAt = "article_updatedat"
}
}
}
mapArticleEntityToArticle :: ArticleEntity Result -> Article
mapArticleEntityToArticle entity = Article
{ articleId = entityArticleId entity
, articleAuthorId = entityArticleAuthorId entity
, articleTitle = entityArticleTitle entity
, articleSlug = entityArticleSlug entity
, articleDescription = entityArticleDescription entity
, articleBody = entityArticleBody entity
, articleTags = []
, articleCreatedAt = entityArticleCreatedAt entity
, articleUpdatedAt = entityArticleUpdatedAt entity
}
mapArticleEntityToArticle entity =
Article
{ articleId = entityArticleId entity
, articleAuthorId = entityArticleAuthorId entity
, articleTitle = entityArticleTitle entity
, articleSlug = entityArticleSlug entity
, articleDescription = entityArticleDescription entity
, articleBody = entityArticleBody entity
, articleTags = []
, articleCreatedAt = entityArticleCreatedAt entity
, articleUpdatedAt = entityArticleUpdatedAt entity
}
insertArticleStmt :: Article -> Insert [ArticleId]
insertArticleStmt article = Insert
{ into = articleSchema
, rows = values [ ArticleEntity
{ entityArticleId = unsafeCastExpr $ nextval "articles_article_id_seq"
, entityArticleAuthorId = lit (articleAuthorId article)
, entityArticleTitle = lit (articleTitle article)
, entityArticleSlug = lit (articleSlug article)
, entityArticleDescription = lit (articleDescription article)
, entityArticleBody = lit (articleBody article)
, entityArticleCreatedAt = now
, entityArticleUpdatedAt = now
}
]
, onConflict = Abort
, returning = Projection entityArticleId
}
insertArticleStmt article =
Insert
{ into = articleSchema
, rows =
values
[ ArticleEntity
{ entityArticleId = unsafeCastExpr $ nextval "articles_article_id_seq"
, entityArticleAuthorId = lit (articleAuthorId article)
, entityArticleTitle = lit (articleTitle article)
, entityArticleSlug = lit (articleSlug article)
, entityArticleDescription = lit (articleDescription article)
, entityArticleBody = lit (articleBody article)
, entityArticleCreatedAt = now
, entityArticleUpdatedAt = now
}
]
, onConflict = Abort
, returning = Projection entityArticleId
}
updateArticleStmt :: Article -> Update Int64
updateArticleStmt article = Update
{ target = articleSchema
, from = pure ()
, updateWhere = \_ row -> entityArticleId row ==. lit (articleId article)
, set = \_ row -> row
{ entityArticleTitle = lit (articleTitle article)
, entityArticleDescription = lit (articleDescription article)
, entityArticleBody = lit (articleBody article)
, entityArticleUpdatedAt = now
updateArticleStmt article =
Update
{ target = articleSchema
, from = pure ()
, updateWhere = \_ row -> entityArticleId row ==. lit (articleId article)
, set = \_ row ->
row
{ entityArticleTitle = lit (articleTitle article)
, entityArticleDescription = lit (articleDescription article)
, entityArticleBody = lit (articleBody article)
, entityArticleUpdatedAt = now
}
, returning = NumberOfRowsAffected
}
, returning = NumberOfRowsAffected
}
getArticleEntityByIdStmt :: Expr ArticleId -> Query (ArticleEntity Expr)
getArticleEntityByIdStmt id = do
@ -103,9 +111,10 @@ getArticleEntityBySlugStmt slug = do
return article
deleteArticleByIdStmt :: ArticleId -> Delete Int64
deleteArticleByIdStmt articleId' = Delete
{ from = articleSchema
, using = pure ()
, deleteWhere = \_ row -> entityArticleId row ==. lit articleId'
, returning = NumberOfRowsAffected
}
deleteArticleByIdStmt articleId' =
Delete
{ from = articleSchema
, using = pure ()
, deleteWhere = \_ row -> entityArticleId row ==. lit articleId'
, returning = NumberOfRowsAffected
}

View File

@ -1,19 +1,19 @@
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE StandaloneDeriving #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE StandaloneDeriving #-}
{-# LANGUAGE TypeFamilies #-}
module Conduit.Db.Schema.ArticleTag where
import Conduit.Core.Article
import RIO
import Rel8
import Conduit.Core.Article
data ArticleTagEntity f = ArticleTagEntity
{ tgdArticleId :: Column f ArticleId
, tgdTagId :: Column f TagId
, tgdTagId :: Column f TagId
}
deriving stock (Generic)
deriving anyclass (Rel8able)
@ -21,23 +21,25 @@ data ArticleTagEntity f = ArticleTagEntity
deriving stock instance f ~ Result => Show (ArticleTagEntity f)
articleTagSchema :: TableSchema (ArticleTagEntity Name)
articleTagSchema = TableSchema
{ name = "tagged"
, schema = Nothing
, columns = ArticleTagEntity
{ tgdArticleId = "tgd_article_id"
, tgdTagId = "tgd_tag_id"
articleTagSchema =
TableSchema
{ name = "tagged"
, schema = Nothing
, columns =
ArticleTagEntity
{ tgdArticleId = "tgd_article_id"
, tgdTagId = "tgd_tag_id"
}
}
}
insertArticleTagsStmt :: ArticleId -> [TagId] -> Insert Int64
insertArticleTagsStmt articleId tagIds = Insert
{ into = articleTagSchema
, rows = values $ map (ArticleTagEntity (lit articleId) . lit) tagIds
, onConflict = DoNothing
, returning = NumberOfRowsAffected
}
insertArticleTagsStmt articleId tagIds =
Insert
{ into = articleTagSchema
, rows = values $ map (ArticleTagEntity (lit articleId) . lit) tagIds
, onConflict = DoNothing
, returning = NumberOfRowsAffected
}
getAllArticleTagsStmt :: Expr ArticleId -> Query (Expr TagId)
getAllArticleTagsStmt articleId = do
@ -46,17 +48,19 @@ getAllArticleTagsStmt articleId = do
return $ tgdTagId articleTag
deleteAllArticleTagsStmt :: ArticleId -> Delete Int64
deleteAllArticleTagsStmt articleId' = Delete
{ from = articleTagSchema
, using = pure()
, deleteWhere = \_ row -> tgdArticleId row ==. lit articleId'
, returning = NumberOfRowsAffected
}
deleteAllArticleTagsStmt articleId' =
Delete
{ from = articleTagSchema
, using = pure ()
, deleteWhere = \_ row -> tgdArticleId row ==. lit articleId'
, returning = NumberOfRowsAffected
}
deleteArticleTagStmt :: ArticleId -> TagId -> Delete Int64
deleteArticleTagStmt articleId tagId = Delete
{ from = articleTagSchema
, using = pure ()
, deleteWhere = \_ row -> tgdArticleId row ==. lit articleId &&. tgdTagId row ==. lit tagId
, returning = NumberOfRowsAffected
}
deleteArticleTagStmt articleId tagId =
Delete
{ from = articleTagSchema
, using = pure ()
, deleteWhere = \_ row -> tgdArticleId row ==. lit articleId &&. tgdTagId row ==. lit tagId
, returning = NumberOfRowsAffected
}

View File

@ -1,27 +1,27 @@
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE StandaloneDeriving #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE StandaloneDeriving #-}
{-# LANGUAGE TypeFamilies #-}
module Conduit.Db.Schema.Comment where
import Conduit.Core.Article
import Conduit.Core.Comment
import Conduit.Core.User
import Data.Time
import Data.UUID
import RIO
import Rel8
import Rel8.Expr.Time
import Data.Time
import Data.UUID
import Conduit.Core.User
import Conduit.Core.Article
import Conduit.Core.Comment
data CommentEntity f = CommentEntity
{ entityCommentId :: Column f CommentId
, entityCommentUUID :: Column f UUID
, entityCommentBody :: Column f Text
{ entityCommentId :: Column f CommentId
, entityCommentUUID :: Column f UUID
, entityCommentBody :: Column f Text
, entityCommentArticleId :: Column f ArticleId
, entityCommentAuthorId :: Column f UserId
, entityCommentAuthorId :: Column f UserId
, entityCommentCreatedAt :: Column f UTCTime
, entityCommentUpdatedAt :: Column f UTCTime
}
@ -31,30 +31,33 @@ data CommentEntity f = CommentEntity
deriving stock instance f ~ Result => Show (CommentEntity f)
commentSchema :: TableSchema (CommentEntity Name)
commentSchema = TableSchema
{ name = "comments"
, schema = Nothing
, columns = CommentEntity
{ entityCommentId = "comment_id"
, entityCommentUUID = "comment_uuid"
, entityCommentBody = "comment_body"
, entityCommentArticleId = "comment_article_id"
, entityCommentAuthorId = "comment_user_id"
, entityCommentCreatedAt = "comment_createdat"
, entityCommentUpdatedAt = "comment_updatedat"
commentSchema =
TableSchema
{ name = "comments"
, schema = Nothing
, columns =
CommentEntity
{ entityCommentId = "comment_id"
, entityCommentUUID = "comment_uuid"
, entityCommentBody = "comment_body"
, entityCommentArticleId = "comment_article_id"
, entityCommentAuthorId = "comment_user_id"
, entityCommentCreatedAt = "comment_createdat"
, entityCommentUpdatedAt = "comment_updatedat"
}
}
}
mapCommentEntityToComment :: CommentEntity Result -> Comment
mapCommentEntityToComment entity = Comment
{ commentId = entityCommentId entity
, commentUUID = entityCommentUUID entity
, commentBody = entityCommentBody entity
, commentArticleId = entityCommentArticleId entity
, commentAuthorId = entityCommentAuthorId entity
, commentCreatedAt = entityCommentCreatedAt entity
, commentUpdatedAt = entityCommentUpdatedAt entity
}
mapCommentEntityToComment entity =
Comment
{ commentId = entityCommentId entity
, commentUUID = entityCommentUUID entity
, commentBody = entityCommentBody entity
, commentArticleId = entityCommentArticleId entity
, commentAuthorId = entityCommentAuthorId entity
, commentCreatedAt = entityCommentCreatedAt entity
, commentUpdatedAt = entityCommentUpdatedAt entity
}
getCommentByIdStmt :: Expr CommentId -> Query (CommentEntity Expr)
getCommentByIdStmt commentId = do
@ -75,35 +78,39 @@ getCommentsByArticleIdStmt articleId = do
return comment
insertCommentStmt :: Comment -> Insert [CommentId]
insertCommentStmt comment = Insert
{ into = commentSchema
, rows = values [
CommentEntity
{ entityCommentId = unsafeCastExpr $ nextval "comments_comment_id_seq"
, entityCommentUUID = lit $ commentUUID comment
, entityCommentBody = lit $ commentBody comment
, entityCommentArticleId = lit $ commentArticleId comment
, entityCommentAuthorId = lit $ commentAuthorId comment
, entityCommentCreatedAt = now
, entityCommentUpdatedAt = now
}
]
, onConflict = Abort
, returning = Projection entityCommentId
}
insertCommentStmt comment =
Insert
{ into = commentSchema
, rows =
values
[ CommentEntity
{ entityCommentId = unsafeCastExpr $ nextval "comments_comment_id_seq"
, entityCommentUUID = lit $ commentUUID comment
, entityCommentBody = lit $ commentBody comment
, entityCommentArticleId = lit $ commentArticleId comment
, entityCommentAuthorId = lit $ commentAuthorId comment
, entityCommentCreatedAt = now
, entityCommentUpdatedAt = now
}
]
, onConflict = Abort
, returning = Projection entityCommentId
}
deleteCommentStmt :: CommentId -> Delete Int64
deleteCommentStmt commentId = Delete
{ from = commentSchema
, using = pure ()
, deleteWhere = \_ row -> entityCommentId row ==. lit commentId
, returning = NumberOfRowsAffected
}
deleteCommentStmt commentId =
Delete
{ from = commentSchema
, using = pure ()
, deleteWhere = \_ row -> entityCommentId row ==. lit commentId
, returning = NumberOfRowsAffected
}
deleteCommentByArticleIdStmt :: ArticleId -> Delete Int64
deleteCommentByArticleIdStmt articleId = Delete
{ from = commentSchema
, using = pure ()
, deleteWhere = \_ row -> entityCommentArticleId row ==. lit articleId
, returning = NumberOfRowsAffected
}
deleteCommentByArticleIdStmt articleId =
Delete
{ from = commentSchema
, using = pure ()
, deleteWhere = \_ row -> entityCommentArticleId row ==. lit articleId
, returning = NumberOfRowsAffected
}

View File

@ -1,20 +1,20 @@
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE StandaloneDeriving #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE StandaloneDeriving #-}
{-# LANGUAGE TypeFamilies #-}
module Conduit.Db.Schema.Favorite where
import Conduit.App
import Conduit.Core.Article
import Conduit.Core.User
import RIO
import Rel8
import Conduit.App
import Conduit.Core.User
import Conduit.Core.Article
data FavoriteEntity f = FavoriteEntity
{ favoriteUserId :: Column f UserId
{ favoriteUserId :: Column f UserId
, favoriteArticleId :: Column f ArticleId
}
deriving stock (Generic)
@ -23,38 +23,43 @@ data FavoriteEntity f = FavoriteEntity
deriving stock instance f ~ Result => Show (FavoriteEntity f)
favoriteSchema :: TableSchema (FavoriteEntity Name)
favoriteSchema = TableSchema
{ name = "favorited"
, schema = Nothing
, columns = FavoriteEntity
{ favoriteUserId = "favor_user_id"
, favoriteArticleId = "favor_article_id"
favoriteSchema =
TableSchema
{ name = "favorited"
, schema = Nothing
, columns =
FavoriteEntity
{ favoriteUserId = "favor_user_id"
, favoriteArticleId = "favor_article_id"
}
}
}
addFavoritedArticleStmt :: UserId -> ArticleId -> Insert Int64
addFavoritedArticleStmt currentUserId targetArticleId = Insert
{ into = favoriteSchema
, rows = values [ FavoriteEntity (lit currentUserId) (lit targetArticleId) ]
, onConflict = DoNothing
, returning = NumberOfRowsAffected
}
addFavoritedArticleStmt currentUserId targetArticleId =
Insert
{ into = favoriteSchema
, rows = values [FavoriteEntity (lit currentUserId) (lit targetArticleId)]
, onConflict = DoNothing
, returning = NumberOfRowsAffected
}
removeFavoritedArticleStmt :: UserId -> ArticleId -> Delete Int64
removeFavoritedArticleStmt userId articleId' = Delete
{ from = favoriteSchema
, using = pure ()
, deleteWhere = \_ row -> favoriteArticleId row ==. lit articleId' &&. favoriteUserId row ==. lit userId
, returning = NumberOfRowsAffected
}
removeFavoritedArticleStmt userId articleId' =
Delete
{ from = favoriteSchema
, using = pure ()
, deleteWhere = \_ row -> favoriteArticleId row ==. lit articleId' &&. favoriteUserId row ==. lit userId
, returning = NumberOfRowsAffected
}
removeAllFavoritesByArticleIdStmt :: ArticleId -> Delete Int64
removeAllFavoritesByArticleIdStmt articleId' = Delete
{ from = favoriteSchema
, using = pure ()
, deleteWhere = \_ row -> favoriteArticleId row ==. lit articleId'
, returning = NumberOfRowsAffected
}
removeAllFavoritesByArticleIdStmt articleId' =
Delete
{ from = favoriteSchema
, using = pure ()
, deleteWhere = \_ row -> favoriteArticleId row ==. lit articleId'
, returning = NumberOfRowsAffected
}
getFavoritedCountOfArticleStmt :: Expr ArticleId -> Query (Expr Int64)
getFavoritedCountOfArticleStmt articleId = countRows $ do

View File

@ -1,18 +1,18 @@
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE StandaloneDeriving #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE StandaloneDeriving #-}
{-# LANGUAGE TypeFamilies #-}
module Conduit.Db.Schema.Tag where
import Conduit.Core.Article
import RIO
import Rel8
import Conduit.Core.Article
data TagEntity f = TagEntity
{ entityTagId :: Column f TagId
{ entityTagId :: Column f TagId
, entityTagText :: Column f Text
}
deriving stock (Generic)
@ -21,14 +21,16 @@ data TagEntity f = TagEntity
deriving stock instance f ~ Result => Show (TagEntity f)
tagSchema :: TableSchema (TagEntity Name)
tagSchema = TableSchema
{ name = "tags"
, schema = Nothing
, columns = TagEntity
{ entityTagId = "tag_id"
, entityTagText = "tag_text"
tagSchema =
TableSchema
{ name = "tags"
, schema = Nothing
, columns =
TagEntity
{ entityTagId = "tag_id"
, entityTagText = "tag_text"
}
}
}
getTagIdStmt :: Text -> Query (Expr TagId)
getTagIdStmt tag = do
@ -37,9 +39,10 @@ getTagIdStmt tag = do
return $ entityTagId tagEntity
insertTagStmt :: Text -> Insert [TagId]
insertTagStmt tag = Insert
{ into = tagSchema
, rows = values [TagEntity (unsafeCastExpr $ nextval "tags_tag_id_seq") (lit tag)]
, onConflict = Abort
, returning = Projection entityTagId
}
insertTagStmt tag =
Insert
{ into = tagSchema
, rows = values [TagEntity (unsafeCastExpr $ nextval "tags_tag_id_seq") (lit tag)]
, onConflict = Abort
, returning = Projection entityTagId
}

View File

@ -1,27 +1,27 @@
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE StandaloneDeriving #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE DuplicateRecordFields #-}
module Conduit.Db.Schema.User where
import Conduit.Core.Password
import Conduit.Core.User
import Hasql.Connection (Connection)
import RIO hiding (set)
import Rel8
import Hasql.Connection ( Connection )
import Conduit.Core.User
import Conduit.Core.Password
data UserEntity f = UserEntity
{ entityUserId :: Column f UserId
, entityUserName :: Column f Username
, entityUserEmail :: Column f EmailAddress
{ entityUserId :: Column f UserId
, entityUserName :: Column f Username
, entityUserEmail :: Column f EmailAddress
, entityUserPassword :: Column f HashedPassword
, entityUserBio :: Column f Text
, entityUserImage :: Column f Text
, entityUserBio :: Column f Text
, entityUserImage :: Column f Text
}
deriving stock (Generic)
deriving anyclass (Rel8able)
@ -29,40 +29,45 @@ data UserEntity f = UserEntity
deriving stock instance f ~ Result => Show (UserEntity f)
userSchema :: TableSchema (UserEntity Name)
userSchema = TableSchema
{ name = "users"
, schema = Nothing
, columns = UserEntity
{ entityUserId = "user_id"
, entityUserName = "user_username"
, entityUserEmail = "user_email"
, entityUserPassword = "user_password"
, entityUserBio = "user_bio"
, entityUserImage = "user_image"
userSchema =
TableSchema
{ name = "users"
, schema = Nothing
, columns =
UserEntity
{ entityUserId = "user_id"
, entityUserName = "user_username"
, entityUserEmail = "user_email"
, entityUserPassword = "user_password"
, entityUserBio = "user_bio"
, entityUserImage = "user_image"
}
}
}
mapUserEntityToUser :: UserEntity Result -> User
mapUserEntityToUser entity = User
{ userId = entityUserId entity
, userName = entityUserName entity
, userEmail = entityUserEmail entity
, userBio = entityUserBio entity
, userImage = entityUserImage entity
}
mapUserEntityToUser entity =
User
{ userId = entityUserId entity
, userName = entityUserName entity
, userEmail = entityUserEmail entity
, userBio = entityUserBio entity
, userImage = entityUserImage entity
}
updateUserProperties :: User -> UserEntity Expr -> UserEntity Expr
updateUserProperties user expr = expr
{ entityUserName = lit (userName user)
, entityUserEmail = lit (userEmail user)
, entityUserBio = lit (userBio user)
, entityUserImage = lit (userImage user)
}
updateUserProperties user expr =
expr
{ entityUserName = lit (userName user)
, entityUserEmail = lit (userEmail user)
, entityUserBio = lit (userBio user)
, entityUserImage = lit (userImage user)
}
updatePassword :: HashedPassword -> UserEntity Expr -> UserEntity Expr
updatePassword hash expr = expr
{ entityUserPassword = lit hash
}
updatePassword :: HashedPassword -> UserEntity Expr -> UserEntity Expr
updatePassword hash expr =
expr
{ entityUserPassword = lit hash
}
getUserByIdStmt :: Expr UserId -> Query (UserEntity Expr)
getUserByIdStmt uid = do
@ -76,27 +81,30 @@ getUserByNameStmt name = do
where_ $ entityUserName a ==. lit name
return a
getUserByEmailStmt :: EmailAddress -> Query (UserEntity Expr)
getUserByEmailStmt :: EmailAddress -> Query (UserEntity Expr)
getUserByEmailStmt email = do
a <- each userSchema
where_ $ entityUserEmail a ==. lit email
return a
insertUserStmt :: User -> HashedPassword -> Insert [UserId]
insertUserStmt user hash = Insert
{ into = userSchema
, rows = values [ UserEntity
{ entityUserId = unsafeCastExpr $ nextval "users_user_id_seq"
, entityUserName = lit (userName user)
, entityUserEmail = lit (userEmail user)
, entityUserBio = lit (userBio user)
, entityUserImage = lit (userImage user)
, entityUserPassword = lit hash
}
]
, onConflict = DoNothing
, returning = Projection entityUserId
}
insertUserStmt user hash =
Insert
{ into = userSchema
, rows =
values
[ UserEntity
{ entityUserId = unsafeCastExpr $ nextval "users_user_id_seq"
, entityUserName = lit (userName user)
, entityUserEmail = lit (userEmail user)
, entityUserBio = lit (userBio user)
, entityUserImage = lit (userImage user)
, entityUserPassword = lit hash
}
]
, onConflict = DoNothing
, returning = Projection entityUserId
}
updateUserStmt :: User -> Maybe HashedPassword -> Update Int64
updateUserStmt user mbPassword =
@ -110,4 +118,4 @@ updateUserStmt user mbPassword =
where
setter _ = case mbPassword of
Just password -> updatePassword password . updateUserProperties user
_ -> updateUserProperties user
_ -> updateUserProperties user

View File

@ -1,18 +1,18 @@
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE StandaloneDeriving #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE StandaloneDeriving #-}
{-# LANGUAGE TypeFamilies #-}
module Conduit.Db.Schema.UserFollow where
import Conduit.Core.User
import RIO
import Rel8
import Conduit.Core.User
data UserFollowEntity f = UserFollowEntity
{ fwsUserId :: Column f UserId
{ fwsUserId :: Column f UserId
, fwsFollowingUserId :: Column f UserId
}
deriving stock (Generic)
@ -21,31 +21,35 @@ data UserFollowEntity f = UserFollowEntity
deriving stock instance f ~ Result => Show (UserFollowEntity f)
userFollowSchema :: TableSchema (UserFollowEntity Name)
userFollowSchema = TableSchema
{ name = "follows"
, schema = Nothing
, columns = UserFollowEntity
{ fwsUserId = "fws_user_id"
, fwsFollowingUserId = "fws_follows_user_id"
userFollowSchema =
TableSchema
{ name = "follows"
, schema = Nothing
, columns =
UserFollowEntity
{ fwsUserId = "fws_user_id"
, fwsFollowingUserId = "fws_follows_user_id"
}
}
}
createFollowshipStmt :: UserId -> UserId -> Insert Int64
createFollowshipStmt currentUserId toFollowUserId =
Insert
{ into = userFollowSchema
, rows = values
[ UserFollowEntity
{ fwsUserId = lit currentUserId
, fwsFollowingUserId = lit toFollowUserId
}
]
, rows =
values
[ UserFollowEntity
{ fwsUserId = lit currentUserId
, fwsFollowingUserId = lit toFollowUserId
}
]
, onConflict = DoNothing
, returning = NumberOfRowsAffected
}
removeFollowshipStmt :: UserId -> UserId -> Delete Int64
removeFollowshipStmt currentUserId followingUserId = Delete
removeFollowshipStmt currentUserId followingUserId =
Delete
{ from = userFollowSchema
, using = pure ()
, deleteWhere = \_ o -> (fwsUserId o ==. lit currentUserId) &&. (fwsFollowingUserId o ==. lit followingUserId)

View File

@ -1,19 +1,19 @@
{-# LANGUAGE RankNTypes #-}
module Conduit.Db.Transaction where
import RIO
import Conduit.App
import Conduit.Config
import Conduit.Environment
import Data.List
import Hasql.Connection (Connection)
import Hasql.Pool ( Pool, UsageError(..), acquire )
import Hasql.Pool (Pool, UsageError (..), acquire)
import qualified Hasql.Pool as Pool
import Hasql.Transaction ( Transaction, condemn, statement, sql )
import qualified Hasql.Session as Session
import qualified Hasql.Transaction.Sessions as Hasql
import Hasql.Statement (Statement)
import Conduit.Config
import Conduit.App
import Conduit.Environment
import Hasql.Transaction (Transaction, condemn, sql, statement)
import qualified Hasql.Transaction.Sessions as Hasql
import RIO
loadPool :: ByteString -> Int -> IO Pool
loadPool connectString poolSize = acquire poolSize (Just 10) connectString
@ -35,7 +35,7 @@ runTransactionWithPool pool transaction = do
runStmt :: Statement () a -> Transaction a
runStmt = statement ()
runTransaction :: forall a m env . (HasDbPool env, MonadReader env m, MonadIO m) => Transaction a -> m a
runTransaction :: forall a m env. (HasDbPool env, MonadReader env m, MonadIO m) => Transaction a -> m a
runTransaction transaction = do
pool <- getDbPool'
runTransactionWithPool pool transaction
@ -44,8 +44,10 @@ executeStmt :: Statement () a -> AppM a
executeStmt = runTransaction . runStmt
truncateTables :: [Text] -> Transaction ()
truncateTables tables = sql $ mconcat
[ "TRUNCATE "
, fromString $ intercalate ", " (map show tables)
," RESTART IDENTITY;"
]
truncateTables tables =
sql $
mconcat
[ "TRUNCATE "
, fromString $ intercalate ", " (map show tables)
, " RESTART IDENTITY;"
]

View File

@ -1,15 +1,16 @@
{-# LANGUAGE RankNTypes #-}
module Conduit.Environment where
import Hasql.Pool (Pool)
import Crypto.JOSE (JWK)
import Hasql.Pool (Pool)
import RIO (MonadReader, ask, (<&>))
class HasDbPool env where
getDbPool :: env -> Pool
getDbPool :: env -> Pool
class HasJwtKey env where
getJwtKey :: env -> JWK
getJwtKey :: env -> JWK
getDbPool' :: (HasDbPool env, MonadReader env m) => m Pool
getDbPool' = ask <&> getDbPool

View File

@ -1,45 +1,46 @@
module Conduit.JWT where
import RIO hiding ((^?), (^.))
import Conduit.Core.User (Username (getUsername))
import Control.Lens
import Control.Monad.Except (runExceptT)
import Crypto.JWT
( JWK,
bestJWSAlg,
ClaimsSet,
JWTError,
decodeCompact,
encodeCompact,
newJWSHeader,
claimAud,
claimExp,
claimIat,
claimIss,
claimSub,
defaultJWTValidationSettings,
emptyClaimsSet,
signClaims,
string,
verifyClaims,
Audience(Audience),
NumericDate(NumericDate) )
import Control.Lens
import Data.Time.Clock ( getCurrentTime, nominalDay, addUTCTime )
( Audience (Audience)
, ClaimsSet
, JWK
, JWTError
, NumericDate (NumericDate)
, bestJWSAlg
, claimAud
, claimExp
, claimIat
, claimIss
, claimSub
, decodeCompact
, defaultJWTValidationSettings
, emptyClaimsSet
, encodeCompact
, newJWSHeader
, signClaims
, string
, verifyClaims
)
import Data.ByteString.Lazy (fromStrict, toStrict)
import qualified Data.Text as T
import Data.Text.Encoding ( decodeUtf8 )
import Conduit.Core.User ( Username(getUsername) )
import Data.Text.Encoding (decodeUtf8)
import Data.Time.Clock (addUTCTime, getCurrentTime, nominalDay)
import RIO hiding ((^.), (^?))
mkClaims :: Username -> IO ClaimsSet
mkClaims name = do
currentTime <- getCurrentTime
let expiredAt = addUTCTime nominalDay currentTime
pure $ emptyClaimsSet
& claimIss ?~ "conduit-server"
& claimAud ?~ Audience ["conduit-client"]
& claimIat ?~ NumericDate currentTime
& claimExp ?~ NumericDate expiredAt
& claimSub ?~ (fromString . T.unpack . getUsername) name
pure $
emptyClaimsSet
& claimIss ?~ "conduit-server"
& claimAud ?~ Audience ["conduit-client"]
& claimIat ?~ NumericDate currentTime
& claimExp ?~ NumericDate expiredAt
& claimSub ?~ (fromString . T.unpack . getUsername) name
signJwt :: JWK -> ClaimsSet -> IO (Either JWTError Text)
signJwt jwk claims = runExceptT $ do
@ -53,4 +54,4 @@ verifyJwt key token = runExceptT $ do
verifyClaims (defaultJWTValidationSettings audCheck) key signedJwt
getSubject :: ClaimsSet -> Maybe Text
getSubject claimSet = fromMaybe "" (claimSet ^. claimSub) ^? string
getSubject claimSet = fromMaybe "" (claimSet ^. claimSub) ^? string

View File

@ -1,38 +1,38 @@
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE TypeFamilies #-}
module Conduit.Repository.Article where
import RIO
import Rel8
import Hasql.Transaction (Transaction)
import Conduit.App
import Conduit.Core.Article
import Conduit.Core.User
import Conduit.Db
import Conduit.Util
import Data.Functor.Contravariant
import Data.List (head, (\\))
import Conduit.Core.User
import Conduit.Core.Article
import Conduit.Db
import Conduit.App
import Conduit.Util
import Hasql.Transaction (Transaction)
import RIO
import Rel8
data ArticleFilters = ArticleFilters
{ getTagFilter :: Maybe TagId
{ getTagFilter :: Maybe TagId
, getFavoriteFilter :: Maybe UserId
, getAuthorFilter :: Maybe UserId
, getAuthorFilter :: Maybe UserId
}
data Pagination = Pagination
{ pageNum :: Int64
{ pageNum :: Int64
, pageSize :: Int64
}
data EnrichedArticle = EnrichedArticle
{ enrArticle :: Article
, enrAuthor :: User
{ enrArticle :: Article
, enrAuthor :: User
, enrFollowingAuthor :: Bool
, enrIsFavorited :: Bool
, enrFavoritedCount :: Int64
, enrIsFavorited :: Bool
, enrFavoritedCount :: Int64
}
type EnrichedArticleQuery = Query (MaybeTable Expr (ListTable Expr (TagEntity Expr)), UserEntity Expr, Expr Bool, Expr Bool, Expr Int64)
@ -42,17 +42,18 @@ type EnrichedArticleResult = (Maybe [TagEntity Result], UserEntity Result, Bool,
mapArticleEntityWithTagsToArticle :: (ArticleEntity Result, [TagEntity Result]) -> Article
mapArticleEntityWithTagsToArticle (article, tags) =
let article' = mapArticleEntityToArticle article
in article' { articleTags = map entityTagText tags}
in article' {articleTags = map entityTagText tags}
enrichedArticle :: (ArticleEntity Result, EnrichedArticleResult)
-> EnrichedArticle
enrichedArticle ::
(ArticleEntity Result, EnrichedArticleResult) ->
EnrichedArticle
enrichedArticle (articleEntity, (mbTags, authorEntity, followingAuthor, favorited, favoritedCount)) =
EnrichedArticle
(mapArticleEntityWithTagsToArticle (articleEntity, fromMaybe [] mbTags))
(mapUserEntityToUser authorEntity)
followingAuthor
favorited
favoritedCount
EnrichedArticle
(mapArticleEntityWithTagsToArticle (articleEntity, fromMaybe [] mbTags))
(mapUserEntityToUser authorEntity)
followingAuthor
favorited
favoritedCount
filterArticleByAuthorStmt :: ArticleEntity Expr -> UserId -> Expr Bool
filterArticleByAuthorStmt article uid = entityArticleAuthorId article ==. lit uid
@ -75,20 +76,21 @@ getAllArticleStmt filters = do
forM_ tag' $ filterArticleByTagStmt article
return article
where
tag' = getTagFilter filters
tag' = getTagFilter filters
favorite' = getFavoriteFilter filters
author' = getAuthorFilter filters
author' = getAuthorFilter filters
getPagedArticleStmt :: Int64 -> Int64 -> ArticleFilters -> Query (ArticleEntity Expr)
getPagedArticleStmt pageSize page filters =
limit (fromIntegral pageSize)
$ offset (fromIntegral page)
$ orderBy (entityArticleCreatedAt >$< desc)
$ getAllArticleStmt filters
limit (fromIntegral pageSize) $
offset (fromIntegral page) $
orderBy (entityArticleCreatedAt >$< desc) $
getAllArticleStmt filters
getTagsOfArticleStmt :: (Column f ArticleId ~ Expr ArticleId)
=> ArticleEntity f
-> Query (MaybeTable Expr (ListTable Expr (TagEntity Expr)))
getTagsOfArticleStmt ::
(Column f ArticleId ~ Expr ArticleId) =>
ArticleEntity f ->
Query (MaybeTable Expr (ListTable Expr (TagEntity Expr)))
getTagsOfArticleStmt article = Rel8.optional $ aggregate $ do
tagId <- getAllArticleTagsStmt (entityArticleId article)
tag <- each tagSchema
@ -113,7 +115,7 @@ createArticle article = runTransaction $ do
Just articleId' -> do
tagIds <- mapM getOrCreateTagId' (articleTags article)
_ <- runStmt $ insert $ insertArticleTagsStmt articleId' tagIds
return $ Just article { articleId = articleId' }
return $ Just article {articleId = articleId'}
Nothing -> return Nothing
getTagId' :: Text -> Transaction (Maybe TagId)
@ -125,9 +127,10 @@ getTagId tag = runTransaction $ getTagId' tag
getOrCreateTagId' :: Text -> Transaction TagId
getOrCreateTagId' tag = do
tagId <- getTagId' tag
maybe (runStmt $ head <$> insert (insertTagStmt tag))
return
tagId
maybe
(runStmt $ head <$> insert (insertTagStmt tag))
return
tagId
getEnrichedArticleById :: Maybe User -> ArticleId -> AppM (Maybe EnrichedArticle)
getEnrichedArticleById mbUser id = do
@ -145,9 +148,9 @@ getArticleById id = do
getEnrichedArticleBySlug :: Maybe User -> Slug -> AppM (Maybe EnrichedArticle)
getEnrichedArticleBySlug mbUser slug = do
records <- executeStmt $ select $ do
article <- getArticleEntityBySlugStmt $ litExpr slug
enrichedData <- getArticleEnrichedDataStmt mbUser article
return (article, enrichedData)
article <- getArticleEntityBySlugStmt $ litExpr slug
enrichedData <- getArticleEnrichedDataStmt mbUser article
return (article, enrichedData)
return $ listToMaybe $ map enrichedArticle records
getArticleBySlug :: Slug -> AppM (Maybe Article)
@ -168,7 +171,7 @@ getPagedArticle mbUser p filters = do
return (total, pagedResults)
return (map enrichedArticle pagedResults, fromMaybe 0 . listToMaybe $ total)
checkFavorite :: User -> ArticleId -> AppM Bool
checkFavorite :: User -> ArticleId -> AppM Bool
checkFavorite user articleId = do
exists <- executeStmt $ select $ checkFavoriteStmt (userId user) (litExpr articleId)
return $ exists == [True]

View File

@ -1,17 +1,17 @@
{-# LANGUAGE RankNTypes #-}
module Conduit.Repository.Comment where
import RIO
import Rel8
import Data.UUID
import Conduit.App
import Conduit.Core.User
import Conduit.Core.Article
import Conduit.Core.Comment
import Conduit.Core.User
import Conduit.Db
import Data.UUID
import RIO
import Rel8
type EnrichedComment = (Comment, User, Bool )
type EnrichedComment = (Comment, User, Bool)
getEnrichedCommentsByArticleId :: Maybe User -> ArticleId -> AppM [EnrichedComment]
getEnrichedCommentsByArticleId mbUser articleId = do
@ -28,7 +28,7 @@ getCommentById commentId = do
return $ listToMaybe $ map mapCommentEntityToComment comments
getCommentByUUID :: UUID -> AppM (Maybe Comment)
getCommentByUUID uuid = do
getCommentByUUID uuid = do
comments <- executeStmt $ select $ getCommentByUUIDStmt (litExpr uuid)
return $ listToMaybe $ map mapCommentEntityToComment comments
@ -37,7 +37,7 @@ addComment comment = do
commentId <- executeStmt $ insert (insertCommentStmt comment)
return $ updateCommentId =<< listToMaybe commentId
where
updateCommentId commentId' = Just $ comment {commentId = commentId' }
updateCommentId commentId' = Just $ comment {commentId = commentId'}
deleteCommentById :: CommentId -> AppM Bool
deleteCommentById commentId = do

View File

@ -1,16 +1,16 @@
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE RankNTypes #-}
module Conduit.Repository.User where
import Conduit.App
import Conduit.Core.Password
import Conduit.Core.User
import Conduit.Db
import RIO
import Rel8
import Conduit.App
import Conduit.Core.User
import Conduit.Core.Password
import Conduit.Db
getUserById :: UserId -> AppM (Maybe User)
getUserById uid = do
users <- executeStmt $ select $ getUserByIdStmt (litExpr uid)
@ -26,30 +26,28 @@ getUserByEmail email = do
users <- executeStmt $ select $ getUserByEmailStmt email
return $ listToMaybe $ map mapUserEntityToUser users
getUserByEmailAndPassword :: EmailAddress -> Password -> AppM (Maybe User)
getUserByEmailAndPassword :: EmailAddress -> Password -> AppM (Maybe User)
getUserByEmailAndPassword email password = do
users <- executeStmt $ select $ getUserByEmailStmt email
return $ verifyPassword' =<< listToMaybe users
where
verifyPassword' user =
if verifyPassword password (entityUserPassword user)
then
Just $ mapUserEntityToUser user
else
Nothing
then Just $ mapUserEntityToUser user
else Nothing
saveNewUser :: User -> Password -> AppM (Maybe User)
saveNewUser user password = do
hashedPwdAndSalt <- liftIO $ hashPassword password
userIds <- executeStmt $ insert $ insertUserStmt user hashedPwdAndSalt
return $ listToMaybe userIds >>= \uid -> Just $ user { userId = uid }
return $ listToMaybe userIds >>= \uid -> Just $ user {userId = uid}
updateUser :: User -> Maybe Password -> AppM Bool
updateUser user mbPassword = do
hashAndSalt <-
case mbPassword of
Just password -> liftIO $ Just <$> hashPassword password
_ -> return Nothing
_ -> return Nothing
rows <- executeStmt $ update $ updateUserStmt user hashAndSalt
return $ rows > 0
@ -60,7 +58,7 @@ checkFollowship user following = do
followUser :: User -> UserId -> AppM Bool
followUser user toFollow = do
rows <-executeStmt $ insert $ createFollowshipStmt (userId user) toFollow
rows <- executeStmt $ insert $ createFollowshipStmt (userId user) toFollow
return $ rows == 1
unfollowUser :: User -> UserId -> AppM Bool

View File

@ -1,21 +1,23 @@
{-# LANGUAGE RankNTypes #-}
module Conduit.Util where
import RIO
import Control.Monad.Trans.Maybe ( MaybeT(..) )
import Data.Aeson ( defaultOptions, Options(fieldLabelModifier) )
import Data.List ( head, tail )
import Data.Char ( toLower )
import Data.UUID
import System.Random
import Control.Monad.Trans.Maybe (MaybeT (..))
import Data.Aeson (Options (fieldLabelModifier), defaultOptions)
import Data.ByteArray (Bytes, convert)
import Data.Char (toLower)
import Data.List (head, tail)
import Data.Text.Encoding (decodeUtf8)
import System.IO ( hPutStrLn )
import Data.UUID
import RIO
import System.IO (hPutStrLn)
import System.Random
toJsonOptions :: Int -> Options
toJsonOptions prefixLength =
defaultOptions
{ fieldLabelModifier = headToLower . drop prefixLength }
{ fieldLabelModifier = headToLower . drop prefixLength
}
where
headToLower x = toLower (head x) : tail x

9
stack-9.2.7.yaml Normal file
View File

@ -0,0 +1,9 @@
resolver: lts-20.21
packages:
- .
allow-newer: true
extra-deps:
- unix-compat-0.7

View File

@ -6,4 +6,4 @@ packages:
allow-newer: true
extra-deps:
- unix-compat-0.7
- unix-compat-0.7

View File

@ -1,19 +1,20 @@
{-# LANGUAGE RankNTypes #-}
module Test.Conduit.Api.AuthApiSpec where
import RIO
import Conduit.Api.Auth
import Conduit.Api.Common
import Data.Aeson
import Data.Maybe
import Test.Hspec
import Network.Wai.Test
import Network.HTTP.Types
import Conduit.Api.Common
import Conduit.Api.Auth
import Network.Wai.Test
import RIO
import Test.Conduit.TestHelper
import Test.Hspec
spec :: Spec
spec =
context "Login/Register user" $ afterAll_ cleanUpDb $ do
context "Login/Register user" $ afterAll_ cleanUpDb $ do
withApplication $ do
it "should register a new user" $ do
response <- registerNewUser "test001"

View File

@ -1,23 +1,24 @@
module Test.Conduit.Api.StorySpec where
import Test.Hspec
import RIO
import qualified RIO.List as L
import qualified Test.Hspec.Wai as THW
import Test.Conduit.TestHelper
import Conduit.Api.Common
import Conduit.Api.User
import Conduit.Api.Article
import Conduit.Api.Comment
import Data.Maybe
import Conduit.Api.Common
import Conduit.Api.User
import Data.Aeson
import Network.Wai.Test
import Network.HTTP.Types
import qualified Data.ByteString as B
import Data.Maybe
import qualified Data.Text as T
import qualified Data.Text.Encoding as T
import Network.HTTP.Types
import Network.Wai.Test
import RIO
import qualified RIO.List as L
import Test.Conduit.TestHelper
import Test.Hspec
import qualified Test.Hspec.Wai as THW
-- * popularism is defined by the number of comments
-- A user comes to the website and sees a list of most popular articles
-- A user reads a few articles
-- A user finds his favourite author and looks for a list of article from this author sorted by most recent
@ -29,86 +30,88 @@ import qualified Data.Text.Encoding as T
spec :: Spec
spec = do
let username = "test"
context "Story APIs"
$ beforeAll_ (setupTestUser username)
$ beforeAll_ (setupTestArticle "1")
$ afterAll_ cleanUpDb
$ withApplication
$ do
it "should get a list of articles" $ do
response <- THW.request methodGet "/api/articles" [] ""
liftIO $ statusCode (simpleStatus response) `shouldBe` 200
let mbBody = decode (simpleBody response) :: Maybe ArticlesResponse
liftIO $ mbBody `shouldNotBe` Nothing
let (ArticlesResponse count [article]) = fromJust mbBody
liftIO $ count `shouldBe` 1
liftIO $ articleDataTitle article `shouldBe` "test article title"
context "Story APIs" $
beforeAll_ (setupTestUser username) $
beforeAll_ (setupTestArticle "1") $
afterAll_ cleanUpDb $
withApplication $
do
it "should get a list of articles" $ do
response <- THW.request methodGet "/api/articles" [] ""
liftIO $ statusCode (simpleStatus response) `shouldBe` 200
let mbBody = decode (simpleBody response) :: Maybe ArticlesResponse
liftIO $ mbBody `shouldNotBe` Nothing
let (ArticlesResponse count [article]) = fromJust mbBody
liftIO $ count `shouldBe` 1
liftIO $ articleDataTitle article `shouldBe` "test article title"
it "should get an article by slug" $ do
response <- THW.request methodGet "/api/articles/test-article" [] ""
liftIO $ statusCode (simpleStatus response) `shouldBe` 200
let mbBody = decode (simpleBody response) :: Maybe (BoxedArticle ArticleData)
liftIO $ mbBody `shouldNotBe` Nothing
let (BoxedArticle articleData) = fromJust mbBody
liftIO $ articleDataBody articleData `shouldBe` "test article body"
liftIO $ articleDataTitle articleData `shouldBe` "test article title"
it "should get an article by slug" $ do
response <- THW.request methodGet "/api/articles/test-article" [] ""
liftIO $ statusCode (simpleStatus response) `shouldBe` 200
let mbBody = decode (simpleBody response) :: Maybe (BoxedArticle ArticleData)
liftIO $ mbBody `shouldNotBe` Nothing
let (BoxedArticle articleData) = fromJust mbBody
liftIO $ articleDataBody articleData `shouldBe` "test article body"
liftIO $ articleDataTitle articleData `shouldBe` "test article title"
it "should get a list of articles from a specific author" $ do
response <- THW.request methodGet "/api/articles?author=test" [] ""
liftIO $ statusCode (simpleStatus response) `shouldBe` 200
let mbBody = decode (simpleBody response) :: Maybe ArticlesResponse
liftIO $ mbBody `shouldNotBe` Nothing
let (ArticlesResponse count [article]) = fromJust mbBody
liftIO $ count `shouldBe` 1
liftIO $ articleDataTitle article `shouldBe` "test article title"
it "should get a list of articles from a specific author" $ do
response <- THW.request methodGet "/api/articles?author=test" [] ""
liftIO $ statusCode (simpleStatus response) `shouldBe` 200
let mbBody = decode (simpleBody response) :: Maybe ArticlesResponse
liftIO $ mbBody `shouldNotBe` Nothing
let (ArticlesResponse count [article]) = fromJust mbBody
liftIO $ count `shouldBe` 1
liftIO $ articleDataTitle article `shouldBe` "test article title"
it "should fail to comment without authentication" $ do
let comment = BoxedComment (NewCommentData "a good test article")
response <- THW.request
methodPost
"/api/articles/test-article/comments"
[("Content-Type", "application/json")]
$ encode comment
liftIO $ statusCode (simpleStatus response) `shouldBe` 401
let mbBody = decode (simpleBody response) :: Maybe (BoxedComment CommentData)
liftIO $ mbBody `shouldBe` Nothing
it "should fail to comment without authentication" $ do
let comment = BoxedComment (NewCommentData "a good test article")
response <-
THW.request
methodPost
"/api/articles/test-article/comments"
[("Content-Type", "application/json")]
$ encode comment
liftIO $ statusCode (simpleStatus response) `shouldBe` 401
let mbBody = decode (simpleBody response) :: Maybe (BoxedComment CommentData)
liftIO $ mbBody `shouldBe` Nothing
it "should add a comment to an article" $ do
let body = "a good test article"
let comment = BoxedComment (NewCommentData body)
token <- getAccessToken username
response <-
THW.request
methodPost
"/api/articles/test-article/comments"
[ ("Content-Type", "application/json")
, ("Authorization", T.encodeUtf8 $ T.append "Token " token)]
$ encode comment
liftIO $ statusCode (simpleStatus response) `shouldBe` 200
let mbBody = decode (simpleBody response) :: Maybe (BoxedComment CommentData)
liftIO $ mbBody `shouldNotBe` Nothing
let (BoxedComment commentData) = fromJust mbBody
liftIO $ commentDataBody commentData `shouldBe` body
liftIO $ (profileUsername $ commentDataAuthor commentData) `shouldBe` username
it "should create an article" $ do
let title = "test article title"
let description = "test article description"
let body = "test article body"
let tagList = []
let article = BoxedArticle (NewArticleData title description body tagList)
token <- getAccessToken username
response <-
THW.request
methodPost
"/api/articles"
[ ("Content-Type", "application/json")
, ("Authorization", T.encodeUtf8 $ T.append "Token " token)]
$ encode article
liftIO $ statusCode (simpleStatus response) `shouldBe` 200
let mbBody = decode (simpleBody response) :: Maybe (BoxedArticle ArticleData)
liftIO $ mbBody `shouldNotBe` Nothing
let (BoxedArticle articleData) = fromJust mbBody
liftIO $ articleDataBody articleData `shouldBe` body
liftIO $ (profileUsername $ articleDataAuthor articleData) `shouldBe` username
it "should add a comment to an article" $ do
let body = "a good test article"
let comment = BoxedComment (NewCommentData body)
token <- getAccessToken username
response <-
THW.request
methodPost
"/api/articles/test-article/comments"
[ ("Content-Type", "application/json")
, ("Authorization", T.encodeUtf8 $ T.append "Token " token)
]
$ encode comment
liftIO $ statusCode (simpleStatus response) `shouldBe` 200
let mbBody = decode (simpleBody response) :: Maybe (BoxedComment CommentData)
liftIO $ mbBody `shouldNotBe` Nothing
let (BoxedComment commentData) = fromJust mbBody
liftIO $ commentDataBody commentData `shouldBe` body
liftIO $ (profileUsername $ commentDataAuthor commentData) `shouldBe` username
it "should create an article" $ do
let title = "test article title"
let description = "test article description"
let body = "test article body"
let tagList = []
let article = BoxedArticle (NewArticleData title description body tagList)
token <- getAccessToken username
response <-
THW.request
methodPost
"/api/articles"
[ ("Content-Type", "application/json")
, ("Authorization", T.encodeUtf8 $ T.append "Token " token)
]
$ encode article
liftIO $ statusCode (simpleStatus response) `shouldBe` 200
let mbBody = decode (simpleBody response) :: Maybe (BoxedArticle ArticleData)
liftIO $ mbBody `shouldNotBe` Nothing
let (BoxedArticle articleData) = fromJust mbBody
liftIO $ articleDataBody articleData `shouldBe` body
liftIO $ (profileUsername $ articleDataAuthor articleData) `shouldBe` username

View File

@ -1,55 +1,55 @@
module Test.Conduit.Api.UserApiSpec where
import Test.Hspec
import RIO
import qualified Test.Hspec.Wai as THW
import Test.Conduit.TestHelper
import Conduit.Api.Common
import Conduit.Api.User
import Data.Maybe
import Data.Aeson
import Network.Wai.Test
import Network.HTTP.Types
import qualified Data.ByteString as B
import Data.Maybe
import qualified Data.Text as T
import qualified Data.Text.Encoding as T
import Network.HTTP.Types
import Network.Wai.Test
import RIO
import Test.Conduit.TestHelper
import Test.Hspec
import qualified Test.Hspec.Wai as THW
spec :: Spec
spec = do
let username = "test"
context "User APIs"
$ beforeAll_ (setupTestUser username)
$ afterAll_ cleanUpDb
$ withApplication
$ do
it "Get user detail with token" $ do
token <- getAccessToken username
response <-
THW.request
methodGet
"/api/user"
[("Authorization", T.encodeUtf8 $ T.append "Token " token)]
""
liftIO $ statusCode (simpleStatus response) `shouldBe` 200
let mbBody = decode (simpleBody response) :: Maybe (UserData UserResponse)
liftIO $ mbBody `shouldNotBe` Nothing
let (UserData userResponse) = fromJust mbBody
liftIO $ urUsername userResponse `shouldBe` username
it "Update user" $ do
token <- getAccessToken username
response <-
THW.request
methodPut
"/api/user"
[ ("Content-Type", "application/json")
, ("Authorization", T.encodeUtf8 $ T.append "Token " token)]
"{ \"user\": { \"bio\": \"Test\", \"image\": \"URL\" }}"
liftIO $ statusCode (simpleStatus response) `shouldBe` 200
let mbBody = decode (simpleBody response) :: Maybe (UserData UserResponse)
liftIO $ mbBody `shouldNotBe` Nothing
let (UserData userResponse) = fromJust mbBody
liftIO $ urUsername userResponse `shouldBe` username
liftIO $ urImage userResponse `shouldBe` "URL"
liftIO $ urBio userResponse `shouldBe` "Test"
context "User APIs" $
beforeAll_ (setupTestUser username) $
afterAll_ cleanUpDb $
withApplication $
do
it "Get user detail with token" $ do
token <- getAccessToken username
response <-
THW.request
methodGet
"/api/user"
[("Authorization", T.encodeUtf8 $ T.append "Token " token)]
""
liftIO $ statusCode (simpleStatus response) `shouldBe` 200
let mbBody = decode (simpleBody response) :: Maybe (UserData UserResponse)
liftIO $ mbBody `shouldNotBe` Nothing
let (UserData userResponse) = fromJust mbBody
liftIO $ urUsername userResponse `shouldBe` username
it "Update user" $ do
token <- getAccessToken username
response <-
THW.request
methodPut
"/api/user"
[ ("Content-Type", "application/json")
, ("Authorization", T.encodeUtf8 $ T.append "Token " token)
]
"{ \"user\": { \"bio\": \"Test\", \"image\": \"URL\" }}"
liftIO $ statusCode (simpleStatus response) `shouldBe` 200
let mbBody = decode (simpleBody response) :: Maybe (UserData UserResponse)
liftIO $ mbBody `shouldNotBe` Nothing
let (UserData userResponse) = fromJust mbBody
liftIO $ urUsername userResponse `shouldBe` username
liftIO $ urImage userResponse `shouldBe` "URL"
liftIO $ urBio userResponse `shouldBe` "Test"

View File

@ -22,11 +22,10 @@ import Network.Wai
import qualified Network.Wai.Handler.Warp as Warp
import Network.Wai.Test
import RIO
import System.IO (putStrLn)
import Test.Hspec
import Test.Hspec.Wai
import qualified Test.Hspec.Wai as THW
import System.IO (putStrLn)
import Conduit.Config
loadTestEnv :: IO AppEnv
loadTestEnv = do
@ -36,7 +35,8 @@ loadTestEnv = do
result <- autoMigrate pool
whenJust result $ \e -> do
error $ show e
let env = AppEnv
let env =
AppEnv
{ envDbPool = pool
, envJwtKey = jwtKey
}
@ -75,20 +75,21 @@ setupTestArticle userId = do
let articleBody = "test article body"
let articleUserId = userId
runSql $
sql $ mconcat
[ "INSERT INTO articles (article_slug, article_title, article_description, article_body, article_user_id)"
, "VALUES ('"
, T.encodeUtf8 articleSlug
, "', '"
, T.encodeUtf8 articleTitle
, "', '"
, T.encodeUtf8 articleDescription
, "', '"
, T.encodeUtf8 articleBody
, "', "
, T.encodeUtf8 articleUserId
, ");"
]
sql $
mconcat
[ "INSERT INTO articles (article_slug, article_title, article_description, article_body, article_user_id)"
, "VALUES ('"
, T.encodeUtf8 articleSlug
, "', '"
, T.encodeUtf8 articleTitle
, "', '"
, T.encodeUtf8 articleDescription
, "', '"
, T.encodeUtf8 articleBody
, "', "
, T.encodeUtf8 articleUserId
, ");"
]
setupTestUser :: Text -> IO ()
setupTestUser username = do
@ -96,17 +97,18 @@ setupTestUser username = do
let password = Password $ T.append username "password"
hash <- hashPassword password
runSql $
sql $ mconcat
[ "INSERT INTO users (user_email, user_username, user_password, user_bio, user_image)"
, "VALUES ('"
, T.encodeUtf8 email
, "', '"
, T.encodeUtf8 username
, "', '"
, T.encodeUtf8 . getHashedPasswd $ hash
, "', ''"
, ", '');"
]
sql $
mconcat
[ "INSERT INTO users (user_email, user_username, user_password, user_bio, user_image)"
, "VALUES ('"
, T.encodeUtf8 email
, "', '"
, T.encodeUtf8 username
, "', '"
, T.encodeUtf8 . getHashedPasswd $ hash
, "', ''"
, ", '');"
]
removeTestUser :: Text -> IO ()
removeTestUser username = do
@ -114,12 +116,13 @@ removeTestUser username = do
let password = Password $ T.append username "password"
hash <- hashPassword password
runSql $
sql $ mconcat
[ "DELETE FROM users "
, "WHERE user_username = '"
, T.encodeUtf8 username
, "';"
]
sql $
mconcat
[ "DELETE FROM users "
, "WHERE user_username = '"
, T.encodeUtf8 username
, "';"
]
registerNewUser :: forall st. Text -> THW.WaiSession st SResponse
registerNewUser username = do