packages feed

postgrest 0.3.2.0 → 0.4.0.0

raw patch · 32 files changed

+2633/−1751 lines, 32 filesdep +aeson-qqdep +ansi-wl-pprintdep +auto-updatedep −base64-stringdep −bytestring-tree-builderdep −errorsdep ~Ranged-setsdep ~aesondep ~basePVP ok

version bump matches the API change (PVP)

Dependencies added: aeson-qq, ansi-wl-pprint, auto-update, base64-bytestring, configurator, either, hjsonpointer, hjsonschema, insert-ordered-containers, lens, lens-aeson, network-uri, protolude, swagger2

Dependencies removed: base64-string, bytestring-tree-builder, errors, microlens, microlens-aeson, mtl, postgresql-binary, string-conversions, transformers

Dependency ranges changed: Ranged-sets, aeson, base, hasql, hasql-pool, hasql-transaction, optparse-applicative, safe, unix, wai, wai-middleware-static, warp

API changes (from Hackage documentation)

- PostgREST.ApiRequest: ActionInappropriate :: Action
- PostgREST.ApiRequest: ApplicationJSON :: ContentType
- PostgREST.ApiRequest: TextCSV :: ContentType
- PostgREST.ApiRequest: [iPreferSingular] :: ApiRequest -> Bool
- PostgREST.ApiRequest: csvToJson :: (Header, CsvData) -> Array
- PostgREST.ApiRequest: ensureUniform :: Array -> Maybe UniformObjects
- PostgREST.ApiRequest: instance GHC.Show.Show PostgREST.ApiRequest.ContentType
- PostgREST.ApiRequest: pickContentType :: Maybe ByteString -> Either ByteString ContentType
- PostgREST.ApiRequest: pluralize :: Value -> Array
- PostgREST.ApiRequest: type CsvData = Vector (HashMap Text ByteString)
- PostgREST.ApiRequest: type RequestBody = ByteString
- PostgREST.App: instance Data.Aeson.Types.Class.ToJSON PostgREST.App.TableOptions
- PostgREST.DbStructure: doesProcExist :: Query QualifiedIdentifier Bool
- PostgREST.DbStructure: doesProcReturnJWT :: Query QualifiedIdentifier Bool
- PostgREST.Error: instance Data.Aeson.Types.Class.ToJSON Hasql.Pool.UsageError
- PostgREST.Error: instance Data.Aeson.Types.Class.ToJSON Hasql.Private.Session.Error
- PostgREST.Middleware: unsupportedAccept :: Application -> Application
- PostgREST.QueryBuilder: addJoinConditions :: Schema -> ReadRequest -> Either Text ReadRequest
- PostgREST.QueryBuilder: addRelations :: Schema -> [Relation] -> Maybe ReadRequest -> ReadRequest -> Either Text ReadRequest
- PostgREST.Types: PayloadParseError :: ByteString -> Payload
- PostgREST.Types: UniformObjects :: (Vector Object) -> UniformObjects
- PostgREST.Types: data Payload
- PostgREST.Types: instance Data.Aeson.Types.Class.ToJSON PostgREST.Types.Column
- PostgREST.Types: instance Data.Aeson.Types.Class.ToJSON PostgREST.Types.ForeignKey
- PostgREST.Types: instance Data.Aeson.Types.Class.ToJSON PostgREST.Types.Table
- PostgREST.Types: instance GHC.Classes.Eq PostgREST.Types.Payload
- PostgREST.Types: instance GHC.Classes.Eq PostgREST.Types.UniformObjects
- PostgREST.Types: instance GHC.Show.Show PostgREST.Types.Payload
- PostgREST.Types: instance GHC.Show.Show PostgREST.Types.UniformObjects
- PostgREST.Types: newtype UniformObjects
- PostgREST.Types: unUniformObjects :: UniformObjects -> Vector Object
+ PostgREST.ApiRequest: ActionInspect :: Action
+ PostgREST.ApiRequest: CTAny :: ContentType
+ PostgREST.ApiRequest: CTApplicationJSON :: ContentType
+ PostgREST.ApiRequest: CTOpenAPI :: ContentType
+ PostgREST.ApiRequest: CTOther :: ByteString -> ContentType
+ PostgREST.ApiRequest: CTSingularJSON :: ContentType
+ PostgREST.ApiRequest: CTTextCSV :: ContentType
+ PostgREST.ApiRequest: ErrorActionInappropriate :: ApiRequestError
+ PostgREST.ApiRequest: ErrorInvalidBody :: ByteString -> ApiRequestError
+ PostgREST.ApiRequest: ErrorInvalidRange :: ApiRequestError
+ PostgREST.ApiRequest: [iPreferSingleObjectParameter] :: ApiRequest -> Bool
+ PostgREST.ApiRequest: data ApiRequestError
+ PostgREST.ApiRequest: instance GHC.Classes.Eq PostgREST.ApiRequest.ApiRequestError
+ PostgREST.ApiRequest: instance GHC.Classes.Eq PostgREST.ApiRequest.Target
+ PostgREST.ApiRequest: instance GHC.Show.Show PostgREST.ApiRequest.ApiRequestError
+ PostgREST.ApiRequest: mutuallyAgreeable :: [ContentType] -> [ContentType] -> Maybe ContentType
+ PostgREST.ApiRequest: toHeader :: ContentType -> Header
+ PostgREST.ApiRequest: toMime :: ContentType -> ByteString
+ PostgREST.Auth: JWTClaims :: (HashMap Text Value) -> JWTAttempt
+ PostgREST.Auth: JWTExpired :: JWTAttempt
+ PostgREST.Auth: JWTInvalid :: JWTAttempt
+ PostgREST.Auth: JWTMissingSecret :: JWTAttempt
+ PostgREST.Auth: data JWTAttempt
+ PostgREST.Auth: instance GHC.Classes.Eq PostgREST.Auth.JWTAttempt
+ PostgREST.Config: PgVersion :: Int32 -> Text -> PgVersion
+ PostgREST.Config: [configHost] :: AppConfig -> Text
+ PostgREST.Config: [configJwtSecretIsBase64] :: AppConfig -> Bool
+ PostgREST.Config: [configProxyUri] :: AppConfig -> Maybe Text
+ PostgREST.Config: [configReqCheck] :: AppConfig -> Maybe Text
+ PostgREST.Config: [pgvName] :: PgVersion -> Text
+ PostgREST.Config: [pgvNum] :: PgVersion -> Int32
+ PostgREST.Config: data PgVersion
+ PostgREST.DbRequestBuilder: mutateRequest :: ApiRequest -> ReadRequest -> Either Response MutateRequest
+ PostgREST.DbRequestBuilder: readRequest :: Maybe Integer -> [Relation] -> [(Text, Text)] -> ApiRequest -> Either Response ReadRequest
+ PostgREST.Error: apiRequestErrResponse :: ApiRequestError -> Response
+ PostgREST.Error: formatGeneralError :: Text -> Text -> Text
+ PostgREST.Error: formatParserError :: ParseError -> Text
+ PostgREST.Error: instance Data.Aeson.Types.ToJSON.ToJSON Hasql.Pool.UsageError
+ PostgREST.Error: instance Data.Aeson.Types.ToJSON.ToJSON Hasql.Private.Session.Error
+ PostgREST.Error: prettyUsageError :: UsageError -> Text
+ PostgREST.Error: singularityError :: Integer -> Response
+ PostgREST.OpenAPI: encodeOpenAPI :: [ProcDescription] -> [(Table, [Column], [Text])] -> (Text, Text, Integer, Text) -> LByteString
+ PostgREST.OpenAPI: isMalformedProxyUri :: Maybe Text -> Bool
+ PostgREST.OpenAPI: pickProxy :: Maybe Text -> Maybe Proxy
+ PostgREST.Parsers: pReadRequest :: Text -> Parser ReadRequest
+ PostgREST.QueryBuilder: getJoinConditions :: Relation -> [Filter]
+ PostgREST.Types: PgArg :: Text -> Text -> Bool -> PgArg
+ PostgREST.Types: ProcDescription :: Text -> [PgArg] -> Text -> ProcDescription
+ PostgREST.Types: Proxy :: Text -> Text -> Integer -> Text -> Proxy
+ PostgREST.Types: Root :: RelationType
+ PostgREST.Types: [dbProcs] :: DbStructure -> [(Text, ProcDescription)]
+ PostgREST.Types: [pdArgs] :: ProcDescription -> [PgArg]
+ PostgREST.Types: [pdName] :: ProcDescription -> Text
+ PostgREST.Types: [pdReturnType] :: ProcDescription -> Text
+ PostgREST.Types: [pgaName] :: PgArg -> Text
+ PostgREST.Types: [pgaReq] :: PgArg -> Bool
+ PostgREST.Types: [pgaType] :: PgArg -> Text
+ PostgREST.Types: [proxyHost] :: Proxy -> Text
+ PostgREST.Types: [proxyPath] :: Proxy -> Text
+ PostgREST.Types: [proxyPort] :: Proxy -> Integer
+ PostgREST.Types: [proxyScheme] :: Proxy -> Text
+ PostgREST.Types: [returning] :: MutateQuery -> [FieldName]
+ PostgREST.Types: data PgArg
+ PostgREST.Types: data ProcDescription
+ PostgREST.Types: data Proxy
+ PostgREST.Types: instance Data.Aeson.Types.ToJSON.ToJSON PostgREST.Types.Column
+ PostgREST.Types: instance Data.Aeson.Types.ToJSON.ToJSON PostgREST.Types.ForeignKey
+ PostgREST.Types: instance Data.Aeson.Types.ToJSON.ToJSON PostgREST.Types.Table
+ PostgREST.Types: instance GHC.Classes.Eq PostgREST.Types.PayloadJSON
+ PostgREST.Types: instance GHC.Classes.Eq PostgREST.Types.PgArg
+ PostgREST.Types: instance GHC.Classes.Eq PostgREST.Types.ProcDescription
+ PostgREST.Types: instance GHC.Classes.Eq PostgREST.Types.Proxy
+ PostgREST.Types: instance GHC.Show.Show PostgREST.Types.PayloadJSON
+ PostgREST.Types: instance GHC.Show.Show PostgREST.Types.PgArg
+ PostgREST.Types: instance GHC.Show.Show PostgREST.Types.ProcDescription
+ PostgREST.Types: instance GHC.Show.Show PostgREST.Types.Proxy
+ PostgREST.Types: newtype PayloadJSON
+ PostgREST.Types: unPayloadJSON :: PayloadJSON -> Vector Object
- PostgREST.ApiRequest: ApiRequest :: Action -> HashMap String NonnegRange -> Target -> Either ByteString ContentType -> Maybe Payload -> PreferRepresentation -> Bool -> Bool -> [(String, String)] -> String -> [(String, String)] -> String -> Text -> ApiRequest
+ PostgREST.ApiRequest: ApiRequest :: Action -> HashMap ByteString NonnegRange -> Target -> [ContentType] -> Maybe PayloadJSON -> PreferRepresentation -> Bool -> Bool -> [(Text, Text)] -> Text -> [(Text, Text)] -> ByteString -> Text -> ApiRequest
- PostgREST.ApiRequest: [iAccepts] :: ApiRequest -> Either ByteString ContentType
+ PostgREST.ApiRequest: [iAccepts] :: ApiRequest -> [ContentType]
- PostgREST.ApiRequest: [iCanonicalQS] :: ApiRequest -> String
+ PostgREST.ApiRequest: [iCanonicalQS] :: ApiRequest -> ByteString
- PostgREST.ApiRequest: [iFilters] :: ApiRequest -> [(String, String)]
+ PostgREST.ApiRequest: [iFilters] :: ApiRequest -> [(Text, Text)]
- PostgREST.ApiRequest: [iOrder] :: ApiRequest -> [(String, String)]
+ PostgREST.ApiRequest: [iOrder] :: ApiRequest -> [(Text, Text)]
- PostgREST.ApiRequest: [iPayload] :: ApiRequest -> Maybe Payload
+ PostgREST.ApiRequest: [iPayload] :: ApiRequest -> Maybe PayloadJSON
- PostgREST.ApiRequest: [iRange] :: ApiRequest -> HashMap String NonnegRange
+ PostgREST.ApiRequest: [iRange] :: ApiRequest -> HashMap ByteString NonnegRange
- PostgREST.ApiRequest: [iSelect] :: ApiRequest -> String
+ PostgREST.ApiRequest: [iSelect] :: ApiRequest -> Text
- PostgREST.ApiRequest: userApiRequest :: Schema -> Request -> RequestBody -> ApiRequest
+ PostgREST.ApiRequest: userApiRequest :: Schema -> Request -> RequestBody -> Either ApiRequestError ApiRequest
- PostgREST.App: postgrest :: AppConfig -> IORef DbStructure -> Pool -> Application
+ PostgREST.App: postgrest :: AppConfig -> IORef DbStructure -> Pool -> IO POSIXTime -> Application
- PostgREST.Auth: containsRole :: Either Text (HashMap Text Value) -> Bool
+ PostgREST.Auth: containsRole :: JWTAttempt -> Bool
- PostgREST.Auth: jwtClaims :: Secret -> Text -> NominalDiffTime -> Either Text (HashMap Text Value)
+ PostgREST.Auth: jwtClaims :: Maybe Secret -> Text -> NominalDiffTime -> JWTAttempt
- PostgREST.Config: AppConfig :: String -> String -> String -> Int -> Secret -> Int -> Maybe Integer -> Bool -> AppConfig
+ PostgREST.Config: AppConfig :: Text -> Text -> Maybe Text -> Text -> Text -> Int -> Maybe ByteString -> Bool -> Int -> Maybe Integer -> Maybe Text -> Bool -> AppConfig
- PostgREST.Config: [configAnonRole] :: AppConfig -> String
+ PostgREST.Config: [configAnonRole] :: AppConfig -> Text
- PostgREST.Config: [configDatabase] :: AppConfig -> String
+ PostgREST.Config: [configDatabase] :: AppConfig -> Text
- PostgREST.Config: [configJwtSecret] :: AppConfig -> Secret
+ PostgREST.Config: [configJwtSecret] :: AppConfig -> Maybe ByteString
- PostgREST.Config: [configSchema] :: AppConfig -> String
+ PostgREST.Config: [configSchema] :: AppConfig -> Text
- PostgREST.Config: minimumPgVersion :: Integer
+ PostgREST.Config: minimumPgVersion :: PgVersion
- PostgREST.Config: prettyVersion :: String
+ PostgREST.Config: prettyVersion :: Text
- PostgREST.Middleware: runWithClaims :: AppConfig -> Either Text (HashMap Text Value) -> (ApiRequest -> Transaction Response) -> ApiRequest -> Transaction Response
+ PostgREST.Middleware: runWithClaims :: AppConfig -> JWTAttempt -> (ApiRequest -> Transaction Response) -> ApiRequest -> Transaction Response
- PostgREST.Parsers: pRequestFilter :: (String, String) -> Either ParseError (Path, Filter)
+ PostgREST.Parsers: pRequestFilter :: (Text, Text) -> Either ParseError (Path, Filter)
- PostgREST.Parsers: pRequestOrder :: (String, String) -> Either ParseError (Path, [OrderTerm])
+ PostgREST.Parsers: pRequestOrder :: (Text, Text) -> Either ParseError (Path, [OrderTerm])
- PostgREST.Parsers: pRequestRange :: (String, NonnegRange) -> Either ParseError (Path, NonnegRange)
+ PostgREST.Parsers: pRequestRange :: (ByteString, NonnegRange) -> Either ParseError (Path, NonnegRange)
- PostgREST.Parsers: pRequestSelect :: Text -> Parser ReadRequest
+ PostgREST.Parsers: pRequestSelect :: Text -> Text -> Either ParseError ReadRequest
- PostgREST.QueryBuilder: callProc :: QualifiedIdentifier -> Object -> NonnegRange -> Bool -> Query () (Maybe ProcResults)
+ PostgREST.QueryBuilder: callProc :: QualifiedIdentifier -> Object -> SqlQuery -> SqlQuery -> NonnegRange -> Bool -> Bool -> Bool -> Query () (Maybe ProcResults)
- PostgREST.QueryBuilder: createWriteStatement :: QualifiedIdentifier -> SqlQuery -> SqlQuery -> Bool -> PreferRepresentation -> [Text] -> Bool -> Payload -> Query UniformObjects (Maybe ResultsWithCount)
+ PostgREST.QueryBuilder: createWriteStatement :: SqlQuery -> SqlQuery -> Bool -> Bool -> Bool -> PreferRepresentation -> [Text] -> Query PayloadJSON (Maybe ResultsWithCount)
- PostgREST.QueryBuilder: requestToQuery :: Schema -> DbRequest -> SqlQuery
+ PostgREST.QueryBuilder: requestToQuery :: Schema -> Bool -> DbRequest -> SqlQuery
- PostgREST.Types: DbStructure :: [Table] -> [Column] -> [Relation] -> [PrimaryKey] -> DbStructure
+ PostgREST.Types: DbStructure :: [Table] -> [Column] -> [Relation] -> [PrimaryKey] -> [(Text, ProcDescription)] -> DbStructure
- PostgREST.Types: Delete :: TableName -> [Filter] -> MutateQuery
+ PostgREST.Types: Delete :: TableName -> [Filter] -> [FieldName] -> MutateQuery
- PostgREST.Types: Insert :: TableName -> Payload -> MutateQuery
+ PostgREST.Types: Insert :: TableName -> PayloadJSON -> [FieldName] -> MutateQuery
- PostgREST.Types: OrderTerm :: Text -> OrderDirection -> Maybe OrderNulls -> OrderTerm
+ PostgREST.Types: OrderTerm :: Field -> Maybe OrderDirection -> Maybe OrderNulls -> OrderTerm
- PostgREST.Types: PayloadJSON :: UniformObjects -> Payload
+ PostgREST.Types: PayloadJSON :: (Vector Object) -> PayloadJSON
- PostgREST.Types: Update :: TableName -> Payload -> [Filter] -> MutateQuery
+ PostgREST.Types: Update :: TableName -> PayloadJSON -> [Filter] -> [FieldName] -> MutateQuery
- PostgREST.Types: [otDirection] :: OrderTerm -> OrderDirection
+ PostgREST.Types: [otDirection] :: OrderTerm -> Maybe OrderDirection
- PostgREST.Types: [otTerm] :: OrderTerm -> Text
+ PostgREST.Types: [otTerm] :: OrderTerm -> Field
- PostgREST.Types: [qPayload] :: MutateQuery -> Payload
+ PostgREST.Types: [qPayload] :: MutateQuery -> PayloadJSON

Files

main/Main.hs view
@@ -2,17 +2,25 @@  module Main where -+import           Protolude import           PostgREST.App import           PostgREST.Config                     (AppConfig (..),+                                                       PgVersion (..),                                                        minimumPgVersion,                                                        prettyVersion,                                                        readOptions)+import           PostgREST.Error                      (prettyUsageError)+import           PostgREST.OpenAPI                    (isMalformedProxyUri) import           PostgREST.DbStructure -import           Control.Monad-import           Data.Monoid                          ((<>))-import           Data.String.Conversions              (cs)+import           Control.AutoUpdate+import           Data.ByteString.Base64               (decode)+import           Data.String                          (IsString (..))+import           Data.Text                            (stripPrefix, pack, replace)+import           Data.Text.Encoding                   (encodeUtf8, decodeUtf8)+import           Data.Text.IO                         (hPutStrLn, readFile)+import           Data.Function                        (id)+import           Data.Time.Clock.POSIX                (getPOSIXTime) import qualified Hasql.Query                          as H import qualified Hasql.Session                        as H import qualified Hasql.Decoders                       as HD@@ -20,25 +28,20 @@ import qualified Hasql.Pool                           as P import           Network.Wai.Handler.Warp import           System.IO                            (BufferMode (..),-                                                       hSetBuffering, stderr,-                                                       stdin, stdout)-import           Web.JWT                              (secret)+                                                       hSetBuffering) import           Data.IORef #ifndef mingw32_HOST_OS-import           Control.Monad.IO.Class               (liftIO) import           System.Posix.Signals-import           Control.Concurrent                   (myThreadId)-import           Control.Exception.Base               (throwTo, AsyncException(..)) #endif  isServerVersionSupported :: H.Session Bool isServerVersionSupported = do   ver <- H.query () pgVersion-  return $ read (cs ver) >= minimumPgVersion+  return $ ver >= pgvNum minimumPgVersion  where   pgVersion =-    H.statement "SHOW server_version_num"-      HE.unit (HD.singleRow $ HD.value HD.text) True+    H.statement "SELECT current_setting('server_version_num')::integer"+      HE.unit (HD.singleRow $ HD.value HD.int4) False  main :: IO () main = do@@ -46,29 +49,36 @@   hSetBuffering stdin  LineBuffering   hSetBuffering stderr NoBuffering -  conf <- readOptions-  let port = configPort conf-      pgSettings = cs (configDatabase conf)-      appSettings = setPort port-                  . setServerName (cs $ "postgrest/" <> prettyVersion)+  conf <- loadSecretFile =<< readOptions+  let host = configHost conf+      port = configPort conf+      proxy = configProxyUri conf+      pgSettings = toS (configDatabase conf)+      appSettings = setHost ((fromString . toS) host)+                  . setPort port+                  . setServerName (toS $ "postgrest/" <> prettyVersion)                   $ defaultSettings -  unless (secret "secret" /= configJwtSecret conf) $-    putStrLn "WARNING, running in insecure mode, JWT secret is the default value"-  Prelude.putStrLn $ "Listening on port " ++-    (show $ configPort conf :: String)+  when (isMalformedProxyUri $ toS <$> proxy) $ panic+    "Malformed proxy uri, a correct example: https://example.com:8443/basePath" +  putStrLn $ ("Listening on port " :: Text) <> show (configPort conf)+   pool <- P.acquire (configPool conf, 10, pgSettings)    result <- P.use pool $ do     supported <- isServerVersionSupported-    unless supported $ error (+    unless supported $ panic (       "Cannot run in this PostgreSQL version, PostgREST needs at least "-      <> show minimumPgVersion)-    getDbStructure (cs $ configSchema conf)+      <> pgvName minimumPgVersion)+    getDbStructure (toS $ configSchema conf) -  refDbStructure <- newIORef $ either (error.show) id result+  forM_ (lefts [result]) $ \e -> do+    hPutStrLn stderr (prettyUsageError e)+    exitFailure +  refDbStructure <- newIORef $ either (panic . show) id result+ #ifndef mingw32_HOST_OS   tid <- myThreadId   forM_ [sigINT, sigTERM] $ \sig ->@@ -79,9 +89,39 @@    void $ installHandler sigHUP (       Catch . void . P.use pool $ do-        s <- getDbStructure (cs $ configSchema conf)+        s <- getDbStructure (toS $ configSchema conf)         liftIO $ atomicWriteIORef refDbStructure s    ) Nothing #endif -  runSettings appSettings $ postgrest conf refDbStructure pool+  -- ask for the OS time at most once per second+  getTime <- mkAutoUpdate+    defaultUpdateSettings { updateAction = getPOSIXTime }++  runSettings appSettings $ postgrest conf refDbStructure pool getTime++loadSecretFile :: AppConfig -> IO AppConfig+loadSecretFile conf = extractAndTransform mSecret+  where+    mSecret   = decodeUtf8 <$> configJwtSecret conf+    isB64     = configJwtSecretIsBase64 conf++    extractAndTransform :: Maybe Text -> IO AppConfig+    extractAndTransform Nothing  = return conf+    extractAndTransform (Just s) =+      fmap setSecret $ transformString isB64 =<<+        case stripPrefix "@" s of+            Nothing       -> return s+            Just filename -> readFile (toS filename)++    transformString :: Bool -> Text -> IO ByteString+    transformString False t = return . encodeUtf8 $ t+    transformString True  t =+      case decode (encodeUtf8 $ replaceUrlChars t) of+        Left errMsg -> panic $ pack errMsg+        Right bs    -> return bs++    setSecret bs = conf { configJwtSecret = Just bs }++    replaceUrlChars = replace "_" "/" . replace "-" "+" . replace "." "="+        
postgrest.cabal view
@@ -2,7 +2,7 @@ description:           Reads the schema of a PostgreSQL database and creates RESTful routes                        for the tables and views, supporting all HTTP verbs that security                        permits.-version:               0.3.2.0+version:               0.4.0.0 synopsis:              REST API for any Postgres database license:               MIT license-file:          LICENSE@@ -23,168 +23,137 @@  executable postgrest   main-is:             Main.hs-  default-extensions:  OverloadedStrings, ScopedTypeVariables, QuasiQuotes+  default-extensions:  OverloadedStrings, QuasiQuotes, NoImplicitPrelude   ghc-options:     -threaded     -rtsopts     "-with-rtsopts=-N -I2"   default-language:    Haskell2010-  build-depends:       aeson (>= 0.8 && < 0.10) || (>= 0.11 && < 0.12)-                     , base >= 4.8 && < 6-                     , bytestring-                     , bytestring-tree-builder == 0.2.7-                     , case-insensitive-                     , cassava-                     , containers-                     , contravariant-                     , errors-                     , hasql == 0.19.12-                     , hasql-pool == 0.4.1-                     , hasql-transaction == 0.4.5-                     , http-types-                     , interpolatedstring-perl6-                     , jwt-                     , microlens >= 0.4.2 && < 0.5-                     , microlens-aeson >= 2.1.1 && < 2.2-                     , mtl-                     , optparse-applicative >= 0.11 && < 0.13-                     , parsec-                     , postgresql-binary == 0.9.0.1+  build-depends:       auto-update+                     , base+                     , hasql+                     , hasql-pool                      , postgrest-                     , regex-tdfa-                     , safe >= 0.3 && < 0.4-                     , scientific-                     , string-conversions+                     , protolude                      , text                      , time-                     , transformers-                     , unordered-containers-                     , vector-                     , wai >= 3.0.1-                     , wai-cors-                     , wai-extra-                     , wai-middleware-static >= 0.6.0-                     , warp >= 3.1.0-                     , HTTP-                     , Ranged-sets+                     , warp+                     , bytestring+                     , base64-bytestring   if !os(windows)-    build-depends:     unix >= 2.7 && < 3+    build-depends:     unix    hs-source-dirs:      main  library   default-language:    Haskell2010-  default-extensions:  OverloadedStrings, ScopedTypeVariables, QuasiQuotes+  default-extensions:  OverloadedStrings, QuasiQuotes, NoImplicitPrelude   build-depends:       aeson-                     , base+                     , ansi-wl-pprint+                     , base >= 4.8 && < 6                      , bytestring                      , case-insensitive                      , cassava+                     , configurator                      , containers                      , contravariant-                     , errors+                     , either                      , hasql-                     , hasql-transaction-                     , hasql-pool+                     , hasql-pool == 0.4.1+                     , hasql-transaction == 0.5+                     , heredoc+                     , HTTP                      , http-types+                     , insert-ordered-containers                      , interpolatedstring-perl6                      , jwt-                     , microlens-                     , microlens-aeson-                     , mtl-                     , optparse-applicative+                     , lens+                     , lens-aeson+                     , network-uri+                     , optparse-applicative >= 0.12.0.0 && < 0.13.0.0                      , parsec+                     , protolude+                     , Ranged-sets == 0.3.0                      , regex-tdfa                      , safe                      , scientific-                     , string-conversions+                     , swagger2                      , text                      , time                      , unordered-containers                      , vector-                     , HTTP-                     , Ranged-sets-                     , wai >= 3.0.1+                     , wai                      , wai-cors                      , wai-extra-                     , wai-middleware-static >= 0.6.0-                     , warp >= 3.1.0+                     , wai-middleware-static    Other-Modules:       Paths_postgrest-  Exposed-Modules:     PostgREST.App+  Exposed-Modules:     PostgREST.ApiRequest+                     , PostgREST.App                      , PostgREST.Auth                      , PostgREST.Config+                     , PostgREST.DbStructure+                     , PostgREST.DbRequestBuilder                      , PostgREST.Error                      , PostgREST.Middleware+                     , PostgREST.OpenAPI                      , PostgREST.Parsers-                     , PostgREST.DbStructure                      , PostgREST.QueryBuilder                      , PostgREST.RangeQuery-                     , PostgREST.ApiRequest                      , PostgREST.Types   hs-source-dirs:      src  Test-Suite spec   Type:                exitcode-stdio-1.0   Default-Language:    Haskell2010-  default-extensions:  OverloadedStrings, ScopedTypeVariables, QuasiQuotes+  default-extensions:  OverloadedStrings, QuasiQuotes, NoImplicitPrelude   ghc-options:         -threaded -rtsopts -with-rtsopts=-N   Hs-Source-Dirs:      test   Main-Is:             Main.hs   Other-Modules:       Feature.AuthSpec+                     , Feature.BinaryJwtSecretSpec                      , Feature.ConcurrentSpec                      , Feature.CorsSpec                      , Feature.DeleteSpec                      , Feature.InsertSpec-                     , Feature.QuerySpec+                     , Feature.NoJwtSpec+                     , Feature.ProxySpec                      , Feature.QueryLimitedSpec+                     , Feature.QuerySpec                      , Feature.RangeSpec+                     , Feature.SingularSpec                      , Feature.StructureSpec                      , Feature.UnicodeSpec                      , SpecHelper                      , TestTypes   Build-Depends:       aeson+                     , aeson-qq                      , async+                     , auto-update                      , base-                     , base64-string                      , bytestring+                     , base64-bytestring                      , case-insensitive                      , cassava                      , containers                      , contravariant-                     , errors                      , hasql                      , hasql-pool-                     , hasql-transaction                      , heredoc+                     , hjsonpointer+                     , hjsonschema                      , hspec                      , hspec-wai                      , hspec-wai-json                      , http-types-                     , interpolatedstring-perl6-                     , jwt-                     , microlens-                     , microlens-aeson+                     , lens+                     , lens-aeson                      , monad-control-                     , mtl-                     , optparse-applicative-                     , parsec                      , postgrest                      , process+                     , protolude                      , regex-tdfa-                     , safe-                     , scientific-                     , string-conversions-                     , text                      , time-                     , transformers                      , transformers-base-                     , unordered-containers-                     , vector                      , wai-                     , wai-cors                      , wai-extra-                     , wai-middleware-static-                     , warp-                     , HTTP-                     , Ranged-sets
src/PostgREST/ApiRequest.hs view
@@ -1,32 +1,44 @@-module PostgREST.ApiRequest where+{-|+Module      : PostgREST.ApiRequest+Description : PostgREST functions to translate HTTP request to a domain type called ApiRequest.+-}+module PostgREST.ApiRequest ( ApiRequest(..)+                            , ApiRequestError(..)+                            , ContentType(..)+                            , Action(..)+                            , Target(..)+                            , PreferRepresentation (..)+                            , mutuallyAgreeable+                            , toHeader+                            , userApiRequest+                            , toMime+                            ) where +import           Protolude import qualified Data.Aeson                as JSON import qualified Data.ByteString           as BS+import qualified Data.ByteString.Internal  as BS (c2w) import qualified Data.ByteString.Lazy      as BL import qualified Data.Csv                  as CSV-import           Data.List                 (find, sortBy)+import qualified Data.List                 as L+import           Data.List                 (lookup, last) import qualified Data.HashMap.Strict       as M import qualified Data.Set                  as S-import           Data.Maybe                (fromMaybe, isJust, isNothing,-                                            listToMaybe, fromJust)+import           Data.Maybe                (fromJust) import           Control.Arrow             ((***))-import           Control.Monad             (join)-import           Data.Monoid               ((<>))-import           Data.Ord                  (comparing)-import           Data.String.Conversions   (cs) import qualified Data.Text                 as T-import           Text.Read                 (readMaybe) import qualified Data.Vector               as V import           Network.HTTP.Base         (urlEncodeVars)-import           Network.HTTP.Types.Header (hAuthorization)+import           Network.HTTP.Types.Header (hAuthorization, hContentType, Header) import           Network.HTTP.Types.URI    (parseSimpleQuery) import           Network.Wai               (Request (..)) import           Network.Wai.Parse         (parseHttpAccept)-import           PostgREST.RangeQuery      (NonnegRange, rangeRequested, restrictRange, rangeGeq, allRange)+import           PostgREST.RangeQuery      (NonnegRange, rangeRequested, restrictRange, rangeGeq, allRange, rangeLimit, rangeOffset)+import           Data.Ranged.Boundaries import           PostgREST.Types           (QualifiedIdentifier (..),-                                            Schema, Payload(..),-                                            UniformObjects(..))-import           Data.Ranged.Ranges        (singletonRange, rangeIntersection)+                                            Schema,+                                            PayloadJSON(..))+import           Data.Ranged.Ranges        (Range(..), rangeIntersection, emptyRange)  type RequestBody = BL.ByteString @@ -34,22 +46,40 @@ data Action = ActionCreate | ActionRead             | ActionUpdate | ActionDelete             | ActionInfo   | ActionInvoke-            | ActionInappropriate+            | ActionInspect             deriving Eq -- | The target db object of a user action data Target = TargetIdent QualifiedIdentifier             | TargetProc  QualifiedIdentifier             | TargetRoot-            | TargetUnknown [T.Text]+            | TargetUnknown [Text]+            deriving Eq -- | How to return the inserted data data PreferRepresentation = Full | HeadersOnly | None deriving Eq--- | Enumeration of currently supported content types for--- route responses and upload payloads-data ContentType = ApplicationJSON | TextCSV deriving Eq-instance Show ContentType where-  show ApplicationJSON = "application/json; charset=utf-8"-  show TextCSV         = "text/csv; charset=utf-8"+                          --+-- | Enumeration of currently supported response content types+data ContentType = CTApplicationJSON | CTTextCSV | CTOpenAPI+                 | CTSingularJSON+                 | CTAny | CTOther BS.ByteString deriving Eq +data ApiRequestError = ErrorActionInappropriate+                     | ErrorInvalidBody ByteString+                     | ErrorInvalidRange+                     deriving (Show, Eq)++-- | Convert from ContentType to a full HTTP Header+toHeader :: ContentType -> Header+toHeader ct = (hContentType, toMime ct <> "; charset=utf-8")++-- | Convert from ContentType to a ByteString representing the mime type+toMime :: ContentType -> ByteString+toMime CTApplicationJSON = "application/json"+toMime CTTextCSV         = "text/csv"+toMime CTOpenAPI         = "application/openapi+json"+toMime CTSingularJSON    = "application/vnd.pgrst.object+json"+toMime CTAny             = "*/*"+toMime (CTOther ct)      = ct+ {-|   Describes what the user wants to do. This data type is a   translation of the raw elements of an HTTP request into domain@@ -61,163 +91,166 @@   -- | Similar but not identical to HTTP verb, e.g. Create/Invoke both POST     iAction :: Action   -- | Requested range of rows within response-  , iRange  :: M.HashMap String NonnegRange+  , iRange  :: M.HashMap ByteString NonnegRange   -- | The target, be it calling a proc or accessing a table   , iTarget :: Target-  -- | The content type the client most desires (or JSON if undecided)-  , iAccepts :: Either BS.ByteString ContentType+  -- | Content types the client will accept, [CTAny] if no Accept header+  , iAccepts :: [ContentType]   -- | Data sent by client and used for mutation actions-  , iPayload :: Maybe Payload+  , iPayload :: Maybe PayloadJSON   -- | If client wants created items echoed back   , iPreferRepresentation :: PreferRepresentation-  -- | If client wants first row as raw object-  , iPreferSingular :: Bool+  -- | Pass all parameters as a single json object to a stored procedure+  , iPreferSingleObjectParameter :: Bool   -- | Whether the client wants a result count (slower)   , iPreferCount :: Bool   -- | Filters on the result ("id", "eq.10")-  , iFilters :: [(String, String)]+  , iFilters :: [(Text, Text)]   -- | &select parameter used to shape the response-  , iSelect :: String+  , iSelect :: Text   -- | &order parameters for each level-  , iOrder :: [(String,String)]+  , iOrder :: [(Text, Text)]   -- | Alphabetized (canonical) request query string for response URLs-  , iCanonicalQS :: String+  , iCanonicalQS :: ByteString   -- | JSON Web Token-  , iJWT :: T.Text+  , iJWT :: Text   }  -- | Examines HTTP request and translates it into user intent.-userApiRequest :: Schema -> Request -> RequestBody -> ApiRequest-userApiRequest schema req reqBody =-  let action =-        if isTargetingProc-          then-            if method == "POST"-               then ActionInvoke-               else ActionInappropriate-          else-            case method of-               "GET"     -> ActionRead-               "POST"    -> ActionCreate-               "PATCH"   -> ActionUpdate-               "DELETE"  -> ActionDelete-               "OPTIONS" -> ActionInfo-               _         -> ActionInappropriate-      target = case path of-                 []            -> TargetRoot-                 [table]       -> TargetIdent-                                  $ QualifiedIdentifier schema table-                 ["rpc", proc] -> TargetProc-                                  $ QualifiedIdentifier schema proc-                 other         -> TargetUnknown other-      payload = case pickContentType (lookupHeader "content-type") of-        Right ApplicationJSON ->-          either (PayloadParseError . cs)-            (\val -> case ensureUniform (pluralize val) of-              Nothing -> PayloadParseError "All object keys must match"-              Just json -> PayloadJSON json)-            (JSON.eitherDecode reqBody)-        Right TextCSV ->-          either (PayloadParseError . cs)-            (\val -> case ensureUniform (csvToJson val) of-              Nothing -> PayloadParseError "All lines must have same number of fields"-              Just json -> PayloadJSON json)-            (CSV.decodeByName reqBody)-        -- This is a Left value because form-urlencoded is not a content-        -- type which we ever use for responses, only something we handle-        -- just this once for requests-        Left "application/x-www-form-urlencoded" ->-          PayloadJSON . UniformObjects . V.singleton . M.fromList-                      . map (cs *** JSON.String . cs) . parseSimpleQuery-                      $ cs reqBody-        Left accept ->-          PayloadParseError $-            "Content-type not acceptable: " <> accept-      relevantPayload = case action of-        ActionCreate -> Just payload-        ActionUpdate -> Just payload-        ActionInvoke -> Just payload-        _            -> Nothing in--  ApiRequest {-    iAction = action-  , iTarget = target-  , iRange = M.insert "limit" (rangeIntersection headerRange urlRange) $-      M.fromList [ (cs k, restrictRange (readMaybe =<< v) allRange) | (k,v) <- qParams, isJust v, endingIn ["limit"] k ]-  , iAccepts = pickContentType $ lookupHeader "accept"-  , iPayload = relevantPayload-  , iPreferRepresentation = representation-  , iPreferSingular = singular-  , iPreferCount = not $ singular || hasPrefer "count=none"-  , iFilters = [ (cs k, fromJust v) | (k,v) <- qParams, isJust v, k /= "select", k /= "offset", not (endingIn ["order", "limit"] k) ]-  , iSelect = fromMaybe "*" $ fromMaybe (Just "*") $ lookup "select" qParams-  , iOrder = [(cs k, fromJust v) | (k,v) <- qParams, isJust v, endingIn ["order"] k ]-  , iCanonicalQS = urlEncodeVars-     . sortBy (comparing fst)-     . map (join (***) cs)-     . parseSimpleQuery-     $ rawQueryString req-  , iJWT = tokenStr-  }-+userApiRequest :: Schema -> Request -> RequestBody -> Either ApiRequestError ApiRequest+userApiRequest schema req reqBody+  | isTargetingProc && method /= "POST" = Left ErrorActionInappropriate+  | topLevelRange == emptyRange = Left ErrorInvalidRange+  | shouldParsePayload && isLeft payload = either (Left . ErrorInvalidBody . toS) undefined payload+  | otherwise = Right ApiRequest {+      iAction = action+      , iTarget = target+      , iRange = ranges+      , iAccepts = fromMaybe [CTAny] $+        map decodeContentType . parseHttpAccept <$> lookupHeader "accept"+      , iPayload = relevantPayload+      , iPreferRepresentation = representation+      , iPreferSingleObjectParameter = singleObject+      , iPreferCount = hasPrefer "count=exact"+      , iFilters = [ (toS k, toS $ fromJust v) | (k,v) <- qParams, isJust v, k /= "select", not (endingIn ["order", "limit", "offset"] k) ]+      , iSelect = toS $ fromMaybe "*" $ fromMaybe (Just "*") $ lookup "select" qParams+      , iOrder = [(toS k, toS $ fromJust v) | (k,v) <- qParams, isJust v, endingIn ["order"] k ]+      , iCanonicalQS = toS $ urlEncodeVars+        . L.sortBy (comparing fst)+        . map (join (***) toS)+        . parseSimpleQuery+        $ rawQueryString req+      , iJWT = tokenStr+      }  where+  isTargetingProc = fromMaybe False $ (== "rpc") <$> listToMaybe path+  payload =+    case decodeContentType . fromMaybe "application/json" $ lookupHeader "content-type" of+      CTApplicationJSON ->+          either Left (\val -> case ensureUniform (pluralize val) of+            Nothing -> Left "All object keys must match"+            Just json -> Right json) (JSON.eitherDecode reqBody)+      CTTextCSV ->+          either Left (\val -> case ensureUniform (csvToJson val) of+            Nothing -> Left "All lines must have same number of fields"+            Just json -> Right json) (CSV.decodeByName reqBody)+      CTOther "application/x-www-form-urlencoded" ->+        Right . PayloadJSON . V.singleton . M.fromList+                    . map (toS *** JSON.String . toS) . parseSimpleQuery+                    $ toS reqBody+      ct ->+        Left $ toS $ "Content-Type not acceptable: " <> toMime ct+  topLevelRange = fromMaybe allRange $ M.lookup "limit" ranges+  action = case method of+            "GET"     -> if target == TargetRoot+                          then ActionInspect+                          else ActionRead+            "POST"    -> if isTargetingProc+                          then ActionInvoke+                          else ActionCreate+            "PATCH"   -> ActionUpdate+            "DELETE"  -> ActionDelete+            "OPTIONS" -> ActionInfo+            _         -> ActionInspect+  target = case path of+              []            -> TargetRoot+              [table]       -> TargetIdent+                              $ QualifiedIdentifier schema table+              ["rpc", proc] -> TargetProc+                              $ QualifiedIdentifier schema proc+              other         -> TargetUnknown other+  shouldParsePayload = action `elem` [ActionCreate, ActionUpdate, ActionInvoke]+  relevantPayload = if shouldParsePayload+                      then rightToMaybe payload+                      else Nothing   path            = pathInfo req   method          = requestMethod req-  isTargetingProc = fromMaybe False $ (== "rpc") <$> listToMaybe path   hdrs            = requestHeaders req-  qParams         = [(cs k, cs <$> v)|(k,v) <- queryString req]+  qParams         = [(toS k, v)|(k,v) <- queryString req]   lookupHeader    = flip lookup hdrs-  hasPrefer :: T.Text -> Bool+  hasPrefer :: Text -> Bool   hasPrefer val   = any (\(h,v) -> h == "Prefer" && val `elem` split v) hdrs     where-        split :: BS.ByteString -> [T.Text]-        split = map T.strip . T.split (==';') . cs-  singular        = hasPrefer "plurality=singular"+        split :: BS.ByteString -> [Text]+        split = map T.strip . T.split (==',') . toS+  singleObject    = hasPrefer "params=single-object"   representation     | hasPrefer "return=representation" = Full     | hasPrefer "return=minimal" = None     | otherwise = HeadersOnly   auth = fromMaybe "" $ lookupHeader hAuthorization-  tokenStr = case T.split (== ' ') (cs auth) of+  tokenStr = case T.split (== ' ') (toS auth) of     ("Bearer" : t : _) -> t     _                  -> ""-  endingIn:: [T.Text] -> T.Text -> Bool+  endingIn:: [Text] -> Text -> Bool   endingIn xx key = lastWord `elem` xx     where lastWord = last $ T.split (=='.') key -  headerRange = if singular then singletonRange 0 else rangeRequested hdrs-  urlOffsetRange = rangeGeq . fromMaybe (0::Integer) $-    readMaybe =<< join (lookup "offset" qParams)-  urlRange = restrictRange-    (readMaybe =<< join (lookup "limit" qParams))-    urlOffsetRange+  headerRange = rangeRequested hdrs+  replaceLast x s = T.intercalate "." $ L.init (T.split (=='.') s) ++ [x]+  limitParams :: M.HashMap ByteString NonnegRange+  limitParams  = M.fromList [(toS (replaceLast "limit" k), restrictRange (readMaybe =<< (toS <$> v)) allRange) | (k,v) <- qParams, isJust v, endingIn ["limit"] k]+  offsetParams :: M.HashMap ByteString NonnegRange+  offsetParams = M.fromList [(toS (replaceLast "limit" k), fromMaybe allRange (rangeGeq <$> (readMaybe =<< (toS <$> v)))) | (k,v) <- qParams, isJust v, endingIn ["offset"] k] --- PRIVATE ---------------------------------------------------------------+  urlRange = M.unionWith f limitParams offsetParams+    where+      f rl ro = Range (BoundaryBelow o) (BoundaryAbove $ o + l - 1)+        where+          l = fromMaybe 0 $ rangeLimit rl+          o = rangeOffset ro+  ranges = M.insert "limit" (rangeIntersection headerRange (fromMaybe allRange (M.lookup "limit" urlRange))) urlRange  {-|-  Picks a preferred content type from an Accept header (or from-  Content-Type as a degenerate case).+  Find the best match from a list of content types accepted by the+  client in order of decreasing preference and a list of types+  producible by the server.  If there is no match but the client+  accepts */* then return the top server pick.+-}+mutuallyAgreeable :: [ContentType] -> [ContentType] -> Maybe ContentType+mutuallyAgreeable sProduces cAccepts =+  let exact = listToMaybe $ L.intersect cAccepts sProduces in+  if isNothing exact && CTAny `elem` cAccepts+     then listToMaybe sProduces+     else exact -  For example-  text/csv -> TextCSV-  */*      -> ApplicationJSON-  text/csv, application/json -> TextCSV-  application/json, text/csv -> ApplicationJSON+-- PRIVATE ---------------------------------------------------------------++{-|+  Warning: discards MIME parameters -}-pickContentType :: Maybe BS.ByteString -> Either BS.ByteString ContentType-pickContentType accept-  | isNothing accept || has ctAll || has ctJson = Right ApplicationJSON-  | has ctCsv = Right TextCSV-  | otherwise = Left accept'- where-  ctAll  = "*/*"-  ctCsv  = "text/csv"-  ctJson = "application/json"-  Just accept' = accept-  findInAccept = flip find $ parseHttpAccept accept'-  has          = isJust . findInAccept . BS.isPrefixOf+decodeContentType :: BS.ByteString -> ContentType+decodeContentType ct =+  case BS.takeWhile (/= BS.c2w ';') ct of+    "application/json"                  -> CTApplicationJSON+    "text/csv"                          -> CTTextCSV+    "application/openapi+json"          -> CTOpenAPI+    "application/vnd.pgrst.object+json" -> CTSingularJSON+    "application/vnd.pgrst.object"      -> CTSingularJSON+    "*/*"                               -> CTAny+    ct'                                 -> CTOther ct' -type CsvData = V.Vector (M.HashMap T.Text BL.ByteString)+type CsvData = V.Vector (M.HashMap Text BL.ByteString)  {-|   Converts CSV like@@ -239,7 +272,7 @@     M.map (\str ->         if str == "NULL"           then JSON.Null-          else JSON.String $ cs str+          else JSON.String $ toS str       )  -- | Convert {foo} to [{foo}], leave arrays unchanged@@ -250,8 +283,8 @@ pluralize _                   = V.empty  -- | Test that Array contains only Objects having the same keys--- and if so mark it as UniformObjects-ensureUniform :: JSON.Array -> Maybe UniformObjects+-- and if so mark it as PayloadJSON+ensureUniform :: JSON.Array -> Maybe PayloadJSON ensureUniform arr =   let objs :: V.Vector JSON.Object       objs = foldr -- filter non-objects, map to raw objects@@ -264,5 +297,5 @@       areKeysUniform = all (==canonicalKeys) keysPerObj in    if (V.length objs == V.length arr) && areKeysUniform-    then Just (UniformObjects objs)+    then Just (PayloadJSON objs)     else Nothing
src/PostgREST/App.hs view
@@ -7,82 +7,76 @@ ) where  import           Control.Applicative-import           Data.Bifunctor            (first)-import qualified Data.ByteString.Char8   as BS+import qualified Data.ByteString.Char8     as BS import           Data.IORef                (IORef, readIORef)-import           Data.List                 (find, delete)-import           Data.Maybe                (fromMaybe, fromJust, mapMaybe)-import           Data.Ranged.Ranges        (emptyRange)-import           Data.String.Conversions   (cs)-import           Data.Text                 (Text, replace, strip)-import           Data.Tree--import qualified Hasql.Pool                as P-import qualified Hasql.Transaction         as HT+import           Data.Text                 (intercalate)+import           Data.Time.Clock.POSIX     (POSIXTime) -import           Text.Parsec.Error-import           Text.ParserCombinators.Parsec (parse)+import qualified Hasql.Pool                 as P+import qualified Hasql.Transaction          as HT+import qualified Hasql.Transaction.Sessions as HT  import           Network.HTTP.Types.Header import           Network.HTTP.Types.Status-import           Network.HTTP.Types.URI (renderSimpleQuery)+import           Network.HTTP.Types.URI    (renderSimpleQuery) import           Network.Wai import           Network.Wai.Middleware.RequestLogger (logStdout)+import           Web.JWT                   (binarySecret) -import           Data.Aeson-import           Data.Aeson.Types (emptyArray)-import           Data.Monoid-import           Data.Time.Clock.POSIX     (getPOSIXTime) import qualified Data.Vector               as V import qualified Hasql.Transaction         as H  import qualified Data.HashMap.Strict       as M -import           PostgREST.ApiRequest   (ApiRequest(..), ContentType(..)-                                            , Action(..), Target(..)-                                            , PreferRepresentation (..)-                                            , userApiRequest)-import           PostgREST.Auth            (tokenJWT, jwtClaims, containsRole)+import           PostgREST.ApiRequest   ( ApiRequest(..), ContentType(..)+                                        , Action(..), Target(..)+                                        , PreferRepresentation (..)+                                        , mutuallyAgreeable+                                        , toHeader+                                        , userApiRequest+                                        , toMime+                                        )+import           PostgREST.Auth            (jwtClaims, containsRole) import           PostgREST.Config          (AppConfig (..)) import           PostgREST.DbStructure-import           PostgREST.Error           (errResponse, pgErrResponse)-import           PostgREST.Parsers-import           PostgREST.RangeQuery      (NonnegRange, allRange, rangeOffset, restrictRange)+import           PostgREST.DbRequestBuilder(readRequest, mutateRequest)+import           PostgREST.Error           (errResponse, pgErrResponse, apiRequestErrResponse, singularityError)+import           PostgREST.RangeQuery      (allRange, rangeOffset) import           PostgREST.Middleware import           PostgREST.QueryBuilder ( callProc-                                        , addJoinConditions-                                        , sourceCTEName                                         , requestToQuery                                         , requestToCountQuery-                                        , addRelations                                         , createReadStatement                                         , createWriteStatement                                         , ResultsWithCount                                         ) import           PostgREST.Types--import           Prelude+import           PostgREST.OpenAPI +import           Data.Function (id)+import           Protolude                hiding (intercalate, Proxy) -postgrest :: AppConfig -> IORef DbStructure -> P.Pool -> Application-postgrest conf refDbStructure pool =+postgrest :: AppConfig -> IORef DbStructure -> P.Pool -> IO POSIXTime ->+             Application+postgrest conf refDbStructure pool getTime =   let middle = (if configQuiet conf then id else logStdout) . defaultMiddle in    middle $ \ req respond -> do-    time <- getPOSIXTime+    time <- getTime     body <- strictRequestBody req     dbStructure <- readIORef refDbStructure -    let schema = cs $ configSchema conf-        apiRequest = userApiRequest schema req body-        eClaims = jwtClaims (configJwtSecret conf) (iJWT apiRequest) time-        authed = containsRole eClaims-        handleReq = runWithClaims conf eClaims (app dbStructure conf) apiRequest-        txMode = transactionMode $ iAction apiRequest--    resp <- either (pgErrResponse authed) id <$> P.use pool-      (HT.run handleReq HT.ReadCommitted txMode)-    respond resp+    response <- case userApiRequest (configSchema conf) req body of+      Left err -> return $ apiRequestErrResponse err+      Right apiRequest -> do+        let jwtSecret = binarySecret <$> configJwtSecret conf+            eClaims = jwtClaims jwtSecret (iJWT apiRequest) time+            authed = containsRole eClaims+            handleReq = runWithClaims conf eClaims (app dbStructure conf) apiRequest+            txMode = transactionMode $ iAction apiRequest+        response <- P.use pool $ HT.transaction HT.ReadCommitted txMode handleReq+        return $ either (pgErrResponse authed) identity response+    respond response  transactionMode :: Action -> H.Mode transactionMode ActionRead = HT.Read@@ -91,157 +85,205 @@  app :: DbStructure -> AppConfig -> ApiRequest -> H.Transaction Response app dbStructure conf apiRequest =-  let-      -- TODO: blow up for Left values (there is a middleware that checks the headers)-      contentType = either (const ApplicationJSON) id (iAccepts apiRequest)-      contentTypeH = (hContentType, cs $ show contentType) in--  case (iAction apiRequest, iTarget apiRequest, iPayload apiRequest) of+  case responseContentTypeOrError (iAccepts apiRequest) (iAction apiRequest) of+    Left errorResponse -> return errorResponse+    Right contentType ->+      case (iAction apiRequest, iTarget apiRequest, iPayload apiRequest) of -    (ActionRead, TargetIdent qi, Nothing) ->-      case readSqlParts of-        Left e -> return $ responseLBS status400 [jsonH] $ cs e-        Right (q, cq) -> do-          let singular = iPreferSingular apiRequest-              stm = createReadStatement q cq singular-                    shouldCount (contentType == TextCSV)-          respondToRange $ do-            row <- H.query () stm-            let (tableTotal, queryTotal, _ , body) = row-            if singular-            then return $ if queryTotal <= 0-              then responseLBS status404 [] ""-              else responseLBS status200 [contentTypeH] (cs body)-            else do-              let (status, contentRange) = rangeHeader queryTotal tableTotal+        (ActionRead, TargetIdent qi, Nothing) ->+          case readSqlParts of+            Left errorResponse -> return errorResponse+            Right (q, cq) -> do+              let stm = createReadStatement q cq (contentType == CTSingularJSON) shouldCount (contentType == CTTextCSV)+              row <- H.query () stm+              let (tableTotal, queryTotal, _ , body) = row+                  (status, contentRange) = rangeHeader queryTotal tableTotal                   canonical = iCanonicalQS apiRequest-              return $ responseLBS status-                [contentTypeH, contentRange,-                  ("Content-Location",-                    "/" <> cs (qiName qi) <>-                      if Prelude.null canonical then "" else "?" <> cs canonical-                  )-                ] (cs body)--    (ActionCreate, TargetIdent qi@(QualifiedIdentifier _ table),-     Just payload@(PayloadJSON uniform@(UniformObjects rows))) ->-      case mutateSqlParts of-        Left e -> return $ responseLBS status400 [jsonH] $ cs e-        Right (sq,mq) -> do-          let isSingle = (==1) $ V.length rows-          let pKeys = map pkName $ filter (filterPk schema table) allPrKeys -- would it be ok to move primary key detection in the query itself?-          let stm = createWriteStatement qi sq mq isSingle (iPreferRepresentation apiRequest) pKeys (contentType == TextCSV) payload-          row <- H.query uniform stm-          let (_, _, fs, body) = extractQueryResult row-              header =-                if null fs then []-                else [(hLocation, "/" <> cs table <> renderLocationFields fs)]+              return $+                if contentType == CTSingularJSON && queryTotal /= 1+                  then singularityError (toInteger queryTotal)+                  else responseLBS status+                    [toHeader contentType, contentRange,+                      ("Content-Location",+                        "/" <> toS (qiName qi) <>+                          if BS.null canonical then "" else "?" <> toS canonical+                      )+                    ] (toS body) -          return $ if iPreferRepresentation apiRequest == Full-            then responseLBS status201 (contentTypeH : header) (cs body)-            else responseLBS status201 header ""+        (ActionCreate, TargetIdent (QualifiedIdentifier _ table), Just payload@(PayloadJSON rows)) ->+          case mutateSqlParts of+            Left errorResponse -> return errorResponse+            Right (sq, mq) -> do+              let isSingle = (==1) $ V.length rows+              if contentType == CTSingularJSON+                 && not isSingle+                 && iPreferRepresentation apiRequest == Full+                then return $ singularityError (toInteger $ V.length rows)+                else do+                  let pKeys = map pkName $ filter (filterPk schema table) allPrKeys -- would it be ok to move primary key detection in the query itself?+                      stm = createWriteStatement sq mq+                        (contentType == CTSingularJSON) isSingle+                        (contentType == CTTextCSV) (iPreferRepresentation apiRequest)+                        pKeys+                  row <- H.query payload stm+                  let (_, _, fs, body) = extractQueryResult row+                      headers = catMaybes [+                          if null fs+                            then Nothing+                            else Just (hLocation, "/" <> toS table <> renderLocationFields fs)+                        , if iPreferRepresentation apiRequest == Full+                            then Just $ toHeader contentType+                            else Nothing+                        , Just . contentRangeH 1 0 $+                            toInteger <$> if shouldCount then Just (V.length rows) else Nothing+                        ] -    (ActionUpdate, TargetIdent qi, Just payload@(PayloadJSON uniform)) ->-      case mutateSqlParts of-        Left e -> return $ responseLBS status400 [jsonH] $ cs e-        Right (sq,mq) -> do-          let stm = createWriteStatement qi sq mq False (iPreferRepresentation apiRequest) [] (contentType == TextCSV) payload-          row <- H.query uniform stm-          let (_, queryTotal, _, body) = extractQueryResult row-              r = contentRangeH 0 (toInteger $ queryTotal-1) (toInteger <$> Just queryTotal)-              s = case () of _ | queryTotal == 0 -> status404-                               | iPreferRepresentation apiRequest == Full -> status200-                               | otherwise -> status204-          return $ if iPreferRepresentation apiRequest == Full-            then responseLBS s [contentTypeH, r] (cs body)-            else responseLBS s [r] ""+                  return . responseLBS status201 headers $+                    if iPreferRepresentation apiRequest == Full+                      then toS body else "" -    (ActionDelete, TargetIdent qi, Nothing) ->-      case mutateSqlParts of-        Left e -> return $ responseLBS status400 [jsonH] $ cs e-        Right (sq,mq) -> do-          let emptyUniform = UniformObjects V.empty-              fakeload = PayloadJSON emptyUniform-              stm = createWriteStatement qi sq mq False (iPreferRepresentation apiRequest) [] (contentType == TextCSV) fakeload-          row <- H.query emptyUniform stm-          let (_, queryTotal, _, body) = extractQueryResult row-              r = contentRangeH 1 0 (toInteger <$> Just queryTotal)-          return $ if queryTotal == 0-            then notFound-            else if iPreferRepresentation apiRequest == Full-              then responseLBS status200 [contentTypeH, r] (cs body)-              else responseLBS status204 [r] ""+        (ActionUpdate, TargetIdent _, Just payload) ->+          case mutateSqlParts of+            Left errorResponse -> return errorResponse+            Right (sq, mq) -> do+              let stm = createWriteStatement sq mq+                    (contentType == CTSingularJSON) False (contentType == CTTextCSV)+                    (iPreferRepresentation apiRequest) []+              row <- H.query payload stm+              let (_, queryTotal, _, body) = extractQueryResult row+              if contentType == CTSingularJSON+                 && queryTotal /= 1+                 && iPreferRepresentation apiRequest == Full+                then do+                  HT.condemn+                  return $ singularityError (toInteger queryTotal)+                else do+                  let r = contentRangeH 0 (toInteger $ queryTotal-1)+                            (toInteger <$> if shouldCount then Just queryTotal else Nothing)+                      s = if iPreferRepresentation apiRequest == Full+                            then status200+                            else status204+                  return $ if iPreferRepresentation apiRequest == Full+                    then responseLBS s [toHeader contentType, r] (toS body)+                    else responseLBS s [r] "" -    (ActionInfo, TargetIdent (QualifiedIdentifier tSchema tTable), Nothing) ->-      let mTable = find (\t -> tableName t == tTable && tableSchema t == tSchema) (dbTables dbStructure) in-      case mTable of-        Nothing -> return notFound-        Just table ->-          let cols = filter (filterCol tSchema tTable) $ dbColumns dbStructure-              pkeys = map pkName $ filter (filterPk tSchema tTable) allPrKeys-              body = encode (TableOptions cols pkeys)-              filterCol :: Schema -> TableName -> Column -> Bool-              filterCol sc tb Column{colTable=Table{tableSchema=s, tableName=t}} = s==sc && t==tb-              filterCol _ _ _ =  False-              acceptH = (hAllow, if tableInsertable table then "GET,POST,PATCH,DELETE" else "GET") in-          return $ responseLBS status200 [jsonH, allOrigins, acceptH] $ cs body+        (ActionDelete, TargetIdent _, Nothing) ->+          case mutateSqlParts of+            Left errorResponse -> return errorResponse+            Right (sq, mq) -> do+              let emptyPayload = PayloadJSON V.empty+                  stm = createWriteStatement sq mq+                    (contentType == CTSingularJSON) False+                    (contentType == CTTextCSV)+                    (iPreferRepresentation apiRequest) []+              row <- H.query emptyPayload stm+              let (_, queryTotal, _, body) = extractQueryResult row+                  r = contentRangeH 1 0 $+                        toInteger <$> if shouldCount then Just queryTotal else Nothing+              if contentType == CTSingularJSON+                 && queryTotal /= 1+                 && iPreferRepresentation apiRequest == Full+                then do+                  HT.condemn+                  return $ singularityError (toInteger queryTotal)+                else+                  return $ if iPreferRepresentation apiRequest == Full+                    then responseLBS status200 [toHeader contentType, r] (toS body)+                    else responseLBS status204 [r] "" -    (ActionInvoke, TargetProc qi,-     Just (PayloadJSON (UniformObjects payload))) -> do-      exists <- H.query qi doesProcExist-      if exists-        then do-          let p = V.head payload-              jwtSecret = configJwtSecret conf-          respondToRange $ do-            row <- H.query () (callProc qi p topLevelRange shouldCount)-            returnJWT <- H.query qi doesProcReturnJWT-            let (tableTotal, queryTotal, body) = fromMaybe (Just 0, 0, emptyArray) row-                (status, contentRange) = rangeHeader queryTotal tableTotal-              in-              return $ responseLBS status [jsonH, contentRange]-                      (if returnJWT-                      then "{\"token\":\"" <> cs (tokenJWT jwtSecret body) <> "\"}"-                      else cs $ encode body)-        else return notFound+        (ActionInfo, TargetIdent (QualifiedIdentifier tSchema tTable), Nothing) ->+          let mTable = find (\t -> tableName t == tTable && tableSchema t == tSchema) (dbTables dbStructure) in+          case mTable of+            Nothing -> return notFound+            Just table ->+              let acceptH = (hAllow, if tableInsertable table then "GET,POST,PATCH,DELETE" else "GET") in+              return $ responseLBS status200 [allOrigins, acceptH] "" -    (ActionRead, TargetRoot, Nothing) -> do-      body <- encode <$> H.query schema accessibleTables-      return $ responseLBS status200 [jsonH] $ cs body+        (ActionInvoke, TargetProc qi, Just (PayloadJSON payload)) ->+          case readSqlParts of+            Left errorResponse -> return errorResponse+            Right (q, cq) -> do+              let p = V.head payload+                  singular = contentType == CTSingularJSON+                  paramsAsSingleObject = iPreferSingleObjectParameter apiRequest+              row <- H.query () (callProc qi p q cq topLevelRange shouldCount singular paramsAsSingleObject)+              let (tableTotal, queryTotal, body) =+                    fromMaybe (Just 0, 0, "[]") row+                  (status, contentRange) = rangeHeader queryTotal tableTotal+              if singular && queryTotal /= 1+                then do+                  HT.condemn+                  return $ singularityError (toInteger queryTotal)+                else return $ responseLBS status [jsonH, contentRange] (toS body) -    (ActionInappropriate, _, _) -> return $ responseLBS status405 [] ""+        (ActionInspect, TargetRoot, Nothing) -> do+          let host = configHost conf+              port = toInteger $ configPort conf+              proxy = pickProxy $ toS <$> configProxyUri conf+              uri Nothing = ("http", host, port, "/")+              uri (Just Proxy { proxyScheme = s, proxyHost = h, proxyPort = p, proxyPath = b }) = (s, h, p, b)+              uri' = uri proxy+              encodeApi ti = encodeOpenAPI (map snd $ dbProcs dbStructure) ti uri'+          body <- encodeApi . toTableInfo <$> H.query schema accessibleTables+          return $ responseLBS status200 [toHeader CTOpenAPI] $ toS body -    (_, _, Just (PayloadParseError e)) ->-      return $ responseLBS status400 [jsonH] $-        cs (formatGeneralError "Cannot parse request payload" (cs e))+        _ -> return notFound -    (_, TargetUnknown _, _) -> return notFound+    where+      toTableInfo :: [Table] -> [(Table, [Column], [Text])]+      toTableInfo = map (\t ->+        let tSchema = tableSchema t+            tTable = tableName t+            cols = filter (filterCol tSchema tTable) $ dbColumns dbStructure+            pkeys = map pkName $ filter (filterPk tSchema tTable) allPrKeys+        in (t, cols, pkeys))+      notFound = responseLBS status404 [] ""+      filterPk sc table pk = sc == (tableSchema . pkTable) pk && table == (tableName . pkTable) pk+      filterCol :: Schema -> TableName -> Column -> Bool+      filterCol sc tb Column{colTable=Table{tableSchema=s, tableName=t}} = s==sc && t==tb+      filterCol _ _ _ =  False+      allPrKeys = dbPrimaryKeys dbStructure+      allOrigins = ("Access-Control-Allow-Origin", "*") :: Header+      jsonH = toHeader CTApplicationJSON+      shouldCount = iPreferCount apiRequest+      schema = toS $ configSchema conf+      topLevelRange = fromMaybe allRange $ M.lookup "limit" $ iRange apiRequest+      rangeHeader queryTotal tableTotal =+        let lower = rangeOffset topLevelRange+            upper = lower + toInteger queryTotal - 1+            contentRange = contentRangeH lower upper (toInteger <$> tableTotal)+            status = rangeStatus lower upper (toInteger <$> tableTotal)+        in (status, contentRange) -    (_, _, _) -> return notFound+      mapSnd f (a, b) = (a, f b)+      readReq = readRequest (configMaxRows conf) (dbRelations dbStructure) (map (mapSnd pdReturnType) $ dbProcs dbStructure) apiRequest+      readDbRequest = DbRead <$> readReq+      mutateDbRequest = DbMutate <$> (mutateRequest apiRequest =<< readReq)+      selectQuery = requestToQuery schema False <$> readDbRequest+      mutateQuery = requestToQuery schema False <$> mutateDbRequest+      countQuery = requestToCountQuery schema <$> readDbRequest+      readSqlParts = (,) <$> selectQuery <*> countQuery+      mutateSqlParts = (,) <$> selectQuery <*> mutateQuery - where-  notFound = responseLBS status404 [] ""-  filterPk sc table pk = sc == (tableSchema . pkTable) pk && table == (tableName . pkTable) pk-  allPrKeys = dbPrimaryKeys dbStructure-  allOrigins = ("Access-Control-Allow-Origin", "*") :: Header-  schema = cs $ configSchema conf-  shouldCount = iPreferCount apiRequest-  topLevelRange = fromMaybe allRange $ M.lookup "limit" $ iRange apiRequest-  readDbRequest = DbRead <$> buildReadRequest (configMaxRows conf) (dbRelations dbStructure) apiRequest-  mutateDbRequest = DbMutate <$> buildMutateRequest apiRequest-  selectQuery = requestToQuery schema <$> readDbRequest-  countQuery = requestToCountQuery schema <$> readDbRequest-  mutateQuery = requestToQuery schema <$> mutateDbRequest-  readSqlParts = (,) <$> selectQuery <*> countQuery-  mutateSqlParts = (,) <$> selectQuery <*> mutateQuery-  respondToRange response = if topLevelRange == emptyRange-                            then return $ errResponse status416 "HTTP Range error"-                            else response-  rangeHeader queryTotal tableTotal = let frm = rangeOffset topLevelRange-                                          to = frm + toInteger queryTotal - 1-                                          contentRange = contentRangeH frm to (toInteger <$> tableTotal)-                                          status = rangeStatus frm to (toInteger <$> tableTotal)-                                      in (status, contentRange)+responseContentTypeOrError :: [ContentType] -> Action -> Either Response ContentType+responseContentTypeOrError accepts action = serves contentTypesForRequest accepts+  where+    contentTypesForRequest =+      case action of+        ActionRead ->    [CTApplicationJSON, CTSingularJSON, CTTextCSV]+        ActionCreate ->  [CTApplicationJSON, CTSingularJSON, CTTextCSV]+        ActionUpdate ->  [CTApplicationJSON, CTSingularJSON, CTTextCSV]+        ActionDelete ->  [CTApplicationJSON, CTSingularJSON, CTTextCSV]+        ActionInvoke ->  [CTApplicationJSON, CTSingularJSON]+        ActionInspect -> [CTOpenAPI]+        ActionInfo ->    [CTTextCSV]+    serves sProduces cAccepts =+      case mutuallyAgreeable sProduces cAccepts of+        Nothing -> do+          let failed = intercalate ", " $ map (toS . toMime) cAccepts+          Left $ errResponse status415 $+            "None of these Content-Types are available: " <> failed+        Just ct -> Right ct  splitKeyValue :: BS.ByteString -> (BS.ByteString, BS.ByteString) splitKeyValue kv = (k, BS.tail v)@@ -253,186 +295,22 @@  rangeStatus :: Integer -> Integer -> Maybe Integer -> Status rangeStatus _ _ Nothing = status200-rangeStatus frm to (Just total)-  | frm > total            = status416-  | (1 + to - frm) < total = status206+rangeStatus lower upper (Just total)+  | lower > total            = status416+  | (1 + upper - lower) < total = status206   | otherwise               = status200  contentRangeH :: Integer -> Integer -> Maybe Integer -> Header-contentRangeH frm to total =-    ("Content-Range", cs headerValue)+contentRangeH lower upper total =+    ("Content-Range", headerValue)     where       headerValue   = rangeString <> "/" <> totalString       rangeString-        | totalNotZero && fromInRange = show frm <> "-" <> cs (show to)+        | totalNotZero && fromInRange = show lower <> "-" <> show upper         | otherwise = "*"       totalString   = fromMaybe "*" (show <$> total)       totalNotZero  = fromMaybe True ((/=) 0 <$> total)-      fromInRange   = frm <= to--jsonH :: Header-jsonH = (hContentType, "application/json; charset=utf-8")--formatRelationError :: Text -> Text-formatRelationError = formatGeneralError-  "could not find foreign keys between these entities"--formatParserError :: ParseError -> Text-formatParserError e = formatGeneralError message details-  where-     message = cs $ show (errorPos e)-     details = strip $ replace "\n" " " $ cs-       $ showErrorMessages "or" "unknown parse error" "expecting" "unexpected" "end of input" (errorMessages e)--formatGeneralError :: Text -> Text -> Text-formatGeneralError message details = cs $ encode $ object [-  "message" .= message,-  "details" .= details]--augumentRequestWithJoin :: Schema ->  [Relation] ->  ReadRequest -> Either Text ReadRequest-augumentRequestWithJoin schema allRels request =-  (first formatRelationError . addRelations schema allRels Nothing) request-  >>= addJoinConditions schema--addFiltersOrdersRanges :: ApiRequest -> Either ParseError (ReadRequest -> ReadRequest)-addFiltersOrdersRanges apiRequest = foldr1 (liftA2 (.)) [-    flip (foldr addFilter) <$> filters,-    flip (foldr addOrder) <$> orders,-    flip (foldr addRange) <$> ranges-  ]-  {--  The esence of what is going on above is that we are composing tree functions-  of type (ReadRequest->ReadRequest) that are in (Either ParseError a) context-  -}-  where-    filters :: Either ParseError [(Path, Filter)]-    filters = mapM pRequestFilter flts-      where-        action = iAction apiRequest-        flts = if action == ActionRead-          then iFilters apiRequest-          else filter (( '.' `elem` ) . fst) $ iFilters apiRequest -- there can be no filters on the root table whre we are doing insert/update-    orders :: Either ParseError [(Path, [OrderTerm])]-    orders = mapM pRequestOrder $ iOrder apiRequest-    ranges :: Either ParseError [(Path, NonnegRange)]-    ranges = mapM pRequestRange $ M.toList $ iRange apiRequest--treeRestrictRange :: Maybe Integer -> ReadRequest -> Either Text ReadRequest-treeRestrictRange maxRows_ request = pure $ nodeRestrictRange maxRows_ `fmap` request-  where-    nodeRestrictRange :: Maybe Integer -> ReadNode -> ReadNode-    nodeRestrictRange m (q@Select {range_=r}, i) = (q{range_=restrictRange m r }, i)--buildReadRequest :: Maybe Integer -> [Relation] -> ApiRequest -> Either Text ReadRequest-buildReadRequest maxRows allRels apiRequest  =-  treeRestrictRange maxRows =<<-  augumentRequestWithJoin schema relations =<<-  first formatParserError readRequest-  where-    (schema, rootTableName) = fromJust $ -- Make it safe-      let target = iTarget apiRequest in-      case target of-        (TargetIdent (QualifiedIdentifier s t) ) -> Just (s, t)-        _ -> Nothing--    action :: Action-    action = iAction apiRequest--    readRequest :: Either ParseError ReadRequest-    readRequest = addFiltersOrdersRanges apiRequest <*>-      parse (pRequestSelect rootName) ("failed to parse select parameter <<"++selStr++">>") selStr-      where-        selStr = iSelect apiRequest-        rootName = if action == ActionRead-          then rootTableName-          else sourceCTEName--    relations :: [Relation]-    relations = case action of-      ActionCreate -> fakeSourceRelations ++ allRels-      ActionUpdate -> fakeSourceRelations ++ allRels-      ActionDelete -> fakeSourceRelations ++ allRels-      _       -> allRels-      where fakeSourceRelations = mapMaybe (toSourceRelation rootTableName) allRels -- see comment in toSourceRelation--buildMutateRequest :: ApiRequest -> Either Text MutateRequest-buildMutateRequest apiRequest = case action of-  ActionCreate -> Insert rootTableName <$> pure payload-  ActionUpdate -> Update rootTableName <$> pure payload <*> filters-  ActionDelete -> Delete rootTableName <$> filters-  _        -> Left "Unsupported HTTP verb"-  where-    action = iAction apiRequest-    payload = fromJust $ iPayload apiRequest-    rootTableName = -- TODO: Make it safe-      let target = iTarget apiRequest in-      case target of-        (TargetIdent (QualifiedIdentifier _ t) ) -> t-        _ -> undefined-    filters = first formatParserError $ map snd <$> mapM pRequestFilter mutateFilters-      where mutateFilters = filter (not . ( '.' `elem` ) . fst) $ iFilters apiRequest -- update/delete filters can be only on the root table--addFilterToNode :: Filter -> ReadRequest -> ReadRequest-addFilterToNode flt (Node (q@Select {flt_=flts}, i) f) = Node (q {flt_=flt:flts}, i) f--addFilter :: (Path, Filter) -> ReadRequest -> ReadRequest-addFilter = addProperty addFilterToNode--addOrderToNode :: [OrderTerm] -> ReadRequest -> ReadRequest-addOrderToNode o (Node (q,i) f) = Node (q{order=Just o}, i) f--addOrder :: (Path, [OrderTerm]) -> ReadRequest -> ReadRequest-addOrder = addProperty addOrderToNode--addRangeToNode :: NonnegRange -> ReadRequest -> ReadRequest-addRangeToNode r (Node (q,i) f) = Node (q{range_=r}, i) f--addRange :: (Path, NonnegRange) -> ReadRequest -> ReadRequest-addRange = addProperty addRangeToNode--addProperty :: (a -> ReadRequest -> ReadRequest) -> (Path, a) -> ReadRequest -> ReadRequest-addProperty f ([], a) n = f a n-addProperty f (path, a) (Node rn forest) =-  case targetNode of-    Nothing -> Node rn forest -- the property is silenty dropped in the Request does not contain the required path-    Just tn -> Node rn (addProperty f (remainingPath, a) tn:restForest)-  where-    targetNodeName:remainingPath = path-    (targetNode,restForest) = splitForest targetNodeName forest-    splitForest :: NodeName -> Forest ReadNode -> (Maybe ReadRequest, Forest ReadNode)-    splitForest name forst =-      case maybeNode of-        Nothing -> (Nothing,forest)-        Just node -> (Just node, delete node forest)-      where-        maybeNode :: Maybe ReadRequest-        maybeNode = find fnd forst-          where-            fnd :: ReadRequest -> Bool-            fnd (Node (_,(n,_,_)) _) = n == name---- in a relation where one of the tables mathces "TableName"--- replace the name to that table with pg_source--- this "fake" relations is needed so that in a mutate query--- we can look a the "returning *" part which is wrapped with a "with"--- as just another table that has relations with other tables-toSourceRelation :: TableName -> Relation -> Maybe Relation-toSourceRelation mt r@(Relation t _ ft _ _ rt _ _)-  | mt == tableName t = Just $ r {relTable=t {tableName=sourceCTEName}}-  | mt == tableName ft = Just $ r {relFTable=t {tableName=sourceCTEName}}-  | Just mt == (tableName <$> rt) = Just $ r {relLTable=(\tbl -> tbl {tableName=sourceCTEName}) <$> rt}-  | otherwise = Nothing--data TableOptions = TableOptions {-  tblOptcolumns :: [Column]-, tblOptpkey    :: [Text]-}--instance ToJSON TableOptions where-  toJSON t = object [-      "columns" .= tblOptcolumns t-    , "pkey"   .= tblOptpkey t ]-+      fromInRange   = lower <= upper  extractQueryResult :: Maybe ResultsWithCount -> ResultsWithCount extractQueryResult = fromMaybe (Nothing, 0, [], "")
src/PostgREST/Auth.hs view
@@ -16,19 +16,17 @@   , containsRole   , jwtClaims   , tokenJWT+  , JWTAttempt(..)   ) where -import           Lens.Micro-import           Lens.Micro.Aeson+import           Protolude+import           Control.Lens import           Data.Aeson              (Value (..), parseJSON, toJSON)+import           Data.Aeson.Lens import           Data.Aeson.Types        (parseMaybe, emptyObject, emptyArray)-import qualified Data.ByteString         as BS import qualified Data.Vector             as V import qualified Data.HashMap.Strict     as M-import           Data.Maybe              (fromMaybe, maybeToList, fromJust)-import           Data.Monoid             ((<>))-import           Data.String.Conversions (cs)-import           Data.Text               (Text)+import           Data.Maybe              (fromJust) import           Data.Time.Clock         (NominalDiffTime) import           PostgREST.QueryBuilder  (pgFmtIdent, pgFmtLit, unquoted) import qualified Web.JWT                 as JWT@@ -39,33 +37,44 @@   have a claim called role, this one is mapped to a SET ROLE   statement. -}-claimsToSQL :: M.HashMap Text Value -> [BS.ByteString]+claimsToSQL :: M.HashMap Text Value -> [ByteString] claimsToSQL claims = roleStmts <> varStmts  where   roleStmts = maybeToList $-    (\r -> "set local role " <> r <> ";") . cs . valueToVariable <$> M.lookup "role" claims+    (\r -> "set local role " <> r <> ";") . toS . valueToVariable <$> M.lookup "role" claims   varStmts = map setVar $ M.toList (M.delete "role" claims)-  setVar (k, val) = "set local " <> cs (pgFmtIdent $ "postgrest.claims." <> k)-                    <> " = " <> cs (valueToVariable val) <> ";"+  setVar (k, val) = "set local " <> toS (pgFmtIdent $ "request.jwt.claim." <> k)+                    <> " = " <> toS (valueToVariable val) <> ";"   valueToVariable = pgFmtLit . unquoted  {-|-  Receives the JWT secret (from config) and a JWT and-  returns a map of JWT claims-  In case there is any problem decoding the JWT it returns an error Text+  Possible situations encountered with client JWTs -}-jwtClaims :: JWT.Secret -> Text -> NominalDiffTime -> Either Text (M.HashMap Text Value)-jwtClaims _ "" _ = Right M.empty+data JWTAttempt = JWTExpired+                | JWTInvalid+                | JWTMissingSecret+                | JWTClaims (M.HashMap Text Value)+                deriving Eq++{-|+  Receives the JWT secret (from config) and a JWT and returns a map+  of JWT claims.+-}+jwtClaims :: Maybe JWT.Secret -> Text -> NominalDiffTime -> JWTAttempt+jwtClaims _ "" _ = JWTClaims M.empty jwtClaims secret jwt time =-  case isExpired <$> mClaims of-    Just True -> Left "JWT expired"-    Nothing -> Left "Invalid JWT"-    Just False -> Right $ value2map $ fromJust mClaims+  case secret of+    Nothing -> JWTMissingSecret+    Just s ->+      let mClaims = toJSON . JWT.claims <$> JWT.decodeAndVerifySignature s jwt in+      case isExpired <$> mClaims of+        Just True -> JWTExpired+        Nothing -> JWTInvalid+        Just False -> JWTClaims $ value2map $ fromJust mClaims  where   isExpired claims =     let mExp = claims ^? key "exp" . _Integer     in fromMaybe False $ (<= time) . fromInteger <$> mExp-  mClaims = toJSON . JWT.claims <$> JWT.decodeAndVerifySignature secret jwt   value2map (Object o) = o   value2map _          = M.empty @@ -83,6 +92,6 @@ {-|   Whether a response from jwtClaims contains a role claim -}-containsRole :: Either Text (M.HashMap Text Value) -> Bool-containsRole (Left _) = False-containsRole (Right claims) = M.member "role" claims+containsRole :: JWTAttempt -> Bool+containsRole (JWTClaims claims) = M.member "role" claims+containsRole _ = False
src/PostgREST/Config.hs view
@@ -2,12 +2,13 @@ Module      : PostgREST.Config Description : Manages PostgREST configuration options. -This module provides a helper function to read the command line arguments using the optparse-applicative-and the AppConfig type to store them.-It also can be used to define other middleware configuration that may be delegated to some sort of-external configuration.+This module provides a helper function to read the command line+arguments using the optparse-applicative and the AppConfig type to store+them.  It also can be used to define other middleware configuration that+may be delegated to some sort of external configuration. -It currently includes a hardcoded CORS policy but this could easly be turned in configurable behaviour if needed.+It currently includes a hardcoded CORS policy but this could easly be+turned in configurable behaviour if needed.  Other hardcoded options such as the minimum version number also belong here. -}@@ -15,49 +16,51 @@                         , readOptions                         , corsPolicy                         , minimumPgVersion+                        , PgVersion (..)                         , AppConfig (..)                         )        where +import           System.IO.Error             (IOError) import           Control.Applicative+import qualified Data.ByteString             as B import qualified Data.ByteString.Char8       as BS import qualified Data.CaseInsensitive        as CI-import           Data.List                   (intercalate)-import           Data.String.Conversions     (cs)-import           Data.Text                   (strip)+import qualified Data.Configurator           as C+import qualified Data.Configurator.Types     as C+import           Data.List                   (lookup)+import           Data.Text                   (strip, intercalate, lines)+import           Data.Text.Encoding          (encodeUtf8)+import           Data.Text.IO                (hPutStrLn) import           Data.Version                (versionBranch) import           Network.Wai import           Network.Wai.Middleware.Cors (CorsResourcePolicy (..))-import           Options.Applicative+import           Options.Applicative hiding  (str) import           Paths_postgrest             (version)-import           Prelude-import           Safe                        (readMay)-import           Web.JWT                     (Secret, secret)+import           Text.Heredoc+import           Text.PrettyPrint.ANSI.Leijen hiding ((<>), (<$>)) --- | Data type to store all command line options+import           Protolude hiding            (intercalate+                                             , (<>))++-- | Config file settings for the server data AppConfig = AppConfig {-    configDatabase  :: String-  , configAnonRole  :: String-  , configSchema    :: String-  , configPort      :: Int-  , configJwtSecret :: Secret-  , configPool      :: Int-  , configMaxRows   :: Maybe Integer-  , configQuiet     :: Bool-  }+    configDatabase          :: Text+  , configAnonRole          :: Text+  , configProxyUri          :: Maybe Text+  , configSchema            :: Text+  , configHost              :: Text+  , configPort              :: Int -argParser :: Parser AppConfig-argParser = AppConfig-  <$> argument str (help "(REQUIRED) database connection string, e.g. postgres://user:pass@host:port/db" <> metavar "DB_URL")-  <*> strOption    (long "anonymous"  <> short 'a' <> help "(REQUIRED) postgres role to use for non-authenticated requests" <> metavar "ROLE")-  <*> strOption    (long "schema"     <> short 's' <> help "schema to use for API routes" <> metavar "NAME" <> value "public" <> showDefault)-  <*> option auto  (long "port"       <> short 'p' <> help "port number on which to run HTTP server" <> metavar "PORT" <> value 3000 <> showDefault)-  <*> (secret . cs <$>-      strOption    (long "jwt-secret" <> short 'j' <> help "secret used to encrypt and decrypt JWT tokens" <> metavar "SECRET" <> value "secret" <> showDefault))-  <*> option auto  (long "pool"       <> short 'o' <> help "max connections in database pool" <> metavar "COUNT" <> value 10 <> showDefault)-  <*> (readMay <$> strOption  (long "max-rows"   <> short 'm' <> help "max rows in response" <> metavar "COUNT" <> value "infinity" <> showDefault))-  <*> pure False+  , configJwtSecret         :: Maybe B.ByteString+  , configJwtSecretIsBase64 :: Bool +  , configPool              :: Int+  , configMaxRows           :: Maybe Integer+  , configReqCheck          :: Maybe Text+  , configQuiet             :: Bool+  }+ defaultCorsPolicy :: CorsResourcePolicy defaultCorsPolicy =  CorsResourcePolicy Nothing   ["GET", "POST", "PATCH", "DELETE", "OPTIONS"] ["Authorization"] Nothing@@ -78,26 +81,108 @@   where     headers = requestHeaders req     accHeaders = case lookup "access-control-request-headers" headers of-      Just hdrs -> map (CI.mk . cs . strip . cs) $ BS.split ',' hdrs+      Just hdrs -> map (CI.mk . toS . strip . toS) $ BS.split ',' hdrs       Nothing -> []  -- | User friendly version number-prettyVersion :: String+prettyVersion :: Text prettyVersion = intercalate "." $ map show $ versionBranch version  -- | Function to read and parse options from the command line readOptions :: IO AppConfig-readOptions = customExecParser parserPrefs opts-  where-    opts = info (helper <*> argParser) $-                    fullDesc-                    <> progDesc (-                    "PostgREST "-                    <> prettyVersion-                    <> " / create a REST API to an existing Postgres database"-                    )-    parserPrefs = prefs showHelpOnError+readOptions = do+  -- First read the config file path from command line+  cfgPath <- customExecParser parserPrefs opts+  -- Now read the actual config file+  conf <- catch+    (C.load [C.Required cfgPath])+    configNotfoundHint +  handle missingKeyHint $ do+    -- db ----------------+    cDbUri    <- C.require conf "db-uri"+    cDbSchema <- C.require conf "db-schema"+    cDbAnon   <- C.require conf "db-anon-role"+    cPool     <- C.lookupDefault 10 conf "db-pool"+    -- server ------------+    cHost     <- C.lookupDefault "*4" conf "server-host"+    cPort     <- C.lookupDefault 3000 conf "server-port"+    cProxy    <- C.lookup conf "server-proxy-uri"+    -- jwt ---------------+    cJwtSec   <- C.lookup conf "jwt-secret"+    cJwtB64   <- C.lookupDefault False conf "secret-is-base64"+    -- safety ------------+    cMaxRows  <- C.lookup conf "max-rows"+    cReqCheck <- C.lookup conf "pre-request"++    return $ AppConfig cDbUri cDbAnon cProxy cDbSchema cHost cPort+          (encodeUtf8 <$> cJwtSec) cJwtB64 cPool cMaxRows cReqCheck False++ where+  opts = info (helper <*> pathParser) $+           fullDesc+           <> progDesc (+               "PostgREST "+               <> toS prettyVersion+               <> " / create a REST API to an existing Postgres database"+             )+           <> footerDoc (Just $+               text "Example Config File:"+               <> nest 2 (hardline <> exampleCfg)+             )++  parserPrefs = prefs showHelpOnError++  configNotfoundHint :: IOError -> IO a+  configNotfoundHint e = do+    hPutStrLn stderr $+      "Cannot open config file:\n\t" <> show e+    exitFailure++  missingKeyHint :: C.KeyError -> IO a+  missingKeyHint (C.KeyError n) = do+    hPutStrLn stderr $+      "Required config parameter \"" <> n <> "\" is missing or of wrong type.\n" <>+      "Try the --example-config option to see how to configure PostgREST."+    exitFailure++  exampleCfg :: Doc+  exampleCfg = vsep . map (text . toS) . lines $+    [str|db-uri = "postgres://user:pass@localhost:5432/dbname"+        |db-schema = "public"+        |db-anon-role = "postgres"+        |db-pool = 10+        |+        |server-host = "*4"+        |server-port = 3000+        |+        |## base url for swagger output+        |# server-proxy-uri = ""+        |+        |## choose a secret to enable JWT auth+        |## (use "@filename" to load from separate file)+        |# jwt-secret = "foo"+        |# secret-is-base64 = false+        |+        |## limit rows in response+        |# max-rows = 1000+        |+        |## stored proc to exec immediately after auth+        |# pre-request = "stored_proc_name"+        |]+++pathParser :: Parser FilePath+pathParser =+  strArgument $+    metavar "FILENAME" <>+    help "Path to configuration file"++data PgVersion = PgVersion {+  pgvNum  :: Int32+, pgvName :: Text+}+ -- | Tells the minimum PostgreSQL version required by this version of PostgREST-minimumPgVersion :: Integer-minimumPgVersion = 90300+minimumPgVersion :: PgVersion+minimumPgVersion = PgVersion 90300 "9.3"
+ src/PostgREST/DbRequestBuilder.hs view
@@ -0,0 +1,275 @@+{-# LANGUAGE FlexibleContexts #-}+module PostgREST.DbRequestBuilder (+  readRequest+, mutateRequest+) where++import           Control.Applicative+import           Control.Lens.Getter       (view)+import           Control.Lens.Tuple        (_1)+import qualified Data.ByteString.Char8     as BS+import           Data.List                 (delete, lookup)+import           Data.Maybe                (fromJust)+import           Data.Text                 (isInfixOf, dropWhile, drop)+import           Data.Tree+import           Data.Either.Combinators   (mapLeft)++import           Text.Parsec.Error++import           Network.HTTP.Types.Status+import           Network.Wai++import           Data.Foldable (foldr1)+import qualified Data.HashMap.Strict       as M++import           PostgREST.ApiRequest   ( ApiRequest(..) +                                        , Action(..), Target(..)+                                        , PreferRepresentation (..)+                                        )+import           PostgREST.Error           (errResponse, formatParserError)+import           PostgREST.Parsers+import           PostgREST.RangeQuery      (NonnegRange, restrictRange)+import           PostgREST.QueryBuilder (getJoinConditions, sourceCTEName)+import           PostgREST.Types++import           Protolude                hiding (from, dropWhile, drop)+import           Text.Regex.TDFA         ((=~))+import           Unsafe                  (unsafeHead)++readRequest :: Maybe Integer -> [Relation] -> [(Text, Text)] -> ApiRequest -> Either Response ReadRequest+readRequest maxRows allRels allProcs apiRequest  =+  mapLeft (errResponse status400) $+  treeRestrictRange maxRows =<<+  augumentRequestWithJoin schema relations =<<+  first formatParserError parseReadRequest+  where+    (schema, rootTableName) = fromJust $ -- Make it safe+      let target = iTarget apiRequest in+      case target of+        (TargetIdent (QualifiedIdentifier s t) ) -> Just (s, t)+        (TargetProc  (QualifiedIdentifier s p) ) -> Just (s, t)+          where+            returnType = fromMaybe "" $ lookup p allProcs+            -- we are looking for results looking like "SETOF schema.tablename" and want to extract tablename+            t = if "SETOF " `isInfixOf` returnType+              then drop 1 $ dropWhile (/= '.') returnType+              else p++        _ -> Nothing++    action :: Action+    action = iAction apiRequest++    parseReadRequest :: Either ParseError ReadRequest+    parseReadRequest = addFiltersOrdersRanges apiRequest <*>+      pRequestSelect rootName selStr+      where+        selStr = iSelect apiRequest+        rootName = if action == ActionRead+          then rootTableName+          else sourceCTEName++    relations :: [Relation]+    relations = case action of+      ActionCreate -> fakeSourceRelations ++ allRels+      ActionUpdate -> fakeSourceRelations ++ allRels+      ActionDelete -> fakeSourceRelations ++ allRels+      ActionInvoke -> fakeSourceRelations ++ allRels+      _       -> allRels+      where fakeSourceRelations = mapMaybe (toSourceRelation rootTableName) allRels -- see comment in toSourceRelation++treeRestrictRange :: Maybe Integer -> ReadRequest -> Either Text ReadRequest+treeRestrictRange maxRows_ request = pure $ nodeRestrictRange maxRows_ `fmap` request+  where+    nodeRestrictRange :: Maybe Integer -> ReadNode -> ReadNode+    nodeRestrictRange m (q@Select {range_=r}, i) = (q{range_=restrictRange m r }, i)++augumentRequestWithJoin :: Schema ->  [Relation] ->  ReadRequest -> Either Text ReadRequest+augumentRequestWithJoin schema allRels request =+  (first formatRelationError . addRelations schema allRels Nothing) request+  >>= addJoinConditions schema+  where+    formatRelationError = ("could not find foreign keys between these entities, " <>)++addRelations :: Schema -> [Relation] -> Maybe ReadRequest -> ReadRequest -> Either Text ReadRequest+addRelations schema allRelations parentNode (Node readNode@(query, (name, _, alias)) forest) =+  case parentNode of+    (Just (Node (Select{from=[parentNodeTable]}, (_, _, _)) _)) ->+      Node <$> readNode' <*> forest'+      where+        forest' = updateForest $ hush node'+        node' = Node <$> readNode' <*> pure forest+        readNode' = addRel readNode <$> rel+        rel :: Either Text Relation+        rel = note ("no relation between " <> parentNodeTable <> " and " <> name)+            $ findRelation schema name parentNodeTable++            where+              findRelation s nodeTableName parentNodeTableName =+                find (\r ->+                  s == tableSchema (relTable r) && -- match schema for relation table+                  s == tableSchema (relFTable r) && -- match schema for relation foriegn table+                  (++                    -- (request)        => projects { ..., clients{...} }+                    -- will match+                    -- (relation type)  => parent+                    -- (entity)         => clients  {id}+                    -- (foriegn entity) => projects {client_id}+                    (+                      nodeTableName == tableName (relTable r) && -- match relation table name+                      parentNodeTableName == tableName (relFTable r) -- match relation foreign table name+                    ) ||+++                    -- (request)        => projects { ..., client_id{...} }+                    -- will match+                    -- (relation type)  => parent+                    -- (entity)         => clients  {id}+                    -- (foriegn entity) => projects {client_id}+                    (+                      parentNodeTableName == tableName (relFTable r) &&+                      length (relFColumns r) == 1 &&+                      nodeTableName `colMatches` (colName . unsafeHead . relFColumns) r+                    )++                    -- (request)        => project_id { ..., client_id{...} }+                    -- will match+                    -- (relation type)  => parent+                    -- (entity)         => clients  {id}+                    -- (foriegn entity) => projects {client_id}+                    -- this case works becasue before reaching this place+                    -- addRelation will turn project_id to project so the above condition will match+                  )+                ) allRelations+                where n `colMatches` rc = (toS ("^" <> rc <> "_?(?:|[iI][dD]|[fF][kK])$") :: BS.ByteString) =~ (toS n :: BS.ByteString)+        addRel :: (ReadQuery, (NodeName, Maybe Relation, Maybe Alias)) -> Relation -> (ReadQuery, (NodeName, Maybe Relation, Maybe Alias))+        addRel (query', (n, _, a)) r = (query' {from=fromRelation}, (n, Just r, a))+          where fromRelation = map (\t -> if t == n then tableName (relTable r) else t) (from query')++    _ -> n' <$> updateForest (Just (n' forest))+      where+        n' = Node (query, (name, Just r, alias))+        t = Table schema name True -- !!! TODO find another way to get the table from the query+        r = Relation t [] t [] Root Nothing Nothing Nothing+  where+    updateForest :: Maybe ReadRequest -> Either Text [ReadRequest]+    updateForest n = mapM (addRelations schema allRelations n) forest++addJoinConditions :: Schema -> ReadRequest -> Either Text ReadRequest+addJoinConditions schema (Node nn@(query, (n, r, a)) forest) =+  case r of+    Just Relation{relType=Root} -> Node nn  <$> updatedForest -- this is the root node+    Just rel@Relation{relType=Child} -> Node (addCond query (getJoinConditions rel),(n,r,a)) <$> updatedForest+    Just Relation{relType=Parent} -> Node nn <$> updatedForest+    Just rel@Relation{relType=Many, relLTable=(Just linkTable)} ->+      Node (qq, (n, r, a)) <$> updatedForest+      where+         query' = addCond query (getJoinConditions rel)+         qq = query'{from=tableName linkTable : from query'}+    _ -> Left "unknown relation"+  where+    updatedForest = mapM (addJoinConditions schema) forest+    addCond query' con = query'{flt_=con ++ flt_ query'}++addFiltersOrdersRanges :: ApiRequest -> Either ParseError (ReadRequest -> ReadRequest)+addFiltersOrdersRanges apiRequest = foldr1 (liftA2 (.)) [+    flip (foldr addFilter) <$> filters,+    flip (foldr addOrder) <$> orders,+    flip (foldr addRange) <$> ranges+  ]+  {-+  The esence of what is going on above is that we are composing tree functions+  of type (ReadRequest->ReadRequest) that are in (Either ParseError a) context+  -}+  where+    filters :: Either ParseError [(Path, Filter)]+    filters = mapM pRequestFilter flts+      where+        action = iAction apiRequest+        flts+          | action == ActionRead = iFilters apiRequest+          | action == ActionInvoke = iFilters apiRequest+          | otherwise = filter (( "." `isInfixOf` ) . fst) $ iFilters apiRequest -- there can be no filters on the root table whre we are doing insert/update+    orders :: Either ParseError [(Path, [OrderTerm])]+    orders = mapM pRequestOrder $ iOrder apiRequest+    ranges :: Either ParseError [(Path, NonnegRange)]+    ranges = mapM pRequestRange $ M.toList $ iRange apiRequest++addFilterToNode :: Filter -> ReadRequest -> ReadRequest+addFilterToNode flt (Node (q@Select {flt_=flts}, i) f) = Node (q {flt_=flt:flts}, i) f++addFilter :: (Path, Filter) -> ReadRequest -> ReadRequest+addFilter = addProperty addFilterToNode++addOrderToNode :: [OrderTerm] -> ReadRequest -> ReadRequest+addOrderToNode o (Node (q,i) f) = Node (q{order=Just o}, i) f++addOrder :: (Path, [OrderTerm]) -> ReadRequest -> ReadRequest+addOrder = addProperty addOrderToNode++addRangeToNode :: NonnegRange -> ReadRequest -> ReadRequest+addRangeToNode r (Node (q,i) f) = Node (q{range_=r}, i) f++addRange :: (Path, NonnegRange) -> ReadRequest -> ReadRequest+addRange = addProperty addRangeToNode++addProperty :: (a -> ReadRequest -> ReadRequest) -> (Path, a) -> ReadRequest -> ReadRequest+addProperty f ([], a) n = f a n+addProperty f (path, a) (Node rn forest) =+  case targetNode of+    Nothing -> Node rn forest -- the property is silenty dropped in the Request does not contain the required path+    Just tn -> Node rn (addProperty f (remainingPath, a) tn:restForest)+  where+    targetNodeName:remainingPath = path+    (targetNode,restForest) = splitForest targetNodeName forest+    splitForest :: NodeName -> Forest ReadNode -> (Maybe ReadRequest, Forest ReadNode)+    splitForest name forst =+      case maybeNode of+        Nothing -> (Nothing,forest)+        Just node -> (Just node, delete node forest)+      where+        maybeNode :: Maybe ReadRequest+        maybeNode = find fnd forst+          where+            fnd :: ReadRequest -> Bool+            fnd (Node (_,(n,_,_)) _) = n == name++-- in a relation where one of the tables mathces "TableName"+-- replace the name to that table with pg_source+-- this "fake" relations is needed so that in a mutate query+-- we can look a the "returning *" part which is wrapped with a "with"+-- as just another table that has relations with other tables+toSourceRelation :: TableName -> Relation -> Maybe Relation+toSourceRelation mt r@(Relation t _ ft _ _ rt _ _)+  | mt == tableName t = Just $ r {relTable=t {tableName=sourceCTEName}}+  | mt == tableName ft = Just $ r {relFTable=t {tableName=sourceCTEName}}+  | Just mt == (tableName <$> rt) = Just $ r {relLTable=(\tbl -> tbl {tableName=sourceCTEName}) <$> rt}+  | otherwise = Nothing++mutateRequest :: ApiRequest -> ReadRequest -> Either Response MutateRequest+mutateRequest apiRequest readReq = mapLeft (errResponse status400) $+  case action of+    ActionCreate -> Right $ Insert rootTableName payload returnings+    ActionUpdate -> Update rootTableName <$> pure payload <*> filters <*> pure returnings+    ActionDelete -> Delete rootTableName <$> filters <*> pure returnings+    _        -> Left "Unsupported HTTP verb"+  where+    action = iAction apiRequest+    payload = fromJust $ iPayload apiRequest+    rootTableName = -- TODO: Make it safe+      let target = iTarget apiRequest in+      case target of+        (TargetIdent (QualifiedIdentifier _ t) ) -> t+        _ -> undefined+    fieldNames :: ReadRequest -> PreferRepresentation -> [FieldName]+    fieldNames _ None = []+    fieldNames (Node (sel, _) forest) _ =+      map (fst . view _1) (select sel) ++ map colName fks+      where+        fks = concatMap (fromMaybe [] . f) forest+        f (Node (_, (_, Just Relation{relFColumns=cols, relType=Parent}, _)) _) = Just cols+        f _ = Nothing+    returnings = fieldNames readReq (iPreferRepresentation apiRequest) +    filters = first formatParserError $ map snd <$> mapM pRequestFilter mutateFilters+      where mutateFilters = filter (not . ( "." `isInfixOf` ) . fst) $ iFilters apiRequest -- update/delete filters can be only on the root table
src/PostgREST/DbStructure.hs view
@@ -6,8 +6,6 @@ module PostgREST.DbStructure (   getDbStructure , accessibleTables-, doesProcExist-, doesProcReturnJWT ) where  import qualified Hasql.Decoders                as HD@@ -15,21 +13,18 @@ import qualified Hasql.Query                   as H  import           Control.Applicative-import           Control.Monad                 (join, replicateM)-import           Data.Functor.Contravariant    (contramap)-import           Data.List                     (elemIndex, find, sort,-                                                subsequences, transpose)-import           Data.Maybe                    (fromJust, fromMaybe, isJust,-                                                listToMaybe, mapMaybe)-import           Data.Monoid-import           Data.Text                     (Text, split)+import           Data.List                     (elemIndex)+import           Data.Maybe                    (fromJust)+import           Data.Text                     (split, strip,+                                                breakOn, dropAround)+import qualified Data.Text                     as T import qualified Hasql.Session                 as H import           PostgREST.Types import           Text.InterpolatedString.Perl6 (q) -import           Data.Int                      (Int32) import           GHC.Exts                      (groupWith)-import           Prelude+import           Protolude+import           Unsafe (unsafeHead)  getDbStructure :: Schema -> H.Session DbStructure getDbStructure schema = do@@ -38,6 +33,7 @@   syns <- H.query () $ allSynonyms cols   rels <- H.query () $ allRelations tabs cols   keys <- H.query () $ allPrimaryKeys tabs+  procs <- H.query schema accessibleProcs    let rels' = (addManyToManyRelations . raiseRelations schema syns . addParentRelations . addSynonymousRelations syns) rels       cols' = addForeignKeys rels' cols@@ -48,13 +44,9 @@     , dbColumns = cols'     , dbRelations = rels'     , dbPrimaryKeys = keys'+    , dbProcs = procs     } -encodeQi :: HE.Params QualifiedIdentifier-encodeQi =-  contramap qiSchema (HE.value HE.text) <>-  contramap qiName   (HE.value HE.text)- decodeTables :: HD.Result [Table] decodeTables =   HD.rowsList tblRow@@ -104,33 +96,37 @@     <*> HD.value HD.text <*> HD.value HD.text     <*> HD.value HD.text <*> HD.value HD.text -doesProcExist :: H.Query QualifiedIdentifier Bool-doesProcExist =-  H.statement sql encodeQi (HD.singleRow (HD.value HD.bool)) True+accessibleProcs :: H.Query Schema [(Text, ProcDescription)]+accessibleProcs =+  H.statement sql (HE.value HE.text)+    (map addName <$> HD.rowsList (ProcDescription <$> HD.value HD.text+                                <*> (parseArgs <$> HD.value HD.text)+                                <*> HD.value HD.text)) True  where-  sql = [q| SELECT EXISTS (-      SELECT 1-      FROM   pg_catalog.pg_namespace n-      JOIN   pg_catalog.pg_proc p-      ON     pronamespace = n.oid-      WHERE  nspname = $1-      AND    proname = $2-    ) |]+  addName :: ProcDescription -> (Text, ProcDescription)+  addName pd = (pdName pd, pd) -doesProcReturnJWT :: H.Query QualifiedIdentifier Bool-doesProcReturnJWT =-  H.statement sql encodeQi (HD.singleRow (HD.value HD.bool)) True- where-  sql = [q| SELECT EXISTS (-      SELECT 1-      FROM   pg_catalog.pg_namespace n-      JOIN   pg_catalog.pg_proc p-      ON     pronamespace = n.oid-      WHERE  nspname = $1-      AND    proname = $2-      AND    pg_catalog.pg_get_function_result(p.oid) like '%jwt_claims'-    ) |]+  parseArgs :: Text -> [PgArg]+  parseArgs = mapMaybe (parseArg . strip) . split (==',') +  parseArg :: Text -> Maybe PgArg+  parseArg a =+    let (body, def) = breakOn " DEFAULT " a+        (name, typ) = breakOn " " body in+    if T.null typ+       then Nothing+       else Just $+         PgArg (dropAround (== '"') name) (strip typ) (T.null def)++  sql = [q|+    SELECT p.proname as "proc_name",+           pg_get_function_arguments(p.oid) as "args",+           pg_get_function_result(p.oid) as "return_type"+    FROM   pg_namespace n+    JOIN   pg_proc p+    ON     pronamespace = n.oid+    WHERE  n.nspname = $1|]+ accessibleTables :: H.Query Schema [Table] accessibleTables =   H.statement sql (HE.value HE.text) decodeTables True@@ -161,7 +157,9 @@ synonymousColumns :: [(Column,Column)] -> [Column] -> [[Column]] synonymousColumns allSyns cols = synCols'   where-    syns = sort $ filter ((== colTable (head cols)) . colTable . fst) allSyns+    syns = case headMay cols of+            Just firstCol -> sort $ filter ((== colTable firstCol) . colTable . fst) allSyns+            Nothing -> []     synCols  = transpose $ map (\c -> map snd $ filter ((== c) . fst) syns) cols     synCols' = (filter sameTable . filter matchLength) synCols     matchLength cs = length cols == length cs@@ -175,11 +173,10 @@     fk col = join $ relToFk col <$> find (lookupFn col) rels     lookupFn :: Column -> Relation -> Bool     lookupFn c Relation{relColumns=cs, relType=rty} = c `elem` cs && rty==Child-    -- lookupFn _ _ = False-    relToFk col Relation{relColumns=cols, relFColumns=colsF} = ForeignKey <$> colF-      where-        pos = elemIndex col cols-        colF = (colsF !!) <$> pos+    relToFk col Relation{relColumns=cols, relFColumns=colsF} = do+      pos <- elemIndex col cols+      colF <- atMay colsF pos+      return $ ForeignKey colF  addSynonymousRelations :: [(Column,Column)] -> [Relation] -> [Relation] addSynonymousRelations _ [] = []@@ -187,7 +184,7 @@   where     synRelsP = synRels (relColumns rel) (\t cs -> rel{relTable=t,relColumns=cs})     synRelsF = synRels (relFColumns rel) (\t cs -> rel{relFTable=t,relFColumns=cs})-    synRels cols mapFn = map (\cs -> mapFn (colTable $ head cs) cs) $ synonymousColumns syns cols+    synRels cols mapFn = map (\cs -> mapFn (colTable $ unsafeHead cs) cs) $ synonymousColumns syns cols  addParentRelations :: [Relation] -> [Relation] addParentRelations [] = []@@ -220,8 +217,8 @@       where         cols = relFColumns rel         table = relFTable rel-        newCols = listToMaybe $ filter ((== schema) . tableSchema . colTable . head) (synonymousColumns syns cols)-        newTable = (colTable . head) <$> newCols+        newCols = listToMaybe $ filter ((== schema) . tableSchema . colTable . unsafeHead) (synonymousColumns syns cols)+        newTable = (colTable . unsafeHead) <$> newCols  synonymousPrimaryKeys :: [(Column,Column)] -> [PrimaryKey] -> [PrimaryKey] synonymousPrimaryKeys _ [] = []@@ -289,13 +286,13 @@                         CASE                             WHEN bt.typelem <> 0::oid AND bt.typlen = (-1) THEN 'ARRAY'::text                             WHEN nbt.nspname = 'pg_catalog'::name THEN format_type(t.typbasetype, NULL::integer)-                            ELSE 'USER-DEFINED'::text+                            ELSE format_type(a.atttypid, a.atttypmod)                         END                         ELSE                         CASE                             WHEN t.typelem <> 0::oid AND t.typlen = (-1) THEN 'ARRAY'::text                             WHEN nt.nspname = 'pg_catalog'::name THEN format_type(a.atttypid, NULL::integer)-                            ELSE 'USER-DEFINED'::text+                            ELSE format_type(a.atttypid, a.atttypmod)                         END                     END::information_schema.character_data AS data_type,                 information_schema._pg_char_max_length(information_schema._pg_truetypid(a.*, t.*), information_schema._pg_truetypmod(a.*, t.*))::information_schema.cardinal_number AS character_maximum_length,@@ -560,73 +557,73 @@  where   -- query explanation at https://gist.github.com/ruslantalpa/2eab8c930a65e8043d8f   sql = [q|-    WITH view_columns AS (-    	SELECT-    		c.oid AS view_oid,-    		a.attname::information_schema.sql_identifier AS column_name-    	FROM pg_attribute a-    	JOIN pg_class c ON a.attrelid = c.oid-    	JOIN pg_namespace nc ON c.relnamespace = nc.oid-    	WHERE-    		NOT pg_is_other_temp_schema(nc.oid)-    		AND a.attnum > 0-    		AND NOT a.attisdropped-    		AND (c.relkind = 'v'::"char")-    		AND nc.nspname NOT IN ('information_schema', 'pg_catalog')+    with view_columns as (+        select+            c.oid as view_oid,+            a.attname::information_schema.sql_identifier as column_name+        from pg_attribute a+        join pg_class c on a.attrelid = c.oid+        join pg_namespace nc on c.relnamespace = nc.oid+        where+            not pg_is_other_temp_schema(nc.oid)+            and a.attnum > 0+            and not a.attisdropped+            and (c.relkind = 'v'::"char")+            and nc.nspname not in ('information_schema', 'pg_catalog')     ),-    view_column_usage AS (-    	SELECT DISTINCT-    		v.oid as view_oid,-    		nv.nspname::information_schema.sql_identifier AS view_schema,-    		v.relname::information_schema.sql_identifier AS view_name,-    		nt.nspname::information_schema.sql_identifier AS table_schema,-    		t.relname::information_schema.sql_identifier AS table_name,-    		a.attname::information_schema.sql_identifier AS column_name,-    		pg_get_viewdef(v.oid)::information_schema.character_data AS view_definition-    	FROM pg_namespace nv-    	JOIN pg_class v ON nv.oid = v.relnamespace-    	JOIN pg_depend dv ON v.oid = dv.refobjid-    	JOIN pg_depend dt ON dv.objid = dt.objid-    	JOIN pg_class t ON dt.refobjid = t.oid-    	JOIN pg_namespace nt ON t.relnamespace = nt.oid-    	JOIN pg_attribute a ON t.oid = a.attrelid AND dt.refobjsubid = a.attnum+    view_column_usage as (+        select distinct+            v.oid as view_oid,+            nv.nspname::information_schema.sql_identifier as view_schema,+            v.relname::information_schema.sql_identifier as view_name,+            nt.nspname::information_schema.sql_identifier as table_schema,+            t.relname::information_schema.sql_identifier as table_name,+            a.attname::information_schema.sql_identifier as column_name,+            pg_get_viewdef(v.oid)::information_schema.character_data as view_definition+        from pg_namespace nv+        join pg_class v on nv.oid = v.relnamespace+        join pg_depend dv on v.oid = dv.refobjid+        join pg_depend dt on dv.objid = dt.objid+        join pg_class t on dt.refobjid = t.oid+        join pg_namespace nt on t.relnamespace = nt.oid+        join pg_attribute a on t.oid = a.attrelid and dt.refobjsubid = a.attnum -    	WHERE-    		nv.nspname not in ('information_schema', 'pg_catalog')-    		AND v.relkind = 'v'::"char"-    		AND dv.refclassid = 'pg_class'::regclass::oid-    		AND dv.classid = 'pg_rewrite'::regclass::oid-    		AND dv.deptype = 'i'::"char"-    		AND dv.refobjid <> dt.refobjid-    		AND dt.classid = 'pg_rewrite'::regclass::oid-    		AND dt.refclassid = 'pg_class'::regclass::oid-    		AND (t.relkind = ANY (ARRAY['r'::"char", 'v'::"char", 'f'::"char"]))+        where+            nv.nspname not in ('information_schema', 'pg_catalog')+            and v.relkind = 'v'::"char"+            and dv.refclassid = 'pg_class'::regclass::oid+            and dv.classid = 'pg_rewrite'::regclass::oid+            and dv.deptype = 'i'::"char"+            and dv.refobjid <> dt.refobjid+            and dt.classid = 'pg_rewrite'::regclass::oid+            and dt.refclassid = 'pg_class'::regclass::oid+            and (t.relkind = any (array['r'::"char", 'v'::"char", 'f'::"char"]))     ),-    candidates AS (-    	SELECT-    		vcu.*,-    		(-    			SELECT CASE WHEN match IS NOT NULL THEN coalesce(match[7], match[4]) END-    			FROM REGEXP_MATCHES(-    				CONCAT('SELECT ', SPLIT_PART(vcu.view_definition, 'SELECT', 2)),-    				CONCAT('SELECT.*?((',vcu.table_name,')|(\w+))\.(', vcu.column_name, ')(\sAS\s(")?([^"]+)\6)?.*?FROM.*?',vcu.table_schema,'\.(\2|',vcu.table_name,'\s+(AS\s)?\3)'),-    				'ns'-    			) match-    		) AS view_column_name-    	FROM view_column_usage AS vcu+    candidates as (+        select+            vcu.*,+            (+                select case when match is not null then coalesce(match[8], match[7], match[4]) end+                from regexp_matches(+                    CONCAT('SELECT ', SPLIT_PART(vcu.view_definition, 'SELECT', 2)),+                    CONCAT('SELECT.*?((',vcu.table_name,')|(\w+))\.(', vcu.column_name, ')(\s+AS\s+("([^"]+)"|([^, \n\t]+)))?.*?FROM.*?',vcu.table_schema,'\.(\2|',vcu.table_name,'\s+(as\s)?\3)'),+                    'nsi'+                ) match+            ) as view_column_name+        from view_column_usage as vcu     )-    SELECT-    	c.table_schema,-    	c.table_name,-    	c.column_name AS table_column_name,-    	c.view_schema,-    	c.view_name,-    	c.view_column_name-    FROM view_columns AS vc, candidates AS c-    WHERE-    	vc.view_oid = c.view_oid AND-    	vc.column_name = c.view_column_name-    ORDER BY c.view_schema, c.view_name, c.table_name, c.view_column_name+    select+        c.table_schema,+        c.table_name,+        c.column_name as table_column_name,+        c.view_schema,+        c.view_name,+        c.view_column_name+    from view_columns as vc, candidates as c+    where+        vc.view_oid = c.view_oid+        and vc.column_name = c.view_column_name+    order by c.view_schema, c.view_name, c.table_name, c.view_column_name     |]  synonymFromRow :: [Column] -> (Text,Text,Text,Text,Text,Text) -> Maybe (Column,Column)
src/PostgREST/Error.hs view
@@ -2,74 +2,108 @@ {-# LANGUAGE FlexibleInstances    #-} {-# LANGUAGE TypeSynonymInstances #-} -module PostgREST.Error (pgErrResponse, errResponse) where-+module PostgREST.Error (apiRequestErrResponse, pgErrResponse, errResponse, prettyUsageError, singularityError, formatGeneralError, formatParserError) where +import           Protolude import           Data.Aeson                ((.=)) import qualified Data.Aeson                as JSON-import           Data.Maybe                (fromMaybe)-import           Data.Monoid               ((<>))-import           Data.String.Conversions   (cs)-import           Data.Text                 (Text)-import qualified Data.Text                 as T+import           Data.Text                 (replace, strip, unwords) import qualified Hasql.Pool                as P import qualified Hasql.Session             as H-import           Network.HTTP.Types.Header import qualified Network.HTTP.Types.Status as HT import           Network.Wai               (Response, responseLBS)+import           PostgREST.ApiRequest      (toHeader, toMime, ContentType(..), ApiRequestError(..))+import           Text.Parsec.Error +apiRequestErrResponse :: ApiRequestError -> Response+apiRequestErrResponse err =+  case err of+    ErrorActionInappropriate -> errResponse HT.status405 "Bad Request"+    ErrorInvalidBody errorMessage -> errResponse HT.status400 $ toS errorMessage+    ErrorInvalidRange -> errResponse HT.status416 "HTTP Range error"+ errResponse :: HT.Status -> Text -> Response-errResponse status message = responseLBS status [(hContentType, "application/json")] (cs $ T.concat ["{\"message\":\"",message,"\"}"])+errResponse status message = jsonErrResponse status $ JSON.object ["message" .= message] +jsonErrResponse :: HT.Status -> JSON.Value -> Response+jsonErrResponse status message = responseLBS status [toHeader CTApplicationJSON] $ JSON.encode message+ pgErrResponse :: Bool -> P.UsageError -> Response pgErrResponse authed e =   let status = httpStatus authed e-      jsonType = (hContentType, "application/json")+      jsonType = toHeader CTApplicationJSON       wwwAuth = ("WWW-Authenticate", "Bearer")       hdrs = if status == HT.status401                 then [jsonType, wwwAuth]                 else [jsonType] in   responseLBS status hdrs (JSON.encode e) +prettyUsageError :: P.UsageError -> Text+prettyUsageError (P.ConnectionError e) =+  "Database connection error:\n" <> toS (fromMaybe "" e)+prettyUsageError e = show $ JSON.encode e++singularityError :: Integer -> Response+singularityError numRows =+  responseLBS HT.status406+    [toHeader CTSingularJSON]+    $ toS . formatGeneralError+      "JSON object requested, multiple (or no) rows returned"+      $ unwords+        [ "Results contain", show numRows, "rows,"+        , toS (toMime CTSingularJSON), "requires 1 row"+        ]++formatParserError :: ParseError -> Text+formatParserError e = formatGeneralError message details+  where+     message = show $ errorPos e+     details = strip $ replace "\n" " " $ toS+       $ showErrorMessages "or" "unknown parse error" "expecting" "unexpected" "end of input" (errorMessages e)++formatGeneralError :: Text -> Text -> Text+formatGeneralError message details = toS . JSON.encode $+  JSON.object ["message" .= message, "details" .= details]+ instance JSON.ToJSON P.UsageError where   toJSON (P.ConnectionError e) = JSON.object [-    "code" .= ("" :: T.Text),-    "message" .= ("Connection error" :: T.Text),-    "details" .= (cs (fromMaybe "" e) :: T.Text)]+    "code" .= ("" :: Text),+    "message" .= ("Connection error" :: Text),+    "details" .= (toS $ fromMaybe "" e :: Text)]   toJSON (P.SessionError e) = JSON.toJSON e -- H.Error  instance JSON.ToJSON H.Error where   toJSON (H.ResultError (H.ServerError c m d h)) = JSON.object [-    "code" .= (cs c::T.Text),-    "message" .= (cs m::T.Text),-    "details" .= (fmap cs d::Maybe T.Text),-    "hint" .= (fmap cs h::Maybe T.Text)]+    "code" .= (toS c::Text),+    "message" .= (toS m::Text),+    "details" .= (fmap toS d::Maybe Text),+    "hint" .= (fmap toS h::Maybe Text)]   toJSON (H.ResultError (H.UnexpectedResult m)) = JSON.object [-    "message" .= (cs m::T.Text)]+    "message" .= (m::Text)]   toJSON (H.ResultError (H.RowError i H.EndOfInput)) = JSON.object [-    "message" .= ("Row error: end of input"::String),+    "message" .= ("Row error: end of input"::Text),     "details" .=-      ("Attempt to parse more columns than there are in the result"::String),-    "details" .= ("Row number " <> show i)]+      ("Attempt to parse more columns than there are in the result"::Text),+    "details" .= (("Row number " <> show i)::Text)]   toJSON (H.ResultError (H.RowError i H.UnexpectedNull)) = JSON.object [-    "message" .= ("Row error: unexpected null"::String),-    "details" .= ("Attempt to parse a NULL as some value."::String),-    "details" .= ("Row number " <> show i)]+    "message" .= ("Row error: unexpected null"::Text),+    "details" .= ("Attempt to parse a NULL as some value."::Text),+    "details" .= (("Row number " <> show i)::Text)]   toJSON (H.ResultError (H.RowError i (H.ValueError d))) = JSON.object [-    "message" .= ("Row error: Wrong value parser used"::String),+    "message" .= ("Row error: Wrong value parser used"::Text),     "details" .= d,-    "details" .= ("Row number " <> show i)]+    "details" .= (("Row number " <> show i)::Text)]   toJSON (H.ResultError (H.UnexpectedAmountOfRows i)) = JSON.object [-    "message" .= ("Unexpected amount of rows"::String),+    "message" .= ("Unexpected amount of rows"::Text),     "details" .= i]   toJSON (H.ClientError d) = JSON.object [-    "message" .= ("Database client error"::String),-    "details" .= (fmap cs d::Maybe T.Text)]+    "message" .= ("Database client error"::Text),+    "details" .= (fmap toS d::Maybe Text)]  httpStatus :: Bool -> P.UsageError -> HT.Status httpStatus _ (P.ConnectionError _) = HT.status500 httpStatus authed (P.SessionError (H.ResultError (H.ServerError c _ _ _))) =-  case cs c of+  case toS c of     '0':'8':_ -> HT.status503 -- pg connection err     '0':'9':_ -> HT.status500 -- triggered action exception     '0':'L':_ -> HT.status403 -- invalid grantor@@ -90,8 +124,10 @@     '5':'8':_ -> HT.status500 -- system error     'F':'0':_ -> HT.status500 -- conf file error     'H':'V':_ -> HT.status500 -- foreign data wrapper error+    "P0001"   -> HT.status400 -- default code for "raise"     'P':'0':_ -> HT.status500 -- PL/pgSQL Error     'X':'X':_ -> HT.status500 -- internal Error+    "42883"   -> HT.status404 -- undefined function     "42P01"   -> HT.status404 -- undefined table     "42501"   -> if authed then HT.status403 else HT.status401 -- insufficient privilege     _         -> HT.status400
src/PostgREST/Middleware.hs view
@@ -5,49 +5,51 @@  import           Data.Aeson                    (Value (..)) import qualified Data.HashMap.Strict           as M-import           Data.String.Conversions       (cs)-import           Data.Text import qualified Hasql.Transaction             as H -import           Network.HTTP.Types.Header     (hAccept)-import           Network.HTTP.Types.Status     (status400, status415)-import           Network.Wai                   (Application, Request (..),-                                                Response, requestHeaders)+import           Network.HTTP.Types.Status     (unauthorized401, status500)+import           Network.Wai                   (Application, Response,+                                                responseLBS) import           Network.Wai.Middleware.Cors   (cors) import           Network.Wai.Middleware.Gzip   (def, gzip) import           Network.Wai.Middleware.Static (only, staticPolicy) -import           PostgREST.ApiRequest          (ApiRequest(..), pickContentType)-import           PostgREST.Auth                (claimsToSQL)+import           PostgREST.ApiRequest          (ApiRequest(..), ContentType(..),+                                                toHeader)+import           PostgREST.Auth                (claimsToSQL, JWTAttempt(..)) import           PostgREST.Config              (AppConfig (..), corsPolicy) import           PostgREST.Error               (errResponse) -import           Prelude                       hiding (concat, null)+import           Protolude                     hiding (concat, null) -runWithClaims :: AppConfig -> Either Text (M.HashMap Text Value) ->+runWithClaims :: AppConfig -> JWTAttempt ->                  (ApiRequest -> H.Transaction Response) ->                  ApiRequest -> H.Transaction Response runWithClaims conf eClaims app req =   case eClaims of-    Left e -> clientErr e-    Right claims -> do-          -- role claim defaults to anon if not specified in jwt-          H.sql . mconcat . claimsToSQL $ M.union claims (M.singleton "role" anon)-          app req+    JWTExpired -> return $ unauthed "JWT expired"+    JWTInvalid -> return $ unauthed "JWT invalid"+    JWTMissingSecret -> return $ errResponse status500 "Server lacks JWT secret"+    JWTClaims claims -> do+      -- role claim defaults to anon if not specified in jwt+      let setClaims = claimsToSQL (M.union claims (M.singleton "role" anon))+      H.sql $ mconcat setClaims+      mapM_ H.sql customReqCheck+      app req   where-    anon = String . cs $ configAnonRole conf-    clientErr = return . errResponse status400--unsupportedAccept :: Application -> Application-unsupportedAccept app req respond =-  case accept of-    Left _ -> respond $ errResponse status415 "Unsupported Accept header, try: application/json"-    Right _ -> app req respond-  where accept = pickContentType $ lookup hAccept $ requestHeaders req+    anon = String . toS $ configAnonRole conf+    customReqCheck = (\f -> "select " <> toS f <> "();") <$> configReqCheck conf+    unauthed message = responseLBS unauthorized401+      [ toHeader CTApplicationJSON+      , ( "WWW-Authenticate"+        , "Bearer error=\"invalid_token\", " <>+          "error_description=\"" <> message <> "\""+        )+      ]+      (toS $ "{\"message\":\""<>message<>"\"}")  defaultMiddle :: Application -> Application defaultMiddle =     gzip def   . cors corsPolicy   . staticPolicy (only [("favicon.ico", "static/favicon.ico")])-  . unsupportedAccept
+ src/PostgREST/OpenAPI.hs view
@@ -0,0 +1,357 @@+{-# LANGUAGE OverloadedStrings #-}++module PostgREST.OpenAPI (+  encodeOpenAPI+  , isMalformedProxyUri+  , pickProxy+  ) where++import           Control.Lens+import           Data.Aeson                  (decode, encode)+import           Data.HashMap.Strict.InsOrd  (InsOrdHashMap, fromList)+import           Data.Maybe                  (fromJust)+import           Data.String                 (IsString (..))+import           Data.Text                   (unpack, pack, concat, intercalate, init, tail, toLower)+import qualified Data.Set                    as Set+import           Network.URI                 (parseURI, isAbsoluteURI,+                                              URI (..), URIAuth (..))++import           Protolude hiding              (concat, (&), Proxy, get, intercalate)++import           Data.Swagger++import           PostgREST.ApiRequest        (ContentType(..), toMime)+import           PostgREST.Config            (prettyVersion)+import           PostgREST.QueryBuilder      (operators)+import           PostgREST.Types             (Table(..), Column(..), PgArg(..),+                                              Proxy(..), ProcDescription(..))++makeMimeList :: [ContentType] -> MimeList+makeMimeList cs = MimeList $ map (fromString . toS . toMime) cs++toSwaggerType :: Text -> SwaggerType t+toSwaggerType "text"      = SwaggerString+toSwaggerType "integer"   = SwaggerInteger+toSwaggerType "boolean"   = SwaggerBoolean+toSwaggerType "numeric"   = SwaggerNumber+toSwaggerType _           = SwaggerString++makeTableDef :: (Table, [Column], [Text]) -> (Text, Schema)+makeTableDef (t, cs, _) =+  let tn = tableName t in+      (tn, (mempty :: Schema)+        & type_ .~ SwaggerObject+        & properties .~ fromList (map makeProperty cs))++makeProperty :: Column -> (Text, Referenced Schema)+makeProperty c = (colName c, Inline u)+  where+    r = mempty :: Schema+    s = if null $ colEnum c+           then r+           else r & enum_ .~ decode (encode (colEnum c))+    t = s & type_ .~ toSwaggerType (colType c)+    u = t & format ?~ colType c++makeProcDef :: ProcDescription -> (Text, Schema)+makeProcDef pd = ("(rpc) " <> pdName pd, s)+  where+    s = (mempty :: Schema)+          & type_ .~ SwaggerObject+          & properties .~ fromList (map makeProcProperty (pdArgs pd))+          & required .~ map pgaName (filter pgaReq (pdArgs pd))++makeProcProperty :: PgArg -> (Text, Referenced Schema)+makeProcProperty (PgArg n t _) = (n, Inline s)+  where+    s = (mempty :: Schema)+          & type_ .~ toSwaggerType t+          & format ?~ t++makeOperatorPattern :: Text+makeOperatorPattern =+  intercalate "|"+  [ concat ["^", x, y, "[.]"] |+    x <- ["not[.]", ""],+    y <- map fst operators ]++makeRowFilter :: Column -> Param+makeRowFilter c =+  (mempty :: Param)+  & name .~ colName c+  & required ?~ False+  & schema .~ ParamOther ((mempty :: ParamOtherSchema)+    & in_ .~ ParamQuery+    & type_ .~ SwaggerString+    & format ?~ colType c+    & pattern ?~ makeOperatorPattern)++makeRowFilters :: [Column] -> [Param]+makeRowFilters = map makeRowFilter++makeOrderItems :: [Column] -> [Text]+makeOrderItems cs =+  [ concat [x, y, z] |+    x <- map colName cs,+    y <- [".asc", ".desc", ""],+    z <- [".nullsfirst", ".nulllast", ""]+  ]++makeRangeParams :: [Param]+makeRangeParams =+  [ (mempty :: Param)+    & name        .~ "Range"+    & description ?~ "Limiting and Pagination"+    & required    ?~ False+    & schema .~ ParamOther ((mempty :: ParamOtherSchema)+      & in_ .~ ParamHeader+      & type_ .~ SwaggerString)+  , (mempty :: Param)+    & name        .~ "Range-Unit"+    & description ?~ "Limiting and Pagination"+    & required    ?~ False+    & schema .~ ParamOther ((mempty :: ParamOtherSchema)+      & in_ .~ ParamHeader+      & type_ .~ SwaggerString+      & default_ .~ decode "\"items\"")+  , (mempty :: Param)+    & name        .~ "offset"+    & description ?~ "Limiting and Pagination"+    & required    ?~ False+    & schema .~ ParamOther ((mempty :: ParamOtherSchema)+      & in_ .~ ParamQuery+      & type_ .~ SwaggerString)+  , (mempty :: Param)+    & name        .~ "limit"+    & description ?~ "Limiting and Pagination"+    & required    ?~ False+    & schema .~ ParamOther ((mempty :: ParamOtherSchema)+      & in_ .~ ParamQuery+      & type_ .~ SwaggerString)+  ]++makePreferParam :: [Text] -> Param+makePreferParam ts =+  (mempty :: Param)+  & name        .~ "Prefer"+  & description ?~ "Preference"+  & required    ?~ False+  & schema .~ ParamOther ((mempty :: ParamOtherSchema)+    & in_ .~ ParamHeader+    & type_ .~ SwaggerString+    & enum_ .~ decode (encode ts))++makeSelectParam :: Param+makeSelectParam =+  (mempty :: Param)+    & name        .~ "select"+    & description ?~ "Filtering Columns"+    & required    ?~ False+    & schema .~ ParamOther ((mempty :: ParamOtherSchema)+      & in_ .~ ParamQuery+      & type_ .~ SwaggerString)++makeGetParams :: [Column] -> [Param]+makeGetParams [] =+  makeRangeParams +++  [ makeSelectParam+  , makePreferParam ["count=none"]+  ]+makeGetParams cs =+  makeRangeParams +++  [ makeSelectParam+  , (mempty :: Param)+    & name        .~ "order"+    & description ?~ "Ordering"+    & required    ?~ False+    & schema .~ ParamOther ((mempty :: ParamOtherSchema)+      & in_ .~ ParamQuery+      & type_ .~ SwaggerString+      & enum_ .~ decode (encode $ makeOrderItems cs))+  , makePreferParam ["count=none"]+  ]++makePostParams :: Text -> [Param]+makePostParams tn =+  [ makePreferParam ["return=representation",+                     "return=minimal", "return=none"]+  , (mempty :: Param)+    & name        .~ "body"+    & description ?~ tn+    & required    ?~ False+    & schema .~ ParamBody (Ref (Reference tn))+  ]++makeProcParam :: Text -> [Param]+makeProcParam refName =+  [ makePreferParam ["params=single-object"]+  , (mempty :: Param)+    & name     .~ "args"+    & required ?~ True+    & schema   .~ ParamBody (Ref (Reference refName))+  ]++makeDeleteParams :: [Param]+makeDeleteParams =+  [ makePreferParam ["return=representation", "return=minimal", "return=none"] ]++makePathItem :: (Table, [Column], [Text]) -> (FilePath, PathItem)+makePathItem (t, cs, _) = ("/" ++ unpack tn, p $ tableInsertable t)+  where+    tOp = (mempty :: Operation)+      & tags .~ Set.fromList [tn]+      & produces ?~ makeMimeList [CTApplicationJSON, CTSingularJSON, CTTextCSV]+      & at 200 ?~ "OK"+    getOp = tOp+      & parameters .~ map Inline (makeGetParams cs ++ rs)+      & at 206 ?~ "Partial Content"+    postOp = tOp+      & consumes ?~ makeMimeList [CTApplicationJSON, CTSingularJSON, CTTextCSV]+      & parameters .~ map Inline (makePostParams tn)+      & at 201 ?~ "Created"+    patchOp = tOp+      & consumes ?~ makeMimeList [CTApplicationJSON, CTSingularJSON, CTTextCSV]+      & parameters .~ map Inline (makePostParams tn ++ rs)+      & at 204 ?~ "No Content"+    deletOp = tOp+      & parameters .~ map Inline (makeDeleteParams ++ rs)+    pr = (mempty :: PathItem) & get ?~ getOp+    pw = pr & post ?~ postOp & patch ?~ patchOp & delete ?~ deletOp+    p False = pr+    p True  = pw+    rs = makeRowFilters cs+    tn = tableName t++makeProcPathItem :: ProcDescription -> (FilePath, PathItem)+makeProcPathItem pd = ("/rpc/" ++ toS (pdName pd), pe)+  where+    postOp = (mempty :: Operation)+      & parameters .~ map Inline (makeProcParam $ "(rpc) " <> pdName pd)+      & tags .~ Set.fromList ["(rpc) " <> pdName pd]+      & produces ?~ makeMimeList [CTApplicationJSON, CTSingularJSON]+      & at 200 ?~ "OK"+    pe = (mempty :: PathItem) & post ?~ postOp++makeRootPathItem :: (FilePath, PathItem)+makeRootPathItem = ("/", p)+  where+    getOp = (mempty :: Operation)+      & tags .~ Set.fromList ["/"]+      & produces ?~ makeMimeList [CTOpenAPI]+      & at 200 ?~ "OK"+    pr = (mempty :: PathItem) & get ?~ getOp+    p = pr++makePathItems :: [ProcDescription] -> [(Table, [Column], [Text])] -> InsOrdHashMap FilePath PathItem+makePathItems pds ti = fromList $ makeRootPathItem :+  map makePathItem ti ++ map makeProcPathItem pds++escapeHostName :: Text -> Text+escapeHostName "*"  = "0.0.0.0"+escapeHostName "*4" = "0.0.0.0"+escapeHostName "!4" = "0.0.0.0"+escapeHostName "*6" = "0.0.0.0"+escapeHostName "!6" = "0.0.0.0"+escapeHostName h    = h++postgrestSpec :: [ProcDescription] -> [(Table, [Column], [Text])] -> (Text, Text, Integer, Text) -> Swagger+postgrestSpec pds ti (s, h, p, b) = (mempty :: Swagger)+  & basePath ?~ unpack b+  & schemes ?~ [s']+  & info .~ ((mempty :: Info)+      & version .~ prettyVersion+      & title .~ "PostgREST API"+      & description ?~ "This is a dynamic API generated by PostgREST")+  & host .~ h'+  & definitions .~ fromList (map makeTableDef ti <> map makeProcDef pds)+  & paths .~ makePathItems pds ti+    where+      s' = if s == "http" then Http else Https+      h' = Just $ Host (unpack $ escapeHostName h) (Just (fromInteger p))++encodeOpenAPI :: [ProcDescription] -> [(Table, [Column], [Text])] -> (Text, Text, Integer, Text) -> LByteString+encodeOpenAPI pds ti uri = encode $ postgrestSpec pds ti uri++{-|+  Test whether a proxy uri is malformed or not.+  A valid proxy uri should be an absolute uri without query and user info,+  only http(s) schemes are valid, port number range is 1-65535.++  For example+  http://postgrest.com/openapi.json+  https://postgrest.com:8080/openapi.json+-}+isMalformedProxyUri :: Maybe Text -> Bool+isMalformedProxyUri Nothing =  False+isMalformedProxyUri (Just uri)+  | isAbsoluteURI (toS uri) = not $ isUriValid $ toURI uri+  | otherwise = True++toURI :: Text -> URI+toURI uri = fromJust $ parseURI (toS uri)++pickProxy :: Maybe Text -> Maybe Proxy+pickProxy proxy+  | isNothing proxy = Nothing+  -- should never happen+  -- since the request would have been rejected by the middleware if proxy uri+  -- is malformed+  | isMalformedProxyUri proxy = Nothing+  | otherwise = Just Proxy {+    proxyScheme = scheme+  , proxyHost = host'+  , proxyPort = port''+  , proxyPath = path'+  }+ where+   uri = toURI $ fromJust proxy+   scheme = init $ toLower $ pack $ uriScheme uri+   path URI {uriPath = ""} =  "/"+   path URI {uriPath = p} = p+   path' = pack $ path uri+   authority = fromJust $ uriAuthority uri+   host' = pack $ uriRegName authority+   port' = uriPort authority+   readPort = fromMaybe 80 . readMaybe+   port'' :: Integer+   port'' = case (port', scheme) of+             ("", "http") -> 80+             ("", "https") -> 443+             _ -> readPort $ unpack $ tail $ pack port'++isUriValid:: URI -> Bool+isUriValid = fAnd [isSchemeValid, isQueryValid, isAuthorityValid]++fAnd :: [a -> Bool] -> a -> Bool+fAnd fs x = all ($x) fs++isSchemeValid :: URI -> Bool+isSchemeValid URI {uriScheme = s}+  | toLower (pack s) == "https:" = True+  | toLower (pack s) == "http:" = True+  | otherwise = False++isQueryValid :: URI -> Bool+isQueryValid URI {uriQuery = ""} = True+isQueryValid _ = False++isAuthorityValid :: URI -> Bool+isAuthorityValid URI {uriAuthority = a}+  | isJust a = fAnd [isUserInfoValid, isHostValid, isPortValid] $ fromJust a+  | otherwise = False++isUserInfoValid :: URIAuth -> Bool+isUserInfoValid URIAuth {uriUserInfo = ""} = True+isUserInfoValid _ = False++isHostValid :: URIAuth -> Bool+isHostValid URIAuth {uriRegName = ""} = False+isHostValid _ = True++isPortValid :: URIAuth -> Bool+isPortValid URIAuth {uriPort = ""} = True+isPortValid URIAuth {uriPort = (':':p)} =+  case readMaybe p of+    Just i -> i > (0 :: Integer) && i < 65536+    Nothing -> False+isPortValid _ = False
src/PostgREST/Parsers.hs view
@@ -1,69 +1,68 @@-module PostgREST.Parsers--- ( parseGetRequest--- )-where+module PostgREST.Parsers where -import           Control.Applicative           hiding ((<$>))-import           Data.Monoid-import           Data.String.Conversions       (cs)-import           Data.Text                     (Text, intercalate)+import           Protolude                     hiding (try, intercalate)+import           Control.Monad                ((>>))+import           Data.Text                     (intercalate)+import           Data.List                     (init, last) import           Data.Tree import           PostgREST.QueryBuilder        (operators) import           PostgREST.Types import           Text.ParserCombinators.Parsec hiding (many, (<|>)) import           PostgREST.RangeQuery      (NonnegRange,allRange) -pRequestSelect :: Text -> Parser ReadRequest-pRequestSelect rootNodeName = do-  fieldTree <- pFieldForest-  return $ foldr treeEntry (Node (readQuery, (rootNodeName, Nothing, Nothing)) []) fieldTree-  where-    readQuery = Select [] [rootNodeName] [] Nothing allRange-    treeEntry :: Tree SelectItem -> ReadRequest -> ReadRequest-    treeEntry (Node fld@((fn, _),_,alias) fldForest) (Node (q, i) rForest) =-      case fldForest of-        [] -> Node (q {select=fld:select q}, i) rForest-        _  -> Node (q, i) newForest-          where-            newForest =-              foldr treeEntry (Node (Select [] [fn] [] Nothing allRange, (fn, Nothing, alias)) []) fldForest:rForest+pRequestSelect :: Text -> Text -> Either ParseError ReadRequest+pRequestSelect rootName selStr = +  parse (pReadRequest rootName) ("failed to parse select parameter (" <> toS selStr <> ")") (toS selStr) -pRequestFilter :: (String, String) -> Either ParseError (Path, Filter)+pRequestFilter :: (Text, Text) -> Either ParseError (Path, Filter) pRequestFilter (k, v) = (,) <$> path <*> (Filter <$> fld <*> op <*> val)   where-    treePath = parse pTreePath ("failed to parser tree path (" ++ k ++ ")") k-    opVal = parse pOpValueExp ("failed to parse filter (" ++ v ++ ")") v+    treePath = parse pTreePath ("failed to parser tree path (" ++ toS k ++ ")") $ toS k+    opVal = parse pOpValueExp ("failed to parse filter (" ++ toS v ++ ")") $ toS v     path = fst <$> treePath     fld = snd <$> treePath     op = fst <$> opVal     val = snd <$> opVal -pRequestOrder :: (String, String) -> Either ParseError (Path, [OrderTerm])-pRequestOrder (k, v) = (,) <$> path <*> ord+pRequestOrder :: (Text, Text) -> Either ParseError (Path, [OrderTerm])+pRequestOrder (k, v) = (,) <$> path <*> ord'   where-    treePath = parse pTreePath ("failed to parser tree path (" ++ k ++ ")") k+    treePath = parse pTreePath ("failed to parser tree path (" ++ toS k ++ ")") $ toS k     path = fst <$> treePath-    ord = parse pOrder ("failed to parse order (" ++ v ++ ")") v+    ord' = parse pOrder ("failed to parse order (" ++ toS v ++ ")") $ toS v -pRequestRange :: (String, NonnegRange) -> Either ParseError (Path, NonnegRange)+pRequestRange :: (ByteString, NonnegRange) -> Either ParseError (Path, NonnegRange) pRequestRange (k, v) = (,) <$> path <*> pure v   where-    treePath = parse pTreePath ("failed to parser tree path (" ++ k ++ ")") k+    treePath = parse pTreePath ("failed to parser tree path (" ++ toS k ++ ")") $ toS k     path = fst <$> treePath  ws :: Parser Text-ws = cs <$> many (oneOf " \t")+ws = toS <$> many (oneOf " \t")  lexeme :: Parser a -> Parser a lexeme p = ws *> p <* ws +pReadRequest :: Text -> Parser ReadRequest+pReadRequest rootNodeName = do+  fieldTree <- pFieldForest+  return $ foldr treeEntry (Node (readQuery, (rootNodeName, Nothing, Nothing)) []) fieldTree+  where+    readQuery = Select [] [rootNodeName] [] Nothing allRange+    treeEntry :: Tree SelectItem -> ReadRequest -> ReadRequest+    treeEntry (Node fld@((fn, _),_,alias) fldForest) (Node (q, i) rForest) =+      case fldForest of+        [] -> Node (q {select=fld:select q}, i) rForest+        _  -> Node (q, i) newForest+          where+            newForest =+              foldr treeEntry (Node (Select [] [fn] [] Nothing allRange, (fn, Nothing, alias)) []) fldForest:rForest+ pTreePath :: Parser (Path,Field) pTreePath = do   p <- pFieldName `sepBy1` pDelimiter   jp <- optionMaybe pJsonPath-  let pp = map cs p-      jpp = map cs <$> jp-  return (init pp, (last pp, jpp))+  return (init p, (last p, jp))  pFieldForest :: Parser [Tree SelectItem] pFieldForest = pFieldTree `sepBy1` lexeme (char ',')@@ -73,13 +72,13 @@           <|>     Node <$> pSelect <*> pure []  pStar :: Parser Text-pStar = cs <$> (string "*" *> pure ("*"::String))+pStar = toS <$> (string "*" *> pure ("*"::ByteString))   pFieldName :: Parser Text pFieldName = do   matches <- (many1 (letter <|> digit <|> oneOf "_") `sepBy1` dash) <?> "field name (* or [a..z0..9_])"-  return $ intercalate "-" $ map cs matches+  return $ intercalate "-" $ map toS matches   where     isDash :: GenParser Char st ()     isDash = try ( char '-' >> notFollowedBy (char '>') )@@ -88,10 +87,10 @@   pJsonPathStep :: Parser Text-pJsonPathStep = cs <$> try (string "->" *> pFieldName)+pJsonPathStep = toS <$> try (string "->" *> pFieldName)  pJsonPath :: Parser [Text]-pJsonPath = (++) <$> many pJsonPathStep <*> ( (:[]) <$> (string "->>" *> pFieldName) )+pJsonPath = (<>) <$> many pJsonPathStep <*> ( (:[]) <$> (string "->>" *> pFieldName) )  pField :: Parser Field pField = lexeme $ (,) <$> pFieldName <*> optionMaybe pJsonPath@@ -112,25 +111,25 @@     do       alias <- optionMaybe ( try(pFieldName <* aliasSeparator) )       fld <- pField-      cast <- optionMaybe (string "::" *> many letter)-      return (fld, cs <$> cast, alias)+      cast' <- optionMaybe (string "::" *> many letter)+      return (fld, toS <$> cast', alias)   )   <|> do     s <- pStar     return ((s, Nothing), Nothing, Nothing)  pOperator :: Parser Operator-pOperator = cs <$> (pOp <?> "operator (eq, gt, ...)")-  where pOp = foldl (<|>) empty $ map (try . string . cs . fst) operators+pOperator = toS <$> (pOp <?> "operator (eq, gt, ...)")+  where pOp = foldl (<|>) empty $ map (try . string . toS . fst) operators  pValue :: Parser FValue-pValue = VText <$> (cs <$> many anyChar)+pValue = VText <$> (toS <$> many anyChar)  pDelimiter :: Parser Char pDelimiter = char '.' <?> "delimiter (.)"  pOperatiorWithNegation :: Parser Operator-pOperatiorWithNegation = try ( (<>) <$> ( cs <$> string "not." ) <*>  pOperator) <|> pOperator+pOperatiorWithNegation = try ( (<>) <$> ( toS <$> string "not." ) <*>  pOperator) <|> pOperator  pOpValueExp :: Parser (Operator, FValue) pOpValueExp = (,) <$> pOperatiorWithNegation <*> (pDelimiter *> pValue)@@ -141,14 +140,15 @@ pOrderTerm :: Parser OrderTerm pOrderTerm =   try ( do-    c <- pFieldName-    _ <- pDelimiter-    d <- (string "asc" *> pure OrderAsc)-         <|> (string "desc" *> pure OrderDesc)+    c <- pField+    d <- optionMaybe (try $ pDelimiter *> (+               try(string "asc" *> pure OrderAsc)+           <|> try(string "desc" *> pure OrderDesc)+         ))     nls <- optionMaybe (pDelimiter *> (                  try(string "nullslast" *> pure OrderNullsLast)              <|> try(string "nullsfirst" *> pure OrderNullsFirst)            ))     return $ OrderTerm c d nls   )-  <|> OrderTerm <$> (cs <$> pFieldName) <*> pure OrderAsc <*> pure Nothing+  <|> OrderTerm <$> pField <*> pure Nothing <*> pure Nothing
src/PostgREST/QueryBuilder.hs view
@@ -1,5 +1,4 @@ {-# LANGUAGE FlexibleInstances    #-}-{-# LANGUAGE FlexibleContexts     #-} {-# LANGUAGE TupleSections        #-} {-# OPTIONS_GHC -fno-warn-orphans #-} {-|@@ -13,11 +12,10 @@ Any function that outputs a SQL fragment should be in this module. -} module PostgREST.QueryBuilder (-    addRelations-  , addJoinConditions-  , callProc+    callProc   , createReadStatement   , createWriteStatement+  , getJoinConditions   , operators   , pgFmtIdent   , pgFmtLit@@ -33,32 +31,24 @@ import qualified Hasql.Decoders          as HD  import qualified Data.Aeson              as JSON-import           Data.Int                (Int64)  import           PostgREST.RangeQuery    (NonnegRange, rangeLimit, rangeOffset, allRange)-import           Control.Error           (note, fromMaybe) import           Data.Functor.Contravariant (contramap) import qualified Data.HashMap.Strict     as HM-import           Data.List               (find)-import           Data.Monoid             ((<>))-import           Data.Text               (Text, intercalate, unwords, replace, isInfixOf, toLower, split)+import           Data.Text               (intercalate, unwords, replace, isInfixOf, toLower, split) import qualified Data.Text as T          (map, takeWhile, null) import qualified Data.Text.Encoding as T-import           Data.String.Conversions (cs)-import           Control.Applicative     ((<|>))-import           Control.Monad           (replicateM) import           Data.Tree               (Tree(..)) import qualified Data.Vector as V import           PostgREST.Types import qualified Data.Map as M import           Text.InterpolatedString.Perl6 (qc)-import           Text.Regex.TDFA         ((=~)) import qualified Data.ByteString.Char8   as BS import           Data.Scientific         ( FPFormat (..)                                          , formatScientific                                          , isInteger                                          )-import           Prelude hiding          (unwords)+import           Protolude hiding        (from, intercalate, ord, cast) import           PostgREST.ApiRequest    (PreferRepresentation (..))  {-| The generic query result format used by API responses. The location header@@ -89,25 +79,25 @@   HD.maybeRow standardRow  {-| JSON and CSV payloads from the client are given to us as-    UniformObjects (objects who all have the same keys),+    PayloadJSON (objects who all have the same keys),     and we turn this into an old fasioned JSON array -}-encodeUniformObjs :: HE.Params UniformObjects+encodeUniformObjs :: HE.Params PayloadJSON encodeUniformObjs =-  contramap (JSON.Array . V.map JSON.Object . unUniformObjects) (HE.value HE.json)+  contramap (JSON.Array . V.map JSON.Object . unPayloadJSON) (HE.value HE.json)  createReadStatement :: SqlQuery -> SqlQuery -> Bool -> Bool -> Bool ->                        H.Query () ResultsWithCount createReadStatement selectQuery countQuery isSingle countTotal asCsv =-  unicodeStatement sql HE.unit decodeStandard True+  unicodeStatement sql HE.unit decodeStandard False  where   sql = [qc|       WITH {sourceCTEName} AS ({selectQuery}) SELECT {cols}-      FROM ( SELECT * FROM {sourceCTEName}) t |]+      FROM ( SELECT * FROM {sourceCTEName}) _postgrest_t |]   countResultF = if countTotal then "("<>countQuery<>")" else "null"   cols = intercalate ", " [       countResultF <> " AS total_result_set",-      "pg_catalog.count(t) AS page_total",+      "pg_catalog.count(_postgrest_t) AS page_total",       noLocationF <> " AS header",       bodyF <> " AS body"     ]@@ -116,116 +106,78 @@     | isSingle = asJsonSingleF     | otherwise = asJsonF -createWriteStatement :: QualifiedIdentifier -> SqlQuery -> SqlQuery -> Bool ->-                        PreferRepresentation -> [Text] -> Bool -> Payload ->-                        H.Query UniformObjects (Maybe ResultsWithCount)-createWriteStatement _ _ _ _ _ _ _ (PayloadParseError _) = undefined-createWriteStatement _ _ mutateQuery _ None-                     _ _ (PayloadJSON (UniformObjects _)) =+createWriteStatement :: SqlQuery -> SqlQuery -> Bool -> Bool -> Bool ->+                        PreferRepresentation -> [Text] ->+                        H.Query PayloadJSON (Maybe ResultsWithCount)+createWriteStatement selectQuery mutateQuery wantSingle wantHdrs asCsv rep pKeys =   unicodeStatement sql encodeUniformObjs decodeStandardMay True+  where-  sql = [qc|+  sql = case rep of+    None -> [qc|       WITH {sourceCTEName} AS ({mutateQuery})       SELECT '', 0, {noLocationF}, '' |]--createWriteStatement qi _ mutateQuery isSingle HeadersOnly-                     pKeys _ (PayloadJSON (UniformObjects _)) =-  unicodeStatement sql encodeUniformObjs decodeStandardMay True- where-  sql = [qc|-      WITH {sourceCTEName} AS ({mutateQuery} RETURNING {fromQi qi}.*)+    HeadersOnly -> [qc|+      WITH {sourceCTEName} AS ({mutateQuery})       SELECT {cols}-      FROM (SELECT 1 FROM {sourceCTEName}) t |]-  cols = intercalate ", " [-      "'' AS total_result_set",-      "pg_catalog.count(t) AS page_total",-      if isSingle then locationF pKeys else noLocationF,-      "''"-    ]--createWriteStatement qi selectQuery mutateQuery isSingle Full-                     pKeys asCsv (PayloadJSON (UniformObjects _)) =-  unicodeStatement sql encodeUniformObjs decodeStandardMay True- where-  sql = [qc|-      WITH {sourceCTEName} AS ({mutateQuery} RETURNING {fromQi qi}.*)+      FROM (SELECT 1 FROM {sourceCTEName}) _postgrest_t |]+    Full -> [qc|+      WITH {sourceCTEName} AS ({mutateQuery})       SELECT {cols}-      FROM ({selectQuery}) t |]+      FROM ({selectQuery}) _postgrest_t |]+   cols = intercalate ", " [       "'' AS total_result_set", -- when updateing it does not make sense-      "pg_catalog.count(t) AS page_total",-      if isSingle then locationF pKeys else noLocationF <> " AS header",-      bodyF <> " AS body"+      "pg_catalog.count(_postgrest_t) AS page_total",+      if wantHdrs+         then locationF pKeys+         else noLocationF <> " AS header",+      if rep == Full+         then bodyF <> " AS body"+         else "''"     ]+   bodyF     | asCsv = asCsvF-    | isSingle = asJsonSingleF+    | wantSingle = asJsonSingleF     | otherwise = asJsonF -addRelations :: Schema -> [Relation] -> Maybe ReadRequest -> ReadRequest -> Either Text ReadRequest-addRelations schema allRelations parentNode node@(Node readNode@(query, (name, _, alias)) forest) =-  case parentNode of-    (Just (Node (Select{from=[parentTable]}, (_, _, _)) _)) -> Node <$> (addRel readNode <$> rel) <*> updatedForest-      where-        rel = note ("no relation between " <> parentTable <> " and " <> name)-            $  findRelationByTable schema name parentTable-           <|> findRelationByColumn schema parentTable name-        addRel :: (ReadQuery, (NodeName, Maybe Relation, Maybe Alias)) -> Relation -> (ReadQuery, (NodeName, Maybe Relation, Maybe Alias))-        addRel (query', (n, _, a)) r = (query' {from=fromRelation}, (n, Just r, a))-          where fromRelation = map (\t -> if t == n then tableName (relTable r) else t) (from query')--    _ -> Node (query, (name, Nothing, alias)) <$> updatedForest-  where-    updatedForest = mapM (addRelations schema allRelations (Just node)) forest-    -- Searches through all the relations and returns a match given the parameter conditions.-    -- Will only find a relation where both schemas are in the PostgREST schema.-    -- `findRelationByColumn` also does a ducktype check to see if the column name has any variation of `id` or `fk`. If so then the relation is returned as a match.-    findRelationByTable s t1 t2 =-      find (\r -> s == tableSchema (relTable r) && s == tableSchema (relFTable r) && t1 == tableName (relTable r) && t2 == tableName (relFTable r)) allRelations-    findRelationByColumn s t c =-      find (\r -> s == tableSchema (relTable r) && s == tableSchema (relFTable r) && t == tableName (relFTable r) && length (relFColumns r) == 1 && c `colMatches` (colName . head . relFColumns) r) allRelations-      where n `colMatches` rc = (cs ("^" <> rc <> "_?(?:|[iI][dD]|[fF][kK])$") :: BS.ByteString) =~ (cs n :: BS.ByteString)--addJoinConditions :: Schema -> ReadRequest -> Either Text ReadRequest-addJoinConditions schema (Node nn@(query, (n, r, a)) forest) =-  case r of-    Nothing -> Node nn  <$> updatedForest -- this is the root node-    Just rel@Relation{relType=Child} -> Node (addCond query (getJoinConditions rel),(n,r,a)) <$> updatedForest-    Just Relation{relType=Parent} -> Node nn <$> updatedForest-    Just rel@Relation{relType=Many, relLTable=(Just linkTable)} ->-      Node (qq, (n, r, a)) <$> updatedForest-      where-         query' = addCond query (getJoinConditions rel)-         qq = query'{from=tableName linkTable : from query'}-    _ -> Left "unknown relation"-  where-    updatedForest = mapM (addJoinConditions schema) forest-    addCond query' con = query'{flt_=con ++ flt_ query'}--type ProcResults = (Maybe Int64, Int64, JSON.Value)-callProc :: QualifiedIdentifier -> JSON.Object -> NonnegRange -> Bool -> H.Query () (Maybe ProcResults)-callProc qi params range countTotal =+type ProcResults = (Maybe Int64, Int64, ByteString)+callProc :: QualifiedIdentifier -> JSON.Object -> SqlQuery -> SqlQuery -> NonnegRange -> Bool -> Bool -> Bool -> H.Query () (Maybe ProcResults)+callProc qi params selectQuery countQuery _ countTotal isSingle paramsAsJson =   unicodeStatement sql HE.unit decodeProc True   where     sql = [qc|-            WITH t AS (select * {_callSql})+            WITH {sourceCTEName} AS ({_callSql})             SELECT-              {_countExpr} as countTotal,-              pg_catalog.count(1) as countResult,-              array_to_json(-                coalesce(array_agg(row_to_json(r)), '\{}')-              )::character varying-            FROM (select * from t {limitF range}) r;+              {countResultF} AS total_result_set,+              pg_catalog.count(_postgrest_t) AS page_total,+              case+                when pg_catalog.count(*) > 1 then+                  {bodyF}+                else+                  coalesce(((array_agg(row_to_json(_postgrest_t)))[1]->{_procName})::character varying, {bodyF})++              end as body+            FROM ({selectQuery}) _postgrest_t;           |]-    _args = intercalate "," $ map _assignment (HM.toList params)+          -- FROM (select * from {sourceCTEName} {limitF range}) t;+    countResultF = if countTotal then "("<>countQuery<>")" else "null::bigint" :: Text+    _args = if paramsAsJson+                then insertableValueWithType "json" $ JSON.Object params+                else intercalate "," $ map _assignment (HM.toList params)+    _procName = pgFmtLit $ qiName qi     _assignment (n,v) = pgFmtIdent n <> ":=" <> insertableValue v-    _callSql = [qc| from {fromQi qi}({_args}) |] :: Text+    _callSql = [qc|select * from {fromQi qi}({_args}) |] :: Text     _countExpr = if countTotal-                   then "(select pg_catalog.count(1) from t)"+                   then [qc|(select pg_catalog.count(*) from {sourceCTEName})|]                    else "null::bigint" :: Text     decodeProc = HD.maybeRow procRow     procRow = (,,) <$> HD.nullableValue HD.int8 <*> HD.value HD.int8-                   <*> HD.value HD.json+                   <*> HD.value HD.bytea+    bodyF+     | isSingle = asJsonSingleF+     | otherwise = asJsonF  operators :: [(Text, SqlFragment)] operators = [@@ -247,7 +199,7 @@   ]  pgFmtIdent :: SqlFragment -> SqlFragment-pgFmtIdent x = "\"" <> replace "\"" "\"\"" (trimNullChars $ cs x) <> "\""+pgFmtIdent x = "\"" <> replace "\"" "\"\"" (trimNullChars $ toS x) <> "\""  pgFmtLit :: SqlFragment -> SqlFragment pgFmtLit x =@@ -262,22 +214,23 @@ requestToCountQuery _ (DbMutate _) = undefined requestToCountQuery schema (DbRead (Node (Select _ _ conditions _ _, (mainTbl, _, _)) _)) =  unwords [-   "SELECT pg_catalog.count(1)",-   "FROM ", fromQi $ QualifiedIdentifier schema mainTbl,-   ("WHERE " <> intercalate " AND " ( map (pgFmtCondition (QualifiedIdentifier schema mainTbl)) localConditions )) `emptyOnNull` localConditions+   "SELECT pg_catalog.count(*)",+   "FROM ", fromQi qi,+   ("WHERE " <> intercalate " AND " ( map (pgFmtCondition qi) localConditions )) `emptyOnNull` localConditions    ]  where+   qi = if mainTbl == sourceCTEName+     then QualifiedIdentifier "" mainTbl+     else QualifiedIdentifier schema mainTbl    fn Filter{value=VText _} = True    fn Filter{value=VForeignKey _ _} = False    localConditions = filter fn conditions -requestToQuery :: Schema -> DbRequest -> SqlQuery-requestToQuery _ (DbMutate (Insert _ (PayloadParseError _))) = undefined-requestToQuery _ (DbMutate (Update _ (PayloadParseError _) _)) = undefined-requestToQuery schema (DbRead (Node (Select colSelects tbls conditions ord range, (nodeName, maybeRelation, _)) forest)) =+requestToQuery :: Schema -> Bool -> DbRequest -> SqlQuery+requestToQuery schema isParent (DbRead (Node (Select colSelects tbls conditions ord range, (nodeName, maybeRelation, _)) forest)) =   query   where-    -- TODO! the folloing helper functions are just to remove the "schema" part when the table is "source" which is the name+    -- TODO! the following helper functions are just to remove the "schema" part when the table is "source" which is the name     -- of our WITH query part     mainTbl = fromMaybe nodeName (tableName . relTable <$> maybeRelation)     tblSchema tbl = if tbl == sourceCTEName then "" else schema@@ -289,7 +242,7 @@       unwords joins,       ("WHERE " <> intercalate " AND " ( map (pgFmtCondition qi ) conditions )) `emptyOnNull` conditions,       orderF (fromMaybe [] ord),-      limitF range+      if isParent then "" else limitF range       ]     orderF ts =         if null ts@@ -299,9 +252,9 @@             clause = intercalate "," (map queryTerm ts)             queryTerm :: OrderTerm -> Text             queryTerm t = " "-                <> cs (pgFmtColumn qi $ otTerm t) <> " "-                <> (cs.show) (otDirection t) <> " "-                <> maybe "" (cs.show) (otNullOrder t) <> " "+                <> toS (pgFmtField qi $ otTerm t) <> " "+                <> maybe "" show (otDirection t) <> " "+                <> maybe "" show (otNullOrder t) <> " "     (joins, selects) = foldr getQueryParts ([],[]) forest      getQueryParts :: Tree ReadNode -> ([SqlFragment], [SqlFragment]) -> ([SqlFragment], [SqlFragment])@@ -311,7 +264,7 @@            <> "SELECT array_to_json(array_agg(row_to_json("<>pgFmtIdent table<>"))) "            <> "FROM (" <> subquery <> ") " <> pgFmtIdent table            <> "), '[]') AS " <> pgFmtIdent (fromMaybe name alias)-           where subquery = requestToQuery schema (DbRead (Node n forst))+           where subquery = requestToQuery schema False (DbRead (Node n forst))     getQueryParts (Node n@(_, (name, Just r@Relation{relType=Parent,relTable=Table{tableName=table}}, alias)) forst) (j,s) = (joi:j,sel:s)       where         node_name = fromMaybe name alias@@ -321,31 +274,34 @@         sel = "row_to_json(" <> pgFmtIdent local_table_name <> ".*) AS " <> pgFmtIdent node_name         joi = " LEFT OUTER JOIN ( " <> subquery <> " ) AS " <> pgFmtIdent local_table_name  <>               " ON " <> intercalate " AND " ( map (pgFmtCondition qi . replaceTableName local_table_name) (getJoinConditions r) )-          where subquery = requestToQuery schema (DbRead (Node n forst))+          where subquery = requestToQuery schema True (DbRead (Node n forst))     getQueryParts (Node n@(_, (name, Just Relation{relType=Many,relTable=Table{tableName=table}}, alias)) forst) (j,s) = (j,sel:s)       where         sel = "COALESCE (("            <> "SELECT array_to_json(array_agg(row_to_json("<>pgFmtIdent table<>"))) "            <> "FROM (" <> subquery <> ") " <> pgFmtIdent table            <> "), '[]') AS " <> pgFmtIdent (fromMaybe name alias)-           where subquery = requestToQuery schema (DbRead (Node n forst))+           where subquery = requestToQuery schema False (DbRead (Node n forst))     --the following is just to remove the warning     --getQueryParts is not total but requestToQuery is called only after addJoinConditions which ensures the only     --posible relations are Child Parent Many-    getQueryParts (Node (_,(_,Nothing,_)) _) _ = undefined-requestToQuery schema (DbMutate (Insert mainTbl (PayloadJSON (UniformObjects rows)))) =-  let qi = QualifiedIdentifier schema mainTbl-      cols = map pgFmtIdent $ fromMaybe [] (HM.keys <$> (rows V.!? 0))-      colsString = intercalate ", " cols-      insInto = unwords [ "INSERT INTO" , fromQi qi,-          if T.null colsString then "" else "(" <> colsString <> ")"-        ]-      vals = unwords $ if T.null colsString-                then ["DEFAULT VALUES"]-                else ["SELECT", colsString, "FROM json_populate_recordset(null::" , fromQi qi, ", $1)"] in-  insInto <> vals--requestToQuery schema (DbMutate (Update mainTbl (PayloadJSON (UniformObjects rows)) conditions)) =+    getQueryParts _ _ = undefined+requestToQuery schema _ (DbMutate (Insert mainTbl (PayloadJSON rows) returnings)) =+  insInto <> vals <> ret+  where qi = QualifiedIdentifier schema mainTbl+        cols = map pgFmtIdent $ fromMaybe [] (HM.keys <$> (rows V.!? 0))+        colsString = intercalate ", " cols+        insInto = unwords [ "INSERT INTO" , fromQi qi,+            if T.null colsString then "" else "(" <> colsString <> ")"+          ]+        vals = unwords $+          if T.null colsString+            then if V.null rows then ["SELECT null WHERE false"] else ["DEFAULT VALUES"]+            else ["SELECT", colsString, "FROM json_populate_recordset(null::" , fromQi qi, ", $1)"]+        ret = if null returnings +                  then ""+                  else unwords [" RETURNING ", intercalate ", " (map (pgFmtColumn qi) returnings)]+requestToQuery schema _ (DbMutate (Update mainTbl (PayloadJSON rows) conditions returnings)) =   case rows V.!? 0 of     Just obj ->       let assignments = map@@ -353,18 +309,20 @@       unwords [         "UPDATE ", fromQi qi,         " SET " <> intercalate "," assignments <> " ",-        ("WHERE " <> intercalate " AND " ( map (pgFmtCondition qi ) conditions )) `emptyOnNull` conditions+        ("WHERE " <> intercalate " AND " ( map (pgFmtCondition qi ) conditions )) `emptyOnNull` conditions,+        ("RETURNING " <> intercalate ", " (map (pgFmtColumn qi) returnings)) `emptyOnNull` returnings         ]     Nothing -> undefined   where     qi = QualifiedIdentifier schema mainTbl-requestToQuery schema (DbMutate (Delete mainTbl conditions)) =+requestToQuery schema _ (DbMutate (Delete mainTbl conditions returnings)) =   query   where     qi = QualifiedIdentifier schema mainTbl     query = unwords [       "DELETE FROM ", fromQi qi,-      ("WHERE " <> intercalate " AND " ( map (pgFmtCondition qi ) conditions )) `emptyOnNull` conditions+      ("WHERE " <> intercalate " AND " ( map (pgFmtCondition qi ) conditions )) `emptyOnNull` conditions,+      ("RETURNING " <> intercalate ", " (map (pgFmtColumn qi) returnings)) `emptyOnNull` returnings       ]  sourceCTEName :: SqlFragment@@ -373,9 +331,9 @@ unquoted :: JSON.Value -> Text unquoted (JSON.String t) = t unquoted (JSON.Number n) =-  cs $ formatScientific Fixed (if isInteger n then Just 0 else Nothing) n-unquoted (JSON.Bool b) = cs . show $ b-unquoted v = cs $ JSON.encode v+  toS $ formatScientific Fixed (if isInteger n then Just 0 else Nothing) n+unquoted (JSON.Bool b) = show b+unquoted v = toS $ JSON.encode v  -- private functions asCsvF :: SqlFragment@@ -390,13 +348,13 @@       "    ) s" <>       "  ) a" <>       ")"-    asCsvBodyF = "coalesce(string_agg(substring(t::text, 2, length(t::text) - 2), '\n'), '')"+    asCsvBodyF = "coalesce(string_agg(substring(_postgrest_t::text, 2, length(_postgrest_t::text) - 2), '\n'), '')"  asJsonF :: SqlFragment-asJsonF = "coalesce(array_to_json(array_agg(row_to_json(t))), '[]')::character varying"+asJsonF = "coalesce(array_to_json(array_agg(row_to_json(_postgrest_t))), '[]')::character varying"  asJsonSingleF :: SqlFragment --TODO! unsafe when the query actually returns multiple rows, used only on inserting and returning single element-asJsonSingleF = "coalesce(string_agg(row_to_json(t)::text, ','), '')::character varying "+asJsonSingleF = "coalesce(string_agg(row_to_json(_postgrest_t)::text, ','), '')::character varying "  locationF :: [Text] -> SqlFragment locationF pKeys =@@ -415,8 +373,8 @@   then ""   else "LIMIT " <> limit <> " OFFSET " <> offset   where-    limit  = maybe "ALL" (cs . show) $ rangeLimit r-    offset = cs . show $ rangeOffset r+    limit  = maybe "ALL" show $ rangeLimit r+    offset = show $ rangeOffset r  fromQi :: QualifiedIdentifier -> SqlFragment fromQi t = (if s == "" then "" else pgFmtIdent s <> ".") <> pgFmtIdent n@@ -430,6 +388,7 @@     Child  -> zipWith (toFilter tN ftN) cols fcs     Parent -> zipWith (toFilter tN ftN) cols fcs     Many   -> zipWith (toFilter tN ltN) cols (fromMaybe [] lc1) ++ zipWith (toFilter ftN ltN) fcs (fromMaybe [] lc2)+    Root   -> undefined --error "undefined getJoinConditions"   where     s = if typ == Parent then "" else tableSchema t     tN = tableName t@@ -448,9 +407,13 @@ insertableValue JSON.Null = "null" insertableValue v = (<> "::unknown") . pgFmtLit $ unquoted v +insertableValueWithType :: Text -> JSON.Value -> SqlFragment+insertableValueWithType t v =+  pgFmtLit (unquoted v) <> "::" <> t+ whiteList :: Text -> SqlFragment whiteList val = fromMaybe-  (cs (pgFmtLit val) <> "::unknown ")+  (toS (pgFmtLit val) <> "::unknown ")   (find ((==) . toLower $ val) ["null","true","false"])  pgFmtColumn :: QualifiedIdentifier -> Text -> SqlFragment@@ -471,7 +434,7 @@   where     headPredicate:rest = split (=='.') ops     hasNot caseTrue caseFalse = if headPredicate == "not" then caseTrue else caseFalse-    opCode      = hasNot (head rest) headPredicate+    opCode      = hasNot (headDef "eq" rest) headPredicate     notOp       = hasNot headPredicate ""     sqlCol = case val of       VText _ -> pgFmtColumn table col <> pgFmtJsonPath jp@@ -511,7 +474,9 @@  pgFmtAs :: Maybe JsonPath -> Maybe Alias -> SqlFragment pgFmtAs Nothing Nothing = ""-pgFmtAs (Just xx) Nothing = " AS " <> pgFmtIdent (last xx)+pgFmtAs (Just xx) Nothing = case lastMay xx of+  Just alias -> " AS " <> pgFmtIdent alias+  Nothing -> "" pgFmtAs _ (Just alias) = " AS " <> pgFmtIdent alias  trimNullChars :: Text -> Text
src/PostgREST/RangeQuery.hs view
@@ -17,13 +17,11 @@ import           Data.Ranged.Boundaries import           Data.Ranged.Ranges -import           Data.String.Conversions   (cs)-import           Text.Read                 (readMaybe) import           Text.Regex.TDFA           ((=~)) -import           Data.Maybe                (fromMaybe, listToMaybe)+import Data.List (lookup) -import           Prelude+import           Protolude  type NonnegRange = Range Integer @@ -33,9 +31,9 @@    case listToMaybe (range =~ rangeRegex :: [[BS.ByteString]]) of     Just parsedRange ->-      let [_, from, to] = readMaybe . cs <$> parsedRange-          lower         = fromMaybe emptyRange   (rangeGeq <$> from)-          upper         = fromMaybe allRange (rangeLeq <$> to) in+      let [_, mLower, mUpper] = readMaybe . toS <$> parsedRange+          lower         = fromMaybe emptyRange   (rangeGeq <$> mLower)+          upper         = fromMaybe allRange (rangeLeq <$> mUpper) in       rangeIntersection lower upper     Nothing -> allRange @@ -52,14 +50,14 @@ rangeLimit :: NonnegRange -> Maybe Integer rangeLimit range =   case [rangeLower range, rangeUpper range] of-    [BoundaryBelow from, BoundaryAbove to] -> Just (1 + to - from)+    [BoundaryBelow lower, BoundaryAbove upper] -> Just (1 + upper - lower)     _ -> Nothing  rangeOffset :: NonnegRange -> Integer rangeOffset range =   case rangeLower range of-    BoundaryBelow from -> from-    _ -> error "range without lower bound" -- should never happen+    BoundaryBelow lower -> lower+    _ -> panic "range without lower bound" -- should never happen  rangeGeq :: Integer -> NonnegRange rangeGeq n =
src/PostgREST/Types.hs view
@@ -1,9 +1,8 @@ module PostgREST.Types where+import           Protolude+import qualified GHC.Show import           Data.Aeson-import qualified Data.ByteString      as BS import qualified Data.ByteString.Lazy as BL-import           Data.Int             (Int32)-import           Data.Text import           Data.Tree import qualified Data.Vector          as V import           PostgREST.RangeQuery (NonnegRange)@@ -13,8 +12,21 @@ , dbColumns     :: [Column] , dbRelations   :: [Relation] , dbPrimaryKeys :: [PrimaryKey]+, dbProcs       :: [(Text,ProcDescription)] } deriving (Show, Eq) +data PgArg = PgArg {+  pgaName :: Text+, pgaType :: Text+, pgaReq  :: Bool+} deriving (Show, Eq)++data ProcDescription = ProcDescription {+  pdName       :: Text+, pdArgs       :: [PgArg]+, pdReturnType :: Text+} deriving (Show, Eq)+ type Schema = Text type TableName = Text type SqlQuery = Text@@ -64,8 +76,8 @@   show OrderNullsLast  = "nulls last"  data OrderTerm = OrderTerm {-  otTerm      :: Text-, otDirection :: OrderDirection+  otTerm      :: Field+, otDirection :: Maybe OrderDirection , otNullOrder :: Maybe OrderNulls } deriving (Show, Eq) @@ -75,7 +87,7 @@ } deriving (Show, Eq)  -data RelationType = Child | Parent | Many deriving (Show, Eq)+data RelationType = Child | Parent | Many | Root deriving (Show, Eq) data Relation = Relation {   relTable    :: Table , relColumns  :: [Column]@@ -89,18 +101,18 @@  -- | An array of JSON objects that has been verified to have -- the same keys in every object-newtype UniformObjects = UniformObjects (V.Vector Object)+newtype PayloadJSON = PayloadJSON (V.Vector Object)   deriving (Show, Eq) -unUniformObjects :: UniformObjects -> V.Vector Object-unUniformObjects (UniformObjects objs) = objs+unPayloadJSON :: PayloadJSON -> V.Vector Object+unPayloadJSON (PayloadJSON objs) = objs --- | When Hasql supports the COPY command then we can--- have a special payload just for CSV, but until--- then CSV is converted to a JSON array.-data Payload = PayloadJSON UniformObjects-             | PayloadParseError BS.ByteString-             deriving (Show, Eq)+data Proxy = Proxy {+  proxyScheme     :: Text+, proxyHost       :: Text+, proxyPort       :: Integer+, proxyPath       :: Text+} deriving (Show, Eq)  type Operator = Text data FValue = VText Text | VForeignKey QualifiedIdentifier ForeignKey deriving (Show, Eq)@@ -113,9 +125,9 @@ type SelectItem = (Field, Maybe Cast, Maybe Alias) type Path = [Text] data ReadQuery = Select { select::[SelectItem], from::[TableName], flt_::[Filter], order::Maybe [OrderTerm], range_::NonnegRange } deriving (Show, Eq)-data MutateQuery = Insert { in_::TableName, qPayload::Payload }-                 | Delete { in_::TableName, where_::[Filter] }-                 | Update { in_::TableName, qPayload::Payload, where_::[Filter] } deriving (Show, Eq)+data MutateQuery = Insert { in_::TableName, qPayload::PayloadJSON, returning::[FieldName] }+                 | Delete { in_::TableName, where_::[Filter], returning::[FieldName] }+                 | Update { in_::TableName, qPayload::PayloadJSON, where_::[Filter], returning::[FieldName] } deriving (Show, Eq) data Filter = Filter {field::Field, operator::Operator, value::FValue} deriving (Show, Eq) type ReadNode = (ReadQuery, (NodeName, Maybe Relation, Maybe Alias)) type ReadRequest = Tree ReadNode
test/Feature/AuthSpec.hs view
@@ -1,6 +1,7 @@ module Feature.AuthSpec where  -- {{{ Imports+import Text.Heredoc import Test.Hspec import Test.Hspec.Wai import Test.Hspec.Wai.JSON@@ -8,10 +9,13 @@  import SpecHelper import Network.Wai (Application)++import Protolude hiding (get) -- }}}  spec :: SpecWith Application spec = describe "authorization" $ do+  let single = ("Accept","application/vnd.pgrst.object+json")    it "denies access to tables that anonymous does not own" $     get "/authors_only" `shouldRespondWith` ResponseMatcher {@@ -38,17 +42,18 @@       }    it "returns jwt functions as jwt tokens" $-    post "/rpc/login" [json| { "id": "jdoe", "pass": "1234" } |]+    request methodPost "/rpc/login" [single]+      [json| { "id": "jdoe", "pass": "1234" } |]       `shouldRespondWith` ResponseMatcher {-          matchBody = Just [json| {"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0"} |]+          matchBody = Just [json| {"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xuYW1lIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.P2G9EVSVI22MWxXWFuhEYd9BZerLS1WDlqzdqplM15s"} |]         , matchStatus = 200         , matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8"]         }    it "sql functions can encode custom and standard claims" $-    post "/rpc/jwt_test" "{}"+    request methodPost  "/rpc/jwt_test" [single] "{}"       `shouldRespondWith` ResponseMatcher {-          matchBody = Just [json| {"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJmdW4iLCJqdGkiOiJmb28iLCJuYmYiOjEzMDA4MTkzODAsImV4cCI6MTMwMDgxOTM4MCwiaHR0cDovL3Bvc3RncmVzdC5jb20vZm9vIjp0cnVlLCJpc3MiOiJqb2UiLCJyb2xlIjoicG9zdGdyZXN0X3Rlc3QiLCJpYXQiOjEzMDA4MTkzODAsImF1ZCI6ImV2ZXJ5b25lIn0._tQCF79-ZZGMlLktd3csM_bVaiMg7A8YvIb6K2hcu5w"} |]+          matchBody = Just [json| {"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJqb2UiLCJzdWIiOiJmdW4iLCJhdWQiOiJldmVyeW9uZSIsImV4cCI6MTMwMDgxOTM4MCwibmJmIjoxMzAwODE5MzgwLCJpYXQiOjEzMDA4MTkzODAsImp0aSI6ImZvbyIsInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdCIsImh0dHA6Ly9wb3N0Z3Jlc3QuY29tL2ZvbyI6dHJ1ZX0.IHF16ZSU6XTbOnUWO8CCpUn2fJwt8P00rlYVyXQjpWc"} |]         , matchStatus = 200         , matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8"]         }@@ -56,10 +61,7 @@   it "sql functions can read custom and standard claims variables" $ do     let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJmdW4iLCJqdGkiOiJmb28iLCJuYmYiOjEzMDA4MTkzODAsImV4cCI6OTk5OTk5OTk5OSwiaHR0cDovL3Bvc3RncmVzdC5jb20vZm9vIjp0cnVlLCJpc3MiOiJqb2UiLCJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWF0IjoxMzAwODE5MzgwLCJhdWQiOiJldmVyeW9uZSJ9.AQmCA7CMScvfaDRMqRPeUY6eNf--69gpW-kxaWfq9X0"     request methodPost "/rpc/reveal_big_jwt" [auth] "{}"-      `shouldRespondWith` [json| [-          {"sub":"fun", "jti":"foo", "nbf":1300819380, "exp":9999999999,-          "http://postgrest.com/foo":true, "iss":"joe", "iat":1300819380,-          "aud":"everyone"}] |]+      `shouldRespondWith` [str|[{"iss":"joe","sub":"fun","aud":"everyone","exp":9999999999,"nbf":1300819380,"iat":1300819380,"jti":"foo","http://postgrest.com/foo":true}]|]    it "allows users with permissions to see their tables" $ do     let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0"@@ -80,26 +82,72 @@   it "fails with an expired token" $ do     let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE0NDY2NzgxNDksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UifQ.enk_qZ_u6gZsXY4R8bREKB_HNExRpM0lIWSLktk9JJQ"     request methodGet "/authors_only" [auth] ""-      `shouldRespondWith` 400+      `shouldRespondWith` ResponseMatcher {+          matchBody = Nothing+        , matchStatus = 401+        , matchHeaders = [+            "WWW-Authenticate" <:>+            "Bearer error=\"invalid_token\", error_description=\"JWT expired\""+          ]+        }    it "hides tables from users with invalid JWT" $ do     let auth = authHeaderJWT "ey9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0"     request methodGet "/authors_only" [auth] ""-      `shouldRespondWith` 400+      `shouldRespondWith` ResponseMatcher {+          matchBody = Nothing+        , matchStatus = 401+        , matchHeaders = [+            "WWW-Authenticate" <:>+            "Bearer error=\"invalid_token\", error_description=\"JWT invalid\""+          ]+        }    it "should fail when jwt contains no claims" $ do-    let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.MKYc_lOECtB0LJOiykilAdlHodB-I0_id2qHKq35dmc"+    let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.lu-rG8aSCiw-aOlN0IxpRGz5r7Jwq7K9r3tuMPUpytI"     request methodGet "/authors_only" [auth] ""-      `shouldRespondWith` 400+      `shouldRespondWith` 401    it "hides tables from users with JWT that contain no claims about role" $ do-    let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Impkb2UifQ.zyohGMnrDy4_8eJTl6I2AUXO3MeCCiwR24aGWRkTE9o"+    let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Impkb2UifQ.Jneso9X519Vh0z7i9PbXIu7W1HEoq9RRw9BBbyQKFCQ"     request methodGet "/authors_only" [auth] ""-      `shouldRespondWith` 400+      `shouldRespondWith` 401 -  it "recovers after 400 error with logged in user" $ do+  it "recovers after 401 error with logged in user" $ do     _ <- post "/authors_only" [json| { "owner": "jdoe", "secret": "test content" } |]     let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0"     _ <- request methodPost "/rpc/problem" [auth] ""     request methodGet "/authors_only" [auth] ""       `shouldRespondWith` 200++  describe "custom pre-request proc acting on id claim" $ do++    it "able to switch to postgrest_test_author role (id=1)" $+      let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MX0.mI2HNoOum6xM3sc4oHLxU4yLv-_WV5W1kqBfY_wEvLw" in+      request methodPost "/rpc/get_current_user" [auth]+        [json| {} |]+         `shouldRespondWith` ResponseMatcher {+            matchBody    = Just [str|"postgrest_test_author"|]+          , matchStatus = 200+          , matchHeaders = []+          }++    it "able to switch to postgrest_test_default_role (id=2)" $+      let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Mn0.W7jLsG-zswM91AJkCvZeIMHrnz7_6ceY2jnscVl3Yhk" in+      request methodPost "/rpc/get_current_user" [auth]+        [json| {} |]+         `shouldRespondWith` ResponseMatcher {+            matchBody    = Just [str|"postgrest_test_default_role"|]+          , matchStatus = 200+          , matchHeaders = []+          }++    it "raises error (id=3)" $+      let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6M30.15Gy8PezQhJIaHYDJVLa-Gmz9T3sJnW66EKAYIsXc7c" in+      request methodPost "/rpc/get_current_user" [auth]+        [json| {} |]+         `shouldRespondWith` ResponseMatcher {+            matchBody    = Just [str|{"hint":"Please contact administrator","details":null,"code":"P0001","message":"Disabled ID --> 3"}|]+          , matchStatus = 400+          , matchHeaders = []+          }
+ test/Feature/BinaryJwtSecretSpec.hs view
@@ -0,0 +1,21 @@+module Feature.BinaryJwtSecretSpec where++-- {{{ Imports+import Test.Hspec+import Test.Hspec.Wai+import Network.HTTP.Types++import SpecHelper+import Network.Wai (Application)++import Protolude hiding (get)+-- }}}++spec :: SpecWith Application+spec = describe "server started with binary JWT secret" $++  -- this test will stop working 9999999999s after the UNIX EPOCH+  it "succeeds with jwt token encoded with a binary secret" $ do+    let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UifQ.l_EcSRWeNtL4OKUTIplrHyioNrff9Rd0MV7RXNCxCyk"+    request methodGet "/authors_only" [auth] ""+      `shouldRespondWith` 200
test/Feature/ConcurrentSpec.hs view
@@ -16,6 +16,8 @@  import Network.Wai (Application) +import Protolude hiding (get)+ spec :: SpecWith Application spec =   describe "Queryiny in parallel" $
test/Feature/CorsSpec.hs view
@@ -10,6 +10,8 @@  import Network.HTTP.Types import Network.Wai (Application)++import Protolude hiding (get) -- }}}  spec :: SpecWith Application@@ -69,4 +71,4 @@         liftIO $ do           simpleHeaders r `shouldSatisfy` matchHeader             "Access-Control-Allow-Origin" "\\*"-          simpleBody r `shouldSatisfy` not . BL.null+          simpleBody r `shouldSatisfy` BL.null
test/Feature/DeleteSpec.hs view
@@ -7,6 +7,8 @@ import Network.HTTP.Types import Network.Wai (Application) +import Protolude hiding (get)+ spec :: SpecWith Application spec =   describe "Deleting" $ do@@ -16,11 +18,11 @@           `shouldRespondWith` ResponseMatcher {             matchBody    = Nothing           , matchStatus  = 204-          , matchHeaders = ["Content-Range" <:> "*/1"]+          , matchHeaders = ["Content-Range" <:> "*/*"]           } -      it "returns the deleted item" $-        request methodDelete "/items?id=eq.2" [("Prefer", "return=representation")] ""+      it "returns the deleted item and count if requested" $+        request methodDelete "/items?id=eq.2" [("Prefer", "return=representation"), ("Prefer", "count=exact")] ""           `shouldRespondWith` ResponseMatcher {             matchBody    = Just [str|[{"id":2}]|]           , matchStatus  = 200@@ -31,14 +33,17 @@           `shouldRespondWith` ResponseMatcher {             matchBody    = Just [str|[{"id":2,"name":"Two"}]|]           , matchStatus  = 200-          , matchHeaders = ["Content-Range" <:> "*/1"]+          , matchHeaders = ["Content-Range" <:> "*/*"]           }+      it "can rename and cast the selected columns" $+        request methodDelete "/complex_items?id=eq.3&select=ciId:id::text,ciName:name" [("Prefer", "return=representation")] ""+          `shouldRespondWith` [str|[{"ciId":"3","ciName":"Three"}]|]       it "can embed (parent) entities" $         request methodDelete "/tasks?id=eq.8&select=id,name,project{id}" [("Prefer", "return=representation")] ""           `shouldRespondWith` ResponseMatcher {             matchBody    = Just [str|[{"id":8,"name":"Code OSX","project":{"id":4}}]|]           , matchStatus  = 200-          , matchHeaders = ["Content-Range" <:> "*/1"]+          , matchHeaders = ["Content-Range" <:> "*/*"]           }        it "actually clears items ouf the db" $ do@@ -47,12 +52,18 @@           `shouldRespondWith` ResponseMatcher {             matchBody    = Just [str|[{"id":15}]|]           , matchStatus  = 200-          , matchHeaders = ["Content-Range" <:> "0-0/1"]+          , matchHeaders = ["Content-Range" <:> "0-0/*"]           } -    context "known route, unknown record" $-      it "fails with 404" $-        request methodDelete "/items?id=eq.101" [] "" `shouldRespondWith` 404+    context "known route, no records matched" $+      it "includes [] body if return=rep" $+        request methodDelete "/items?id=eq.101"+          [("Prefer", "return=representation")] ""+          `shouldRespondWith` ResponseMatcher {+            matchBody    = Just "[]"+          , matchStatus  = 200+          , matchHeaders = ["Content-Range" <:> "*/*"]+          }      context "totally unknown route" $       it "fails with 404" $
test/Feature/InsertSpec.hs view
@@ -8,8 +8,8 @@ import SpecHelper  import qualified Data.Aeson as JSON+import Data.List (lookup) import Data.Maybe (fromJust)-import Data.Monoid ((<>)) import Text.Heredoc import Network.HTTP.Types.Header import Network.HTTP.Types@@ -18,6 +18,8 @@ import TestTypes(IncPK(..), CompoundPK(..)) import Network.Wai (Application) +import Protolude hiding (get)+ spec :: SpecWith Application spec = do   describe "Posting new record" $ do@@ -37,24 +39,39 @@        it "filters columns in result using &select" $         request methodPost "/menagerie?select=integer,varchar" [("Prefer", "return=representation")]-          [json| {+          [json| [{             "integer": 14, "double": 3.14159, "varchar": "testing!"           , "boolean": false, "date": "1900-01-01", "money": "$3.99"           , "enum": "foo"-          } |] `shouldRespondWith` ResponseMatcher {-            matchBody    = Just [str|{"integer":14,"varchar":"testing!"}|]+          }] |] `shouldRespondWith` ResponseMatcher {+            matchBody    = Just [str|[{"integer":14,"varchar":"testing!"}]|]           , matchStatus  = 201           , matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8"]           } +    context "requesting full representation" $ do       it "includes related data after insert" $-        request methodPost "/projects?select=id,name,clients{id,name}" [("Prefer", "return=representation")]+        request methodPost "/projects?select=id,name,clients{id,name}"+                [("Prefer", "return=representation"), ("Prefer", "count=exact")]           [str|{"id":6,"name":"New Project","client_id":2}|] `shouldRespondWith` ResponseMatcher {-            matchBody    = Just [str|{"id":6,"name":"New Project","clients":{"id":2,"name":"Apple"}}|]+            matchBody    = Just [str|[{"id":6,"name":"New Project","clients":{"id":2,"name":"Apple"}}]|]           , matchStatus  = 201-          , matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8", "Location" <:> "/projects?id=eq.6"]+          , matchHeaders = [ "Content-Type" <:> "application/json; charset=utf-8"+                           , "Location" <:> "/projects?id=eq.6"+                           , "Content-Range" <:> "*/1" ]           } +      it "can rename and cast the selected columns" $+        request methodPost "/projects?select=pId:id::text,pName:name,cId:client_id::text"+                [("Prefer", "return=representation")] +          [str|{"id":7,"name":"New Project","client_id":2}|] `shouldRespondWith` ResponseMatcher {+            matchBody    = Just [str|[{"pId":"7","pName":"New Project","cId":"2"}]|]+          , matchStatus  = 201+          , matchHeaders = [ "Content-Type" <:> "application/json; charset=utf-8"+                           , "Location" <:> "/projects?id=eq.7"+                           , "Content-Range" <:> "*/*" ]+          }+     context "from an html form" $       it "accepts disparate json types" $ do         p <- request methodPost "/menagerie"@@ -81,9 +98,11 @@             incNullableStr record `shouldBe` Nothing        context "into a table with simple pk" $-        it "fails with 400 and error" $-          post "/simple_pk" [json| { "extra":"foo"} |]-            `shouldRespondWith` 400+        it "fails with 400 and error" $ do+          p <- post "/simple_pk" [json| { "extra":"foo"} |]+          liftIO $ do+            simpleStatus p `shouldBe` badRequest400+            isErrorFormat (simpleBody p) `shouldBe` True        context "into a table with no pk" $ do         it "succeeds with 201 and a link including all fields" $ do@@ -98,10 +117,18 @@                        [("Prefer", "return=representation")]                        [json| { "a":"bar", "b":"baz" } |]           liftIO $ do-            simpleBody p `shouldBe` [json| { "a":"bar", "b":"baz" } |]+            simpleBody p `shouldBe` [json| [{ "a":"bar", "b":"baz" }] |]             simpleHeaders p `shouldSatisfy` matchHeader hLocation "/no_pk\\?a=eq.bar&b=eq.baz"             simpleStatus p `shouldBe` created201 +        it "returns empty array when no items inserted, and return=rep" $ do+          p <- request methodPost "/no_pk"+                       [("Prefer", "return=representation")]+                       [json| [] |]+          liftIO $ do+            simpleBody p `shouldBe` [json| [] |]+            simpleStatus p `shouldBe` created201+         it "can insert in tables with no select privileges" $ do           p <- request methodPost "/insertonly"                        [("Prefer", "return=minimal")]@@ -116,7 +143,7 @@                        [("Prefer", "return=representation")]                        [json| { "a":null, "b":"foo" } |]           liftIO $ do-            simpleBody p `shouldBe` [json| { "a":null, "b":"foo" } |]+            simpleBody p `shouldBe` [json| [{ "a":null, "b":"foo" }] |]             simpleHeaders p `shouldSatisfy` matchHeader hLocation "/no_pk\\?a=is.null&b=eq.foo"             simpleStatus p `shouldBe` created201 @@ -129,7 +156,7 @@                      [("Prefer", "return=representation")]                      inserted         liftIO $ do-          JSON.decode (simpleBody p) `shouldBe` Just expectedObj+          JSON.decode (simpleBody p) `shouldBe` Just [expectedObj]           simpleStatus p `shouldBe` created201           lookup hLocation (simpleHeaders p) `shouldBe` Just expectedLoc @@ -149,8 +176,11 @@           lookup hLocation (simpleHeaders p) `shouldBe` Nothing      context "with invalid json payload" $-      it "fails with 400 and error" $-        post "/simple_pk" "}{ x = 2" `shouldRespondWith` 400+      it "fails with 400 and error" $ do+        p <- post "/simple_pk" "}{ x = 2"+        liftIO $ do+          simpleStatus p `shouldBe` badRequest400+          isErrorFormat (simpleBody p) `shouldBe` True      context "with valid json payload" $       it "succeeds and returns 201 created" $@@ -172,7 +202,7 @@                      [("Prefer", "return=representation")]                      inserted           `shouldRespondWith` ResponseMatcher {-            matchBody    = Just inserted+            matchBody    = Just [str|[{"data":{"foo":"bar"}}]|]           , matchStatus  = 201           , matchHeaders = ["Location" <:> location]           }@@ -184,7 +214,7 @@                      [("Prefer", "return=representation")]                      inserted           `shouldRespondWith` ResponseMatcher {-            matchBody    = Just inserted+            matchBody    = Just [str|[{"data":[1,2,3]}]|]           , matchStatus  = 201           , matchHeaders = ["Location" <:> location]           }@@ -196,6 +226,28 @@           , matchStatus  = 201           , matchHeaders = []           }+    context "table with limited privileges" $ do+      it "succeeds if correct select is applied" $+        request methodPost "/limited_article_stars?select=article_id,user_id" [("Prefer", "return=representation")]+          [json| {"article_id": 2, "user_id": 1} |] `shouldRespondWith` ResponseMatcher {+            matchBody    = Just [str|[{"article_id":2,"user_id":1}]|]+          , matchStatus  = 201+          , matchHeaders = []+          }+      it "fails if more columns are selected" $+        request methodPost "/limited_article_stars?select=article_id,user_id,created_at" [("Prefer", "return=representation")]+          [json| {"article_id": 2, "user_id": 2} |] `shouldRespondWith` ResponseMatcher {+            matchBody    = Just [str|{"hint":null,"details":null,"code":"42501","message":"permission denied for relation limited_article_stars"}|]+          , matchStatus  = 401+          , matchHeaders = []+          }+      it "fails if select is not specified" $+        request methodPost "/limited_article_stars" [("Prefer", "return=representation")]+          [json| {"article_id": 3, "user_id": 1} |] `shouldRespondWith` ResponseMatcher {+            matchBody    = Just [str|{"hint":null,"details":null,"code":"42501","message":"permission denied for relation limited_article_stars"}|]+          , matchStatus  = 401+          , matchHeaders = []+          }    describe "CSV insert" $ do @@ -237,11 +289,23 @@                             "Location" <:> "/no_pk?a=is.null&b=eq.foo"]           } +      it "only returns the requested column header with its associated data" $+        request methodPost "/projects?select=id"+                     [("Content-Type", "text/csv"), ("Accept", "text/csv"), ("Prefer", "return=representation")]+                     "id,name,client_id\n8,Xenix,1\n9,Windows NT,1"+          `shouldRespondWith` ResponseMatcher {+            matchBody    = Just "id\n8\n9"+          , matchStatus  = 201+          , matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8",+                            "Content-Range" <:> "*/*"]+          }      context "with wrong number of columns" $       it "fails for too few" $ do         p <- request methodPost "/no_pk" [("Content-Type", "text/csv")] "a,b\nfoo,bar\nbaz"-        liftIO $ simpleStatus p `shouldBe` badRequest400+        liftIO $ do+          simpleStatus p `shouldBe` badRequest400+          isErrorFormat (simpleBody p) `shouldBe` True      context "with unicode values" $       it "succeeds and returns usable location header" $ do@@ -250,7 +314,7 @@                      [("Prefer", "return=representation")]                      payload         liftIO $ do-          simpleBody p `shouldBe` payload+          simpleBody p `shouldBe` "["<>payload<>"]"           simpleStatus p `shouldBe` created201          let Just location = lookup hLocation $ simpleHeaders p@@ -258,81 +322,6 @@         liftIO $ simpleBody r `shouldBe` "["<>payload<>"]"  -  describe "Putting record" $ do--    context "to unknown uri" $-      it "gives a 404" $ do-        pendingWith "Decide on PUT usefullness"-        request methodPut "/fake" []-          [json| { "real": false } |]-            `shouldRespondWith` 404--    context "to a known uri" $ do-      context "without a fully-specified primary key" $-        it "is not an allowed operation" $ do-          pendingWith "Decide on PUT usefullness"-          request methodPut "/compound_pk?k1=eq.12" []-            [json| { "k1":12, "k2":42 } |]-              `shouldRespondWith` 405--      context "with a fully-specified primary key" $ do--        context "not specifying every column in the table" $-          it "is rejected for lack of idempotence" $ do-            pendingWith "Decide on PUT usefullness"-            request methodPut "/compound_pk?k1=eq.12&k2=eq.42" []-              [json| { "k1":12, "k2":42 } |]-                `shouldRespondWith` 400--        context "specifying every column in the table" $ do-          it "can create a new record" $ do-            pendingWith "Decide on PUT usefullness"-            p <- request methodPut "/compound_pk?k1=eq.12&k2=eq.42" []-                 [json| { "k1":12, "k2":42, "extra":3 } |]-            liftIO $ do-              simpleBody p `shouldBe` ""-              simpleStatus p `shouldBe` status204--            r <- get "/compound_pk?k1=eq.12&k2=eq.42"-            let rows = fromJust (JSON.decode $ simpleBody r :: Maybe [CompoundPK])-            liftIO $ do-              length rows `shouldBe` 1-              let record = head rows-              compoundK1 record `shouldBe` 12-              compoundK2 record `shouldBe` "42"-              compoundExtra record `shouldBe` Just 3--          it "can update an existing record" $ do-            pendingWith "Decide on PUT usefullness"-            _ <- request methodPut "/compound_pk?k1=eq.12&k2=eq.42" []-                 [json| { "k1":12, "k2":42, "extra":4 } |]-            _ <- request methodPut "/compound_pk?k1=eq.12&k2=eq.42" []-                 [json| { "k1":12, "k2":42, "extra":5 } |]--            r <- get "/compound_pk?k1=eq.12&k2=eq.42"-            let rows = fromJust (JSON.decode $ simpleBody r :: Maybe [CompoundPK])-            liftIO $ do-              length rows `shouldBe` 1-              let record = head rows-              compoundExtra record `shouldBe` Just 5--      context "with an auto-incrementing primary key"$--        it "succeeds with 204" $ do-          pendingWith "Decide on PUT usefullness"-          request methodPut "/auto_incrementing_pk?id=eq.1" []-               [json| {-                 "id":1,-                 "nullable_string":"hi",-                 "non_nullable_string":"bye",-                 "inserted_at": "2020-11-11"-               } |]-            `shouldRespondWith` ResponseMatcher {-              matchBody    = Nothing,-              matchStatus  = 204,-              matchHeaders = []-            }-   describe "Patching record" $ do      context "to unknown uri" $@@ -345,26 +334,50 @@       it "indicates no records found to update" $         request methodPatch "/empty_table" []           [json| { "extra":20 } |]-            `shouldRespondWith` 404+          `shouldRespondWith` ResponseMatcher {+            matchBody    = Just "",+            matchStatus  = 204,+            matchHeaders = ["Content-Range" <:> "*/*"]+          }      context "in a nonempty table" $ do       it "can update a single item" $ do         g <- get "/items?id=eq.42"         liftIO $ simpleHeaders g-          `shouldSatisfy` matchHeader "Content-Range" "\\*/0"+          `shouldSatisfy` matchHeader "Content-Range" "\\*/\\*"         p <- request methodPatch "/items?id=eq.2" [] [json| { "id":42 } |]         pure p `shouldRespondWith` ResponseMatcher {             matchBody    = Nothing,             matchStatus  = 204,-            matchHeaders = ["Content-Range" <:> "0-0/1"]+            matchHeaders = ["Content-Range" <:> "0-0/*"]           }-        liftIO $-          lookup hContentType (simpleHeaders p) `shouldBe` Nothing+        liftIO $ lookup hContentType (simpleHeaders p) `shouldBe` Nothing +        -- check it really got updated         g' <- get "/items?id=eq.42"         liftIO $ simpleHeaders g'-          `shouldSatisfy` matchHeader "Content-Range" "0-0/1"+          `shouldSatisfy` matchHeader "Content-Range" "0-0/\\*"+        -- put value back for other tests+        void $ request methodPatch "/items?id=eq.42" [] [json| { "id":2 } |] +      it "returns empty array when no rows updated and return=rep" $+        request methodPatch "/items?id=eq.999999"+          [("Prefer", "return=representation")] [json| { "id":999999 } |]+          `shouldRespondWith` ResponseMatcher {+            matchBody    = Just "[]",+            matchStatus  = 200,+            matchHeaders = ["Content-Range" <:> "*/*"]+          }++      it "returns updated object as array when return=rep" $+        request methodPatch "/items?id=eq.2"+          [("Prefer", "return=representation")] [json| { "id":2 } |]+          `shouldRespondWith` ResponseMatcher {+            matchBody    = Just [str|[{"id":2}]|],+            matchStatus  = 200,+            matchHeaders = ["Content-Range" <:> "0-0/*"]+          }+       it "can update multiple items" $ do         replicateM_ 10 $ post "/auto_incrementing_pk"           [json| { non_nullable_string: "a" } |]@@ -375,7 +388,7 @@           [json| { non_nullable_string: "c" } |]         g <- get "/auto_incrementing_pk?non_nullable_string=eq.c"         liftIO $ simpleHeaders g-          `shouldSatisfy` matchHeader "Content-Range" "0-9/10"+          `shouldSatisfy` matchHeader "Content-Range" "0-9/\\*"        it "can set a column to NULL" $ do         _ <- post "/no_pk" [json| { a: "keepme", b: "nullme" } |]@@ -383,12 +396,28 @@         get "/no_pk?a=eq.keepme" `shouldRespondWith`           [json| [{ a: "keepme", b: null }] |] +      it "can set a json column to escaped value" $ do+        _ <- post "/json" [json| { data: {"escaped":"bar"} } |]+        request methodPatch "/json?data->>escaped=eq.bar"+                     [("Prefer", "return=representation")]+                     [json| { "data": { "escaped":" \"bar" } } |]+          `shouldRespondWith` ResponseMatcher {+            matchBody    = Just [json| [{ "data": { "escaped":" \"bar" } }] |]+          , matchStatus  = 200+          , matchHeaders = []+          }+       it "can update based on a computed column" $         request methodPatch           "/items?always_true=eq.false"           [("Prefer", "return=representation")]           [json| { id: 100 } |]-          `shouldRespondWith` 404+          `shouldRespondWith` ResponseMatcher {+            matchBody    = Just "[]",+            matchStatus  = 200,+            matchHeaders = ["Content-Range" <:> "*/*"]+          }+       it "can provide a representation" $ do         _ <- post "/items"           [json| { id: 1 } |]@@ -398,17 +427,6 @@           [json| { id: 99 } |]           `shouldRespondWith` [json| [{id:99}] |] -      it "can set a json column to escaped value" $ do-        _ <- post "/json" [json| { data: {"escaped":"bar"} } |]-        request methodPatch "/json?data->>escaped=eq.bar"-                     [("Prefer", "return=representation")]-                     [json| { "data": { "escaped":" \"bar" } } |]-          `shouldRespondWith` ResponseMatcher {-            matchBody    = Just [json| [{ "data": { "escaped":" \"bar" } }] |]-          , matchStatus  = 200-          , matchHeaders = []-          }-     context "with unicode values" $       it "succeeds and returns values intact" $ do         void $ request methodPost "/no_pk" []@@ -430,13 +448,13 @@         [ auth, ("Prefer", "return=representation") ]         [json| { "secret": "nyancat" } |]       liftIO $ do-          simpleBody p1 `shouldBe` [str|{"owner":"jdoe","secret":"nyancat"}|]-          simpleStatus p1 `shouldBe` created201+        simpleBody p1 `shouldBe` [str|[{"owner":"jdoe","secret":"nyancat"}]|]+        simpleStatus p1 `shouldBe` created201        p2 <- request methodPost "/authors_only"         -- jwt token for jroe         [ authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqcm9lIn0.YuF_VfmyIxWyuceT7crnNKEprIYXsJAyXid3rjPjIow", ("Prefer", "return=representation") ]         [json| { "secret": "lolcat", "owner": "hacker" } |]       liftIO $ do-          simpleBody p2 `shouldBe` [str|{"owner":"jroe","secret":"lolcat"}|]-          simpleStatus p2 `shouldBe` created201+        simpleBody p2 `shouldBe` [str|[{"owner":"jroe","secret":"lolcat"}]|]+        simpleStatus p2 `shouldBe` created201
+ test/Feature/NoJwtSpec.hs view
@@ -0,0 +1,25 @@+module Feature.NoJwtSpec where++-- {{{ Imports+import Test.Hspec+import Test.Hspec.Wai+import Network.HTTP.Types++import SpecHelper+import Network.Wai (Application)++import Protolude hiding (get)+-- }}}++spec :: SpecWith Application+spec = describe "server started without JWT secret" $ do++  -- this test will stop working 9999999999s after the UNIX EPOCH+  it "responds with error on attempted auth" $ do+    let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UifQ.QaPPLWTuyydMu_q7H4noMT7Lk6P4muet1OpJXF6ofhc"+    request methodGet "/authors_only" [auth] ""+      `shouldRespondWith` 500++  it "behaves normally when user does not attempt auth" $+    request methodGet "/items" [] ""+      `shouldRespondWith` 200
+ test/Feature/ProxySpec.hs view
@@ -0,0 +1,15 @@+module Feature.ProxySpec where++import Test.Hspec hiding (pendingWith)++import SpecHelper++import Network.Wai (Application)++import Protolude hiding (get)++spec :: SpecWith Application+spec =+  describe "GET / with proxy" $+    it "returns a valid openapi spec with proxy" $+      validateOpenApiResponse [("Accept", "application/openapi+json")]
test/Feature/QueryLimitedSpec.hs view
@@ -9,6 +9,8 @@ import SpecHelper import Network.Wai (Application) +import Protolude hiding (get)+ spec :: SpecWith Application spec =   describe "Requesting many items with server limits enabled" $ do@@ -16,8 +18,8 @@       get "/items"         `shouldRespondWith` ResponseMatcher {           matchBody    = Just [json| [{"id":1},{"id":2}] |]-        , matchStatus  = 206-        , matchHeaders = ["Content-Range" <:> "0-1/15"]+        , matchStatus  = 200+        , matchHeaders = ["Content-Range" <:> "0-1/*"]         }      it "respects additional client limiting" $ do@@ -25,13 +27,22 @@                    (rangeHdrs $ ByteRangeFromTo 0 0) ""       liftIO $ do         simpleHeaders r `shouldSatisfy`-          matchHeader "Content-Range" "0-0/15"-        simpleStatus r `shouldBe` partialContent206+          matchHeader "Content-Range" "0-0/*"+        simpleStatus r `shouldBe` ok200      it "limit works on all levels" $       get "/users?select=id,tasks{id}&order=id.asc&tasks.order=id.asc"         `shouldRespondWith` ResponseMatcher {           matchBody    = Just [str|[{"id":1,"tasks":[{"id":1},{"id":2}]},{"id":2,"tasks":[{"id":5},{"id":6}]}]|]-        , matchStatus  = 206-        , matchHeaders = ["Content-Range" <:> "0-1/3"]+        , matchStatus  = 200+        , matchHeaders = ["Content-Range" <:> "0-1/*"]         }++    it "limit is not applied to parent embeds" $+      get "/tasks?select=id,project{id}&id=gt.5"+        `shouldRespondWith` ResponseMatcher {+          matchBody    = Just [str|[{"id":6,"project":{"id":3}},{"id":7,"project":{"id":4}}]|]+        , matchStatus  = 200+        , matchHeaders = ["Content-Range" <:> "0-1/*"]+        }+
test/Feature/QuerySpec.hs view
@@ -4,12 +4,14 @@ import Test.Hspec.Wai import Test.Hspec.Wai.JSON import Network.HTTP.Types-import Network.Wai.Test (SResponse(simpleHeaders))+import Network.Wai.Test (SResponse(simpleHeaders,simpleStatus,simpleBody))  import SpecHelper import Text.Heredoc import Network.Wai (Application) +import Protolude hiding (get)+ spec :: SpecWith Application spec = do @@ -17,6 +19,10 @@     it "should not confuse count column with pg_catalog.count aggregate" $       get "/has_count_column" `shouldRespondWith` 200 +  describe "Querying a table with a column called t" $+    it "should not conflict with internal postgrest table alias" $+      get "/clashing_column?select=t" `shouldRespondWith` 200+   describe "Querying a nonexistent table" $     it "causes a 404" $       get "/faketable" `shouldRespondWith` 404@@ -27,7 +33,7 @@         `shouldRespondWith` ResponseMatcher {           matchBody    = Just [json| [{"id":5}] |]         , matchStatus  = 200-        , matchHeaders = ["Content-Range" <:> "0-0/1"]+        , matchHeaders = ["Content-Range" <:> "0-0/*"]         }      it "matches with equality using not operator" $@@ -35,7 +41,7 @@         `shouldRespondWith` ResponseMatcher {           matchBody    = Just [json| [{"id":1},{"id":2},{"id":3},{"id":4},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}] |]         , matchStatus  = 200-        , matchHeaders = ["Content-Range" <:> "0-13/14"]+        , matchHeaders = ["Content-Range" <:> "0-13/*"]         }      it "matches with more than one condition using not operator" $@@ -46,13 +52,13 @@         `shouldRespondWith` ResponseMatcher {           matchBody    = Just [json| [{"id":14},{"id":15}] |]         , matchStatus  = 200-        , matchHeaders = ["Content-Range" <:> "0-1/2"]+        , matchHeaders = ["Content-Range" <:> "0-1/*"]         }       get "/items?id=not.gt.2&order=id.asc"         `shouldRespondWith` ResponseMatcher {           matchBody    = Just [json| [{"id":1},{"id":2}] |]         , matchStatus  = 200-        , matchHeaders = ["Content-Range" <:> "0-1/2"]+        , matchHeaders = ["Content-Range" <:> "0-1/*"]         }      it "matches items IN" $@@ -60,7 +66,7 @@         `shouldRespondWith` ResponseMatcher {           matchBody    = Just [json| [{"id":1},{"id":3},{"id":5}] |]         , matchStatus  = 200-        , matchHeaders = ["Content-Range" <:> "0-2/3"]+        , matchHeaders = ["Content-Range" <:> "0-2/*"]         }      it "matches items NOT IN" $@@ -68,7 +74,7 @@         `shouldRespondWith` ResponseMatcher {           matchBody    = Just [json| [{"id":1},{"id":3},{"id":5}] |]         , matchStatus  = 200-        , matchHeaders = ["Content-Range" <:> "0-2/3"]+        , matchHeaders = ["Content-Range" <:> "0-2/*"]         }      it "matches items NOT IN using not operator" $@@ -76,7 +82,7 @@         `shouldRespondWith` ResponseMatcher {           matchBody    = Just [json| [{"id":1},{"id":3},{"id":5}] |]         , matchStatus  = 200-        , matchHeaders = ["Content-Range" <:> "0-2/3"]+        , matchHeaders = ["Content-Range" <:> "0-2/*"]         }      it "matches nulls using not operator" $@@ -126,6 +132,14 @@       get "/items?order=anti_id.desc" `shouldRespondWith`         [json| [{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}] |] +    it "matches filtering nested items 2" $+      get "/clients?select=id,projects{id,tasks2{id,name}}&projects.tasks.name=like.Design*"+        `shouldRespondWith` ResponseMatcher {+          matchBody    = Just [json| {"message":"could not find foreign keys between these entities, no relation between projects and tasks2"}|]+        , matchStatus  = 400+        , matchHeaders = []+        }+     it "matches filtering nested items" $       get "/clients?select=id,projects{id,tasks{id,name}}&projects.tasks.name=like.Design*" `shouldRespondWith`         [str|[{"id":1,"projects":[{"id":1,"tasks":[{"id":1,"name":"Design w7"}]},{"id":2,"tasks":[{"id":3,"name":"Design w10"}]}]},{"id":2,"projects":[{"id":3,"tasks":[{"id":5,"name":"Design IOS"}]},{"id":4,"tasks":[{"id":7,"name":"Design OSX"}]}]}]|]@@ -225,7 +239,15 @@       get "/projects?id=eq.1&select=myId:id, name, project_client:client_id{*}, project_tasks:tasks{id, name}" `shouldRespondWith`         [str|[{"myId":1,"name":"Windows 7","project_client":{"id":1,"name":"Microsoft"},"project_tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}]|] +    it "requesting parents two levels up while using FK to specify the link" $+      get "/tasks?id=eq.1&select=id,name,project:project_id{id,name,client:client_id{id,name}}" `shouldRespondWith`+        [str|[{"id":1,"name":"Design w7","project":{"id":1,"name":"Windows 7","client":{"id":1,"name":"Microsoft"}}}]|] +    it "requesting parents two levels up while using FK to specify the link (with rename)" $+      get "/tasks?id=eq.1&select=id,name,project:project_id{id,name,client:client_id{id,name}}" `shouldRespondWith`+        [str|[{"id":1,"name":"Design w7","project":{"id":1,"name":"Windows 7","client":{"id":1,"name":"Microsoft"}}}]|]++     it "requesting parents and filtering parent columns" $       get "/projects?id=eq.1&select=id, name, clients{id}" `shouldRespondWith`         [str|[{"id":1,"name":"Windows 7","clients":{"id":1}}]|]@@ -259,6 +281,11 @@       get "/projects_view?id=eq.1&select=id, name, clients{*}, tasks{id, name}" `shouldRespondWith`         [str|[{"id":1,"name":"Windows 7","clients":{"id":1,"name":"Microsoft"},"tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}]|] +    it "requesting parents and children on views with renamed keys" $+      get "/projects_view_alt?t_id=eq.1&select=t_id, name, clients{*}, tasks{id, name}" `shouldRespondWith`+        [str|[{"t_id":1,"name":"Windows 7","clients":{"id":1,"name":"Microsoft"},"tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}]|]++     it "requesting children with composite key" $       get "/users_tasks?user_id=eq.2&task_id=eq.6&select=*, comments{content}" `shouldRespondWith`         [str|[{"user_id":2,"task_id":6,"comments":[{"content":"Needs to be delivered ASAP"}]}]|]@@ -275,60 +302,33 @@       get "/projects?id=in.1,3&select=id,name,client_id,client{id,name}" `shouldRespondWith`         [str|[{"id":1,"name":"Windows 7","client_id":1,"client":{"id":1,"name":"Microsoft"}},{"id":3,"name":"IOS","client_id":2,"client":{"id":2,"name":"Apple"}}]|] --  describe "Plurality singular" $ do-    it "will select an existing object" $-      request methodGet "/items?id=eq.5" [("Prefer","plurality=singular")] ""-        `shouldRespondWith` ResponseMatcher {-          matchBody    = Just [json| {"id":5} |]-        , matchStatus  = 200-        , matchHeaders = []-        }--    it "can combine multiple prefer values" $-      request methodGet "/items?id=eq.5" [("Prefer","plurality=singular ; future=new; count=none")] ""-        `shouldRespondWith` ResponseMatcher {-          matchBody    = Just [json| {"id":5} |]-        , matchStatus  = 200-        , matchHeaders = []-        }--    it "works in the presence of a range header" $-      let headers = ("Prefer","plurality=singular") :-            rangeHdrs (ByteRangeFromTo 0 9) in-      request methodGet "/items" headers ""-        `shouldRespondWith` ResponseMatcher {-          matchBody    = Just [json| {"id":1} |]-        , matchStatus  = 200-        , matchHeaders = []-        }--    it "will respond with 404 when not found" $-      request methodGet "/items?id=eq.9999" [("Prefer","plurality=singular")] ""-        `shouldRespondWith` 404--    it "can shape plurality singular object routes" $-      request methodGet "/projects_view?id=eq.1&select=id,name,clients{*},tasks{id,name}" [("Prefer","plurality=singular")] ""-        `shouldRespondWith`-          [str|{"id":1,"name":"Windows 7","clients":{"id":1,"name":"Microsoft"},"tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}|]--   describe "ordering response" $ do     it "by a column asc" $       get "/items?id=lte.2&order=id.asc"         `shouldRespondWith` ResponseMatcher {           matchBody    = Just [json| [{"id":1},{"id":2}] |]         , matchStatus  = 200-        , matchHeaders = ["Content-Range" <:> "0-1/2"]+        , matchHeaders = ["Content-Range" <:> "0-1/*"]         }     it "by a column desc" $       get "/items?id=lte.2&order=id.desc"         `shouldRespondWith` ResponseMatcher {           matchBody    = Just [json| [{"id":2},{"id":1}] |]         , matchStatus  = 200-        , matchHeaders = ["Content-Range" <:> "0-1/2"]+        , matchHeaders = ["Content-Range" <:> "0-1/*"]         } +    it "by a column with nulls first" $+      get "/no_pk?order=a.nullsfirst"+        `shouldRespondWith` ResponseMatcher {+          matchBody = Just [json| [{"a":null,"b":null},+                              {"a":"1","b":"0"},+                              {"a":"2","b":"0"}+                              ] |]+        , matchStatus = 200+        , matchHeaders = ["Content-Range" <:> "0-2/*"]+        }+     it "by a column asc with nulls last" $       get "/no_pk?order=a.asc.nullslast"         `shouldRespondWith` ResponseMatcher {@@ -336,7 +336,7 @@                               {"a":"2","b":"0"},                               {"a":null,"b":null}] |]         , matchStatus = 200-        , matchHeaders = ["Content-Range" <:> "0-2/3"]+        , matchHeaders = ["Content-Range" <:> "0-2/*"]         }      it "by a column desc with nulls first" $@@ -346,7 +346,7 @@                               {"a":"2","b":"0"},                               {"a":"1","b":"0"}] |]         , matchStatus = 200-        , matchHeaders = ["Content-Range" <:> "0-2/3"]+        , matchHeaders = ["Content-Range" <:> "0-2/*"]         }      it "by a column desc with nulls last" $@@ -356,9 +356,17 @@                               {"a":"1","b":"0"},                               {"a":null,"b":null}] |]         , matchStatus = 200-        , matchHeaders = ["Content-Range" <:> "0-2/3"]+        , matchHeaders = ["Content-Range" <:> "0-2/*"]         } +    it "by a json column property asc" $+      get "/json?order=data->>id.asc" `shouldRespondWith`+        [json| [{"data": {"id": 0}}, {"data": {"id": 1, "foo": {"bar": "baz"}}}, {"data": {"id": 3}}] |]++    it "by a json column with two level property nulls first" $+      get "/json?order=data->foo->>bar.nullsfirst" `shouldRespondWith`+        [json| [{"data": {"id": 3}}, {"data": {"id": 0}}, {"data": {"id": 1, "foo": {"bar": "baz"}}}] |]+     it "without other constraints" $       get "/items?order=id.asc" `shouldRespondWith` 200 @@ -395,6 +403,27 @@               (acceptHdrs "*/*") ""         `shouldRespondWith` 200 +    it "*/* should rescue an unknown type" $+      request methodGet "/simple_pk"+              (acceptHdrs "text/unknowntype, */*") ""+        `shouldRespondWith` 200++    it "specific available preference should override */*" $ do+      r <- request methodGet "/simple_pk"+              (acceptHdrs "text/csv, */*") ""+      liftIO $ do+        let respHeaders = simpleHeaders r+        respHeaders `shouldSatisfy` matchHeader+          "Content-Type" "text/csv; charset=utf-8"++    it "honors client preference even when opposite of server preference" $ do+      r <- request methodGet "/simple_pk"+              (acceptHdrs "text/csv, application/json") ""+      liftIO $ do+        let respHeaders = simpleHeaders r+        respHeaders `shouldSatisfy` matchHeader+          "Content-Type" "text/csv; charset=utf-8"+     it "should respond correctly to multiple types in accept header" $       request methodGet "/simple_pk"               (acceptHdrs "text/unknowntype, text/csv") ""@@ -445,7 +474,17 @@                 (rangeHdrs (ByteRangeFromTo 0 0))  [json| { "min": 2, "max": 4 } |]            `shouldRespondWith` ResponseMatcher {               matchBody    = Just [json| [{"id":3}] |]-            , matchStatus = 206+            , matchStatus = 200+            , matchHeaders = ["Content-Range" <:> "0-0/*"]+            }++      it "includes total count if requested" $+        request methodPost "/rpc/getitemrange"+                (rangeHdrsWithCount (ByteRangeFromTo 0 0))+                [json| { "min": 2, "max": 4 } |]+           `shouldRespondWith` ResponseMatcher {+              matchBody    = Just [json| [{"id":3}] |]+            , matchStatus = 206 -- it now knows the response is partial             , matchHeaders = ["Content-Range" <:> "0-0/2"]             } @@ -454,6 +493,35 @@         post "/rpc/getitemrange" [json| { "min": 2, "max": 4 } |] `shouldRespondWith`           [json| [ {"id": 3}, {"id":4} ] |] +    context "unknown function" $+      it "returns 404" $+        post "/rpc/fakefunc" [json| {} |] `shouldRespondWith` 404++    context "shaping the response returned by a proc" $ do+      it "returns a project" $+        post "/rpc/getproject" [json| { "id": 1} |] `shouldRespondWith`+          [str|[{"id":1,"name":"Windows 7","client_id":1}]|]++      it "can filter proc results" $+        post "/rpc/getallprojects?id=gt.1&id=lt.5&select=id" [json| {} |] `shouldRespondWith`+          [json|[{"id":2},{"id":3},{"id":4}]|]++      it "can limit proc results" $+        post "/rpc/getallprojects?id=gt.1&id=lt.5&select=id?limit=2&offset=1" [json| {} |]+          `shouldRespondWith` ResponseMatcher {+               matchBody    = Just [json|[{"id":3},{"id":4}]|]+             , matchStatus = 200+             , matchHeaders = ["Content-Range" <:> "1-2/*"]+             }++      it "select works on the first level" $+        post "/rpc/getproject?select=id,name" [json| { "id": 1} |] `shouldRespondWith`+          [str|[{"id":1,"name":"Windows 7"}]|]++      it "can embed foreign entities to the items returned by a proc" $+        post "/rpc/getproject?select=id,name,client{id},tasks{id}" [json| { "id": 1} |] `shouldRespondWith`+          [str|[{"id":1,"name":"Windows 7","client":{"id":1},"tasks":[{"id":1},{"id":2}]}]|]+     context "a proc that returns an empty rowset" $       it "returns empty json array" $         post "/rpc/test_empty_rowset" [json| {} |] `shouldRespondWith`@@ -462,21 +530,28 @@     context "a proc that returns plain text" $ do       it "returns proper json" $         post "/rpc/sayhello" [json| { "name": "world" } |] `shouldRespondWith`-          [json| [{"sayhello":"Hello, world"}] |]+          [json|"Hello, world"|]        it "can handle unicode" $         post "/rpc/sayhello" [json| { "name": "¥" } |] `shouldRespondWith`-          [json| [{"sayhello":"Hello, ¥"}] |]+          [json|"Hello, ¥"|]      context "improper input" $ do       it "rejects unknown content type even if payload is good" $         request methodPost "/rpc/sayhello"           (acceptHdrs "audio/mpeg3") [json| { "name": "world" } |]             `shouldRespondWith` 415-      it "rejects malformed json payload" $-        request methodPost "/rpc/sayhello"+      it "rejects malformed json payload" $ do+        p <- request methodPost "/rpc/sayhello"           (acceptHdrs "application/json") "sdfsdf"-            `shouldRespondWith` 400+        liftIO $ do+          simpleStatus p `shouldBe` badRequest400+          isErrorFormat (simpleBody p) `shouldBe` True+      it "treats simple plpgsql raise as invalid input" $ do+        p <- post "/rpc/problem" "{}"+        liftIO $ do+          simpleStatus p `shouldBe` badRequest400+          isErrorFormat (simpleBody p) `shouldBe` True      context "unsupported verbs" $ do       it "DELETE fails" $@@ -497,9 +572,23 @@      it "executes the proc exactly once per request" $ do       post "/rpc/callcounter" [json| {} |] `shouldRespondWith`-        [json| [{"callcounter":1}] |]+        [json|1|]       post "/rpc/callcounter" [json| {} |] `shouldRespondWith`-        [json| [{"callcounter":2}] |]+        [json|2|]++    context "expects a single json object" $ do+      it "does not expand posted json into parameters" $+        request methodPost "/rpc/singlejsonparam"+          [("Prefer","params=single-object")] [json| { "p1": 1, "p2": "text", "p3" : {"obj":"text"} } |] `shouldRespondWith`+          [json| { "p1": 1, "p2": "text", "p3" : {"obj":"text"} } |]++      it "accepts parameters from an html form" $+        request methodPost "/rpc/singlejsonparam"+          [("Prefer","params=single-object"),("Content-Type", "application/x-www-form-urlencoded")]+          ("integer=7&double=2.71828&varchar=forms+are+fun&" <>+           "boolean=false&date=1900-01-01&money=$3.99&enum=foo") `shouldRespondWith`+          [json| { "integer": "7", "double": "2.71828", "varchar" : "forms are fun"+                 , "boolean":"false", "date":"1900-01-01", "money":"$3.99", "enum":"foo" } |]    describe "weird requests" $ do     it "can query as normal" $ do
test/Feature/RangeSpec.hs view
@@ -12,6 +12,8 @@ import Text.Heredoc import Network.Wai (Application) +import Protolude hiding (get)+ defaultRange :: BL.ByteString defaultRange = [json| { "min": 0, "max": 15 } |] @@ -28,8 +30,7 @@        context "when I don't want the count" $ do         it "returns range Content-Range with */* for empty range" $-          request methodPost "/rpc/getitemrange"-                  [("Prefer", "count=none")] emptyRange+          request methodPost "/rpc/getitemrange" [] emptyRange             `shouldRespondWith` ResponseMatcher {               matchBody    = Just [json| [] |]             , matchStatus  = 200@@ -37,8 +38,7 @@             }          it "returns range Content-Range with range/*" $-          request methodPost "/rpc/getitemrange"-                  [("Prefer", "count=none")] defaultRange+          request methodPost "/rpc/getitemrange" [] defaultRange             `shouldRespondWith` ResponseMatcher {               matchBody    = Just [json| [{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}] |]             , matchStatus  = 200@@ -53,8 +53,8 @@                        (rangeHdrs $ ByteRangeFromTo 0 1) defaultRange           liftIO $ do             simpleHeaders r `shouldSatisfy`-              matchHeader "Content-Range" "0-1/15"-            simpleStatus r `shouldBe` partialContent206+              matchHeader "Content-Range" "0-1/*"+            simpleStatus r `shouldBe` ok200          it "understands open-ended ranges" $           request methodPost "/rpc/getitemrange"@@ -67,7 +67,7 @@             `shouldRespondWith` ResponseMatcher {               matchBody    = Just "[]"             , matchStatus  = 200-            , matchHeaders = ["Content-Range" <:> "*/0"]+            , matchHeaders = ["Content-Range" <:> "*/*"]             }          it "allows one-item requests" $ do@@ -75,16 +75,16 @@                        (rangeHdrs $ ByteRangeFromTo 0 0) defaultRange           liftIO $ do             simpleHeaders r `shouldSatisfy`-              matchHeader "Content-Range" "0-0/15"-            simpleStatus r `shouldBe` partialContent206+              matchHeader "Content-Range" "0-0/*"+            simpleStatus r `shouldBe` ok200          it "handles ranges beyond collection length via truncation" $ do           r <- request methodPost  "/rpc/getitemrange"                        (rangeHdrs $ ByteRangeFromTo 10 100) defaultRange           liftIO $ do             simpleHeaders r `shouldSatisfy`-              matchHeader "Content-Range" "10-14/15"-            simpleStatus r `shouldBe` partialContent206+              matchHeader "Content-Range" "10-14/*"+            simpleStatus r `shouldBe` ok200        context "of invalid range" $ do         it "fails with 416 for offside range" $@@ -94,7 +94,7 @@          it "refuses a range with nonzero start when there are no items" $           request methodPost "/rpc/getitemrange"-                  (rangeHdrs $ ByteRangeFromTo 1 2) emptyRange+                  (rangeHdrsWithCount $ ByteRangeFromTo 1 2) emptyRange             `shouldRespondWith` ResponseMatcher {               matchBody    = Nothing             , matchStatus  = 416@@ -103,12 +103,13 @@          it "refuses a range requesting start past last item" $           request methodPost "/rpc/getitemrange"-                  (rangeHdrs $ ByteRangeFromTo 100 199) defaultRange+                  (rangeHdrsWithCount $ ByteRangeFromTo 100 199) defaultRange             `shouldRespondWith` ResponseMatcher {               matchBody    = Nothing             , matchStatus  = 416             , matchHeaders = ["Content-Range" <:> "*/15"]             }+   describe "GET /items" $ do     context "without range headers" $ do       context "with response under server size limit" $@@ -149,42 +150,39 @@           `shouldRespondWith` ResponseMatcher {             matchBody    = Just [str|[{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}]|]           , matchStatus  = 200-          , matchHeaders = ["Content-Range" <:> "0-14/15"]+          , matchHeaders = ["Content-Range" <:> "0-14/*"]           }       it "top level limit with parameter" $         get "/items?select=id&order=id.asc&limit=3"           `shouldRespondWith` ResponseMatcher {             matchBody    = Just [str|[{"id":1},{"id":2},{"id":3}]|]-          , matchStatus  = 206-          , matchHeaders = ["Content-Range" <:> "0-2/15"]+          , matchStatus  = 200+          , matchHeaders = ["Content-Range" <:> "0-2/*"]           }       it "headers override get parameters" $         request methodGet  "/items?select=id&order=id.asc&limit=3"                      (rangeHdrs $ ByteRangeFromTo 0 1) ""           `shouldRespondWith` ResponseMatcher {             matchBody    = Just [str|[{"id":1},{"id":2}]|]-          , matchStatus  = 206-          , matchHeaders = ["Content-Range" <:> "0-1/15"]+          , matchStatus  = 200+          , matchHeaders = ["Content-Range" <:> "0-1/*"]           }        it "limit works on all levels" $-        get "/clients?select=id,projects{id,tasks{id}}&order=id.asc&limit=1&projects.order=id.asc&projects.limit=1&projects.tasks.order=id.asc&projects.tasks.limit=2"+        get "/clients?select=id,projects{id,tasks{id}}&order=id.asc&limit=1&projects.order=id.asc&projects.limit=2&projects.tasks.order=id.asc&projects.tasks.limit=1"           `shouldRespondWith` ResponseMatcher {-            matchBody    = Just [str|[{"id":1,"projects":[{"id":1,"tasks":[{"id":1},{"id":2}]}]}]|]-          , matchStatus  = 206-          , matchHeaders = ["Content-Range" <:> "0-0/2"]+            matchBody    = Just [str|[{"id":1,"projects":[{"id":1,"tasks":[{"id":1}]},{"id":2,"tasks":[{"id":3}]}]}]|]+          , matchStatus  = 200+          , matchHeaders = ["Content-Range" <:> "0-0/*"]           } -      it "fails on offset specified below level 1" $-        get "/clients?select=id,projects{id,tasks{id}}&projects.offset=2&projects.limit=1"-          `shouldRespondWith` 400        it "limit and offset works on first level" $         get "/items?select=id&order=id.asc&limit=3&offset=2"           `shouldRespondWith` ResponseMatcher {             matchBody    = Just [str|[{"id":3},{"id":4},{"id":5}]|]-          , matchStatus  = 206-          , matchHeaders = ["Content-Range" <:> "2-4/15"]+          , matchStatus  = 200+          , matchHeaders = ["Content-Range" <:> "2-4/*"]           }      context "with range headers" $ do@@ -195,8 +193,8 @@                        (rangeHdrs $ ByteRangeFromTo 0 1) ""           liftIO $ do             simpleHeaders r `shouldSatisfy`-              matchHeader "Content-Range" "0-1/15"-            simpleStatus r `shouldBe` partialContent206+              matchHeader "Content-Range" "0-1/*"+            simpleStatus r `shouldBe` ok200          it "understands open-ended ranges" $           request methodGet "/items"@@ -209,7 +207,7 @@             `shouldRespondWith` ResponseMatcher {               matchBody    = Just "[]"             , matchStatus  = 200-            , matchHeaders = ["Content-Range" <:> "*/0"]+            , matchHeaders = ["Content-Range" <:> "*/*"]             }          it "allows one-item requests" $ do@@ -217,16 +215,16 @@                        (rangeHdrs $ ByteRangeFromTo 0 0) ""           liftIO $ do             simpleHeaders r `shouldSatisfy`-              matchHeader "Content-Range" "0-0/15"-            simpleStatus r `shouldBe` partialContent206+              matchHeader "Content-Range" "0-0/*"+            simpleStatus r `shouldBe` ok200          it "handles ranges beyond collection length via truncation" $ do           r <- request methodGet  "/items"                        (rangeHdrs $ ByteRangeFromTo 10 100) ""           liftIO $ do             simpleHeaders r `shouldSatisfy`-              matchHeader "Content-Range" "10-14/15"-            simpleStatus r `shouldBe` partialContent206+              matchHeader "Content-Range" "10-14/*"+            simpleStatus r `shouldBe` ok200        context "of invalid range" $ do         it "fails with 416 for offside range" $@@ -236,7 +234,7 @@          it "refuses a range with nonzero start when there are no items" $           request methodGet "/menagerie"-                  (rangeHdrs $ ByteRangeFromTo 1 2) ""+                  (rangeHdrsWithCount $ ByteRangeFromTo 1 2) ""             `shouldRespondWith` ResponseMatcher {               matchBody    = Nothing             , matchStatus  = 416@@ -245,7 +243,7 @@          it "refuses a range requesting start past last item" $           request methodGet "/items"-                  (rangeHdrs $ ByteRangeFromTo 100 199) ""+                  (rangeHdrsWithCount $ ByteRangeFromTo 100 199) ""             `shouldRespondWith` ResponseMatcher {               matchBody    = Nothing             , matchStatus  = 416
+ test/Feature/SingularSpec.hs view
@@ -0,0 +1,198 @@+module Feature.SingularSpec where++import Text.Heredoc+import Test.Hspec+import Test.Hspec.Wai+import Test.Hspec.Wai.JSON+import Network.HTTP.Types+import Network.Wai.Test (SResponse(..))++import Network.Wai (Application)++import SpecHelper+import Protolude hiding (get)+++spec :: SpecWith Application+spec =+  describe "Requesting singular json object" $ do+    let pgrstObj = "application/vnd.pgrst.object+json"+        singular = ("Accept", pgrstObj)++    context "with GET request" $ do+      it "fails for zero rows" $+        request methodGet  "/items?id=gt.0&id=lt.0" [singular] ""+          `shouldRespondWith` 406++      it "will select an existing object" $ do+        request methodGet "/items?id=eq.5" [singular] ""+          `shouldRespondWith` [str|{"id":5}|]+        -- also test without the +json suffix+        request methodGet "/items?id=eq.5"+          [("Accept", "application/vnd.pgrst.object")] ""+          `shouldRespondWith` [str|{"id":5}|]++      it "can combine multiple prefer values" $+        request methodGet "/items?id=eq.5" [singular, ("Prefer","count=none")] ""+          `shouldRespondWith` [str|{"id":5}|]++      it "can shape plurality singular object routes" $+        request methodGet "/projects_view?id=eq.1&select=id,name,clients{*},tasks{id,name}" [singular] ""+          `shouldRespondWith`+            [str|{"id":1,"name":"Windows 7","clients":{"id":1,"name":"Microsoft"},"tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}|]++    context "when updating rows" $ do++      it "works for one row" $ do+        _ <- post "/addresses" [json| { id: 97, address: "A Street" } |]+        request methodPatch+          "/addresses?id=eq.97"+          [("Prefer", "return=representation"), singular]+          [json| { address: "B Street" } |]+          `shouldRespondWith`+            [str|{"id":97,"address":"B Street"}|]++      it "raises an error for multiple rows" $ do+        _ <- post "/addresses" [json| { id: 98, address: "xxx" } |]+        _ <- post "/addresses" [json| { id: 99, address: "yyy" } |]+        p <- request methodPatch+          "/addresses?id=gt.0"+          [("Prefer", "return=representation"), singular]+          [json| { address: "zzz" } |]+        liftIO $ do+          simpleStatus p `shouldBe` notAcceptable406+          isErrorFormat (simpleBody p) `shouldBe` True++        -- the rows should not be updated, either+        get "/addresses?id=eq.98" `shouldRespondWith` [str|[{"id":98,"address":"xxx"}]|]++      it "raises an error for zero rows" $ do+        p <- request methodPatch  "/items?id=gt.0&id=lt.0"+          [("Prefer", "return=representation"), singular] [json|{"id":1}|]+        liftIO $ do+          simpleStatus p `shouldBe` notAcceptable406+          isErrorFormat (simpleBody p) `shouldBe` True++    context "when creating rows" $ do++      it "works for one row" $ do+        p <- request methodPost+          "/addresses"+          [("Prefer", "return=representation"), singular]+          [json| [ { id: 100, address: "xxx" } ] |]+        liftIO $ simpleBody p `shouldBe` [str|{"id":100,"address":"xxx"}|]++      it "works for one row even with return=minimal" $ do+        request methodPost "/addresses"+          [("Prefer", "return=minimal"), singular]+          [json| [ { id: 101, address: "xxx" } ] |]+          `shouldRespondWith` ResponseMatcher {+            matchBody    = Just ""+          , matchStatus  = 201+          , matchHeaders = ["Content-Range" <:> "*/*"]+          }+        -- and the element should exist+        get "/addresses?id=eq.101"+          `shouldRespondWith` ResponseMatcher {+            matchBody    = Just [str|[{"id":101,"address":"xxx"}]|]+          , matchStatus  = 200+          , matchHeaders = []+          }++      it "raises an error when attempting to create multiple entities" $ do+        p <- request methodPost+          "/addresses"+          [("Prefer", "return=representation"), singular]+          [json| [ { id: 200, address: "xxx" }, { id: 201, address: "yyy" } ] |]+        liftIO $ simpleStatus p `shouldBe` notAcceptable406++        -- the rows should not exist, either+        get "/addresses?id=eq.200" `shouldRespondWith` "[]"++      it "return=minimal allows request to create multiple elements" $+        request methodPost "/addresses"+          [("Prefer", "return=minimal"), singular]+          [json| [ { id: 200, address: "xxx" }, { id: 201, address: "yyy" } ] |]+          `shouldRespondWith` ResponseMatcher {+            matchBody    = Just ""+          , matchStatus  = 201+          , matchHeaders = ["Content-Range" <:> "*/*"]+          }++      it "raises an error when creating zero entities" $ do+        p <- request methodPost+          "/addresses"+          [("Prefer", "return=representation"), singular]+          [json| [ ] |]+        liftIO $ do+          simpleStatus p `shouldBe` notAcceptable406+          isErrorFormat (simpleBody p) `shouldBe` True++    context "when deleting rows" $ do++      it "works for one row" $ do+        p <- request methodDelete+          "/items?id=eq.11"+          [("Prefer", "return=representation"), singular] ""+        liftIO $ simpleBody p `shouldBe` [str|{"id":11}|]++      it "raises an error when attempting to delete multiple entities" $ do+        let firstItems = "/items?id=gt.0&id=lt.11"+        request methodDelete firstItems+          [("Prefer", "return=representation"), singular] ""+          `shouldRespondWith` 406++        -- the rows should not exist, either+        get firstItems+          `shouldRespondWith` ResponseMatcher {+            matchBody    = Nothing+          , matchStatus  = 200+          , matchHeaders = ["Content-Range" <:> "0-9/*"]+          }++      it "raises an error when deleting zero entities" $ do+        p <- request methodDelete "/items?id=lt.0"+          [("Prefer", "return=representation"), singular] ""+        liftIO $ do+          simpleStatus p `shouldBe` notAcceptable406+          isErrorFormat (simpleBody p) `shouldBe` True++    context "when calling a stored proc" $ do++      it "fails for zero rows" $ do+        p <- request methodPost "/rpc/getproject"+          [singular] [json|{ "id": 9999999}|]+        liftIO $ do+          simpleStatus p `shouldBe` notAcceptable406+          isErrorFormat (simpleBody p) `shouldBe` True++      -- this one may be controversial, should vnd.pgrst.object include+      -- the likes of 2 and "hello?"+      it "succeeds for scalar result" $+        request methodPost "/rpc/sayhello"+          [singular] [json|{ "name": "world"}|]+          `shouldRespondWith` 200++      it "returns a single object for json proc" $+        request methodPost "/rpc/getproject"+          [singular] [json|{ "id": 1}|] `shouldRespondWith`+          [str|{"id":1,"name":"Windows 7","client_id":1}|]++      it "fails for multiple rows" $ do+        p <- request methodPost "/rpc/getallprojects" [singular] "{}"+        liftIO $ do+          simpleStatus p `shouldBe` notAcceptable406+          isErrorFormat (simpleBody p) `shouldBe` True++      it "executes the proc exactly once per request" $ do+        request methodPost "/rpc/getproject?select=id,name" [] [json| {"id": 1} |]+          `shouldRespondWith` [str|[{"id":1,"name":"Windows 7"}]|]+        p <- request methodPost "/rpc/setprojects" [singular]+          [json| {"id_l": 1, "id_h": 2, "name": "changed"} |]+        liftIO $ do+          simpleStatus p `shouldBe` notAcceptable406+          isErrorFormat (simpleBody p) `shouldBe` True++        -- should not actually have executed the function+        request methodPost "/rpc/getproject?select=id,name" [] [json| {"id": 1} |]+          `shouldRespondWith` [str|[{"id":1,"name":"Windows 7"}]|]
test/Feature/StructureSpec.hs view
@@ -2,387 +2,87 @@  import Test.Hspec hiding (pendingWith) import Test.Hspec.Wai-import Test.Hspec.Wai.JSON+import Network.HTTP.Types +import Control.Lens ((^?))+import Data.Aeson.Lens+import Data.Aeson.QQ+ import SpecHelper -import Network.HTTP.Types import Network.Wai (Application)-import Network.Wai.Test (SResponse(simpleHeaders))+import Network.Wai.Test (SResponse(..)) +import Protolude hiding (get)+ spec :: SpecWith Application spec = do -  describe "GET /" $ do-    it "lists views in schema" $-      request methodGet "/" [] ""-        `shouldRespondWith` [json| [-          {"schema":"test","name":"Escap3e;","insertable":true}-        , {"schema":"test","name":"addresses","insertable":true}-        , {"schema":"test","name":"articleStars","insertable":true}-        , {"schema":"test","name":"articles","insertable":true}-        , {"schema":"test","name":"auto_incrementing_pk","insertable":true}-        , {"schema":"test","name":"clients","insertable":true}-        , {"schema":"test","name":"comments","insertable":true}-        , {"schema":"test","name":"complex_items","insertable":true}-        , {"schema":"test","name":"compound_pk","insertable":true}-        , {"schema":"test","name":"empty_table","insertable":true}-        , {"schema":"test","name":"filtered_tasks","insertable":true}-        , {"schema":"test","name":"ghostBusters","insertable":true}-        , {"schema":"test","name":"has_count_column","insertable":false}-        , {"schema":"test","name":"has_fk","insertable":true}-        , {"schema":"test","name":"insertable_view_with_join","insertable":true}-        , {"schema":"test","name":"insertonly","insertable":true}-        , {"schema":"test","name":"items","insertable":true}-        , {"schema":"test","name":"json","insertable":true}-        , {"schema":"test","name":"materialized_view","insertable":false}-        , {"schema":"test","name":"menagerie","insertable":true}-        , {"schema":"test","name":"no_pk","insertable":true}-        , {"schema":"test","name":"nullable_integer","insertable":true}-        , {"schema":"test","name":"orders","insertable":true}-        , {"schema":"test","name":"projects","insertable":true}-        , {"schema":"test","name":"projects_view","insertable":true}-        , {"schema":"test","name":"simple_pk","insertable":true}-        , {"schema":"test","name":"tasks","insertable":true}-        , {"schema":"test","name":"tsearch","insertable":true}-        , {"schema":"test","name":"users","insertable":true}-        , {"schema":"test","name":"users_projects","insertable":true}-        , {"schema":"test","name":"users_tasks","insertable":true}-        , {"schema":"test","name":"withUnique","insertable":true}-        ] |]-        {matchStatus = 200}--    it "lists only views user has permission to see" $ do-      let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0"--      request methodGet "/" [auth] ""-        `shouldRespondWith` [json| [-            {"schema":"test","name":"authors_only","insertable":true}-        ] |]-        {matchStatus = 200}--  describe "Table info" $ do-    it "The structure of complex views is correctly detected" $-      request methodOptions "/filtered_tasks" [] "" `shouldRespondWith`-      [json|-      {-        "pkey": [-          "myId"-        ],-        "columns": [-          {-            "references": null,-            "default": null,-            "precision": 32,-            "updatable": true,-            "schema": "test",-            "name": "myId",-            "type": "integer",-            "maxLen": null,-            "enum": [],-            "nullable": true,-            "position": 1-          },-          {-            "references": null,-            "default": null,-            "precision": null,-            "updatable": true,-            "schema": "test",-            "name": "name",-            "type": "text",-            "maxLen": null,-            "enum": [],-            "nullable": true,-            "position": 2-          },-          {-            "references": {-              "schema": "test",-              "column": "id",-              "table": "projects"-            },-            "default": null,-            "precision": 32,-            "updatable": true,-            "schema": "test",-            "name": "projectID",-            "type": "integer",-            "maxLen": null,-            "enum": [],-            "nullable": true,-            "position": 3-          }-        ]-      }-      |]+  describe "OpenAPI" $ do+    it "root path returns a valid openapi spec" $+      validateOpenApiResponse [("Accept", "application/openapi+json")] -    it "is available with OPTIONS verb" $-      request methodOptions "/menagerie" [] "" `shouldRespondWith`-      [json|-      {-        "pkey":["integer"],-        "columns":[-          {-            "default": null,-            "precision": 32,-            "updatable": true,-            "schema": "test",-            "name": "integer",-            "type": "integer",-            "maxLen": null,-            "enum": [],-            "nullable": false,-            "position": 1,-            "references": null,-            "default": null-          }, {-            "default": null,-            "precision": 53,-            "updatable": true,-            "schema": "test",-            "name": "double",-            "type": "double precision",-            "maxLen": null,-            "enum": [],-            "nullable": false,-            "references": null,-            "position": 2-          }, {-            "default": null,-            "precision": null,-            "updatable": true,-            "schema": "test",-            "name": "varchar",-            "type": "character varying",-            "maxLen": null,-            "enum": [],-            "nullable": false,-            "position": 3,-            "references": null,-            "default": null-          }, {-            "default": null,-            "precision": null,-            "updatable": true,-            "schema": "test",-            "name": "boolean",-            "type": "boolean",-            "maxLen": null,-            "enum": [],-            "nullable": false,-            "references": null,-            "position": 4-          }, {-            "default": null,-            "precision": null,-            "updatable": true,-            "schema": "test",-            "name": "date",-            "type": "date",-            "maxLen": null,-            "enum": [],-            "nullable": false,-            "references": null,-            "position": 5-          }, {-            "default": null,-            "precision": null,-            "updatable": true,-            "schema": "test",-            "name": "money",-            "type": "money",-            "maxLen": null,-            "enum": [],-            "nullable": false,-            "position": 6,-            "references": null,-            "default": null-          }, {-            "default": null,-            "precision": null,-            "updatable": true,-            "schema": "test",-            "name": "enum",-            "type": "USER-DEFINED",-            "maxLen": null,-            "enum": [-              "foo",-              "bar"-            ],-            "nullable": false,-            "position": 7,-            "references": null,-            "default": null-          }-        ]-      }-      |]+    it "should respond to openapi request on none root path with 415" $+      request methodGet "/items"+              (acceptHdrs "application/openapi+json") ""+        `shouldRespondWith` 415 -    it "it includes primary and foreign keys for views" $-      request methodOptions "/projects_view" [] "" `shouldRespondWith`-      [json|-      {-         "pkey":[-            "id"-         ],-         "columns":[-          {-            "references":null,-            "default":null,-            "precision":32,-            "updatable":true,-            "schema":"test",-            "name":"id",-            "type":"integer",-            "maxLen":null,-            "enum":[],-            "nullable":true,-            "position":1-          },-          {-            "references":null,-            "default":null,-            "precision":null,-            "updatable":true,-            "schema":"test",-            "name":"name",-            "type":"text",-            "maxLen":null,-            "enum":[],-            "nullable":true,-            "position":2-          },-          {-            "references": {-              "schema":"test",-              "column":"id",-              "table":"clients"-            },-            "default":null,-            "precision":32,-            "updatable":true,-            "schema":"test",-            "name":"client_id",-            "type":"integer",-            "maxLen":null,-            "enum":[],-            "nullable":true,-            "position":3-          }-        ]-      }-      |]+    describe "RPC" $ -    it "includes foreign key data" $-      request methodOptions "/has_fk" [] ""-        `shouldRespondWith` [json|-      {-        "pkey": ["id"],-        "columns":[-          {-            "default": "nextval('test.has_fk_id_seq'::regclass)",-            "precision": 64,-            "updatable": true,-            "schema": "test",-            "name": "id",-            "type": "bigint",-            "maxLen": null,-            "nullable": false,-            "position": 1,-            "enum": [],-            "references": null-          }, {-            "default": null,-            "precision": 32,-            "updatable": true,-            "schema": "test",-            "name": "auto_inc_fk",-            "type": "integer",-            "maxLen": null,-            "nullable": true,-            "position": 2,-            "enum": [],-            "references": {"schema":"test", "table": "auto_incrementing_pk", "column": "id"}-          }, {-            "default": null,-            "precision": null,-            "updatable": true,-            "schema": "test",-            "name": "simple_fk",-            "type": "character varying",-            "maxLen": 255,-            "nullable": true,-            "position": 3,-            "enum": [],-            "references": {"schema":"test", "table": "simple_pk", "column": "k"}-          }-        ]-      }-      |]+      it "includes a representative function with parameters" $ do+        r <- simpleBody <$> get "/"+        let ref = r ^? key "paths" . key "/rpc/varied_arguments"+                     . key "post"  . key "parameters"+                     . nth 1       . key "schema"+                     . key "$ref"  . _String+            args = r ^? key "definitions" . key "(rpc) varied_arguments" -    it "includes all information on views for renamed columns, and raises relations to correct schema" $-      request methodOptions "/articleStars" [] ""-        `shouldRespondWith` [json|-          {-            "pkey": [-              "articleId",-              "userId"-            ],-            "columns": [-              {-                "references": {-                  "schema": "test",-                  "column": "id",-                  "table": "articles"-                },-                "default": null,-                "precision": 32,-                "updatable": true,-                "schema": "test",-                "name": "articleId",-                "type": "integer",-                "maxLen": null,-                "enum": [],-                "nullable": true,-                "position": 1-              },+        liftIO $ do+          ref `shouldBe` Just "#/definitions/(rpc) varied_arguments"+          args `shouldBe` Just+            [aesonQQ|               {-                "references": {-                  "schema": "test",-                  "column": "id",-                  "table": "users"+                "required": [+                  "double",+                  "varchar",+                  "boolean",+                  "date",+                  "money",+                  "enum"+                ],+                "properties": {+                  "double": {+                    "format": "double precision",+                    "type": "string"+                  },+                  "varchar": {+                    "format": "character varying",+                    "type": "string"+                  },+                  "boolean": {+                    "format": "boolean",+                    "type": "boolean"+                  },+                  "date": {+                    "format": "date",+                    "type": "string"+                  },+                  "money": {+                    "format": "money",+                    "type": "string"+                  },+                  "enum": {+                    "format": "test.enum_menagerie_type",+                    "type": "string"+                  },+                  "integer": {+                    "format": "integer",+                    "type": "integer"+                  }                 },-                "default": null,-                "precision": 32,-                "updatable": true,-                "schema": "test",-                "name": "userId",-                "type": "integer",-                "maxLen": null,-                "enum": [],-                "nullable": true,-                "position": 2-              },-              {-                "references": null,-                "default": null,-                "precision": null,-                "updatable": true,-                "schema": "test",-                "name": "createdAt",-                "type": "timestamp without time zone",-                "maxLen": null,-                "enum": [],-                "nullable": true,-                "position": 3+                "type": "object"               }-            ]-          }-        |]--    it "errors for non existant tables" $-      request methodOptions "/dne" [] "" `shouldRespondWith` 404+            |]    describe "Allow header" $ do 
test/Feature/UnicodeSpec.hs view
@@ -6,6 +6,8 @@ import Network.Wai (Application) import Control.Monad (void) +import Protolude hiding (get)+ spec :: SpecWith Application spec =   describe "Reading and writing to unicode schema and table names" $
test/Main.hs view
@@ -7,43 +7,72 @@  import PostgREST.DbStructure (getDbStructure) import PostgREST.App (postgrest)+import Control.AutoUpdate+import Data.Function (id) import Data.IORef-import Data.String.Conversions (cs)+import Data.Time.Clock.POSIX   (getPOSIXTime)  import qualified Feature.AuthSpec+import qualified Feature.BinaryJwtSecretSpec import qualified Feature.ConcurrentSpec import qualified Feature.CorsSpec import qualified Feature.DeleteSpec import qualified Feature.InsertSpec+import qualified Feature.NoJwtSpec import qualified Feature.QueryLimitedSpec import qualified Feature.QuerySpec import qualified Feature.RangeSpec import qualified Feature.StructureSpec+import qualified Feature.SingularSpec import qualified Feature.UnicodeSpec+import qualified Feature.ProxySpec +import Protolude+ main :: IO () main = do-  setupDb+  testDbConn <- getEnvVarWithDefault "POSTGREST_TEST_CONNECTION" "postgres://postgrest_test@localhost/postgrest_test"+  setupDb testDbConn -  pool <- P.acquire (3, 10, cs testDbConn)+  pool <- P.acquire (3, 10, toS testDbConn)+  -- ask for the OS time at most once per second+  getTime <- mkAutoUpdate+    defaultUpdateSettings { updateAction = getPOSIXTime } +   result <- P.use pool $ getDbStructure "test"-  refDbStructure <- newIORef $ either (error.show) id result-  let withApp = return $ postgrest testCfg refDbStructure pool-      ltdApp  = return $ postgrest testLtdRowsCfg refDbStructure pool-      unicodeApp = return $ postgrest testUnicodeCfg refDbStructure pool+  refDbStructure <- newIORef $ either (panic.show) id result+  let withApp      = return $ postgrest (testCfg testDbConn)          refDbStructure pool getTime+      ltdApp       = return $ postgrest (testLtdRowsCfg testDbConn)   refDbStructure pool getTime+      unicodeApp   = return $ postgrest (testUnicodeCfg testDbConn)   refDbStructure pool getTime+      proxyApp     = return $ postgrest (testProxyCfg testDbConn)     refDbStructure pool getTime+      noJwtApp     = return $ postgrest (testCfgNoJWT testDbConn)     refDbStructure pool getTime+      binaryJwtApp = return $ postgrest (testCfgBinaryJWT testDbConn) refDbStructure pool getTime +  let reset = resetDb testDbConn   hspec $ do-    mapM_ (beforeAll_ resetDb . before withApp) specs+    mapM_ (beforeAll_ reset . before withApp) specs      -- this test runs with a different server flag-    beforeAll_ resetDb . before ltdApp $+    beforeAll_ reset . before ltdApp $       describe "Feature.QueryLimitedSpec" Feature.QueryLimitedSpec.spec      -- this test runs with a different schema-    beforeAll_ resetDb . before unicodeApp $+    beforeAll_ reset . before unicodeApp $       describe "Feature.UnicodeSpec" Feature.UnicodeSpec.spec +    -- this test runs with a proxy+    beforeAll_ reset . before proxyApp $+      describe "Feature.ProxySpec" Feature.ProxySpec.spec++    -- this test runs without a JWT secret+    beforeAll_ reset . before noJwtApp $+      describe "Feature.NoJwtSpec" Feature.NoJwtSpec.spec++    -- this test runs with a binary JWT secret+    beforeAll_ reset . before binaryJwtApp $+      describe "Feature.BinaryJwtSecretSpec" Feature.BinaryJwtSecretSpec.spec+  where   specs = map (uncurry describe) [       ("Feature.AuthSpec"         , Feature.AuthSpec.spec)@@ -53,5 +82,6 @@     , ("Feature.InsertSpec"       , Feature.InsertSpec.spec)     , ("Feature.QuerySpec"        , Feature.QuerySpec.spec)     , ("Feature.RangeSpec"        , Feature.RangeSpec.spec)+    , ("Feature.SingularSpec"     , Feature.SingularSpec.spec)     , ("Feature.StructureSpec"    , Feature.StructureSpec.spec)     ]
test/SpecHelper.hs view
@@ -1,66 +1,140 @@ module SpecHelper where -import Data.String.Conversions (cs) import Control.Monad (void) -import Network.HTTP.Types.Header (Header, ByteRange, renderByteRange,-                                  hRange, hAuthorization, hAccept)-import Codec.Binary.Base64.String (encode)+import qualified System.IO.Error as E+import System.Environment (getEnv)++import qualified Data.ByteString.Base64 as B64 (encode, decodeLenient) import Data.CaseInsensitive (CI(..))+import qualified Data.Set as S+import qualified Data.Map.Strict as M+import Data.List (lookup) import Text.Regex.TDFA ((=~)) import qualified Data.ByteString.Char8 as BS+import qualified Data.ByteString.Lazy as BL import System.Process (readProcess)-import Web.JWT (secret)  import PostgREST.Config (AppConfig(..)) -testDbConn :: String-testDbConn = "postgres://postgrest_test_authenticator@localhost:5432/postgrest_test"+import Test.Hspec hiding (pendingWith)+import Test.Hspec.Wai -testCfg :: AppConfig-testCfg =-  AppConfig testDbConn "postgrest_test_anonymous" "test" 3000 (secret "safe") 10 Nothing True+import Network.HTTP.Types+import Network.Wai.Test (SResponse(simpleStatus, simpleHeaders, simpleBody)) -testUnicodeCfg :: AppConfig-testUnicodeCfg =-  AppConfig testDbConn "postgrest_test_anonymous" "تست" 3000 (secret "safe") 10 Nothing True+import Data.Maybe (fromJust)+import Data.Aeson (decode, Value(..))+import qualified Data.JsonSchema.Draft4 as D4 -testLtdRowsCfg :: AppConfig-testLtdRowsCfg =-  AppConfig testDbConn "postgrest_test_anonymous" "test" 3000 (secret "safe") 10 (Just 2) True+import Protolude -setupDb :: IO ()-setupDb = do-  void $ readProcess "psql" ["-d", "postgres", "-a", "-f", "test/fixtures/database.sql"] []-  loadFixture "roles"-  loadFixture "schema"-  loadFixture "privileges"-  resetDb+validateOpenApiResponse :: [Header] -> WaiSession ()+validateOpenApiResponse headers = do+  r <- request methodGet "/" headers ""+  liftIO $+    let respStatus = simpleStatus r in+    respStatus `shouldSatisfy`+      \s -> s == Status { statusCode = 200, statusMessage="OK" }+  liftIO $+    let respHeaders = simpleHeaders r in+    respHeaders `shouldSatisfy`+      \hs -> ("Content-Type", "application/openapi+json; charset=utf-8") `elem` hs+  liftIO $+    let respBody = simpleBody r+        schema :: D4.Schema+        schema = D4.emptySchema { D4._schemaRef = Just "openapi.json" }+        schemaContext :: D4.SchemaWithURI D4.Schema+        schemaContext = D4.SchemaWithURI+          { D4._swSchema = schema+          , D4._swURI    = Just "test/fixtures/openapi.json"+          }+       in+       D4.fetchFilesystemAndValidate schemaContext ((fromJust . decode) respBody) `shouldReturn` Right () -resetDb :: IO ()-resetDb = loadFixture "data"+getEnvVarWithDefault :: Text -> Text -> IO Text+getEnvVarWithDefault var def = do+  varValue <- getEnv (toS var) `E.catchIOError` const (return $ toS def)+  return $ toS varValue -loadFixture :: FilePath -> IO()-loadFixture name =-  void $ readProcess "psql" ["-U", "postgrest_test", "-d", "postgrest_test", "-a", "-f", "test/fixtures/" ++ name ++ ".sql"] []+_baseCfg :: AppConfig+_baseCfg =  -- Connection Settings+  AppConfig mempty "postgrest_test_anonymous" Nothing "test" "localhost" 3000+            -- Jwt settings+            (Just $ encodeUtf8 "safe") False+            -- Connection Modifiers+            10 Nothing (Just "test.switch_role")+            -- Debug Settings+            True +testCfg :: Text -> AppConfig+testCfg testDbConn = _baseCfg { configDatabase = testDbConn }++testCfgNoJWT :: Text -> AppConfig+testCfgNoJWT testDbConn = (testCfg testDbConn) { configJwtSecret = Nothing }++testUnicodeCfg :: Text -> AppConfig+testUnicodeCfg testDbConn = (testCfg testDbConn) { configSchema = "تست" }++testLtdRowsCfg :: Text -> AppConfig+testLtdRowsCfg testDbConn = (testCfg testDbConn) { configMaxRows = Just 2 }++testProxyCfg :: Text -> AppConfig+testProxyCfg testDbConn = (testCfg testDbConn) { configProxyUri = Just "https://postgrest.com/openapi.json" }++testCfgBinaryJWT :: Text -> AppConfig+testCfgBinaryJWT testDbConn = (testCfg testDbConn) { configJwtSecret = Just secretBs }+  where secretBs = B64.decodeLenient "h2CGB1FoBd51aQooCS2g+UmRgYQfTPQ6v3+9ALbaqM4="+++setupDb :: Text -> IO ()+setupDb dbConn = do+  loadFixture dbConn "database"+  loadFixture dbConn "roles"+  loadFixture dbConn "schema"+  loadFixture dbConn "jwt"+  loadFixture dbConn "privileges"+  resetDb dbConn++resetDb :: Text -> IO ()+resetDb dbConn = loadFixture dbConn "data"++loadFixture :: Text -> FilePath -> IO()+loadFixture dbConn name =+  void $ readProcess "psql" [toS dbConn, "-a", "-f", "test/fixtures/" ++ name ++ ".sql"] []+ rangeHdrs :: ByteRange -> [Header] rangeHdrs r = [rangeUnit, (hRange, renderByteRange r)] +rangeHdrsWithCount :: ByteRange -> [Header]+rangeHdrsWithCount r = ("Prefer", "count=exact") : rangeHdrs r+ acceptHdrs :: BS.ByteString -> [Header] acceptHdrs mime = [(hAccept, mime)]  rangeUnit :: Header rangeUnit = ("Range-Unit" :: CI BS.ByteString, "items") -matchHeader :: CI BS.ByteString -> String -> [Header] -> Bool+matchHeader :: CI BS.ByteString -> BS.ByteString -> [Header] -> Bool matchHeader name valRegex headers =   maybe False (=~ valRegex) $ lookup name headers -authHeaderBasic :: String -> String -> Header+authHeaderBasic :: BS.ByteString -> BS.ByteString -> Header authHeaderBasic u p =-  (hAuthorization, cs $ "Basic " ++ encode (u ++ ":" ++ p))+  (hAuthorization, "Basic " <> (toS . B64.encode . toS $ u <> ":" <> p)) -authHeaderJWT :: String -> Header+authHeaderJWT :: BS.ByteString -> Header authHeaderJWT token =-  (hAuthorization, cs $ "Bearer " ++ token)+  (hAuthorization, "Bearer " <> token)++-- | Tests whether the text can be parsed as a json object comtaining+-- the key "message", and optional keys "details", "hint", "code",+-- and no extraneous keys+isErrorFormat :: BL.ByteString -> Bool+isErrorFormat s =+  "message" `S.member` keys &&+    S.null (S.difference keys validKeys)+ where+  obj = decode s :: Maybe (M.Map Text Value)+  keys = fromMaybe S.empty (M.keysSet <$> obj)+  validKeys = S.fromList ["message", "details", "hint", "code"]
test/TestTypes.hs view
@@ -1,23 +1,18 @@ module TestTypes (   IncPK(..) , CompoundPK(..)--- , incFromList--- , compoundFromList ) where  import qualified Data.Aeson as JSON import Data.Aeson ((.:))--- import Data.Maybe (fromJust)-import Control.Applicative-import Control.Monad (mzero) -import Prelude+import Protolude  data IncPK = IncPK {   incId :: Int-, incNullableStr :: Maybe String-, incStr :: String-, incInsert :: String+, incNullableStr :: Maybe Text+, incStr :: Text+, incInsert :: Text } deriving (Eq, Show)  instance JSON.FromJSON IncPK where@@ -28,16 +23,9 @@     r .: "inserted_at"   parseJSON _ = mzero --- incFromList :: [(String, SqlValue)] -> IncPK--- incFromList row = IncPK---   (fromSql . fromJust $ lookup "id" row)---   (fromSql . fromJust $ lookup "nullable_string" row)---   (fromSql . fromJust $ lookup "non_nullable_string" row)---   (fromSql . fromJust $ lookup "inserted_at" row)- data CompoundPK = CompoundPK {   compoundK1 :: Int-, compoundK2 :: String+, compoundK2 :: Text , compoundExtra :: Maybe Int } deriving (Eq, Show) @@ -47,9 +35,3 @@     r .: "k2" <*>     r .: "extra"   parseJSON _ = mzero---- compoundFromList :: [(String, SqlValue)] -> CompoundPK--- compoundFromList row = CompoundPK---   (fromSql . fromJust $ lookup "k1" row)---   (fromSql . fromJust $ lookup "k2" row)---   (fromSql . fromJust $ lookup "extra" row)