+7
-1
@@ -5,10 +5,13 @@ This project adheres to [Semantic Versioning](http://semver.org/).
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Use reasonable amount of memory during bulk inserts - @begriffs
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- Ensure JWT expires - @calebmer
|
- Ensure JWT expires - @calebmer
|
||||||
- Postgres connection string argument - @calebmer
|
- Postgres connection string argument - @calebmer
|
||||||
- Encode JWT for procs that return type `jwt_claims` - @
|
- Encode JWT for procs that return type `jwt_claims` - @diogob
|
||||||
- Full text operators `@>`,`<@` - @ruslantalpa
|
- Full text operators `@>`,`<@` - @ruslantalpa
|
||||||
- Shaping of the response body (filter columns, embed relations) with &select parameter for POST/PATCH - @ruslantalpa
|
- Shaping of the response body (filter columns, embed relations) with &select parameter for POST/PATCH - @ruslantalpa
|
||||||
- Detect relationships between public views and private tables - @calebmer
|
- Detect relationships between public views and private tables - @calebmer
|
||||||
@@ -20,6 +23,9 @@ This project adheres to [Semantic Versioning](http://semver.org/).
|
|||||||
- Secure flag - @calebmer
|
- Secure flag - @calebmer
|
||||||
- PUT request handling - @ruslantalpa
|
- PUT request handling - @ruslantalpa
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Embed foreign keys with {} rather than () - @begriffs
|
||||||
|
|
||||||
## [0.2.12.1] - 2015-11-12
|
## [0.2.12.1] - 2015-11-12
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ executable postgrest
|
|||||||
, PostgREST.DbStructure
|
, PostgREST.DbStructure
|
||||||
, PostgREST.QueryBuilder
|
, PostgREST.QueryBuilder
|
||||||
, PostgREST.RangeQuery
|
, PostgREST.RangeQuery
|
||||||
|
, PostgREST.ApiRequest
|
||||||
, PostgREST.Types
|
, PostgREST.Types
|
||||||
|
|
||||||
library
|
library
|
||||||
@@ -135,6 +136,7 @@ library
|
|||||||
, PostgREST.DbStructure
|
, PostgREST.DbStructure
|
||||||
, PostgREST.QueryBuilder
|
, PostgREST.QueryBuilder
|
||||||
, PostgREST.RangeQuery
|
, PostgREST.RangeQuery
|
||||||
|
, PostgREST.ApiRequest
|
||||||
, PostgREST.Types
|
, PostgREST.Types
|
||||||
hs-source-dirs: src
|
hs-source-dirs: src
|
||||||
|
|
||||||
@@ -165,6 +167,7 @@ Test-Suite spec
|
|||||||
, PostgREST.DbStructure
|
, PostgREST.DbStructure
|
||||||
, PostgREST.QueryBuilder
|
, PostgREST.QueryBuilder
|
||||||
, PostgREST.RangeQuery
|
, PostgREST.RangeQuery
|
||||||
|
, PostgREST.ApiRequest
|
||||||
, PostgREST.Types
|
, PostgREST.Types
|
||||||
, Spec
|
, Spec
|
||||||
, SpecHelper
|
, SpecHelper
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
module PostgREST.ApiRequest where
|
||||||
|
|
||||||
|
import qualified Data.Aeson as JSON
|
||||||
|
import qualified Data.ByteString as BS
|
||||||
|
import qualified Data.ByteString.Lazy as BL
|
||||||
|
import qualified Data.Csv as CSV
|
||||||
|
import Data.List (find)
|
||||||
|
import qualified Data.HashMap.Strict as M
|
||||||
|
import qualified Data.Set as S
|
||||||
|
import Data.Maybe (fromMaybe, isJust, isNothing,
|
||||||
|
listToMaybe, fromJust)
|
||||||
|
import Control.Monad (join)
|
||||||
|
import Data.Monoid ((<>))
|
||||||
|
import Data.String.Conversions (cs)
|
||||||
|
import qualified Data.Text as T
|
||||||
|
import qualified Data.Vector as V
|
||||||
|
import Network.Wai (Request (..))
|
||||||
|
import Network.Wai.Parse (parseHttpAccept)
|
||||||
|
import PostgREST.RangeQuery (NonnegRange, rangeRequested)
|
||||||
|
import PostgREST.Types (QualifiedIdentifier (..),
|
||||||
|
Schema, Payload(..),
|
||||||
|
UniformObjects(..))
|
||||||
|
|
||||||
|
type RequestBody = BL.ByteString
|
||||||
|
|
||||||
|
-- | Types of things a user wants to do to tables/views/procs
|
||||||
|
data Action = ActionCreate | ActionRead
|
||||||
|
| ActionUpdate | ActionDelete
|
||||||
|
| ActionInfo | ActionInvoke
|
||||||
|
| ActionUnknown BS.ByteString deriving Eq
|
||||||
|
-- | The target db object of a user action
|
||||||
|
data Target = TargetIdent QualifiedIdentifier
|
||||||
|
| TargetRoot
|
||||||
|
| TargetUnknown [T.Text]
|
||||||
|
-- | 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"
|
||||||
|
show TextCSV = "text/csv"
|
||||||
|
|
||||||
|
{-|
|
||||||
|
Describes what the user wants to do. This data type is a
|
||||||
|
translation of the raw elements of an HTTP request into domain
|
||||||
|
specific language. There is no guarantee that the intent is
|
||||||
|
sensible, it is up to a later stage of processing to determine
|
||||||
|
if it is an action we are able to perform.
|
||||||
|
-}
|
||||||
|
data ApiRequest = ApiRequest {
|
||||||
|
-- | Set to Nothing for unknown HTTP verbs
|
||||||
|
iAction :: Action
|
||||||
|
-- | Set to Nothing for malformed range
|
||||||
|
, iRange :: Maybe NonnegRange
|
||||||
|
-- | Set to Nothing for strangely nested urls
|
||||||
|
, iTarget :: Target
|
||||||
|
-- | The content type the client most desires (or JSON if undecided)
|
||||||
|
, iAccepts :: Either BS.ByteString ContentType
|
||||||
|
-- | Data sent by client and used for mutation actions
|
||||||
|
, iPayload :: Maybe Payload
|
||||||
|
-- | If client wants created items echoed back
|
||||||
|
, iPreferRepresentation :: Bool
|
||||||
|
-- | If client wants first row as raw object
|
||||||
|
, iPreferSingular :: Bool
|
||||||
|
-- | Whether the client wants a result count (slower)
|
||||||
|
, iPreferCount :: Bool
|
||||||
|
-- | Filters on the result ("id", "eq.10")
|
||||||
|
, iFilters :: [(String, String)]
|
||||||
|
-- | &select parameter used to shape the response
|
||||||
|
, iSelect :: String
|
||||||
|
-- | &order parameter
|
||||||
|
, iOrder :: Maybe String
|
||||||
|
}
|
||||||
|
|
||||||
|
-- | Examines HTTP request and translates it into user intent.
|
||||||
|
userApiRequest :: Schema -> Request -> RequestBody -> ApiRequest
|
||||||
|
userApiRequest schema req reqBody =
|
||||||
|
let action = case method of
|
||||||
|
"GET" -> ActionRead
|
||||||
|
"POST" -> if isTargetingProc
|
||||||
|
then ActionInvoke
|
||||||
|
else ActionCreate
|
||||||
|
"PATCH" -> ActionUpdate
|
||||||
|
"DELETE" -> ActionDelete
|
||||||
|
"OPTIONS" -> ActionInfo
|
||||||
|
other -> ActionUnknown other
|
||||||
|
target = case path of
|
||||||
|
[] -> TargetRoot
|
||||||
|
[table] -> TargetIdent
|
||||||
|
$ QualifiedIdentifier schema table
|
||||||
|
["rpc", proc] -> TargetIdent
|
||||||
|
$ 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)
|
||||||
|
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
|
||||||
|
, iRange = if singular then Nothing else rangeRequested hdrs
|
||||||
|
, iTarget = target
|
||||||
|
, iAccepts = pickContentType $ lookupHeader "accept"
|
||||||
|
, iPayload = relevantPayload
|
||||||
|
, iPreferRepresentation = hasPrefer "return=representation"
|
||||||
|
, iPreferSingular = singular
|
||||||
|
, iPreferCount = not $ hasPrefer "count=none"
|
||||||
|
, iFilters = [ (k, fromJust v) | (k,v) <- qParams, k `notElem` ["select", "order"], isJust v ]
|
||||||
|
, iSelect = if method == "DELETE"
|
||||||
|
then "*"
|
||||||
|
else fromMaybe "*" $ fromMaybe (Just "*") $ lookup "select" qParams
|
||||||
|
, iOrder = join $ lookup "order" qParams
|
||||||
|
}
|
||||||
|
|
||||||
|
where
|
||||||
|
path = pathInfo req
|
||||||
|
method = requestMethod req
|
||||||
|
isTargetingProc = fromMaybe False $ (== "rpc") <$> listToMaybe path
|
||||||
|
hdrs = requestHeaders req
|
||||||
|
qParams = [(cs k, cs <$> v)|(k,v) <- queryString req]
|
||||||
|
lookupHeader = flip lookup hdrs
|
||||||
|
hasPrefer val = any (\(h,v) -> h == "Prefer" && v == val) hdrs
|
||||||
|
singular = hasPrefer "plurality=singular"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- PRIVATE ---------------------------------------------------------------
|
||||||
|
|
||||||
|
{-|
|
||||||
|
Picks a preferred content type from an Accept header (or from
|
||||||
|
Content-Type as a degenerate case).
|
||||||
|
|
||||||
|
For example
|
||||||
|
text/csv -> TextCSV
|
||||||
|
*/* -> ApplicationJSON
|
||||||
|
text/csv, application/json -> TextCSV
|
||||||
|
application/json, text/csv -> ApplicationJSON
|
||||||
|
-}
|
||||||
|
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
|
||||||
|
|
||||||
|
type CsvData = V.Vector (M.HashMap T.Text BL.ByteString)
|
||||||
|
|
||||||
|
{-|
|
||||||
|
Converts CSV like
|
||||||
|
a,b
|
||||||
|
1,hi
|
||||||
|
2,bye
|
||||||
|
|
||||||
|
into a JSON array like
|
||||||
|
[ {"a": "1", "b": "hi"}, {"a": 2, "b": "bye"} ]
|
||||||
|
|
||||||
|
The reason for its odd signature is so that it can compose
|
||||||
|
directly with CSV.decodeByName
|
||||||
|
-}
|
||||||
|
csvToJson :: (CSV.Header, CsvData) -> JSON.Array
|
||||||
|
csvToJson (_, vals) =
|
||||||
|
V.map rowToJsonObj vals
|
||||||
|
where
|
||||||
|
rowToJsonObj = JSON.Object .
|
||||||
|
M.map (\str ->
|
||||||
|
if str == "NULL"
|
||||||
|
then JSON.Null
|
||||||
|
else JSON.String $ cs str
|
||||||
|
)
|
||||||
|
|
||||||
|
-- | Convert {foo} to [{foo}], leave arrays unchanged
|
||||||
|
-- and truncate everything else to an empty array.
|
||||||
|
pluralize :: JSON.Value -> JSON.Array
|
||||||
|
pluralize obj@(JSON.Object _) = V.singleton obj
|
||||||
|
pluralize (JSON.Array arr) = arr
|
||||||
|
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
|
||||||
|
ensureUniform arr =
|
||||||
|
let objs :: V.Vector JSON.Object
|
||||||
|
objs = foldr -- filter non-objects, map to raw objects
|
||||||
|
(\val result -> case val of
|
||||||
|
JSON.Object o -> V.cons o result
|
||||||
|
_ -> result)
|
||||||
|
V.empty arr
|
||||||
|
keysPerObj = V.map (S.fromList . M.keys) objs
|
||||||
|
canonicalKeys = fromMaybe S.empty $ keysPerObj V.!? 0
|
||||||
|
areKeysUniform = all (==canonicalKeys) keysPerObj in
|
||||||
|
|
||||||
|
if (V.length objs == V.length arr) && areKeysUniform
|
||||||
|
then Just (UniformObjects objs)
|
||||||
|
else Nothing
|
||||||
+152
-274
@@ -4,26 +4,21 @@
|
|||||||
--module PostgREST.App where
|
--module PostgREST.App where
|
||||||
module PostgREST.App (
|
module PostgREST.App (
|
||||||
app
|
app
|
||||||
, contentTypeForAccept
|
|
||||||
) where
|
) where
|
||||||
|
|
||||||
import Control.Applicative
|
import Control.Applicative
|
||||||
import Control.Arrow ((***))
|
import Control.Arrow ((***))
|
||||||
import Control.Monad (join)
|
import Control.Monad (join)
|
||||||
import Data.Bifunctor (first)
|
import Data.Bifunctor (first)
|
||||||
import qualified Data.ByteString.Char8 as BS
|
|
||||||
import qualified Data.ByteString.Lazy as BL
|
import qualified Data.ByteString.Lazy as BL
|
||||||
import qualified Data.Csv as CSV
|
|
||||||
import Data.Functor.Identity
|
import Data.Functor.Identity
|
||||||
import qualified Data.HashMap.Strict as HM
|
import Data.List (find, sortBy, delete)
|
||||||
import Data.List (find, sortBy, delete, transpose)
|
import Data.Maybe (fromMaybe, fromJust, mapMaybe)
|
||||||
import Data.Maybe (fromMaybe, fromJust, isJust, isNothing, mapMaybe)
|
|
||||||
import Data.Ord (comparing)
|
import Data.Ord (comparing)
|
||||||
import Data.Ranged.Ranges (emptyRange, singletonRange)
|
import Data.Ranged.Ranges (emptyRange)
|
||||||
import Data.String.Conversions (cs)
|
import Data.String.Conversions (cs)
|
||||||
import Data.Text (Text, replace, strip)
|
import Data.Text (Text, replace, strip)
|
||||||
import Data.Tree
|
import Data.Tree
|
||||||
import qualified Data.Map as M
|
|
||||||
|
|
||||||
import Text.Parsec.Error
|
import Text.Parsec.Error
|
||||||
import Text.ParserCombinators.Parsec (parse)
|
import Text.ParserCombinators.Parsec (parse)
|
||||||
@@ -33,7 +28,6 @@ import Network.HTTP.Types.Header
|
|||||||
import Network.HTTP.Types.Status
|
import Network.HTTP.Types.Status
|
||||||
import Network.HTTP.Types.URI (parseSimpleQuery)
|
import Network.HTTP.Types.URI (parseSimpleQuery)
|
||||||
import Network.Wai
|
import Network.Wai
|
||||||
import Network.Wai.Parse (parseHttpAccept)
|
|
||||||
|
|
||||||
import Data.Aeson
|
import Data.Aeson
|
||||||
import Data.Aeson.Types (emptyArray)
|
import Data.Aeson.Types (emptyArray)
|
||||||
@@ -47,51 +41,46 @@ import PostgREST.Config (AppConfig (..))
|
|||||||
import PostgREST.Parsers
|
import PostgREST.Parsers
|
||||||
import PostgREST.DbStructure
|
import PostgREST.DbStructure
|
||||||
import PostgREST.RangeQuery
|
import PostgREST.RangeQuery
|
||||||
|
import PostgREST.ApiRequest (ApiRequest(..), ContentType(..)
|
||||||
|
, Action(..), Target(..)
|
||||||
|
, userApiRequest)
|
||||||
import PostgREST.Types
|
import PostgREST.Types
|
||||||
import PostgREST.Auth (tokenJWT)
|
import PostgREST.Auth (tokenJWT)
|
||||||
import PostgREST.Error (errResponse)
|
import PostgREST.Error (errResponse)
|
||||||
|
|
||||||
import PostgREST.QueryBuilder ( asJson
|
import PostgREST.QueryBuilder ( asJson
|
||||||
, callProc
|
, callProc
|
||||||
, asCsvF
|
|
||||||
, asJsonF
|
|
||||||
, selectStarF
|
|
||||||
, countF
|
|
||||||
, locationF
|
|
||||||
, asJsonSingleF
|
|
||||||
, addJoinConditions
|
, addJoinConditions
|
||||||
, sourceSubqueryName
|
, sourceSubqueryName
|
||||||
, requestToQuery
|
, requestToQuery
|
||||||
, wrapQuery
|
|
||||||
, countAllF
|
|
||||||
, countNoneF
|
|
||||||
, addRelations
|
, addRelations
|
||||||
|
, createReadStatement
|
||||||
|
, createWriteStatement
|
||||||
)
|
)
|
||||||
|
|
||||||
import Prelude
|
import Prelude
|
||||||
|
|
||||||
app :: DbStructure -> AppConfig -> RequestBody -> Request -> H.Tx P.Postgres s Response
|
app :: DbStructure -> AppConfig -> RequestBody -> Request -> H.Tx P.Postgres s Response
|
||||||
app dbStructure conf reqBody req =
|
app dbStructure conf reqBody req =
|
||||||
case (path, verb) of
|
let
|
||||||
([table], "OPTIONS") -> do
|
-- TODO: blow up for Left values (there is a middleware that checks the headers)
|
||||||
let cols = filter (filterCol schema table) $ dbColumns dbStructure
|
contentType = either (const ApplicationJSON) id (iAccepts apiRequest)
|
||||||
pkeys = map pkName $ filter (filterPk schema table) allPrKeys
|
contentTypeH = (hContentType, cs $ show contentType) in
|
||||||
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
|
|
||||||
|
|
||||||
return $ responseLBS status200 [jsonH, allOrigins] $ cs body
|
case (iAction apiRequest, iTarget apiRequest, iPayload apiRequest) of
|
||||||
|
|
||||||
([table], _) ->
|
(ActionRead, TargetIdent qi, Nothing) ->
|
||||||
case request of
|
case selectQuery of
|
||||||
Left e -> return $ responseLBS status400 [jsonH] $ cs e
|
Left e -> return $ responseLBS status400 [jsonH] $ cs e
|
||||||
Right (selectQuery, Nothing) -> -- should we do sanity check to make sure its a GET request?
|
Right q -> do
|
||||||
|
let range = iRange apiRequest
|
||||||
|
singular = iPreferSingular apiRequest
|
||||||
|
stm = createReadStatement q range singular
|
||||||
|
(iPreferCount apiRequest) (contentType == TextCSV)
|
||||||
if range == Just emptyRange
|
if range == Just emptyRange
|
||||||
then return $ errResponse status416 "HTTP Range error"
|
then return $ errResponse status416 "HTTP Range error"
|
||||||
else do
|
else do
|
||||||
let q = createReadStatement selectQuery (if singular then Nothing else range) singular (not $ hasPrefer "count=none") isCsv
|
row <- H.maybeEx stm
|
||||||
row <- H.maybeEx q
|
|
||||||
let (tableTotal, queryTotal, _ , body) = extractQueryResult row
|
let (tableTotal, queryTotal, _ , body) = extractQueryResult row
|
||||||
if singular
|
if singular
|
||||||
then return $ if queryTotal <= 0
|
then return $ if queryTotal <= 0
|
||||||
@@ -110,51 +99,75 @@ app dbStructure conf reqBody req =
|
|||||||
return $ responseLBS status
|
return $ responseLBS status
|
||||||
[contentTypeH, contentRange,
|
[contentTypeH, contentRange,
|
||||||
("Content-Location",
|
("Content-Location",
|
||||||
"/" <> cs table <>
|
"/" <> cs (qiName qi) <>
|
||||||
if Prelude.null canonical then "" else "?" <> cs canonical
|
if Prelude.null canonical then "" else "?" <> cs canonical
|
||||||
)
|
)
|
||||||
] (fromMaybe "[]" body)
|
] (fromMaybe "[]" body)
|
||||||
Right (selectQuery, Just (mutateQuery, isSingle)) ->
|
|
||||||
case verb of
|
|
||||||
"POST" -> do
|
|
||||||
let pKeys = map pkName $ filter (filterPk schema table) allPrKeys -- would it be ok to move primary key detection in the query itself?
|
|
||||||
q = createWriteStatement selectQuery mutateQuery isSingle echoRequested pKeys isCsv
|
|
||||||
row <- H.maybeEx q
|
|
||||||
let (_, _, location, body) = extractQueryResult row
|
|
||||||
return $ responseLBS status201
|
|
||||||
[
|
|
||||||
contentTypeH,
|
|
||||||
(hLocation, "/" <> cs table <> "?" <> cs (fromMaybe "" location))
|
|
||||||
]
|
|
||||||
$ if echoRequested then fromMaybe "[]" body else ""
|
|
||||||
"PATCH" -> do
|
|
||||||
let q = createWriteStatement selectQuery mutateQuery False echoRequested [] isCsv
|
|
||||||
row <- H.maybeEx q
|
|
||||||
let (_, queryTotal, _, body) = extractQueryResult row
|
|
||||||
r = contentRangeH 0 (queryTotal-1) (Just queryTotal)
|
|
||||||
s = case () of _ | queryTotal == 0 -> status404
|
|
||||||
| echoRequested -> status200
|
|
||||||
| otherwise -> status204
|
|
||||||
return $ responseLBS s [contentTypeH, r]
|
|
||||||
$ if echoRequested then fromMaybe "[]" body else ""
|
|
||||||
"DELETE" -> do
|
|
||||||
let q = createWriteStatement selectQuery mutateQuery False False [] isCsv
|
|
||||||
row <- H.maybeEx q
|
|
||||||
let (_, queryTotal, _, _) = extractQueryResult row
|
|
||||||
return $ if queryTotal == 0
|
|
||||||
then notFound
|
|
||||||
else responseLBS status204 [("Content-Range", "*/"<> cs (show queryTotal))] ""
|
|
||||||
_ -> return notFound
|
|
||||||
|
|
||||||
(["rpc", proc], "POST") -> do
|
(ActionCreate, TargetIdent (QualifiedIdentifier _ table),
|
||||||
let qi = QualifiedIdentifier schema (cs proc)
|
Just payload@(PayloadJSON (UniformObjects rows))) ->
|
||||||
exists <- doesProcExist schema proc
|
case queries 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 sq mq isSingle (iPreferRepresentation apiRequest) pKeys (contentType == TextCSV) payload
|
||||||
|
row <- H.maybeEx stm
|
||||||
|
let (_, _, location, body) = extractQueryResult row
|
||||||
|
return $ responseLBS status201
|
||||||
|
[
|
||||||
|
contentTypeH,
|
||||||
|
(hLocation, "/" <> cs table <> "?" <> cs (fromMaybe "" location))
|
||||||
|
]
|
||||||
|
$ if iPreferRepresentation apiRequest then fromMaybe "[]" body else ""
|
||||||
|
|
||||||
|
(ActionUpdate, TargetIdent _, Just payload@(PayloadJSON _)) ->
|
||||||
|
case queries of
|
||||||
|
Left e -> return $ responseLBS status400 [jsonH] $ cs e
|
||||||
|
Right (sq,mq) -> do
|
||||||
|
let stm = createWriteStatement sq mq False (iPreferRepresentation apiRequest) [] (contentType == TextCSV) payload
|
||||||
|
row <- H.maybeEx stm
|
||||||
|
let (_, queryTotal, _, body) = extractQueryResult row
|
||||||
|
r = contentRangeH 0 (queryTotal-1) (Just queryTotal)
|
||||||
|
s = case () of _ | queryTotal == 0 -> status404
|
||||||
|
| iPreferRepresentation apiRequest -> status200
|
||||||
|
| otherwise -> status204
|
||||||
|
return $ responseLBS s [contentTypeH, r]
|
||||||
|
$ if iPreferRepresentation apiRequest then fromMaybe "[]" body else ""
|
||||||
|
|
||||||
|
(ActionDelete, TargetIdent _, Nothing) ->
|
||||||
|
case queries of
|
||||||
|
Left e -> return $ responseLBS status400 [jsonH] $ cs e
|
||||||
|
Right (sq,mq) -> do
|
||||||
|
let fakeload = PayloadJSON $ UniformObjects V.empty
|
||||||
|
let stm = createWriteStatement sq mq False False [] (contentType == TextCSV) fakeload
|
||||||
|
row <- H.maybeEx stm
|
||||||
|
let (_, queryTotal, _, _) = extractQueryResult row
|
||||||
|
return $ if queryTotal == 0
|
||||||
|
then notFound
|
||||||
|
else responseLBS status204 [("Content-Range", "*/"<> cs (show queryTotal))] ""
|
||||||
|
|
||||||
|
(ActionInfo, TargetIdent (QualifiedIdentifier tSchema tTable), Nothing) -> do
|
||||||
|
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
|
||||||
|
return $ responseLBS status200 [jsonH, allOrigins] $ cs body
|
||||||
|
|
||||||
|
(ActionInvoke, TargetIdent qi,
|
||||||
|
Just (PayloadJSON (UniformObjects payload))) -> do
|
||||||
|
exists <- doesProcExist qi
|
||||||
if exists
|
if exists
|
||||||
then do
|
then do
|
||||||
let call = B.Stmt "select " V.empty True <>
|
let p = V.head payload
|
||||||
asJson (callProc qi $ fromMaybe HM.empty (decode reqBody))
|
call = B.Stmt "select " V.empty True <>
|
||||||
|
asJson (callProc qi p)
|
||||||
|
jwtSecret = configJwtSecret conf
|
||||||
|
|
||||||
bodyJson :: Maybe (Identity Value) <- H.maybeEx call
|
bodyJson :: Maybe (Identity Value) <- H.maybeEx call
|
||||||
returnJWT <- doesProcReturnJWT schema proc
|
returnJWT <- doesProcReturnJWT qi
|
||||||
return $ responseLBS status200 [jsonH]
|
return $ responseLBS status200 [jsonH]
|
||||||
(let body = fromMaybe emptyArray $ runIdentity <$> bodyJson in
|
(let body = fromMaybe emptyArray $ runIdentity <$> bodyJson in
|
||||||
if returnJWT
|
if returnJWT
|
||||||
@@ -162,37 +175,30 @@ app dbStructure conf reqBody req =
|
|||||||
else cs $ encode body)
|
else cs $ encode body)
|
||||||
else return notFound
|
else return notFound
|
||||||
|
|
||||||
-- check that proc exists
|
(ActionRead, TargetRoot, Nothing) -> do
|
||||||
-- check that arg names are all specified
|
|
||||||
-- select * from public.proc(a := "foo"::undefined) where whereT limit limitT
|
|
||||||
|
|
||||||
([], "GET") -> do -- this should be a GET request only
|
|
||||||
body <- encode <$> accessibleTables (filter ((== cs schema) . tableSchema) (dbTables dbStructure))
|
body <- encode <$> accessibleTables (filter ((== cs schema) . tableSchema) (dbTables dbStructure))
|
||||||
return $ responseLBS status200 [jsonH] $ cs body
|
return $ responseLBS status200 [jsonH] $ cs body
|
||||||
|
|
||||||
(_, _) ->
|
(ActionUnknown _, _, _) -> return notFound
|
||||||
return notFound
|
|
||||||
|
|
||||||
where
|
(_, TargetUnknown _, _) -> return notFound
|
||||||
notFound = responseLBS status404 [] ""
|
|
||||||
allPrKeys = dbPrimaryKeys dbStructure
|
(_, _, Just (PayloadParseError e)) ->
|
||||||
filterPk sc table pk = sc == (tableSchema . pkTable) pk && table == (tableName . pkTable) pk
|
return $ responseLBS status400 [jsonH] $
|
||||||
path = pathInfo req
|
cs (formatGeneralError "Cannot parse request payload" (cs e))
|
||||||
verb = requestMethod req
|
|
||||||
hdrs = requestHeaders req
|
(_, _, _) -> return notFound
|
||||||
lookupHeader = flip lookup hdrs
|
|
||||||
hasPrefer val = any (\(h,v) -> h == "Prefer" && v == val) hdrs
|
where
|
||||||
accept = lookupHeader hAccept
|
notFound = responseLBS status404 [] ""
|
||||||
schema = cs $ configSchema conf
|
filterPk sc table pk = sc == (tableSchema . pkTable) pk && table == (tableName . pkTable) pk
|
||||||
jwtSecret = (cs $ configJwtSecret conf) :: Text
|
allPrKeys = dbPrimaryKeys dbStructure
|
||||||
range = rangeRequested hdrs
|
allOrigins = ("Access-Control-Allow-Origin", "*") :: Header
|
||||||
allOrigins = ("Access-Control-Allow-Origin", "*") :: Header
|
schema = cs $ configSchema conf
|
||||||
contentType = fromMaybe "application/json" $ contentTypeForAccept accept
|
apiRequest = userApiRequest schema req reqBody
|
||||||
isCsv = contentType == csvMT
|
selectQuery = requestToQuery schema <$> (DbRead <$> buildReadRequest (dbRelations dbStructure) apiRequest)
|
||||||
contentTypeH = (hContentType, contentType)
|
mutateQuery = requestToQuery schema <$> (DbMutate <$> buildMutateRequest apiRequest)
|
||||||
echoRequested = hasPrefer "return=representation"
|
queries = (,) <$> selectQuery <*> mutateQuery
|
||||||
singular = hasPrefer "plurality=singular"
|
|
||||||
request = parseRequest schema (dbRelations dbStructure) (head path) req reqBody --TODO! is head safe?
|
|
||||||
|
|
||||||
rangeStatus :: Int -> Int -> Maybe Int -> Status
|
rangeStatus :: Int -> Int -> Maybe Int -> Status
|
||||||
rangeStatus _ _ Nothing = status200
|
rangeStatus _ _ Nothing = status200
|
||||||
@@ -213,159 +219,80 @@ contentRangeH frm to total =
|
|||||||
totalNotZero = fromMaybe True ((/=) 0 <$> total)
|
totalNotZero = fromMaybe True ((/=) 0 <$> total)
|
||||||
fromInRange = frm <= to
|
fromInRange = frm <= to
|
||||||
|
|
||||||
jsonMT :: BS.ByteString
|
|
||||||
jsonMT = "application/json"
|
|
||||||
|
|
||||||
csvMT :: BS.ByteString
|
|
||||||
csvMT = "text/csv"
|
|
||||||
|
|
||||||
allMT :: BS.ByteString
|
|
||||||
allMT = "*/*"
|
|
||||||
|
|
||||||
jsonH :: Header
|
jsonH :: Header
|
||||||
jsonH = (hContentType, jsonMT)
|
jsonH = (hContentType, "application/json")
|
||||||
|
|
||||||
contentTypeForAccept :: Maybe BS.ByteString -> Maybe BS.ByteString
|
|
||||||
contentTypeForAccept accept
|
|
||||||
| isNothing accept || has allMT || has jsonMT = Just jsonMT
|
|
||||||
| has csvMT = Just csvMT
|
|
||||||
| otherwise = Nothing
|
|
||||||
where
|
|
||||||
Just acceptH = accept
|
|
||||||
findInAccept = flip find $ parseHttpAccept acceptH
|
|
||||||
has = isJust . findInAccept . BS.isPrefixOf
|
|
||||||
|
|
||||||
parseCsvCell :: BL.ByteString -> Value
|
|
||||||
parseCsvCell s = if s == "NULL" then Null else String $ cs s
|
|
||||||
|
|
||||||
formatRelationError :: Text -> Text
|
formatRelationError :: Text -> Text
|
||||||
formatRelationError e = cs $ encode $ object [
|
formatRelationError = formatGeneralError
|
||||||
"mesage" .= ("could not find foreign keys between these entities"::String),
|
"could not find foreign keys between these entities"
|
||||||
"details" .= e]
|
|
||||||
|
|
||||||
formatParserError :: ParseError -> Text
|
formatParserError :: ParseError -> Text
|
||||||
formatParserError e = cs $ encode $ object [
|
formatParserError e = formatGeneralError message details
|
||||||
"message" .= message,
|
|
||||||
"details" .= details]
|
|
||||||
where
|
where
|
||||||
message = show (errorPos e)
|
message = cs $ show (errorPos e)
|
||||||
details = strip $ replace "\n" " " $ cs
|
details = strip $ replace "\n" " " $ cs
|
||||||
$ showErrorMessages "or" "unknown parse error" "expecting" "unexpected" "end of input" (errorMessages e)
|
$ showErrorMessages "or" "unknown parse error" "expecting" "unexpected" "end of input" (errorMessages e)
|
||||||
|
|
||||||
parseRequestBody :: Bool -> RequestBody -> Either Text ([Text],[[Value]])
|
formatGeneralError :: Text -> Text -> Text
|
||||||
parseRequestBody isCsv reqBody = first cs $
|
formatGeneralError message details = cs $ encode $ object [
|
||||||
checkStructure =<<
|
"message" .= message,
|
||||||
if isCsv
|
"details" .= details]
|
||||||
then do
|
|
||||||
rows <- (map V.toList . V.toList) <$> CSV.decode CSV.NoHeader reqBody
|
|
||||||
if null rows then Left "CSV requires header" -- TODO! should check if length rows > 1 (header and 1 row)
|
|
||||||
else Right (head rows, (map $ map $ parseCsvCell . cs) (tail rows))
|
|
||||||
else eitherDecode reqBody >>= convertJson
|
|
||||||
where
|
|
||||||
checkStructure :: ([Text], [[Value]]) -> Either String ([Text], [[Value]])
|
|
||||||
checkStructure v
|
|
||||||
| headerMatchesContent v = Right v
|
|
||||||
| isCsv = Left "CSV header does not match rows length"
|
|
||||||
| otherwise = Left "The number of keys in objects do not match"
|
|
||||||
|
|
||||||
headerMatchesContent :: ([Text], [[Value]]) -> Bool
|
augumentRequestWithJoin :: Schema -> [Relation] -> ReadRequest -> Either Text ReadRequest
|
||||||
headerMatchesContent (header, vals) = all ( (headerLength ==) . length) vals
|
|
||||||
where headerLength = length header
|
|
||||||
|
|
||||||
convertJson :: Value -> Either String ([Text],[[Value]])
|
|
||||||
convertJson v = (,) <$> (header <$> normalized) <*> (vals <$> normalized)
|
|
||||||
where
|
|
||||||
invalidMsg = "Expecting single JSON object or JSON array of objects"
|
|
||||||
normalized :: Either String [(Text, [Value])]
|
|
||||||
normalized = groupByKey =<< normalizeValue v
|
|
||||||
|
|
||||||
vals :: [(Text, [Value])] -> [[Value]]
|
|
||||||
vals = transpose . map snd
|
|
||||||
|
|
||||||
header :: [(Text, [Value])] -> [Text]
|
|
||||||
header = map fst
|
|
||||||
|
|
||||||
groupByKey :: Value -> Either String [(Text,[Value])]
|
|
||||||
groupByKey (Array a) = HM.toList . foldr (HM.unionWith (++)) (HM.fromList []) <$> maps
|
|
||||||
where
|
|
||||||
maps :: Either String [HM.HashMap Text [Value]]
|
|
||||||
maps = mapM getElems $ V.toList a
|
|
||||||
getElems (Object o) = Right $ HM.map (:[]) o
|
|
||||||
getElems _ = Left invalidMsg
|
|
||||||
groupByKey _ = Left invalidMsg
|
|
||||||
|
|
||||||
normalizeValue :: Value -> Either String Value
|
|
||||||
normalizeValue val =
|
|
||||||
case val of
|
|
||||||
Object obj -> Right $ Array (V.fromList[Object obj])
|
|
||||||
a@(Array _) -> Right a
|
|
||||||
_ -> Left invalidMsg
|
|
||||||
|
|
||||||
augumentRequestWithJoin :: Schema -> [Relation] -> ApiRequest -> Either Text ApiRequest
|
|
||||||
augumentRequestWithJoin schema allRels request =
|
augumentRequestWithJoin schema allRels request =
|
||||||
(first formatRelationError . addRelations schema allRels Nothing) request
|
(first formatRelationError . addRelations schema allRels Nothing) request
|
||||||
>>= addJoinConditions schema
|
>>= addJoinConditions schema
|
||||||
|
|
||||||
-- we use strings here because most of this data will be sent to parsers (which need strings for now)
|
buildReadRequest :: [Relation] -> ApiRequest -> Either Text ReadRequest
|
||||||
queryParams :: Request -> [(String, Maybe String)]
|
buildReadRequest allRels apiRequest =
|
||||||
queryParams httpRequest = [(cs k, cs <$> v)|(k,v) <- queryString httpRequest]
|
augumentRequestWithJoin schema rels =<< first formatParserError (foldr addFilter <$> (addOrder <$> readRequest <*> ord) <*> flts)
|
||||||
|
|
||||||
selectStr :: [(String, Maybe String)] -> String
|
|
||||||
selectStr qParams = fromMaybe "*" $ fromMaybe (Just "*") $ lookup "select" qParams
|
|
||||||
|
|
||||||
whereFilters :: [(String, Maybe String)] -> [(String, String)]
|
|
||||||
whereFilters qParams = [ (k, fromJust v) | (k,v) <- qParams, k `notElem` ["select", "order"], isJust v ]
|
|
||||||
|
|
||||||
orderStr :: [(String, Maybe String)] -> Maybe String
|
|
||||||
orderStr qParams = join $ lookup "order" qParams
|
|
||||||
|
|
||||||
buildSelectApiRequest :: Text -> Schema -> TableName -> [(String, String)] -> [Relation] -> [(String, Maybe String)] -> Either Text ApiRequest
|
|
||||||
buildSelectApiRequest method schema rootTableName allFilters allRels qParams =
|
|
||||||
augumentRequestWithJoin schema rels =<< first formatParserError (foldr addFilter <$> (addOrder <$> apiRequest <*> ord) <*> flts)
|
|
||||||
where
|
where
|
||||||
selStr = selectStr qParams
|
selStr = iSelect apiRequest
|
||||||
orderS = orderStr qParams
|
orderS = iOrder apiRequest
|
||||||
rels = case method of
|
action = iAction apiRequest
|
||||||
"POST" -> fakeSourceRelations ++ allRels
|
target = iTarget apiRequest
|
||||||
"PATCH" -> fakeSourceRelations ++ allRels
|
(schema, rootTableName) = fromJust $ -- Make it safe
|
||||||
_ -> allRels
|
case target of
|
||||||
where fakeSourceRelations = mapMaybe (toSourceRelation rootTableName) allRels -- see comment in toSourceRelation
|
(TargetIdent (QualifiedIdentifier s t) ) -> Just (s, t)
|
||||||
sel = if method == "DELETE"
|
_ -> Nothing
|
||||||
then "*" -- we are not returning the records so no need to consider nested items
|
|
||||||
else selStr
|
rootName = if action == ActionRead
|
||||||
rootName = if method == "GET"
|
|
||||||
then rootTableName
|
then rootTableName
|
||||||
else sourceSubqueryName
|
else sourceSubqueryName
|
||||||
filters = if method == "GET"
|
filters = if action == ActionRead
|
||||||
then allFilters
|
then iFilters apiRequest
|
||||||
else filter (( '.' `elem` ) . fst) allFilters -- there can be no filters on the root table whre we are doing insert/update
|
else filter (( '.' `elem` ) . fst) $ iFilters apiRequest -- there can be no filters on the root table whre we are doing insert/update
|
||||||
apiRequest = parse (pRequestSelect rootName) ("failed to parse select parameter <<"++sel++">>") sel
|
rels = case action of
|
||||||
|
ActionCreate -> fakeSourceRelations ++ allRels
|
||||||
|
ActionUpdate -> fakeSourceRelations ++ allRels
|
||||||
|
_ -> allRels
|
||||||
|
where fakeSourceRelations = mapMaybe (toSourceRelation rootTableName) allRels -- see comment in toSourceRelation
|
||||||
|
readRequest = parse (pRequestSelect rootName) ("failed to parse select parameter <<"++selStr++">>") selStr
|
||||||
addOrder (Node (q,i) f) o = Node (q{order=o}, i) f
|
addOrder (Node (q,i) f) o = Node (q{order=o}, i) f
|
||||||
flts = mapM pRequestFilter filters
|
flts = mapM pRequestFilter filters
|
||||||
ord = traverse (parse pOrder ("failed to parse order parameter <<"++fromMaybe "" orderS++">>")) orderS
|
ord = traverse (parse pOrder ("failed to parse order parameter <<"++fromMaybe "" orderS++">>")) orderS
|
||||||
|
|
||||||
buildMutateApiRequest :: Text -> Bool -> TableName -> RequestBody -> [(String, String)] -> Either Text (ApiRequest, Bool)
|
buildMutateRequest :: ApiRequest -> Either Text MutateRequest
|
||||||
buildMutateApiRequest method isCsv rootTableName reqBody allFilters =
|
buildMutateRequest apiRequest =
|
||||||
(,) <$> mutateApiRequest <*> pure isSingleRecord
|
mutateApiRequest
|
||||||
where
|
where
|
||||||
mutateApiRequest = case method of
|
action = iAction apiRequest
|
||||||
"POST" -> Node <$> ((,) <$> (Insert rootTableName <$> flds <*> vals) <*> pure (rootTableName, Nothing)) <*> pure []
|
target = iTarget apiRequest
|
||||||
"PATCH" -> Node <$> ((,) <$> (Update rootTableName <$> setWith <*> cond) <*> pure (rootTableName, Nothing)) <*> pure []
|
payload = fromJust $ iPayload apiRequest
|
||||||
"DELETE" -> Node <$> ((,) <$> (Delete [rootTableName] <$> cond) <*> pure (rootTableName, Nothing)) <*> pure []
|
rootTableName = -- TODO: Make it safe
|
||||||
|
case target of
|
||||||
|
(TargetIdent (QualifiedIdentifier _ t) ) -> t
|
||||||
|
_ -> undefined
|
||||||
|
mutateApiRequest = case action of
|
||||||
|
ActionCreate -> Insert rootTableName <$> pure payload
|
||||||
|
ActionUpdate -> Update rootTableName <$> pure payload <*> cond
|
||||||
|
ActionDelete -> Delete rootTableName <$> cond
|
||||||
_ -> Left "Unsupported HTTP verb"
|
_ -> Left "Unsupported HTTP verb"
|
||||||
parseField f = parse pField ("failed to parse field <<"++f++">>") f
|
mutateFilters = filter (not . ( '.' `elem` ) . fst) $ iFilters apiRequest -- update/delete filters can be only on the root table
|
||||||
parsedBody = parseRequestBody isCsv reqBody
|
|
||||||
isSingleRecord = either (const False) ((==1) . length . snd ) parsedBody
|
|
||||||
flds = join $ first formatParserError . mapM (parseField . cs) <$> (fst <$> parsedBody)
|
|
||||||
vals = snd <$> parsedBody
|
|
||||||
mutateFilters = filter (not . ( '.' `elem` ) . fst) allFilters -- update/delete filters can be only on the root table
|
|
||||||
cond = first formatParserError $ map snd <$> mapM pRequestFilter mutateFilters
|
cond = first formatParserError $ map snd <$> mapM pRequestFilter mutateFilters
|
||||||
setWith = if isSingleRecord
|
|
||||||
then M.fromList <$> (zip <$> flds <*> (head <$> vals))
|
|
||||||
else Left "Expecting a sigle CSV line with header or a JSON object"
|
|
||||||
|
|
||||||
addFilter :: (Path, Filter) -> ApiRequest -> ApiRequest
|
addFilter :: (Path, Filter) -> ReadRequest -> ReadRequest
|
||||||
addFilter ([], flt) (Node (q@(Select {where_=flts}), i) forest) = Node (q {where_=flt:flts}, i) forest
|
addFilter ([], flt) (Node (q@(Select {flt_=flts}), i) forest) = Node (q {flt_=flt:flts}, i) forest
|
||||||
addFilter (path, flt) (Node rn forest) =
|
addFilter (path, flt) (Node rn forest) =
|
||||||
case targetNode of
|
case targetNode of
|
||||||
Nothing -> Node rn forest -- the filter is silenty dropped in the Request does not contain the required path
|
Nothing -> Node rn forest -- the filter is silenty dropped in the Request does not contain the required path
|
||||||
@@ -401,55 +328,6 @@ instance ToJSON TableOptions where
|
|||||||
"columns" .= tblOptcolumns t
|
"columns" .= tblOptcolumns t
|
||||||
, "pkey" .= tblOptpkey t ]
|
, "pkey" .= tblOptpkey t ]
|
||||||
|
|
||||||
parseRequest :: Schema -> [Relation] -> TableName -> Request -> RequestBody -> Either Text (SqlQuery, Maybe (SqlQuery, Bool))
|
|
||||||
parseRequest schema allRels rootTableName httpRequest reqBody =
|
|
||||||
if method == "GET"
|
|
||||||
then (,Nothing) <$> selectQuery
|
|
||||||
else (,) <$> selectQuery <*> ( Just <$> mutatePart )
|
|
||||||
where
|
|
||||||
mutatePart = (,) <$> mutateQuery <*> isSingleRecord
|
|
||||||
hdrs = requestHeaders httpRequest
|
|
||||||
lookupHeader = flip lookup hdrs
|
|
||||||
isCsv = lookupHeader "Content-Type" == Just csvMT
|
|
||||||
method = requestMethod httpRequest
|
|
||||||
qParams = queryParams httpRequest
|
|
||||||
allFilters = whereFilters qParams
|
|
||||||
selectApiRequest = buildSelectApiRequest (cs method) schema rootTableName allFilters allRels qParams
|
|
||||||
mutateTuple = buildMutateApiRequest (cs method) isCsv rootTableName reqBody allFilters
|
|
||||||
mutateApiRequest = fst <$> mutateTuple
|
|
||||||
isSingleRecord = snd <$> mutateTuple
|
|
||||||
selectQuery = requestToQuery schema <$> selectApiRequest
|
|
||||||
mutateQuery = requestToQuery schema <$> mutateApiRequest
|
|
||||||
|
|
||||||
createReadStatement :: SqlQuery -> Maybe NonnegRange -> Bool -> Bool -> Bool -> B.Stmt P.Postgres
|
|
||||||
createReadStatement selectQuery range isSingle countTable asCsv =
|
|
||||||
B.Stmt (
|
|
||||||
wrapQuery selectQuery [
|
|
||||||
if countTable then countAllF else countNoneF,
|
|
||||||
countF,
|
|
||||||
"null", -- location header can not be calucalted
|
|
||||||
if asCsv
|
|
||||||
then asCsvF
|
|
||||||
else if isSingle then asJsonSingleF else asJsonF
|
|
||||||
] selectStarF (if isNothing range && isSingle then Just $ singletonRange 0 else range)
|
|
||||||
) V.empty True
|
|
||||||
|
|
||||||
createWriteStatement :: SqlQuery -> SqlQuery -> Bool -> Bool -> [Text] -> Bool -> B.Stmt P.Postgres
|
|
||||||
createWriteStatement selectQuery mutateQuery isSingle echoRequested pKeys asCsv =
|
|
||||||
B.Stmt (
|
|
||||||
wrapQuery mutateQuery [
|
|
||||||
countNoneF, -- when updateing it does not make sense
|
|
||||||
countF,
|
|
||||||
if isSingle then locationF pKeys else "null",
|
|
||||||
if echoRequested
|
|
||||||
then
|
|
||||||
if asCsv
|
|
||||||
then asCsvF
|
|
||||||
else if isSingle then asJsonSingleF else asJsonF
|
|
||||||
else "null"
|
|
||||||
|
|
||||||
] selectQuery Nothing
|
|
||||||
) V.empty True
|
|
||||||
|
|
||||||
extractQueryResult :: Maybe (Maybe Int, Int, Maybe BL.ByteString, Maybe BL.ByteString)
|
extractQueryResult :: Maybe (Maybe Int, Int, Maybe BL.ByteString, Maybe BL.ByteString)
|
||||||
-> (Maybe Int, Int, Maybe BL.ByteString, Maybe BL.ByteString)
|
-> (Maybe Int, Int, Maybe BL.ByteString, Maybe BL.ByteString)
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ claimsToSQL = map setVar . toList
|
|||||||
returns a map of JWT claims
|
returns a map of JWT claims
|
||||||
In case there is any problem decoding the JWT it returns Nothing.
|
In case there is any problem decoding the JWT it returns Nothing.
|
||||||
-}
|
-}
|
||||||
jwtClaims :: Text -> Text -> NominalDiffTime -> Maybe JWT.ClaimsMap
|
jwtClaims :: JWT.Secret -> Text -> NominalDiffTime -> Maybe JWT.ClaimsMap
|
||||||
jwtClaims secret input time =
|
jwtClaims secret input time =
|
||||||
case join $ claim JWT.exp of
|
case join $ claim JWT.exp of
|
||||||
Just expires ->
|
Just expires ->
|
||||||
@@ -60,7 +60,7 @@ jwtClaims secret input time =
|
|||||||
else Nothing
|
else Nothing
|
||||||
_ -> customClaims
|
_ -> customClaims
|
||||||
where
|
where
|
||||||
decoded = JWT.decodeAndVerifySignature (JWT.secret secret) input
|
decoded = JWT.decodeAndVerifySignature secret input
|
||||||
claim :: (JWT.JWTClaimsSet -> a) -> Maybe a
|
claim :: (JWT.JWTClaimsSet -> a) -> Maybe a
|
||||||
claim prop = prop . JWT.claims <$> decoded
|
claim prop = prop . JWT.claims <$> decoded
|
||||||
customClaims = claim JWT.unregisteredClaims
|
customClaims = claim JWT.unregisteredClaims
|
||||||
@@ -74,8 +74,8 @@ setRole role = "set local role " <> cs (pgFmtLit role) <> ";"
|
|||||||
Receives the JWT secret (from config) and a JWT and a JSON value
|
Receives the JWT secret (from config) and a JWT and a JSON value
|
||||||
and returns a signed JWT.
|
and returns a signed JWT.
|
||||||
-}
|
-}
|
||||||
tokenJWT :: Text -> Value -> Text
|
tokenJWT :: JWT.Secret -> Value -> Text
|
||||||
tokenJWT secret (Array a) = JWT.encodeSigned JWT.HS256 (JWT.secret secret)
|
tokenJWT secret (Array a) = JWT.encodeSigned JWT.HS256 secret
|
||||||
JWT.def { JWT.unregisteredClaims = fromHashMap o }
|
JWT.def { JWT.unregisteredClaims = fromHashMap o }
|
||||||
where
|
where
|
||||||
Object o = if V.null a then emptyObject else V.head a
|
Object o = if V.null a then emptyObject else V.head a
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import Network.Wai
|
|||||||
import Network.Wai.Middleware.Cors (CorsResourcePolicy (..))
|
import Network.Wai.Middleware.Cors (CorsResourcePolicy (..))
|
||||||
import Options.Applicative
|
import Options.Applicative
|
||||||
import Paths_postgrest (version)
|
import Paths_postgrest (version)
|
||||||
|
import Web.JWT (Secret, secret)
|
||||||
import Prelude
|
import Prelude
|
||||||
|
|
||||||
-- | Data type to store all command line options
|
-- | Data type to store all command line options
|
||||||
@@ -38,7 +39,7 @@ data AppConfig = AppConfig {
|
|||||||
, configPort :: Int
|
, configPort :: Int
|
||||||
, configAnonRole :: String
|
, configAnonRole :: String
|
||||||
, configSchema :: String
|
, configSchema :: String
|
||||||
, configJwtSecret :: String
|
, configJwtSecret :: Secret
|
||||||
, configPool :: Int
|
, configPool :: Int
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +50,8 @@ argParser = AppConfig
|
|||||||
<*> option auto (long "port" <> short 'p' <> help "port number on which to run HTTP server" <> metavar "PORT" <> value 3000 <> showDefault)
|
<*> option auto (long "port" <> short 'p' <> help "port number on which to run HTTP server" <> metavar "PORT" <> value 3000 <> showDefault)
|
||||||
<*> strOption (long "anonymous" <> short 'a' <> help "postgres role to use for non-authenticated requests" <> metavar "ROLE")
|
<*> strOption (long "anonymous" <> short 'a' <> help "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 "1" <> showDefault)
|
<*> strOption (long "schema" <> short 's' <> help "schema to use for API routes" <> metavar "NAME" <> value "1" <> showDefault)
|
||||||
<*> strOption (long "jwt-secret" <> short 'j' <> help "secret used to encrypt and decrypt JWT tokens" <> metavar "SECRET" <> value "secret" <> 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)
|
<*> option auto (long "pool" <> short 'o' <> help "max connections in database pool" <> metavar "COUNT" <> value 10 <> showDefault)
|
||||||
|
|
||||||
defaultCorsPolicy :: CorsResourcePolicy
|
defaultCorsPolicy :: CorsResourcePolicy
|
||||||
|
|||||||
@@ -45,12 +45,12 @@ getDbStructure schema = do
|
|||||||
}
|
}
|
||||||
|
|
||||||
doesProc :: forall c s. B.CxValue c Int =>
|
doesProc :: forall c s. B.CxValue c Int =>
|
||||||
(Text -> Text -> B.Stmt c) -> Text -> Text -> H.Tx c s Bool
|
(Text -> Text -> B.Stmt c) -> QualifiedIdentifier -> H.Tx c s Bool
|
||||||
doesProc stmt schema proc = do
|
doesProc stmt qi = do
|
||||||
row :: Maybe (Identity Int) <- H.maybeEx $ stmt schema proc
|
row :: Maybe (Identity Int) <- H.maybeEx $ stmt (qiSchema qi) (qiName qi)
|
||||||
return $ isJust row
|
return $ isJust row
|
||||||
|
|
||||||
doesProcExist :: Text -> Text -> H.Tx P.Postgres s Bool
|
doesProcExist :: QualifiedIdentifier -> H.Tx P.Postgres s Bool
|
||||||
doesProcExist = doesProc [H.stmt|
|
doesProcExist = doesProc [H.stmt|
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM pg_catalog.pg_namespace n
|
FROM pg_catalog.pg_namespace n
|
||||||
@@ -60,7 +60,7 @@ doesProcExist = doesProc [H.stmt|
|
|||||||
AND proname = ?
|
AND proname = ?
|
||||||
|]
|
|]
|
||||||
|
|
||||||
doesProcReturnJWT :: Text -> Text -> H.Tx P.Postgres s Bool
|
doesProcReturnJWT :: QualifiedIdentifier -> H.Tx P.Postgres s Bool
|
||||||
doesProcReturnJWT = doesProc [H.stmt|
|
doesProcReturnJWT = doesProc [H.stmt|
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM pg_catalog.pg_namespace n
|
FROM pg_catalog.pg_namespace n
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import Network.Wai.Middleware.RequestLogger (logStdout)
|
|||||||
import System.IO (BufferMode (..),
|
import System.IO (BufferMode (..),
|
||||||
hSetBuffering, stderr,
|
hSetBuffering, stderr,
|
||||||
stdin, stdout)
|
stdin, stdout)
|
||||||
|
import Web.JWT (secret)
|
||||||
|
|
||||||
isServerVersionSupported :: H.Session P.Postgres IO Bool
|
isServerVersionSupported :: H.Session P.Postgres IO Bool
|
||||||
isServerVersionSupported = do
|
isServerVersionSupported = do
|
||||||
@@ -43,7 +44,7 @@ main = do
|
|||||||
conf <- readOptions
|
conf <- readOptions
|
||||||
let port = configPort conf
|
let port = configPort conf
|
||||||
|
|
||||||
unless ("secret" /= configJwtSecret conf) $
|
unless (secret "secret" /= configJwtSecret conf) $
|
||||||
putStrLn "WARNING, running in insecure mode, JWT secret is the default value"
|
putStrLn "WARNING, running in insecure mode, JWT secret is the default value"
|
||||||
Prelude.putStrLn $ "Listening on port " ++
|
Prelude.putStrLn $ "Listening on port " ++
|
||||||
(show $ configPort conf :: String)
|
(show $ configPort conf :: String)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
module PostgREST.Middleware where
|
module PostgREST.Middleware where
|
||||||
|
|
||||||
import Data.Maybe (fromMaybe, isNothing)
|
import Data.Maybe (fromMaybe)
|
||||||
import Data.Text
|
import Data.Text
|
||||||
import Data.String.Conversions (cs)
|
import Data.String.Conversions (cs)
|
||||||
import Data.Time.Clock.POSIX (getPOSIXTime)
|
import Data.Time.Clock.POSIX (getPOSIXTime)
|
||||||
@@ -18,7 +18,7 @@ import Network.Wai.Middleware.Cors (cors)
|
|||||||
import Network.Wai.Middleware.Gzip (def, gzip)
|
import Network.Wai.Middleware.Gzip (def, gzip)
|
||||||
import Network.Wai.Middleware.Static (only, staticPolicy)
|
import Network.Wai.Middleware.Static (only, staticPolicy)
|
||||||
|
|
||||||
import PostgREST.App (contentTypeForAccept)
|
import PostgREST.ApiRequest (pickContentType)
|
||||||
import PostgREST.Auth (setRole, jwtClaims, claimsToSQL)
|
import PostgREST.Auth (setRole, jwtClaims, claimsToSQL)
|
||||||
import PostgREST.Config (AppConfig (..), corsPolicy)
|
import PostgREST.Config (AppConfig (..), corsPolicy)
|
||||||
import PostgREST.Error (errResponse)
|
import PostgREST.Error (errResponse)
|
||||||
@@ -51,19 +51,18 @@ runWithClaims conf app req = do
|
|||||||
where
|
where
|
||||||
stmt c = B.Stmt c V.empty True
|
stmt c = B.Stmt c V.empty True
|
||||||
hdrs = requestHeaders req
|
hdrs = requestHeaders req
|
||||||
jwtSecret = (cs $ configJwtSecret conf) :: Text
|
jwtSecret = configJwtSecret conf
|
||||||
auth = fromMaybe "" $ lookup hAuthorization hdrs
|
auth = fromMaybe "" $ lookup hAuthorization hdrs
|
||||||
anon = cs $ configAnonRole conf
|
anon = cs $ configAnonRole conf
|
||||||
setAnon = setRole anon
|
setAnon = setRole anon
|
||||||
invalidJWT = return $ errResponse status400 "Invalid JWT"
|
invalidJWT = return $ errResponse status400 "Invalid JWT"
|
||||||
|
|
||||||
unsupportedAccept :: Application -> Application
|
unsupportedAccept :: Application -> Application
|
||||||
unsupportedAccept app req respond = do
|
unsupportedAccept app req respond =
|
||||||
let
|
case accept of
|
||||||
accept = lookup hAccept $ requestHeaders req
|
Left _ -> respond $ errResponse status415 "Unsupported Accept header, try: application/json"
|
||||||
if isNothing $ contentTypeForAccept accept
|
Right _ -> app req respond
|
||||||
then respond $ errResponse status415 "Unsupported Accept header, try: application/json"
|
where accept = pickContentType $ lookup hAccept $ requestHeaders req
|
||||||
else app req respond
|
|
||||||
|
|
||||||
defaultMiddle :: Application -> Application
|
defaultMiddle :: Application -> Application
|
||||||
defaultMiddle =
|
defaultMiddle =
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ import PostgREST.Types
|
|||||||
import Text.ParserCombinators.Parsec hiding (many, (<|>))
|
import Text.ParserCombinators.Parsec hiding (many, (<|>))
|
||||||
import PostgREST.QueryBuilder (operators)
|
import PostgREST.QueryBuilder (operators)
|
||||||
|
|
||||||
pRequestSelect :: Text -> Parser ApiRequest
|
pRequestSelect :: Text -> Parser ReadRequest
|
||||||
pRequestSelect rootNodeName = do
|
pRequestSelect rootNodeName = do
|
||||||
fieldTree <- pFieldForest
|
fieldTree <- pFieldForest
|
||||||
return $ foldr treeEntry (Node (Select [] [rootNodeName] [] Nothing, (rootNodeName, Nothing)) []) fieldTree
|
return $ foldr treeEntry (Node (Select [] [rootNodeName] [] Nothing, (rootNodeName, Nothing)) []) fieldTree
|
||||||
where
|
where
|
||||||
treeEntry :: Tree SelectItem -> ApiRequest -> ApiRequest
|
treeEntry :: Tree SelectItem -> ReadRequest -> ReadRequest
|
||||||
treeEntry (Node fld@((fn, _),_) fldForest) (Node (q, i) rForest) =
|
treeEntry (Node fld@((fn, _),_) fldForest) (Node (q, i) rForest) =
|
||||||
case fldForest of
|
case fldForest of
|
||||||
[] -> Node (q {select=fld:select q}, i) rForest
|
[] -> Node (q {select=fld:select q}, i) rForest
|
||||||
@@ -51,7 +51,7 @@ pFieldForest :: Parser [Tree SelectItem]
|
|||||||
pFieldForest = pFieldTree `sepBy1` lexeme (char ',')
|
pFieldForest = pFieldTree `sepBy1` lexeme (char ',')
|
||||||
|
|
||||||
pFieldTree :: Parser (Tree SelectItem)
|
pFieldTree :: Parser (Tree SelectItem)
|
||||||
pFieldTree = try (Node <$> pSelect <*> between (char '(') (char ')') pFieldForest)
|
pFieldTree = try (Node <$> pSelect <*> between (char '{') (char '}') pFieldForest)
|
||||||
<|> Node <$> pSelect <*> pure []
|
<|> Node <$> pSelect <*> pure []
|
||||||
|
|
||||||
pStar :: Parser Text
|
pStar :: Parser Text
|
||||||
@@ -101,8 +101,12 @@ pOrderTerm =
|
|||||||
try ( do
|
try ( do
|
||||||
c <- pFieldName
|
c <- pFieldName
|
||||||
_ <- pDelimiter
|
_ <- pDelimiter
|
||||||
d <- string "asc" <|> string "desc"
|
d <- (string "asc" *> pure OrderAsc)
|
||||||
nls <- optionMaybe (pDelimiter *> ( try(string "nullslast" *> pure ("nulls last"::String)) <|> try(string "nullsfirst" *> pure ("nulls first"::String))))
|
<|> (string "desc" *> pure OrderDesc)
|
||||||
return $ OrderTerm (cs c) (cs d) (cs <$> nls)
|
nls <- optionMaybe (pDelimiter *> (
|
||||||
|
try(string "nullslast" *> pure OrderNullsLast)
|
||||||
|
<|> try(string "nullsfirst" *> pure OrderNullsFirst)
|
||||||
|
))
|
||||||
|
return $ OrderTerm c d nls
|
||||||
)
|
)
|
||||||
<|> OrderTerm <$> (cs <$> pFieldName) <*> pure "asc" <*> pure Nothing
|
<|> OrderTerm <$> (cs <$> pFieldName) <*> pure OrderAsc <*> pure Nothing
|
||||||
|
|||||||
+143
-102
@@ -1,26 +1,29 @@
|
|||||||
{-# LANGUAGE FlexibleInstances #-}
|
{-# LANGUAGE FlexibleInstances #-}
|
||||||
{-# LANGUAGE TupleSections #-}
|
{-# LANGUAGE TupleSections #-}
|
||||||
{-# OPTIONS_GHC -fno-warn-orphans #-}
|
{-# OPTIONS_GHC -fno-warn-orphans #-}
|
||||||
|
{-|
|
||||||
|
Module : PostgREST.QueryBuilder
|
||||||
|
Description : PostgREST SQL generating functions.
|
||||||
|
|
||||||
|
This module provides functions to consume data types that
|
||||||
|
represent database objects (e.g. Relation, Schema, SqlQuery)
|
||||||
|
and produces SQL Statements.
|
||||||
|
|
||||||
|
Any function that outputs a SQL fragment should be in this module.
|
||||||
|
-}
|
||||||
module PostgREST.QueryBuilder (
|
module PostgREST.QueryBuilder (
|
||||||
addRelations
|
addRelations
|
||||||
, addJoinConditions
|
, addJoinConditions
|
||||||
, asCsvF
|
|
||||||
, asJson
|
, asJson
|
||||||
, asJsonF
|
|
||||||
, asJsonSingleF
|
|
||||||
, callProc
|
, callProc
|
||||||
, countAllF
|
, createReadStatement
|
||||||
, countF
|
, createWriteStatement
|
||||||
, countNoneF
|
|
||||||
, locationF
|
|
||||||
, operators
|
, operators
|
||||||
, pgFmtIdent
|
, pgFmtIdent
|
||||||
, pgFmtLit
|
, pgFmtLit
|
||||||
, requestToQuery
|
, requestToQuery
|
||||||
, selectStarF
|
|
||||||
, sourceSubqueryName
|
, sourceSubqueryName
|
||||||
, unquoted
|
, unquoted
|
||||||
, wrapQuery
|
|
||||||
) where
|
) where
|
||||||
|
|
||||||
import qualified Hasql as H
|
import qualified Hasql as H
|
||||||
@@ -31,15 +34,17 @@ import qualified Data.Aeson as JSON
|
|||||||
|
|
||||||
import PostgREST.RangeQuery (NonnegRange, rangeLimit, rangeOffset)
|
import PostgREST.RangeQuery (NonnegRange, rangeLimit, rangeOffset)
|
||||||
import Control.Error (note, fromMaybe, mapMaybe)
|
import Control.Error (note, fromMaybe, mapMaybe)
|
||||||
|
import Data.Maybe (isNothing)
|
||||||
import Control.Monad (join)
|
import Control.Monad (join)
|
||||||
|
import qualified Data.HashMap.Strict as HM
|
||||||
import Data.List (find)
|
import Data.List (find)
|
||||||
import Data.Monoid ((<>))
|
import Data.Monoid ((<>))
|
||||||
import Data.Text (Text, intercalate, unwords, replace, isInfixOf, toLower, split)
|
import Data.Text (Text, intercalate, unwords, replace, isInfixOf, toLower, split)
|
||||||
import qualified Data.Text as T (map, takeWhile)
|
import qualified Data.Text as T (map, takeWhile)
|
||||||
import Data.String.Conversions (cs)
|
import Data.String.Conversions (cs)
|
||||||
import qualified Data.HashMap.Strict as H
|
|
||||||
import Control.Applicative (empty, (<|>))
|
import Control.Applicative (empty, (<|>))
|
||||||
import Data.Tree (Tree(..))
|
import Data.Tree (Tree(..))
|
||||||
|
import qualified Data.Vector as V
|
||||||
import PostgREST.Types
|
import PostgREST.Types
|
||||||
import qualified Data.Map as M
|
import qualified Data.Map as M
|
||||||
import Text.Regex.TDFA ((=~))
|
import Text.Regex.TDFA ((=~))
|
||||||
@@ -50,6 +55,8 @@ import Data.Scientific ( FPFormat (..)
|
|||||||
)
|
)
|
||||||
import Prelude hiding (unwords)
|
import Prelude hiding (unwords)
|
||||||
|
|
||||||
|
import Data.Ranged.Ranges (singletonRange)
|
||||||
|
|
||||||
type PStmt = H.Stmt P.Postgres
|
type PStmt = H.Stmt P.Postgres
|
||||||
instance Monoid PStmt where
|
instance Monoid PStmt where
|
||||||
mappend (B.Stmt query params prep) (B.Stmt query' params' prep') =
|
mappend (B.Stmt query params prep) (B.Stmt query' params' prep') =
|
||||||
@@ -57,7 +64,40 @@ instance Monoid PStmt where
|
|||||||
mempty = B.Stmt "" empty True
|
mempty = B.Stmt "" empty True
|
||||||
type StatementT = PStmt -> PStmt
|
type StatementT = PStmt -> PStmt
|
||||||
|
|
||||||
addRelations :: Schema -> [Relation] -> Maybe ApiRequest -> ApiRequest -> Either Text ApiRequest
|
createReadStatement :: SqlQuery -> Maybe NonnegRange -> Bool -> Bool -> Bool -> B.Stmt P.Postgres
|
||||||
|
createReadStatement selectQuery range isSingle countTable asCsv =
|
||||||
|
B.Stmt (
|
||||||
|
wrapQuery selectQuery [
|
||||||
|
if countTable then countAllF else countNoneF,
|
||||||
|
countF,
|
||||||
|
"null", -- location header can not be calucalted
|
||||||
|
if asCsv
|
||||||
|
then asCsvF
|
||||||
|
else if isSingle then asJsonSingleF else asJsonF
|
||||||
|
] selectStarF (if isNothing range && isSingle then Just $ singletonRange 0 else range)
|
||||||
|
) V.empty True
|
||||||
|
|
||||||
|
createWriteStatement :: SqlQuery -> SqlQuery -> Bool -> Bool ->
|
||||||
|
[Text] -> Bool -> Payload -> B.Stmt P.Postgres
|
||||||
|
createWriteStatement _ _ _ _ _ _ (PayloadParseError _) = undefined
|
||||||
|
createWriteStatement selectQuery mutateQuery isSingle echoRequested
|
||||||
|
pKeys asCsv (PayloadJSON (UniformObjects rows)) =
|
||||||
|
B.Stmt (
|
||||||
|
wrapQuery mutateQuery [
|
||||||
|
countNoneF, -- when updateing it does not make sense
|
||||||
|
countF,
|
||||||
|
if isSingle then locationF pKeys else "null",
|
||||||
|
if echoRequested
|
||||||
|
then
|
||||||
|
if asCsv
|
||||||
|
then asCsvF
|
||||||
|
else if isSingle then asJsonSingleF else asJsonF
|
||||||
|
else "null"
|
||||||
|
|
||||||
|
] selectQuery Nothing
|
||||||
|
) (V.singleton . B.encodeValue . JSON.Array . V.map JSON.Object $ rows) True
|
||||||
|
|
||||||
|
addRelations :: Schema -> [Relation] -> Maybe ReadRequest -> ReadRequest -> Either Text ReadRequest
|
||||||
addRelations schema allRelations parentNode node@(Node n@(query, (table, _)) forest) =
|
addRelations schema allRelations parentNode node@(Node n@(query, (table, _)) forest) =
|
||||||
case parentNode of
|
case parentNode of
|
||||||
Nothing -> Node (query, (table, Nothing)) <$> updatedForest
|
Nothing -> Node (query, (table, Nothing)) <$> updatedForest
|
||||||
@@ -66,14 +106,14 @@ addRelations schema allRelations parentNode node@(Node n@(query, (table, _)) for
|
|||||||
rel = note ("no relation between " <> table <> " and " <> parentTable)
|
rel = note ("no relation between " <> table <> " and " <> parentTable)
|
||||||
$ findRelation schema table parentTable
|
$ findRelation schema table parentTable
|
||||||
<|> findRelation schema parentTable table
|
<|> findRelation schema parentTable table
|
||||||
addRel :: (Query, (NodeName, Maybe Relation)) -> Relation -> (Query, (NodeName, Maybe Relation))
|
addRel :: (ReadQuery, (NodeName, Maybe Relation)) -> Relation -> (ReadQuery, (NodeName, Maybe Relation))
|
||||||
addRel (q, (t, _)) r = (q, (t, Just r))
|
addRel (q, (t, _)) r = (q, (t, Just r))
|
||||||
where
|
where
|
||||||
updatedForest = mapM (addRelations schema allRelations (Just node)) forest
|
updatedForest = mapM (addRelations schema allRelations (Just node)) forest
|
||||||
findRelation s t1 t2 =
|
findRelation s t1 t2 =
|
||||||
find (\r -> s == (tableSchema . relTable) r && t1 == (tableName . relTable) r && t2 == (tableName . relFTable) r) allRelations
|
find (\r -> s == (tableSchema . relTable) r && t1 == (tableName . relTable) r && t2 == (tableName . relFTable) r) allRelations
|
||||||
|
|
||||||
addJoinConditions :: Schema -> ApiRequest -> Either Text ApiRequest
|
addJoinConditions :: Schema -> ReadRequest -> Either Text ReadRequest
|
||||||
addJoinConditions schema (Node (query, (n, r)) forest) =
|
addJoinConditions schema (Node (query, (n, r)) forest) =
|
||||||
case r of
|
case r of
|
||||||
Nothing -> Node (updatedQuery, (n,r)) <$> updatedForest -- this is the root node
|
Nothing -> Node (updatedQuery, (n,r)) <$> updatedForest -- this is the root node
|
||||||
@@ -95,21 +135,7 @@ addJoinConditions schema (Node (query, (n, r)) forest) =
|
|||||||
getParents (_, (tbl, Just rel@(Relation{relType=Parent}))) = Just (tbl, rel)
|
getParents (_, (tbl, Just rel@(Relation{relType=Parent}))) = Just (tbl, rel)
|
||||||
getParents _ = Nothing
|
getParents _ = Nothing
|
||||||
updatedForest = mapM (addJoinConditions schema) forest
|
updatedForest = mapM (addJoinConditions schema) forest
|
||||||
addCond q con = q{where_=con ++ where_ q}
|
addCond q con = q{flt_=con ++ flt_ q}
|
||||||
|
|
||||||
asCsvF :: SqlFragment
|
|
||||||
asCsvF = asCsvHeaderF <> " || '\n' || " <> asCsvBodyF
|
|
||||||
where
|
|
||||||
asCsvHeaderF =
|
|
||||||
"(SELECT string_agg(a.k, ',')" <>
|
|
||||||
" FROM (" <>
|
|
||||||
" SELECT json_object_keys(r)::TEXT as k" <>
|
|
||||||
" FROM ( " <>
|
|
||||||
" SELECT row_to_json(hh) as r from " <> sourceSubqueryName <> " as hh limit 1" <>
|
|
||||||
" ) s" <>
|
|
||||||
" ) a" <>
|
|
||||||
")"
|
|
||||||
asCsvBodyF = "coalesce(string_agg(substring(t::text, 2, length(t::text) - 2), '\n'), '')"
|
|
||||||
|
|
||||||
asJson :: StatementT
|
asJson :: StatementT
|
||||||
asJson s = s {
|
asJson s = s {
|
||||||
@@ -117,41 +143,13 @@ asJson s = s {
|
|||||||
"array_to_json(coalesce(array_agg(row_to_json(t)), '{}'))::character varying from ("
|
"array_to_json(coalesce(array_agg(row_to_json(t)), '{}'))::character varying from ("
|
||||||
<> B.stmtTemplate s <> ") t" }
|
<> B.stmtTemplate s <> ") t" }
|
||||||
|
|
||||||
asJsonF :: SqlFragment
|
|
||||||
asJsonF = "array_to_json(array_agg(row_to_json(t)))::character varying"
|
|
||||||
|
|
||||||
asJsonSingleF :: SqlFragment --TODO! unsafe when the query actually returns multiple rows, used only on inserting and returning single element
|
|
||||||
asJsonSingleF = "string_agg(row_to_json(t)::text, ',')::character varying "
|
|
||||||
|
|
||||||
callProc :: QualifiedIdentifier -> JSON.Object -> PStmt
|
callProc :: QualifiedIdentifier -> JSON.Object -> PStmt
|
||||||
callProc qi params = do
|
callProc qi params = do
|
||||||
let args = intercalate "," $ map assignment (H.toList params)
|
let args = intercalate "," $ map assignment (HM.toList params)
|
||||||
B.Stmt ("select * from " <> fromQi qi <> "(" <> args <> ")") empty True
|
B.Stmt ("select * from " <> fromQi qi <> "(" <> args <> ")") empty True
|
||||||
where
|
where
|
||||||
assignment (n,v) = pgFmtIdent n <> ":=" <> insertableValue v
|
assignment (n,v) = pgFmtIdent n <> ":=" <> insertableValue v
|
||||||
|
|
||||||
countAllF :: SqlFragment
|
|
||||||
countAllF = "(SELECT pg_catalog.count(1) FROM (SELECT * FROM " <> sourceSubqueryName <> ") a )"
|
|
||||||
|
|
||||||
countF :: SqlFragment
|
|
||||||
countF = "pg_catalog.count(t)"
|
|
||||||
|
|
||||||
countNoneF :: SqlFragment
|
|
||||||
countNoneF = "null"
|
|
||||||
|
|
||||||
locationF :: [Text] -> SqlFragment
|
|
||||||
locationF pKeys =
|
|
||||||
"(" <>
|
|
||||||
" WITH s AS (SELECT row_to_json(ss) as r from " <> sourceSubqueryName <> " as ss limit 1)" <>
|
|
||||||
" SELECT string_agg(json_data.key || '=' || coalesce( 'eq.' || json_data.value, 'is.null'), '&')" <>
|
|
||||||
" FROM s, json_each_text(s.r) AS json_data" <>
|
|
||||||
(
|
|
||||||
if null pKeys
|
|
||||||
then ""
|
|
||||||
else " WHERE json_data.key IN ('" <> intercalate "','" pKeys <> "')"
|
|
||||||
) <>
|
|
||||||
")"
|
|
||||||
|
|
||||||
operators :: [(Text, SqlFragment)]
|
operators :: [(Text, SqlFragment)]
|
||||||
operators = [
|
operators = [
|
||||||
("eq", "="),
|
("eq", "="),
|
||||||
@@ -188,8 +186,10 @@ pgFmtLit x =
|
|||||||
then "E" <> slashed
|
then "E" <> slashed
|
||||||
else slashed
|
else slashed
|
||||||
|
|
||||||
requestToQuery :: Schema -> ApiRequest -> SqlQuery
|
requestToQuery :: Schema -> DbRequest -> SqlQuery
|
||||||
requestToQuery schema (Node (Select colSelects tbls conditions ord, (mainTbl, _)) forest) =
|
requestToQuery _ (DbMutate (Insert _ (PayloadParseError _))) = undefined
|
||||||
|
requestToQuery _ (DbMutate (Update _ (PayloadParseError _) _)) = undefined
|
||||||
|
requestToQuery schema (DbRead (Node (Select colSelects tbls conditions ord, (mainTbl, _)) forest)) =
|
||||||
query
|
query
|
||||||
where
|
where
|
||||||
-- TODO! the folloing helper functions are just to remove the "schema" part when the table is "source" which is the name
|
-- TODO! the folloing helper functions are just to remove the "schema" part when the table is "source" which is the name
|
||||||
@@ -205,58 +205,57 @@ requestToQuery schema (Node (Select colSelects tbls conditions ord, (mainTbl, _)
|
|||||||
orderF (fromMaybe [] ord)
|
orderF (fromMaybe [] ord)
|
||||||
]
|
]
|
||||||
(withs, selects) = foldr getQueryParts ([],[]) forest
|
(withs, selects) = foldr getQueryParts ([],[]) forest
|
||||||
getQueryParts :: Tree ApiNode -> ([SqlFragment], [SqlFragment]) -> ([SqlFragment], [SqlFragment])
|
getQueryParts :: Tree ReadNode -> ([SqlFragment], [SqlFragment]) -> ([SqlFragment], [SqlFragment])
|
||||||
getQueryParts (Node n@(_, (table, Just (Relation {relType=Child}))) forst) (w,s) = (w,sel:s)
|
getQueryParts (Node n@(_, (table, Just (Relation {relType=Child}))) forst) (w,s) = (w,sel:s)
|
||||||
where
|
where
|
||||||
sel = "("
|
sel = "("
|
||||||
<> "SELECT array_to_json(array_agg(row_to_json("<>table<>"))) "
|
<> "SELECT array_to_json(array_agg(row_to_json("<>table<>"))) "
|
||||||
<> "FROM (" <> subquery <> ") " <> table
|
<> "FROM (" <> subquery <> ") " <> table
|
||||||
<> ") AS " <> table
|
<> ") AS " <> table
|
||||||
where subquery = requestToQuery schema (Node n forst)
|
where subquery = requestToQuery schema (DbRead (Node n forst))
|
||||||
getQueryParts (Node n@(_, (table, Just (Relation {relType=Parent}))) forst) (w,s) = (wit:w,sel:s)
|
getQueryParts (Node n@(_, (table, Just (Relation {relType=Parent}))) forst) (w,s) = (wit:w,sel:s)
|
||||||
where
|
where
|
||||||
sel = "row_to_json(" <> table <> ".*) AS "<>table --TODO must be singular
|
sel = "row_to_json(" <> table <> ".*) AS "<>table --TODO must be singular
|
||||||
wit = table <> " AS ( " <> subquery <> " )"
|
wit = table <> " AS ( " <> subquery <> " )"
|
||||||
where subquery = requestToQuery schema (Node n forst)
|
where subquery = requestToQuery schema (DbRead (Node n forst))
|
||||||
getQueryParts (Node n@(_, (table, Just (Relation {relType=Many}))) forst) (w,s) = (w,sel:s)
|
getQueryParts (Node n@(_, (table, Just (Relation {relType=Many}))) forst) (w,s) = (w,sel:s)
|
||||||
where
|
where
|
||||||
sel = "("
|
sel = "("
|
||||||
<> "SELECT array_to_json(array_agg(row_to_json("<>table<>"))) "
|
<> "SELECT array_to_json(array_agg(row_to_json("<>table<>"))) "
|
||||||
<> "FROM (" <> subquery <> ") " <> table
|
<> "FROM (" <> subquery <> ") " <> table
|
||||||
<> ") AS " <> table
|
<> ") AS " <> table
|
||||||
where subquery = requestToQuery schema (Node n forst)
|
where subquery = requestToQuery schema (DbRead (Node n forst))
|
||||||
--the following is just to remove the warning
|
--the following is just to remove the warning
|
||||||
--getQueryParts is not total but requestToQuery is called only after addJoinConditions which ensures the only
|
--getQueryParts is not total but requestToQuery is called only after addJoinConditions which ensures the only
|
||||||
--posible relations are Child Parent Many
|
--posible relations are Child Parent Many
|
||||||
getQueryParts (Node (_,(_,Nothing)) _) _ = undefined
|
getQueryParts (Node (_,(_,Nothing)) _) _ = undefined
|
||||||
requestToQuery schema (Node (Insert _ flds vals, (mainTbl, _)) _) =
|
requestToQuery schema (DbMutate (Insert mainTbl (PayloadJSON (UniformObjects rows)))) =
|
||||||
query
|
let qi = QualifiedIdentifier schema mainTbl
|
||||||
|
cols = map pgFmtIdent $ fromMaybe [] (HM.keys <$> (rows V.!? 0))
|
||||||
|
colsString = intercalate ", " cols in
|
||||||
|
unwords [
|
||||||
|
"INSERT INTO ", fromQi qi,
|
||||||
|
" (" <> colsString <> ")" <>
|
||||||
|
" SELECT " <> colsString <>
|
||||||
|
" FROM json_populate_recordset(null::" , fromQi qi, ", ?)",
|
||||||
|
" RETURNING " <> fromQi qi <> ".*"
|
||||||
|
]
|
||||||
|
requestToQuery schema (DbMutate (Update mainTbl (PayloadJSON (UniformObjects rows)) conditions)) =
|
||||||
|
case rows V.!? 0 of
|
||||||
|
Just obj ->
|
||||||
|
let assignments = map
|
||||||
|
(\(k,v) -> pgFmtIdent k <> "=" <> insertableValue v) $ HM.toList obj in
|
||||||
|
unwords [
|
||||||
|
"UPDATE ", fromQi qi,
|
||||||
|
" SET " <> intercalate "," assignments <> " ",
|
||||||
|
("WHERE " <> intercalate " AND " ( map (pgFmtCondition qi ) conditions )) `emptyOnNull` conditions,
|
||||||
|
"RETURNING " <> fromQi qi <> ".*"
|
||||||
|
]
|
||||||
|
Nothing -> undefined
|
||||||
where
|
where
|
||||||
qi = QualifiedIdentifier schema mainTbl
|
qi = QualifiedIdentifier schema mainTbl
|
||||||
query = unwords [
|
|
||||||
"INSERT INTO ", fromQi qi,
|
requestToQuery schema (DbMutate (Delete mainTbl conditions)) =
|
||||||
" (" <> intercalate ", " (map (pgFmtIdent . fst) flds) <> ") ",
|
|
||||||
"VALUES " <> intercalate ", "
|
|
||||||
( map (\v ->
|
|
||||||
"(" <>
|
|
||||||
intercalate ", " ( map insertableValue v ) <>
|
|
||||||
")"
|
|
||||||
) vals
|
|
||||||
),
|
|
||||||
"RETURNING " <> fromQi qi <> ".*"
|
|
||||||
]
|
|
||||||
requestToQuery schema (Node (Update _ setWith conditions, (mainTbl, _)) _) =
|
|
||||||
query
|
|
||||||
where
|
|
||||||
qi = QualifiedIdentifier schema mainTbl
|
|
||||||
query = unwords [
|
|
||||||
"UPDATE ", fromQi qi,
|
|
||||||
" SET " <> intercalate ", " (map formatSet (M.toList setWith)) <> " ",
|
|
||||||
("WHERE " <> intercalate " AND " ( map (pgFmtCondition qi ) conditions )) `emptyOnNull` conditions,
|
|
||||||
"RETURNING " <> fromQi qi <> ".*"
|
|
||||||
]
|
|
||||||
formatSet ((c, jp), v) = pgFmtIdent c <> pgFmtJsonPath jp <> " = " <> insertableValue v
|
|
||||||
requestToQuery schema (Node (Delete _ conditions, (mainTbl, _)) _) =
|
|
||||||
query
|
query
|
||||||
where
|
where
|
||||||
qi = QualifiedIdentifier schema mainTbl
|
qi = QualifiedIdentifier schema mainTbl
|
||||||
@@ -266,9 +265,6 @@ requestToQuery schema (Node (Delete _ conditions, (mainTbl, _)) _) =
|
|||||||
"RETURNING " <> fromQi qi <> ".*"
|
"RETURNING " <> fromQi qi <> ".*"
|
||||||
]
|
]
|
||||||
|
|
||||||
selectStarF :: SqlFragment
|
|
||||||
selectStarF = "SELECT * FROM " <> sourceSubqueryName
|
|
||||||
|
|
||||||
sourceSubqueryName :: SqlFragment
|
sourceSubqueryName :: SqlFragment
|
||||||
sourceSubqueryName = "pg_source"
|
sourceSubqueryName = "pg_source"
|
||||||
|
|
||||||
@@ -279,15 +275,49 @@ unquoted (JSON.Number n) =
|
|||||||
unquoted (JSON.Bool b) = cs . show $ b
|
unquoted (JSON.Bool b) = cs . show $ b
|
||||||
unquoted v = cs $ JSON.encode v
|
unquoted v = cs $ JSON.encode v
|
||||||
|
|
||||||
wrapQuery :: SqlQuery -> [Text] -> Text -> Maybe NonnegRange -> SqlQuery
|
|
||||||
wrapQuery source selectColumns returnSelect range =
|
|
||||||
withSourceF source <>
|
|
||||||
" SELECT " <>
|
|
||||||
intercalate ", " selectColumns <>
|
|
||||||
" " <>
|
|
||||||
fromF returnSelect ( limitF range )
|
|
||||||
|
|
||||||
-- private functions
|
-- private functions
|
||||||
|
asCsvF :: SqlFragment
|
||||||
|
asCsvF = asCsvHeaderF <> " || '\n' || " <> asCsvBodyF
|
||||||
|
where
|
||||||
|
asCsvHeaderF =
|
||||||
|
"(SELECT string_agg(a.k, ',')" <>
|
||||||
|
" FROM (" <>
|
||||||
|
" SELECT json_object_keys(r)::TEXT as k" <>
|
||||||
|
" FROM ( " <>
|
||||||
|
" SELECT row_to_json(hh) as r from " <> sourceSubqueryName <> " as hh limit 1" <>
|
||||||
|
" ) s" <>
|
||||||
|
" ) a" <>
|
||||||
|
")"
|
||||||
|
asCsvBodyF = "coalesce(string_agg(substring(t::text, 2, length(t::text) - 2), '\n'), '')"
|
||||||
|
|
||||||
|
asJsonF :: SqlFragment
|
||||||
|
asJsonF = "array_to_json(array_agg(row_to_json(t)))::character varying"
|
||||||
|
|
||||||
|
asJsonSingleF :: SqlFragment --TODO! unsafe when the query actually returns multiple rows, used only on inserting and returning single element
|
||||||
|
asJsonSingleF = "string_agg(row_to_json(t)::text, ',')::character varying "
|
||||||
|
|
||||||
|
countAllF :: SqlFragment
|
||||||
|
countAllF = "(SELECT pg_catalog.count(1) FROM (SELECT * FROM " <> sourceSubqueryName <> ") a )"
|
||||||
|
|
||||||
|
countF :: SqlFragment
|
||||||
|
countF = "pg_catalog.count(t)"
|
||||||
|
|
||||||
|
countNoneF :: SqlFragment
|
||||||
|
countNoneF = "null"
|
||||||
|
|
||||||
|
locationF :: [Text] -> SqlFragment
|
||||||
|
locationF pKeys =
|
||||||
|
"(" <>
|
||||||
|
" WITH s AS (SELECT row_to_json(ss) as r from " <> sourceSubqueryName <> " as ss limit 1)" <>
|
||||||
|
" SELECT string_agg(json_data.key || '=' || coalesce( 'eq.' || json_data.value, 'is.null'), '&')" <>
|
||||||
|
" FROM s, json_each_text(s.r) AS json_data" <>
|
||||||
|
(
|
||||||
|
if null pKeys
|
||||||
|
then ""
|
||||||
|
else " WHERE json_data.key IN ('" <> intercalate "','" pKeys <> "')"
|
||||||
|
) <>
|
||||||
|
")"
|
||||||
|
|
||||||
fromQi :: QualifiedIdentifier -> SqlFragment
|
fromQi :: QualifiedIdentifier -> SqlFragment
|
||||||
fromQi t = (if s == "" then "" else pgFmtIdent s <> ".") <> pgFmtIdent n
|
fromQi t = (if s == "" then "" else pgFmtIdent s <> ".") <> pgFmtIdent n
|
||||||
where
|
where
|
||||||
@@ -321,8 +351,8 @@ orderF ts =
|
|||||||
queryTerm :: OrderTerm -> Text
|
queryTerm :: OrderTerm -> Text
|
||||||
queryTerm t = " "
|
queryTerm t = " "
|
||||||
<> cs (pgFmtIdent $ otTerm t) <> " "
|
<> cs (pgFmtIdent $ otTerm t) <> " "
|
||||||
<> cs (otDirection t) <> " "
|
<> (cs.show) (otDirection t) <> " "
|
||||||
<> maybe "" cs (otNullOrder t) <> " "
|
<> maybe "" (cs.show) (otNullOrder t) <> " "
|
||||||
|
|
||||||
insertableValue :: JSON.Value -> SqlFragment
|
insertableValue :: JSON.Value -> SqlFragment
|
||||||
insertableValue JSON.Null = "null"
|
insertableValue JSON.Null = "null"
|
||||||
@@ -407,3 +437,14 @@ limitF r = "LIMIT " <> limit <> " OFFSET " <> offset
|
|||||||
where
|
where
|
||||||
limit = maybe "ALL" (cs . show) $ join $ rangeLimit <$> r
|
limit = maybe "ALL" (cs . show) $ join $ rangeLimit <$> r
|
||||||
offset = cs . show $ fromMaybe 0 $ rangeOffset <$> r
|
offset = cs . show $ fromMaybe 0 $ rangeOffset <$> r
|
||||||
|
|
||||||
|
selectStarF :: SqlFragment
|
||||||
|
selectStarF = "SELECT * FROM " <> sourceSubqueryName
|
||||||
|
|
||||||
|
wrapQuery :: SqlQuery -> [Text] -> Text -> Maybe NonnegRange -> SqlQuery
|
||||||
|
wrapQuery source selectColumns returnSelect range =
|
||||||
|
withSourceF source <>
|
||||||
|
" SELECT " <>
|
||||||
|
intercalate ", " selectColumns <>
|
||||||
|
" " <>
|
||||||
|
fromF returnSelect ( limitF range )
|
||||||
|
|||||||
+34
-11
@@ -1,10 +1,10 @@
|
|||||||
module PostgREST.Types where
|
module PostgREST.Types where
|
||||||
import Data.Text
|
import Data.Text
|
||||||
import Data.Tree
|
import Data.Tree
|
||||||
import qualified Data.ByteString.Char8 as BS
|
import qualified Data.ByteString.Lazy as BL
|
||||||
import qualified Data.ByteString.Lazy as BL
|
import qualified Data.ByteString as BS
|
||||||
|
import qualified Data.Vector as V
|
||||||
import Data.Aeson
|
import Data.Aeson
|
||||||
import Data.Map
|
|
||||||
|
|
||||||
data DbStructure = DbStructure {
|
data DbStructure = DbStructure {
|
||||||
dbTables :: [Table]
|
dbTables :: [Table]
|
||||||
@@ -51,10 +51,20 @@ data PrimaryKey = PrimaryKey {
|
|||||||
, pkName :: Text
|
, pkName :: Text
|
||||||
} deriving (Show, Eq)
|
} deriving (Show, Eq)
|
||||||
|
|
||||||
|
data OrderDirection = OrderAsc | OrderDesc deriving (Eq)
|
||||||
|
instance Show OrderDirection where
|
||||||
|
show OrderAsc = "asc"
|
||||||
|
show OrderDesc = "desc"
|
||||||
|
|
||||||
|
data OrderNulls = OrderNullsFirst | OrderNullsLast deriving (Eq)
|
||||||
|
instance Show OrderNulls where
|
||||||
|
show OrderNullsFirst = "nulls first"
|
||||||
|
show OrderNullsLast = "nulls last"
|
||||||
|
|
||||||
data OrderTerm = OrderTerm {
|
data OrderTerm = OrderTerm {
|
||||||
otTerm :: Text
|
otTerm :: Text
|
||||||
, otDirection :: BS.ByteString
|
, otDirection :: OrderDirection
|
||||||
, otNullOrder :: Maybe BS.ByteString
|
, otNullOrder :: Maybe OrderNulls
|
||||||
} deriving (Show, Eq)
|
} deriving (Show, Eq)
|
||||||
|
|
||||||
data QualifiedIdentifier = QualifiedIdentifier {
|
data QualifiedIdentifier = QualifiedIdentifier {
|
||||||
@@ -75,6 +85,17 @@ data Relation = Relation {
|
|||||||
, relLCols2 :: Maybe [Column]
|
, relLCols2 :: Maybe [Column]
|
||||||
} deriving (Show, Eq)
|
} deriving (Show, Eq)
|
||||||
|
|
||||||
|
-- | An array of JSON objects that has been verified to have
|
||||||
|
-- the same keys in every object
|
||||||
|
newtype UniformObjects = UniformObjects (V.Vector Object)
|
||||||
|
deriving (Show, Eq)
|
||||||
|
|
||||||
|
-- | 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)
|
||||||
|
|
||||||
type Operator = Text
|
type Operator = Text
|
||||||
data FValue = VText Text | VForeignKey QualifiedIdentifier ForeignKey deriving (Show, Eq)
|
data FValue = VText Text | VForeignKey QualifiedIdentifier ForeignKey deriving (Show, Eq)
|
||||||
@@ -85,13 +106,15 @@ type Cast = Text
|
|||||||
type NodeName = Text
|
type NodeName = Text
|
||||||
type SelectItem = (Field, Maybe Cast)
|
type SelectItem = (Field, Maybe Cast)
|
||||||
type Path = [Text]
|
type Path = [Text]
|
||||||
data Query = Select { select::[SelectItem], from::[Text], where_::[Filter], order::Maybe [OrderTerm] }
|
data ReadQuery = Select { select::[SelectItem], from::[Text], flt_::[Filter], order::Maybe [OrderTerm] } deriving (Show, Eq)
|
||||||
| Insert { into::Text, fields::[Field], values::[[Value]] }
|
data MutateQuery = Insert { in_::Text, qPayload::Payload }
|
||||||
| Delete { from::[Text], where_::[Filter] }
|
| Delete { in_::Text, where_::[Filter] }
|
||||||
| Update { into::Text, set::Map Field Value, where_::[Filter] } deriving (Show, Eq)
|
| Update { in_::Text, qPayload::Payload, where_::[Filter] } deriving (Show, Eq)
|
||||||
data Filter = Filter {field::Field, operator::Operator, value::FValue} deriving (Show, Eq)
|
data Filter = Filter {field::Field, operator::Operator, value::FValue} deriving (Show, Eq)
|
||||||
type ApiNode = (Query, (NodeName, Maybe Relation))
|
type ReadNode = (ReadQuery, (NodeName, Maybe Relation))
|
||||||
type ApiRequest = Tree ApiNode
|
type ReadRequest = Tree ReadNode
|
||||||
|
type MutateRequest = MutateQuery
|
||||||
|
data DbRequest = DbRead ReadRequest | DbMutate MutateRequest
|
||||||
|
|
||||||
|
|
||||||
instance ToJSON Column where
|
instance ToJSON Column where
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ spec = afterAll_ resetDb $ around withApp $ do
|
|||||||
}
|
}
|
||||||
|
|
||||||
it "includes related data after insert" $
|
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")]
|
||||||
[str|{"id":5,"name":"New Project","client_id":2}|] `shouldRespondWith` ResponseMatcher {
|
[str|{"id":5,"name":"New Project","client_id":2}|] `shouldRespondWith` ResponseMatcher {
|
||||||
matchBody = Just [str|{"id":5,"name":"New Project","clients":{"id":2,"name":"Apple"}}|]
|
matchBody = Just [str|{"id":5,"name":"New Project","clients":{"id":2,"name":"Apple"}}|]
|
||||||
, matchStatus = 201
|
, matchStatus = 201
|
||||||
@@ -207,13 +207,14 @@ spec = afterAll_ resetDb $ around withApp $ do
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
after_ (clearTable "no_pk") . context "with wrong number of columns" $ do
|
after_ (clearTable "no_pk") . context "with wrong number of columns" $
|
||||||
it "fails for too few" $ do
|
it "fails for too few" $ do
|
||||||
p <- request methodPost "/no_pk" [("Content-Type", "text/csv")] "a,b\nfoo,bar\nbaz"
|
p <- request methodPost "/no_pk" [("Content-Type", "text/csv")] "a,b\nfoo,bar\nbaz"
|
||||||
liftIO $ simpleStatus p `shouldBe` badRequest400
|
liftIO $ simpleStatus p `shouldBe` badRequest400
|
||||||
it "fails for too many" $ do
|
-- it does not fail because the extra columns are ignored
|
||||||
p <- request methodPost "/no_pk" [("Content-Type", "text/csv")] "a,b\nfoo,bar\nbaz,bat,bad"
|
-- it "fails for too many" $ do
|
||||||
liftIO $ simpleStatus p `shouldBe` badRequest400
|
-- p <- request methodPost "/no_pk" [("Content-Type", "text/csv")] "a,b\nfoo,bar\nbaz,bat,bad"
|
||||||
|
-- liftIO $ simpleStatus p `shouldBe` badRequest400
|
||||||
|
|
||||||
describe "Putting record" $ do
|
describe "Putting record" $ do
|
||||||
|
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ spec =
|
|||||||
[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}] |]
|
[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" $
|
it "matches filtering nested items" $
|
||||||
get "/clients?select=id,projects(id,tasks(id,name))&projects.tasks.name=like.Design*" `shouldRespondWith`
|
get "/clients?select=id,projects{id,tasks{id,name}}&projects.tasks.name=like.Design*" `shouldRespondWith`
|
||||||
"[{\"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\"}]}]}]"
|
"[{\"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\"}]}]}]"
|
||||||
|
|
||||||
it "matches with @> operator" $
|
it "matches with @> operator" $
|
||||||
@@ -195,23 +195,23 @@ spec =
|
|||||||
[json| [{"int":1}] |] -- the value in the db is an int, but here we expect a string for now
|
[json| [{"int":1}] |] -- the value in the db is an int, but here we expect a string for now
|
||||||
|
|
||||||
it "requesting parents and children" $
|
it "requesting parents and children" $
|
||||||
get "/projects?id=eq.1&select=id, name, clients(*), tasks(id, name)" `shouldRespondWith`
|
get "/projects?id=eq.1&select=id, name, clients{*}, tasks{id, name}" `shouldRespondWith`
|
||||||
"[{\"id\":1,\"name\":\"Windows 7\",\"clients\":{\"id\":1,\"name\":\"Microsoft\"},\"tasks\":[{\"id\":1,\"name\":\"Design w7\"},{\"id\":2,\"name\":\"Code w7\"}]}]"
|
"[{\"id\":1,\"name\":\"Windows 7\",\"clients\":{\"id\":1,\"name\":\"Microsoft\"},\"tasks\":[{\"id\":1,\"name\":\"Design w7\"},{\"id\":2,\"name\":\"Code w7\"}]}]"
|
||||||
|
|
||||||
it "requesting children 2 levels" $
|
it "requesting children 2 levels" $
|
||||||
get "/clients?id=eq.1&select=id,projects(id,tasks(id))" `shouldRespondWith`
|
get "/clients?id=eq.1&select=id,projects{id,tasks{id}}" `shouldRespondWith`
|
||||||
"[{\"id\":1,\"projects\":[{\"id\":1,\"tasks\":[{\"id\":1},{\"id\":2}]},{\"id\":2,\"tasks\":[{\"id\":3},{\"id\":4}]}]}]"
|
"[{\"id\":1,\"projects\":[{\"id\":1,\"tasks\":[{\"id\":1},{\"id\":2}]},{\"id\":2,\"tasks\":[{\"id\":3},{\"id\":4}]}]}]"
|
||||||
|
|
||||||
it "requesting many<->many relation" $
|
it "requesting many<->many relation" $
|
||||||
get "/tasks?select=id,users(id)" `shouldRespondWith`
|
get "/tasks?select=id,users{id}" `shouldRespondWith`
|
||||||
"[{\"id\":1,\"users\":[{\"id\":1},{\"id\":3}]},{\"id\":2,\"users\":[{\"id\":1}]},{\"id\":3,\"users\":[{\"id\":1}]},{\"id\":4,\"users\":[{\"id\":1}]},{\"id\":5,\"users\":[{\"id\":2},{\"id\":3}]},{\"id\":6,\"users\":[{\"id\":2}]},{\"id\":7,\"users\":[{\"id\":2}]},{\"id\":8,\"users\":null}]"
|
"[{\"id\":1,\"users\":[{\"id\":1},{\"id\":3}]},{\"id\":2,\"users\":[{\"id\":1}]},{\"id\":3,\"users\":[{\"id\":1}]},{\"id\":4,\"users\":[{\"id\":1}]},{\"id\":5,\"users\":[{\"id\":2},{\"id\":3}]},{\"id\":6,\"users\":[{\"id\":2}]},{\"id\":7,\"users\":[{\"id\":2}]},{\"id\":8,\"users\":null}]"
|
||||||
|
|
||||||
it "requesting parents and children on views" $
|
it "requesting parents and children on views" $
|
||||||
get "/projects_view?id=eq.1&select=id, name, clients(*), tasks(id, name)" `shouldRespondWith`
|
get "/projects_view?id=eq.1&select=id, name, clients{*}, tasks{id, name}" `shouldRespondWith`
|
||||||
"[{\"id\":1,\"name\":\"Windows 7\",\"clients\":{\"id\":1,\"name\":\"Microsoft\"},\"tasks\":[{\"id\":1,\"name\":\"Design w7\"},{\"id\":2,\"name\":\"Code w7\"}]}]"
|
"[{\"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" $
|
it "requesting children with composite key" $
|
||||||
get "/users_tasks?user_id=eq.2&task_id=eq.6&select=*, comments(content)" `shouldRespondWith`
|
get "/users_tasks?user_id=eq.2&task_id=eq.6&select=*, comments{content}" `shouldRespondWith`
|
||||||
"[{\"user_id\":2,\"task_id\":6,\"comments\":[{\"content\":\"Needs to be delivered ASAP\"}]}]"
|
"[{\"user_id\":2,\"task_id\":6,\"comments\":[{\"content\":\"Needs to be delivered ASAP\"}]}]"
|
||||||
|
|
||||||
describe "Plurality singular" $ do
|
describe "Plurality singular" $ do
|
||||||
@@ -228,7 +228,7 @@ spec =
|
|||||||
`shouldRespondWith` 404
|
`shouldRespondWith` 404
|
||||||
|
|
||||||
it "can shape plurality singular object routes" $
|
it "can shape plurality singular object routes" $
|
||||||
request methodGet "/projects_view?id=eq.1&select=id,name,clients(*),tasks(id,name)" [("Prefer","plurality=singular")] ""
|
request methodGet "/projects_view?id=eq.1&select=id,name,clients{*},tasks{id,name}" [("Prefer","plurality=singular")] ""
|
||||||
`shouldRespondWith`
|
`shouldRespondWith`
|
||||||
"{\"id\":1,\"name\":\"Windows 7\",\"clients\":{\"id\":1,\"name\":\"Microsoft\"},\"tasks\":[{\"id\":1,\"name\":\"Design w7\"},{\"id\":2,\"name\":\"Code w7\"}]}"
|
"{\"id\":1,\"name\":\"Windows 7\",\"clients\":{\"id\":1,\"name\":\"Microsoft\"},\"tasks\":[{\"id\":1,\"name\":\"Design w7\"},{\"id\":2,\"name\":\"Code w7\"}]}"
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -23,6 +23,7 @@ import Data.Maybe (fromMaybe)
|
|||||||
import Text.Regex.TDFA ((=~))
|
import Text.Regex.TDFA ((=~))
|
||||||
import qualified Data.ByteString.Char8 as BS
|
import qualified Data.ByteString.Char8 as BS
|
||||||
import System.Process (readProcess)
|
import System.Process (readProcess)
|
||||||
|
import Web.JWT (secret)
|
||||||
|
|
||||||
import qualified Data.Aeson.Types as J
|
import qualified Data.Aeson.Types as J
|
||||||
|
|
||||||
@@ -40,7 +41,7 @@ isLeft (Left _ ) = True
|
|||||||
isLeft _ = False
|
isLeft _ = False
|
||||||
|
|
||||||
cfg :: AppConfig
|
cfg :: AppConfig
|
||||||
cfg = AppConfig dbString 3000 "postgrest_anonymous" "test" "safe" 10
|
cfg = AppConfig dbString 3000 "postgrest_anonymous" "test" (secret "safe") 10
|
||||||
|
|
||||||
testPoolOpts :: PoolSettings
|
testPoolOpts :: PoolSettings
|
||||||
testPoolOpts = fromMaybe (error "bad settings") $ H.poolSettings 1 30
|
testPoolOpts = fromMaybe (error "bad settings") $ H.poolSettings 1 30
|
||||||
|
|||||||
Reference in New Issue
Block a user