Transfer all example from old repo

This commit is contained in:
Rashad Gover 2022-03-30 01:36:29 +00:00
parent ae26c53280
commit 826225af45
17 changed files with 4912 additions and 8 deletions

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +0,0 @@
module Main where
main :: IO ()
main = print "Okapi"

71
examples/calc/Main.hs Normal file
View 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)

View 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

View 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

View 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

View 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

View 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

View 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
)

View 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 {..}

View 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
]
]

View 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
View 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"

View File

@ -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
View 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

View File

@ -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
View 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" \
"$@"