mirror of
https://github.com/monadicsystems/okapi.git
synced 2024-11-22 17:04:17 +03:00
Transfer all example from old repo
This commit is contained in:
parent
ae26c53280
commit
826225af45
2246
Conduit.postman_collection.json
Normal file
2246
Conduit.postman_collection.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,4 +0,0 @@
|
||||
module Main where
|
||||
|
||||
main :: IO ()
|
||||
main = print "Okapi"
|
71
examples/calc/Main.hs
Normal file
71
examples/calc/Main.hs
Normal file
@ -0,0 +1,71 @@
|
||||
{-# LANGUAGE DeriveAnyClass #-}
|
||||
{-# LANGUAGE DeriveGeneric #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
{-# LANGUAGE TypeApplications #-}
|
||||
|
||||
module Main where
|
||||
|
||||
import Control.Applicative ((<|>))
|
||||
import Data.Aeson (ToJSON)
|
||||
import Data.Text
|
||||
import GHC.Generics (Generic)
|
||||
import Okapi
|
||||
|
||||
|
||||
main :: IO ()
|
||||
main = runOkapi id 3000 calc
|
||||
|
||||
type Okapi a = OkapiT IO a
|
||||
|
||||
calc :: Okapi Response
|
||||
calc = do
|
||||
get
|
||||
seg "calc"
|
||||
addOp <|> subOp <|> mulOp <|> divOp
|
||||
|
||||
addOp :: Okapi Response
|
||||
addOp = do
|
||||
seg "add"
|
||||
(x, y) <- getArgs
|
||||
respondJSON [] $ x + y
|
||||
|
||||
subOp :: Okapi Response
|
||||
subOp = do
|
||||
seg "sub" <|> seg "minus"
|
||||
(x, y) <- getArgs
|
||||
respondJSON [] $ x - y
|
||||
|
||||
mulOp :: Okapi Response
|
||||
mulOp = do
|
||||
seg "mul"
|
||||
(x, y) <- getArgs
|
||||
respondJSON [] $ x * y
|
||||
|
||||
data DivResult = DivResult
|
||||
{ answer :: Int,
|
||||
remainder :: Int
|
||||
}
|
||||
deriving (Eq, Show, Generic, ToJSON)
|
||||
|
||||
divOp :: Okapi Response
|
||||
divOp = do
|
||||
seg "div"
|
||||
(x, y) <- getArgs
|
||||
if y == 0
|
||||
then abort401 [] ""
|
||||
else respondJSON [] $ DivResult {answer = x `div` y, remainder = x `mod` y}
|
||||
|
||||
getArgs :: Okapi (Int, Int)
|
||||
getArgs = getArgsFromPath <|> getArgsFromQueryParams
|
||||
where
|
||||
getArgsFromPath :: Okapi (Int, Int)
|
||||
getArgsFromPath = do
|
||||
x <- segParamAs @Int
|
||||
y <- segParamAs @Int
|
||||
pure (x, y)
|
||||
|
||||
getArgsFromQueryParams :: Okapi (Int, Int)
|
||||
getArgsFromQueryParams = do
|
||||
x <- queryParamAs @Int "x"
|
||||
y <- queryParamAs @Int "y"
|
||||
pure (x, y)
|
62
examples/realworld/Conduit/Auth.hs
Normal file
62
examples/realworld/Conduit/Auth.hs
Normal file
@ -0,0 +1,62 @@
|
||||
{-# LANGUAGE FlexibleContexts #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
{-# LANGUAGE RecordWildCards #-}
|
||||
{-# LANGUAGE TypeApplications #-}
|
||||
|
||||
module Conduit.Auth where
|
||||
|
||||
import Conduit.Type (EncodedUser (..), MonadHandler, User (..), grab)
|
||||
import Data.Aeson
|
||||
import Data.Char (isSpace)
|
||||
import Data.Int
|
||||
import Data.Map as Map (fromList, (!?))
|
||||
import Data.Text
|
||||
import qualified Data.Text as Text
|
||||
import Web.JWT as JWT
|
||||
|
||||
userIdToToken :: MonadHandler m => Int32 -> m Text
|
||||
userIdToToken userID = do
|
||||
jwtSecret <- grab @Text
|
||||
return $ encodeUserID jwtSecret userID
|
||||
|
||||
encodeUser :: Text -> User -> EncodedUser
|
||||
encodeUser secret User {..} =
|
||||
EncodedUser
|
||||
{ encodedUserEmail = userEmail,
|
||||
encodedUserToken = encodeUserID secret userID,
|
||||
encodedUserUsername = userUsername,
|
||||
encodedUserBio = userBio,
|
||||
encodedUserImage = userImage,
|
||||
encodedUserCreatedAt = userCreatedAt,
|
||||
encodedUserUpdatedAt = userUpdatedAt
|
||||
}
|
||||
|
||||
encodeUserID :: Text -> Int32 -> Text
|
||||
encodeUserID jwtSecret userID =
|
||||
encodeSigned
|
||||
(JWT.hmacSecret jwtSecret)
|
||||
mempty
|
||||
(mempty {unregisteredClaims = ClaimsMap $ Map.fromList [(jwtKey, toJSON userID)]})
|
||||
|
||||
tokenToUserID :: MonadHandler m => Text -> m (Maybe Int32)
|
||||
tokenToUserID token = do
|
||||
jwtSecret <- grab @Text
|
||||
pure $ verifyToken jwtSecret token
|
||||
|
||||
verifyToken :: Text -> Text -> Maybe Int32
|
||||
verifyToken jwtSecret token = do
|
||||
jwt <- JWT.decodeAndVerifySignature (JWT.toVerify $ JWT.hmacSecret jwtSecret) token
|
||||
result <- fromJSON <$> unClaimsMap (JWT.unregisteredClaims (JWT.claims jwt)) !? jwtKey
|
||||
case result of
|
||||
(Success userID) -> Just userID
|
||||
_ -> Nothing
|
||||
|
||||
jwtKey :: Text
|
||||
jwtKey = "jwt"
|
||||
|
||||
extractToken :: Text -> Maybe Text
|
||||
extractToken auth
|
||||
| toLower x == "token" = Just $ Text.dropWhile isSpace y
|
||||
| otherwise = Nothing
|
||||
where
|
||||
(x, y) = Text.break isSpace auth
|
8
examples/realworld/Conduit/Database.hs
Normal file
8
examples/realworld/Conduit/Database.hs
Normal file
@ -0,0 +1,8 @@
|
||||
module Conduit.Database
|
||||
( module Conduit.Database.Query,
|
||||
module Conduit.Database.Setup,
|
||||
)
|
||||
where
|
||||
|
||||
import Conduit.Database.Query
|
||||
import Conduit.Database.Setup
|
628
examples/realworld/Conduit/Database/Query.hs
Normal file
628
examples/realworld/Conduit/Database/Query.hs
Normal file
@ -0,0 +1,628 @@
|
||||
{-# LANGUAGE ConstraintKinds #-}
|
||||
{-# LANGUAGE FlexibleContexts #-}
|
||||
{-# LANGUAGE QuasiQuotes #-}
|
||||
{-# LANGUAGE RecordWildCards #-}
|
||||
{-# LANGUAGE ScopedTypeVariables #-}
|
||||
{-# LANGUAGE TypeApplications #-}
|
||||
|
||||
module Conduit.Database.Query where
|
||||
|
||||
import Conduit.Auth
|
||||
import Conduit.Type
|
||||
import Control.Monad (forM)
|
||||
import Control.Monad.IO.Class (MonadIO, liftIO)
|
||||
import Control.Monad.Reader.Class (MonadReader)
|
||||
import Data.Bifunctor (bimap)
|
||||
import Data.Either (rights)
|
||||
import Data.Function ((&))
|
||||
import Data.Int
|
||||
import Data.Profunctor (dimap)
|
||||
import Data.Text
|
||||
import Data.Time
|
||||
import Data.Vector (Vector)
|
||||
import qualified Data.Vector as Vector
|
||||
import Hasql.Connection (Connection)
|
||||
import Hasql.Session (QueryError, Session, statement)
|
||||
import qualified Hasql.Session as Session
|
||||
import Hasql.Statement (Statement)
|
||||
import qualified Hasql.Statement as Statement
|
||||
import Hasql.TH (resultlessStatement)
|
||||
import qualified Hasql.TH as TH
|
||||
|
||||
-- DB Functions
|
||||
|
||||
loginUser :: MonadHandler m => Login -> m (Either QueryError EncodedUser)
|
||||
loginUser loginData = do
|
||||
result <- execSql (statement loginData sql)
|
||||
case result of
|
||||
Right user -> do
|
||||
secret <- grab @Text
|
||||
pure $ Right $ encodeUser secret user
|
||||
Left queryError -> pure $ Left queryError
|
||||
where
|
||||
sql :: Statement Login User
|
||||
sql =
|
||||
dimap
|
||||
loginToTuple
|
||||
tupleToUser
|
||||
[TH.singletonStatement|
|
||||
select
|
||||
pk_user :: int4,
|
||||
email :: text,
|
||||
username :: text,
|
||||
bio :: text,
|
||||
image :: text,
|
||||
created_at :: timestamptz,
|
||||
updated_at :: timestamptz
|
||||
from users
|
||||
where email = $1 :: text and hash = crypt($2 :: text, hash)
|
||||
|]
|
||||
|
||||
loginToTuple :: Login -> (Text, Text)
|
||||
loginToTuple Login {..} = (loginEmail, loginPassword)
|
||||
|
||||
registerUser :: MonadHandler m => Register -> m (Either QueryError EncodedUser)
|
||||
registerUser register = do
|
||||
result <- execSql (statement register sql)
|
||||
case result of
|
||||
Right user -> do
|
||||
secret <- grab @Text
|
||||
pure $ Right $ encodeUser secret user
|
||||
Left queryError -> pure $ Left $ queryError
|
||||
where
|
||||
sql :: Statement Register User
|
||||
sql =
|
||||
dimap
|
||||
registerToTuple
|
||||
tupleToUser
|
||||
[TH.singletonStatement|
|
||||
insert into users (email, username, hash)
|
||||
values ($1 :: text, $2 :: text, crypt($3 :: text, gen_salt('bf')))
|
||||
returning pk_user :: int4, email :: text, username :: text, bio :: text, image :: text, created_at :: timestamptz, updated_at :: timestamptz
|
||||
|]
|
||||
|
||||
registerToTuple :: Register -> (Text, Text, Text)
|
||||
registerToTuple Register {..} = (registerEmail, registerUsername, registerPassword)
|
||||
|
||||
tupleToUser :: (Int32, Text, Text, Text, Text, UTCTime, UTCTime) -> User
|
||||
tupleToUser (userID, userEmail, userUsername, userBio, userImage, userCreatedAt, userUpdatedAt) = User {..}
|
||||
|
||||
getCurrentUser :: MonadHandler m => Int32 -> m (Either QueryError EncodedUser)
|
||||
getCurrentUser userID = do
|
||||
result <- execSql (statement userID sql)
|
||||
case result of
|
||||
Right user -> do
|
||||
secret <- grab @Text
|
||||
pure $ Right $ encodeUser secret user
|
||||
Left queryError -> pure $ Left $ queryError
|
||||
where
|
||||
sql :: Statement Int32 User
|
||||
sql =
|
||||
dimap
|
||||
id
|
||||
tupleToUser
|
||||
[TH.singletonStatement|
|
||||
select pk_user :: int, email :: text, username :: text, bio :: text, image :: text, created_at :: timestamptz, updated_at :: timestamptz
|
||||
from users
|
||||
where $1 :: int4 = pk_user
|
||||
|]
|
||||
|
||||
updateUser :: MonadHandler m => Int32 -> UpdateUser -> m (Either QueryError EncodedUser)
|
||||
updateUser userID updateUser = do
|
||||
result <- execSql (statement (userID, updateUser) sql)
|
||||
case result of
|
||||
Right user -> do
|
||||
secret <- grab @Text
|
||||
pure $ Right $ encodeUser secret user
|
||||
Left queryError -> pure $ Left $ queryError
|
||||
where
|
||||
sql :: Statement (Int32, UpdateUser) User
|
||||
sql =
|
||||
dimap
|
||||
userIDWithUpdateUserToTuple
|
||||
tupleToUser
|
||||
[TH.singletonStatement|
|
||||
update users
|
||||
set
|
||||
email = coalesce($2 :: text?, email),
|
||||
username = coalesce($3 :: text?, username),
|
||||
bio = coalesce($5 :: text?, bio),
|
||||
image = coalesce($6 :: text?, image),
|
||||
hash =
|
||||
case
|
||||
when $4 :: text? is null then hash
|
||||
else crypt($4 :: text?, gen_salt('bf'))
|
||||
end
|
||||
where pk_user = $1 :: int4
|
||||
returning pk_user :: int4, username :: text, email :: text, bio :: text, image :: text, created_at :: timestamptz, updated_at :: timestamptz
|
||||
|]
|
||||
|
||||
userIDWithUpdateUserToTuple :: (Int32, UpdateUser) -> (Int32, Maybe Text, Maybe Text, Maybe Text, Maybe Text, Maybe Text)
|
||||
userIDWithUpdateUserToTuple (userID, UpdateUser {..}) = (userID, updateUserEmail, updateUserUsername, updateUserPassword, updateUserBio, updateUserImage)
|
||||
|
||||
getProfile :: MonadHandler m => Maybe Int32 -> Text -> m (Either QueryError Profile)
|
||||
getProfile mbUserID username = execSql (statement (mbUserID, username) sql)
|
||||
where
|
||||
sql :: Statement (Maybe Int32, Text) Profile
|
||||
sql =
|
||||
dimap
|
||||
id
|
||||
tupleToProfile
|
||||
[TH.singletonStatement|
|
||||
select
|
||||
username :: text
|
||||
, bio :: text
|
||||
, image :: text
|
||||
, ( exists
|
||||
( select follower_id :: int4
|
||||
from follows
|
||||
inner join users on username = $2 :: text
|
||||
where follower_id = $1 :: int4?
|
||||
)
|
||||
) :: bool
|
||||
from users
|
||||
where $2 :: text = username
|
||||
|]
|
||||
|
||||
tupleToProfile :: (Text, Text, Text, Bool) -> Profile
|
||||
tupleToProfile (profileUsername, profileImage, profileBio, profileFollowing) = Profile {..}
|
||||
|
||||
followUser :: MonadHandler m => Int32 -> Text -> m (Either QueryError Profile)
|
||||
followUser userID username = do
|
||||
execSql (statement (userID, username) sql)
|
||||
getProfile (Just userID) username
|
||||
where
|
||||
sql :: Statement (Int32, Text) ()
|
||||
sql =
|
||||
dimap
|
||||
id
|
||||
id
|
||||
[TH.resultlessStatement|
|
||||
insert into follows (follower_id, followee_id)
|
||||
values ($1 :: int4, (select pk_user from users where users.username = $2 :: text))
|
||||
|]
|
||||
|
||||
unfollowUser :: MonadHandler m => Int32 -> Text -> m (Either QueryError Profile)
|
||||
unfollowUser userID username = do
|
||||
execSql (statement (userID, username) sql)
|
||||
getProfile (Just userID) username
|
||||
where
|
||||
sql :: Statement (Int32, Text) ()
|
||||
sql =
|
||||
dimap
|
||||
id
|
||||
id
|
||||
[TH.resultlessStatement|
|
||||
delete from follows
|
||||
where
|
||||
$1 :: int4 = follower_id and (select pk_user from users where users.username = $2 :: text) = followee_id
|
||||
|]
|
||||
|
||||
getArticles :: MonadHandler m => Maybe Int32 -> ArticlesQuery -> m (Either QueryError Articles)
|
||||
getArticles mbUserID articlesQuery = execSql (statement (mbUserID, articlesQuery) sql)
|
||||
where
|
||||
sql :: Statement (Maybe Int32, ArticlesQuery) Articles
|
||||
sql =
|
||||
dimap
|
||||
articlesQueryToTuple
|
||||
(articleListToArticles . Vector.toList . fmap tupleToArticle)
|
||||
[TH.vectorStatement|
|
||||
select
|
||||
articles.slug :: text
|
||||
, articles.title :: text
|
||||
, articles.description :: text
|
||||
, articles.body :: text
|
||||
, (array(select tag from tags where tags.fk_article = articles.pk_article order by tag)) :: text[]
|
||||
, (exists(select favorites.fk_user from favorites where favorites.fk_user = $1 :: int4? and favorites.fk_article = articles.pk_article)) :: bool
|
||||
, (select count(*) from favorites favoritess where favoritess.fk_article = articles.pk_article) :: int4
|
||||
, articles.created_at :: timestamptz
|
||||
, articles.updated_at :: timestamptz
|
||||
, users.username :: text
|
||||
, users.bio :: text
|
||||
, users.image :: text
|
||||
, ( exists
|
||||
( select follower_id :: int4
|
||||
from follows
|
||||
inner join users userss on userss.username = users.username
|
||||
where follower_id = $1 :: int4?
|
||||
)
|
||||
) :: bool
|
||||
from articles
|
||||
inner join users on articles.fk_user = users.pk_user
|
||||
where
|
||||
($2 :: text? is null or (articles.pk_article in (select tags.fk_article from tags where tags.tag = $2 :: text? and tags.fk_article = articles.pk_article)))
|
||||
and ($3 :: text? is null or $3 :: text? = users.username)
|
||||
and ($4 :: text? is null or articles.pk_article in (select favoritesss.fk_article from favorites favoritesss inner join users userss on userss.username = $4 :: text? where favoritesss.fk_article = articles.pk_article))
|
||||
order by articles.created_at
|
||||
limit $5 :: int4
|
||||
offset $6 :: int4
|
||||
|]
|
||||
|
||||
articlesQueryToTuple :: (Maybe Int32, ArticlesQuery) -> (Maybe Int32, Maybe Text, Maybe Text, Maybe Text, Int32, Int32)
|
||||
articlesQueryToTuple (mbUserID, ArticlesQuery {..}) =
|
||||
( mbUserID,
|
||||
articlesQueryTag,
|
||||
articlesQueryAuthor,
|
||||
articlesQueryFavorited,
|
||||
articlesQueryLimit,
|
||||
articlesQueryOffset
|
||||
)
|
||||
|
||||
articleListToArticles :: [Article] -> Articles
|
||||
articleListToArticles articlesArticleList = let articlesArticlesCount = Prelude.length articlesArticleList in Articles {..}
|
||||
|
||||
feedArticles :: MonadHandler m => Int32 -> Int32 -> Int32 -> m (Either QueryError Articles)
|
||||
feedArticles userID limit offset = execSql (statement (userID, limit, offset) sql)
|
||||
where
|
||||
sql :: Statement (Int32, Int32, Int32) Articles
|
||||
sql =
|
||||
dimap
|
||||
id
|
||||
(articleListToArticles . Vector.toList . fmap tupleToArticle)
|
||||
[TH.vectorStatement|
|
||||
select
|
||||
slug :: text
|
||||
, title :: text
|
||||
, description :: text
|
||||
, body :: text
|
||||
, (array(select tag from tags where tags.fk_article = articles.pk_article)) :: text[]
|
||||
, true :: bool
|
||||
, (select count(*) from favorites where favorites.fk_article = articles.pk_article) :: int4
|
||||
, articles.created_at :: timestamptz
|
||||
, articles.updated_at :: timestamptz
|
||||
, users.username :: text
|
||||
, users.bio :: text
|
||||
, users.image :: text
|
||||
, ( exists
|
||||
( select follower_id :: int4
|
||||
from follows
|
||||
inner join users userss on userss.username = users.username
|
||||
where follower_id = $1 :: int4
|
||||
)
|
||||
) :: bool
|
||||
from articles
|
||||
inner join users on articles.fk_user = users.pk_user
|
||||
inner join favorites favoritess on articles.pk_article = favoritess.fk_article and favoritess.fk_user = $1 :: int4
|
||||
order by articles.created_at
|
||||
limit $2 :: int4
|
||||
offset $3 :: int4
|
||||
|]
|
||||
|
||||
getArticle :: MonadHandler m => Text -> m (Either QueryError Article)
|
||||
getArticle slug = execSql (statement slug sql)
|
||||
where
|
||||
sql :: Statement Text Article
|
||||
sql =
|
||||
dimap
|
||||
id
|
||||
tupleToArticle
|
||||
[TH.singletonStatement|
|
||||
select
|
||||
slug :: text
|
||||
, title :: text
|
||||
, description :: text
|
||||
, body :: text
|
||||
, (array(select tag from tags where tags.fk_article = articles.pk_article)) :: text[]
|
||||
, false :: bool
|
||||
, (select count(*) from favorites where favorites.fk_article = articles.pk_article) :: int4
|
||||
, articles.created_at :: timestamptz
|
||||
, articles.updated_at :: timestamptz
|
||||
, users.username :: text
|
||||
, users.bio :: text
|
||||
, users.image :: text
|
||||
, false :: bool
|
||||
from articles
|
||||
inner join users on articles.fk_user = users.pk_user
|
||||
where articles.slug = $1 :: text
|
||||
|]
|
||||
|
||||
getArticleByID :: MonadHandler m => Maybe Int32 -> Int32 -> m (Either QueryError Article)
|
||||
getArticleByID mbUserID articleID = execSql (statement (mbUserID, articleID) sql)
|
||||
where
|
||||
sql :: Statement (Maybe Int32, Int32) Article
|
||||
sql =
|
||||
dimap
|
||||
id
|
||||
tupleToArticle
|
||||
[TH.singletonStatement|
|
||||
select
|
||||
slug :: text
|
||||
, title :: text
|
||||
, description :: text
|
||||
, body :: text
|
||||
, (array(select tag from tags where tags.fk_article = articles.pk_article)) :: text[]
|
||||
, (exists(select favorites.fk_user from favorites where favorites.fk_user = $1 :: int4? and favorites.fk_article = articles.pk_article)) :: bool
|
||||
, (select count(*) from favorites where favorites.fk_article = articles.pk_article) :: int4
|
||||
, articles.created_at :: timestamptz
|
||||
, articles.updated_at :: timestamptz
|
||||
, users.username :: text
|
||||
, users.bio :: text
|
||||
, users.image :: text
|
||||
, ( exists
|
||||
( select follower_id :: int4
|
||||
from follows
|
||||
inner join users userss on userss.username = users.username
|
||||
where follower_id = $1 :: int4?
|
||||
)
|
||||
) :: bool
|
||||
from articles
|
||||
inner join users on articles.fk_user = users.pk_user
|
||||
where articles.pk_article = $2 :: int4
|
||||
|]
|
||||
|
||||
tupleToArticle ::
|
||||
( Text,
|
||||
Text,
|
||||
Text,
|
||||
Text,
|
||||
Vector Text,
|
||||
Bool,
|
||||
Int32,
|
||||
UTCTime,
|
||||
UTCTime,
|
||||
Text,
|
||||
Text,
|
||||
Text,
|
||||
Bool
|
||||
) ->
|
||||
Article
|
||||
tupleToArticle
|
||||
( articleSlug,
|
||||
articleTitle,
|
||||
articleDescription,
|
||||
articleBody,
|
||||
articleTagVector,
|
||||
articleFavorited,
|
||||
articleFavoritesCount,
|
||||
articleCreatedAt,
|
||||
articleUpdatedAt,
|
||||
authorUsername,
|
||||
authorBio,
|
||||
authorImage,
|
||||
authorFollowing
|
||||
) =
|
||||
let articleTagList = Vector.toList articleTagVector
|
||||
articleAuthor = Author {..}
|
||||
in Article {..}
|
||||
|
||||
createTags :: MonadHandler m => Int32 -> Maybe [Text] -> m (Either QueryError ())
|
||||
createTags articleID mbTags = case mbTags of
|
||||
Nothing -> pure $ Right ()
|
||||
Just tags -> execSql (statement (bimap Vector.fromList Vector.fromList $ unzip $ fmap (\tag -> (articleID, tag)) tags) sql)
|
||||
where
|
||||
sql :: Statement (Vector Int32, Vector Text) ()
|
||||
sql =
|
||||
dimap
|
||||
id
|
||||
id
|
||||
[TH.resultlessStatement|
|
||||
insert into tags (fk_article, tag)
|
||||
select * from unnest ($1 :: int4[], $2 :: text[])
|
||||
|]
|
||||
|
||||
createArticle :: MonadHandler m => Int32 -> CreateArticle -> m (Either QueryError Article)
|
||||
createArticle userID createArticleData = do
|
||||
eitherArticleID <- execSql (statement (userID, createArticleData) sql)
|
||||
case eitherArticleID of
|
||||
Left queryError -> pure $ Left queryError
|
||||
Right articleID -> do
|
||||
createTags articleID $ createArticleTagList createArticleData
|
||||
getArticleByID (Just userID) articleID
|
||||
where
|
||||
sql :: Statement (Int32, CreateArticle) Int32
|
||||
sql =
|
||||
dimap
|
||||
userIDWithCreateArticleToTuple
|
||||
id
|
||||
[TH.singletonStatement|
|
||||
insert into articles (fk_user, slug, title, description, body)
|
||||
values ($1 :: int4, $2 :: text, $3 :: text, $4 :: text, $5 :: text)
|
||||
returning pk_article :: int4
|
||||
|]
|
||||
|
||||
userIDWithCreateArticleToTuple :: (Int32, CreateArticle) -> (Int32, Text, Text, Text, Text)
|
||||
userIDWithCreateArticleToTuple (userID, CreateArticle {..}) =
|
||||
(userID, slugify createArticleTitle, createArticleTitle, createArticleDescription, createArticleBody)
|
||||
|
||||
slugify :: Text -> Text
|
||||
slugify = id
|
||||
|
||||
updateArticle :: MonadHandler m => Int32 -> Text -> UpdateArticle -> m (Either QueryError Article)
|
||||
updateArticle userID slug updateArticleData = do
|
||||
eitherArticleID <- execSql (statement (userID, slug, updateArticleData) sql)
|
||||
case eitherArticleID of
|
||||
Left queryError -> pure $ Left queryError
|
||||
Right articleID -> getArticleByID (Just userID) articleID
|
||||
where
|
||||
sql :: Statement (Int32, Text, UpdateArticle) Int32
|
||||
sql =
|
||||
dimap
|
||||
userIDWithUpdateArticleToTuple
|
||||
id
|
||||
[TH.singletonStatement|
|
||||
update articles
|
||||
set
|
||||
title = coalesce($3 :: text?, title),
|
||||
slug = coalesce($4 :: text?, slug),
|
||||
description = coalesce($5 :: text?, description),
|
||||
body = coalesce($6 :: text?, body)
|
||||
where fk_user = $1 :: int4 and slug = $2 :: text
|
||||
returning pk_article :: int4
|
||||
|]
|
||||
|
||||
userIDWithUpdateArticleToTuple :: (Int32, Text, UpdateArticle) -> (Int32, Text, Maybe Text, Maybe Text, Maybe Text, Maybe Text)
|
||||
userIDWithUpdateArticleToTuple (userID, slug, UpdateArticle {..}) =
|
||||
(userID, slug, slugify <$> updateArticleTitle, updateArticleTitle, updateArticleDescription, updateArticleBody)
|
||||
|
||||
deleteArticle :: MonadHandler m => Int32 -> Text -> m (Either QueryError ())
|
||||
deleteArticle userID slug = execSql (statement (userID, slug) sql)
|
||||
where
|
||||
sql :: Statement (Int32, Text) ()
|
||||
sql =
|
||||
dimap
|
||||
id
|
||||
id
|
||||
[TH.resultlessStatement|
|
||||
delete from articles
|
||||
where $1 :: int4 = fk_user and $2 :: text = slug
|
||||
|]
|
||||
|
||||
getCommentByID :: MonadHandler m => Int32 -> m (Either QueryError Comment)
|
||||
getCommentByID commentID = execSql (statement commentID sql)
|
||||
where
|
||||
sql :: Statement Int32 Comment
|
||||
sql =
|
||||
dimap
|
||||
id
|
||||
tupleToComment
|
||||
[TH.singletonStatement|
|
||||
select
|
||||
pk_comment :: int4,
|
||||
comments.created_at :: timestamptz,
|
||||
comments.updated_at :: timestamptz,
|
||||
body :: text,
|
||||
users.username :: text,
|
||||
users.bio :: text,
|
||||
users.image :: text,
|
||||
false :: bool
|
||||
from comments
|
||||
inner join users on users.pk_user = comments.fk_user
|
||||
where $1 :: int4 = pk_comment
|
||||
|]
|
||||
|
||||
createComment :: MonadHandler m => Int32 -> Text -> CreateComment -> m (Either QueryError Comment)
|
||||
createComment userID slug createCommentData = do
|
||||
eitherCommentID <- execSql (statement (userID, slug, createCommentData) sql)
|
||||
case eitherCommentID of
|
||||
Left queryError -> pure $ Left queryError
|
||||
Right commentID -> getCommentByID commentID
|
||||
where
|
||||
sql :: Statement (Int32, Text, CreateComment) Int32
|
||||
sql =
|
||||
dimap
|
||||
userIDAndSlugWithCreateCommentToTuple
|
||||
id
|
||||
[TH.singletonStatement|
|
||||
insert into comments (fk_user, fk_article, body)
|
||||
values ($1 :: int4, (select pk_article from articles where slug = $2 :: text), $3 :: text)
|
||||
returning pk_comment :: int4
|
||||
|]
|
||||
|
||||
userIDAndSlugWithCreateCommentToTuple :: (Int32, Text, CreateComment) -> (Int32, Text, Text)
|
||||
userIDAndSlugWithCreateCommentToTuple (userID, slug, CreateComment {..}) =
|
||||
(userID, slug, createCommentBody)
|
||||
|
||||
tupleToComment :: (Int32, UTCTime, UTCTime, Text, Text, Text, Text, Bool) -> Comment
|
||||
tupleToComment (commentID, commentCreatedAt, commentUpdatedAt, commentBody, authorUsername, authorBio, authorImage, authorFollowing) =
|
||||
let commentAuthor = Author {..}
|
||||
in Comment {..}
|
||||
|
||||
getComments :: MonadHandler m => Maybe Int32 -> Text -> m (Either QueryError Comments)
|
||||
getComments mbUserID slug = execSql (statement (mbUserID, slug) sql)
|
||||
where
|
||||
sql :: Statement (Maybe Int32, Text) Comments
|
||||
sql =
|
||||
dimap
|
||||
id
|
||||
(Comments . Vector.toList . fmap tupleToComment)
|
||||
[TH.vectorStatement|
|
||||
select
|
||||
pk_comment :: int4,
|
||||
comments.created_at :: timestamptz,
|
||||
comments.updated_at :: timestamptz,
|
||||
comments.body :: text,
|
||||
users.username :: text,
|
||||
users.bio :: text,
|
||||
users.image :: text,
|
||||
( exists
|
||||
( select follower_id :: int4
|
||||
from follows
|
||||
inner join users userss on userss.username = users.username
|
||||
where follower_id = $1 :: int4?
|
||||
)
|
||||
) :: bool
|
||||
from comments
|
||||
inner join users on users.pk_user = comments.fk_user
|
||||
inner join articles on articles.pk_article = comments.fk_article
|
||||
where $2 :: text = articles.slug
|
||||
order by comments.created_at
|
||||
|]
|
||||
|
||||
deleteComment :: MonadHandler m => Int32 -> Text -> Int32 -> m (Either QueryError ())
|
||||
deleteComment userID _ commentID = execSql (statement (userID, commentID) sql)
|
||||
where
|
||||
sql :: Statement (Int32, Int32) ()
|
||||
sql =
|
||||
dimap
|
||||
id
|
||||
id
|
||||
[TH.resultlessStatement|
|
||||
delete from comments
|
||||
where $1 :: int4 = fk_user and $2 :: int4 = pk_comment
|
||||
|]
|
||||
|
||||
favoriteArticle :: MonadHandler m => Int32 -> Text -> m (Either QueryError Article)
|
||||
favoriteArticle userID slug = do
|
||||
eitherArticleID <- execSql (statement (userID, slug) sql)
|
||||
case eitherArticleID of
|
||||
Left queryError -> pure $ Left queryError
|
||||
Right articleID -> getArticleByID (Just userID) articleID
|
||||
where
|
||||
sql :: Statement (Int32, Text) Int32
|
||||
sql =
|
||||
dimap
|
||||
id
|
||||
id
|
||||
[TH.singletonStatement|
|
||||
insert into favorites (fk_user, fk_article)
|
||||
values ($1 :: int4, (select pk_article from articles where slug = $2 :: text))
|
||||
returning favorites.fk_article :: int4
|
||||
|]
|
||||
|
||||
unfavoriteArticle :: MonadHandler m => Int32 -> Text -> m (Either QueryError Article)
|
||||
unfavoriteArticle userID slug = do
|
||||
eitherArticleID <- execSql (statement (userID, slug) sql)
|
||||
case eitherArticleID of
|
||||
Left queryError -> pure $ Left queryError
|
||||
Right articleID -> getArticleByID (Just userID) articleID
|
||||
where
|
||||
sql :: Statement (Int32, Text) Int32
|
||||
sql =
|
||||
dimap
|
||||
id
|
||||
id
|
||||
[TH.singletonStatement|
|
||||
delete from favorites
|
||||
using articles
|
||||
where favorites.fk_user = $1 :: int4 and articles.slug = $2 :: text and articles.pk_article = favorites.fk_article
|
||||
returning favorites.fk_article :: int4
|
||||
|]
|
||||
|
||||
getTags :: MonadHandler m => m (Either QueryError Tags)
|
||||
getTags = execSql (statement () sql)
|
||||
where
|
||||
sql :: Statement () Tags
|
||||
sql =
|
||||
dimap
|
||||
id
|
||||
(Tags . Vector.toList)
|
||||
[TH.vectorStatement|
|
||||
select tag :: text from tags
|
||||
|]
|
||||
|
||||
deleteTags :: MonadHandler m => Int32 -> m (Either QueryError ())
|
||||
deleteTags articleID = execSql (statement articleID sql)
|
||||
where
|
||||
sql :: Statement Int32 ()
|
||||
sql =
|
||||
dimap
|
||||
id
|
||||
id
|
||||
[TH.resultlessStatement|
|
||||
delete from tags where fk_article = $1 :: int4
|
||||
|]
|
||||
|
||||
execSql :: MonadHandler m => Session a -> m (Either QueryError a)
|
||||
execSql session = do
|
||||
conn <- grab @Connection
|
||||
liftIO $ Session.run session conn
|
115
examples/realworld/Conduit/Database/Setup.hs
Normal file
115
examples/realworld/Conduit/Database/Setup.hs
Normal file
@ -0,0 +1,115 @@
|
||||
{-# LANGUAGE ConstraintKinds #-}
|
||||
{-# LANGUAGE FlexibleContexts #-}
|
||||
{-# LANGUAGE QuasiQuotes #-}
|
||||
{-# LANGUAGE RecordWildCards #-}
|
||||
{-# LANGUAGE ScopedTypeVariables #-}
|
||||
{-# LANGUAGE TypeApplications #-}
|
||||
|
||||
module Conduit.Database.Setup where
|
||||
|
||||
import Conduit.Auth
|
||||
import Conduit.Type
|
||||
import Control.Monad (forM)
|
||||
import Control.Monad.IO.Class (MonadIO, liftIO)
|
||||
import Control.Monad.Reader.Class (MonadReader)
|
||||
import Data.Bifunctor (bimap)
|
||||
import Data.Either (rights)
|
||||
import Data.Function ((&))
|
||||
import Data.Int
|
||||
import Data.Profunctor (dimap)
|
||||
import Data.Text
|
||||
import Data.Time
|
||||
import Data.Vector (Vector)
|
||||
import qualified Data.Vector as Vector
|
||||
import Hasql.Connection (Connection)
|
||||
import Hasql.Session (QueryError, Session, statement)
|
||||
import qualified Hasql.Session as Session
|
||||
import Hasql.Statement (Statement)
|
||||
import qualified Hasql.Statement as Statement
|
||||
import Hasql.TH (resultlessStatement)
|
||||
import qualified Hasql.TH as TH
|
||||
|
||||
setupDB :: Connection -> IO ()
|
||||
setupDB conn = do
|
||||
let create =
|
||||
Session.sql
|
||||
[TH.uncheckedSql|
|
||||
CREATE OR REPLACE FUNCTION update_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
create table if not exists users (
|
||||
pk_user serial primary key,
|
||||
username text unique not null,
|
||||
email text unique not null,
|
||||
image text not null default 'https://api.realworld.io/images/smiley-cyrus.jpeg',
|
||||
bio text not null default '',
|
||||
hash text not null,
|
||||
created_at timestamp with time zone default current_timestamp,
|
||||
updated_at timestamp with time zone default current_timestamp
|
||||
);
|
||||
|
||||
create trigger update_users before update on users for each row execute procedure update_updated_at();
|
||||
|
||||
create table if not exists follows (
|
||||
follower_id serial references users(pk_user) on delete cascade,
|
||||
followee_id serial references users(pk_user) on delete cascade,
|
||||
constraint pk_follows primary key (follower_id, followee_id)
|
||||
);
|
||||
|
||||
create table if not exists articles (
|
||||
pk_article serial primary key,
|
||||
fk_user serial references users(pk_user) on delete cascade,
|
||||
title text unique not null,
|
||||
slug text unique not null,
|
||||
description text unique not null,
|
||||
body text unique not null,
|
||||
created_at timestamp with time zone default current_timestamp,
|
||||
updated_at timestamp with time zone default current_timestamp
|
||||
);
|
||||
|
||||
create trigger update_articles before update on articles for each row execute procedure update_updated_at();
|
||||
|
||||
create table if not exists comments (
|
||||
pk_comment serial primary key,
|
||||
fk_user serial references users(pk_user) on delete cascade,
|
||||
fk_article serial references articles(pk_article) on delete cascade,
|
||||
body text unique not null,
|
||||
created_at timestamp with time zone default current_timestamp,
|
||||
updated_at timestamp with time zone default current_timestamp
|
||||
);
|
||||
|
||||
create trigger update_comments before update on comments for each row execute procedure update_updated_at();
|
||||
|
||||
create table if not exists favorites (
|
||||
fk_user serial references users(pk_user) on delete cascade,
|
||||
fk_article serial references articles(pk_article) on delete cascade,
|
||||
constraint pk_favorites primary key (fk_user, fk_article)
|
||||
);
|
||||
|
||||
create table if not exists tags (
|
||||
tag text not null,
|
||||
fk_article serial references articles(pk_article) on delete cascade,
|
||||
constraint pk_tags primary key (tag, fk_article)
|
||||
);
|
||||
|]
|
||||
destroy =
|
||||
Session.sql
|
||||
[TH.uncheckedSql|
|
||||
drop table if exists users, articles, comments, favorites, tags, follows cascade;
|
||||
drop trigger if exists update_updated_at on users;
|
||||
drop trigger if exists update_updated_at on articles;
|
||||
drop trigger if exists update_updated_at on comments;
|
||||
drop function if exists update_update_at;
|
||||
|]
|
||||
|
||||
result <- Session.run destroy conn
|
||||
print "Destroying tables..."
|
||||
print result
|
||||
print "Making tables..."
|
||||
result <- Session.run create conn
|
||||
print result
|
196
examples/realworld/Conduit/Server.hs
Normal file
196
examples/realworld/Conduit/Server.hs
Normal file
@ -0,0 +1,196 @@
|
||||
{-# LANGUAGE ConstraintKinds #-}
|
||||
{-# LANGUAGE FlexibleContexts #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
{-# LANGUAGE RecordWildCards #-}
|
||||
{-# LANGUAGE TypeApplications #-}
|
||||
|
||||
module Conduit.Server where
|
||||
|
||||
import Conduit.Auth
|
||||
import qualified Conduit.Database as DB
|
||||
import Conduit.Type
|
||||
import Control.Applicative
|
||||
import Control.Monad.Combinators
|
||||
import Control.Monad.Except
|
||||
import Control.Monad.Trans.Reader
|
||||
import Data.Aeson
|
||||
import Data.Int
|
||||
import Data.Text
|
||||
import Data.Time
|
||||
import GHC.Generics
|
||||
import Hasql.Session (QueryError)
|
||||
import Okapi
|
||||
|
||||
type Okapi a = OkapiT Handler a
|
||||
|
||||
conduit :: Okapi Response
|
||||
conduit = do
|
||||
seg "api"
|
||||
choice
|
||||
[ users,
|
||||
user,
|
||||
profiles,
|
||||
articles,
|
||||
tags
|
||||
]
|
||||
|
||||
users = do
|
||||
post
|
||||
seg "users"
|
||||
login <|> register
|
||||
|
||||
login = do
|
||||
seg "login"
|
||||
loginData <- bodyJSON @Login
|
||||
handleQuery $ DB.loginUser loginData
|
||||
|
||||
register = do
|
||||
registerData <- bodyJSON @Register
|
||||
handleQuery $ DB.registerUser registerData
|
||||
|
||||
user = do
|
||||
seg "user"
|
||||
userID <- authorize
|
||||
currentUser userID <|> updateUser userID
|
||||
|
||||
currentUser userID = do
|
||||
get
|
||||
handleQuery $ DB.getCurrentUser userID
|
||||
|
||||
updateUser userID = do
|
||||
put
|
||||
updateUserData <- bodyJSON @UpdateUser
|
||||
handleQuery $ DB.updateUser userID updateUserData
|
||||
|
||||
profiles = do
|
||||
seg "profiles"
|
||||
username <- segParam
|
||||
profile username <|> (seg "follow" >> follow username <|> unfollow username)
|
||||
|
||||
profile username = do
|
||||
get
|
||||
mbUserID <- optional authorize
|
||||
handleQuery $ DB.getProfile mbUserID username
|
||||
|
||||
follow username = do
|
||||
post
|
||||
userID <- authorize
|
||||
handleQuery $ DB.followUser userID username
|
||||
|
||||
unfollow username = do
|
||||
delete
|
||||
userID <- authorize
|
||||
handleQuery $ DB.unfollowUser userID username
|
||||
|
||||
articles = do
|
||||
seg "articles"
|
||||
choice
|
||||
[ get
|
||||
>> choice
|
||||
[ feed,
|
||||
comments,
|
||||
article,
|
||||
global
|
||||
],
|
||||
post
|
||||
>> choice
|
||||
[ createComment,
|
||||
favoriteArticle,
|
||||
createArticle
|
||||
],
|
||||
updateArticle,
|
||||
delete
|
||||
>> choice
|
||||
[ deleteComment,
|
||||
unfavoriteArticle,
|
||||
deleteArticle
|
||||
]
|
||||
]
|
||||
|
||||
global = do
|
||||
mbUserID <- optional authorize
|
||||
articlesQueryTag <- optional $ queryParam "tag"
|
||||
articlesQueryAuthor <- optional $ queryParam "author"
|
||||
articlesQueryFavorited <- optional $ queryParam "favorited"
|
||||
articlesQueryLimit <- option 20 $ queryParamAs @Int32 "limit"
|
||||
articlesQueryOffset <- option 0 $ queryParamAs @Int32 "offset"
|
||||
handleQuery $ DB.getArticles mbUserID ArticlesQuery {..}
|
||||
|
||||
feed = do
|
||||
seg "feed"
|
||||
userID <- authorize
|
||||
limit <- option 20 $ queryParamAs @Int32 "limit"
|
||||
offset <- option 0 $ queryParamAs @Int32 "offset"
|
||||
handleQuery $ DB.feedArticles userID limit offset
|
||||
|
||||
article = do
|
||||
slug <- segParam
|
||||
handleQuery $ DB.getArticle slug
|
||||
|
||||
comments = do
|
||||
slug <- segParam
|
||||
seg "comments"
|
||||
mbUserID <- optional authorize
|
||||
handleQuery $ DB.getComments mbUserID slug
|
||||
|
||||
createArticle = do
|
||||
userID <- authorize
|
||||
createArticleData <- bodyJSON @CreateArticle
|
||||
handleQuery $ DB.createArticle userID createArticleData
|
||||
|
||||
createComment = do
|
||||
slug <- segParam
|
||||
seg "comments"
|
||||
userID <- authorize
|
||||
createCommentData <- bodyJSON @CreateComment
|
||||
handleQuery $ DB.createComment userID slug createCommentData
|
||||
|
||||
favoriteArticle = do
|
||||
slug <- segParam
|
||||
seg "favorite"
|
||||
userID <- authorize
|
||||
handleQuery $ DB.favoriteArticle userID slug
|
||||
|
||||
updateArticle = do
|
||||
put
|
||||
slug <- segParam
|
||||
userID <- authorize
|
||||
updateArticleData <- bodyJSON @UpdateArticle
|
||||
handleQuery $ DB.updateArticle userID slug updateArticleData
|
||||
|
||||
deleteArticle = do
|
||||
slug <- segParam
|
||||
userID <- authorize
|
||||
handleQuery $ DB.deleteArticle userID slug
|
||||
|
||||
deleteComment = do
|
||||
slug <- segParam
|
||||
seg "comments"
|
||||
commentID <- segParamAs @Int32
|
||||
userID <- authorize
|
||||
handleQuery $ DB.deleteComment userID slug commentID
|
||||
|
||||
unfavoriteArticle = do
|
||||
slug <- segParam
|
||||
seg "favorite"
|
||||
userID <- authorize
|
||||
handleQuery $ DB.unfavoriteArticle userID slug
|
||||
|
||||
tags = do
|
||||
get
|
||||
seg "tags"
|
||||
handleQuery DB.getTags
|
||||
|
||||
authorize = do
|
||||
authHeaderValue <- auth
|
||||
jwtSecret <- grab @Text
|
||||
case extractToken authHeaderValue >>= verifyToken jwtSecret of
|
||||
Nothing -> abort401 [] ""
|
||||
Just userID -> pure userID
|
||||
|
||||
handleQuery :: ToJSON a => Okapi (Either QueryError a) -> Okapi Response
|
||||
handleQuery query = do
|
||||
queryResult <- query
|
||||
case queryResult of
|
||||
Left _ -> abort422 [] genericError
|
||||
Right value -> respondJSON [] value
|
63
examples/realworld/Conduit/Type.hs
Normal file
63
examples/realworld/Conduit/Type.hs
Normal file
@ -0,0 +1,63 @@
|
||||
{-# LANGUAGE ConstraintKinds #-}
|
||||
{-# LANGUAGE DeriveAnyClass #-}
|
||||
{-# LANGUAGE DeriveFunctor #-}
|
||||
{-# LANGUAGE DerivingStrategies #-}
|
||||
{-# LANGUAGE FlexibleContexts #-}
|
||||
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
|
||||
{-# LANGUAGE MultiParamTypeClasses #-}
|
||||
{-# LANGUAGE RankNTypes #-}
|
||||
{-# LANGUAGE ScopedTypeVariables #-}
|
||||
{-# LANGUAGE TypeApplications #-}
|
||||
|
||||
module Conduit.Type
|
||||
( module Conduit.Type.Request,
|
||||
module Conduit.Type.Response,
|
||||
Handler (..),
|
||||
Config (..),
|
||||
MonadHandler,
|
||||
grab,
|
||||
Has (..),
|
||||
)
|
||||
where
|
||||
|
||||
import Conduit.Type.Request
|
||||
import Conduit.Type.Response
|
||||
import Control.Monad.Except (MonadError)
|
||||
import Control.Monad.IO.Class (MonadIO)
|
||||
import Control.Monad.Reader.Class (MonadReader, asks)
|
||||
import Control.Monad.State.Class (MonadState)
|
||||
import Control.Monad.Trans.Reader (ReaderT, runReaderT)
|
||||
import Data.Text
|
||||
import Hasql.Connection (Connection)
|
||||
|
||||
data Config = Config
|
||||
{ configJWTSecret :: Text,
|
||||
configDBConnection :: Connection
|
||||
}
|
||||
|
||||
class Has field env where
|
||||
obtain :: env -> field
|
||||
|
||||
instance Has Connection Config where
|
||||
obtain = configDBConnection
|
||||
|
||||
instance Has Text Config where
|
||||
obtain = configJWTSecret
|
||||
|
||||
grab :: forall field env m. (MonadReader env m, Has field env) => m field
|
||||
grab = asks $ obtain @field
|
||||
|
||||
newtype Handler a = Handler {runHandler :: ReaderT Config IO a}
|
||||
deriving newtype
|
||||
( Functor,
|
||||
Applicative,
|
||||
Monad,
|
||||
MonadReader Config,
|
||||
MonadIO
|
||||
)
|
||||
|
||||
type MonadHandler m =
|
||||
( Monad m,
|
||||
MonadReader Config m,
|
||||
MonadIO m
|
||||
)
|
106
examples/realworld/Conduit/Type/Request.hs
Normal file
106
examples/realworld/Conduit/Type/Request.hs
Normal file
@ -0,0 +1,106 @@
|
||||
{-# LANGUAGE FlexibleContexts #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
{-# LANGUAGE RecordWildCards #-}
|
||||
{-# LANGUAGE TypeApplications #-}
|
||||
|
||||
module Conduit.Type.Request where
|
||||
|
||||
import Control.Applicative
|
||||
import Control.Monad.Combinators
|
||||
import Data.Aeson
|
||||
import Data.Int
|
||||
import Data.Text
|
||||
import Data.Time
|
||||
import GHC.Generics
|
||||
|
||||
data Login = Login
|
||||
{ loginEmail :: Text,
|
||||
loginPassword :: Text
|
||||
}
|
||||
|
||||
instance FromJSON Login where
|
||||
parseJSON = withObject "login" $ \o -> do
|
||||
userO <- o .: "user"
|
||||
loginEmail <- userO .: "email"
|
||||
loginPassword <- userO .: "password"
|
||||
pure Login {..}
|
||||
|
||||
data Register = Register
|
||||
{ registerEmail :: Text,
|
||||
registerUsername :: Text,
|
||||
registerPassword :: Text
|
||||
}
|
||||
|
||||
instance FromJSON Register where
|
||||
parseJSON = withObject "register" $ \o -> do
|
||||
userO <- o .: "user"
|
||||
registerEmail <- userO .: "email"
|
||||
registerUsername <- userO .: "username"
|
||||
registerPassword <- userO .: "password"
|
||||
pure Register {..}
|
||||
|
||||
data UpdateUser = UpdateUser
|
||||
{ updateUserEmail :: Maybe Text,
|
||||
updateUserUsername :: Maybe Text,
|
||||
updateUserPassword :: Maybe Text,
|
||||
updateUserBio :: Maybe Text,
|
||||
updateUserImage :: Maybe Text
|
||||
}
|
||||
|
||||
instance FromJSON UpdateUser where
|
||||
parseJSON = withObject "updateUser" $ \o -> do
|
||||
userO <- o .: "user"
|
||||
updateUserEmail <- userO .:? "email"
|
||||
updateUserUsername <- userO .:? "username"
|
||||
updateUserPassword <- userO .:? "password"
|
||||
updateUserImage <- userO .:? "image"
|
||||
updateUserBio <- userO .:? "bio"
|
||||
pure UpdateUser {..}
|
||||
|
||||
data ArticlesQuery = ArticlesQuery
|
||||
{ articlesQueryTag :: Maybe Text,
|
||||
articlesQueryAuthor :: Maybe Text,
|
||||
articlesQueryFavorited :: Maybe Text,
|
||||
articlesQueryLimit :: Int32,
|
||||
articlesQueryOffset :: Int32
|
||||
}
|
||||
|
||||
data CreateArticle = CreateArticle
|
||||
{ createArticleTitle :: Text,
|
||||
createArticleDescription :: Text,
|
||||
createArticleBody :: Text,
|
||||
createArticleTagList :: Maybe [Text]
|
||||
}
|
||||
|
||||
instance FromJSON CreateArticle where
|
||||
parseJSON = withObject "createArticle" $ \o -> do
|
||||
articleO <- o .: "article"
|
||||
createArticleTitle <- articleO .: "title"
|
||||
createArticleDescription <- articleO .: "description"
|
||||
createArticleBody <- articleO .: "body"
|
||||
createArticleTagList <- articleO .:? "tagList"
|
||||
pure CreateArticle {..}
|
||||
|
||||
data UpdateArticle = UpdateArticle
|
||||
{ updateArticleTitle :: Maybe Text,
|
||||
updateArticleDescription :: Maybe Text,
|
||||
updateArticleBody :: Maybe Text
|
||||
}
|
||||
|
||||
instance FromJSON UpdateArticle where
|
||||
parseJSON = withObject "updateArticle" $ \o -> do
|
||||
articleO <- o .: "article"
|
||||
updateArticleTitle <- articleO .:? "title"
|
||||
updateArticleDescription <- articleO .:? "description"
|
||||
updateArticleBody <- articleO .:? "body"
|
||||
pure UpdateArticle {..}
|
||||
|
||||
newtype CreateComment = CreateComment
|
||||
{ createCommentBody :: Text
|
||||
}
|
||||
|
||||
instance FromJSON CreateComment where
|
||||
parseJSON = withObject "createComment" $ \o -> do
|
||||
commentO <- o .: "comment"
|
||||
createCommentBody <- commentO .: "body"
|
||||
pure CreateComment {..}
|
329
examples/realworld/Conduit/Type/Response.hs
Normal file
329
examples/realworld/Conduit/Type/Response.hs
Normal file
@ -0,0 +1,329 @@
|
||||
{-# LANGUAGE DeriveGeneric #-}
|
||||
{-# LANGUAGE FlexibleContexts #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
{-# LANGUAGE RecordWildCards #-}
|
||||
{-# LANGUAGE TypeApplications #-}
|
||||
|
||||
module Conduit.Type.Response where
|
||||
|
||||
import Control.Applicative
|
||||
import Control.Monad.Combinators
|
||||
import Data.Aeson
|
||||
import Data.Int
|
||||
import Data.Text
|
||||
import Data.Time
|
||||
import GHC.Generics
|
||||
|
||||
data User = User
|
||||
{ userID :: Int32,
|
||||
userEmail :: Text,
|
||||
-- userToken :: Text,
|
||||
userUsername :: Text,
|
||||
userBio :: Text,
|
||||
userImage :: Text,
|
||||
userCreatedAt :: UTCTime,
|
||||
userUpdatedAt :: UTCTime
|
||||
}
|
||||
deriving (Eq, Show, Generic)
|
||||
|
||||
data EncodedUser = EncodedUser
|
||||
{ encodedUserEmail :: Text,
|
||||
encodedUserToken :: Text,
|
||||
encodedUserUsername :: Text,
|
||||
encodedUserBio :: Text,
|
||||
encodedUserImage :: Text,
|
||||
encodedUserCreatedAt :: UTCTime,
|
||||
encodedUserUpdatedAt :: UTCTime
|
||||
}
|
||||
deriving (Eq, Show, Generic)
|
||||
|
||||
{-
|
||||
{
|
||||
"user": {
|
||||
"email": "jake@jake.jake",
|
||||
"token": "jwt.token.here",
|
||||
"username": "jake",
|
||||
"bio": "I work at statefarm",
|
||||
"image": null
|
||||
}
|
||||
}
|
||||
-}
|
||||
|
||||
instance ToJSON EncodedUser where
|
||||
toJSON EncodedUser {..} =
|
||||
object
|
||||
[ "user"
|
||||
.= object
|
||||
[ "email" .= encodedUserEmail,
|
||||
"token" .= encodedUserToken,
|
||||
"username" .= encodedUserUsername,
|
||||
"bio" .= encodedUserBio,
|
||||
"image" .= encodedUserImage
|
||||
]
|
||||
]
|
||||
|
||||
{-
|
||||
{
|
||||
"profile": {
|
||||
"username": "jake",
|
||||
"bio": "I work at statefarm",
|
||||
"image": "https://api.realworld.io/images/smiley-cyrus.jpg",
|
||||
"following": false
|
||||
}
|
||||
}
|
||||
-}
|
||||
|
||||
data Profile = Profile
|
||||
{ profileUsername :: Text,
|
||||
profileBio :: Text,
|
||||
profileImage :: Text,
|
||||
profileFollowing :: Bool
|
||||
}
|
||||
deriving (Eq, Show, Generic)
|
||||
|
||||
instance ToJSON Profile where
|
||||
toJSON Profile {..} =
|
||||
object
|
||||
[ "profile"
|
||||
.= object
|
||||
[ "username" .= profileUsername,
|
||||
"bio" .= profileBio,
|
||||
"image" .= profileImage,
|
||||
"following" .= profileFollowing
|
||||
]
|
||||
]
|
||||
|
||||
{-
|
||||
{
|
||||
"article": {
|
||||
"slug": "how-to-train-your-dragon",
|
||||
"title": "How to train your dragon",
|
||||
"description": "Ever wonder how?",
|
||||
"body": "It takes a Jacobian",
|
||||
"tagList": ["dragons", "training"],
|
||||
"createdAt": "2016-02-18T03:22:56.637Z",
|
||||
"updatedAt": "2016-02-18T03:48:35.824Z",
|
||||
"favorited": false,
|
||||
"favoritesCount": 0,
|
||||
"author": {
|
||||
"username": "jake",
|
||||
"bio": "I work at statefarm",
|
||||
"image": "https://i.stack.imgur.com/xHWG8.jpg",
|
||||
"following": false
|
||||
}
|
||||
}
|
||||
}
|
||||
-}
|
||||
|
||||
data Author = Author
|
||||
{ authorUsername :: Text,
|
||||
authorBio :: Text,
|
||||
authorImage :: Text,
|
||||
authorFollowing :: Bool
|
||||
}
|
||||
deriving (Eq, Show, Generic)
|
||||
|
||||
instance ToJSON Author where
|
||||
toJSON Author {..} =
|
||||
object
|
||||
[ "username" .= authorUsername,
|
||||
"bio" .= authorBio,
|
||||
"image" .= authorImage,
|
||||
"following" .= authorFollowing
|
||||
]
|
||||
|
||||
data Article = Article
|
||||
{ articleSlug :: Text,
|
||||
articleTitle :: Text,
|
||||
articleDescription :: Text,
|
||||
articleBody :: Text,
|
||||
articleTagList :: [Text],
|
||||
articleCreatedAt :: UTCTime,
|
||||
articleUpdatedAt :: UTCTime,
|
||||
articleFavorited :: Bool,
|
||||
articleFavoritesCount :: Int32,
|
||||
articleAuthor :: Author
|
||||
}
|
||||
deriving (Eq, Show, Generic)
|
||||
|
||||
instance ToJSON Article where
|
||||
toJSON Article {..} =
|
||||
object
|
||||
[ "article"
|
||||
.= object
|
||||
[ "slug" .= articleSlug,
|
||||
"title" .= articleTitle,
|
||||
"description" .= articleDescription,
|
||||
"body" .= articleBody,
|
||||
"tagList" .= articleTagList,
|
||||
"createdAt" .= articleCreatedAt,
|
||||
"updatedAt" .= articleUpdatedAt,
|
||||
"favorited" .= articleFavorited,
|
||||
"favoritesCount" .= articleFavoritesCount,
|
||||
"author" .= articleAuthor
|
||||
]
|
||||
]
|
||||
|
||||
{-
|
||||
{
|
||||
"articles":[{
|
||||
"slug": "how-to-train-your-dragon",
|
||||
"title": "How to train your dragon",
|
||||
"description": "Ever wonder how?",
|
||||
"body": "It takes a Jacobian",
|
||||
"tagList": ["dragons", "training"],
|
||||
"createdAt": "2016-02-18T03:22:56.637Z",
|
||||
"updatedAt": "2016-02-18T03:48:35.824Z",
|
||||
"favorited": false,
|
||||
"favoritesCount": 0,
|
||||
"author": {
|
||||
"username": "jake",
|
||||
"bio": "I work at statefarm",
|
||||
"image": "https://i.stack.imgur.com/xHWG8.jpg",
|
||||
"following": false
|
||||
}
|
||||
}, {
|
||||
"slug": "how-to-train-your-dragon-2",
|
||||
"title": "How to train your dragon 2",
|
||||
"description": "So toothless",
|
||||
"body": "It a dragon",
|
||||
"tagList": ["dragons", "training"],
|
||||
"createdAt": "2016-02-18T03:22:56.637Z",
|
||||
"updatedAt": "2016-02-18T03:48:35.824Z",
|
||||
"favorited": false,
|
||||
"favoritesCount": 0,
|
||||
"author": {
|
||||
"username": "jake",
|
||||
"bio": "I work at statefarm",
|
||||
"image": "https://i.stack.imgur.com/xHWG8.jpg",
|
||||
"following": false
|
||||
}
|
||||
}],
|
||||
"articlesCount": 2
|
||||
}
|
||||
-}
|
||||
|
||||
data Articles = Articles
|
||||
{ articlesArticleList :: [Article],
|
||||
articlesArticlesCount :: Int
|
||||
}
|
||||
deriving (Eq, Show, Generic)
|
||||
|
||||
instance ToJSON Articles where
|
||||
toJSON Articles {..} =
|
||||
object
|
||||
[ "articles"
|
||||
.= fmap
|
||||
( \Article {..} ->
|
||||
object
|
||||
[ "slug" .= articleSlug,
|
||||
"title" .= articleTitle,
|
||||
"description" .= articleDescription,
|
||||
"body" .= articleBody,
|
||||
"tagList" .= articleTagList,
|
||||
"createdAt" .= articleCreatedAt,
|
||||
"updatedAt" .= articleUpdatedAt,
|
||||
"favorited" .= articleFavorited,
|
||||
"favoritesCount" .= articleFavoritesCount,
|
||||
"author" .= articleAuthor
|
||||
]
|
||||
)
|
||||
articlesArticleList,
|
||||
"articlesCount" .= articlesArticlesCount
|
||||
]
|
||||
|
||||
{-
|
||||
{
|
||||
"comment": {
|
||||
"id": 1,
|
||||
"createdAt": "2016-02-18T03:22:56.637Z",
|
||||
"updatedAt": "2016-02-18T03:22:56.637Z",
|
||||
"body": "It takes a Jacobian",
|
||||
"author": {
|
||||
"username": "jake",
|
||||
"bio": "I work at statefarm",
|
||||
"image": "https://i.stack.imgur.com/xHWG8.jpg",
|
||||
"following": false
|
||||
}
|
||||
}
|
||||
}
|
||||
-}
|
||||
|
||||
data Comment = Comment
|
||||
{ commentID :: Int32,
|
||||
commentCreatedAt :: UTCTime,
|
||||
commentUpdatedAt :: UTCTime,
|
||||
commentBody :: Text,
|
||||
commentAuthor :: Author
|
||||
}
|
||||
deriving (Eq, Show, Generic)
|
||||
|
||||
instance ToJSON Comment where
|
||||
toJSON Comment {..} =
|
||||
object
|
||||
[ "comment"
|
||||
.= object
|
||||
[ "id" .= commentID,
|
||||
"createdAt" .= commentCreatedAt,
|
||||
"updatedAt" .= commentUpdatedAt,
|
||||
"body" .= commentBody,
|
||||
"author" .= commentAuthor
|
||||
]
|
||||
]
|
||||
|
||||
newtype Comments = Comments
|
||||
{commentsCommentList :: [Comment]}
|
||||
deriving (Eq, Show, Generic)
|
||||
|
||||
instance ToJSON Comments where
|
||||
toJSON Comments {..} =
|
||||
object
|
||||
[ "comments"
|
||||
.= fmap
|
||||
( \Comment {..} ->
|
||||
object
|
||||
[ "id" .= commentID,
|
||||
"createdAt" .= commentCreatedAt,
|
||||
"updatedAt" .= commentUpdatedAt,
|
||||
"body" .= commentBody,
|
||||
"author" .= commentAuthor
|
||||
]
|
||||
)
|
||||
commentsCommentList
|
||||
]
|
||||
|
||||
newtype Tags = Tags
|
||||
{tagsTagList :: [Text]}
|
||||
deriving (Eq, Show, Generic)
|
||||
|
||||
instance ToJSON Tags where
|
||||
toJSON Tags {..} =
|
||||
object
|
||||
[ "tags" .= tagsTagList
|
||||
]
|
||||
|
||||
{-
|
||||
{
|
||||
"errors":{
|
||||
"body": [
|
||||
"can't be empty"
|
||||
]
|
||||
}
|
||||
}
|
||||
-}
|
||||
|
||||
newtype GenericError = GenericError
|
||||
{ genericErrorErrors :: [Text]
|
||||
}
|
||||
deriving (Eq, Show, Generic)
|
||||
|
||||
genericError = encode $ GenericError ["Something happened...", "Try again."]
|
||||
|
||||
instance ToJSON GenericError where
|
||||
toJSON GenericError {..} =
|
||||
object
|
||||
[ "errors"
|
||||
.= object
|
||||
[ "body" .= genericErrorErrors
|
||||
]
|
||||
]
|
42
examples/realworld/Main.hs
Normal file
42
examples/realworld/Main.hs
Normal file
@ -0,0 +1,42 @@
|
||||
{-# LANGUAGE FlexibleContexts #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
{-# LANGUAGE RecordWildCards #-}
|
||||
{-# LANGUAGE TypeApplications #-}
|
||||
|
||||
module Main where
|
||||
|
||||
import Conduit.Database
|
||||
import Conduit.Server
|
||||
import Conduit.Type
|
||||
import Control.Monad.Reader (runReaderT)
|
||||
import qualified Hasql.Connection as Connection
|
||||
import Okapi
|
||||
import System.Random
|
||||
import qualified Data.Text as Text
|
||||
|
||||
main :: IO ()
|
||||
main = do
|
||||
config <- getConfig
|
||||
setupDB $ configDBConnection config
|
||||
runOkapi (hoistHandler config) 3000 conduit
|
||||
|
||||
hoistHandler :: Config -> Handler a -> IO a
|
||||
hoistHandler config app = runReaderT (runHandler app) config
|
||||
|
||||
getConfig :: IO Config
|
||||
getConfig = do
|
||||
let dbConnSettings = Connection.settings
|
||||
"localhost"
|
||||
5432
|
||||
"realworld"
|
||||
"abc"
|
||||
"realworld_api"
|
||||
connResult <- Connection.acquire dbConnSettings
|
||||
|
||||
case connResult of
|
||||
Left err -> error $ show err
|
||||
Right configDBConnection -> do
|
||||
gen <- newStdGen
|
||||
let configJWTSecret = Text.pack $ take 50 $ randoms gen
|
||||
print "Config generated successfully"
|
||||
pure $ Config {..}
|
17
examples/todo/Main.hs
Normal file
17
examples/todo/Main.hs
Normal file
@ -0,0 +1,17 @@
|
||||
{-# LANGUAGE DeriveAnyClass #-}
|
||||
{-# LANGUAGE DeriveGeneric #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
{-# LANGUAGE TypeApplications #-}
|
||||
|
||||
module Main where
|
||||
|
||||
import Control.Applicative ((<|>))
|
||||
import Data.Aeson (ToJSON)
|
||||
import Data.ByteString.Lazy (fromStrict)
|
||||
import Data.Text
|
||||
import Data.Text.Encoding (encodeUtf8)
|
||||
import GHC.Generics (Generic)
|
||||
import Okapi
|
||||
|
||||
main :: IO ()
|
||||
main = print "todo"
|
70
okapi.cabal
70
okapi.cabal
@ -49,12 +49,78 @@ library
|
||||
, warp-tls
|
||||
default-language: Haskell2010
|
||||
|
||||
executable okapi-exe
|
||||
executable calc-exe
|
||||
main-is: Main.hs
|
||||
other-modules:
|
||||
Paths_okapi
|
||||
hs-source-dirs:
|
||||
app
|
||||
examples/calc
|
||||
ghc-options: -threaded -rtsopts -with-rtsopts=-N
|
||||
build-depends:
|
||||
aeson
|
||||
, base >=4.7 && <5
|
||||
, base64
|
||||
, bytestring
|
||||
, http-api-data
|
||||
, http-types
|
||||
, mmorph
|
||||
, mtl
|
||||
, okapi
|
||||
, random
|
||||
, text
|
||||
, transformers
|
||||
, wai
|
||||
, warp
|
||||
, warp-tls
|
||||
default-language: Haskell2010
|
||||
|
||||
executable realworld-exe
|
||||
main-is: Main.hs
|
||||
other-modules:
|
||||
Conduit.Auth
|
||||
Conduit.Database
|
||||
Conduit.Database.Query
|
||||
Conduit.Database.Setup
|
||||
Conduit.Server
|
||||
Conduit.Type
|
||||
Conduit.Type.Request
|
||||
Conduit.Type.Response
|
||||
Paths_okapi
|
||||
hs-source-dirs:
|
||||
examples/realworld
|
||||
ghc-options: -threaded -rtsopts -with-rtsopts=-N
|
||||
build-depends:
|
||||
aeson
|
||||
, base >=4.7 && <5
|
||||
, base64
|
||||
, bytestring
|
||||
, containers
|
||||
, hasql
|
||||
, hasql-th
|
||||
, http-api-data
|
||||
, http-types
|
||||
, jwt
|
||||
, mmorph
|
||||
, mtl
|
||||
, okapi
|
||||
, parser-combinators
|
||||
, profunctors
|
||||
, random
|
||||
, text
|
||||
, time
|
||||
, transformers
|
||||
, vector
|
||||
, wai
|
||||
, warp
|
||||
, warp-tls
|
||||
default-language: Haskell2010
|
||||
|
||||
executable todo-exe
|
||||
main-is: Main.hs
|
||||
other-modules:
|
||||
Paths_okapi
|
||||
hs-source-dirs:
|
||||
examples/todo
|
||||
ghc-options: -threaded -rtsopts -with-rtsopts=-N
|
||||
build-depends:
|
||||
aeson
|
||||
|
916
openapi.yml
Normal file
916
openapi.yml
Normal file
@ -0,0 +1,916 @@
|
||||
openapi: 3.0.1
|
||||
info:
|
||||
title: Conduit API
|
||||
description: Conduit API
|
||||
contact:
|
||||
name: RealWorld
|
||||
url: https://realworld.io
|
||||
license:
|
||||
name: MIT License
|
||||
url: https://opensource.org/licenses/MIT
|
||||
version: 1.0.0
|
||||
servers:
|
||||
- url: /api
|
||||
paths:
|
||||
/users/login:
|
||||
post:
|
||||
tags:
|
||||
- User and Authentication
|
||||
summary: Existing user login
|
||||
description: Login for existing user
|
||||
operationId: Login
|
||||
requestBody:
|
||||
description: Credentials to use
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/LoginUserRequest'
|
||||
required: true
|
||||
responses:
|
||||
200:
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UserResponse'
|
||||
401:
|
||||
description: Unauthorized
|
||||
content: {}
|
||||
422:
|
||||
description: Unexpected error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/GenericErrorModel'
|
||||
x-codegen-request-body-name: body
|
||||
/users:
|
||||
post:
|
||||
tags:
|
||||
- User and Authentication
|
||||
summary: Register a new user
|
||||
description: Register a new user
|
||||
operationId: CreateUser
|
||||
requestBody:
|
||||
description: Details of the new user to register
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/NewUserRequest'
|
||||
required: true
|
||||
responses:
|
||||
201:
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UserResponse'
|
||||
422:
|
||||
description: Unexpected error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/GenericErrorModel'
|
||||
x-codegen-request-body-name: body
|
||||
/user:
|
||||
get:
|
||||
tags:
|
||||
- User and Authentication
|
||||
summary: Get current user
|
||||
description: Gets the currently logged-in user
|
||||
operationId: GetCurrentUser
|
||||
responses:
|
||||
200:
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UserResponse'
|
||||
401:
|
||||
description: Unauthorized
|
||||
content: {}
|
||||
422:
|
||||
description: Unexpected error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/GenericErrorModel'
|
||||
security:
|
||||
- Token: []
|
||||
put:
|
||||
tags:
|
||||
- User and Authentication
|
||||
summary: Update current user
|
||||
description: Updated user information for current user
|
||||
operationId: UpdateCurrentUser
|
||||
requestBody:
|
||||
description: User details to update. At least **one** field is required.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UpdateUserRequest'
|
||||
required: true
|
||||
responses:
|
||||
200:
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UserResponse'
|
||||
401:
|
||||
description: Unauthorized
|
||||
content: {}
|
||||
422:
|
||||
description: Unexpected error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/GenericErrorModel'
|
||||
security:
|
||||
- Token: []
|
||||
x-codegen-request-body-name: body
|
||||
/profiles/{username}:
|
||||
get:
|
||||
tags:
|
||||
- Profile
|
||||
summary: Get a profile
|
||||
description: Get a profile of a user of the system. Auth is optional
|
||||
operationId: GetProfileByUsername
|
||||
parameters:
|
||||
- name: username
|
||||
in: path
|
||||
description: Username of the profile to get
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
200:
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProfileResponse'
|
||||
401:
|
||||
description: Unauthorized
|
||||
content: {}
|
||||
422:
|
||||
description: Unexpected error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/GenericErrorModel'
|
||||
/profiles/{username}/follow:
|
||||
post:
|
||||
tags:
|
||||
- Profile
|
||||
summary: Follow a user
|
||||
description: Follow a user by username
|
||||
operationId: FollowUserByUsername
|
||||
parameters:
|
||||
- name: username
|
||||
in: path
|
||||
description: Username of the profile you want to follow
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
200:
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProfileResponse'
|
||||
401:
|
||||
description: Unauthorized
|
||||
content: {}
|
||||
422:
|
||||
description: Unexpected error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/GenericErrorModel'
|
||||
security:
|
||||
- Token: []
|
||||
delete:
|
||||
tags:
|
||||
- Profile
|
||||
summary: Unfollow a user
|
||||
description: Unfollow a user by username
|
||||
operationId: UnfollowUserByUsername
|
||||
parameters:
|
||||
- name: username
|
||||
in: path
|
||||
description: Username of the profile you want to unfollow
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
200:
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProfileResponse'
|
||||
401:
|
||||
description: Unauthorized
|
||||
content: {}
|
||||
422:
|
||||
description: Unexpected error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/GenericErrorModel'
|
||||
security:
|
||||
- Token: []
|
||||
/articles/feed:
|
||||
get:
|
||||
tags:
|
||||
- Articles
|
||||
summary: Get recent articles from users you follow
|
||||
description: Get most recent articles from users you follow. Use query parameters
|
||||
to limit. Auth is required
|
||||
operationId: GetArticlesFeed
|
||||
parameters:
|
||||
- name: limit
|
||||
in: query
|
||||
description: Limit number of articles returned (default is 20)
|
||||
schema:
|
||||
type: integer
|
||||
default: 20
|
||||
- name: offset
|
||||
in: query
|
||||
description: Offset/skip number of articles (default is 0)
|
||||
schema:
|
||||
type: integer
|
||||
default: 0
|
||||
responses:
|
||||
200:
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MultipleArticlesResponse'
|
||||
401:
|
||||
description: Unauthorized
|
||||
content: {}
|
||||
422:
|
||||
description: Unexpected error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/GenericErrorModel'
|
||||
security:
|
||||
- Token: []
|
||||
/articles:
|
||||
get:
|
||||
tags:
|
||||
- Articles
|
||||
summary: Get recent articles globally
|
||||
description: Get most recent articles globally. Use query parameters to filter
|
||||
results. Auth is optional
|
||||
operationId: GetArticles
|
||||
parameters:
|
||||
- name: tag
|
||||
in: query
|
||||
description: Filter by tag
|
||||
schema:
|
||||
type: string
|
||||
- name: author
|
||||
in: query
|
||||
description: Filter by author (username)
|
||||
schema:
|
||||
type: string
|
||||
- name: favorited
|
||||
in: query
|
||||
description: Filter by favorites of a user (username)
|
||||
schema:
|
||||
type: string
|
||||
- name: limit
|
||||
in: query
|
||||
description: Limit number of articles returned (default is 20)
|
||||
schema:
|
||||
type: integer
|
||||
default: 20
|
||||
- name: offset
|
||||
in: query
|
||||
description: Offset/skip number of articles (default is 0)
|
||||
schema:
|
||||
type: integer
|
||||
default: 0
|
||||
responses:
|
||||
200:
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MultipleArticlesResponse'
|
||||
401:
|
||||
description: Unauthorized
|
||||
content: {}
|
||||
422:
|
||||
description: Unexpected error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/GenericErrorModel'
|
||||
post:
|
||||
tags:
|
||||
- Articles
|
||||
summary: Create an article
|
||||
description: Create an article. Auth is required
|
||||
operationId: CreateArticle
|
||||
requestBody:
|
||||
description: Article to create
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/NewArticleRequest'
|
||||
required: true
|
||||
responses:
|
||||
201:
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SingleArticleResponse'
|
||||
401:
|
||||
description: Unauthorized
|
||||
content: {}
|
||||
422:
|
||||
description: Unexpected error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/GenericErrorModel'
|
||||
security:
|
||||
- Token: []
|
||||
x-codegen-request-body-name: article
|
||||
/articles/{slug}:
|
||||
get:
|
||||
tags:
|
||||
- Articles
|
||||
summary: Get an article
|
||||
description: Get an article. Auth not required
|
||||
operationId: GetArticle
|
||||
parameters:
|
||||
- name: slug
|
||||
in: path
|
||||
description: Slug of the article to get
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
200:
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SingleArticleResponse'
|
||||
422:
|
||||
description: Unexpected error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/GenericErrorModel'
|
||||
put:
|
||||
tags:
|
||||
- Articles
|
||||
summary: Update an article
|
||||
description: Update an article. Auth is required
|
||||
operationId: UpdateArticle
|
||||
parameters:
|
||||
- name: slug
|
||||
in: path
|
||||
description: Slug of the article to update
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
description: Article to update
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UpdateArticleRequest'
|
||||
required: true
|
||||
responses:
|
||||
200:
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SingleArticleResponse'
|
||||
401:
|
||||
description: Unauthorized
|
||||
content: {}
|
||||
422:
|
||||
description: Unexpected error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/GenericErrorModel'
|
||||
security:
|
||||
- Token: []
|
||||
x-codegen-request-body-name: article
|
||||
delete:
|
||||
tags:
|
||||
- Articles
|
||||
summary: Delete an article
|
||||
description: Delete an article. Auth is required
|
||||
operationId: DeleteArticle
|
||||
parameters:
|
||||
- name: slug
|
||||
in: path
|
||||
description: Slug of the article to delete
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
200:
|
||||
description: OK
|
||||
content: {}
|
||||
401:
|
||||
description: Unauthorized
|
||||
content: {}
|
||||
422:
|
||||
description: Unexpected error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/GenericErrorModel'
|
||||
security:
|
||||
- Token: []
|
||||
/articles/{slug}/comments:
|
||||
get:
|
||||
tags:
|
||||
- Comments
|
||||
summary: Get comments for an article
|
||||
description: Get the comments for an article. Auth is optional
|
||||
operationId: GetArticleComments
|
||||
parameters:
|
||||
- name: slug
|
||||
in: path
|
||||
description: Slug of the article that you want to get comments for
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
200:
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MultipleCommentsResponse'
|
||||
401:
|
||||
description: Unauthorized
|
||||
content: {}
|
||||
422:
|
||||
description: Unexpected error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/GenericErrorModel'
|
||||
post:
|
||||
tags:
|
||||
- Comments
|
||||
summary: Create a comment for an article
|
||||
description: Create a comment for an article. Auth is required
|
||||
operationId: CreateArticleComment
|
||||
parameters:
|
||||
- name: slug
|
||||
in: path
|
||||
description: Slug of the article that you want to create a comment for
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
description: Comment you want to create
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/NewCommentRequest'
|
||||
required: true
|
||||
responses:
|
||||
200:
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SingleCommentResponse'
|
||||
401:
|
||||
description: Unauthorized
|
||||
content: {}
|
||||
422:
|
||||
description: Unexpected error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/GenericErrorModel'
|
||||
security:
|
||||
- Token: []
|
||||
x-codegen-request-body-name: comment
|
||||
/articles/{slug}/comments/{id}:
|
||||
delete:
|
||||
tags:
|
||||
- Comments
|
||||
summary: Delete a comment for an article
|
||||
description: Delete a comment for an article. Auth is required
|
||||
operationId: DeleteArticleComment
|
||||
parameters:
|
||||
- name: slug
|
||||
in: path
|
||||
description: Slug of the article that you want to delete a comment for
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: id
|
||||
in: path
|
||||
description: ID of the comment you want to delete
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
200:
|
||||
description: OK
|
||||
content: {}
|
||||
401:
|
||||
description: Unauthorized
|
||||
content: {}
|
||||
422:
|
||||
description: Unexpected error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/GenericErrorModel'
|
||||
security:
|
||||
- Token: []
|
||||
/articles/{slug}/favorite:
|
||||
post:
|
||||
tags:
|
||||
- Favorites
|
||||
summary: Favorite an article
|
||||
description: Favorite an article. Auth is required
|
||||
operationId: CreateArticleFavorite
|
||||
parameters:
|
||||
- name: slug
|
||||
in: path
|
||||
description: Slug of the article that you want to favorite
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
200:
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SingleArticleResponse'
|
||||
401:
|
||||
description: Unauthorized
|
||||
content: {}
|
||||
422:
|
||||
description: Unexpected error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/GenericErrorModel'
|
||||
security:
|
||||
- Token: []
|
||||
delete:
|
||||
tags:
|
||||
- Favorites
|
||||
summary: Unfavorite an article
|
||||
description: Unfavorite an article. Auth is required
|
||||
operationId: DeleteArticleFavorite
|
||||
parameters:
|
||||
- name: slug
|
||||
in: path
|
||||
description: Slug of the article that you want to unfavorite
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
200:
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SingleArticleResponse'
|
||||
401:
|
||||
description: Unauthorized
|
||||
content: {}
|
||||
422:
|
||||
description: Unexpected error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/GenericErrorModel'
|
||||
security:
|
||||
- Token: []
|
||||
/tags:
|
||||
get:
|
||||
summary: Get tags
|
||||
description: Get tags. Auth not required
|
||||
responses:
|
||||
200:
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TagsResponse'
|
||||
422:
|
||||
description: Unexpected error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/GenericErrorModel'
|
||||
components:
|
||||
schemas:
|
||||
LoginUser:
|
||||
required:
|
||||
- email
|
||||
- password
|
||||
type: object
|
||||
properties:
|
||||
email:
|
||||
type: string
|
||||
password:
|
||||
type: string
|
||||
format: password
|
||||
LoginUserRequest:
|
||||
required:
|
||||
- user
|
||||
type: object
|
||||
properties:
|
||||
user:
|
||||
$ref: '#/components/schemas/LoginUser'
|
||||
NewUser:
|
||||
required:
|
||||
- email
|
||||
- password
|
||||
- username
|
||||
type: object
|
||||
properties:
|
||||
username:
|
||||
type: string
|
||||
email:
|
||||
type: string
|
||||
password:
|
||||
type: string
|
||||
format: password
|
||||
NewUserRequest:
|
||||
required:
|
||||
- user
|
||||
type: object
|
||||
properties:
|
||||
user:
|
||||
$ref: '#/components/schemas/NewUser'
|
||||
User:
|
||||
required:
|
||||
- bio
|
||||
- email
|
||||
- image
|
||||
- token
|
||||
- username
|
||||
type: object
|
||||
properties:
|
||||
email:
|
||||
type: string
|
||||
token:
|
||||
type: string
|
||||
username:
|
||||
type: string
|
||||
bio:
|
||||
type: string
|
||||
image:
|
||||
type: string
|
||||
UserResponse:
|
||||
required:
|
||||
- user
|
||||
type: object
|
||||
properties:
|
||||
user:
|
||||
$ref: '#/components/schemas/User'
|
||||
UpdateUser:
|
||||
type: object
|
||||
properties:
|
||||
email:
|
||||
type: string
|
||||
token:
|
||||
type: string
|
||||
username:
|
||||
type: string
|
||||
bio:
|
||||
type: string
|
||||
image:
|
||||
type: string
|
||||
UpdateUserRequest:
|
||||
required:
|
||||
- user
|
||||
type: object
|
||||
properties:
|
||||
user:
|
||||
$ref: '#/components/schemas/UpdateUser'
|
||||
ProfileResponse:
|
||||
required:
|
||||
- profile
|
||||
type: object
|
||||
properties:
|
||||
profile:
|
||||
$ref: '#/components/schemas/Profile'
|
||||
Profile:
|
||||
required:
|
||||
- bio
|
||||
- following
|
||||
- image
|
||||
- username
|
||||
type: object
|
||||
properties:
|
||||
username:
|
||||
type: string
|
||||
bio:
|
||||
type: string
|
||||
image:
|
||||
type: string
|
||||
following:
|
||||
type: boolean
|
||||
Article:
|
||||
required:
|
||||
- author
|
||||
- body
|
||||
- createdAt
|
||||
- description
|
||||
- favorited
|
||||
- favoritesCount
|
||||
- slug
|
||||
- tagList
|
||||
- title
|
||||
- updatedAt
|
||||
type: object
|
||||
properties:
|
||||
slug:
|
||||
type: string
|
||||
title:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
body:
|
||||
type: string
|
||||
tagList:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
updatedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
favorited:
|
||||
type: boolean
|
||||
favoritesCount:
|
||||
type: integer
|
||||
author:
|
||||
$ref: '#/components/schemas/Profile'
|
||||
SingleArticleResponse:
|
||||
required:
|
||||
- article
|
||||
type: object
|
||||
properties:
|
||||
article:
|
||||
$ref: '#/components/schemas/Article'
|
||||
MultipleArticlesResponse:
|
||||
required:
|
||||
- articles
|
||||
- articlesCount
|
||||
type: object
|
||||
properties:
|
||||
articles:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Article'
|
||||
articlesCount:
|
||||
type: integer
|
||||
NewArticle:
|
||||
required:
|
||||
- body
|
||||
- description
|
||||
- title
|
||||
type: object
|
||||
properties:
|
||||
title:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
body:
|
||||
type: string
|
||||
tagList:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
NewArticleRequest:
|
||||
required:
|
||||
- article
|
||||
type: object
|
||||
properties:
|
||||
article:
|
||||
$ref: '#/components/schemas/NewArticle'
|
||||
UpdateArticle:
|
||||
type: object
|
||||
properties:
|
||||
title:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
body:
|
||||
type: string
|
||||
UpdateArticleRequest:
|
||||
required:
|
||||
- article
|
||||
type: object
|
||||
properties:
|
||||
article:
|
||||
$ref: '#/components/schemas/UpdateArticle'
|
||||
Comment:
|
||||
required:
|
||||
- author
|
||||
- body
|
||||
- createdAt
|
||||
- id
|
||||
- updatedAt
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
updatedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
body:
|
||||
type: string
|
||||
author:
|
||||
$ref: '#/components/schemas/Profile'
|
||||
SingleCommentResponse:
|
||||
required:
|
||||
- comment
|
||||
type: object
|
||||
properties:
|
||||
comment:
|
||||
$ref: '#/components/schemas/Comment'
|
||||
MultipleCommentsResponse:
|
||||
required:
|
||||
- comments
|
||||
type: object
|
||||
properties:
|
||||
comments:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Comment'
|
||||
NewComment:
|
||||
required:
|
||||
- body
|
||||
type: object
|
||||
properties:
|
||||
body:
|
||||
type: string
|
||||
NewCommentRequest:
|
||||
required:
|
||||
- comment
|
||||
type: object
|
||||
properties:
|
||||
comment:
|
||||
$ref: '#/components/schemas/NewComment'
|
||||
TagsResponse:
|
||||
required:
|
||||
- tags
|
||||
type: object
|
||||
properties:
|
||||
tags:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
GenericErrorModel:
|
||||
required:
|
||||
- errors
|
||||
type: object
|
||||
properties:
|
||||
errors:
|
||||
required:
|
||||
- body
|
||||
type: object
|
||||
properties:
|
||||
body:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
securitySchemes:
|
||||
Token:
|
||||
type: apiKey
|
||||
description: "For accessing the protected API resources, you must have received\
|
||||
\ a a valid JWT token after registering or logging in. This JWT token must\
|
||||
\ then be used for all protected resources by passing it in via the 'Authorization'\
|
||||
\ header.\n\nA JWT token is generated by the API by either registering via\
|
||||
\ /users or logging in via /users/login.\n\nThe following format must be in\
|
||||
\ the 'Authorization' header :\n\n Token xxxxxx.yyyyyyy.zzzzzz\n \n"
|
||||
name: Authorization
|
||||
in: header
|
30
package.yaml
30
package.yaml
@ -39,9 +39,35 @@ library:
|
||||
source-dirs: src
|
||||
|
||||
executables:
|
||||
okapi-exe:
|
||||
realworld-exe:
|
||||
main: Main.hs
|
||||
source-dirs: app
|
||||
source-dirs: examples/realworld
|
||||
ghc-options:
|
||||
- -threaded
|
||||
- -rtsopts
|
||||
- -with-rtsopts=-N
|
||||
dependencies:
|
||||
- okapi
|
||||
- containers
|
||||
- hasql
|
||||
- hasql-th
|
||||
- jwt
|
||||
- parser-combinators
|
||||
- time
|
||||
- vector
|
||||
- profunctors
|
||||
calc-exe:
|
||||
main: Main.hs
|
||||
source-dirs: examples/calc
|
||||
ghc-options:
|
||||
- -threaded
|
||||
- -rtsopts
|
||||
- -with-rtsopts=-N
|
||||
dependencies:
|
||||
- okapi
|
||||
todo-exe:
|
||||
main: Main.hs
|
||||
source-dirs: examples/todo
|
||||
ghc-options:
|
||||
- -threaded
|
||||
- -rtsopts
|
||||
|
17
run-api-tests.sh
Executable file
17
run-api-tests.sh
Executable file
@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
set -x
|
||||
|
||||
SCRIPTDIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )"
|
||||
|
||||
APIURL=${APIURL:-https://api.realworld.io/api}
|
||||
USERNAME=${USERNAME:-u`date +%s`}
|
||||
EMAIL=${EMAIL:-$USERNAME@mail.com}
|
||||
PASSWORD=${PASSWORD:-password}
|
||||
|
||||
npx newman run $SCRIPTDIR/Conduit.postman_collection.json \
|
||||
--delay-request 500 \
|
||||
--global-var "APIURL=$APIURL" \
|
||||
--global-var "USERNAME=$USERNAME" \
|
||||
--global-var "EMAIL=$EMAIL" \
|
||||
--global-var "PASSWORD=$PASSWORD" \
|
||||
"$@"
|
Loading…
Reference in New Issue
Block a user