Compare commits

...
15 Commits
Author SHA1 Message Date
Joe Nelson b070994912 Patch version bump for conditional -Werror flag 2015-05-21 00:18:26 -07:00
Joe Nelson 988df54e53 Use Werror on CI 2015-05-21 00:18:03 -07:00
Joe Nelson 4bbf053896 Bump version 2015-05-20 09:42:51 -07:00
Joe Nelson da79f1da3e Merge pull request #193 from srid/makelibrary
Make postgrest a library
2015-05-19 23:59:16 -07:00
Sridhar Ratnakumar c3ad87ffaf Make postgrest usable as a library 2015-05-19 23:40:39 -07:00
Joe Nelson 035acebf59 Update CHANGELOG.md 2015-05-19 22:23:49 -07:00
Joe Nelson 0b665676d7 Update CHANGELOG.md 2015-05-19 22:21:28 -07:00
Joe Nelson dcf62b020f Merge pull request #194 from framp/jwt
JWT support
2015-05-19 22:17:34 -07:00
Federico Rampazzo 77aecc9e86 JWT support 2015-05-20 06:01:50 +01:00
Joe Nelson e35ad0f340 Update changelog, remove debugging 2015-05-12 10:55:51 -07:00
Joe Nelson 709e70561f Provide more information in PATCH response
* 404 if no records updated
* Range header for number updated
* Full results depending on Prefer header

Fixes #187
Fixes #182
2015-05-12 10:47:55 -07:00
Joe Nelson ef0dc26de5 Merge pull request #186 from jcristovao/patch-2
GHC 7.10.1 support
2015-04-24 09:37:27 -07:00
João Cristóvão 35d36d95c1 GHC 7.10.1 support
Without it, the following error occurs:

```
src/PgStructure.hs:88:5:
    Non type-variable argument
      in the constraint: Data.String.Conversions.ConvertibleStrings
                           Text k
    (Use FlexibleContexts to permit this)
    When checking that ‘addFK’ has the inferred type
      addFK :: forall k.
               (Ord k, Data.String.Conversions.ConvertibleStrings Text k) =>
               Map.Map k ForeignKey -> Column -> Column
    In an equation for ‘columns’:
        columns table
          = do { cols <- H.listEx
                         $ (\ _1 _2
                              -> Hasql.Backend.Stmt
                                   "select info.table_schema as schema, info.table_name as table_name, info.column_name as name, info.ordinal_position as position, info.is_nullable as nullable, info.data_type as col_type, info.is_updatable as updatable, info.character_maximum_length as max_len, info.numeric_precision as precision, info.column_default as default_value, array_to_string(enum_info.vals, ',') as enum from ( select table_schema, table_name, column_name, ordinal_position, is_nullable, data_type, is_updatable, character_maximum_length, numeric_precision, column_default, udt_name from information_schema.columns where table_schema = ? and table_name = ? ) as info left outer join ( select n.nspname as s, t.typname as n, array_agg(e.enumlabel ORDER BY e.enumsortorder) as vals from pg_type t join pg_enum e on t.oid = e.enumtypid join pg_catalog.pg_namespace n ON n.oid = t.typnamespace group by s, n ) as enum_info on (info.udt_name = enum_info.n) order by position"
                                   (GHC.ST.runST (do { ... }))
                                   True)
                             (qtSchema table) (qtName table);
                 fks <- foreignKeys table;
                 return $ map (addFK fks . columnFromRow) cols }
          where
              addFK fks col = col {colFK = Map.lookup (cs . colName $ col) fks}
```

Also, the regex-tdfa-text library needs a similar patch, but I didn't have time to contact the author yet.

Cheers
2015-04-24 16:22:43 +01:00
Joe Nelson 13cda09c7e Contributing 2015-04-22 17:22:39 -07:00
Joe Nelson 5688030104 Allow posting JSON array and object
Fixes #168

Fixes #156
2015-04-19 17:46:11 -07:00
21 changed files with 342 additions and 145 deletions
+11
View File
@@ -3,6 +3,17 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
This project adheres to [Semantic Versioning](http://semver.org/). This project adheres to [Semantic Versioning](http://semver.org/).
## [0.2.9.0] - 2015-05-20
### Added
- Return range headers in PATCH
- Return PATCHed resources if header "Prefer: return=representation"
- Allow nested objects and arrays in JSON post for jsonb columns
- JSON Web Tokens - [Federico Rampazzo](https://github.com/framp)
- Expose PostgREST as a Haskell package
### Fixed
- Return 404 if no records updated by PATCH
## [0.2.8.0] - 2015-04-17 ## [0.2.8.0] - 2015-04-17
### Added ### Added
- Option to specify nulls first or last, eg `/people?order=age.desc.nullsfirst` - Option to specify nulls first or last, eg `/people?order=age.desc.nullsfirst`
+50
View File
@@ -0,0 +1,50 @@
# Contributing to PostgREST
**First:** if you're unsure or afraid of _anything_, just ask or
submit the issue or pull request anyways. You won't be yelled at
for giving your best effort. The worst that can happen is that
you'll be politely asked to change something. We appreciate any
sort of contributions, and don't want a wall of rules to get in the
way of that.
However, for those individuals who want a bit more guidance on the
best way to contribute to the project, read on. This document will
cover what we're looking for. By addressing all the points we're
looking for, it raises the chances we can quickly merge or address
your contributions.
## Issues
### Reporting an Issue
* Make sure you test against the latest released version. It is possible
we already fixed the bug you're experiencing.
* Also check the `CHANGELOG.md` to see if any unreleased changes affect
the issue. The very newest changes can take a little while to be released
as a new official version.
* Provide steps to reproduce the issue, including your OS version and
the specific database schema that you are using.
## Code
### Haskell Conventions
* All contributions must pass the tests before being merged. When
you create a pull request your code will automatically be tested.
* All code must also pass [hlint](http://community.haskell.org/~ndm/hlint/)
with no warnings. This helps enforce a uniform style for all
committers. Continuous integration will check this as well on every
pull request.
## Maintenance
### Schedule
Currently I (@begriffs) am the sole maintainer, and while I am
overjoyed to help resolve issues I also have to balance this with
my other obligations. I check and respond to github issues **once
per week** (on Mondays). So if you don't get a response right away
don't worry, I will definitely get to it.
+1 -1
View File
@@ -20,7 +20,7 @@ your own projects.
### Usage ### Usage
Download the binary ([OS X](http://bin.begriffs.com/dbapi/osx/postgrest-0.2.8.0.tar.xz) / [Linux](http://bin.begriffs.com/dbapi/heroku/postgrest-0.2.8.0.tar.xz)) and invoke like so: Download the binary ([OS X](http://bin.begriffs.com/dbapi/osx/postgrest-0.2.9.0.tar.xz) / [Linux](http://bin.begriffs.com/dbapi/heroku/postgrest-0.2.9.0.tar.xz)) and invoke like so:
```bash ```bash
postgrest --db-host localhost --db-port 5432 \ postgrest --db-host localhost --db-port 5432 \
+1 -1
View File
@@ -10,7 +10,7 @@
}, },
"POSTGREST_VER": { "POSTGREST_VER": {
"description": "Version of PostgREST to deploy", "description": "Version of PostgREST to deploy",
"value": "0.2.8.0" "value": "0.2.9.0"
}, },
"DB_NAME": { "DB_NAME": {
"description": "Database name", "description": "Database name",
+7 -1
View File
@@ -4,7 +4,13 @@ machine:
- createdb -O postgrest_test -U ubuntu postgrest_test - createdb -O postgrest_test -U ubuntu postgrest_test
ghc: ghc:
version: 7.8.3 version: 7.8.3
dependencies:
override:
- cabal update
- cabal sandbox init
- cabal install --upgrade-dependencies --constraint="template-haskell installed" --dependencies-only --enable-tests
- cabal configure --enable-tests -f ci
test: test:
post: post:
- cabal exec hlint -- -X QuasiQuotes src/*.hs test/**/*.hs - cabal exec hlint -- -X QuasiQuotes src/**/*.hs test/**/*.hs
- cabal exec packdeps postgrest.cabal - cabal exec packdeps postgrest.cabal
+74 -27
View File
@@ -2,7 +2,7 @@ name: postgrest
description: Reads the schema of a PostgreSQL database and creates RESTful routes description: Reads the schema of a PostgreSQL database and creates RESTful routes
for the tables and views, supporting all HTTP verbs that security for the tables and views, supporting all HTTP verbs that security
permits. permits.
version: 0.2.8.0 version: 0.2.9.1
synopsis: REST API for any Postgres database synopsis: REST API for any Postgres database
license: MIT license: MIT
license-file: LICENSE license-file: LICENSE
@@ -12,15 +12,23 @@ maintainer: cred+github@begriffs.com
category: Web category: Web
build-type: Simple build-type: Simple
cabal-version: >=1.10 cabal-version: >=1.10
source-repository head
type: git
location: git://github.com/begriffs/postgrest.git
Flag CI
Description: No warnings allowed in continuous integration
Manual: True
Default: False
executable postgrest executable postgrest
main-is: Main.hs main-is: PostgREST/Main.hs
ghc-options: -Wall -W -O2
default-language: Haskell2010
default-extensions: OverloadedStrings, ScopedTypeVariables, QuasiQuotes default-extensions: OverloadedStrings, ScopedTypeVariables, QuasiQuotes
default-language: Haskell2010
build-depends: base >=4.6 && <5 build-depends: base >=4.6 && <5
, hasql == 0.7.3, hasql-backend == 0.4.1 , postgrest
, hasql-postgres == 0.10.3 , hasql == 0.7.3.1, hasql-backend == 0.4.1
, hasql-postgres == 0.10.3.1
, warp >= 3.0.2, wai >= 3.0.1 , warp >= 3.0.2, wai >= 3.0.1
, wai-extra, wai-cors , wai-extra, wai-cors
, wai-middleware-static >= 0.6.0 , wai-middleware-static >= 0.6.0
@@ -43,15 +51,51 @@ executable postgrest
, vector , vector
, mtl , mtl
, cassava , cassava
Other-Modules: App , jwt
, Auth hs-source-dirs: src
, Config
, Error library
, Middleware if flag(ci)
, PgQuery ghc-options: -Wall -W -Werror
, PgStructure else
, RangeQuery ghc-options: -Wall -W -O2
, Types
default-language: Haskell2010
default-extensions: OverloadedStrings, ScopedTypeVariables, QuasiQuotes
build-depends: base >=4.6 && <5
, hasql == 0.7.3.1, hasql-backend == 0.4.1
, hasql-postgres == 0.10.3.1
, warp >= 3.0.2, wai >= 3.0.1
, wai-extra, wai-cors
, wai-middleware-static >= 0.6.0
, HTTP, convertible, http-types
, case-insensitive
, scientific, time
, aeson, network >= 2.6
, bytestring, text, split, string-conversions
, stringsearch
, containers, unordered-containers
, optparse-applicative == 0.11.*
, regex-base, regex-tdfa
, regex-tdfa-text
, Ranged-sets
, transformers, MissingH
, bcrypt >= 0.0.6, base64-string
, network-uri >= 2.6
, resource-pool
, blaze-builder
, vector
, mtl
, cassava
, jwt
Exposed-Modules: PostgREST.App
, PostgREST.Auth
, PostgREST.Config
, PostgREST.Error
, PostgREST.Middleware
, PostgREST.PgQuery
, PostgREST.PgStructure
, PostgREST.RangeQuery
hs-source-dirs: src hs-source-dirs: src
Test-Suite spec Test-Suite spec
@@ -59,23 +103,25 @@ Test-Suite spec
Default-Language: Haskell2010 Default-Language: Haskell2010
default-extensions: OverloadedStrings, ScopedTypeVariables, QuasiQuotes default-extensions: OverloadedStrings, ScopedTypeVariables, QuasiQuotes
Hs-Source-Dirs: test, src Hs-Source-Dirs: test, src
ghc-options: -Wall -W -Werror if flag(ci)
ghc-options: -Wall -W -Werror
else
ghc-options: -Wall -W -O2
Main-Is: Main.hs Main-Is: Main.hs
Other-Modules: App Other-Modules: PostgREST.App
, Auth , PostgREST.Auth
, Config , PostgREST.Config
, Error , PostgREST.Error
, Middleware , PostgREST.Middleware
, PgQuery , PostgREST.PgQuery
, PgStructure , PostgREST.PgStructure
, RangeQuery , PostgREST.RangeQuery
, Types
, Spec , Spec
, SpecHelper , SpecHelper
Build-Depends: base, hspec >= 2.1.2, QuickCheck Build-Depends: base, hspec >= 2.1.2, QuickCheck
, hspec-wai >= 0.5.0, hspec-wai-json , hspec-wai >= 0.5.0, hspec-wai-json
, hasql == 0.7.3, hasql-backend == 0.4.1 , hasql == 0.7.3.1, hasql-backend == 0.4.1
, hasql-postgres == 0.10.3 , hasql-postgres == 0.10.3.1
, warp, wai , warp, wai
, packdeps, hlint , packdeps, hlint
, HTTP, convertible , HTTP, convertible
@@ -102,3 +148,4 @@ Test-Suite spec
, cassava , cassava
, process , process
, heredoc , heredoc
, jwt
+50 -12
View File
@@ -1,5 +1,5 @@
{-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE FlexibleContexts #-}
module App (app, sqlError, isSqlError) where module PostgREST.App (app, sqlError, isSqlError) where
import Control.Monad (join) import Control.Monad (join)
import Control.Arrow ((***), second) import Control.Arrow ((***), second)
@@ -34,13 +34,16 @@ import qualified Hasql as H
import qualified Hasql.Backend as B import qualified Hasql.Backend as B
import qualified Hasql.Postgres as P import qualified Hasql.Postgres as P
import Auth import PostgREST.Config (AppConfig(..))
import PgQuery import PostgREST.Auth
import RangeQuery import PostgREST.PgQuery
import PgStructure import PostgREST.RangeQuery
import PostgREST.PgStructure
app :: Text -> BL.ByteString -> Request -> H.Tx P.Postgres s Response import Prelude
app v1schema reqBody req =
app :: AppConfig -> BL.ByteString -> Request -> H.Tx P.Postgres s Response
app conf reqBody req =
case (path, verb) of case (path, verb) of
([], _) -> do ([], _) -> do
body <- encode <$> tables (cs schema) body <- encode <$> tables (cs schema)
@@ -102,6 +105,27 @@ app v1schema reqBody req =
, (hLocation, "/postgrest/users?id=eq." <> cs (userId u)) , (hLocation, "/postgrest/users?id=eq." <> cs (userId u))
] "" ] ""
(["postgrest", "tokens"], "POST") ->
case jwtSecret of
"secret" -> return $ responseLBS status500 [jsonH] $
encode . object $ [("message", String "JWT Secret is set as \"secret\" which is an unsafe default.")]
_ -> do
let user = decode reqBody :: Maybe AuthUser
case user of
Nothing -> return $ responseLBS status400 [jsonH] $
encode . object $ [("message", String "Failed to parse user.")]
Just u -> do
setRole authenticator
login <- signInRole (cs $ userId u)
(cs $ userPass u)
case login of
LoginSuccess role ->
return $ responseLBS status201 [ jsonH ] $
encode . object $ [("token", String $ tokenJWT jwtSecret (cs $ userId u) role)]
_ -> return $ responseLBS status401 [jsonH] $
encode . object $ [("message", String "Failed authentication.")]
([table], "POST") -> do ([table], "POST") -> do
let qt = QualifiedTable schema (cs table) let qt = QualifiedTable schema (cs table)
echoRequested = lookup "Prefer" hdrs == Just "return=representation" echoRequested = lookup "Prefer" hdrs == Just "return=representation"
@@ -164,10 +188,22 @@ app v1schema reqBody req =
([table], "PATCH") -> ([table], "PATCH") ->
handleJsonObj reqBody $ \obj -> do handleJsonObj reqBody $ \obj -> do
let qt = QualifiedTable schema (cs table) let qt = QualifiedTable schema (cs table)
H.unitEx up = returningStarT
$ whereT qq . whereT qq
$ update qt (map cs $ M.keys obj) (M.elems obj) $ update qt (map cs $ M.keys obj) (M.elems obj)
return $ responseLBS status204 [ jsonH ] "" patch = withT up "t" $ B.Stmt
"select count(t), array_to_json(array_agg(row_to_json(t)))::character varying"
V.empty True
row <- H.maybeEx patch
let (queryTotal, body) =
fromMaybe (0 :: Int, Just "" :: Maybe Text) row
r = contentRangeH 0 (queryTotal-1) queryTotal
echoRequested = lookup "Prefer" hdrs == Just "return=representation"
s = case () of _ | queryTotal == 0 -> status404
| echoRequested -> status200
| otherwise -> status204
return $ responseLBS s [ jsonH, r ] $ if echoRequested then cs $ fromMaybe "[]" body else ""
([table], "DELETE") -> do ([table], "DELETE") -> do
let qt = QualifiedTable schema (cs table) let qt = QualifiedTable schema (cs table)
@@ -189,7 +225,9 @@ app v1schema reqBody req =
verb = requestMethod req verb = requestMethod req
qq = queryString req qq = queryString req
hdrs = requestHeaders req hdrs = requestHeaders req
schema = requestedSchema v1schema hdrs schema = requestedSchema (cs $ configV1Schema conf) hdrs
authenticator = cs $ configDbUser conf
jwtSecret = cs $ configJwtSecret conf
range = rangeRequested hdrs range = rangeRequested hdrs
allOrigins = ("Access-Control-Allow-Origin", "*") :: Header allOrigins = ("Access-Control-Allow-Origin", "*") :: Header
+24 -4
View File
@@ -1,18 +1,22 @@
{-# LANGUAGE QuasiQuotes, ScopedTypeVariables, OverloadedStrings #-} {-# LANGUAGE QuasiQuotes, ScopedTypeVariables, OverloadedStrings #-}
module Auth where module PostgREST.Auth where
import Data.Aeson import Data.Aeson
import Control.Monad (mzero) import Control.Monad (mzero)
import Control.Applicative ( (<*>), (<$>) ) import Control.Applicative
import Crypto.BCrypt import Crypto.BCrypt
import Data.Text import Data.Text
import Data.Monoid import Data.Monoid
import Data.Map
import qualified Data.Vector as V import qualified Data.Vector as V
import qualified Hasql as H import qualified Hasql as H
import qualified Hasql.Backend as B import qualified Hasql.Backend as B
import qualified Hasql.Postgres as P import qualified Hasql.Postgres as P
import qualified Web.JWT as JWT
import Data.String.Conversions (cs) import Data.String.Conversions (cs)
import PgQuery (pgFmtLit) import PostgREST.PgQuery (pgFmtLit)
import Prelude
import System.IO.Unsafe import System.IO.Unsafe
@@ -26,7 +30,7 @@ instance FromJSON AuthUser where
parseJSON (Object v) = AuthUser <$> parseJSON (Object v) = AuthUser <$>
v .: "id" <*> v .: "id" <*>
v .: "pass" <*> v .: "pass" <*>
v .: "role" v .:? "role" .!= ""
parseJSON _ = mzero parseJSON _ = mzero
instance ToJSON AuthUser where instance ToJSON AuthUser where
@@ -69,3 +73,19 @@ signInRole user pass = do
then LoginSuccess role then LoginSuccess role
else LoginFailed else LoginFailed
) u ) u
signInWithJWT :: Text -> Text -> LoginAttempt
signInWithJWT secret input = case maybeRole of
Just (Just (String role)) -> LoginSuccess $ cs role
_ -> LoginFailed
where
maybeRole = (Data.Map.lookup "role" <$> claims) ::Maybe (Maybe Value)
claims = JWT.unregisteredClaims <$> JWT.claims <$> decoded
decoded = JWT.decodeAndVerifySignature (JWT.secret secret) input
tokenJWT :: Text -> Text -> Text -> Text
tokenJWT secret uid role = JWT.encodeSigned JWT.HS256 (JWT.secret secret) claimsSet
where
claimsSet = JWT.def {
JWT.unregisteredClaims = Data.Map.fromList [("id", String uid), ("role", String role)]
}
+5 -1
View File
@@ -1,4 +1,4 @@
module Config where module PostgREST.Config where
import Network.Wai import Network.Wai
import Control.Applicative import Control.Applicative
@@ -8,6 +8,7 @@ import qualified Data.ByteString.Char8 as BS
import Data.String.Conversions (cs) import Data.String.Conversions (cs)
import Options.Applicative hiding (columns) import Options.Applicative hiding (columns)
import Network.Wai.Middleware.Cors (CorsResourcePolicy(..)) import Network.Wai.Middleware.Cors (CorsResourcePolicy(..))
import Prelude
data AppConfig = AppConfig { data AppConfig = AppConfig {
configDbName :: String configDbName :: String
@@ -21,6 +22,8 @@ data AppConfig = AppConfig {
, configSecure :: Bool , configSecure :: Bool
, configPool :: Int , configPool :: Int
, configV1Schema :: String , configV1Schema :: String
, configJwtSecret :: String
} }
argParser :: Parser AppConfig argParser :: Parser AppConfig
@@ -36,6 +39,7 @@ argParser = AppConfig
<*> switch (long "secure" <> short 's' <> help "Redirect all requests to HTTPS") <*> switch (long "secure" <> short 's' <> help "Redirect all requests to HTTPS")
<*> option auto (long "db-pool" <> metavar "COUNT" <> value 10 <> help "Max connections in database pool" <> showDefault) <*> option auto (long "db-pool" <> metavar "COUNT" <> value 10 <> help "Max connections in database pool" <> showDefault)
<*> strOption (long "v1schema" <> metavar "NAME" <> value "1" <> help "Schema to use for nonspecified version (or explicit v1)" <> showDefault) <*> strOption (long "v1schema" <> metavar "NAME" <> value "1" <> help "Schema to use for nonspecified version (or explicit v1)" <> showDefault)
<*> strOption (long "jwt-secret" <> metavar "SECRET" <> value "secret" <> help "Secret used to encrypt and decrypt JWT tokens)" <> showDefault)
defaultCorsPolicy :: CorsResourcePolicy defaultCorsPolicy :: CorsResourcePolicy
defaultCorsPolicy = CorsResourcePolicy Nothing defaultCorsPolicy = CorsResourcePolicy Nothing
+1 -1
View File
@@ -1,7 +1,7 @@
{-# OPTIONS_GHC -fno-warn-orphans #-} {-# OPTIONS_GHC -fno-warn-orphans #-}
{-# LANGUAGE FlexibleInstances, TypeSynonymInstances #-} {-# LANGUAGE FlexibleInstances, TypeSynonymInstances #-}
module Error (PgError, errResponse) where module PostgREST.Error (PgError, errResponse) where
import qualified Hasql as H import qualified Hasql as H
import qualified Hasql.Postgres as P import qualified Hasql.Postgres as P
+7 -7
View File
@@ -2,9 +2,9 @@ module Main where
import Paths_postgrest (version) import Paths_postgrest (version)
import App import PostgREST.App
import Middleware import PostgREST.Middleware
import Error(errResponse) import PostgREST.Error(errResponse)
import Control.Monad (unless) import Control.Monad (unless)
import Control.Monad.IO.Class (liftIO) import Control.Monad.IO.Class (liftIO)
@@ -21,7 +21,7 @@ import qualified Hasql as H
import qualified Hasql.Postgres as P import qualified Hasql.Postgres as P
import Options.Applicative hiding (columns) import Options.Applicative hiding (columns)
import Config (AppConfig(..), argParser, corsPolicy) import PostgREST.Config (AppConfig(..), argParser, corsPolicy)
main :: IO () main :: IO ()
main = do main = do
@@ -38,6 +38,8 @@ main = do
unless (configSecure conf) $ unless (configSecure conf) $
putStrLn "WARNING, running in insecure mode, auth will be in plaintext" putStrLn "WARNING, running in insecure mode, auth will be in plaintext"
unless ("secret" /= configJwtSecret conf) $
putStrLn "WARNING, running in insecure mode, JWT secret is the default value"
Prelude.putStrLn $ "Listening on port " ++ Prelude.putStrLn $ "Listening on port " ++
(show $ configPort conf :: String) (show $ configPort conf :: String)
@@ -53,8 +55,6 @@ main = do
. (if configSecure conf then redirectInsecure else id) . (if configSecure conf then redirectInsecure else id)
. gzip def . cors corsPolicy . gzip def . cors corsPolicy
. staticPolicy (only [("favicon.ico", "static/favicon.ico")]) . staticPolicy (only [("favicon.ico", "static/favicon.ico")])
anonRole = cs $ configAnonRole conf
currRole = cs $ configDbUser conf
poolSettings <- maybe (fail "Improper session settings") return $ poolSettings <- maybe (fail "Improper session settings") return $
H.poolSettings (fromIntegral $ configPool conf) 30 H.poolSettings (fromIntegral $ configPool conf) 30
@@ -64,7 +64,7 @@ main = do
runSettings appSettings $ middle $ \req respond -> do runSettings appSettings $ middle $ \req respond -> do
body <- strictRequestBody req body <- strictRequestBody req
resOrError <- liftIO $ H.session pool $ H.tx Nothing $ resOrError <- liftIO $ H.session pool $ H.tx Nothing $
authenticated currRole anonRole (app (cs $ configV1Schema conf) body) req authenticated conf (app conf body) req
either (respond . errResponse) respond resOrError either (respond . errResponse) respond resOrError
where where
@@ -1,10 +1,10 @@
{-# OPTIONS_GHC -fno-warn-orphans #-} {-# OPTIONS_GHC -fno-warn-orphans #-}
{-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE ScopedTypeVariables #-}
module Middleware where module PostgREST.Middleware where
import Data.Maybe (fromMaybe) import Data.Maybe (fromMaybe)
import Data.Monoid (mconcat) import Data.Monoid
import Data.Text import Data.Text
-- import Data.Pool(withResource, Pool) -- import Data.Pool(withResource, Pool)
@@ -19,13 +19,16 @@ import Network.Wai (Application, requestHeaders, responseLBS, rawPathInfo,
rawQueryString, isSecure, Request(..), Response) rawQueryString, isSecure, Request(..), Response)
import Network.URI (URI(..), parseURI) import Network.URI (URI(..), parseURI)
import Auth (LoginAttempt(..), signInRole, setRole, resetRole) import PostgREST.Config (AppConfig(..))
import PostgREST.Auth (LoginAttempt(..), signInRole, signInWithJWT, setRole, resetRole)
import Codec.Binary.Base64.String (decode) import Codec.Binary.Base64.String (decode)
authenticated :: forall s. Text -> Text -> import Prelude
authenticated :: forall s. AppConfig ->
(Request -> H.Tx P.Postgres s Response) -> (Request -> H.Tx P.Postgres s Response) ->
Request -> H.Tx P.Postgres s Response Request -> H.Tx P.Postgres s Response
authenticated currentRole anon app req = do authenticated conf app req = do
attempt <- httpRequesterRole (requestHeaders req) attempt <- httpRequesterRole (requestHeaders req)
case attempt of case attempt of
MalformedAuth -> MalformedAuth ->
@@ -36,6 +39,9 @@ authenticated currentRole anon app req = do
NoCredentials -> if anon /= currentRole then runInRole anon else app req NoCredentials -> if anon /= currentRole then runInRole anon else app req
where where
jwtSecret = cs $ configJwtSecret conf
currentRole = cs $ configDbUser conf
anon = cs $ configAnonRole conf
httpRequesterRole :: RequestHeaders -> H.Tx P.Postgres s LoginAttempt httpRequesterRole :: RequestHeaders -> H.Tx P.Postgres s LoginAttempt
httpRequesterRole hdrs = do httpRequesterRole hdrs = do
let auth = fromMaybe "" $ lookup hAuthorization hdrs let auth = fromMaybe "" $ lookup hAuthorization hdrs
@@ -44,6 +50,8 @@ authenticated currentRole anon app req = do
case split (==':') (cs . decode . cs $ b64) of case split (==':') (cs . decode . cs $ b64) of
(u:p:_) -> signInRole u p (u:p:_) -> signInRole u p
_ -> return MalformedAuth _ -> return MalformedAuth
("Bearer" : jwt : _) ->
return $ signInWithJWT jwtSecret jwt
_ -> return NoCredentials _ -> return NoCredentials
runInRole :: Text -> H.Tx P.Postgres s Response runInRole :: Text -> H.Tx P.Postgres s Response
+12 -4
View File
@@ -1,9 +1,9 @@
{-# LANGUAGE TypeSynonymInstances, FlexibleInstances, MultiWayIf #-} {-# LANGUAGE TypeSynonymInstances, FlexibleInstances, MultiWayIf #-}
{-# OPTIONS_GHC -fno-warn-orphans #-} {-# OPTIONS_GHC -fno-warn-orphans #-}
module PgQuery where module PostgREST.PgQuery where
import RangeQuery import PostgREST.RangeQuery
import qualified Hasql as H import qualified Hasql as H
import qualified Hasql.Postgres as P import qualified Hasql.Postgres as P
@@ -17,7 +17,7 @@ import qualified Data.ByteString.Char8 as BS
import Data.Monoid import Data.Monoid
import Data.Vector (empty) import Data.Vector (empty)
import Data.Maybe (fromMaybe, mapMaybe) import Data.Maybe (fromMaybe, mapMaybe)
import Data.Functor ( (<$>) ) import Data.Functor
import Control.Monad (join) import Control.Monad (join)
import Data.String.Conversions (cs) import Data.String.Conversions (cs)
import qualified Data.Aeson as JSON import qualified Data.Aeson as JSON
@@ -25,6 +25,8 @@ import qualified Data.List as L
import qualified Data.Vector as V import qualified Data.Vector as V
import Data.Scientific (isInteger, formatScientific, FPFormat(..)) import Data.Scientific (isInteger, formatScientific, FPFormat(..))
import Prelude
type PStmt = H.Stmt P.Postgres type PStmt = H.Stmt P.Postgres
instance Monoid PStmt where instance Monoid PStmt where
mappend (B.Stmt query params prep) (B.Stmt query' params' prep') = mappend (B.Stmt query params prep) (B.Stmt query' params' prep') =
@@ -59,6 +61,12 @@ whereT params q =
cols = [ col | col <- params, fst col `notElem` ["order"] ] cols = [ col | col <- params, fst col `notElem` ["order"] ]
conjunction = mconcat $ L.intersperse andq (map wherePred cols) conjunction = mconcat $ L.intersperse andq (map wherePred cols)
withT :: PStmt -> T.Text -> StatementT
withT (B.Stmt eq ep epre) v (B.Stmt wq wp wpre) =
B.Stmt ("WITH " <> v <> " AS (" <> eq <> ") " <> wq <> " from " <> v)
(ep <> wp)
(epre && wpre)
orderT :: [OrderTerm] -> StatementT orderT :: [OrderTerm] -> StatementT
orderT ts q = orderT ts q =
if L.null ts if L.null ts
@@ -268,7 +276,7 @@ unquoted (JSON.String t) = t
unquoted (JSON.Number n) = unquoted (JSON.Number n) =
cs $ formatScientific Fixed (if isInteger n then Just 0 else Nothing) n cs $ formatScientific Fixed (if isInteger n then Just 0 else Nothing) n
unquoted (JSON.Bool b) = cs . show $ b unquoted (JSON.Bool b) = cs . show $ b
unquoted _ = "" unquoted v = cs $ JSON.encode v
insertableText :: T.Text -> T.Text insertableText :: T.Text -> T.Text
insertableText = (<> "::unknown") . pgFmtLit insertableText = (<> "::unknown") . pgFmtLit
@@ -1,20 +1,23 @@
{-# LANGUAGE QuasiQuotes, OverloadedStrings, TypeSynonymInstances, {-# LANGUAGE QuasiQuotes, OverloadedStrings, TypeSynonymInstances,
MultiParamTypeClasses, ScopedTypeVariables #-} MultiParamTypeClasses, ScopedTypeVariables,
module PgStructure where FlexibleContexts #-}
module PostgREST.PgStructure where
import PgQuery (QualifiedTable(..)) import PostgREST.PgQuery (QualifiedTable(..))
import Data.Text hiding (foldl, map, zipWith, concat) import Data.Text hiding (foldl, map, zipWith, concat)
import Data.Aeson import Data.Aeson
import Data.Functor.Identity import Data.Functor.Identity
import Data.String.Conversions (cs) import Data.String.Conversions (cs)
import Data.Maybe (fromMaybe) import Data.Maybe (fromMaybe)
import Control.Applicative ( (<$>) ) import Control.Applicative
import qualified Data.Map as Map import qualified Data.Map as Map
import qualified Hasql as H import qualified Hasql as H
import qualified Hasql.Postgres as P import qualified Hasql.Postgres as P
import Prelude
foreignKeys :: QualifiedTable -> H.Tx P.Postgres s (Map.Map Text ForeignKey) foreignKeys :: QualifiedTable -> H.Tx P.Postgres s (Map.Map Text ForeignKey)
foreignKeys table = do foreignKeys table = do
r <- H.listEx $ [H.stmt| r <- H.listEx $ [H.stmt|
@@ -1,4 +1,4 @@
module RangeQuery ( module PostgREST.RangeQuery (
rangeParse rangeParse
, rangeRequested , rangeRequested
, rangeLimit , rangeLimit
@@ -20,6 +20,8 @@ import Text.Read (readMaybe)
import Data.Maybe (fromMaybe, listToMaybe) import Data.Maybe (fromMaybe, listToMaybe)
import Prelude
type NonnegRange = Range Int type NonnegRange = Range Int
rangeParse :: BS.ByteString -> Maybe NonnegRange rangeParse :: BS.ByteString -> Maybe NonnegRange
-57
View File
@@ -1,57 +0,0 @@
{-# OPTIONS_GHC -fno-warn-orphans #-}
module Types where
import qualified Data.Aeson as JSON
import Data.Aeson.Types (Parser)
import Data.Scientific (floatingOrInteger)
import Data.HashMap.Strict (foldlWithKey')
import Data.Text (Text)
import Data.Text.Encoding (decodeUtf8)
import Data.Time.Calendar (showGregorian)
import Control.Monad (mzero)
instance JSON.FromJSON SqlValue where
parseJSON (JSON.Number n) = return $ either toSql iToSql (floatingOrInteger n :: Either Double Int)
parseJSON (JSON.String s) = return $ toSql s
parseJSON (JSON.Bool b) = return $ toSql b
parseJSON JSON.Null = return SqlNull
parseJSON (JSON.Object o) = return . toSql $ JSON.encode o
parseJSON (JSON.Array a) = return . toSql $ JSON.encode a
instance JSON.ToJSON SqlValue where
toJSON (SqlString s) = JSON.toJSON s
toJSON (SqlByteString s) = JSON.toJSON $ decodeUtf8 s
toJSON (SqlWord32 w) = JSON.toJSON w
toJSON (SqlWord64 w) = JSON.toJSON w
toJSON (SqlInt32 i) = JSON.toJSON i
toJSON (SqlInt64 i) = JSON.toJSON i
toJSON (SqlInteger i) = JSON.toJSON i
toJSON (SqlChar c) = JSON.toJSON c
toJSON (SqlBool b) = JSON.toJSON b
toJSON (SqlDouble n) = JSON.toJSON n
toJSON (SqlRational n) = JSON.toJSON n
toJSON (SqlLocalDate d) = JSON.toJSON $ showGregorian d
toJSON (SqlLocalTimeOfDay t) = JSON.toJSON $ show t
toJSON (SqlLocalTime t) = JSON.toJSON $ show t
toJSON SqlNull = JSON.Null
toJSON x = JSON.toJSON $ show x
newtype SqlRow = SqlRow {getRow :: [(Text, SqlValue)] } deriving (Show)
sqlRowColumns :: SqlRow -> [Text]
sqlRowColumns = map fst . getRow
sqlRowValues :: SqlRow -> [SqlValue]
sqlRowValues = map snd . getRow
instance JSON.FromJSON SqlRow where
parseJSON (JSON.Object m) = foldlWithKey' add (return $ SqlRow []) m
where
add :: Parser SqlRow -> Text -> JSON.Value -> Parser SqlRow
add parser k v = do
SqlRow l <- parser
sqlV <- JSON.parseJSON v
return . SqlRow $ (k, sqlV) : l
parseJSON _ = mzero
+28 -4
View File
@@ -18,13 +18,37 @@ spec = beforeAll
it "hides tables that anonymous does not own" $ it "hides tables that anonymous does not own" $
get "/authors_only" `shouldRespondWith` 404 get "/authors_only" `shouldRespondWith` 404
it "indicates login failure" $ do it "indicates login failure (BasicAuth)" $ do
let auth = authHeader "postgrest_test_author" "fakefake" let auth = authHeaderBasic "postgrest_test_author" "fakefake"
request methodGet "/authors_only" [auth] "" request methodGet "/authors_only" [auth] ""
`shouldRespondWith` 401 `shouldRespondWith` 401
it "allows users with permissions to see their tables" $ do it "allows users with permissions to see their tables (BasicAuth)" $ do
_ <- post "/postgrest/users" [json| { "id":"jdoe", "pass": "1234", "role": "postgrest_test_author" } |] _ <- post "/postgrest/users" [json| { "id":"jdoe", "pass": "1234", "role": "postgrest_test_author" } |]
let auth = authHeader "jdoe" "1234" let auth = authHeaderBasic "jdoe" "1234"
request methodGet "/authors_only" [auth] ""
`shouldRespondWith` 200
it "allows users to login (JWT)" $ do
_ <- post "/postgrest/users" [json| { "id":"jdoe", "pass": "1234", "role": "postgrest_test_author" } |]
post "/postgrest/tokens" [json| { "id":"jdoe", "pass": "1234" } |]
`shouldRespondWith` ResponseMatcher {
matchBody = Just [json| {"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0"} |]
, matchStatus = 201
, matchHeaders = ["Content-Type" <:> "application/json"]
}
it "indicates login failure (JWT)" $ do
_ <- post "/postgrest/users" [json| { "id":"jdoe", "pass": "1234", "role": "postgrest_test_author" } |]
post "/postgrest/tokens" [json| { "id":"jdoe", "pass": "NOPE" } |]
`shouldRespondWith` ResponseMatcher {
matchBody = Just [json| {"message":"Failed authentication."} |]
, matchStatus = 401
, matchHeaders = ["Content-Type" <:> "application/json"]
}
it "allows users with permissions to see their tables (JWT)" $ do
_ <- post "/postgrest/users" [json| { "id":"jdoe", "pass": "1234", "role": "postgrest_test_author" } |]
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0"
request methodGet "/authors_only" [auth] "" request methodGet "/authors_only" [auth] ""
`shouldRespondWith` 200 `shouldRespondWith` 200
+32 -3
View File
@@ -94,6 +94,22 @@ spec = afterAll_ resetDb $ around withApp $ do
, matchHeaders = [] , matchHeaders = []
} }
context "jsonb" . after_ (clearTable "json") $ do
it "serializes nested object" $ do
let inserted = [json| { "data": { "foo":"bar" } } |]
p <- request methodPost "json" [("Prefer", "return=representation")] inserted
liftIO $ do
simpleBody p `shouldBe` inserted
simpleHeaders p `shouldSatisfy` matchHeader hLocation "/json\\?data=eq\\.%7B%22foo%22%3A%22bar%22%7D"
simpleStatus p `shouldBe` created201
it "serializes nested array" $ do
let inserted = [json| { "data": [1,2,3] } |]
p <- request methodPost "json" [("Prefer", "return=representation")] inserted
liftIO $ do
simpleBody p `shouldBe` inserted
simpleHeaders p `shouldSatisfy` matchHeader hLocation "/json\\?data=eq\\.%5B1%2C2%2C3%5D"
simpleStatus p `shouldBe` created201
describe "CSV insert" $ do describe "CSV insert" $ do
after_ (clearTable "menagerie") . context "disparate csv types" $ after_ (clearTable "menagerie") . context "disparate csv types" $
@@ -216,10 +232,10 @@ spec = afterAll_ resetDb $ around withApp $ do
`shouldRespondWith` 404 `shouldRespondWith` 404
context "on an empty table" $ context "on an empty table" $
it "succeeds with no effect" $ it "indicates no records found to update" $
request methodPatch "/simple_pk" [] request methodPatch "/simple_pk" []
[json| { "extra":20 } |] [json| { "extra":20 } |]
`shouldRespondWith` 204 `shouldRespondWith` 404
context "in a nonempty table" . before_ (clearTable "items" >> createItems 15) . context "in a nonempty table" . before_ (clearTable "items" >> createItems 15) .
after_ (clearTable "items") $ do after_ (clearTable "items") $ do
@@ -229,7 +245,11 @@ spec = afterAll_ resetDb $ around withApp $ do
`shouldSatisfy` matchHeader "Content-Range" "\\*/0" `shouldSatisfy` matchHeader "Content-Range" "\\*/0"
request methodPatch "/items?id=eq.1" [] request methodPatch "/items?id=eq.1" []
[json| { "id":42 } |] [json| { "id":42 } |]
`shouldRespondWith` 204 `shouldRespondWith` ResponseMatcher {
matchBody = Nothing,
matchStatus = 204,
matchHeaders = ["Content-Range" <:> "0-0/1"]
}
g' <- get "/items?id=eq.42" g' <- get "/items?id=eq.42"
liftIO $ simpleHeaders g' liftIO $ simpleHeaders g'
`shouldSatisfy` matchHeader "Content-Range" "0-0/1" `shouldSatisfy` matchHeader "Content-Range" "0-0/1"
@@ -245,3 +265,12 @@ spec = afterAll_ resetDb $ around withApp $ do
g <- get "/auto_incrementing_pk?non_nullable_string=eq.c" g <- get "/auto_incrementing_pk?non_nullable_string=eq.c"
liftIO $ simpleHeaders g liftIO $ simpleHeaders g
`shouldSatisfy` matchHeader "Content-Range" "0-9/10" `shouldSatisfy` matchHeader "Content-Range" "0-9/10"
it "can provide a representation" $ do
_ <- post "/items"
[json| { id: 1 } |]
request methodPatch
"/items?id=eq.1"
[("Prefer", "return=representation")]
[json| { id: 99 } |]
`shouldRespondWith` [json| [{id:99}] |]
+1 -1
View File
@@ -27,7 +27,7 @@ spec = around withApp $ do
it "lists only views user has permission to see" $ do it "lists only views user has permission to see" $ do
_ <- post "/postgrest/users" [json| { "id":"jdoe", "pass": "1234", "role": "postgrest_test_author" } |] _ <- post "/postgrest/users" [json| { "id":"jdoe", "pass": "1234", "role": "postgrest_test_author" } |]
let auth = authHeader "jdoe" "1234" let auth = authHeaderBasic "jdoe" "1234"
request methodGet "/" [auth] "" request methodGet "/" [auth] ""
`shouldRespondWith` [json| [ `shouldRespondWith` [json| [
+12 -10
View File
@@ -26,17 +26,17 @@ import System.Process (readProcess)
import qualified Data.Aeson.Types as J import qualified Data.Aeson.Types as J
import App (app) import PostgREST.App (app)
import Config (AppConfig(..), corsPolicy) import PostgREST.Config (AppConfig(..), corsPolicy)
import Middleware import PostgREST.Middleware
import Error(errResponse) import PostgREST.Error(errResponse)
isLeft :: Either a b -> Bool isLeft :: Either a b -> Bool
isLeft (Left _ ) = True isLeft (Left _ ) = True
isLeft _ = False isLeft _ = False
cfg :: AppConfig cfg :: AppConfig
cfg = AppConfig "postgrest_test" 5432 "postgrest_test" "" "localhost" 3000 "postgrest_anonymous" False 10 "1" cfg = AppConfig "postgrest_test" 5432 "postgrest_test" "" "localhost" 3000 "postgrest_anonymous" False 10 "1" "safe"
testPoolOpts :: PoolSettings testPoolOpts :: PoolSettings
testPoolOpts = fromMaybe (error "bad settings") $ H.poolSettings 1 30 testPoolOpts = fromMaybe (error "bad settings") $ H.poolSettings 1 30
@@ -50,15 +50,13 @@ pgSettings = P.ParamSettings (cs $ configDbHost cfg)
withApp :: ActionWith Application -> IO () withApp :: ActionWith Application -> IO ()
withApp perform = do withApp perform = do
let anonRole = cs $ configAnonRole cfg
currRole = cs $ configDbUser cfg
pool :: H.Pool P.Postgres pool :: H.Pool P.Postgres
<- H.acquirePool pgSettings testPoolOpts <- H.acquirePool pgSettings testPoolOpts
perform $ middle $ \req resp -> do perform $ middle $ \req resp -> do
body <- strictRequestBody req body <- strictRequestBody req
result <- liftIO $ H.session pool $ H.tx Nothing result <- liftIO $ H.session pool $ H.tx Nothing
$ authenticated currRole anonRole (app (cs $ configV1Schema cfg) body) req $ authenticated cfg (app cfg body) req
either (resp . errResponse) resp result either (resp . errResponse) resp result
where middle = cors corsPolicy where middle = cors corsPolicy
@@ -93,10 +91,14 @@ matchHeader :: CI BS.ByteString -> String -> [Header] -> Bool
matchHeader name valRegex headers = matchHeader name valRegex headers =
maybe False (=~ valRegex) $ lookup name headers maybe False (=~ valRegex) $ lookup name headers
authHeader :: String -> String -> Header authHeaderBasic :: String -> String -> Header
authHeader u p = authHeaderBasic u p =
(hAuthorization, cs $ "Basic " ++ encode (u ++ ":" ++ p)) (hAuthorization, cs $ "Basic " ++ encode (u ++ ":" ++ p))
authHeaderJWT :: String -> Header
authHeaderJWT token =
(hAuthorization, cs $ "Bearer " ++ token)
testPool :: IO(H.Pool P.Postgres) testPool :: IO(H.Pool P.Postgres)
testPool = H.acquirePool pgSettings testPoolOpts testPool = H.acquirePool pgSettings testPoolOpts
+3 -1
View File
@@ -8,9 +8,11 @@ module TestTypes (
import qualified Data.Aeson as JSON import qualified Data.Aeson as JSON
import Data.Aeson ((.:)) import Data.Aeson ((.:))
-- import Data.Maybe (fromJust) -- import Data.Maybe (fromJust)
import Control.Applicative ((<$>), (<*>)) import Control.Applicative
import Control.Monad (mzero) import Control.Monad (mzero)
import Prelude
data IncPK = IncPK { data IncPK = IncPK {
incId :: Int incId :: Int
, incNullableStr :: Maybe String , incNullableStr :: Maybe String