Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35d5e930ea | ||
|
|
3165d5dbc0 | ||
|
|
1c09058628 | ||
|
|
50de0536de | ||
|
|
74227d3c2b | ||
|
|
3dbcf9cbc3 | ||
|
|
44b3bd5fa5 | ||
|
|
b09b677cd8 | ||
|
|
8593600cfa | ||
|
|
b8eb2cd9c1 | ||
|
|
59abecaf5b | ||
|
|
3e26c1a83f | ||
|
|
c912601e1c | ||
|
|
7ba880e902 | ||
|
|
d99462be01 | ||
|
|
0b436d49e6 | ||
|
|
f4d84e4b58 | ||
|
|
c8f4173262 | ||
|
|
0a9d9cdded | ||
|
|
5fffbbe381 | ||
|
|
3e7a8b5f85 | ||
|
|
8557ee55f5 | ||
|
|
56bdf59e14 | ||
|
|
77af16c9e4 | ||
|
|
0cf49bb8b6 | ||
|
|
10f6ce45db | ||
|
|
9b4b45671c | ||
|
|
0b486ccf44 | ||
|
|
47b023e858 | ||
|
|
206ab163b6 | ||
|
|
2aabbbae58 | ||
|
|
728e40c96c | ||
|
|
98438c437f | ||
|
|
84f68c68cb | ||
|
|
1d8318ce26 | ||
|
|
a61c783e2d | ||
|
|
69fd5f1b33 |
@@ -0,0 +1 @@
|
|||||||
|
/CHANGELOG.md merge=union
|
||||||
@@ -5,10 +5,39 @@ This project adheres to [Semantic Versioning](http://semver.org/).
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
## [0.4.2.0] - 2017-06-11
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- #742, Add connection retrying on startup and SIGHUP - @steve-chavez
|
||||||
|
- #652, Add and/or params for complex boolean logic - @steve-chavez
|
||||||
|
- #808, Env var interpolation in config file (helps Docker) - @begriffs
|
||||||
|
- #878 - CSV output support for RPC - @begriffs
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- #822, Treat blank string JWT secret as no secret - @begriffs
|
||||||
|
|
||||||
|
## [0.4.1.0] - 2017-04-25
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Allow requesting binary output on GET - @steve-chavez
|
||||||
|
- Accept clients requesting `Content-Type: application/json` from / - @feynmanliang
|
||||||
|
- #493, Updating with empty JSON object makes zero updates @koulakis
|
||||||
|
- Make HTTP headers and cookies available as GUCs #800 - @ruslantalpa
|
||||||
|
- #701, Ability to quote values on IN filters - @steve-chavez
|
||||||
|
- #641, Allow IN filter to have no values - @steve-chavez
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- #827, Avoid Warp reaper, extend socket timeout to 1 hour - @majorcode
|
||||||
|
- #791, malformed nested JSON error - @diogob
|
||||||
|
- Resource embedding in views referencing tables in public schema - @fab1an
|
||||||
|
- #777, Empty body is allowed when calling a non-parameterized RPC - @koulakis
|
||||||
|
- #831, Fix proc resource embedding issue with search_path - @steve-chavez
|
||||||
|
- #547, Use read-only transaction for stable/immutable RPC - @begriffs
|
||||||
|
|
||||||
## [0.4.0.0] - 2017-01-19
|
## [0.4.0.0] - 2017-01-19
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+7
-1
@@ -48,7 +48,7 @@ your contributions.
|
|||||||
pull request.
|
pull request.
|
||||||
|
|
||||||
* For help building the Haskell code on your computer check out the [building from
|
* For help building the Haskell code on your computer check out the [building from
|
||||||
source](http://postgrest.com/install/server/#building-from-source)
|
source](https://postgrest.com/en/stable/install.html#build-from-source)
|
||||||
wiki page.
|
wiki page.
|
||||||
|
|
||||||
## Maintenance
|
## Maintenance
|
||||||
@@ -61,3 +61,9 @@ my other obligations. If you don't get a response right away
|
|||||||
don't worry, I will definitely get to it. Also you can join the
|
don't worry, I will definitely get to it. Also you can join the
|
||||||
Gitter [chat room](https://gitter.im/begriffs/postgrest) to
|
Gitter [chat room](https://gitter.im/begriffs/postgrest) to
|
||||||
discuss issues you are having.
|
discuss issues you are having.
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
For instructions on running tests, see the official docs hosted here:
|
||||||
|
|
||||||
|
https://postgrest.com/en/stable/install.html#postgrest-test-suite
|
||||||
|
|||||||
-18
@@ -1,18 +0,0 @@
|
|||||||
FROM debian:jessie
|
|
||||||
|
|
||||||
ENV POSTGREST_VERSION 0.4.0.0
|
|
||||||
|
|
||||||
RUN apt-get update && \
|
|
||||||
apt-get install -y tar xz-utils wget libpq-dev && \
|
|
||||||
apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
|
||||||
|
|
||||||
RUN wget http://github.com/begriffs/postgrest/releases/download/v${POSTGREST_VERSION}/postgrest-${POSTGREST_VERSION}-ubuntu.tar.xz && \
|
|
||||||
tar --xz -xvf postgrest-${POSTGREST_VERSION}-ubuntu.tar.xz && \
|
|
||||||
mv postgrest /usr/local/bin/postgrest && \
|
|
||||||
rm postgrest-${POSTGREST_VERSION}-ubuntu.tar.xz
|
|
||||||
|
|
||||||
# PostgREST reads /etc/postgrest.conf so map the configuration
|
|
||||||
# file in when you run this container
|
|
||||||
CMD exec postgrest
|
|
||||||
|
|
||||||
EXPOSE 3000
|
|
||||||
@@ -5,23 +5,12 @@
|
|||||||
<img src="https://img.shields.io/badge/%E2%86%91_Deploy_to-Heroku-7056bf.svg" alt="Deploy">
|
<img src="https://img.shields.io/badge/%E2%86%91_Deploy_to-Heroku-7056bf.svg" alt="Deploy">
|
||||||
</a>
|
</a>
|
||||||
[](https://gitter.im/begriffs/postgrest)
|
[](https://gitter.im/begriffs/postgrest)
|
||||||
[](https://postgrest.com)
|
[](http://postgrest.com)
|
||||||
|
|
||||||
PostgREST serves a fully RESTful API from any existing PostgreSQL
|
PostgREST serves a fully RESTful API from any existing PostgreSQL
|
||||||
database. It provides a cleaner, more standards-compliant, faster
|
database. It provides a cleaner, more standards-compliant, faster
|
||||||
API than you are likely to write from scratch.
|
API than you are likely to write from scratch.
|
||||||
|
|
||||||
Try making requests to the live [demo
|
|
||||||
server](https://postgrest.herokuapp.com) with an HTTP client such
|
|
||||||
as [postman](http://www.getpostman.com/). The structure of the demo
|
|
||||||
database is defined by
|
|
||||||
[begriffs/postgrest-example](https://github.com/begriffs/postgrest-example).
|
|
||||||
You can use it as inspiration for test-driven server migrations in
|
|
||||||
your own projects.
|
|
||||||
|
|
||||||
Also try other tools in the PostgREST
|
|
||||||
[ecosystem](http://postgrest.com/en/v0.4/intro.html#ecosystem).
|
|
||||||
|
|
||||||
### Usage
|
### Usage
|
||||||
|
|
||||||
1. Download the binary ([latest release](https://github.com/begriffs/postgrest/releases/latest))
|
1. Download the binary ([latest release](https://github.com/begriffs/postgrest/releases/latest))
|
||||||
@@ -66,7 +55,7 @@ Other optimizations are possible, and some are outlined in the
|
|||||||
### Security
|
### Security
|
||||||
|
|
||||||
PostgREST [handles
|
PostgREST [handles
|
||||||
authentication](http://postgrest.com/en/v0.4/auth.html) (via JSON Web
|
authentication](http://postgrest.com/en/stable/auth.html) (via JSON Web
|
||||||
Tokens) and delegates authorization to the role information defined in
|
Tokens) and delegates authorization to the role information defined in
|
||||||
the database. This ensures there is a single declarative source of truth
|
the database. This ensures there is a single declarative source of truth
|
||||||
for security. When dealing with the database the server assumes the
|
for security. When dealing with the database the server assumes the
|
||||||
@@ -112,11 +101,11 @@ directly into your database. Hence no application can corrupt your
|
|||||||
data (including your API server).
|
data (including your API server).
|
||||||
|
|
||||||
The PostgREST exposes HTTP interface with safeguards to prevent
|
The PostgREST exposes HTTP interface with safeguards to prevent
|
||||||
surprises, such as enforcing idempotent PUT requests, and
|
surprises, such as enforcing idempotent PUT requests.
|
||||||
|
|
||||||
See examples of [PostgreSQL
|
See examples of [PostgreSQL
|
||||||
constraints](http://www.tutorialspoint.com/postgresql/postgresql_constraints.htm)
|
constraints](http://www.tutorialspoint.com/postgresql/postgresql_constraints.htm)
|
||||||
and the [API guide](http://postgrest.com/en/v0.4/api.html).
|
and the [API guide](http://postgrest.com/en/stable/api.html).
|
||||||
|
|
||||||
### Thanks
|
### Thanks
|
||||||
|
|
||||||
|
|||||||
@@ -10,47 +10,46 @@
|
|||||||
},
|
},
|
||||||
"POSTGREST_VER": {
|
"POSTGREST_VER": {
|
||||||
"description": "Version of PostgREST to deploy",
|
"description": "Version of PostgREST to deploy",
|
||||||
"value": "0.4.0.0"
|
"value": "0.4.2.0"
|
||||||
},
|
},
|
||||||
"DB_NAME": {
|
"DB_URI": {
|
||||||
"description": "Database name",
|
"description": "Database connection string",
|
||||||
"required": true
|
"required": true
|
||||||
},
|
},
|
||||||
"AUTH_ROLE": {
|
"DB_SCHEMA": {
|
||||||
"description": "Database role to use checking client authentication",
|
"description": "The database schema to expose to REST clients. Tables, views and stored procedures in this schema will get API endpoints",
|
||||||
|
"required": true,
|
||||||
|
"value": "public"
|
||||||
|
},
|
||||||
|
"DB_ANON_ROLE": {
|
||||||
|
"description": "The database role to use when executing commands on behalf of unauthenticated clients",
|
||||||
"required": true
|
"required": true
|
||||||
},
|
},
|
||||||
"AUTH_PASS": {
|
|
||||||
"description": "Authentication password",
|
|
||||||
"required": false
|
|
||||||
},
|
|
||||||
"ANONYMOUS_ROLE": {
|
|
||||||
"description": "Database role for non-authenticated requests",
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
"DB_HOST": {
|
|
||||||
"description": "Database server hostname",
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
"DB_PORT": {
|
|
||||||
"description": "Database server port",
|
|
||||||
"required": false,
|
|
||||||
"value": "5432"
|
|
||||||
},
|
|
||||||
"DB_POOL": {
|
"DB_POOL": {
|
||||||
"description": "Maximum number of connections in database pool",
|
"description": "Number of connections to keep open in PostgREST’s database pool",
|
||||||
"required": false,
|
"required": false,
|
||||||
"value": "10"
|
"value": "10"
|
||||||
},
|
},
|
||||||
"JWT_SECRET": {
|
"SERVER_PROXY_URI": {
|
||||||
"description": "Secret used to encrypt JSON Web Tokens",
|
"description": "Overrides the base URL used within the OpenAPI self-documentation hosted at the API root path",
|
||||||
"required": false,
|
"required": false
|
||||||
"value": "secret"
|
|
||||||
},
|
},
|
||||||
"SCHEMA": {
|
"JWT_SECRET": {
|
||||||
"description": "DB schema to be exported",
|
"description": "The secret used to decode JWT tokens clients provide for authentication",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"SECRET_IS_BASE64": {
|
||||||
|
"description": "When this is set to true, the value derived from jwt-secret will be treated as a base64 encoded secret",
|
||||||
"required": false,
|
"required": false,
|
||||||
"value": "1"
|
"value": "false"
|
||||||
|
},
|
||||||
|
"MAX_ROWS": {
|
||||||
|
"description": "A hard limit to the number of rows PostgREST will fetch from a view, table, or stored procedure",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"PRE_REQUEST": {
|
||||||
|
"description": "A schema-qualified stored procedure name to call right after switching roles for a client request",
|
||||||
|
"required": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -15,7 +15,7 @@ dependencies:
|
|||||||
|
|
||||||
test:
|
test:
|
||||||
override:
|
override:
|
||||||
- POSTGREST_TEST_CONNECTION=$(test/create_test_db "postgres://ubuntu@localhost" postgrest_test) stack test --test-arguments "--skip \"returns a valid openapi\""
|
- POSTGREST_TEST_CONNECTION=$(test/create_test_db "postgres://ubuntu@localhost" postgrest_test) stack test
|
||||||
- git ls-files | grep '\.l\?hs$' | xargs stack exec -- hlint -X QuasiQuotes -X NoPatternSynonyms "$@"
|
- git ls-files | grep '\.l\?hs$' | xargs stack exec -- hlint -X QuasiQuotes -X NoPatternSynonyms "$@"
|
||||||
- stack exec -- cabal update
|
- stack exec -- cabal update
|
||||||
- stack exec --no-ghc-package-path -- cabal install --only-d --dry-run
|
- stack exec --no-ghc-package-path -- cabal install --only-d --dry-run
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
FROM debian:jessie
|
||||||
|
|
||||||
|
# Install libpq5
|
||||||
|
RUN apt-get -qq update && \
|
||||||
|
apt-get -qq install -y --no-install-recommends libpq5 && \
|
||||||
|
apt-get -qq clean && \
|
||||||
|
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||||
|
|
||||||
|
# Install postgrest
|
||||||
|
RUN POSTGREST_VERSION="0.4.2.0" \
|
||||||
|
BUILD_DEPS="curl ca-certificates xz-utils" && \
|
||||||
|
apt-get -qq update && \
|
||||||
|
apt-get -qq install -y --no-install-recommends $BUILD_DEPS && \
|
||||||
|
cd /tmp && \
|
||||||
|
curl -SLO https://github.com/begriffs/postgrest/releases/download/v${POSTGREST_VERSION}/postgrest-${POSTGREST_VERSION}-ubuntu.tar.xz && \
|
||||||
|
tar -xJvf postgrest-${POSTGREST_VERSION}-ubuntu.tar.xz && \
|
||||||
|
mv postgrest /usr/local/bin/postgrest && \
|
||||||
|
cd / && \
|
||||||
|
apt-get -qq purge --auto-remove -y $BUILD_DEPS && \
|
||||||
|
apt-get -qq clean && \
|
||||||
|
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||||
|
|
||||||
|
COPY postgrest.conf /etc/postgrest.conf
|
||||||
|
|
||||||
|
|
||||||
|
ENV PGRST_DB_URI= \
|
||||||
|
PGRST_DB_SCHEMA=public \
|
||||||
|
PGRST_DB_ANON_ROLE= \
|
||||||
|
PGRST_DB_POOL=100 \
|
||||||
|
PGRST_SERVER_HOST=*4 \
|
||||||
|
PGRST_SERVER_PORT=3000 \
|
||||||
|
PGRST_SERVER_PROXY_URL= \
|
||||||
|
PGRST_JWT_SECRET= \
|
||||||
|
PGRST_SECRET_IS_BASE64=false \
|
||||||
|
PGRST_MAX_ROWS= \
|
||||||
|
PGRST_PRE_REQUEST=
|
||||||
|
|
||||||
|
# PostgREST reads /etc/postgrest.conf so map the configuration
|
||||||
|
# file in when you run this container
|
||||||
|
CMD exec postgrest /etc/postgrest.conf
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
db-uri = "postgres://app_user:password@postgres:5432/app_db"
|
||||||
|
db-schema = "public"
|
||||||
|
db-anon-role = "app_user"
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
stgrest:
|
||||||
|
image: pg_local
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
links:
|
||||||
|
- postgres:postgres
|
||||||
|
environment:
|
||||||
|
PGRST_DB_URI: postgres://app_user:password@postgres:5432/app_db
|
||||||
|
PGRST_DB_SCHEMA: public
|
||||||
|
PGRST_DB_ANON_ROLE: app_user
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: app_db
|
||||||
|
POSTGRES_USER: app_user
|
||||||
|
POSTGRES_PASSWORD: password
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
db-uri = "$(PGRST_DB_URI)"
|
||||||
|
db-schema = "$(PGRST_DB_SCHEMA)"
|
||||||
|
db-anon-role = "$(PGRST_DB_ANON_ROLE)"
|
||||||
|
db-pool = "$(PGRST_DB_POOL)"
|
||||||
|
|
||||||
|
server-host = "$(PGRST_SERVER_HOST)"
|
||||||
|
server-port = "$(PGRST_SERVER_PORT)"
|
||||||
|
|
||||||
|
server-proxy-url = "$(PGRST_SERVER_PROXY_URL)"
|
||||||
|
jwt-secret = "$(PGRST_JWT_SECRET)"
|
||||||
|
secret-is-base64 = "$(PGRST_SECRET_IS_BASE64)"
|
||||||
|
|
||||||
|
max-rows = "$(PGRST_MAX_ROWS)"
|
||||||
|
pre-request = "$(PGRST_PRE_REQUEST)"
|
||||||
+72
-18
@@ -9,17 +9,18 @@ import PostgREST.Config (AppConfig (..),
|
|||||||
minimumPgVersion,
|
minimumPgVersion,
|
||||||
prettyVersion,
|
prettyVersion,
|
||||||
readOptions)
|
readOptions)
|
||||||
import PostgREST.Error (prettyUsageError)
|
import PostgREST.Error (encodeError)
|
||||||
import PostgREST.OpenAPI (isMalformedProxyUri)
|
import PostgREST.OpenAPI (isMalformedProxyUri)
|
||||||
import PostgREST.DbStructure
|
import PostgREST.DbStructure
|
||||||
|
import PostgREST.Types (DbStructure, Schema)
|
||||||
|
|
||||||
import Control.AutoUpdate
|
import Control.AutoUpdate
|
||||||
|
import Control.Retry
|
||||||
import Data.ByteString.Base64 (decode)
|
import Data.ByteString.Base64 (decode)
|
||||||
import Data.String (IsString (..))
|
import Data.String (IsString (..))
|
||||||
import Data.Text (stripPrefix, pack, replace)
|
import Data.Text (stripPrefix, pack, replace)
|
||||||
import Data.Text.Encoding (encodeUtf8, decodeUtf8)
|
import Data.Text.Encoding (encodeUtf8, decodeUtf8)
|
||||||
import Data.Text.IO (hPutStrLn, readFile)
|
import Data.Text.IO (hPutStrLn, readFile)
|
||||||
import Data.Function (id)
|
|
||||||
import Data.Time.Clock.POSIX (getPOSIXTime)
|
import Data.Time.Clock.POSIX (getPOSIXTime)
|
||||||
import qualified Hasql.Query as H
|
import qualified Hasql.Query as H
|
||||||
import qualified Hasql.Session as H
|
import qualified Hasql.Session as H
|
||||||
@@ -43,6 +44,65 @@ isServerVersionSupported = do
|
|||||||
H.statement "SELECT current_setting('server_version_num')::integer"
|
H.statement "SELECT current_setting('server_version_num')::integer"
|
||||||
HE.unit (HD.singleRow $ HD.value HD.int4) False
|
HE.unit (HD.singleRow $ HD.value HD.int4) False
|
||||||
|
|
||||||
|
{-|
|
||||||
|
Background thread that does the following :
|
||||||
|
1. Tries to connect to pg server and will keep trying until success.
|
||||||
|
2. Checks if the pg version is supported and if it's not it kills the main program.
|
||||||
|
3. Obtains the dbStructure.
|
||||||
|
4. If 2 or 3 fail to give their result it means the connection is down so it goes back to 1,
|
||||||
|
otherwise it finishes his work successfully.
|
||||||
|
-}
|
||||||
|
connectionWorker :: ThreadId -> P.Pool -> Schema -> IORef (Maybe DbStructure) -> IORef Bool -> IO ()
|
||||||
|
connectionWorker mainTid pool schema refDbStructure refIsWorkerOn = do
|
||||||
|
isWorkerOn <- readIORef refIsWorkerOn
|
||||||
|
unless isWorkerOn $ do
|
||||||
|
atomicWriteIORef refIsWorkerOn True
|
||||||
|
void $ forkIO work
|
||||||
|
where
|
||||||
|
work = do
|
||||||
|
atomicWriteIORef refDbStructure Nothing
|
||||||
|
putStrLn ("Attempting to connect to the database..." :: Text)
|
||||||
|
connected <- connectingSucceeded pool
|
||||||
|
when connected $ do
|
||||||
|
result <- P.use pool $ do
|
||||||
|
supported <- isServerVersionSupported
|
||||||
|
unless supported $ liftIO $ do
|
||||||
|
hPutStrLn stderr
|
||||||
|
("Cannot run in this PostgreSQL version, PostgREST needs at least "
|
||||||
|
<> pgvName minimumPgVersion)
|
||||||
|
killThread mainTid
|
||||||
|
dbStructure <- getDbStructure schema
|
||||||
|
liftIO $ atomicWriteIORef refDbStructure $ Just dbStructure
|
||||||
|
case result of
|
||||||
|
Left e -> do
|
||||||
|
putStrLn ("Failed to query the database. Retrying." :: Text)
|
||||||
|
hPutStrLn stderr (toS $ encodeError e)
|
||||||
|
work
|
||||||
|
Right _ -> do
|
||||||
|
atomicWriteIORef refIsWorkerOn False
|
||||||
|
putStrLn ("Connection successful" :: Text)
|
||||||
|
|
||||||
|
-- | Connect to pg server if it fails retry with capped exponential backoff until success
|
||||||
|
connectingSucceeded :: P.Pool -> IO Bool
|
||||||
|
connectingSucceeded pool =
|
||||||
|
retrying (capDelay 32000000 $ exponentialBackoff 1000000)
|
||||||
|
shouldRetry
|
||||||
|
(const $ P.release pool >> isConnectionSuccessful)
|
||||||
|
where
|
||||||
|
isConnectionSuccessful :: IO Bool
|
||||||
|
isConnectionSuccessful = do
|
||||||
|
testConn <- P.use pool $ H.sql "SELECT 1"
|
||||||
|
case testConn of
|
||||||
|
Left e -> hPutStrLn stderr (toS $ encodeError e) >> pure False
|
||||||
|
_ -> pure True
|
||||||
|
shouldRetry :: RetryStatus -> Bool -> IO Bool
|
||||||
|
shouldRetry rs isConnSucc = do
|
||||||
|
delay <- pure $ fromMaybe 0 (rsPreviousDelay rs) `div` 1000000
|
||||||
|
itShould <- pure $ not isConnSucc
|
||||||
|
when itShould $
|
||||||
|
putStrLn $ "Attempting to reconnect to the database in " <> (show delay::Text) <> " seconds..."
|
||||||
|
return itShould
|
||||||
|
|
||||||
main :: IO ()
|
main :: IO ()
|
||||||
main = do
|
main = do
|
||||||
hSetBuffering stdout LineBuffering
|
hSetBuffering stdout LineBuffering
|
||||||
@@ -57,6 +117,7 @@ main = do
|
|||||||
appSettings = setHost ((fromString . toS) host)
|
appSettings = setHost ((fromString . toS) host)
|
||||||
. setPort port
|
. setPort port
|
||||||
. setServerName (toS $ "postgrest/" <> prettyVersion)
|
. setServerName (toS $ "postgrest/" <> prettyVersion)
|
||||||
|
. setTimeout 3600
|
||||||
$ defaultSettings
|
$ defaultSettings
|
||||||
|
|
||||||
when (isMalformedProxyUri $ toS <$> proxy) $ panic
|
when (isMalformedProxyUri $ toS <$> proxy) $ panic
|
||||||
@@ -66,31 +127,24 @@ main = do
|
|||||||
|
|
||||||
pool <- P.acquire (configPool conf, 10, pgSettings)
|
pool <- P.acquire (configPool conf, 10, pgSettings)
|
||||||
|
|
||||||
result <- P.use pool $ do
|
refDbStructure <- newIORef Nothing
|
||||||
supported <- isServerVersionSupported
|
|
||||||
unless supported $ panic (
|
|
||||||
"Cannot run in this PostgreSQL version, PostgREST needs at least "
|
|
||||||
<> pgvName minimumPgVersion)
|
|
||||||
getDbStructure (toS $ configSchema conf)
|
|
||||||
|
|
||||||
forM_ (lefts [result]) $ \e -> do
|
-- Helper ref to make sure just one connectionWorker can run at a time
|
||||||
hPutStrLn stderr (prettyUsageError e)
|
refIsWorkerOn <- newIORef False
|
||||||
exitFailure
|
|
||||||
|
|
||||||
refDbStructure <- newIORef $ either (panic . show) id result
|
mainTid <- myThreadId
|
||||||
|
|
||||||
|
connectionWorker mainTid pool (configSchema conf) refDbStructure refIsWorkerOn
|
||||||
|
|
||||||
#ifndef mingw32_HOST_OS
|
#ifndef mingw32_HOST_OS
|
||||||
tid <- myThreadId
|
|
||||||
forM_ [sigINT, sigTERM] $ \sig ->
|
forM_ [sigINT, sigTERM] $ \sig ->
|
||||||
void $ installHandler sig (Catch $ do
|
void $ installHandler sig (Catch $ do
|
||||||
P.release pool
|
P.release pool
|
||||||
throwTo tid UserInterrupt
|
throwTo mainTid UserInterrupt
|
||||||
) Nothing
|
) Nothing
|
||||||
|
|
||||||
void $ installHandler sigHUP (
|
void $ installHandler sigHUP (
|
||||||
Catch . void . P.use pool $ do
|
Catch $ connectionWorker mainTid pool (configSchema conf) refDbStructure refIsWorkerOn
|
||||||
s <- getDbStructure (toS $ configSchema conf)
|
|
||||||
liftIO $ atomicWriteIORef refDbStructure s
|
|
||||||
) Nothing
|
) Nothing
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -99,6 +153,7 @@ main = do
|
|||||||
defaultUpdateSettings { updateAction = getPOSIXTime }
|
defaultUpdateSettings { updateAction = getPOSIXTime }
|
||||||
|
|
||||||
runSettings appSettings $ postgrest conf refDbStructure pool getTime
|
runSettings appSettings $ postgrest conf refDbStructure pool getTime
|
||||||
|
(connectionWorker mainTid pool (configSchema conf) refDbStructure refIsWorkerOn)
|
||||||
|
|
||||||
loadSecretFile :: AppConfig -> IO AppConfig
|
loadSecretFile :: AppConfig -> IO AppConfig
|
||||||
loadSecretFile conf = extractAndTransform mSecret
|
loadSecretFile conf = extractAndTransform mSecret
|
||||||
@@ -124,4 +179,3 @@ loadSecretFile conf = extractAndTransform mSecret
|
|||||||
setSecret bs = conf { configJwtSecret = Just bs }
|
setSecret bs = conf { configJwtSecret = Just bs }
|
||||||
|
|
||||||
replaceUrlChars = replace "_" "/" . replace "-" "+" . replace "." "="
|
replaceUrlChars = replace "_" "/" . replace "-" "+" . replace "." "="
|
||||||
|
|
||||||
|
|||||||
+7
-4
@@ -2,7 +2,7 @@ name: postgrest
|
|||||||
description: Reads the schema of a PostgreSQL database and creates RESTful routes
|
description: Reads the schema of a PostgreSQL database and creates RESTful routes
|
||||||
for the tables and views, supporting all HTTP verbs that security
|
for the tables and views, supporting all HTTP verbs that security
|
||||||
permits.
|
permits.
|
||||||
version: 0.4.0.0
|
version: 0.4.2.0
|
||||||
synopsis: REST API for any Postgres database
|
synopsis: REST API for any Postgres database
|
||||||
license: MIT
|
license: MIT
|
||||||
license-file: LICENSE
|
license-file: LICENSE
|
||||||
@@ -40,6 +40,7 @@ executable postgrest
|
|||||||
, warp
|
, warp
|
||||||
, bytestring
|
, bytestring
|
||||||
, base64-bytestring
|
, base64-bytestring
|
||||||
|
, retry
|
||||||
if !os(windows)
|
if !os(windows)
|
||||||
build-depends: unix
|
build-depends: unix
|
||||||
|
|
||||||
@@ -54,7 +55,7 @@ library
|
|||||||
, bytestring
|
, bytestring
|
||||||
, case-insensitive
|
, case-insensitive
|
||||||
, cassava
|
, cassava
|
||||||
, configurator
|
, configurator-ng == 0.0.0.1
|
||||||
, containers
|
, containers
|
||||||
, contravariant
|
, contravariant
|
||||||
, either
|
, either
|
||||||
@@ -70,7 +71,7 @@ library
|
|||||||
, lens
|
, lens
|
||||||
, lens-aeson
|
, lens-aeson
|
||||||
, network-uri
|
, network-uri
|
||||||
, optparse-applicative >= 0.12.0.0 && < 0.13.0.0
|
, optparse-applicative >= 0.13 && < 0.14
|
||||||
, parsec
|
, parsec
|
||||||
, protolude
|
, protolude
|
||||||
, Ranged-sets == 0.3.0
|
, Ranged-sets == 0.3.0
|
||||||
@@ -86,6 +87,7 @@ library
|
|||||||
, wai-cors
|
, wai-cors
|
||||||
, wai-extra
|
, wai-extra
|
||||||
, wai-middleware-static
|
, wai-middleware-static
|
||||||
|
, cookie
|
||||||
|
|
||||||
Other-Modules: Paths_postgrest
|
Other-Modules: Paths_postgrest
|
||||||
Exposed-Modules: PostgREST.ApiRequest
|
Exposed-Modules: PostgREST.ApiRequest
|
||||||
@@ -124,6 +126,7 @@ Test-Suite spec
|
|||||||
, Feature.SingularSpec
|
, Feature.SingularSpec
|
||||||
, Feature.StructureSpec
|
, Feature.StructureSpec
|
||||||
, Feature.UnicodeSpec
|
, Feature.UnicodeSpec
|
||||||
|
, Feature.AndOrParamsSpec
|
||||||
, SpecHelper
|
, SpecHelper
|
||||||
, TestTypes
|
, TestTypes
|
||||||
Build-Depends: aeson
|
Build-Depends: aeson
|
||||||
@@ -143,7 +146,7 @@ Test-Suite spec
|
|||||||
, hjsonpointer
|
, hjsonpointer
|
||||||
, hjsonschema
|
, hjsonschema
|
||||||
, hspec
|
, hspec
|
||||||
, hspec-wai
|
, hspec-wai >= 0.7.0
|
||||||
, hspec-wai-json
|
, hspec-wai-json
|
||||||
, http-types
|
, http-types
|
||||||
, lens
|
, lens
|
||||||
|
|||||||
+30
-40
@@ -3,19 +3,17 @@ Module : PostgREST.ApiRequest
|
|||||||
Description : PostgREST functions to translate HTTP request to a domain type called ApiRequest.
|
Description : PostgREST functions to translate HTTP request to a domain type called ApiRequest.
|
||||||
-}
|
-}
|
||||||
module PostgREST.ApiRequest ( ApiRequest(..)
|
module PostgREST.ApiRequest ( ApiRequest(..)
|
||||||
, ApiRequestError(..)
|
|
||||||
, ContentType(..)
|
, ContentType(..)
|
||||||
, Action(..)
|
, Action(..)
|
||||||
, Target(..)
|
, Target(..)
|
||||||
, PreferRepresentation (..)
|
, PreferRepresentation (..)
|
||||||
, mutuallyAgreeable
|
, mutuallyAgreeable
|
||||||
, toHeader
|
|
||||||
, userApiRequest
|
, userApiRequest
|
||||||
, toMime
|
|
||||||
) where
|
) where
|
||||||
|
|
||||||
import Protolude
|
import Protolude
|
||||||
import qualified Data.Aeson as JSON
|
import qualified Data.Aeson as JSON
|
||||||
|
import Data.Aeson.Types (emptyObject)
|
||||||
import qualified Data.ByteString as BS
|
import qualified Data.ByteString as BS
|
||||||
import qualified Data.ByteString.Internal as BS (c2w)
|
import qualified Data.ByteString.Internal as BS (c2w)
|
||||||
import qualified Data.ByteString.Lazy as BL
|
import qualified Data.ByteString.Lazy as BL
|
||||||
@@ -29,16 +27,21 @@ import Control.Arrow ((***))
|
|||||||
import qualified Data.Text as T
|
import qualified Data.Text as T
|
||||||
import qualified Data.Vector as V
|
import qualified Data.Vector as V
|
||||||
import Network.HTTP.Base (urlEncodeVars)
|
import Network.HTTP.Base (urlEncodeVars)
|
||||||
import Network.HTTP.Types.Header (hAuthorization, hContentType, Header)
|
import Network.HTTP.Types.Header (hAuthorization, hCookie)
|
||||||
import Network.HTTP.Types.URI (parseSimpleQuery)
|
import Network.HTTP.Types.URI (parseSimpleQuery)
|
||||||
import Network.Wai (Request (..))
|
import Network.Wai (Request (..))
|
||||||
import Network.Wai.Parse (parseHttpAccept)
|
import Network.Wai.Parse (parseHttpAccept)
|
||||||
import PostgREST.RangeQuery (NonnegRange, rangeRequested, restrictRange, rangeGeq, allRange, rangeLimit, rangeOffset)
|
import PostgREST.RangeQuery (NonnegRange, rangeRequested, restrictRange, rangeGeq, allRange, rangeLimit, rangeOffset)
|
||||||
import Data.Ranged.Boundaries
|
import Data.Ranged.Boundaries
|
||||||
import PostgREST.Types (QualifiedIdentifier (..),
|
import PostgREST.Types ( QualifiedIdentifier (..)
|
||||||
Schema,
|
, Schema
|
||||||
PayloadJSON(..))
|
, PayloadJSON(..)
|
||||||
|
, ContentType(..)
|
||||||
|
, ApiRequestError(..)
|
||||||
|
, toMime)
|
||||||
import Data.Ranged.Ranges (Range(..), rangeIntersection, emptyRange)
|
import Data.Ranged.Ranges (Range(..), rangeIntersection, emptyRange)
|
||||||
|
import qualified Data.CaseInsensitive as CI
|
||||||
|
import Web.Cookie (parseCookiesText)
|
||||||
|
|
||||||
type RequestBody = BL.ByteString
|
type RequestBody = BL.ByteString
|
||||||
|
|
||||||
@@ -57,29 +60,6 @@ data Target = TargetIdent QualifiedIdentifier
|
|||||||
-- | How to return the inserted data
|
-- | How to return the inserted data
|
||||||
data PreferRepresentation = Full | HeadersOnly | None deriving Eq
|
data PreferRepresentation = Full | HeadersOnly | None deriving Eq
|
||||||
--
|
--
|
||||||
-- | 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
|
Describes what the user wants to do. This data type is a
|
||||||
translation of the raw elements of an HTTP request into domain
|
translation of the raw elements of an HTTP request into domain
|
||||||
@@ -106,6 +86,8 @@ data ApiRequest = ApiRequest {
|
|||||||
, iPreferCount :: Bool
|
, iPreferCount :: Bool
|
||||||
-- | Filters on the result ("id", "eq.10")
|
-- | Filters on the result ("id", "eq.10")
|
||||||
, iFilters :: [(Text, Text)]
|
, iFilters :: [(Text, Text)]
|
||||||
|
-- | &and and &or parameters used for complex boolean logic
|
||||||
|
, iLogic :: [(Text, Text)]
|
||||||
-- | &select parameter used to shape the response
|
-- | &select parameter used to shape the response
|
||||||
, iSelect :: Text
|
, iSelect :: Text
|
||||||
-- | &order parameters for each level
|
-- | &order parameters for each level
|
||||||
@@ -114,14 +96,18 @@ data ApiRequest = ApiRequest {
|
|||||||
, iCanonicalQS :: ByteString
|
, iCanonicalQS :: ByteString
|
||||||
-- | JSON Web Token
|
-- | JSON Web Token
|
||||||
, iJWT :: Text
|
, iJWT :: Text
|
||||||
|
-- | HTTP request headers
|
||||||
|
, iHeaders :: [(Text, Text)]
|
||||||
|
-- | Request Cookies
|
||||||
|
, iCookies :: [(Text, Text)]
|
||||||
}
|
}
|
||||||
|
|
||||||
-- | Examines HTTP request and translates it into user intent.
|
-- | Examines HTTP request and translates it into user intent.
|
||||||
userApiRequest :: Schema -> Request -> RequestBody -> Either ApiRequestError ApiRequest
|
userApiRequest :: Schema -> Request -> RequestBody -> Either ApiRequestError ApiRequest
|
||||||
userApiRequest schema req reqBody
|
userApiRequest schema req reqBody
|
||||||
| isTargetingProc && method /= "POST" = Left ErrorActionInappropriate
|
| isTargetingProc && method /= "POST" = Left ActionInappropriate
|
||||||
| topLevelRange == emptyRange = Left ErrorInvalidRange
|
| topLevelRange == emptyRange = Left InvalidRange
|
||||||
| shouldParsePayload && isLeft payload = either (Left . ErrorInvalidBody . toS) undefined payload
|
| shouldParsePayload && isLeft payload = either (Left . InvalidBody . toS) undefined payload
|
||||||
| otherwise = Right ApiRequest {
|
| otherwise = Right ApiRequest {
|
||||||
iAction = action
|
iAction = action
|
||||||
, iTarget = target
|
, iTarget = target
|
||||||
@@ -132,7 +118,8 @@ userApiRequest schema req reqBody
|
|||||||
, iPreferRepresentation = representation
|
, iPreferRepresentation = representation
|
||||||
, iPreferSingleObjectParameter = singleObject
|
, iPreferSingleObjectParameter = singleObject
|
||||||
, iPreferCount = hasPrefer "count=exact"
|
, iPreferCount = hasPrefer "count=exact"
|
||||||
, iFilters = [ (toS k, toS $ fromJust v) | (k,v) <- qParams, isJust v, k /= "select", not (endingIn ["order", "limit", "offset"] k) ]
|
, iFilters = [ (toS k, toS $ fromJust v) | (k,v) <- qParams, isJust v, k /= "select", not (endingIn ["order", "limit", "offset", "and", "or"] k) ]
|
||||||
|
, iLogic = [(toS k, toS $ fromJust v) | (k,v) <- qParams, isJust v, endingIn ["and", "or"] k ]
|
||||||
, iSelect = toS $ fromMaybe "*" $ fromMaybe (Just "*") $ lookup "select" qParams
|
, iSelect = toS $ fromMaybe "*" $ fromMaybe (Just "*") $ lookup "select" qParams
|
||||||
, iOrder = [(toS k, toS $ fromJust v) | (k,v) <- qParams, isJust v, endingIn ["order"] k ]
|
, iOrder = [(toS k, toS $ fromJust v) | (k,v) <- qParams, isJust v, endingIn ["order"] k ]
|
||||||
, iCanonicalQS = toS $ urlEncodeVars
|
, iCanonicalQS = toS $ urlEncodeVars
|
||||||
@@ -141,19 +128,21 @@ userApiRequest schema req reqBody
|
|||||||
. parseSimpleQuery
|
. parseSimpleQuery
|
||||||
$ rawQueryString req
|
$ rawQueryString req
|
||||||
, iJWT = tokenStr
|
, iJWT = tokenStr
|
||||||
|
, iHeaders = [ (toS $ CI.foldedCase k, toS v) | (k,v) <- hdrs, k /= hAuthorization, k /= hCookie]
|
||||||
|
, iCookies = fromMaybe [] $ parseCookiesText <$> lookupHeader "Cookie"
|
||||||
}
|
}
|
||||||
where
|
where
|
||||||
isTargetingProc = fromMaybe False $ (== "rpc") <$> listToMaybe path
|
isTargetingProc = fromMaybe False $ (== "rpc") <$> listToMaybe path
|
||||||
payload =
|
payload =
|
||||||
case decodeContentType . fromMaybe "application/json" $ lookupHeader "content-type" of
|
case decodeContentType . fromMaybe "application/json" $ lookupHeader "content-type" of
|
||||||
CTApplicationJSON ->
|
CTApplicationJSON ->
|
||||||
either Left (\val -> case ensureUniform (pluralize val) of
|
note "All object keys must match" . ensureUniform . pluralize
|
||||||
Nothing -> Left "All object keys must match"
|
=<< if BL.null reqBody && isTargetingProc
|
||||||
Just json -> Right json) (JSON.eitherDecode reqBody)
|
then Right emptyObject
|
||||||
|
else JSON.eitherDecode reqBody
|
||||||
CTTextCSV ->
|
CTTextCSV ->
|
||||||
either Left (\val -> case ensureUniform (csvToJson val) of
|
note "All lines must have same number of fields" . ensureUniform . csvToJson
|
||||||
Nothing -> Left "All lines must have same number of fields"
|
=<< CSV.decodeByName reqBody
|
||||||
Just json -> Right json) (CSV.decodeByName reqBody)
|
|
||||||
CTOther "application/x-www-form-urlencoded" ->
|
CTOther "application/x-www-form-urlencoded" ->
|
||||||
Right . PayloadJSON . V.singleton . M.fromList
|
Right . PayloadJSON . V.singleton . M.fromList
|
||||||
. map (toS *** JSON.String . toS) . parseSimpleQuery
|
. map (toS *** JSON.String . toS) . parseSimpleQuery
|
||||||
@@ -247,6 +236,7 @@ decodeContentType ct =
|
|||||||
"application/openapi+json" -> CTOpenAPI
|
"application/openapi+json" -> CTOpenAPI
|
||||||
"application/vnd.pgrst.object+json" -> CTSingularJSON
|
"application/vnd.pgrst.object+json" -> CTSingularJSON
|
||||||
"application/vnd.pgrst.object" -> CTSingularJSON
|
"application/vnd.pgrst.object" -> CTSingularJSON
|
||||||
|
"application/octet-stream" -> CTOctetStream
|
||||||
"*/*" -> CTAny
|
"*/*" -> CTAny
|
||||||
ct' -> CTOther ct'
|
ct' -> CTOther ct'
|
||||||
|
|
||||||
|
|||||||
+84
-43
@@ -8,6 +8,7 @@ module PostgREST.App (
|
|||||||
|
|
||||||
import Control.Applicative
|
import Control.Applicative
|
||||||
import qualified Data.ByteString.Char8 as BS
|
import qualified Data.ByteString.Char8 as BS
|
||||||
|
import Data.Maybe
|
||||||
import Data.IORef (IORef, readIORef)
|
import Data.IORef (IORef, readIORef)
|
||||||
import Data.Text (intercalate)
|
import Data.Text (intercalate)
|
||||||
import Data.Time.Clock.POSIX (POSIXTime)
|
import Data.Time.Clock.POSIX (POSIXTime)
|
||||||
@@ -32,15 +33,20 @@ import PostgREST.ApiRequest ( ApiRequest(..), ContentType(..)
|
|||||||
, Action(..), Target(..)
|
, Action(..), Target(..)
|
||||||
, PreferRepresentation (..)
|
, PreferRepresentation (..)
|
||||||
, mutuallyAgreeable
|
, mutuallyAgreeable
|
||||||
, toHeader
|
|
||||||
, userApiRequest
|
, userApiRequest
|
||||||
, toMime
|
|
||||||
)
|
)
|
||||||
import PostgREST.Auth (jwtClaims, containsRole)
|
import PostgREST.Auth (jwtClaims, containsRole)
|
||||||
import PostgREST.Config (AppConfig (..))
|
import PostgREST.Config (AppConfig (..))
|
||||||
import PostgREST.DbStructure
|
import PostgREST.DbStructure
|
||||||
import PostgREST.DbRequestBuilder(readRequest, mutateRequest)
|
import PostgREST.DbRequestBuilder( readRequest
|
||||||
import PostgREST.Error (errResponse, pgErrResponse, apiRequestErrResponse, singularityError)
|
, mutateRequest
|
||||||
|
, fieldNames
|
||||||
|
)
|
||||||
|
import PostgREST.Error ( simpleError, pgError
|
||||||
|
, apiRequestError
|
||||||
|
, singularityError, binaryFieldError
|
||||||
|
, connectionLostError
|
||||||
|
)
|
||||||
import PostgREST.RangeQuery (allRange, rangeOffset)
|
import PostgREST.RangeQuery (allRange, rangeOffset)
|
||||||
import PostgREST.Middleware
|
import PostgREST.Middleware
|
||||||
import PostgREST.QueryBuilder ( callProc
|
import PostgREST.QueryBuilder ( callProc
|
||||||
@@ -54,34 +60,55 @@ import PostgREST.Types
|
|||||||
import PostgREST.OpenAPI
|
import PostgREST.OpenAPI
|
||||||
|
|
||||||
import Data.Function (id)
|
import Data.Function (id)
|
||||||
import Protolude hiding (intercalate, Proxy)
|
import Protolude hiding (intercalate, Proxy)
|
||||||
|
import Safe (headMay)
|
||||||
|
|
||||||
postgrest :: AppConfig -> IORef DbStructure -> P.Pool -> IO POSIXTime ->
|
postgrest :: AppConfig -> IORef (Maybe DbStructure) -> P.Pool -> IO POSIXTime ->
|
||||||
Application
|
IO () -> Application
|
||||||
postgrest conf refDbStructure pool getTime =
|
postgrest conf refDbStructure pool getTime worker =
|
||||||
let middle = (if configQuiet conf then id else logStdout) . defaultMiddle in
|
let middle = (if configQuiet conf then id else logStdout) . defaultMiddle in
|
||||||
|
|
||||||
middle $ \ req respond -> do
|
middle $ \ req respond -> do
|
||||||
time <- getTime
|
time <- getTime
|
||||||
body <- strictRequestBody req
|
body <- strictRequestBody req
|
||||||
dbStructure <- readIORef refDbStructure
|
maybeDbStructure <- readIORef refDbStructure
|
||||||
|
case maybeDbStructure of
|
||||||
|
Nothing -> respond connectionLostError
|
||||||
|
Just dbStructure -> do
|
||||||
|
response <- case userApiRequest (configSchema conf) req body of
|
||||||
|
Left err -> return $ apiRequestError 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 dbStructure
|
||||||
|
(iTarget apiRequest) (iAction apiRequest)
|
||||||
|
response <- P.use pool $ HT.transaction HT.ReadCommitted txMode handleReq
|
||||||
|
return $ either (pgError authed) identity response
|
||||||
|
when (isResponse503 response) worker
|
||||||
|
respond response
|
||||||
|
|
||||||
response <- case userApiRequest (configSchema conf) req body of
|
isResponse503 :: Response -> Bool
|
||||||
Left err -> return $ apiRequestErrResponse err
|
isResponse503 resp = statusCode (responseStatus resp) == 503
|
||||||
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 :: DbStructure -> Target -> Action -> H.Mode
|
||||||
transactionMode ActionRead = HT.Read
|
transactionMode structure target action =
|
||||||
transactionMode ActionInfo = HT.Read
|
case action of
|
||||||
transactionMode _ = HT.Write
|
ActionRead -> HT.Read
|
||||||
|
ActionInfo -> HT.Read
|
||||||
|
ActionInspect -> HT.Read
|
||||||
|
ActionInvoke ->
|
||||||
|
let proc =
|
||||||
|
case target of
|
||||||
|
(TargetProc qi) -> M.lookup (qiName qi) $
|
||||||
|
dbProcs structure
|
||||||
|
_ -> Nothing
|
||||||
|
v = fromMaybe Volatile $ pdVolatility <$> proc in
|
||||||
|
if v == Stable || v == Immutable
|
||||||
|
then HT.Read
|
||||||
|
else HT.Write
|
||||||
|
_ -> HT.Write
|
||||||
|
|
||||||
app :: DbStructure -> AppConfig -> ApiRequest -> H.Transaction Response
|
app :: DbStructure -> AppConfig -> ApiRequest -> H.Transaction Response
|
||||||
app dbStructure conf apiRequest =
|
app dbStructure conf apiRequest =
|
||||||
@@ -91,10 +118,13 @@ app dbStructure conf apiRequest =
|
|||||||
case (iAction apiRequest, iTarget apiRequest, iPayload apiRequest) of
|
case (iAction apiRequest, iTarget apiRequest, iPayload apiRequest) of
|
||||||
|
|
||||||
(ActionRead, TargetIdent qi, Nothing) ->
|
(ActionRead, TargetIdent qi, Nothing) ->
|
||||||
case readSqlParts of
|
let partsField = (,) <$> readSqlParts
|
||||||
|
<*> (binaryField contentType =<< fldNames) in
|
||||||
|
case partsField of
|
||||||
Left errorResponse -> return errorResponse
|
Left errorResponse -> return errorResponse
|
||||||
Right (q, cq) -> do
|
Right ((q, cq), bField) -> do
|
||||||
let stm = createReadStatement q cq (contentType == CTSingularJSON) shouldCount (contentType == CTTextCSV)
|
let stm = createReadStatement q cq (contentType == CTSingularJSON) shouldCount
|
||||||
|
(contentType == CTTextCSV) bField
|
||||||
row <- H.query () stm
|
row <- H.query () stm
|
||||||
let (tableTotal, queryTotal, _ , body) = row
|
let (tableTotal, queryTotal, _ , body) = row
|
||||||
(status, contentRange) = rangeHeader queryTotal tableTotal
|
(status, contentRange) = rangeHeader queryTotal tableTotal
|
||||||
@@ -142,10 +172,12 @@ app dbStructure conf apiRequest =
|
|||||||
if iPreferRepresentation apiRequest == Full
|
if iPreferRepresentation apiRequest == Full
|
||||||
then toS body else ""
|
then toS body else ""
|
||||||
|
|
||||||
(ActionUpdate, TargetIdent _, Just payload) ->
|
(ActionUpdate, TargetIdent _, Just payload@(PayloadJSON rows)) ->
|
||||||
case mutateSqlParts of
|
case (mutateSqlParts, null <$> rows V.!? 0, iPreferRepresentation apiRequest == Full) of
|
||||||
Left errorResponse -> return errorResponse
|
(Left errorResponse, _, _) -> return errorResponse
|
||||||
Right (sq, mq) -> do
|
(_, Just True, True) -> return $ responseLBS status200 [contentRangeH 1 0 Nothing] "[]"
|
||||||
|
(_, Just True, False) -> return $ responseLBS status204 [contentRangeH 1 0 Nothing] ""
|
||||||
|
(Right (sq, mq), _, _) -> do
|
||||||
let stm = createWriteStatement sq mq
|
let stm = createWriteStatement sq mq
|
||||||
(contentType == CTSingularJSON) False (contentType == CTTextCSV)
|
(contentType == CTSingularJSON) False (contentType == CTTextCSV)
|
||||||
(iPreferRepresentation apiRequest) []
|
(iPreferRepresentation apiRequest) []
|
||||||
@@ -206,7 +238,9 @@ app dbStructure conf apiRequest =
|
|||||||
let p = V.head payload
|
let p = V.head payload
|
||||||
singular = contentType == CTSingularJSON
|
singular = contentType == CTSingularJSON
|
||||||
paramsAsSingleObject = iPreferSingleObjectParameter apiRequest
|
paramsAsSingleObject = iPreferSingleObjectParameter apiRequest
|
||||||
row <- H.query () (callProc qi p q cq topLevelRange shouldCount singular paramsAsSingleObject)
|
row <- H.query () $
|
||||||
|
callProc qi p q cq topLevelRange shouldCount singular
|
||||||
|
paramsAsSingleObject (contentType == CTTextCSV)
|
||||||
let (tableTotal, queryTotal, body) =
|
let (tableTotal, queryTotal, body) =
|
||||||
fromMaybe (Just 0, 0, "[]") row
|
fromMaybe (Just 0, 0, "[]") row
|
||||||
(status, contentRange) = rangeHeader queryTotal tableTotal
|
(status, contentRange) = rangeHeader queryTotal tableTotal
|
||||||
@@ -214,7 +248,7 @@ app dbStructure conf apiRequest =
|
|||||||
then do
|
then do
|
||||||
HT.condemn
|
HT.condemn
|
||||||
return $ singularityError (toInteger queryTotal)
|
return $ singularityError (toInteger queryTotal)
|
||||||
else return $ responseLBS status [jsonH, contentRange] (toS body)
|
else return $ responseLBS status [toHeader contentType, contentRange] (toS body)
|
||||||
|
|
||||||
(ActionInspect, TargetRoot, Nothing) -> do
|
(ActionInspect, TargetRoot, Nothing) -> do
|
||||||
let host = configHost conf
|
let host = configHost conf
|
||||||
@@ -223,7 +257,7 @@ app dbStructure conf apiRequest =
|
|||||||
uri Nothing = ("http", host, port, "/")
|
uri Nothing = ("http", host, port, "/")
|
||||||
uri (Just Proxy { proxyScheme = s, proxyHost = h, proxyPort = p, proxyPath = b }) = (s, h, p, b)
|
uri (Just Proxy { proxyScheme = s, proxyHost = h, proxyPort = p, proxyPath = b }) = (s, h, p, b)
|
||||||
uri' = uri proxy
|
uri' = uri proxy
|
||||||
encodeApi ti = encodeOpenAPI (map snd $ dbProcs dbStructure) ti uri'
|
encodeApi ti = encodeOpenAPI (M.elems $ dbProcs dbStructure) ti uri'
|
||||||
body <- encodeApi . toTableInfo <$> H.query schema accessibleTables
|
body <- encodeApi . toTableInfo <$> H.query schema accessibleTables
|
||||||
return $ responseLBS status200 [toHeader CTOpenAPI] $ toS body
|
return $ responseLBS status200 [toHeader CTOpenAPI] $ toS body
|
||||||
|
|
||||||
@@ -241,10 +275,8 @@ app dbStructure conf apiRequest =
|
|||||||
filterPk sc table pk = sc == (tableSchema . pkTable) pk && table == (tableName . pkTable) pk
|
filterPk sc table pk = sc == (tableSchema . pkTable) pk && table == (tableName . pkTable) pk
|
||||||
filterCol :: Schema -> TableName -> Column -> Bool
|
filterCol :: Schema -> TableName -> Column -> Bool
|
||||||
filterCol sc tb Column{colTable=Table{tableSchema=s, tableName=t}} = s==sc && t==tb
|
filterCol sc tb Column{colTable=Table{tableSchema=s, tableName=t}} = s==sc && t==tb
|
||||||
filterCol _ _ _ = False
|
|
||||||
allPrKeys = dbPrimaryKeys dbStructure
|
allPrKeys = dbPrimaryKeys dbStructure
|
||||||
allOrigins = ("Access-Control-Allow-Origin", "*") :: Header
|
allOrigins = ("Access-Control-Allow-Origin", "*") :: Header
|
||||||
jsonH = toHeader CTApplicationJSON
|
|
||||||
shouldCount = iPreferCount apiRequest
|
shouldCount = iPreferCount apiRequest
|
||||||
schema = toS $ configSchema conf
|
schema = toS $ configSchema conf
|
||||||
topLevelRange = fromMaybe allRange $ M.lookup "limit" $ iRange apiRequest
|
topLevelRange = fromMaybe allRange $ M.lookup "limit" $ iRange apiRequest
|
||||||
@@ -255,10 +287,10 @@ app dbStructure conf apiRequest =
|
|||||||
status = rangeStatus lower upper (toInteger <$> tableTotal)
|
status = rangeStatus lower upper (toInteger <$> tableTotal)
|
||||||
in (status, contentRange)
|
in (status, contentRange)
|
||||||
|
|
||||||
mapSnd f (a, b) = (a, f b)
|
readReq = readRequest (configMaxRows conf) (dbRelations dbStructure) (dbProcs dbStructure) apiRequest
|
||||||
readReq = readRequest (configMaxRows conf) (dbRelations dbStructure) (map (mapSnd pdReturnType) $ dbProcs dbStructure) apiRequest
|
fldNames = fieldNames <$> readReq
|
||||||
readDbRequest = DbRead <$> readReq
|
readDbRequest = DbRead <$> readReq
|
||||||
mutateDbRequest = DbMutate <$> (mutateRequest apiRequest =<< readReq)
|
mutateDbRequest = DbMutate <$> (mutateRequest apiRequest =<< fldNames)
|
||||||
selectQuery = requestToQuery schema False <$> readDbRequest
|
selectQuery = requestToQuery schema False <$> readDbRequest
|
||||||
mutateQuery = requestToQuery schema False <$> mutateDbRequest
|
mutateQuery = requestToQuery schema False <$> mutateDbRequest
|
||||||
countQuery = requestToCountQuery schema <$> readDbRequest
|
countQuery = requestToCountQuery schema <$> readDbRequest
|
||||||
@@ -270,21 +302,30 @@ responseContentTypeOrError accepts action = serves contentTypesForRequest accept
|
|||||||
where
|
where
|
||||||
contentTypesForRequest =
|
contentTypesForRequest =
|
||||||
case action of
|
case action of
|
||||||
ActionRead -> [CTApplicationJSON, CTSingularJSON, CTTextCSV]
|
ActionRead -> [CTApplicationJSON, CTSingularJSON, CTTextCSV, CTOctetStream]
|
||||||
ActionCreate -> [CTApplicationJSON, CTSingularJSON, CTTextCSV]
|
ActionCreate -> [CTApplicationJSON, CTSingularJSON, CTTextCSV]
|
||||||
ActionUpdate -> [CTApplicationJSON, CTSingularJSON, CTTextCSV]
|
ActionUpdate -> [CTApplicationJSON, CTSingularJSON, CTTextCSV]
|
||||||
ActionDelete -> [CTApplicationJSON, CTSingularJSON, CTTextCSV]
|
ActionDelete -> [CTApplicationJSON, CTSingularJSON, CTTextCSV]
|
||||||
ActionInvoke -> [CTApplicationJSON, CTSingularJSON]
|
ActionInvoke -> [CTApplicationJSON, CTSingularJSON, CTTextCSV]
|
||||||
ActionInspect -> [CTOpenAPI]
|
ActionInspect -> [CTOpenAPI, CTApplicationJSON]
|
||||||
ActionInfo -> [CTTextCSV]
|
ActionInfo -> [CTTextCSV]
|
||||||
serves sProduces cAccepts =
|
serves sProduces cAccepts =
|
||||||
case mutuallyAgreeable sProduces cAccepts of
|
case mutuallyAgreeable sProduces cAccepts of
|
||||||
Nothing -> do
|
Nothing -> do
|
||||||
let failed = intercalate ", " $ map (toS . toMime) cAccepts
|
let failed = intercalate ", " $ map (toS . toMime) cAccepts
|
||||||
Left $ errResponse status415 $
|
Left $ simpleError status415 $
|
||||||
"None of these Content-Types are available: " <> failed
|
"None of these Content-Types are available: " <> failed
|
||||||
Just ct -> Right ct
|
Just ct -> Right ct
|
||||||
|
|
||||||
|
binaryField :: ContentType -> [FieldName] -> Either Response (Maybe FieldName)
|
||||||
|
binaryField CTOctetStream fldNames =
|
||||||
|
if length fldNames == 1 && fieldName /= Just "*"
|
||||||
|
then Right fieldName
|
||||||
|
else Left binaryFieldError
|
||||||
|
where
|
||||||
|
fieldName = headMay fldNames
|
||||||
|
binaryField _ _ = Right Nothing
|
||||||
|
|
||||||
splitKeyValue :: BS.ByteString -> (BS.ByteString, BS.ByteString)
|
splitKeyValue :: BS.ByteString -> (BS.ByteString, BS.ByteString)
|
||||||
splitKeyValue kv = (k, BS.tail v)
|
splitKeyValue kv = (k, BS.tail v)
|
||||||
where (k, v) = BS.break (== '=') kv
|
where (k, v) = BS.break (== '=') kv
|
||||||
|
|||||||
+1
-18
@@ -12,8 +12,7 @@ In the test suite there is an example of simple login function that can be used
|
|||||||
very simple authentication system inside the PostgreSQL database.
|
very simple authentication system inside the PostgreSQL database.
|
||||||
-}
|
-}
|
||||||
module PostgREST.Auth (
|
module PostgREST.Auth (
|
||||||
claimsToSQL
|
containsRole
|
||||||
, containsRole
|
|
||||||
, jwtClaims
|
, jwtClaims
|
||||||
, tokenJWT
|
, tokenJWT
|
||||||
, JWTAttempt(..)
|
, JWTAttempt(..)
|
||||||
@@ -28,24 +27,8 @@ import qualified Data.Vector as V
|
|||||||
import qualified Data.HashMap.Strict as M
|
import qualified Data.HashMap.Strict as M
|
||||||
import Data.Maybe (fromJust)
|
import Data.Maybe (fromJust)
|
||||||
import Data.Time.Clock (NominalDiffTime)
|
import Data.Time.Clock (NominalDiffTime)
|
||||||
import PostgREST.QueryBuilder (pgFmtIdent, pgFmtLit, unquoted)
|
|
||||||
import qualified Web.JWT as JWT
|
import qualified Web.JWT as JWT
|
||||||
|
|
||||||
{-|
|
|
||||||
Receives a map of JWT claims and returns a list of PostgreSQL
|
|
||||||
statements to set the claims as user defined GUCs. Except if we
|
|
||||||
have a claim called role, this one is mapped to a SET ROLE
|
|
||||||
statement.
|
|
||||||
-}
|
|
||||||
claimsToSQL :: M.HashMap Text Value -> [ByteString]
|
|
||||||
claimsToSQL claims = roleStmts <> varStmts
|
|
||||||
where
|
|
||||||
roleStmts = maybeToList $
|
|
||||||
(\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 " <> toS (pgFmtIdent $ "request.jwt.claim." <> k)
|
|
||||||
<> " = " <> toS (valueToVariable val) <> ";"
|
|
||||||
valueToVariable = pgFmtLit . unquoted
|
|
||||||
|
|
||||||
{-|
|
{-|
|
||||||
Possible situations encountered with client JWTs
|
Possible situations encountered with client JWTs
|
||||||
|
|||||||
+35
-32
@@ -1,3 +1,4 @@
|
|||||||
|
{-# OPTIONS_GHC -fno-warn-type-defaults #-}
|
||||||
{-|
|
{-|
|
||||||
Module : PostgREST.Config
|
Module : PostgREST.Config
|
||||||
Description : Manages PostgREST configuration options.
|
Description : Manages PostgREST configuration options.
|
||||||
@@ -27,8 +28,11 @@ import qualified Data.ByteString as B
|
|||||||
import qualified Data.ByteString.Char8 as BS
|
import qualified Data.ByteString.Char8 as BS
|
||||||
import qualified Data.CaseInsensitive as CI
|
import qualified Data.CaseInsensitive as CI
|
||||||
import qualified Data.Configurator as C
|
import qualified Data.Configurator as C
|
||||||
import qualified Data.Configurator.Types as C
|
import qualified Data.Configurator.Parser as C
|
||||||
|
import Data.Configurator.Types (Value(..))
|
||||||
import Data.List (lookup)
|
import Data.List (lookup)
|
||||||
|
import Data.Monoid
|
||||||
|
import Data.Scientific (floatingOrInteger)
|
||||||
import Data.Text (strip, intercalate, lines)
|
import Data.Text (strip, intercalate, lines)
|
||||||
import Data.Text.Encoding (encodeUtf8)
|
import Data.Text.Encoding (encodeUtf8)
|
||||||
import Data.Text.IO (hPutStrLn)
|
import Data.Text.IO (hPutStrLn)
|
||||||
@@ -37,11 +41,11 @@ import Network.Wai
|
|||||||
import Network.Wai.Middleware.Cors (CorsResourcePolicy (..))
|
import Network.Wai.Middleware.Cors (CorsResourcePolicy (..))
|
||||||
import Options.Applicative hiding (str)
|
import Options.Applicative hiding (str)
|
||||||
import Paths_postgrest (version)
|
import Paths_postgrest (version)
|
||||||
|
import System.IO (hPrint)
|
||||||
import Text.Heredoc
|
import Text.Heredoc
|
||||||
import Text.PrettyPrint.ANSI.Leijen hiding ((<>), (<$>))
|
import Text.PrettyPrint.ANSI.Leijen hiding ((<>), (<$>))
|
||||||
|
import qualified Text.PrettyPrint.ANSI.Leijen as L
|
||||||
import Protolude hiding (intercalate
|
import Protolude hiding (intercalate, (<>))
|
||||||
, (<>))
|
|
||||||
|
|
||||||
-- | Config file settings for the server
|
-- | Config file settings for the server
|
||||||
data AppConfig = AppConfig {
|
data AppConfig = AppConfig {
|
||||||
@@ -95,30 +99,37 @@ readOptions = do
|
|||||||
cfgPath <- customExecParser parserPrefs opts
|
cfgPath <- customExecParser parserPrefs opts
|
||||||
-- Now read the actual config file
|
-- Now read the actual config file
|
||||||
conf <- catch
|
conf <- catch
|
||||||
(C.load [C.Required cfgPath])
|
(C.readConfig =<< C.load [C.Required cfgPath])
|
||||||
configNotfoundHint
|
configNotfoundHint
|
||||||
|
|
||||||
handle missingKeyHint $ do
|
let (mAppConf, errs) = flip C.runParserA conf $
|
||||||
-- db ----------------
|
AppConfig <$>
|
||||||
cDbUri <- C.require conf "db-uri"
|
C.key "db-uri"
|
||||||
cDbSchema <- C.require conf "db-schema"
|
<*> C.key "db-anon-role"
|
||||||
cDbAnon <- C.require conf "db-anon-role"
|
<*> C.key "server-proxy-uri"
|
||||||
cPool <- C.lookupDefault 10 conf "db-pool"
|
<*> C.key "db-schema"
|
||||||
-- server ------------
|
<*> (fromMaybe "*4" <$> C.key "server-host")
|
||||||
cHost <- C.lookupDefault "*4" conf "server-host"
|
<*> (fromMaybe 3000 . join . fmap coerceInt <$> C.key "server-port")
|
||||||
cPort <- C.lookupDefault 3000 conf "server-port"
|
<*> (fmap encodeUtf8 . mfilter (/= "") <$> C.key "jwt-secret")
|
||||||
cProxy <- C.lookup conf "server-proxy-uri"
|
<*> (fromMaybe False <$> C.key "secret-is-base64")
|
||||||
-- jwt ---------------
|
<*> (fromMaybe 10 . join . fmap coerceInt <$> C.key "db-pool")
|
||||||
cJwtSec <- C.lookup conf "jwt-secret"
|
<*> (join . fmap coerceInt <$> C.key "max-rows")
|
||||||
cJwtB64 <- C.lookupDefault False conf "secret-is-base64"
|
<*> C.key "pre-request"
|
||||||
-- safety ------------
|
<*> pure False
|
||||||
cMaxRows <- C.lookup conf "max-rows"
|
|
||||||
cReqCheck <- C.lookup conf "pre-request"
|
|
||||||
|
|
||||||
return $ AppConfig cDbUri cDbAnon cProxy cDbSchema cHost cPort
|
case mAppConf of
|
||||||
(encodeUtf8 <$> cJwtSec) cJwtB64 cPool cMaxRows cReqCheck False
|
Nothing -> do
|
||||||
|
forM_ errs $ hPrint stderr
|
||||||
|
exitFailure
|
||||||
|
Just appConf ->
|
||||||
|
return appConf
|
||||||
|
|
||||||
where
|
where
|
||||||
|
coerceInt :: (Read i, Integral i) => Value -> Maybe i
|
||||||
|
coerceInt (Number x) = rightToMaybe $ floatingOrInteger x
|
||||||
|
coerceInt (String x) = readMaybe $ toS x
|
||||||
|
coerceInt _ = Nothing
|
||||||
|
|
||||||
opts = info (helper <*> pathParser) $
|
opts = info (helper <*> pathParser) $
|
||||||
fullDesc
|
fullDesc
|
||||||
<> progDesc (
|
<> progDesc (
|
||||||
@@ -128,7 +139,7 @@ readOptions = do
|
|||||||
)
|
)
|
||||||
<> footerDoc (Just $
|
<> footerDoc (Just $
|
||||||
text "Example Config File:"
|
text "Example Config File:"
|
||||||
<> nest 2 (hardline <> exampleCfg)
|
L.<> nest 2 (hardline L.<> exampleCfg)
|
||||||
)
|
)
|
||||||
|
|
||||||
parserPrefs = prefs showHelpOnError
|
parserPrefs = prefs showHelpOnError
|
||||||
@@ -139,13 +150,6 @@ readOptions = do
|
|||||||
"Cannot open config file:\n\t" <> show e
|
"Cannot open config file:\n\t" <> show e
|
||||||
exitFailure
|
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 :: Doc
|
||||||
exampleCfg = vsep . map (text . toS) . lines $
|
exampleCfg = vsep . map (text . toS) . lines $
|
||||||
[str|db-uri = "postgres://user:pass@localhost:5432/dbname"
|
[str|db-uri = "postgres://user:pass@localhost:5432/dbname"
|
||||||
@@ -171,7 +175,6 @@ readOptions = do
|
|||||||
|# pre-request = "stored_proc_name"
|
|# pre-request = "stored_proc_name"
|
||||||
|]
|
|]
|
||||||
|
|
||||||
|
|
||||||
pathParser :: Parser FilePath
|
pathParser :: Parser FilePath
|
||||||
pathParser =
|
pathParser =
|
||||||
strArgument $
|
strArgument $
|
||||||
|
|||||||
@@ -1,32 +1,33 @@
|
|||||||
{-# LANGUAGE FlexibleContexts #-}
|
{-# LANGUAGE FlexibleContexts #-}
|
||||||
|
{-# LANGUAGE DuplicateRecordFields #-}
|
||||||
module PostgREST.DbRequestBuilder (
|
module PostgREST.DbRequestBuilder (
|
||||||
readRequest
|
readRequest
|
||||||
, mutateRequest
|
, mutateRequest
|
||||||
|
, fieldNames
|
||||||
) where
|
) where
|
||||||
|
|
||||||
import Control.Applicative
|
import Control.Applicative
|
||||||
|
import Control.Arrow ((***))
|
||||||
import Control.Lens.Getter (view)
|
import Control.Lens.Getter (view)
|
||||||
import Control.Lens.Tuple (_1)
|
import Control.Lens.Tuple (_1)
|
||||||
import qualified Data.ByteString.Char8 as BS
|
import qualified Data.ByteString.Char8 as BS
|
||||||
import Data.List (delete, lookup)
|
import Data.List (delete)
|
||||||
import Data.Maybe (fromJust)
|
import Data.Maybe (fromJust)
|
||||||
import Data.Text (isInfixOf, dropWhile, drop)
|
import Data.Text (isInfixOf)
|
||||||
import Data.Tree
|
import Data.Tree
|
||||||
import Data.Either.Combinators (mapLeft)
|
import Data.Either.Combinators (mapLeft)
|
||||||
|
|
||||||
import Text.Parsec.Error
|
|
||||||
|
|
||||||
import Network.HTTP.Types.Status
|
|
||||||
import Network.Wai
|
import Network.Wai
|
||||||
|
|
||||||
import Data.Foldable (foldr1)
|
import Data.Foldable (foldr1)
|
||||||
import qualified Data.HashMap.Strict as M
|
import qualified Data.HashMap.Strict as M
|
||||||
|
|
||||||
import PostgREST.ApiRequest ( ApiRequest(..)
|
import PostgREST.ApiRequest ( ApiRequest(..)
|
||||||
|
, PreferRepresentation(..)
|
||||||
, Action(..), Target(..)
|
, Action(..), Target(..)
|
||||||
, PreferRepresentation (..)
|
, PreferRepresentation (..)
|
||||||
)
|
)
|
||||||
import PostgREST.Error (errResponse, formatParserError)
|
import PostgREST.Error (apiRequestError)
|
||||||
import PostgREST.Parsers
|
import PostgREST.Parsers
|
||||||
import PostgREST.RangeQuery (NonnegRange, restrictRange)
|
import PostgREST.RangeQuery (NonnegRange, restrictRange)
|
||||||
import PostgREST.QueryBuilder (getJoinConditions, sourceCTEName)
|
import PostgREST.QueryBuilder (getJoinConditions, sourceCTEName)
|
||||||
@@ -36,31 +37,31 @@ import Protolude hiding (from, dropWhile, drop)
|
|||||||
import Text.Regex.TDFA ((=~))
|
import Text.Regex.TDFA ((=~))
|
||||||
import Unsafe (unsafeHead)
|
import Unsafe (unsafeHead)
|
||||||
|
|
||||||
readRequest :: Maybe Integer -> [Relation] -> [(Text, Text)] -> ApiRequest -> Either Response ReadRequest
|
readRequest :: Maybe Integer -> [Relation] -> M.HashMap Text ProcDescription -> ApiRequest -> Either Response ReadRequest
|
||||||
readRequest maxRows allRels allProcs apiRequest =
|
readRequest maxRows allRels allProcs apiRequest =
|
||||||
mapLeft (errResponse status400) $
|
mapLeft apiRequestError $
|
||||||
treeRestrictRange maxRows =<<
|
treeRestrictRange maxRows =<<
|
||||||
augumentRequestWithJoin schema relations =<<
|
augumentRequestWithJoin schema relations =<<
|
||||||
first formatParserError parseReadRequest
|
parseReadRequest
|
||||||
where
|
where
|
||||||
(schema, rootTableName) = fromJust $ -- Make it safe
|
(schema, rootTableName) = fromJust $ -- Make it safe
|
||||||
let target = iTarget apiRequest in
|
let target = iTarget apiRequest in
|
||||||
case target of
|
case target of
|
||||||
(TargetIdent (QualifiedIdentifier s t) ) -> Just (s, t)
|
(TargetIdent (QualifiedIdentifier s t) ) -> Just (s, t)
|
||||||
(TargetProc (QualifiedIdentifier s p) ) -> Just (s, t)
|
(TargetProc (QualifiedIdentifier s proc) ) -> Just (s, tName)
|
||||||
where
|
where
|
||||||
returnType = fromMaybe "" $ lookup p allProcs
|
retType = pdReturnType <$> M.lookup proc allProcs
|
||||||
-- we are looking for results looking like "SETOF schema.tablename" and want to extract tablename
|
tName = case retType of
|
||||||
t = if "SETOF " `isInfixOf` returnType
|
Just (SetOf (Composite qi)) -> qiName qi
|
||||||
then drop 1 $ dropWhile (/= '.') returnType
|
Just (Single (Composite qi)) -> qiName qi
|
||||||
else p
|
_ -> proc
|
||||||
|
|
||||||
_ -> Nothing
|
_ -> Nothing
|
||||||
|
|
||||||
action :: Action
|
action :: Action
|
||||||
action = iAction apiRequest
|
action = iAction apiRequest
|
||||||
|
|
||||||
parseReadRequest :: Either ParseError ReadRequest
|
parseReadRequest :: Either ApiRequestError ReadRequest
|
||||||
parseReadRequest = addFiltersOrdersRanges apiRequest <*>
|
parseReadRequest = addFiltersOrdersRanges apiRequest <*>
|
||||||
pRequestSelect rootName selStr
|
pRequestSelect rootName selStr
|
||||||
where
|
where
|
||||||
@@ -78,20 +79,18 @@ readRequest maxRows allRels allProcs apiRequest =
|
|||||||
_ -> allRels
|
_ -> allRels
|
||||||
where fakeSourceRelations = mapMaybe (toSourceRelation rootTableName) allRels -- see comment in toSourceRelation
|
where fakeSourceRelations = mapMaybe (toSourceRelation rootTableName) allRels -- see comment in toSourceRelation
|
||||||
|
|
||||||
treeRestrictRange :: Maybe Integer -> ReadRequest -> Either Text ReadRequest
|
treeRestrictRange :: Maybe Integer -> ReadRequest -> Either ApiRequestError ReadRequest
|
||||||
treeRestrictRange maxRows_ request = pure $ nodeRestrictRange maxRows_ `fmap` request
|
treeRestrictRange maxRows_ request = pure $ nodeRestrictRange maxRows_ `fmap` request
|
||||||
where
|
where
|
||||||
nodeRestrictRange :: Maybe Integer -> ReadNode -> ReadNode
|
nodeRestrictRange :: Maybe Integer -> ReadNode -> ReadNode
|
||||||
nodeRestrictRange m (q@Select {range_=r}, i) = (q{range_=restrictRange m r }, i)
|
nodeRestrictRange m (q@Select {range_=r}, i) = (q{range_=restrictRange m r }, i)
|
||||||
|
|
||||||
augumentRequestWithJoin :: Schema -> [Relation] -> ReadRequest -> Either Text ReadRequest
|
augumentRequestWithJoin :: Schema -> [Relation] -> ReadRequest -> Either ApiRequestError ReadRequest
|
||||||
augumentRequestWithJoin schema allRels request =
|
augumentRequestWithJoin schema allRels request =
|
||||||
(first formatRelationError . addRelations schema allRels Nothing) request
|
addRelations schema allRels Nothing request
|
||||||
>>= addJoinConditions schema
|
>>= addJoinConditions schema
|
||||||
where
|
|
||||||
formatRelationError = ("could not find foreign keys between these entities, " <>)
|
|
||||||
|
|
||||||
addRelations :: Schema -> [Relation] -> Maybe ReadRequest -> ReadRequest -> Either Text ReadRequest
|
addRelations :: Schema -> [Relation] -> Maybe ReadRequest -> ReadRequest -> Either ApiRequestError ReadRequest
|
||||||
addRelations schema allRelations parentNode (Node readNode@(query, (name, _, alias)) forest) =
|
addRelations schema allRelations parentNode (Node readNode@(query, (name, _, alias)) forest) =
|
||||||
case parentNode of
|
case parentNode of
|
||||||
(Just (Node (Select{from=[parentNodeTable]}, (_, _, _)) _)) ->
|
(Just (Node (Select{from=[parentNodeTable]}, (_, _, _)) _)) ->
|
||||||
@@ -100,8 +99,8 @@ addRelations schema allRelations parentNode (Node readNode@(query, (name, _, ali
|
|||||||
forest' = updateForest $ hush node'
|
forest' = updateForest $ hush node'
|
||||||
node' = Node <$> readNode' <*> pure forest
|
node' = Node <$> readNode' <*> pure forest
|
||||||
readNode' = addRel readNode <$> rel
|
readNode' = addRel readNode <$> rel
|
||||||
rel :: Either Text Relation
|
rel :: Either ApiRequestError Relation
|
||||||
rel = note ("no relation between " <> parentNodeTable <> " and " <> name)
|
rel = note (NoRelationBetween parentNodeTable name)
|
||||||
$ findRelation schema name parentNodeTable
|
$ findRelation schema name parentNodeTable
|
||||||
|
|
||||||
where
|
where
|
||||||
@@ -153,10 +152,10 @@ addRelations schema allRelations parentNode (Node readNode@(query, (name, _, ali
|
|||||||
t = Table schema name True -- !!! TODO find another way to get the table from the query
|
t = Table schema name True -- !!! TODO find another way to get the table from the query
|
||||||
r = Relation t [] t [] Root Nothing Nothing Nothing
|
r = Relation t [] t [] Root Nothing Nothing Nothing
|
||||||
where
|
where
|
||||||
updateForest :: Maybe ReadRequest -> Either Text [ReadRequest]
|
updateForest :: Maybe ReadRequest -> Either ApiRequestError [ReadRequest]
|
||||||
updateForest n = mapM (addRelations schema allRelations n) forest
|
updateForest n = mapM (addRelations schema allRelations n) forest
|
||||||
|
|
||||||
addJoinConditions :: Schema -> ReadRequest -> Either Text ReadRequest
|
addJoinConditions :: Schema -> ReadRequest -> Either ApiRequestError ReadRequest
|
||||||
addJoinConditions schema (Node nn@(query, (n, r, a)) forest) =
|
addJoinConditions schema (Node nn@(query, (n, r, a)) forest) =
|
||||||
case r of
|
case r of
|
||||||
Just Relation{relType=Root} -> Node nn <$> updatedForest -- this is the root node
|
Just Relation{relType=Root} -> Node nn <$> updatedForest -- this is the root node
|
||||||
@@ -167,54 +166,62 @@ addJoinConditions schema (Node nn@(query, (n, r, a)) forest) =
|
|||||||
where
|
where
|
||||||
query' = addCond query (getJoinConditions rel)
|
query' = addCond query (getJoinConditions rel)
|
||||||
qq = query'{from=tableName linkTable : from query'}
|
qq = query'{from=tableName linkTable : from query'}
|
||||||
_ -> Left "unknown relation"
|
_ -> Left UnknownRelation
|
||||||
where
|
where
|
||||||
updatedForest = mapM (addJoinConditions schema) forest
|
updatedForest = mapM (addJoinConditions schema) forest
|
||||||
addCond query' con = query'{flt_=con ++ flt_ query'}
|
addCond query' con = query'{flt_=con ++ flt_ query'}
|
||||||
|
|
||||||
addFiltersOrdersRanges :: ApiRequest -> Either ParseError (ReadRequest -> ReadRequest)
|
addFiltersOrdersRanges :: ApiRequest -> Either ApiRequestError (ReadRequest -> ReadRequest)
|
||||||
addFiltersOrdersRanges apiRequest = foldr1 (liftA2 (.)) [
|
addFiltersOrdersRanges apiRequest = foldr1 (liftA2 (.)) [
|
||||||
flip (foldr addFilter) <$> filters,
|
flip (foldr addFilter) <$> filters,
|
||||||
flip (foldr addOrder) <$> orders,
|
flip (foldr addOrder) <$> orders,
|
||||||
flip (foldr addRange) <$> ranges
|
flip (foldr addRange) <$> ranges,
|
||||||
|
flip (foldr addLogicTree) <$> logicForest
|
||||||
]
|
]
|
||||||
{-
|
{-
|
||||||
The esence of what is going on above is that we are composing tree functions
|
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
|
of type (ReadRequest->ReadRequest) that are in (Either ParseError a) context
|
||||||
-}
|
-}
|
||||||
where
|
where
|
||||||
filters :: Either ParseError [(Path, Filter)]
|
filters :: Either ApiRequestError [(EmbedPath, Filter)]
|
||||||
filters = mapM pRequestFilter flts
|
filters = mapM pRequestFilter flts
|
||||||
where
|
logicForest :: Either ApiRequestError [(EmbedPath, LogicTree)]
|
||||||
action = iAction apiRequest
|
logicForest = mapM pRequestLogicTree logFrst
|
||||||
flts
|
action = iAction apiRequest
|
||||||
| action == ActionRead = iFilters apiRequest
|
-- there can be no filters on the root table when we are doing insert/update/delete
|
||||||
| action == ActionInvoke = iFilters apiRequest
|
(flts, logFrst)
|
||||||
| otherwise = filter (( "." `isInfixOf` ) . fst) $ iFilters apiRequest -- there can be no filters on the root table whre we are doing insert/update
|
| action == ActionRead || action == ActionInvoke = (iFilters apiRequest, iLogic apiRequest)
|
||||||
orders :: Either ParseError [(Path, [OrderTerm])]
|
| otherwise = join (***) (filter (( "." `isInfixOf` ) . fst)) (iFilters apiRequest, iLogic apiRequest)
|
||||||
|
orders :: Either ApiRequestError [(EmbedPath, [OrderTerm])]
|
||||||
orders = mapM pRequestOrder $ iOrder apiRequest
|
orders = mapM pRequestOrder $ iOrder apiRequest
|
||||||
ranges :: Either ParseError [(Path, NonnegRange)]
|
ranges :: Either ApiRequestError [(EmbedPath, NonnegRange)]
|
||||||
ranges = mapM pRequestRange $ M.toList $ iRange apiRequest
|
ranges = mapM pRequestRange $ M.toList $ iRange apiRequest
|
||||||
|
|
||||||
addFilterToNode :: Filter -> ReadRequest -> ReadRequest
|
addFilterToNode :: Filter -> ReadRequest -> ReadRequest
|
||||||
addFilterToNode flt (Node (q@Select {flt_=flts}, i) f) = Node (q {flt_=flt:flts}, i) f
|
addFilterToNode flt (Node (q@Select {flt_=flts}, i) f) = Node (q {flt_=flt:flts}, i) f
|
||||||
|
|
||||||
addFilter :: (Path, Filter) -> ReadRequest -> ReadRequest
|
addFilter :: (EmbedPath, Filter) -> ReadRequest -> ReadRequest
|
||||||
addFilter = addProperty addFilterToNode
|
addFilter = addProperty addFilterToNode
|
||||||
|
|
||||||
addOrderToNode :: [OrderTerm] -> ReadRequest -> ReadRequest
|
addOrderToNode :: [OrderTerm] -> ReadRequest -> ReadRequest
|
||||||
addOrderToNode o (Node (q,i) f) = Node (q{order=Just o}, i) f
|
addOrderToNode o (Node (q,i) f) = Node (q{order=Just o}, i) f
|
||||||
|
|
||||||
addOrder :: (Path, [OrderTerm]) -> ReadRequest -> ReadRequest
|
addOrder :: (EmbedPath, [OrderTerm]) -> ReadRequest -> ReadRequest
|
||||||
addOrder = addProperty addOrderToNode
|
addOrder = addProperty addOrderToNode
|
||||||
|
|
||||||
addRangeToNode :: NonnegRange -> ReadRequest -> ReadRequest
|
addRangeToNode :: NonnegRange -> ReadRequest -> ReadRequest
|
||||||
addRangeToNode r (Node (q,i) f) = Node (q{range_=r}, i) f
|
addRangeToNode r (Node (q,i) f) = Node (q{range_=r}, i) f
|
||||||
|
|
||||||
addRange :: (Path, NonnegRange) -> ReadRequest -> ReadRequest
|
addRange :: (EmbedPath, NonnegRange) -> ReadRequest -> ReadRequest
|
||||||
addRange = addProperty addRangeToNode
|
addRange = addProperty addRangeToNode
|
||||||
|
|
||||||
addProperty :: (a -> ReadRequest -> ReadRequest) -> (Path, a) -> ReadRequest -> ReadRequest
|
addLogicTreeToNode :: LogicTree -> ReadRequest -> ReadRequest
|
||||||
|
addLogicTreeToNode t (Node (q@Select{logic=l},i) f) = Node (q{logic=t:l}::ReadQuery, i) f
|
||||||
|
|
||||||
|
addLogicTree :: (EmbedPath, LogicTree) -> ReadRequest -> ReadRequest
|
||||||
|
addLogicTree = addProperty addLogicTreeToNode
|
||||||
|
|
||||||
|
addProperty :: (a -> ReadRequest -> ReadRequest) -> (EmbedPath, a) -> ReadRequest -> ReadRequest
|
||||||
addProperty f ([], a) n = f a n
|
addProperty f ([], a) n = f a n
|
||||||
addProperty f (path, a) (Node rn forest) =
|
addProperty f (path, a) (Node rn forest) =
|
||||||
case targetNode of
|
case targetNode of
|
||||||
@@ -247,13 +254,13 @@ toSourceRelation mt r@(Relation t _ ft _ _ rt _ _)
|
|||||||
| Just mt == (tableName <$> rt) = Just $ r {relLTable=(\tbl -> tbl {tableName=sourceCTEName}) <$> rt}
|
| Just mt == (tableName <$> rt) = Just $ r {relLTable=(\tbl -> tbl {tableName=sourceCTEName}) <$> rt}
|
||||||
| otherwise = Nothing
|
| otherwise = Nothing
|
||||||
|
|
||||||
mutateRequest :: ApiRequest -> ReadRequest -> Either Response MutateRequest
|
mutateRequest :: ApiRequest -> [FieldName] -> Either Response MutateRequest
|
||||||
mutateRequest apiRequest readReq = mapLeft (errResponse status400) $
|
mutateRequest apiRequest fldNames = mapLeft apiRequestError $
|
||||||
case action of
|
case action of
|
||||||
ActionCreate -> Right $ Insert rootTableName payload returnings
|
ActionCreate -> Right $ Insert rootTableName payload returnings
|
||||||
ActionUpdate -> Update rootTableName <$> pure payload <*> filters <*> pure returnings
|
ActionUpdate -> Update rootTableName <$> pure payload <*> filters <*> logic_ <*> pure returnings
|
||||||
ActionDelete -> Delete rootTableName <$> filters <*> pure returnings
|
ActionDelete -> Delete rootTableName <$> filters <*> logic_ <*> pure returnings
|
||||||
_ -> Left "Unsupported HTTP verb"
|
_ -> Left UnsupportedVerb
|
||||||
where
|
where
|
||||||
action = iAction apiRequest
|
action = iAction apiRequest
|
||||||
payload = fromJust $ iPayload apiRequest
|
payload = fromJust $ iPayload apiRequest
|
||||||
@@ -262,14 +269,18 @@ mutateRequest apiRequest readReq = mapLeft (errResponse status400) $
|
|||||||
case target of
|
case target of
|
||||||
(TargetIdent (QualifiedIdentifier _ t) ) -> t
|
(TargetIdent (QualifiedIdentifier _ t) ) -> t
|
||||||
_ -> undefined
|
_ -> undefined
|
||||||
fieldNames :: ReadRequest -> PreferRepresentation -> [FieldName]
|
returnings = if iPreferRepresentation apiRequest == None then [] else fldNames
|
||||||
fieldNames _ None = []
|
filters = map snd <$> mapM pRequestFilter mutateFilters
|
||||||
fieldNames (Node (sel, _) forest) _ =
|
logic_ = map snd <$> mapM pRequestLogicTree logicFilters
|
||||||
map (fst . view _1) (select sel) ++ map colName fks
|
-- update/delete filters can be only on the root table
|
||||||
where
|
mutateFilters = onlyRoot $ iFilters apiRequest
|
||||||
fks = concatMap (fromMaybe [] . f) forest
|
logicFilters = onlyRoot $ iLogic apiRequest
|
||||||
f (Node (_, (_, Just Relation{relFColumns=cols, relType=Parent}, _)) _) = Just cols
|
onlyRoot = filter (not . ( "." `isInfixOf` ) . fst)
|
||||||
f _ = Nothing
|
|
||||||
returnings = fieldNames readReq (iPreferRepresentation apiRequest)
|
fieldNames :: ReadRequest -> [FieldName]
|
||||||
filters = first formatParserError $ map snd <$> mapM pRequestFilter mutateFilters
|
fieldNames (Node (sel, _) forest) =
|
||||||
where mutateFilters = filter (not . ( "." `isInfixOf` ) . fst) $ iFilters apiRequest -- update/delete filters can be only on the root table
|
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
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import qualified Hasql.Encoders as HE
|
|||||||
import qualified Hasql.Query as H
|
import qualified Hasql.Query as H
|
||||||
|
|
||||||
import Control.Applicative
|
import Control.Applicative
|
||||||
|
import qualified Data.HashMap.Strict as M
|
||||||
import Data.List (elemIndex)
|
import Data.List (elemIndex)
|
||||||
import Data.Maybe (fromJust)
|
import Data.Maybe (fromJust)
|
||||||
import Data.Text (split, strip,
|
import Data.Text (split, strip,
|
||||||
@@ -96,12 +97,22 @@ decodeSynonyms cols =
|
|||||||
<*> HD.value HD.text <*> HD.value HD.text
|
<*> HD.value HD.text <*> HD.value HD.text
|
||||||
<*> HD.value HD.text <*> HD.value HD.text
|
<*> HD.value HD.text <*> HD.value HD.text
|
||||||
|
|
||||||
accessibleProcs :: H.Query Schema [(Text, ProcDescription)]
|
accessibleProcs :: H.Query Schema (M.HashMap Text ProcDescription)
|
||||||
accessibleProcs =
|
accessibleProcs =
|
||||||
H.statement sql (HE.value HE.text)
|
H.statement sql (HE.value HE.text)
|
||||||
(map addName <$> HD.rowsList (ProcDescription <$> HD.value HD.text
|
(M.fromList . map addName <$>
|
||||||
<*> (parseArgs <$> HD.value HD.text)
|
HD.rowsList (
|
||||||
<*> HD.value HD.text)) True
|
ProcDescription <$> HD.value HD.text
|
||||||
|
<*> (parseArgs <$> HD.value HD.text)
|
||||||
|
<*> (parseRetType <$>
|
||||||
|
HD.value HD.text <*>
|
||||||
|
HD.value HD.text <*>
|
||||||
|
HD.value HD.bool <*>
|
||||||
|
HD.value HD.char)
|
||||||
|
<*> (parseVolatility <$>
|
||||||
|
HD.value HD.char)
|
||||||
|
)
|
||||||
|
) True
|
||||||
where
|
where
|
||||||
addName :: ProcDescription -> (Text, ProcDescription)
|
addName :: ProcDescription -> (Text, ProcDescription)
|
||||||
addName pd = (pdName pd, pd)
|
addName pd = (pdName pd, pd)
|
||||||
@@ -118,14 +129,37 @@ accessibleProcs =
|
|||||||
else Just $
|
else Just $
|
||||||
PgArg (dropAround (== '"') name) (strip typ) (T.null def)
|
PgArg (dropAround (== '"') name) (strip typ) (T.null def)
|
||||||
|
|
||||||
|
parseRetType :: Text -> Text -> Bool -> Char -> RetType
|
||||||
|
parseRetType schema name isSetOf typ
|
||||||
|
| isSetOf = SetOf pgType
|
||||||
|
| otherwise = Single pgType
|
||||||
|
where
|
||||||
|
qi = QualifiedIdentifier schema name
|
||||||
|
pgType = case typ of
|
||||||
|
'c' -> Composite qi
|
||||||
|
'p' -> Pseudo name
|
||||||
|
_ -> Scalar qi -- 'b'ase, 'd'omain, 'e'num, 'r'ange
|
||||||
|
|
||||||
|
parseVolatility :: Char -> ProcVolatility
|
||||||
|
parseVolatility 'i' = Immutable
|
||||||
|
parseVolatility 's' = Stable
|
||||||
|
parseVolatility 'v' = Volatile
|
||||||
|
parseVolatility _ = Volatile -- should not happen, but be pessimistic
|
||||||
|
|
||||||
sql = [q|
|
sql = [q|
|
||||||
SELECT p.proname as "proc_name",
|
SELECT p.proname as "proc_name",
|
||||||
pg_get_function_arguments(p.oid) as "args",
|
pg_get_function_arguments(p.oid) as "args",
|
||||||
pg_get_function_result(p.oid) as "return_type"
|
tn.nspname as "rettype_schema",
|
||||||
FROM pg_namespace n
|
coalesce(comp.relname, t.typname) as "rettype_name",
|
||||||
JOIN pg_proc p
|
p.proretset as "rettype_is_setof",
|
||||||
ON pronamespace = n.oid
|
t.typtype as "rettype_typ",
|
||||||
WHERE n.nspname = $1|]
|
p.provolatile
|
||||||
|
FROM pg_proc p
|
||||||
|
JOIN pg_namespace pn ON pn.oid = p.pronamespace
|
||||||
|
JOIN pg_type t ON t.oid = p.prorettype
|
||||||
|
JOIN pg_namespace tn ON tn.oid = t.typnamespace
|
||||||
|
LEFT JOIN pg_class comp ON comp.oid = t.typrelid
|
||||||
|
WHERE pn.nspname = $1|]
|
||||||
|
|
||||||
accessibleTables :: H.Query Schema [Table]
|
accessibleTables :: H.Query Schema [Table]
|
||||||
accessibleTables =
|
accessibleTables =
|
||||||
@@ -606,7 +640,7 @@ allSynonyms cols =
|
|||||||
select case when match is not null then coalesce(match[8], match[7], match[4]) end
|
select case when match is not null then coalesce(match[8], match[7], match[4]) end
|
||||||
from regexp_matches(
|
from regexp_matches(
|
||||||
CONCAT('SELECT ', SPLIT_PART(vcu.view_definition, 'SELECT', 2)),
|
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)'),
|
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'
|
'nsi'
|
||||||
) match
|
) match
|
||||||
) as view_column_name
|
) as view_column_name
|
||||||
|
|||||||
+65
-33
@@ -2,46 +2,56 @@
|
|||||||
{-# LANGUAGE FlexibleInstances #-}
|
{-# LANGUAGE FlexibleInstances #-}
|
||||||
{-# LANGUAGE TypeSynonymInstances #-}
|
{-# LANGUAGE TypeSynonymInstances #-}
|
||||||
|
|
||||||
module PostgREST.Error (apiRequestErrResponse, pgErrResponse, errResponse, prettyUsageError, singularityError, formatGeneralError, formatParserError) where
|
module PostgREST.Error (
|
||||||
|
apiRequestError
|
||||||
|
, pgError
|
||||||
|
, simpleError
|
||||||
|
, singularityError
|
||||||
|
, binaryFieldError
|
||||||
|
, connectionLostError
|
||||||
|
, encodeError
|
||||||
|
) where
|
||||||
|
|
||||||
import Protolude
|
import Protolude
|
||||||
import Data.Aeson ((.=))
|
import Data.Aeson ((.=))
|
||||||
import qualified Data.Aeson as JSON
|
import qualified Data.Aeson as JSON
|
||||||
import Data.Text (replace, strip, unwords)
|
import Data.Text (unwords)
|
||||||
import qualified Hasql.Pool as P
|
import qualified Hasql.Pool as P
|
||||||
import qualified Hasql.Session as H
|
import qualified Hasql.Session as H
|
||||||
import qualified Network.HTTP.Types.Status as HT
|
import qualified Network.HTTP.Types.Status as HT
|
||||||
import Network.Wai (Response, responseLBS)
|
import Network.Wai (Response, responseLBS)
|
||||||
import PostgREST.ApiRequest (toHeader, toMime, ContentType(..), ApiRequestError(..))
|
import PostgREST.Types
|
||||||
import Text.Parsec.Error
|
|
||||||
|
|
||||||
apiRequestErrResponse :: ApiRequestError -> Response
|
apiRequestError :: ApiRequestError -> Response
|
||||||
apiRequestErrResponse err =
|
apiRequestError err = errorResponse status err
|
||||||
case err of
|
where
|
||||||
ErrorActionInappropriate -> errResponse HT.status405 "Bad Request"
|
status =
|
||||||
ErrorInvalidBody errorMessage -> errResponse HT.status400 $ toS errorMessage
|
case err of
|
||||||
ErrorInvalidRange -> errResponse HT.status416 "HTTP Range error"
|
ActionInappropriate -> HT.status405
|
||||||
|
UnsupportedVerb -> HT.status405
|
||||||
|
InvalidBody _ -> HT.status400
|
||||||
|
ParseRequestError _ _ -> HT.status400
|
||||||
|
NoRelationBetween _ _ -> HT.status400
|
||||||
|
InvalidRange -> HT.status416
|
||||||
|
UnknownRelation -> HT.status404
|
||||||
|
|
||||||
errResponse :: HT.Status -> Text -> Response
|
simpleError :: HT.Status -> Text -> Response
|
||||||
errResponse status message = jsonErrResponse status $ JSON.object ["message" .= message]
|
simpleError status message =
|
||||||
|
errorResponse status $ JSON.object ["message" .= message]
|
||||||
|
|
||||||
jsonErrResponse :: HT.Status -> JSON.Value -> Response
|
errorResponse :: JSON.ToJSON a => HT.Status -> a -> Response
|
||||||
jsonErrResponse status message = responseLBS status [toHeader CTApplicationJSON] $ JSON.encode message
|
errorResponse status e =
|
||||||
|
responseLBS status [toHeader CTApplicationJSON] $ encodeError e
|
||||||
|
|
||||||
pgErrResponse :: Bool -> P.UsageError -> Response
|
pgError :: Bool -> P.UsageError -> Response
|
||||||
pgErrResponse authed e =
|
pgError authed e =
|
||||||
let status = httpStatus authed e
|
let status = httpStatus authed e
|
||||||
jsonType = toHeader CTApplicationJSON
|
jsonType = toHeader CTApplicationJSON
|
||||||
wwwAuth = ("WWW-Authenticate", "Bearer")
|
wwwAuth = ("WWW-Authenticate", "Bearer")
|
||||||
hdrs = if status == HT.status401
|
hdrs = if status == HT.status401
|
||||||
then [jsonType, wwwAuth]
|
then [jsonType, wwwAuth]
|
||||||
else [jsonType] in
|
else [jsonType] in
|
||||||
responseLBS status hdrs (JSON.encode e)
|
responseLBS status hdrs (encodeError 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 :: Integer -> Response
|
||||||
singularityError numRows =
|
singularityError numRows =
|
||||||
@@ -53,22 +63,44 @@ singularityError numRows =
|
|||||||
[ "Results contain", show numRows, "rows,"
|
[ "Results contain", show numRows, "rows,"
|
||||||
, toS (toMime CTSingularJSON), "requires 1 row"
|
, toS (toMime CTSingularJSON), "requires 1 row"
|
||||||
]
|
]
|
||||||
|
|
||||||
formatParserError :: ParseError -> Text
|
|
||||||
formatParserError e = formatGeneralError message details
|
|
||||||
where
|
where
|
||||||
message = show $ errorPos e
|
formatGeneralError :: Text -> Text -> Text
|
||||||
details = strip $ replace "\n" " " $ toS
|
formatGeneralError message details = toS . JSON.encode $
|
||||||
$ showErrorMessages "or" "unknown parse error" "expecting" "unexpected" "end of input" (errorMessages e)
|
JSON.object ["message" .= message, "details" .= details]
|
||||||
|
|
||||||
formatGeneralError :: Text -> Text -> Text
|
|
||||||
formatGeneralError message details = toS . JSON.encode $
|
binaryFieldError :: Response
|
||||||
JSON.object ["message" .= message, "details" .= details]
|
binaryFieldError =
|
||||||
|
simpleError HT.status406 (toS (toMime CTOctetStream) <>
|
||||||
|
" requested but a single column was not selected")
|
||||||
|
|
||||||
|
connectionLostError :: Response
|
||||||
|
connectionLostError =
|
||||||
|
simpleError HT.status503 "Database connection lost, retrying the connection."
|
||||||
|
|
||||||
|
encodeError :: JSON.ToJSON a => a -> LByteString
|
||||||
|
encodeError = JSON.encode
|
||||||
|
|
||||||
|
instance JSON.ToJSON ApiRequestError where
|
||||||
|
toJSON (ParseRequestError message details) = JSON.object [
|
||||||
|
"message" .= message, "details" .= details]
|
||||||
|
toJSON ActionInappropriate = JSON.object [
|
||||||
|
"message" .= ("Bad Request" :: Text)]
|
||||||
|
toJSON (InvalidBody errorMessage) = JSON.object [
|
||||||
|
"message" .= (toS errorMessage :: Text)]
|
||||||
|
toJSON InvalidRange = JSON.object [
|
||||||
|
"message" .= ("HTTP Range error" :: Text)]
|
||||||
|
toJSON UnknownRelation = JSON.object [
|
||||||
|
"message" .= ("Unknown relation" :: Text)]
|
||||||
|
toJSON (NoRelationBetween parent child) = JSON.object [
|
||||||
|
"message" .= ("Could not find foreign keys between these entities, No relation found between " <> parent <> " and " <> child :: Text)]
|
||||||
|
toJSON UnsupportedVerb = JSON.object [
|
||||||
|
"message" .= ("Unsupported HTTP verb" :: Text)]
|
||||||
|
|
||||||
instance JSON.ToJSON P.UsageError where
|
instance JSON.ToJSON P.UsageError where
|
||||||
toJSON (P.ConnectionError e) = JSON.object [
|
toJSON (P.ConnectionError e) = JSON.object [
|
||||||
"code" .= ("" :: Text),
|
"code" .= ("" :: Text),
|
||||||
"message" .= ("Connection error" :: Text),
|
"message" .= ("Database connection error" :: Text),
|
||||||
"details" .= (toS $ fromMaybe "" e :: Text)]
|
"details" .= (toS $ fromMaybe "" e :: Text)]
|
||||||
toJSON (P.SessionError e) = JSON.toJSON e -- H.Error
|
toJSON (P.SessionError e) = JSON.toJSON e -- H.Error
|
||||||
|
|
||||||
@@ -101,7 +133,7 @@ instance JSON.ToJSON H.Error where
|
|||||||
"details" .= (fmap toS d::Maybe Text)]
|
"details" .= (fmap toS d::Maybe Text)]
|
||||||
|
|
||||||
httpStatus :: Bool -> P.UsageError -> HT.Status
|
httpStatus :: Bool -> P.UsageError -> HT.Status
|
||||||
httpStatus _ (P.ConnectionError _) = HT.status500
|
httpStatus _ (P.ConnectionError _) = HT.status503
|
||||||
httpStatus authed (P.SessionError (H.ResultError (H.ServerError c _ _ _))) =
|
httpStatus authed (P.SessionError (H.ResultError (H.ServerError c _ _ _))) =
|
||||||
case toS c of
|
case toS c of
|
||||||
'0':'8':_ -> HT.status503 -- pg connection err
|
'0':'8':_ -> HT.status503 -- pg connection err
|
||||||
|
|||||||
+18
-10
@@ -1,5 +1,6 @@
|
|||||||
{-# OPTIONS_GHC -fno-warn-orphans #-}
|
{-# OPTIONS_GHC -fno-warn-orphans #-}
|
||||||
{-# LANGUAGE ScopedTypeVariables #-}
|
{-# LANGUAGE ScopedTypeVariables #-}
|
||||||
|
{-# LANGUAGE FlexibleContexts #-}
|
||||||
|
|
||||||
module PostgREST.Middleware where
|
module PostgREST.Middleware where
|
||||||
|
|
||||||
@@ -14,11 +15,12 @@ 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.ApiRequest (ApiRequest(..), ContentType(..),
|
import PostgREST.ApiRequest (ApiRequest(..))
|
||||||
toHeader)
|
import PostgREST.Auth (JWTAttempt(..))
|
||||||
import PostgREST.Auth (claimsToSQL, JWTAttempt(..))
|
|
||||||
import PostgREST.Config (AppConfig (..), corsPolicy)
|
import PostgREST.Config (AppConfig (..), corsPolicy)
|
||||||
import PostgREST.Error (errResponse)
|
import PostgREST.Error (simpleError)
|
||||||
|
import PostgREST.Types (ContentType (..), toHeader)
|
||||||
|
import PostgREST.QueryBuilder (pgFmtLit, unquoted, pgFmtEnvVar)
|
||||||
|
|
||||||
import Protolude hiding (concat, null)
|
import Protolude hiding (concat, null)
|
||||||
|
|
||||||
@@ -29,16 +31,22 @@ runWithClaims conf eClaims app req =
|
|||||||
case eClaims of
|
case eClaims of
|
||||||
JWTExpired -> return $ unauthed "JWT expired"
|
JWTExpired -> return $ unauthed "JWT expired"
|
||||||
JWTInvalid -> return $ unauthed "JWT invalid"
|
JWTInvalid -> return $ unauthed "JWT invalid"
|
||||||
JWTMissingSecret -> return $ errResponse status500 "Server lacks JWT secret"
|
JWTMissingSecret -> return $ simpleError status500 "Server lacks JWT secret"
|
||||||
JWTClaims claims -> do
|
JWTClaims claims -> do
|
||||||
-- role claim defaults to anon if not specified in jwt
|
H.sql $ toS.mconcat $ setRoleSql ++ claimsSql ++ headersSql ++ cookiesSql
|
||||||
let setClaims = claimsToSQL (M.union claims (M.singleton "role" anon))
|
|
||||||
H.sql $ mconcat setClaims
|
|
||||||
mapM_ H.sql customReqCheck
|
mapM_ H.sql customReqCheck
|
||||||
app req
|
app req
|
||||||
|
where
|
||||||
|
headersSql = map (pgFmtEnvVar "request.header.") $ iHeaders req
|
||||||
|
cookiesSql = map (pgFmtEnvVar "request.cookie.") $ iCookies req
|
||||||
|
claimsSql = map (pgFmtEnvVar "request.jwt.claim.") [(c,unquoted v) | (c,v) <- M.toList claimsWithRole]
|
||||||
|
setRoleSql = maybeToList $
|
||||||
|
(\r -> "set local role " <> r <> ";") . toS . pgFmtLit . unquoted <$> M.lookup "role" claimsWithRole
|
||||||
|
-- role claim defaults to anon if not specified in jwt
|
||||||
|
claimsWithRole = M.union claims (M.singleton "role" anon)
|
||||||
|
anon = String . toS $ configAnonRole conf
|
||||||
|
customReqCheck = (\f -> "select " <> toS f <> "();") <$> configReqCheck conf
|
||||||
where
|
where
|
||||||
anon = String . toS $ configAnonRole conf
|
|
||||||
customReqCheck = (\f -> "select " <> toS f <> "();") <$> configReqCheck conf
|
|
||||||
unauthed message = responseLBS unauthorized401
|
unauthed message = responseLBS unauthorized401
|
||||||
[ toHeader CTApplicationJSON
|
[ toHeader CTApplicationJSON
|
||||||
, ( "WWW-Authenticate"
|
, ( "WWW-Authenticate"
|
||||||
|
|||||||
@@ -8,11 +8,12 @@ module PostgREST.OpenAPI (
|
|||||||
|
|
||||||
import Control.Lens
|
import Control.Lens
|
||||||
import Data.Aeson (decode, encode)
|
import Data.Aeson (decode, encode)
|
||||||
|
import qualified Data.HashMap.Strict as M
|
||||||
import Data.HashMap.Strict.InsOrd (InsOrdHashMap, fromList)
|
import Data.HashMap.Strict.InsOrd (InsOrdHashMap, fromList)
|
||||||
import Data.Maybe (fromJust)
|
import Data.Maybe (fromJust)
|
||||||
|
import qualified Data.Set as Set
|
||||||
import Data.String (IsString (..))
|
import Data.String (IsString (..))
|
||||||
import Data.Text (unpack, pack, concat, intercalate, init, tail, toLower)
|
import Data.Text (unpack, pack, concat, intercalate, init, tail, toLower)
|
||||||
import qualified Data.Set as Set
|
|
||||||
import Network.URI (parseURI, isAbsoluteURI,
|
import Network.URI (parseURI, isAbsoluteURI,
|
||||||
URI (..), URIAuth (..))
|
URI (..), URIAuth (..))
|
||||||
|
|
||||||
@@ -20,11 +21,10 @@ import Protolude hiding (concat, (&), Proxy, get, interca
|
|||||||
|
|
||||||
import Data.Swagger
|
import Data.Swagger
|
||||||
|
|
||||||
import PostgREST.ApiRequest (ContentType(..), toMime)
|
import PostgREST.ApiRequest (ContentType(..))
|
||||||
import PostgREST.Config (prettyVersion)
|
import PostgREST.Config (prettyVersion)
|
||||||
import PostgREST.QueryBuilder (operators)
|
|
||||||
import PostgREST.Types (Table(..), Column(..), PgArg(..),
|
import PostgREST.Types (Table(..), Column(..), PgArg(..),
|
||||||
Proxy(..), ProcDescription(..))
|
Proxy(..), ProcDescription(..), toMime, operators)
|
||||||
|
|
||||||
makeMimeList :: [ContentType] -> MimeList
|
makeMimeList :: [ContentType] -> MimeList
|
||||||
makeMimeList cs = MimeList $ map (fromString . toS . toMime) cs
|
makeMimeList cs = MimeList $ map (fromString . toS . toMime) cs
|
||||||
@@ -73,7 +73,7 @@ makeOperatorPattern =
|
|||||||
intercalate "|"
|
intercalate "|"
|
||||||
[ concat ["^", x, y, "[.]"] |
|
[ concat ["^", x, y, "[.]"] |
|
||||||
x <- ["not[.]", ""],
|
x <- ["not[.]", ""],
|
||||||
y <- map fst operators ]
|
y <- M.keys operators ]
|
||||||
|
|
||||||
makeRowFilter :: Column -> Param
|
makeRowFilter :: Column -> Param
|
||||||
makeRowFilter c =
|
makeRowFilter c =
|
||||||
@@ -237,7 +237,7 @@ makeRootPathItem = ("/", p)
|
|||||||
where
|
where
|
||||||
getOp = (mempty :: Operation)
|
getOp = (mempty :: Operation)
|
||||||
& tags .~ Set.fromList ["/"]
|
& tags .~ Set.fromList ["/"]
|
||||||
& produces ?~ makeMimeList [CTOpenAPI]
|
& produces ?~ makeMimeList [CTOpenAPI, CTApplicationJSON]
|
||||||
& at 200 ?~ "OK"
|
& at 200 ?~ "OK"
|
||||||
pr = (mempty :: PathItem) & get ?~ getOp
|
pr = (mempty :: PathItem) & get ?~ getOp
|
||||||
p = pr
|
p = pr
|
||||||
|
|||||||
+99
-32
@@ -1,42 +1,52 @@
|
|||||||
module PostgREST.Parsers where
|
module PostgREST.Parsers where
|
||||||
|
|
||||||
import Protolude hiding (try, intercalate)
|
import Protolude hiding (try, intercalate)
|
||||||
import Control.Monad ((>>))
|
import Control.Monad ((>>))
|
||||||
import Data.Text (intercalate)
|
import Data.Foldable (foldl1)
|
||||||
|
import qualified Data.HashMap.Strict as M
|
||||||
|
import Data.Text (intercalate, replace, strip)
|
||||||
import Data.List (init, last)
|
import Data.List (init, last)
|
||||||
import Data.Tree
|
import Data.Tree
|
||||||
import PostgREST.QueryBuilder (operators)
|
import Data.Either.Combinators (mapLeft)
|
||||||
|
import PostgREST.RangeQuery (NonnegRange,allRange)
|
||||||
import PostgREST.Types
|
import PostgREST.Types
|
||||||
import Text.ParserCombinators.Parsec hiding (many, (<|>))
|
import Text.ParserCombinators.Parsec hiding (many, (<|>))
|
||||||
import PostgREST.RangeQuery (NonnegRange,allRange)
|
import Text.Parsec.Error
|
||||||
|
|
||||||
pRequestSelect :: Text -> Text -> Either ParseError ReadRequest
|
pRequestSelect :: Text -> Text -> Either ApiRequestError ReadRequest
|
||||||
pRequestSelect rootName selStr =
|
pRequestSelect rootName selStr =
|
||||||
parse (pReadRequest rootName) ("failed to parse select parameter (" <> toS selStr <> ")") (toS selStr)
|
mapError $ parse (pReadRequest rootName) ("failed to parse select parameter (" <> toS selStr <> ")") (toS selStr)
|
||||||
|
|
||||||
pRequestFilter :: (Text, Text) -> Either ParseError (Path, Filter)
|
pRequestFilter :: (Text, Text) -> Either ApiRequestError (EmbedPath, Filter)
|
||||||
pRequestFilter (k, v) = (,) <$> path <*> (Filter <$> fld <*> op <*> val)
|
pRequestFilter (k, v) = mapError $ (,) <$> path <*> (Filter <$> fld <*> oper)
|
||||||
where
|
where
|
||||||
treePath = parse pTreePath ("failed to parser tree path (" ++ toS k ++ ")") $ toS k
|
treePath = parse pTreePath ("failed to parser tree path (" ++ toS k ++ ")") $ toS k
|
||||||
opVal = parse pOpValueExp ("failed to parse filter (" ++ toS v ++ ")") $ toS v
|
oper = parse (pOperation pVText pVTextL) ("failed to parse filter (" ++ toS v ++ ")") $ toS v
|
||||||
path = fst <$> treePath
|
path = fst <$> treePath
|
||||||
fld = snd <$> treePath
|
fld = snd <$> treePath
|
||||||
op = fst <$> opVal
|
|
||||||
val = snd <$> opVal
|
|
||||||
|
|
||||||
pRequestOrder :: (Text, Text) -> Either ParseError (Path, [OrderTerm])
|
pRequestOrder :: (Text, Text) -> Either ApiRequestError (EmbedPath, [OrderTerm])
|
||||||
pRequestOrder (k, v) = (,) <$> path <*> ord'
|
pRequestOrder (k, v) = mapError $ (,) <$> path <*> ord'
|
||||||
where
|
where
|
||||||
treePath = parse pTreePath ("failed to parser tree path (" ++ toS k ++ ")") $ toS k
|
treePath = parse pTreePath ("failed to parser tree path (" ++ toS k ++ ")") $ toS k
|
||||||
path = fst <$> treePath
|
path = fst <$> treePath
|
||||||
ord' = parse pOrder ("failed to parse order (" ++ toS v ++ ")") $ toS v
|
ord' = parse pOrder ("failed to parse order (" ++ toS v ++ ")") $ toS v
|
||||||
|
|
||||||
pRequestRange :: (ByteString, NonnegRange) -> Either ParseError (Path, NonnegRange)
|
pRequestRange :: (ByteString, NonnegRange) -> Either ApiRequestError (EmbedPath, NonnegRange)
|
||||||
pRequestRange (k, v) = (,) <$> path <*> pure v
|
pRequestRange (k, v) = mapError $ (,) <$> path <*> pure v
|
||||||
where
|
where
|
||||||
treePath = parse pTreePath ("failed to parser tree path (" ++ toS k ++ ")") $ toS k
|
treePath = parse pTreePath ("failed to parser tree path (" ++ toS k ++ ")") $ toS k
|
||||||
path = fst <$> treePath
|
path = fst <$> treePath
|
||||||
|
|
||||||
|
pRequestLogicTree :: (Text, Text) -> Either ApiRequestError (EmbedPath, LogicTree)
|
||||||
|
pRequestLogicTree (k, v) = mapError $ (,) <$> embedPath <*> logicTree
|
||||||
|
where
|
||||||
|
path = parse pLogicPath ("failed to parser logic path (" ++ toS k ++ ")") $ toS k
|
||||||
|
embedPath = fst <$> path
|
||||||
|
op = snd <$> path
|
||||||
|
-- Concat op and v to make pLogicTree argument regular, in the form of "op(.,.)"
|
||||||
|
logicTree = join $ parse pLogicTree ("failed to parse logic tree (" ++ toS v ++ ")") . toS <$> ((<>) <$> op <*> pure v)
|
||||||
|
|
||||||
ws :: Parser Text
|
ws :: Parser Text
|
||||||
ws = toS <$> many (oneOf " \t")
|
ws = toS <$> many (oneOf " \t")
|
||||||
|
|
||||||
@@ -48,7 +58,7 @@ pReadRequest rootNodeName = do
|
|||||||
fieldTree <- pFieldForest
|
fieldTree <- pFieldForest
|
||||||
return $ foldr treeEntry (Node (readQuery, (rootNodeName, Nothing, Nothing)) []) fieldTree
|
return $ foldr treeEntry (Node (readQuery, (rootNodeName, Nothing, Nothing)) []) fieldTree
|
||||||
where
|
where
|
||||||
readQuery = Select [] [rootNodeName] [] Nothing allRange
|
readQuery = Select [] [rootNodeName] [] [] Nothing allRange
|
||||||
treeEntry :: Tree SelectItem -> ReadRequest -> ReadRequest
|
treeEntry :: Tree SelectItem -> ReadRequest -> ReadRequest
|
||||||
treeEntry (Node fld@((fn, _),_,alias) fldForest) (Node (q, i) rForest) =
|
treeEntry (Node fld@((fn, _),_,alias) fldForest) (Node (q, i) rForest) =
|
||||||
case fldForest of
|
case fldForest of
|
||||||
@@ -56,9 +66,9 @@ pReadRequest rootNodeName = do
|
|||||||
_ -> Node (q, i) newForest
|
_ -> Node (q, i) newForest
|
||||||
where
|
where
|
||||||
newForest =
|
newForest =
|
||||||
foldr treeEntry (Node (Select [] [fn] [] Nothing allRange, (fn, Nothing, alias)) []) fldForest:rForest
|
foldr treeEntry (Node (Select [] [fn] [] [] Nothing allRange, (fn, Nothing, alias)) []) fldForest:rForest
|
||||||
|
|
||||||
pTreePath :: Parser (Path,Field)
|
pTreePath :: Parser (EmbedPath, Field)
|
||||||
pTreePath = do
|
pTreePath = do
|
||||||
p <- pFieldName `sepBy1` pDelimiter
|
p <- pFieldName `sepBy1` pDelimiter
|
||||||
jp <- optionMaybe pJsonPath
|
jp <- optionMaybe pJsonPath
|
||||||
@@ -68,8 +78,9 @@ 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 <$> pSimpleSelect <*> between (char '{') (char '}') pFieldForest)
|
pFieldTree = try (Node <$> pSimpleSelect <*> between (char '{') (char '}') pFieldForest)
|
||||||
<|> Node <$> pSelect <*> pure []
|
<|> try (Node <$> pSimpleSelect <*> between (char '(') (char ')') pFieldForest)
|
||||||
|
<|> Node <$> pSelect <*> pure []
|
||||||
|
|
||||||
pStar :: Parser Text
|
pStar :: Parser Text
|
||||||
pStar = toS <$> (string "*" *> pure ("*"::ByteString))
|
pStar = toS <$> (string "*" *> pure ("*"::ByteString))
|
||||||
@@ -118,22 +129,33 @@ pSelect = lexeme $
|
|||||||
s <- pStar
|
s <- pStar
|
||||||
return ((s, Nothing), Nothing, Nothing)
|
return ((s, Nothing), Nothing, Nothing)
|
||||||
|
|
||||||
pOperator :: Parser Operator
|
pOperation :: Parser Operand -> Parser Operand -> Parser Operation
|
||||||
pOperator = toS <$> (pOp <?> "operator (eq, gt, ...)")
|
pOperation parserVText parserVTextL = try ( string "not" *> pDelimiter *> (Operation True <$> pExpr)) <|> Operation False <$> pExpr
|
||||||
where pOp = foldl (<|>) empty $ map (try . string . toS . fst) operators
|
where
|
||||||
|
pExpr :: Parser (Operator, Operand)
|
||||||
|
pExpr =
|
||||||
|
((,) <$> (toS <$> foldl1 (<|>) (try . ((<* pDelimiter) . string) . toS <$> M.keys notInOps)) <*> parserVText)
|
||||||
|
<|> ((,) <$> (toS <$> foldl1 (<|>) (try . ((<* pDelimiter) . string) . toS <$> M.keys inOps)) <*> parserVTextL)
|
||||||
|
<?> "operator (eq, gt, ...)"
|
||||||
|
inOps = M.filterWithKey (const . flip elem ["in", "notin"]) operators
|
||||||
|
notInOps = M.difference operators inOps
|
||||||
|
|
||||||
pValue :: Parser FValue
|
pVText :: Parser Operand
|
||||||
pValue = VText <$> (toS <$> many anyChar)
|
pVText = VText . toS <$> many anyChar
|
||||||
|
|
||||||
|
pVTextL :: Parser Operand
|
||||||
|
pVTextL = VTextL <$> try (lexeme (char '(') *> pVTextLElement `sepBy1` char ',' <* lexeme (char ')'))
|
||||||
|
<|> VTextL <$> lexeme pVTextLElement `sepBy1` char ','
|
||||||
|
|
||||||
|
pVTextLElement :: Parser Text
|
||||||
|
pVTextLElement = try pQuotedValue <|> (toS <$> many (noneOf ",)"))
|
||||||
|
|
||||||
|
pQuotedValue :: Parser Text
|
||||||
|
pQuotedValue = toS <$> (char '"' *> many (noneOf "\"") <* char '"' <* notFollowedBy (noneOf ",)"))
|
||||||
|
|
||||||
pDelimiter :: Parser Char
|
pDelimiter :: Parser Char
|
||||||
pDelimiter = char '.' <?> "delimiter (.)"
|
pDelimiter = char '.' <?> "delimiter (.)"
|
||||||
|
|
||||||
pOperatiorWithNegation :: Parser Operator
|
|
||||||
pOperatiorWithNegation = try ( (<>) <$> ( toS <$> string "not." ) <*> pOperator) <|> pOperator
|
|
||||||
|
|
||||||
pOpValueExp :: Parser (Operator, FValue)
|
|
||||||
pOpValueExp = (,) <$> pOperatiorWithNegation <*> (pDelimiter *> pValue)
|
|
||||||
|
|
||||||
pOrder :: Parser [OrderTerm]
|
pOrder :: Parser [OrderTerm]
|
||||||
pOrder = lexeme pOrderTerm `sepBy` char ','
|
pOrder = lexeme pOrderTerm `sepBy` char ','
|
||||||
|
|
||||||
@@ -152,3 +174,48 @@ pOrderTerm =
|
|||||||
return $ OrderTerm c d nls
|
return $ OrderTerm c d nls
|
||||||
)
|
)
|
||||||
<|> OrderTerm <$> pField <*> pure Nothing <*> pure Nothing
|
<|> OrderTerm <$> pField <*> pure Nothing <*> pure Nothing
|
||||||
|
|
||||||
|
pLogicTree :: Parser LogicTree
|
||||||
|
pLogicTree = Stmnt <$> try pLogicFilter
|
||||||
|
<|> Expr <$> pNot <*> pLogicOp <*> (lexeme (char '(') *> pLogicTree) <*> (lexeme (char ',') *> pLogicTree <* lexeme (char ')'))
|
||||||
|
where
|
||||||
|
pLogicFilter :: Parser Filter
|
||||||
|
pLogicFilter = Filter <$> pField <* pDelimiter <*> pOperation pLogicVText pLogicVTextL
|
||||||
|
pNot :: Parser Bool
|
||||||
|
pNot = try (string "not" *> pDelimiter *> pure True)
|
||||||
|
<|> pure False
|
||||||
|
<?> "negation operator (not)"
|
||||||
|
pLogicOp :: Parser LogicOperator
|
||||||
|
pLogicOp = try (string "and" *> pure And)
|
||||||
|
<|> string "or" *> pure Or
|
||||||
|
<?> "logic operator (and, or)"
|
||||||
|
|
||||||
|
pLogicVText :: Parser Operand
|
||||||
|
pLogicVText = VText <$> (try pQuotedValue <|> try pPgArray <|> (toS <$> many (noneOf ",)")))
|
||||||
|
where
|
||||||
|
pPgArray :: Parser Text
|
||||||
|
pPgArray = do
|
||||||
|
a <- string "{"
|
||||||
|
b <- many (noneOf "{}")
|
||||||
|
c <- string "}"
|
||||||
|
toS <$> pure (a ++ b ++ c)
|
||||||
|
|
||||||
|
pLogicVTextL :: Parser Operand
|
||||||
|
pLogicVTextL = VTextL <$> (lexeme (char '(') *> pVTextLElement `sepBy1` char ',' <* lexeme (char ')'))
|
||||||
|
|
||||||
|
pLogicPath :: Parser (EmbedPath, Text)
|
||||||
|
pLogicPath = do
|
||||||
|
path <- pFieldName `sepBy1` pDelimiter
|
||||||
|
let op = last path
|
||||||
|
notOp = "not." <> op
|
||||||
|
return (filter (/= "not") (init path), if "not" `elem` path then notOp else op)
|
||||||
|
|
||||||
|
mapError :: Either ParseError a -> Either ApiRequestError a
|
||||||
|
mapError = mapLeft translateError
|
||||||
|
where
|
||||||
|
translateError e =
|
||||||
|
ParseRequestError message details
|
||||||
|
where
|
||||||
|
message = show $ errorPos e
|
||||||
|
details = strip $ replace "\n" " " $ toS
|
||||||
|
$ showErrorMessages "or" "unknown parse error" "expecting" "unexpected" "end of input" (errorMessages e)
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ module PostgREST.QueryBuilder (
|
|||||||
, createReadStatement
|
, createReadStatement
|
||||||
, createWriteStatement
|
, createWriteStatement
|
||||||
, getJoinConditions
|
, getJoinConditions
|
||||||
, operators
|
|
||||||
, pgFmtIdent
|
, pgFmtIdent
|
||||||
, pgFmtLit
|
, pgFmtLit
|
||||||
, requestToQuery
|
, requestToQuery
|
||||||
@@ -24,6 +23,7 @@ module PostgREST.QueryBuilder (
|
|||||||
, sourceCTEName
|
, sourceCTEName
|
||||||
, unquoted
|
, unquoted
|
||||||
, ResultsWithCount
|
, ResultsWithCount
|
||||||
|
, pgFmtEnvVar
|
||||||
) where
|
) where
|
||||||
|
|
||||||
import qualified Hasql.Query as H
|
import qualified Hasql.Query as H
|
||||||
@@ -35,13 +35,13 @@ import qualified Data.Aeson as JSON
|
|||||||
import PostgREST.RangeQuery (NonnegRange, rangeLimit, rangeOffset, allRange)
|
import PostgREST.RangeQuery (NonnegRange, rangeLimit, rangeOffset, allRange)
|
||||||
import Data.Functor.Contravariant (contramap)
|
import Data.Functor.Contravariant (contramap)
|
||||||
import qualified Data.HashMap.Strict as HM
|
import qualified Data.HashMap.Strict as HM
|
||||||
import Data.Text (intercalate, unwords, replace, isInfixOf, toLower, split)
|
import Data.Maybe
|
||||||
|
import Data.Text (intercalate, unwords, replace, isInfixOf, toLower)
|
||||||
import qualified Data.Text as T (map, takeWhile, null)
|
import qualified Data.Text as T (map, takeWhile, null)
|
||||||
import qualified Data.Text.Encoding as T
|
import qualified Data.Text.Encoding as T
|
||||||
import Data.Tree (Tree(..))
|
import Data.Tree (Tree(..))
|
||||||
import qualified Data.Vector as V
|
import qualified Data.Vector as V
|
||||||
import PostgREST.Types
|
import PostgREST.Types
|
||||||
import qualified Data.Map as M
|
|
||||||
import Text.InterpolatedString.Perl6 (qc)
|
import Text.InterpolatedString.Perl6 (qc)
|
||||||
import qualified Data.ByteString.Char8 as BS
|
import qualified Data.ByteString.Char8 as BS
|
||||||
import Data.Scientific ( FPFormat (..)
|
import Data.Scientific ( FPFormat (..)
|
||||||
@@ -86,9 +86,9 @@ encodeUniformObjs :: HE.Params PayloadJSON
|
|||||||
encodeUniformObjs =
|
encodeUniformObjs =
|
||||||
contramap (JSON.Array . V.map JSON.Object . unPayloadJSON) (HE.value HE.json)
|
contramap (JSON.Array . V.map JSON.Object . unPayloadJSON) (HE.value HE.json)
|
||||||
|
|
||||||
createReadStatement :: SqlQuery -> SqlQuery -> Bool -> Bool -> Bool ->
|
createReadStatement :: SqlQuery -> SqlQuery -> Bool -> Bool -> Bool -> Maybe FieldName ->
|
||||||
H.Query () ResultsWithCount
|
H.Query () ResultsWithCount
|
||||||
createReadStatement selectQuery countQuery isSingle countTotal asCsv =
|
createReadStatement selectQuery countQuery isSingle countTotal asCsv binaryField =
|
||||||
unicodeStatement sql HE.unit decodeStandard False
|
unicodeStatement sql HE.unit decodeStandard False
|
||||||
where
|
where
|
||||||
sql = [qc|
|
sql = [qc|
|
||||||
@@ -104,6 +104,7 @@ createReadStatement selectQuery countQuery isSingle countTotal asCsv =
|
|||||||
bodyF
|
bodyF
|
||||||
| asCsv = asCsvF
|
| asCsv = asCsvF
|
||||||
| isSingle = asJsonSingleF
|
| isSingle = asJsonSingleF
|
||||||
|
| isJust binaryField = asBinaryF $ fromJust binaryField
|
||||||
| otherwise = asJsonF
|
| otherwise = asJsonF
|
||||||
|
|
||||||
createWriteStatement :: SqlQuery -> SqlQuery -> Bool -> Bool -> Bool ->
|
createWriteStatement :: SqlQuery -> SqlQuery -> Bool -> Bool -> Bool ->
|
||||||
@@ -143,8 +144,9 @@ createWriteStatement selectQuery mutateQuery wantSingle wantHdrs asCsv rep pKeys
|
|||||||
| otherwise = asJsonF
|
| otherwise = asJsonF
|
||||||
|
|
||||||
type ProcResults = (Maybe Int64, Int64, ByteString)
|
type ProcResults = (Maybe Int64, Int64, ByteString)
|
||||||
callProc :: QualifiedIdentifier -> JSON.Object -> SqlQuery -> SqlQuery -> NonnegRange -> Bool -> Bool -> Bool -> H.Query () (Maybe ProcResults)
|
callProc :: QualifiedIdentifier -> JSON.Object -> SqlQuery -> SqlQuery -> NonnegRange ->
|
||||||
callProc qi params selectQuery countQuery _ countTotal isSingle paramsAsJson =
|
Bool -> Bool -> Bool -> Bool -> H.Query () (Maybe ProcResults)
|
||||||
|
callProc qi params selectQuery countQuery _ countTotal isSingle paramsAsJson asCsv =
|
||||||
unicodeStatement sql HE.unit decodeProc True
|
unicodeStatement sql HE.unit decodeProc True
|
||||||
where
|
where
|
||||||
sql = [qc|
|
sql = [qc|
|
||||||
@@ -177,27 +179,9 @@ callProc qi params selectQuery countQuery _ countTotal isSingle paramsAsJson =
|
|||||||
<*> HD.value HD.bytea
|
<*> HD.value HD.bytea
|
||||||
bodyF
|
bodyF
|
||||||
| isSingle = asJsonSingleF
|
| isSingle = asJsonSingleF
|
||||||
|
| asCsv = asCsvF
|
||||||
| otherwise = asJsonF
|
| otherwise = asJsonF
|
||||||
|
|
||||||
operators :: [(Text, SqlFragment)]
|
|
||||||
operators = [
|
|
||||||
("eq", "="),
|
|
||||||
("gte", ">="), -- has to be before gt (parsers)
|
|
||||||
("gt", ">"),
|
|
||||||
("lte", "<="), -- has to be before lt (parsers)
|
|
||||||
("lt", "<"),
|
|
||||||
("neq", "<>"),
|
|
||||||
("like", "like"),
|
|
||||||
("ilike", "ilike"),
|
|
||||||
("in", "in"),
|
|
||||||
("notin", "not in"),
|
|
||||||
("isnot", "is not"), -- has to be before is (parsers)
|
|
||||||
("is", "is"),
|
|
||||||
("@@", "@@"),
|
|
||||||
("@>", "@>"),
|
|
||||||
("<@", "<@")
|
|
||||||
]
|
|
||||||
|
|
||||||
pgFmtIdent :: SqlFragment -> SqlFragment
|
pgFmtIdent :: SqlFragment -> SqlFragment
|
||||||
pgFmtIdent x = "\"" <> replace "\"" "\"\"" (trimNullChars $ toS x) <> "\""
|
pgFmtIdent x = "\"" <> replace "\"" "\"\"" (trimNullChars $ toS x) <> "\""
|
||||||
|
|
||||||
@@ -212,35 +196,36 @@ pgFmtLit x =
|
|||||||
|
|
||||||
requestToCountQuery :: Schema -> DbRequest -> SqlQuery
|
requestToCountQuery :: Schema -> DbRequest -> SqlQuery
|
||||||
requestToCountQuery _ (DbMutate _) = undefined
|
requestToCountQuery _ (DbMutate _) = undefined
|
||||||
requestToCountQuery schema (DbRead (Node (Select _ _ conditions _ _, (mainTbl, _, _)) _)) =
|
requestToCountQuery schema (DbRead (Node (Select _ _ conditions logic_ _ _, (mainTbl, _, _)) _)) =
|
||||||
unwords [
|
unwords [
|
||||||
"SELECT pg_catalog.count(*)",
|
"SELECT pg_catalog.count(*)",
|
||||||
"FROM ", fromQi qi,
|
"FROM ", fromQi qi,
|
||||||
("WHERE " <> intercalate " AND " ( map (pgFmtCondition qi) localConditions )) `emptyOnNull` localConditions
|
-- logic_ doesn't not need localFilter filtering because it doesn't have VForeignKey vals
|
||||||
|
("WHERE " <> intercalate " AND " (map (pgFmtFilter qi) localConditions ++ map (pgFmtLogicTree qi) logic_))
|
||||||
|
`emptyOnFalse` (null conditions && null logic_)
|
||||||
]
|
]
|
||||||
where
|
where
|
||||||
qi = if mainTbl == sourceCTEName
|
qi = removeSourceCTESchema schema mainTbl
|
||||||
then QualifiedIdentifier "" mainTbl
|
localFilter :: Filter -> Bool
|
||||||
else QualifiedIdentifier schema mainTbl
|
localFilter Filter{operation=Operation{expr=(_, val)}} = case val of
|
||||||
fn Filter{value=VText _} = True
|
VText _ -> True
|
||||||
fn Filter{value=VForeignKey _ _} = False
|
VTextL _ -> True
|
||||||
localConditions = filter fn conditions
|
VForeignKey _ _ -> False
|
||||||
|
localConditions = filter localFilter conditions
|
||||||
|
|
||||||
requestToQuery :: Schema -> Bool -> DbRequest -> SqlQuery
|
requestToQuery :: Schema -> Bool -> DbRequest -> SqlQuery
|
||||||
requestToQuery schema isParent (DbRead (Node (Select colSelects tbls conditions ord range, (nodeName, maybeRelation, _)) forest)) =
|
requestToQuery schema isParent (DbRead (Node (Select colSelects tbls conditions logic_ ord range, (nodeName, maybeRelation, _)) forest)) =
|
||||||
query
|
query
|
||||||
where
|
where
|
||||||
-- 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)
|
mainTbl = fromMaybe nodeName (tableName . relTable <$> maybeRelation)
|
||||||
tblSchema tbl = if tbl == sourceCTEName then "" else schema
|
qi = removeSourceCTESchema schema mainTbl
|
||||||
qi = QualifiedIdentifier (tblSchema mainTbl) mainTbl
|
toQi = removeSourceCTESchema schema
|
||||||
toQi t = QualifiedIdentifier (tblSchema t) t
|
|
||||||
query = unwords [
|
query = unwords [
|
||||||
"SELECT ", intercalate ", " (map (pgFmtSelectItem qi) colSelects ++ selects),
|
"SELECT ", intercalate ", " (map (pgFmtSelectItem qi) colSelects ++ selects),
|
||||||
"FROM ", intercalate ", " (map (fromQi . toQi) tbls),
|
"FROM ", intercalate ", " (map (fromQi . toQi) tbls),
|
||||||
unwords joins,
|
unwords joins,
|
||||||
("WHERE " <> intercalate " AND " ( map (pgFmtCondition qi ) conditions )) `emptyOnNull` conditions,
|
("WHERE " <> intercalate " AND " (map (pgFmtFilter qi) conditions ++ map (pgFmtLogicTree qi) logic_))
|
||||||
|
`emptyOnFalse` (null conditions && null logic_),
|
||||||
orderF (fromMaybe [] ord),
|
orderF (fromMaybe [] ord),
|
||||||
if isParent then "" else limitF range
|
if isParent then "" else limitF range
|
||||||
]
|
]
|
||||||
@@ -269,11 +254,11 @@ requestToQuery schema isParent (DbRead (Node (Select colSelects tbls conditions
|
|||||||
where
|
where
|
||||||
node_name = fromMaybe name alias
|
node_name = fromMaybe name alias
|
||||||
local_table_name = table <> "_" <> node_name
|
local_table_name = table <> "_" <> node_name
|
||||||
replaceTableName localTableName (Filter a b (VForeignKey (QualifiedIdentifier "" _) c)) = Filter a b (VForeignKey (QualifiedIdentifier "" localTableName) c)
|
replaceTableName localTableName (Filter a (Operation b (c, VForeignKey (QualifiedIdentifier "" _) d))) = Filter a (Operation b (c, VForeignKey (QualifiedIdentifier "" localTableName) d))
|
||||||
replaceTableName _ x = x
|
replaceTableName _ x = x
|
||||||
sel = "row_to_json(" <> pgFmtIdent local_table_name <> ".*) AS " <> pgFmtIdent node_name
|
sel = "row_to_json(" <> pgFmtIdent local_table_name <> ".*) AS " <> pgFmtIdent node_name
|
||||||
joi = " LEFT OUTER JOIN ( " <> subquery <> " ) AS " <> pgFmtIdent local_table_name <>
|
joi = " LEFT OUTER JOIN ( " <> subquery <> " ) AS " <> pgFmtIdent local_table_name <>
|
||||||
" ON " <> intercalate " AND " ( map (pgFmtCondition qi . replaceTableName local_table_name) (getJoinConditions r) )
|
" ON " <> intercalate " AND " ( map (pgFmtFilter qi . replaceTableName local_table_name) (getJoinConditions r) )
|
||||||
where subquery = requestToQuery schema True (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)
|
getQueryParts (Node n@(_, (name, Just Relation{relType=Many,relTable=Table{tableName=table}}, alias)) forst) (j,s) = (j,sel:s)
|
||||||
where
|
where
|
||||||
@@ -298,10 +283,10 @@ requestToQuery schema _ (DbMutate (Insert mainTbl (PayloadJSON rows) returnings)
|
|||||||
if T.null colsString
|
if T.null colsString
|
||||||
then if V.null rows then ["SELECT null WHERE false"] else ["DEFAULT VALUES"]
|
then if V.null rows then ["SELECT null WHERE false"] else ["DEFAULT VALUES"]
|
||||||
else ["SELECT", colsString, "FROM json_populate_recordset(null::" , fromQi qi, ", $1)"]
|
else ["SELECT", colsString, "FROM json_populate_recordset(null::" , fromQi qi, ", $1)"]
|
||||||
ret = if null returnings
|
ret = if null returnings
|
||||||
then ""
|
then ""
|
||||||
else unwords [" RETURNING ", intercalate ", " (map (pgFmtColumn qi) returnings)]
|
else unwords [" RETURNING ", intercalate ", " (map (pgFmtColumn qi) returnings)]
|
||||||
requestToQuery schema _ (DbMutate (Update mainTbl (PayloadJSON rows) conditions returnings)) =
|
requestToQuery schema _ (DbMutate (Update mainTbl (PayloadJSON rows) conditions logic_ returnings)) =
|
||||||
case rows V.!? 0 of
|
case rows V.!? 0 of
|
||||||
Just obj ->
|
Just obj ->
|
||||||
let assignments = map
|
let assignments = map
|
||||||
@@ -309,25 +294,30 @@ requestToQuery schema _ (DbMutate (Update mainTbl (PayloadJSON rows) conditions
|
|||||||
unwords [
|
unwords [
|
||||||
"UPDATE ", fromQi qi,
|
"UPDATE ", fromQi qi,
|
||||||
" SET " <> intercalate "," assignments <> " ",
|
" SET " <> intercalate "," assignments <> " ",
|
||||||
("WHERE " <> intercalate " AND " ( map (pgFmtCondition qi ) conditions )) `emptyOnNull` conditions,
|
("WHERE " <> intercalate " AND " (map (pgFmtFilter qi) conditions ++ map (pgFmtLogicTree qi) logic_))
|
||||||
("RETURNING " <> intercalate ", " (map (pgFmtColumn qi) returnings)) `emptyOnNull` returnings
|
`emptyOnFalse` (null conditions && null logic_),
|
||||||
|
("RETURNING " <> intercalate ", " (map (pgFmtColumn qi) returnings)) `emptyOnFalse` null returnings
|
||||||
]
|
]
|
||||||
Nothing -> undefined
|
Nothing -> undefined
|
||||||
where
|
where
|
||||||
qi = QualifiedIdentifier schema mainTbl
|
qi = QualifiedIdentifier schema mainTbl
|
||||||
requestToQuery schema _ (DbMutate (Delete mainTbl conditions returnings)) =
|
requestToQuery schema _ (DbMutate (Delete mainTbl conditions logic_ returnings)) =
|
||||||
query
|
query
|
||||||
where
|
where
|
||||||
qi = QualifiedIdentifier schema mainTbl
|
qi = QualifiedIdentifier schema mainTbl
|
||||||
query = unwords [
|
query = unwords [
|
||||||
"DELETE FROM ", fromQi qi,
|
"DELETE FROM ", fromQi qi,
|
||||||
("WHERE " <> intercalate " AND " ( map (pgFmtCondition qi ) conditions )) `emptyOnNull` conditions,
|
("WHERE " <> intercalate " AND " (map (pgFmtFilter qi) conditions ++ map (pgFmtLogicTree qi) logic_))
|
||||||
("RETURNING " <> intercalate ", " (map (pgFmtColumn qi) returnings)) `emptyOnNull` returnings
|
`emptyOnFalse` (null conditions && null logic_),
|
||||||
|
("RETURNING " <> intercalate ", " (map (pgFmtColumn qi) returnings)) `emptyOnFalse` null returnings
|
||||||
]
|
]
|
||||||
|
|
||||||
sourceCTEName :: SqlFragment
|
sourceCTEName :: SqlFragment
|
||||||
sourceCTEName = "pg_source"
|
sourceCTEName = "pg_source"
|
||||||
|
|
||||||
|
removeSourceCTESchema :: Schema -> TableName -> QualifiedIdentifier
|
||||||
|
removeSourceCTESchema schema tbl = QualifiedIdentifier (if tbl == sourceCTEName then "" else schema) tbl
|
||||||
|
|
||||||
unquoted :: JSON.Value -> Text
|
unquoted :: JSON.Value -> Text
|
||||||
unquoted (JSON.String t) = t
|
unquoted (JSON.String t) = t
|
||||||
unquoted (JSON.Number n) =
|
unquoted (JSON.Number n) =
|
||||||
@@ -356,6 +346,9 @@ asJsonF = "coalesce(array_to_json(array_agg(row_to_json(_postgrest_t))), '[]')::
|
|||||||
asJsonSingleF :: SqlFragment --TODO! unsafe when the query actually returns multiple rows, used only on inserting and returning single element
|
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(_postgrest_t)::text, ','), '')::character varying "
|
asJsonSingleF = "coalesce(string_agg(row_to_json(_postgrest_t)::text, ','), '')::character varying "
|
||||||
|
|
||||||
|
asBinaryF :: FieldName -> SqlFragment
|
||||||
|
asBinaryF fieldName = "coalesce(string_agg(_postgrest_t." <> pgFmtIdent fieldName <> ", ''), '')"
|
||||||
|
|
||||||
locationF :: [Text] -> SqlFragment
|
locationF :: [Text] -> SqlFragment
|
||||||
locationF pKeys =
|
locationF pKeys =
|
||||||
"(" <>
|
"(" <>
|
||||||
@@ -395,13 +388,13 @@ getJoinConditions (Relation t cols ft fcs typ lt lc1 lc2) =
|
|||||||
ftN = tableName ft
|
ftN = tableName ft
|
||||||
ltN = fromMaybe "" (tableName <$> lt)
|
ltN = fromMaybe "" (tableName <$> lt)
|
||||||
toFilter :: Text -> Text -> Column -> Column -> Filter
|
toFilter :: Text -> Text -> Column -> Column -> Filter
|
||||||
toFilter tb ftb c fc = Filter (colName c, Nothing) "=" (VForeignKey (QualifiedIdentifier s tb) (ForeignKey fc{colTable=(colTable fc){tableName=ftb}}))
|
toFilter tb ftb c fc = Filter (colName c, Nothing) (Operation False ("=", VForeignKey (QualifiedIdentifier s tb) (ForeignKey fc{colTable=(colTable fc){tableName=ftb}})))
|
||||||
|
|
||||||
unicodeStatement :: Text -> HE.Params a -> HD.Result b -> Bool -> H.Query a b
|
unicodeStatement :: Text -> HE.Params a -> HD.Result b -> Bool -> H.Query a b
|
||||||
unicodeStatement = H.statement . T.encodeUtf8
|
unicodeStatement = H.statement . T.encodeUtf8
|
||||||
|
|
||||||
emptyOnNull :: Text -> [a] -> Text
|
emptyOnFalse :: Text -> Bool -> Text
|
||||||
emptyOnNull val x = if null x then "" else val
|
emptyOnFalse val cond = if cond then "" else val
|
||||||
|
|
||||||
insertableValue :: JSON.Value -> SqlFragment
|
insertableValue :: JSON.Value -> SqlFragment
|
||||||
insertableValue JSON.Null = "null"
|
insertableValue JSON.Null = "null"
|
||||||
@@ -411,11 +404,6 @@ insertableValueWithType :: Text -> JSON.Value -> SqlFragment
|
|||||||
insertableValueWithType t v =
|
insertableValueWithType t v =
|
||||||
pgFmtLit (unquoted v) <> "::" <> t
|
pgFmtLit (unquoted v) <> "::" <> t
|
||||||
|
|
||||||
whiteList :: Text -> SqlFragment
|
|
||||||
whiteList val = fromMaybe
|
|
||||||
(toS (pgFmtLit val) <> "::unknown ")
|
|
||||||
(find ((==) . toLower $ val) ["null","true","false"])
|
|
||||||
|
|
||||||
pgFmtColumn :: QualifiedIdentifier -> Text -> SqlFragment
|
pgFmtColumn :: QualifiedIdentifier -> Text -> SqlFragment
|
||||||
pgFmtColumn table "*" = fromQi table <> ".*"
|
pgFmtColumn table "*" = fromQi table <> ".*"
|
||||||
pgFmtColumn table c = fromQi table <> "." <> pgFmtIdent c
|
pgFmtColumn table c = fromQi table <> "." <> pgFmtIdent c
|
||||||
@@ -427,45 +415,43 @@ pgFmtSelectItem :: QualifiedIdentifier -> SelectItem -> SqlFragment
|
|||||||
pgFmtSelectItem table (f@(_, jp), Nothing, alias) = pgFmtField table f <> pgFmtAs jp alias
|
pgFmtSelectItem table (f@(_, jp), Nothing, alias) = pgFmtField table f <> pgFmtAs jp alias
|
||||||
pgFmtSelectItem table (f@(_, jp), Just cast, alias) = "CAST (" <> pgFmtField table f <> " AS " <> cast <> " )" <> pgFmtAs jp alias
|
pgFmtSelectItem table (f@(_, jp), Just cast, alias) = "CAST (" <> pgFmtField table f <> " AS " <> cast <> " )" <> pgFmtAs jp alias
|
||||||
|
|
||||||
pgFmtCondition :: QualifiedIdentifier -> Filter -> SqlFragment
|
pgFmtFilter :: QualifiedIdentifier -> Filter -> SqlFragment
|
||||||
pgFmtCondition table (Filter (col,jp) ops val) =
|
pgFmtFilter table (Filter fld (Operation hasNot_ ex)) = notOp <> " " <> case ex of
|
||||||
notOp <> " " <> sqlCol <> " " <> pgFmtOperator opCode <> " " <>
|
(op, VText val) -> pgFmtFieldOp op <> " " <> case op of
|
||||||
if opCode `elem` ["is","isnot"] then whiteList (getInner val) else sqlValue
|
"like" -> unknownLiteral (T.map star val)
|
||||||
where
|
"ilike" -> unknownLiteral (T.map star val)
|
||||||
headPredicate:rest = split (=='.') ops
|
"@@" -> "to_tsquery(" <> unknownLiteral val <> ") "
|
||||||
hasNot caseTrue caseFalse = if headPredicate == "not" then caseTrue else caseFalse
|
"is" -> whiteList val
|
||||||
opCode = hasNot (headDef "eq" rest) headPredicate
|
"isnot" -> whiteList val
|
||||||
notOp = hasNot headPredicate ""
|
_ -> unknownLiteral val
|
||||||
sqlCol = case val of
|
(op, VTextL vals) -> pgFmtIn op vals -- in and notin
|
||||||
VText _ -> pgFmtColumn table col <> pgFmtJsonPath jp
|
(op, VForeignKey fQi (ForeignKey Column{colTable=Table{tableName=fTableName}, colName=fColName})) ->
|
||||||
VForeignKey qi _ -> pgFmtColumn qi col
|
pgFmtField fQi fld <> " " <> sqlOperator op <> " " <> pgFmtColumn (removeSourceCTESchema (qiSchema fQi) fTableName) fColName
|
||||||
sqlValue = valToStr val
|
|
||||||
getInner v = case v of
|
|
||||||
VText s -> s
|
|
||||||
_ -> ""
|
|
||||||
valToStr v = case v of
|
|
||||||
VText s -> pgFmtValue opCode s
|
|
||||||
VForeignKey (QualifiedIdentifier s _) (ForeignKey Column{colTable=Table{tableName=ft}, colName=fc}) -> pgFmtColumn qi fc
|
|
||||||
where qi = QualifiedIdentifier (if ft == sourceCTEName then "" else s) ft
|
|
||||||
_ -> ""
|
|
||||||
|
|
||||||
pgFmtValue :: Text -> Text -> SqlFragment
|
|
||||||
pgFmtValue opCode val =
|
|
||||||
case opCode of
|
|
||||||
"like" -> unknownLiteral $ T.map star val
|
|
||||||
"ilike" -> unknownLiteral $ T.map star val
|
|
||||||
"in" -> "(" <> intercalate ", " (map unknownLiteral $ split (==',') val) <> ") "
|
|
||||||
"notin" -> "(" <> intercalate ", " (map unknownLiteral $ split (==',') val) <> ") "
|
|
||||||
"@@" -> "to_tsquery(" <> unknownLiteral val <> ") "
|
|
||||||
_ -> unknownLiteral val
|
|
||||||
where
|
where
|
||||||
|
pgFmtFieldOp op = pgFmtField table fld <> " " <> sqlOperator op
|
||||||
|
sqlOperator o = HM.lookupDefault "=" o operators
|
||||||
|
notOp = if hasNot_ then "NOT" else ""
|
||||||
star c = if c == '*' then '%' else c
|
star c = if c == '*' then '%' else c
|
||||||
unknownLiteral = (<> "::unknown ") . pgFmtLit
|
unknownLiteral = (<> "::unknown ") . pgFmtLit
|
||||||
|
whiteList :: Text -> SqlFragment
|
||||||
|
whiteList v = fromMaybe
|
||||||
|
(toS (pgFmtLit v) <> "::unknown ")
|
||||||
|
(find ((==) . toLower $ v) ["null","true","false"])
|
||||||
|
pgFmtIn :: Operator -> [Text] -> SqlFragment
|
||||||
|
pgFmtIn op vals =
|
||||||
|
-- Workaround because for postgresql "col IN ()" is invalid syntax, we instead do "col = any('{}')"
|
||||||
|
let emptyValForIn o = (if "not" `isInfixOf` o then "NOT " else "") -- handle case of "notin" operator
|
||||||
|
<> pgFmtField table fld <> " = any('{}') " in
|
||||||
|
case T.null <$> headMay vals of
|
||||||
|
Just isNull -> if isNull && length vals == 1
|
||||||
|
then emptyValForIn op
|
||||||
|
else pgFmtFieldOp op <> "(" <> intercalate ", " (map unknownLiteral vals) <> ") "
|
||||||
|
Nothing -> emptyValForIn op
|
||||||
|
|
||||||
pgFmtOperator :: Text -> SqlFragment
|
pgFmtLogicTree :: QualifiedIdentifier -> LogicTree -> SqlFragment
|
||||||
pgFmtOperator opCode = fromMaybe "=" $ M.lookup opCode operatorsMap
|
pgFmtLogicTree qi (Expr hasNot_ op lt rt) = notOp <> " (" <> pgFmtLogicTree qi lt <> " " <> show op <> " " <> pgFmtLogicTree qi rt <> ")"
|
||||||
where
|
where notOp = if hasNot_ then "NOT" else ""
|
||||||
operatorsMap = M.fromList operators
|
pgFmtLogicTree qi (Stmnt flt) = pgFmtFilter qi flt
|
||||||
|
|
||||||
pgFmtJsonPath :: Maybe JsonPath -> SqlFragment
|
pgFmtJsonPath :: Maybe JsonPath -> SqlFragment
|
||||||
pgFmtJsonPath (Just [x]) = "->>" <> pgFmtLit x
|
pgFmtJsonPath (Just [x]) = "->>" <> pgFmtLit x
|
||||||
@@ -479,5 +465,9 @@ pgFmtAs (Just xx) Nothing = case lastMay xx of
|
|||||||
Nothing -> ""
|
Nothing -> ""
|
||||||
pgFmtAs _ (Just alias) = " AS " <> pgFmtIdent alias
|
pgFmtAs _ (Just alias) = " AS " <> pgFmtIdent alias
|
||||||
|
|
||||||
|
pgFmtEnvVar :: Text -> (Text, Text) -> SqlFragment
|
||||||
|
pgFmtEnvVar prefix (k, v) =
|
||||||
|
"set local " <> pgFmtIdent (prefix <> k) <> " = " <> pgFmtLit v <> ";"
|
||||||
|
|
||||||
trimNullChars :: Text -> Text
|
trimNullChars :: Text -> Text
|
||||||
trimNullChars = T.takeWhile (/= '\x0')
|
trimNullChars = T.takeWhile (/= '\x0')
|
||||||
|
|||||||
+85
-14
@@ -1,18 +1,35 @@
|
|||||||
|
{-# LANGUAGE DuplicateRecordFields #-}
|
||||||
module PostgREST.Types where
|
module PostgREST.Types where
|
||||||
import Protolude
|
import Protolude
|
||||||
import qualified GHC.Show
|
import qualified GHC.Show
|
||||||
import Data.Aeson
|
import Data.Aeson
|
||||||
import qualified Data.ByteString.Lazy as BL
|
import qualified Data.ByteString.Lazy as BL
|
||||||
|
import qualified Data.HashMap.Strict as M
|
||||||
import Data.Tree
|
import Data.Tree
|
||||||
import qualified Data.Vector as V
|
import qualified Data.Vector as V
|
||||||
import PostgREST.RangeQuery (NonnegRange)
|
import PostgREST.RangeQuery (NonnegRange)
|
||||||
|
import Network.HTTP.Types.Header (hContentType, Header)
|
||||||
|
|
||||||
|
-- | Enumeration of currently supported response content types
|
||||||
|
data ContentType = CTApplicationJSON | CTTextCSV | CTOpenAPI
|
||||||
|
| CTSingularJSON | CTOctetStream
|
||||||
|
| CTAny | CTOther ByteString deriving Eq
|
||||||
|
|
||||||
|
data ApiRequestError = ActionInappropriate
|
||||||
|
| InvalidBody ByteString
|
||||||
|
| InvalidRange
|
||||||
|
| ParseRequestError Text Text
|
||||||
|
| UnknownRelation
|
||||||
|
| NoRelationBetween Text Text
|
||||||
|
| UnsupportedVerb
|
||||||
|
deriving (Show, Eq)
|
||||||
|
|
||||||
data DbStructure = DbStructure {
|
data DbStructure = DbStructure {
|
||||||
dbTables :: [Table]
|
dbTables :: [Table]
|
||||||
, dbColumns :: [Column]
|
, dbColumns :: [Column]
|
||||||
, dbRelations :: [Relation]
|
, dbRelations :: [Relation]
|
||||||
, dbPrimaryKeys :: [PrimaryKey]
|
, dbPrimaryKeys :: [PrimaryKey]
|
||||||
, dbProcs :: [(Text,ProcDescription)]
|
, dbProcs :: M.HashMap Text ProcDescription
|
||||||
} deriving (Show, Eq)
|
} deriving (Show, Eq)
|
||||||
|
|
||||||
data PgArg = PgArg {
|
data PgArg = PgArg {
|
||||||
@@ -21,10 +38,18 @@ data PgArg = PgArg {
|
|||||||
, pgaReq :: Bool
|
, pgaReq :: Bool
|
||||||
} deriving (Show, Eq)
|
} deriving (Show, Eq)
|
||||||
|
|
||||||
|
data PgType = Scalar QualifiedIdentifier | Composite QualifiedIdentifier | Pseudo Text deriving (Eq, Show)
|
||||||
|
|
||||||
|
data RetType = Single PgType | SetOf PgType deriving (Eq, Show)
|
||||||
|
|
||||||
|
data ProcVolatility = Volatile | Stable | Immutable
|
||||||
|
deriving (Eq, Show)
|
||||||
|
|
||||||
data ProcDescription = ProcDescription {
|
data ProcDescription = ProcDescription {
|
||||||
pdName :: Text
|
pdName :: Text
|
||||||
, pdArgs :: [PgArg]
|
, pdArgs :: [PgArg]
|
||||||
, pdReturnType :: Text
|
, pdReturnType :: RetType
|
||||||
|
, pdVolatility :: ProcVolatility
|
||||||
} deriving (Show, Eq)
|
} deriving (Show, Eq)
|
||||||
|
|
||||||
type Schema = Text
|
type Schema = Text
|
||||||
@@ -39,7 +64,7 @@ data Table = Table {
|
|||||||
, tableInsertable :: Bool
|
, tableInsertable :: Bool
|
||||||
} deriving (Show, Ord)
|
} deriving (Show, Ord)
|
||||||
|
|
||||||
data ForeignKey = ForeignKey { fkCol :: Column } deriving (Show, Eq, Ord)
|
newtype ForeignKey = ForeignKey { fkCol :: Column } deriving (Show, Eq, Ord)
|
||||||
|
|
||||||
data Column =
|
data Column =
|
||||||
Column {
|
Column {
|
||||||
@@ -54,9 +79,7 @@ data Column =
|
|||||||
, colDefault :: Maybe Text
|
, colDefault :: Maybe Text
|
||||||
, colEnum :: [Text]
|
, colEnum :: [Text]
|
||||||
, colFK :: Maybe ForeignKey
|
, colFK :: Maybe ForeignKey
|
||||||
}
|
} deriving (Show, Ord)
|
||||||
| Star { colTable :: Table }
|
|
||||||
deriving (Show, Ord)
|
|
||||||
|
|
||||||
type Synonym = (Column,Column)
|
type Synonym = (Column,Column)
|
||||||
|
|
||||||
@@ -115,7 +138,41 @@ data Proxy = Proxy {
|
|||||||
} deriving (Show, Eq)
|
} deriving (Show, Eq)
|
||||||
|
|
||||||
type Operator = Text
|
type Operator = Text
|
||||||
data FValue = VText Text | VForeignKey QualifiedIdentifier ForeignKey deriving (Show, Eq)
|
operators :: M.HashMap Operator SqlFragment
|
||||||
|
operators = M.fromList [
|
||||||
|
("eq", "="),
|
||||||
|
("gte", ">="),
|
||||||
|
("gt", ">"),
|
||||||
|
("lte", "<="),
|
||||||
|
("lt", "<"),
|
||||||
|
("neq", "<>"),
|
||||||
|
("like", "LIKE"),
|
||||||
|
("ilike", "ILIKE"),
|
||||||
|
("in", "IN"),
|
||||||
|
("notin", "NOT IN"),
|
||||||
|
("isnot", "IS NOT"),
|
||||||
|
("is", "IS"),
|
||||||
|
("@@", "@@"),
|
||||||
|
("@>", "@>"),
|
||||||
|
("<@", "<@")]
|
||||||
|
data Operation = Operation{ hasNot::Bool, expr::(Operator, Operand) } deriving (Eq, Show)
|
||||||
|
data Operand = VText Text | VTextL [Text] | VForeignKey QualifiedIdentifier ForeignKey deriving (Show, Eq)
|
||||||
|
|
||||||
|
data LogicOperator = And | Or deriving Eq
|
||||||
|
instance Show LogicOperator where
|
||||||
|
show And = "AND"
|
||||||
|
show Or = "OR"
|
||||||
|
{-|
|
||||||
|
Boolean logic expression tree e.g. "and(name.eq.N,or(id.eq.1,id.eq.2))" is:
|
||||||
|
|
||||||
|
And
|
||||||
|
/ \
|
||||||
|
name.eq.N Or
|
||||||
|
/ \
|
||||||
|
id.eq.1 id.eq.2
|
||||||
|
-}
|
||||||
|
data LogicTree = Expr Bool LogicOperator LogicTree LogicTree | Stmnt Filter deriving (Show, Eq)
|
||||||
|
|
||||||
type FieldName = Text
|
type FieldName = Text
|
||||||
type JsonPath = [Text]
|
type JsonPath = [Text]
|
||||||
type Field = (FieldName, Maybe JsonPath)
|
type Field = (FieldName, Maybe JsonPath)
|
||||||
@@ -123,18 +180,19 @@ type Alias = Text
|
|||||||
type Cast = Text
|
type Cast = Text
|
||||||
type NodeName = Text
|
type NodeName = Text
|
||||||
type SelectItem = (Field, Maybe Cast, Maybe Alias)
|
type SelectItem = (Field, Maybe Cast, Maybe Alias)
|
||||||
type Path = [Text]
|
-- | Path of the embedded levels, e.g "clients.projects.name=eq.." gives Path ["clients", "projects"]
|
||||||
data ReadQuery = Select { select::[SelectItem], from::[TableName], flt_::[Filter], order::Maybe [OrderTerm], range_::NonnegRange } deriving (Show, Eq)
|
type EmbedPath = [Text]
|
||||||
|
data Filter = Filter { field::Field, operation::Operation } deriving (Show, Eq)
|
||||||
|
|
||||||
|
data ReadQuery = Select { select::[SelectItem], from::[TableName], flt_::[Filter], logic::[LogicTree], order::Maybe [OrderTerm], range_::NonnegRange } deriving (Show, Eq)
|
||||||
data MutateQuery = Insert { in_::TableName, qPayload::PayloadJSON, returning::[FieldName] }
|
data MutateQuery = Insert { in_::TableName, qPayload::PayloadJSON, returning::[FieldName] }
|
||||||
| Delete { in_::TableName, where_::[Filter], returning::[FieldName] }
|
| Delete { in_::TableName, where_::[Filter], logic::[LogicTree], returning::[FieldName] }
|
||||||
| Update { in_::TableName, qPayload::PayloadJSON, where_::[Filter], returning::[FieldName] } deriving (Show, Eq)
|
| Update { in_::TableName, qPayload::PayloadJSON, where_::[Filter], logic::[LogicTree], 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 ReadNode = (ReadQuery, (NodeName, Maybe Relation, Maybe Alias))
|
||||||
type ReadRequest = Tree ReadNode
|
type ReadRequest = Tree ReadNode
|
||||||
type MutateRequest = MutateQuery
|
type MutateRequest = MutateQuery
|
||||||
data DbRequest = DbRead ReadRequest | DbMutate MutateRequest
|
data DbRequest = DbRead ReadRequest | DbMutate MutateRequest
|
||||||
|
|
||||||
|
|
||||||
instance ToJSON Column where
|
instance ToJSON Column where
|
||||||
toJSON c = object [
|
toJSON c = object [
|
||||||
"schema" .= tableSchema t
|
"schema" .= tableSchema t
|
||||||
@@ -171,4 +229,17 @@ instance Eq Table where
|
|||||||
|
|
||||||
instance Eq Column where
|
instance Eq Column where
|
||||||
Column{colTable=t1,colName=n1} == Column{colTable=t2,colName=n2} = t1 == t2 && n1 == n2
|
Column{colTable=t1,colName=n1} == Column{colTable=t2,colName=n2} = t1 == t2 && n1 == n2
|
||||||
_ == _ = False
|
|
||||||
|
-- | 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 CTOctetStream = "application/octet-stream"
|
||||||
|
toMime CTAny = "*/*"
|
||||||
|
toMime (CTOther ct) = ct
|
||||||
|
|||||||
+3
-2
@@ -1,8 +1,9 @@
|
|||||||
resolver: lts-7.4
|
resolver: lts-8.5
|
||||||
extra-deps:
|
extra-deps:
|
||||||
- Ranged-sets-0.3.0
|
- Ranged-sets-0.3.0
|
||||||
- hasql-pool-0.4.1
|
- hasql-pool-0.4.1
|
||||||
- hasql-transaction-0.5
|
- configurator-ng-0.0.0.1
|
||||||
|
- critbit-0.2.0.0
|
||||||
ghc-options:
|
ghc-options:
|
||||||
postgrest: -O2 -Werror -Wall -fwarn-identities -fno-warn-redundant-constraints
|
postgrest: -O2 -Werror -Wall -fwarn-identities -fno-warn-redundant-constraints
|
||||||
nix:
|
nix:
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
module Feature.AndOrParamsSpec where
|
||||||
|
import Test.Hspec
|
||||||
|
import Test.Hspec.Wai
|
||||||
|
import Test.Hspec.Wai.JSON
|
||||||
|
import Network.HTTP.Types
|
||||||
|
|
||||||
|
import Network.Wai (Application)
|
||||||
|
|
||||||
|
import SpecHelper
|
||||||
|
import Protolude hiding (get)
|
||||||
|
|
||||||
|
|
||||||
|
spec :: SpecWith Application
|
||||||
|
spec =
|
||||||
|
describe "and/or params used for complex boolean logic" $ do
|
||||||
|
context "used with GET" $ do
|
||||||
|
context "or param" $ do
|
||||||
|
it "can do simple logic" $
|
||||||
|
get "/entities?or=(id.eq.1,id.eq.2)&select=id" `shouldRespondWith`
|
||||||
|
[json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
it "can negate simple logic" $
|
||||||
|
get "/entities?not.or=(id.eq.1,id.eq.2)&select=id" `shouldRespondWith`
|
||||||
|
[json|[{ "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
it "can be combined with traditional filters" $
|
||||||
|
get "/entities?or=(id.eq.1,id.eq.2)&name=eq.entity 1&select=id" `shouldRespondWith`
|
||||||
|
[json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
context "embedded levels" $ do
|
||||||
|
it "can do logic on the second level" $
|
||||||
|
get "/entities?child_entities.or=(id.eq.1,name.eq.child entity 2)&select=id,child_entities{id}" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"id": 1, "child_entities": [ { "id": 1 }, { "id": 2 } ] }, { "id": 2, "child_entities": []},
|
||||||
|
{"id": 3, "child_entities": []}, {"id": 4, "child_entities": []}
|
||||||
|
]|] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
it "can do logic on the third level" $
|
||||||
|
get "/entities?child_entities.grandchild_entities.or=(id.eq.1,id.eq.2)&select=id,child_entities{id,grandchild_entities{id}}" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"id": 1, "child_entities": [ { "id": 1, "grandchild_entities": [ { "id": 1 }, { "id": 2 } ]}, { "id": 2, "grandchild_entities": []}]},
|
||||||
|
{"id": 2, "child_entities": [ { "id": 3, "grandchild_entities": []} ]},
|
||||||
|
{"id": 3, "child_entities": []}, {"id": 4, "child_entities": []}
|
||||||
|
]|] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
context "and/or params combined" $ do
|
||||||
|
it "can be nested inside the same expression" $
|
||||||
|
get "/entities?or=(and(name.eq.entity 2,id.eq.2),and(name.eq.entity 1,id.eq.1))&select=id" `shouldRespondWith`
|
||||||
|
[json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
it "can be negated while nested" $
|
||||||
|
get "/entities?or=(not.and(name.eq.entity 2,id.eq.2),not.and(name.eq.entity 1,id.eq.1))&select=id" `shouldRespondWith`
|
||||||
|
[json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
it "can be combined unnested" $
|
||||||
|
get "/entities?and=(id.eq.1,name.eq.entity 1)&or=(id.eq.1,id.eq.2)&select=id" `shouldRespondWith`
|
||||||
|
[json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
context "operators inside and/or" $ do
|
||||||
|
it "can handle eq and neq" $
|
||||||
|
get "/entities?and=(id.eq.1,id.neq.2))&select=id" `shouldRespondWith`
|
||||||
|
[json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
it "can handle lt and gt" $
|
||||||
|
get "/entities?or=(id.lt.2,id.gt.3)&select=id" `shouldRespondWith`
|
||||||
|
[json|[{ "id": 1 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
it "can handle lte and gte" $
|
||||||
|
get "/entities?or=(id.lte.2,id.gte.3)&select=id" `shouldRespondWith`
|
||||||
|
[json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
it "can handle like and ilike" $
|
||||||
|
get "/entities?or=(name.like.*1,name.ilike.*ENTITY 2)&select=id" `shouldRespondWith`
|
||||||
|
[json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
it "can handle in" $
|
||||||
|
get "/entities?or=(id.in.(1,2),id.in.(3,4))&select=id" `shouldRespondWith`
|
||||||
|
[json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
it "can handle is" $
|
||||||
|
get "/entities?and=(name.is.null,arr.is.null)&select=id" `shouldRespondWith`
|
||||||
|
[json|[{ "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
it "can handle @@" $
|
||||||
|
get "/entities?or=(text_search_vector.@@.bar,text_search_vector.@@.baz)&select=id" `shouldRespondWith`
|
||||||
|
[json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
it "can handle @> and <@" $
|
||||||
|
get "/entities?or=(arr.@>.{1,2,3},arr.<@.{1})&select=id" `shouldRespondWith`
|
||||||
|
[json|[{ "id": 1 },{ "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
context "operators with not" $ do
|
||||||
|
it "eq, @>, like can be negated" $
|
||||||
|
get "/entities?and=(arr.not.@>.{1,2,3},and(id.not.eq.2,name.not.like.*3))&select=id" `shouldRespondWith`
|
||||||
|
[json|[{ "id": 1}]|] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
it "in, is, @@ can be negated" $
|
||||||
|
get "/entities?and=(id.not.in.(1,3),and(name.not.is.null,text_search_vector.not.@@.foo))&select=id" `shouldRespondWith`
|
||||||
|
[json|[{ "id": 2}]|] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
it "lt, gte, <@ can be negated" $
|
||||||
|
get "/entities?and=(arr.not.<@.{1},or(id.not.lt.1,id.not.gte.3))&select=id" `shouldRespondWith`
|
||||||
|
[json|[{"id": 2}, {"id": 3}]|] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
it "gt, lte, ilike can be negated" $
|
||||||
|
get "/entities?and=(name.not.ilike.*ITY2,or(id.not.gt.4,id.not.lte.1))&select=id" `shouldRespondWith`
|
||||||
|
[json|[{"id": 1}, {"id": 2}, {"id": 3}]|] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
context "and/or params with quotes" $ do
|
||||||
|
it "eq can have quotes" $
|
||||||
|
get "/grandchild_entities?or=(name.eq.\"(grandchild,entity,4)\",name.eq.\"(grandchild,entity,5)\")&select=id" `shouldRespondWith`
|
||||||
|
[json|[{ "id": 4 }, { "id": 5 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
it "like and ilike can have quotes" $
|
||||||
|
get "/grandchild_entities?or=(name.like.\"*ity,4*\",name.ilike.\"*ITY,5)\")&select=id" `shouldRespondWith`
|
||||||
|
[json|[{ "id": 4 }, { "id": 5 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
it "in can have quotes" $
|
||||||
|
get "/grandchild_entities?or=(id.in.(\"1\",\"2\"),id.in.(\"3\",\"4\"))&select=id" `shouldRespondWith`
|
||||||
|
[json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "allows whitespace" $
|
||||||
|
get "/entities?and=( and ( id.in.( 1, 2, 3 ) , id.eq.3 ) , or ( id.eq.2 , id.eq.3 ) )&select=id" `shouldRespondWith`
|
||||||
|
[json|[{ "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
context "used with POST" $
|
||||||
|
it "includes related data with filters" $
|
||||||
|
request methodPost "/child_entities?entities.or=(id.eq.2,id.eq.3)&select=id,entities{id}"
|
||||||
|
[("Prefer", "return=representation")]
|
||||||
|
[json|[{"id":4,"name":"entity 4","parent_id":1},
|
||||||
|
{"id":5,"name":"entity 5","parent_id":2},
|
||||||
|
{"id":6,"name":"entity 6","parent_id":3}]|] `shouldRespondWith`
|
||||||
|
[json|[{"id": 4, "entities":null}, {"id": 5, "entities": {"id": 2}}, {"id": 6, "entities": {"id": 3}}]|]
|
||||||
|
{ matchStatus = 201, matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
context "used with PATCH" $
|
||||||
|
it "succeeds when using and/or params" $
|
||||||
|
request methodPatch "/grandchild_entities?or=(id.eq.1,id.eq.2)&select=id,name"
|
||||||
|
[("Prefer", "return=representation")]
|
||||||
|
[json|{ name : "updated grandchild entity"}|] `shouldRespondWith`
|
||||||
|
[json|[{ "id": 1, "name" : "updated grandchild entity"},{ "id": 2, "name" : "updated grandchild entity"}]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
context "used with DELETE" $
|
||||||
|
it "succeeds when using and/or params" $
|
||||||
|
request methodDelete "/grandchild_entities?or=(id.eq.1,id.eq.2)&select=id,name"
|
||||||
|
[("Prefer", "return=representation")] "" `shouldRespondWith`
|
||||||
|
[json|[{ "id": 1, "name" : "updated grandchild entity"},{ "id": 2, "name" : "updated grandchild entity"}]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "can query columns that begin with and/or reserved words" $
|
||||||
|
get "/grandchild_entities?or=(and_starting_col.eq.smth, or_starting_col.eq.smth)" `shouldRespondWith` 200
|
||||||
|
|
||||||
|
it "can query jsonb columns" $
|
||||||
|
get "/grandchild_entities?or=(jsonb_col->a->>b.eq.foo, jsonb_col->>b.eq.bar)&select=id" `shouldRespondWith`
|
||||||
|
[json|[{id: 4}, {id: 5}]|] { matchStatus = 200, matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "fails when using IN without () and provides meaningful error message" $
|
||||||
|
get "/entities?or=(id.in.1,2,id.eq.3)" `shouldRespondWith`
|
||||||
|
[json|{
|
||||||
|
"details": "unexpected \"1\" expecting \"(\"",
|
||||||
|
"message": "\"failed to parse logic tree ((id.in.1,2,id.eq.3))\" (line 1, column 10)"
|
||||||
|
}|] { matchStatus = 400, matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "fails on malformed query params and provides meaningful error message" $ do
|
||||||
|
get "/entities?or=()" `shouldRespondWith`
|
||||||
|
[json|{
|
||||||
|
"details": "unexpected \")\" expecting field name (* or [a..z0..9_]), negation operator (not) or logic operator (and, or)",
|
||||||
|
"message": "\"failed to parse logic tree (())\" (line 1, column 4)"
|
||||||
|
}|] { matchStatus = 400, matchHeaders = [matchContentTypeJson] }
|
||||||
|
get "/entities?or=)(" `shouldRespondWith`
|
||||||
|
[json|{
|
||||||
|
"details": "unexpected \")\" expecting \"(\"",
|
||||||
|
"message": "\"failed to parse logic tree ()()\" (line 1, column 3)"
|
||||||
|
}|] { matchStatus = 400, matchHeaders = [matchContentTypeJson] }
|
||||||
|
get "/entities?or=(id.eq.1)" `shouldRespondWith`
|
||||||
|
[json|{
|
||||||
|
"details": "unexpected \")\" expecting \",\"",
|
||||||
|
"message": "\"failed to parse logic tree ((id.eq.1))\" (line 1, column 11)"
|
||||||
|
}|] { matchStatus = 400, matchHeaders = [matchContentTypeJson] }
|
||||||
|
get "/entities?and=(ord(id.eq.1,id.eq.1),id.eq.2)" `shouldRespondWith`
|
||||||
|
[json|{
|
||||||
|
"details": "unexpected \"d\" expecting \"(\"",
|
||||||
|
"message": "\"failed to parse logic tree ((ord(id.eq.1,id.eq.1),id.eq.2))\" (line 1, column 7)"
|
||||||
|
}|] { matchStatus = 400, matchHeaders = [matchContentTypeJson] }
|
||||||
|
get "/entities?or=(id.eq.1,not.xor(id.eq.2,id.eq.3))" `shouldRespondWith`
|
||||||
|
[json|{
|
||||||
|
"details": "unexpected \"x\" expecting logic operator (and, or)",
|
||||||
|
"message": "\"failed to parse logic tree ((id.eq.1,not.xor(id.eq.2,id.eq.3)))\" (line 1, column 16)"
|
||||||
|
}|] { matchStatus = 400, matchHeaders = [matchContentTypeJson] }
|
||||||
+20
-29
@@ -18,44 +18,40 @@ spec = describe "authorization" $ do
|
|||||||
let single = ("Accept","application/vnd.pgrst.object+json")
|
let single = ("Accept","application/vnd.pgrst.object+json")
|
||||||
|
|
||||||
it "denies access to tables that anonymous does not own" $
|
it "denies access to tables that anonymous does not own" $
|
||||||
get "/authors_only" `shouldRespondWith` ResponseMatcher {
|
get "/authors_only" `shouldRespondWith` [json| {
|
||||||
matchBody = Just [json| {
|
|
||||||
"hint":null,
|
"hint":null,
|
||||||
"details":null,
|
"details":null,
|
||||||
"code":"42501",
|
"code":"42501",
|
||||||
"message":"permission denied for relation authors_only"} |]
|
"message":"permission denied for relation authors_only"} |]
|
||||||
, matchStatus = 401
|
{ matchStatus = 401
|
||||||
, matchHeaders = ["WWW-Authenticate" <:> "Bearer"]
|
, matchHeaders = ["WWW-Authenticate" <:> "Bearer"]
|
||||||
}
|
}
|
||||||
|
|
||||||
it "denies access to tables that postgrest_test_author does not own" $
|
it "denies access to tables that postgrest_test_author does not own" $
|
||||||
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0" in
|
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0" in
|
||||||
request methodGet "/private_table" [auth] ""
|
request methodGet "/private_table" [auth] ""
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` [json| {
|
||||||
matchBody = Just [json| {
|
|
||||||
"hint":null,
|
"hint":null,
|
||||||
"details":null,
|
"details":null,
|
||||||
"code":"42501",
|
"code":"42501",
|
||||||
"message":"permission denied for relation private_table"} |]
|
"message":"permission denied for relation private_table"} |]
|
||||||
, matchStatus = 403
|
{ matchStatus = 403
|
||||||
, matchHeaders = []
|
, matchHeaders = []
|
||||||
}
|
}
|
||||||
|
|
||||||
it "returns jwt functions as jwt tokens" $
|
it "returns jwt functions as jwt tokens" $
|
||||||
request methodPost "/rpc/login" [single]
|
request methodPost "/rpc/login" [single]
|
||||||
[json| { "id": "jdoe", "pass": "1234" } |]
|
[json| { "id": "jdoe", "pass": "1234" } |]
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` [json| {"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xuYW1lIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.P2G9EVSVI22MWxXWFuhEYd9BZerLS1WDlqzdqplM15s"} |]
|
||||||
matchBody = Just [json| {"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xuYW1lIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.P2G9EVSVI22MWxXWFuhEYd9BZerLS1WDlqzdqplM15s"} |]
|
{ matchStatus = 200
|
||||||
, matchStatus = 200
|
, matchHeaders = [matchContentTypeSingular]
|
||||||
, matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8"]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
it "sql functions can encode custom and standard claims" $
|
it "sql functions can encode custom and standard claims" $
|
||||||
request methodPost "/rpc/jwt_test" [single] "{}"
|
request methodPost "/rpc/jwt_test" [single] "{}"
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` [json| {"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJqb2UiLCJzdWIiOiJmdW4iLCJhdWQiOiJldmVyeW9uZSIsImV4cCI6MTMwMDgxOTM4MCwibmJmIjoxMzAwODE5MzgwLCJpYXQiOjEzMDA4MTkzODAsImp0aSI6ImZvbyIsInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdCIsImh0dHA6Ly9wb3N0Z3Jlc3QuY29tL2ZvbyI6dHJ1ZX0.IHF16ZSU6XTbOnUWO8CCpUn2fJwt8P00rlYVyXQjpWc"} |]
|
||||||
matchBody = Just [json| {"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJqb2UiLCJzdWIiOiJmdW4iLCJhdWQiOiJldmVyeW9uZSIsImV4cCI6MTMwMDgxOTM4MCwibmJmIjoxMzAwODE5MzgwLCJpYXQiOjEzMDA4MTkzODAsImp0aSI6ImZvbyIsInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdCIsImh0dHA6Ly9wb3N0Z3Jlc3QuY29tL2ZvbyI6dHJ1ZX0.IHF16ZSU6XTbOnUWO8CCpUn2fJwt8P00rlYVyXQjpWc"} |]
|
{ matchStatus = 200
|
||||||
, matchStatus = 200
|
, matchHeaders = [matchContentTypeSingular]
|
||||||
, matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8"]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
it "sql functions can read custom and standard claims variables" $ do
|
it "sql functions can read custom and standard claims variables" $ do
|
||||||
@@ -82,9 +78,8 @@ spec = describe "authorization" $ do
|
|||||||
it "fails with an expired token" $ do
|
it "fails with an expired token" $ do
|
||||||
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE0NDY2NzgxNDksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UifQ.enk_qZ_u6gZsXY4R8bREKB_HNExRpM0lIWSLktk9JJQ"
|
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE0NDY2NzgxNDksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UifQ.enk_qZ_u6gZsXY4R8bREKB_HNExRpM0lIWSLktk9JJQ"
|
||||||
request methodGet "/authors_only" [auth] ""
|
request methodGet "/authors_only" [auth] ""
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` [json| {"message":"JWT expired"} |]
|
||||||
matchBody = Nothing
|
{ matchStatus = 401
|
||||||
, matchStatus = 401
|
|
||||||
, matchHeaders = [
|
, matchHeaders = [
|
||||||
"WWW-Authenticate" <:>
|
"WWW-Authenticate" <:>
|
||||||
"Bearer error=\"invalid_token\", error_description=\"JWT expired\""
|
"Bearer error=\"invalid_token\", error_description=\"JWT expired\""
|
||||||
@@ -94,9 +89,8 @@ spec = describe "authorization" $ do
|
|||||||
it "hides tables from users with invalid JWT" $ do
|
it "hides tables from users with invalid JWT" $ do
|
||||||
let auth = authHeaderJWT "ey9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0"
|
let auth = authHeaderJWT "ey9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0"
|
||||||
request methodGet "/authors_only" [auth] ""
|
request methodGet "/authors_only" [auth] ""
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` [json| {"message":"JWT invalid"} |]
|
||||||
matchBody = Nothing
|
{ matchStatus = 401
|
||||||
, matchStatus = 401
|
|
||||||
, matchHeaders = [
|
, matchHeaders = [
|
||||||
"WWW-Authenticate" <:>
|
"WWW-Authenticate" <:>
|
||||||
"Bearer error=\"invalid_token\", error_description=\"JWT invalid\""
|
"Bearer error=\"invalid_token\", error_description=\"JWT invalid\""
|
||||||
@@ -126,9 +120,8 @@ spec = describe "authorization" $ do
|
|||||||
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MX0.mI2HNoOum6xM3sc4oHLxU4yLv-_WV5W1kqBfY_wEvLw" in
|
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MX0.mI2HNoOum6xM3sc4oHLxU4yLv-_WV5W1kqBfY_wEvLw" in
|
||||||
request methodPost "/rpc/get_current_user" [auth]
|
request methodPost "/rpc/get_current_user" [auth]
|
||||||
[json| {} |]
|
[json| {} |]
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` [str|"postgrest_test_author"|]
|
||||||
matchBody = Just [str|"postgrest_test_author"|]
|
{ matchStatus = 200
|
||||||
, matchStatus = 200
|
|
||||||
, matchHeaders = []
|
, matchHeaders = []
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,9 +129,8 @@ spec = describe "authorization" $ do
|
|||||||
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Mn0.W7jLsG-zswM91AJkCvZeIMHrnz7_6ceY2jnscVl3Yhk" in
|
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Mn0.W7jLsG-zswM91AJkCvZeIMHrnz7_6ceY2jnscVl3Yhk" in
|
||||||
request methodPost "/rpc/get_current_user" [auth]
|
request methodPost "/rpc/get_current_user" [auth]
|
||||||
[json| {} |]
|
[json| {} |]
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` [str|"postgrest_test_default_role"|]
|
||||||
matchBody = Just [str|"postgrest_test_default_role"|]
|
{ matchStatus = 200
|
||||||
, matchStatus = 200
|
|
||||||
, matchHeaders = []
|
, matchHeaders = []
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,8 +138,7 @@ spec = describe "authorization" $ do
|
|||||||
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6M30.15Gy8PezQhJIaHYDJVLa-Gmz9T3sJnW66EKAYIsXc7c" in
|
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6M30.15Gy8PezQhJIaHYDJVLa-Gmz9T3sJnW66EKAYIsXc7c" in
|
||||||
request methodPost "/rpc/get_current_user" [auth]
|
request methodPost "/rpc/get_current_user" [auth]
|
||||||
[json| {} |]
|
[json| {} |]
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` [str|{"hint":"Please contact administrator","details":null,"code":"P0001","message":"Disabled ID --> 3"}|]
|
||||||
matchBody = Just [str|{"hint":"Please contact administrator","details":null,"code":"P0001","message":"Disabled ID --> 3"}|]
|
{ matchStatus = 400
|
||||||
, matchStatus = 400
|
|
||||||
, matchHeaders = []
|
, matchHeaders = []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,14 +24,13 @@ spec =
|
|||||||
it "should not raise 'transaction in progress' error" $
|
it "should not raise 'transaction in progress' error" $
|
||||||
raceTest 10 $
|
raceTest 10 $
|
||||||
get "/fakefake"
|
get "/fakefake"
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` [json|
|
||||||
matchBody = Just [json|
|
|
||||||
{ "hint": null,
|
{ "hint": null,
|
||||||
"details":null,
|
"details":null,
|
||||||
"code":"42P01",
|
"code":"42P01",
|
||||||
"message":"relation \"test.fakefake\" does not exist"
|
"message":"relation \"test.fakefake\" does not exist"
|
||||||
} |]
|
} |]
|
||||||
, matchStatus = 404
|
{ matchStatus = 404
|
||||||
, matchHeaders = []
|
, matchHeaders = []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+12
-18
@@ -15,24 +15,21 @@ spec =
|
|||||||
context "existing record" $ do
|
context "existing record" $ do
|
||||||
it "succeeds with 204 and deletion count" $
|
it "succeeds with 204 and deletion count" $
|
||||||
request methodDelete "/items?id=eq.1" [] ""
|
request methodDelete "/items?id=eq.1" [] ""
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` ""
|
||||||
matchBody = Nothing
|
{ matchStatus = 204
|
||||||
, matchStatus = 204
|
|
||||||
, matchHeaders = ["Content-Range" <:> "*/*"]
|
, matchHeaders = ["Content-Range" <:> "*/*"]
|
||||||
}
|
}
|
||||||
|
|
||||||
it "returns the deleted item and count if requested" $
|
it "returns the deleted item and count if requested" $
|
||||||
request methodDelete "/items?id=eq.2" [("Prefer", "return=representation"), ("Prefer", "count=exact")] ""
|
request methodDelete "/items?id=eq.2" [("Prefer", "return=representation"), ("Prefer", "count=exact")] ""
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` [str|[{"id":2}]|]
|
||||||
matchBody = Just [str|[{"id":2}]|]
|
{ matchStatus = 200
|
||||||
, matchStatus = 200
|
|
||||||
, matchHeaders = ["Content-Range" <:> "*/1"]
|
, matchHeaders = ["Content-Range" <:> "*/1"]
|
||||||
}
|
}
|
||||||
it "returns the deleted item and shapes the response" $
|
it "returns the deleted item and shapes the response" $
|
||||||
request methodDelete "/complex_items?id=eq.2&select=id,name" [("Prefer", "return=representation")] ""
|
request methodDelete "/complex_items?id=eq.2&select=id,name" [("Prefer", "return=representation")] ""
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` [str|[{"id":2,"name":"Two"}]|]
|
||||||
matchBody = Just [str|[{"id":2,"name":"Two"}]|]
|
{ matchStatus = 200
|
||||||
, matchStatus = 200
|
|
||||||
, matchHeaders = ["Content-Range" <:> "*/*"]
|
, matchHeaders = ["Content-Range" <:> "*/*"]
|
||||||
}
|
}
|
||||||
it "can rename and cast the selected columns" $
|
it "can rename and cast the selected columns" $
|
||||||
@@ -40,18 +37,16 @@ spec =
|
|||||||
`shouldRespondWith` [str|[{"ciId":"3","ciName":"Three"}]|]
|
`shouldRespondWith` [str|[{"ciId":"3","ciName":"Three"}]|]
|
||||||
it "can embed (parent) entities" $
|
it "can embed (parent) entities" $
|
||||||
request methodDelete "/tasks?id=eq.8&select=id,name,project{id}" [("Prefer", "return=representation")] ""
|
request methodDelete "/tasks?id=eq.8&select=id,name,project{id}" [("Prefer", "return=representation")] ""
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` [str|[{"id":8,"name":"Code OSX","project":{"id":4}}]|]
|
||||||
matchBody = Just [str|[{"id":8,"name":"Code OSX","project":{"id":4}}]|]
|
{ matchStatus = 200
|
||||||
, matchStatus = 200
|
|
||||||
, matchHeaders = ["Content-Range" <:> "*/*"]
|
, matchHeaders = ["Content-Range" <:> "*/*"]
|
||||||
}
|
}
|
||||||
|
|
||||||
it "actually clears items ouf the db" $ do
|
it "actually clears items ouf the db" $ do
|
||||||
_ <- request methodDelete "/items?id=lt.15" [] ""
|
_ <- request methodDelete "/items?id=lt.15" [] ""
|
||||||
get "/items"
|
get "/items"
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` [str|[{"id":15}]|]
|
||||||
matchBody = Just [str|[{"id":15}]|]
|
{ matchStatus = 200
|
||||||
, matchStatus = 200
|
|
||||||
, matchHeaders = ["Content-Range" <:> "0-0/*"]
|
, matchHeaders = ["Content-Range" <:> "0-0/*"]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,9 +54,8 @@ spec =
|
|||||||
it "includes [] body if return=rep" $
|
it "includes [] body if return=rep" $
|
||||||
request methodDelete "/items?id=eq.101"
|
request methodDelete "/items?id=eq.101"
|
||||||
[("Prefer", "return=representation")] ""
|
[("Prefer", "return=representation")] ""
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` "[]"
|
||||||
matchBody = Just "[]"
|
{ matchStatus = 200
|
||||||
, matchStatus = 200
|
|
||||||
, matchHeaders = ["Content-Range" <:> "*/*"]
|
, matchHeaders = ["Content-Range" <:> "*/*"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+72
-61
@@ -3,6 +3,7 @@ module Feature.InsertSpec where
|
|||||||
import Test.Hspec hiding (pendingWith)
|
import Test.Hspec hiding (pendingWith)
|
||||||
import Test.Hspec.Wai
|
import Test.Hspec.Wai
|
||||||
import Test.Hspec.Wai.JSON
|
import Test.Hspec.Wai.JSON
|
||||||
|
import Test.Hspec.Wai.Matcher (bodyEquals)
|
||||||
import Network.Wai.Test (SResponse(simpleBody,simpleHeaders,simpleStatus))
|
import Network.Wai.Test (SResponse(simpleBody,simpleHeaders,simpleStatus))
|
||||||
|
|
||||||
import SpecHelper
|
import SpecHelper
|
||||||
@@ -43,31 +44,29 @@ spec = do
|
|||||||
"integer": 14, "double": 3.14159, "varchar": "testing!"
|
"integer": 14, "double": 3.14159, "varchar": "testing!"
|
||||||
, "boolean": false, "date": "1900-01-01", "money": "$3.99"
|
, "boolean": false, "date": "1900-01-01", "money": "$3.99"
|
||||||
, "enum": "foo"
|
, "enum": "foo"
|
||||||
}] |] `shouldRespondWith` ResponseMatcher {
|
}] |] `shouldRespondWith` [str|[{"integer":14,"varchar":"testing!"}]|]
|
||||||
matchBody = Just [str|[{"integer":14,"varchar":"testing!"}]|]
|
{ matchStatus = 201
|
||||||
, matchStatus = 201
|
, matchHeaders = [matchContentTypeJson]
|
||||||
, matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8"]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
context "requesting full representation" $ do
|
context "requesting full representation" $ do
|
||||||
it "includes related data after insert" $
|
it "includes related data after insert" $
|
||||||
request methodPost "/projects?select=id,name,clients{id,name}"
|
request methodPost "/projects?select=id,name,clients{id,name}"
|
||||||
[("Prefer", "return=representation"), ("Prefer", "count=exact")]
|
[("Prefer", "return=representation"), ("Prefer", "count=exact")]
|
||||||
[str|{"id":6,"name":"New Project","client_id":2}|] `shouldRespondWith` ResponseMatcher {
|
[str|{"id":6,"name":"New Project","client_id":2}|] `shouldRespondWith` [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
|
||||||
, matchStatus = 201
|
, matchHeaders = [ matchContentTypeJson
|
||||||
, matchHeaders = [ "Content-Type" <:> "application/json; charset=utf-8"
|
|
||||||
, "Location" <:> "/projects?id=eq.6"
|
, "Location" <:> "/projects?id=eq.6"
|
||||||
, "Content-Range" <:> "*/1" ]
|
, "Content-Range" <:> "*/1" ]
|
||||||
}
|
}
|
||||||
|
|
||||||
it "can rename and cast the selected columns" $
|
it "can rename and cast the selected columns" $
|
||||||
request methodPost "/projects?select=pId:id::text,pName:name,cId:client_id::text"
|
request methodPost "/projects?select=pId:id::text,pName:name,cId:client_id::text"
|
||||||
[("Prefer", "return=representation")]
|
[("Prefer", "return=representation")]
|
||||||
[str|{"id":7,"name":"New Project","client_id":2}|] `shouldRespondWith` ResponseMatcher {
|
[str|{"id":7,"name":"New Project","client_id":2}|] `shouldRespondWith`
|
||||||
matchBody = Just [str|[{"pId":"7","pName":"New Project","cId":"2"}]|]
|
[str|[{"pId":"7","pName":"New Project","cId":"2"}]|]
|
||||||
, matchStatus = 201
|
{ matchStatus = 201
|
||||||
, matchHeaders = [ "Content-Type" <:> "application/json; charset=utf-8"
|
, matchHeaders = [ matchContentTypeJson
|
||||||
, "Location" <:> "/projects?id=eq.7"
|
, "Location" <:> "/projects?id=eq.7"
|
||||||
, "Content-Range" <:> "*/*" ]
|
, "Content-Range" <:> "*/*" ]
|
||||||
}
|
}
|
||||||
@@ -201,9 +200,8 @@ spec = do
|
|||||||
request methodPost "/json"
|
request methodPost "/json"
|
||||||
[("Prefer", "return=representation")]
|
[("Prefer", "return=representation")]
|
||||||
inserted
|
inserted
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` [str|[{"data":{"foo":"bar"}}]|]
|
||||||
matchBody = Just [str|[{"data":{"foo":"bar"}}]|]
|
{ matchStatus = 201
|
||||||
, matchStatus = 201
|
|
||||||
, matchHeaders = ["Location" <:> location]
|
, matchHeaders = ["Location" <:> location]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,39 +211,35 @@ spec = do
|
|||||||
request methodPost "/json"
|
request methodPost "/json"
|
||||||
[("Prefer", "return=representation")]
|
[("Prefer", "return=representation")]
|
||||||
inserted
|
inserted
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` [str|[{"data":[1,2,3]}]|]
|
||||||
matchBody = Just [str|[{"data":[1,2,3]}]|]
|
{ matchStatus = 201
|
||||||
, matchStatus = 201
|
|
||||||
, matchHeaders = ["Location" <:> location]
|
, matchHeaders = ["Location" <:> location]
|
||||||
}
|
}
|
||||||
|
|
||||||
context "empty object" $
|
context "empty object" $
|
||||||
it "successfully populates table with all-default columns" $
|
it "successfully populates table with all-default columns" $
|
||||||
post "/items" "{}" `shouldRespondWith` ResponseMatcher {
|
post "/items" "{}" `shouldRespondWith` ""
|
||||||
matchBody = Just ""
|
{ matchStatus = 201
|
||||||
, matchStatus = 201
|
|
||||||
, matchHeaders = []
|
, matchHeaders = []
|
||||||
}
|
}
|
||||||
context "table with limited privileges" $ do
|
context "table with limited privileges" $ do
|
||||||
it "succeeds if correct select is applied" $
|
it "succeeds if correct select is applied" $
|
||||||
request methodPost "/limited_article_stars?select=article_id,user_id" [("Prefer", "return=representation")]
|
request methodPost "/limited_article_stars?select=article_id,user_id" [("Prefer", "return=representation")]
|
||||||
[json| {"article_id": 2, "user_id": 1} |] `shouldRespondWith` ResponseMatcher {
|
[json| {"article_id": 2, "user_id": 1} |] `shouldRespondWith` [str|[{"article_id":2,"user_id":1}]|]
|
||||||
matchBody = Just [str|[{"article_id":2,"user_id":1}]|]
|
{ matchStatus = 201
|
||||||
, matchStatus = 201
|
|
||||||
, matchHeaders = []
|
, matchHeaders = []
|
||||||
}
|
}
|
||||||
it "fails if more columns are selected" $
|
it "fails if more columns are selected" $
|
||||||
request methodPost "/limited_article_stars?select=article_id,user_id,created_at" [("Prefer", "return=representation")]
|
request methodPost "/limited_article_stars?select=article_id,user_id,created_at" [("Prefer", "return=representation")]
|
||||||
[json| {"article_id": 2, "user_id": 2} |] `shouldRespondWith` ResponseMatcher {
|
[json| {"article_id": 2, "user_id": 2} |] `shouldRespondWith`
|
||||||
matchBody = Just [str|{"hint":null,"details":null,"code":"42501","message":"permission denied for relation limited_article_stars"}|]
|
[str|{"hint":null,"details":null,"code":"42501","message":"permission denied for relation limited_article_stars"}|]
|
||||||
, matchStatus = 401
|
{ matchStatus = 401
|
||||||
, matchHeaders = []
|
, matchHeaders = []
|
||||||
}
|
}
|
||||||
it "fails if select is not specified" $
|
it "fails if select is not specified" $
|
||||||
request methodPost "/limited_article_stars" [("Prefer", "return=representation")]
|
request methodPost "/limited_article_stars" [("Prefer", "return=representation")]
|
||||||
[json| {"article_id": 3, "user_id": 1} |] `shouldRespondWith` ResponseMatcher {
|
[json| {"article_id": 3, "user_id": 1} |] `shouldRespondWith` [str|{"hint":null,"details":null,"code":"42501","message":"permission denied for relation limited_article_stars"}|]
|
||||||
matchBody = Just [str|{"hint":null,"details":null,"code":"42501","message":"permission denied for relation limited_article_stars"}|]
|
{ matchStatus = 401
|
||||||
, matchStatus = 401
|
|
||||||
, matchHeaders = []
|
, matchHeaders = []
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,11 +253,10 @@ spec = do
|
|||||||
|12,0.1,a string,true,1929-10-01,12,bar
|
|12,0.1,a string,true,1929-10-01,12,bar
|
||||||
|]
|
|]
|
||||||
request methodPost "/menagerie" [("Content-Type", "text/csv"), ("Accept", "text/csv"), ("Prefer", "return=representation")] inserted
|
request methodPost "/menagerie" [("Content-Type", "text/csv"), ("Accept", "text/csv"), ("Prefer", "return=representation")] inserted
|
||||||
|
`shouldRespondWith` ResponseMatcher
|
||||||
`shouldRespondWith` ResponseMatcher {
|
{ matchStatus = 201
|
||||||
matchBody = Just inserted
|
|
||||||
, matchStatus = 201
|
|
||||||
, matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8"]
|
, matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8"]
|
||||||
|
, matchBody = bodyEquals inserted
|
||||||
}
|
}
|
||||||
|
|
||||||
context "requesting full representation" $ do
|
context "requesting full representation" $ do
|
||||||
@@ -271,9 +264,8 @@ spec = do
|
|||||||
request methodPost "/no_pk"
|
request methodPost "/no_pk"
|
||||||
[("Content-Type", "text/csv"), ("Accept", "text/csv"), ("Prefer", "return=representation")]
|
[("Content-Type", "text/csv"), ("Accept", "text/csv"), ("Prefer", "return=representation")]
|
||||||
"a,b\nbar,baz"
|
"a,b\nbar,baz"
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` "a,b\nbar,baz"
|
||||||
matchBody = Just "a,b\nbar,baz"
|
{ matchStatus = 201
|
||||||
, matchStatus = 201
|
|
||||||
, matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8",
|
, matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8",
|
||||||
"Location" <:> "/no_pk?a=eq.bar&b=eq.baz"]
|
"Location" <:> "/no_pk?a=eq.bar&b=eq.baz"]
|
||||||
}
|
}
|
||||||
@@ -282,9 +274,8 @@ spec = do
|
|||||||
request methodPost "/no_pk"
|
request methodPost "/no_pk"
|
||||||
[("Content-Type", "text/csv"), ("Accept", "text/csv"), ("Prefer", "return=representation")]
|
[("Content-Type", "text/csv"), ("Accept", "text/csv"), ("Prefer", "return=representation")]
|
||||||
"a,b\nNULL,foo"
|
"a,b\nNULL,foo"
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` "a,b\n,foo"
|
||||||
matchBody = Just "a,b\n,foo"
|
{ matchStatus = 201
|
||||||
, matchStatus = 201
|
|
||||||
, matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8",
|
, matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8",
|
||||||
"Location" <:> "/no_pk?a=is.null&b=eq.foo"]
|
"Location" <:> "/no_pk?a=is.null&b=eq.foo"]
|
||||||
}
|
}
|
||||||
@@ -293,9 +284,8 @@ spec = do
|
|||||||
request methodPost "/projects?select=id"
|
request methodPost "/projects?select=id"
|
||||||
[("Content-Type", "text/csv"), ("Accept", "text/csv"), ("Prefer", "return=representation")]
|
[("Content-Type", "text/csv"), ("Accept", "text/csv"), ("Prefer", "return=representation")]
|
||||||
"id,name,client_id\n8,Xenix,1\n9,Windows NT,1"
|
"id,name,client_id\n8,Xenix,1\n9,Windows NT,1"
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` "id\n8\n9"
|
||||||
matchBody = Just "id\n8\n9"
|
{ matchStatus = 201
|
||||||
, matchStatus = 201
|
|
||||||
, matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8",
|
, matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8",
|
||||||
"Content-Range" <:> "*/*"]
|
"Content-Range" <:> "*/*"]
|
||||||
}
|
}
|
||||||
@@ -334,9 +324,8 @@ spec = do
|
|||||||
it "indicates no records found to update" $
|
it "indicates no records found to update" $
|
||||||
request methodPatch "/empty_table" []
|
request methodPatch "/empty_table" []
|
||||||
[json| { "extra":20 } |]
|
[json| { "extra":20 } |]
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` ""
|
||||||
matchBody = Just "",
|
{ matchStatus = 204,
|
||||||
matchStatus = 204,
|
|
||||||
matchHeaders = ["Content-Range" <:> "*/*"]
|
matchHeaders = ["Content-Range" <:> "*/*"]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,9 +335,8 @@ spec = do
|
|||||||
liftIO $ simpleHeaders g
|
liftIO $ simpleHeaders g
|
||||||
`shouldSatisfy` matchHeader "Content-Range" "\\*/\\*"
|
`shouldSatisfy` matchHeader "Content-Range" "\\*/\\*"
|
||||||
p <- request methodPatch "/items?id=eq.2" [] [json| { "id":42 } |]
|
p <- request methodPatch "/items?id=eq.2" [] [json| { "id":42 } |]
|
||||||
pure p `shouldRespondWith` ResponseMatcher {
|
pure p `shouldRespondWith` ""
|
||||||
matchBody = Nothing,
|
{ matchStatus = 204,
|
||||||
matchStatus = 204,
|
|
||||||
matchHeaders = ["Content-Range" <:> "0-0/*"]
|
matchHeaders = ["Content-Range" <:> "0-0/*"]
|
||||||
}
|
}
|
||||||
liftIO $ lookup hContentType (simpleHeaders p) `shouldBe` Nothing
|
liftIO $ lookup hContentType (simpleHeaders p) `shouldBe` Nothing
|
||||||
@@ -363,8 +351,8 @@ spec = do
|
|||||||
it "returns empty array when no rows updated and return=rep" $
|
it "returns empty array when no rows updated and return=rep" $
|
||||||
request methodPatch "/items?id=eq.999999"
|
request methodPatch "/items?id=eq.999999"
|
||||||
[("Prefer", "return=representation")] [json| { "id":999999 } |]
|
[("Prefer", "return=representation")] [json| { "id":999999 } |]
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` "[]"
|
||||||
matchBody = Just "[]",
|
{
|
||||||
matchStatus = 200,
|
matchStatus = 200,
|
||||||
matchHeaders = ["Content-Range" <:> "*/*"]
|
matchHeaders = ["Content-Range" <:> "*/*"]
|
||||||
}
|
}
|
||||||
@@ -372,9 +360,8 @@ spec = do
|
|||||||
it "returns updated object as array when return=rep" $
|
it "returns updated object as array when return=rep" $
|
||||||
request methodPatch "/items?id=eq.2"
|
request methodPatch "/items?id=eq.2"
|
||||||
[("Prefer", "return=representation")] [json| { "id":2 } |]
|
[("Prefer", "return=representation")] [json| { "id":2 } |]
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` [str|[{"id":2}]|]
|
||||||
matchBody = Just [str|[{"id":2}]|],
|
{ matchStatus = 200,
|
||||||
matchStatus = 200,
|
|
||||||
matchHeaders = ["Content-Range" <:> "0-0/*"]
|
matchHeaders = ["Content-Range" <:> "0-0/*"]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,15 +382,15 @@ spec = do
|
|||||||
_ <- request methodPatch "/no_pk?b=eq.nullme" [] [json| { b: null } |]
|
_ <- request methodPatch "/no_pk?b=eq.nullme" [] [json| { b: null } |]
|
||||||
get "/no_pk?a=eq.keepme" `shouldRespondWith`
|
get "/no_pk?a=eq.keepme" `shouldRespondWith`
|
||||||
[json| [{ a: "keepme", b: null }] |]
|
[json| [{ a: "keepme", b: null }] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
it "can set a json column to escaped value" $ do
|
it "can set a json column to escaped value" $ do
|
||||||
_ <- post "/json" [json| { data: {"escaped":"bar"} } |]
|
_ <- post "/json" [json| { data: {"escaped":"bar"} } |]
|
||||||
request methodPatch "/json?data->>escaped=eq.bar"
|
request methodPatch "/json?data->>escaped=eq.bar"
|
||||||
[("Prefer", "return=representation")]
|
[("Prefer", "return=representation")]
|
||||||
[json| { "data": { "escaped":" \"bar" } } |]
|
[json| { "data": { "escaped":" \"bar" } } |]
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` [json| [{ "data": { "escaped":" \"bar" } }] |]
|
||||||
matchBody = Just [json| [{ "data": { "escaped":" \"bar" } }] |]
|
{ matchStatus = 200
|
||||||
, matchStatus = 200
|
|
||||||
, matchHeaders = []
|
, matchHeaders = []
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,9 +399,8 @@ spec = do
|
|||||||
"/items?always_true=eq.false"
|
"/items?always_true=eq.false"
|
||||||
[("Prefer", "return=representation")]
|
[("Prefer", "return=representation")]
|
||||||
[json| { id: 100 } |]
|
[json| { id: 100 } |]
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` "[]"
|
||||||
matchBody = Just "[]",
|
{ matchStatus = 200,
|
||||||
matchStatus = 200,
|
|
||||||
matchHeaders = ["Content-Range" <:> "*/*"]
|
matchHeaders = ["Content-Range" <:> "*/*"]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,6 +412,31 @@ spec = do
|
|||||||
[("Prefer", "return=representation")]
|
[("Prefer", "return=representation")]
|
||||||
[json| { id: 99 } |]
|
[json| { id: 99 } |]
|
||||||
`shouldRespondWith` [json| [{id:99}] |]
|
`shouldRespondWith` [json| [{id:99}] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
-- put value back for other tests
|
||||||
|
void $ request methodPatch "/items?id=eq.99" [] [json| { "id":1 } |]
|
||||||
|
|
||||||
|
it "makes no updates and returns 204, when patching with an empty json object" $ do
|
||||||
|
request methodPatch "/items" [] [json| {} |]
|
||||||
|
`shouldRespondWith` ""
|
||||||
|
{
|
||||||
|
matchStatus = 204,
|
||||||
|
matchHeaders = ["Content-Range" <:> "*/*"]
|
||||||
|
}
|
||||||
|
|
||||||
|
g <- get "/items"
|
||||||
|
liftIO $ simpleBody g `shouldBe` [json| [{"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},{id:16},{"id":2},{"id":1}] |]
|
||||||
|
|
||||||
|
it "makes no updates and and returns 200, when patching with an empty json object and return=rep" $ do
|
||||||
|
request methodPatch "/items" [("Prefer", "return=representation")] [json| {} |]
|
||||||
|
`shouldRespondWith` "[]"
|
||||||
|
{
|
||||||
|
matchStatus = 200,
|
||||||
|
matchHeaders = ["Content-Range" <:> "*/*"]
|
||||||
|
}
|
||||||
|
|
||||||
|
g <- get "/items"
|
||||||
|
liftIO $ simpleBody g `shouldBe` [json| [{"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},{id:16},{"id":2},{"id":1}] |]
|
||||||
|
|
||||||
context "with unicode values" $
|
context "with unicode values" $
|
||||||
it "succeeds and returns values intact" $ do
|
it "succeeds and returns values intact" $ do
|
||||||
|
|||||||
@@ -16,9 +16,8 @@ spec =
|
|||||||
describe "Requesting many items with server limits enabled" $ do
|
describe "Requesting many items with server limits enabled" $ do
|
||||||
it "restricts results" $
|
it "restricts results" $
|
||||||
get "/items"
|
get "/items"
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` [json| [{"id":1},{"id":2}] |]
|
||||||
matchBody = Just [json| [{"id":1},{"id":2}] |]
|
{ matchStatus = 200
|
||||||
, matchStatus = 200
|
|
||||||
, matchHeaders = ["Content-Range" <:> "0-1/*"]
|
, matchHeaders = ["Content-Range" <:> "0-1/*"]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,17 +31,14 @@ spec =
|
|||||||
|
|
||||||
it "limit works on all levels" $
|
it "limit works on all levels" $
|
||||||
get "/users?select=id,tasks{id}&order=id.asc&tasks.order=id.asc"
|
get "/users?select=id,tasks{id}&order=id.asc&tasks.order=id.asc"
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` [str|[{"id":1,"tasks":[{"id":1},{"id":2}]},{"id":2,"tasks":[{"id":5},{"id":6}]}]|]
|
||||||
matchBody = Just [str|[{"id":1,"tasks":[{"id":1},{"id":2}]},{"id":2,"tasks":[{"id":5},{"id":6}]}]|]
|
{ matchStatus = 200
|
||||||
, matchStatus = 200
|
|
||||||
, matchHeaders = ["Content-Range" <:> "0-1/*"]
|
, matchHeaders = ["Content-Range" <:> "0-1/*"]
|
||||||
}
|
}
|
||||||
|
|
||||||
it "limit is not applied to parent embeds" $
|
it "limit is not applied to parent embeds" $
|
||||||
get "/tasks?select=id,project{id}&id=gt.5"
|
get "/tasks?select=id,project{id}&id=gt.5"
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` [str|[{"id":6,"project":{"id":3}},{"id":7,"project":{"id":4}}]|]
|
||||||
matchBody = Just [str|[{"id":6,"project":{"id":3}},{"id":7,"project":{"id":4}}]|]
|
{ matchStatus = 200
|
||||||
, matchStatus = 200
|
|
||||||
, matchHeaders = ["Content-Range" <:> "0-1/*"]
|
, matchHeaders = ["Content-Range" <:> "0-1/*"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+332
-87
@@ -5,6 +5,7 @@ import Test.Hspec.Wai
|
|||||||
import Test.Hspec.Wai.JSON
|
import Test.Hspec.Wai.JSON
|
||||||
import Network.HTTP.Types
|
import Network.HTTP.Types
|
||||||
import Network.Wai.Test (SResponse(simpleHeaders,simpleStatus,simpleBody))
|
import Network.Wai.Test (SResponse(simpleHeaders,simpleStatus,simpleBody))
|
||||||
|
import qualified Data.ByteString.Lazy as BL (empty)
|
||||||
|
|
||||||
import SpecHelper
|
import SpecHelper
|
||||||
import Text.Heredoc
|
import Text.Heredoc
|
||||||
@@ -30,68 +31,49 @@ spec = do
|
|||||||
describe "Filtering response" $ do
|
describe "Filtering response" $ do
|
||||||
it "matches with equality" $
|
it "matches with equality" $
|
||||||
get "/items?id=eq.5"
|
get "/items?id=eq.5"
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` [json| [{"id":5}] |]
|
||||||
matchBody = Just [json| [{"id":5}] |]
|
{ matchHeaders = ["Content-Range" <:> "0-0/*"] }
|
||||||
, matchStatus = 200
|
|
||||||
, matchHeaders = ["Content-Range" <:> "0-0/*"]
|
|
||||||
}
|
|
||||||
|
|
||||||
it "matches with equality using not operator" $
|
it "matches with equality using not operator" $
|
||||||
get "/items?id=not.eq.5"
|
get "/items?id=not.eq.5"
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` [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}] |]
|
||||||
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}] |]
|
{ matchHeaders = ["Content-Range" <:> "0-13/*"] }
|
||||||
, matchStatus = 200
|
|
||||||
, matchHeaders = ["Content-Range" <:> "0-13/*"]
|
|
||||||
}
|
|
||||||
|
|
||||||
it "matches with more than one condition using not operator" $
|
it "matches with more than one condition using not operator" $
|
||||||
get "/simple_pk?k=like.*yx&extra=not.eq.u" `shouldRespondWith` "[]"
|
get "/simple_pk?k=like.*yx&extra=not.eq.u" `shouldRespondWith` "[]"
|
||||||
|
|
||||||
it "matches with inequality using not operator" $ do
|
it "matches with inequality using not operator" $ do
|
||||||
get "/items?id=not.lt.14&order=id.asc"
|
get "/items?id=not.lt.14&order=id.asc"
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` [json| [{"id":14},{"id":15}] |]
|
||||||
matchBody = Just [json| [{"id":14},{"id":15}] |]
|
{ matchHeaders = ["Content-Range" <:> "0-1/*"] }
|
||||||
, matchStatus = 200
|
|
||||||
, matchHeaders = ["Content-Range" <:> "0-1/*"]
|
|
||||||
}
|
|
||||||
get "/items?id=not.gt.2&order=id.asc"
|
get "/items?id=not.gt.2&order=id.asc"
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` [json| [{"id":1},{"id":2}] |]
|
||||||
matchBody = Just [json| [{"id":1},{"id":2}] |]
|
{ matchHeaders = ["Content-Range" <:> "0-1/*"] }
|
||||||
, matchStatus = 200
|
|
||||||
, matchHeaders = ["Content-Range" <:> "0-1/*"]
|
|
||||||
}
|
|
||||||
|
|
||||||
it "matches items IN" $
|
it "matches items IN" $
|
||||||
get "/items?id=in.1,3,5"
|
get "/items?id=in.1,3,5"
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` [json| [{"id":1},{"id":3},{"id":5}] |]
|
||||||
matchBody = Just [json| [{"id":1},{"id":3},{"id":5}] |]
|
{ matchHeaders = ["Content-Range" <:> "0-2/*"] }
|
||||||
, matchStatus = 200
|
|
||||||
, matchHeaders = ["Content-Range" <:> "0-2/*"]
|
|
||||||
}
|
|
||||||
|
|
||||||
it "matches items NOT IN" $
|
it "matches items NOT IN" $
|
||||||
get "/items?id=notin.2,4,6,7,8,9,10,11,12,13,14,15"
|
get "/items?id=notin.2,4,6,7,8,9,10,11,12,13,14,15"
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` [json| [{"id":1},{"id":3},{"id":5}] |]
|
||||||
matchBody = Just [json| [{"id":1},{"id":3},{"id":5}] |]
|
{ matchHeaders = ["Content-Range" <:> "0-2/*"] }
|
||||||
, matchStatus = 200
|
|
||||||
, matchHeaders = ["Content-Range" <:> "0-2/*"]
|
|
||||||
}
|
|
||||||
|
|
||||||
it "matches items NOT IN using not operator" $
|
it "matches items NOT IN using not operator" $
|
||||||
get "/items?id=not.in.2,4,6,7,8,9,10,11,12,13,14,15"
|
get "/items?id=not.in.2,4,6,7,8,9,10,11,12,13,14,15"
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` [json| [{"id":1},{"id":3},{"id":5}] |]
|
||||||
matchBody = Just [json| [{"id":1},{"id":3},{"id":5}] |]
|
{ matchHeaders = ["Content-Range" <:> "0-2/*"] }
|
||||||
, matchStatus = 200
|
|
||||||
, matchHeaders = ["Content-Range" <:> "0-2/*"]
|
|
||||||
}
|
|
||||||
|
|
||||||
it "matches nulls using not operator" $
|
it "matches nulls using not operator" $
|
||||||
get "/no_pk?a=not.is.null" `shouldRespondWith`
|
get "/no_pk?a=not.is.null" `shouldRespondWith`
|
||||||
[json| [{"a":"1","b":"0"},{"a":"2","b":"0"}] |]
|
[json| [{"a":"1","b":"0"},{"a":"2","b":"0"}] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
it "matches nulls in varchar and numeric fields alike" $ do
|
it "matches nulls in varchar and numeric fields alike" $ do
|
||||||
get "/no_pk?a=is.null" `shouldRespondWith`
|
get "/no_pk?a=is.null" `shouldRespondWith`
|
||||||
[json| [{"a": null, "b": null}] |]
|
[json| [{"a": null, "b": null}] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
get "/nullable_integer?a=is.null" `shouldRespondWith` [str|[{"a":null}]|]
|
get "/nullable_integer?a=is.null" `shouldRespondWith` [str|[{"a":null}]|]
|
||||||
|
|
||||||
@@ -119,25 +101,28 @@ spec = do
|
|||||||
it "matches with tsearch @@" $
|
it "matches with tsearch @@" $
|
||||||
get "/tsearch?text_search_vector=@@.foo" `shouldRespondWith`
|
get "/tsearch?text_search_vector=@@.foo" `shouldRespondWith`
|
||||||
[json| [{"text_search_vector":"'bar':2 'foo':1"}] |]
|
[json| [{"text_search_vector":"'bar':2 'foo':1"}] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
it "matches with tsearch @@ using not operator" $
|
it "matches with tsearch @@ using not operator" $
|
||||||
get "/tsearch?text_search_vector=not.@@.foo" `shouldRespondWith`
|
get "/tsearch?text_search_vector=not.@@.foo" `shouldRespondWith`
|
||||||
[json| [{"text_search_vector":"'baz':1 'qux':2"}] |]
|
[json| [{"text_search_vector":"'baz':1 'qux':2"}] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
it "matches with computed column" $
|
it "matches with computed column" $
|
||||||
get "/items?always_true=eq.true&order=id.asc" `shouldRespondWith`
|
get "/items?always_true=eq.true&order=id.asc" `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}] |]
|
[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}] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
it "order by computed column" $
|
it "order by computed column" $
|
||||||
get "/items?order=anti_id.desc" `shouldRespondWith`
|
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}] |]
|
[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}] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
it "matches filtering nested items 2" $
|
it "matches filtering nested items 2" $
|
||||||
get "/clients?select=id,projects{id,tasks2{id,name}}&projects.tasks.name=like.Design*"
|
get "/clients?select=id,projects{id,tasks2{id,name}}&projects.tasks.name=like.Design*"
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` [json| {"message":"Could not find foreign keys between these entities, No relation found between projects and tasks2"}|]
|
||||||
matchBody = Just [json| {"message":"could not find foreign keys between these entities, no relation between projects and tasks2"}|]
|
{ matchStatus = 400
|
||||||
, matchStatus = 400
|
, matchHeaders = [matchContentTypeJson]
|
||||||
, matchHeaders = []
|
|
||||||
}
|
}
|
||||||
|
|
||||||
it "matches filtering nested items" $
|
it "matches filtering nested items" $
|
||||||
@@ -166,45 +151,50 @@ spec = do
|
|||||||
it "one simple column" $
|
it "one simple column" $
|
||||||
get "/complex_items?select=id" `shouldRespondWith`
|
get "/complex_items?select=id" `shouldRespondWith`
|
||||||
[json| [{"id":1},{"id":2},{"id":3}] |]
|
[json| [{"id":1},{"id":2},{"id":3}] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
it "rename simple column" $
|
it "rename simple column" $
|
||||||
get "/complex_items?id=eq.1&select=myId:id" `shouldRespondWith`
|
get "/complex_items?id=eq.1&select=myId:id" `shouldRespondWith`
|
||||||
[json| [{"myId":1}] |]
|
[json| [{"myId":1}] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
|
||||||
it "one simple column with casting (text)" $
|
it "one simple column with casting (text)" $
|
||||||
get "/complex_items?select=id::text" `shouldRespondWith`
|
get "/complex_items?select=id::text" `shouldRespondWith`
|
||||||
[json| [{"id":"1"},{"id":"2"},{"id":"3"}] |]
|
[json| [{"id":"1"},{"id":"2"},{"id":"3"}] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
it "rename simple column with casting" $
|
it "rename simple column with casting" $
|
||||||
get "/complex_items?id=eq.1&select=myId:id::text" `shouldRespondWith`
|
get "/complex_items?id=eq.1&select=myId:id::text" `shouldRespondWith`
|
||||||
[json| [{"myId":"1"}] |]
|
[json| [{"myId":"1"}] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
it "json column" $
|
it "json column" $
|
||||||
get "/complex_items?id=eq.1&select=settings" `shouldRespondWith`
|
get "/complex_items?id=eq.1&select=settings" `shouldRespondWith`
|
||||||
[json| [{"settings":{"foo":{"int":1,"bar":"baz"}}}] |]
|
[json| [{"settings":{"foo":{"int":1,"bar":"baz"}}}] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
it "json subfield one level with casting (json)" $
|
it "json subfield one level with casting (json)" $
|
||||||
get "/complex_items?id=eq.1&select=settings->>foo::json" `shouldRespondWith`
|
get "/complex_items?id=eq.1&select=settings->>foo::json" `shouldRespondWith`
|
||||||
[json| [{"foo":{"int":1,"bar":"baz"}}] |] -- the value of foo here is of type "text"
|
[json| [{"foo":{"int":1,"bar":"baz"}}] |] -- the value of foo here is of type "text"
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
it "rename json subfield one level with casting (json)" $
|
it "rename json subfield one level with casting (json)" $
|
||||||
get "/complex_items?id=eq.1&select=myFoo:settings->>foo::json" `shouldRespondWith`
|
get "/complex_items?id=eq.1&select=myFoo:settings->>foo::json" `shouldRespondWith`
|
||||||
[json| [{"myFoo":{"int":1,"bar":"baz"}}] |] -- the value of foo here is of type "text"
|
[json| [{"myFoo":{"int":1,"bar":"baz"}}] |] -- the value of foo here is of type "text"
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
it "fails on bad casting (data of the wrong format)" $
|
it "fails on bad casting (data of the wrong format)" $
|
||||||
get "/complex_items?select=settings->foo->>bar::integer"
|
get "/complex_items?select=settings->foo->>bar::integer"
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` [json| {"hint":null,"details":null,"code":"22P02","message":"invalid input syntax for integer: \"baz\""} |]
|
||||||
matchBody = Just [json| {"hint":null,"details":null,"code":"22P02","message":"invalid input syntax for integer: \"baz\""} |]
|
{ matchStatus = 400
|
||||||
, matchStatus = 400
|
|
||||||
, matchHeaders = []
|
, matchHeaders = []
|
||||||
}
|
}
|
||||||
|
|
||||||
it "fails on bad casting (wrong cast type)" $
|
it "fails on bad casting (wrong cast type)" $
|
||||||
get "/complex_items?select=id::fakecolumntype"
|
get "/complex_items?select=id::fakecolumntype"
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` [json| {"hint":null,"details":null,"code":"42704","message":"type \"fakecolumntype\" does not exist"} |]
|
||||||
matchBody = Just [json| {"hint":null,"details":null,"code":"42704","message":"type \"fakecolumntype\" does not exist"} |]
|
{ matchStatus = 400
|
||||||
, matchStatus = 400
|
|
||||||
, matchHeaders = []
|
, matchHeaders = []
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,19 +202,23 @@ spec = do
|
|||||||
it "json subfield two levels (string)" $
|
it "json subfield two levels (string)" $
|
||||||
get "/complex_items?id=eq.1&select=settings->foo->>bar" `shouldRespondWith`
|
get "/complex_items?id=eq.1&select=settings->foo->>bar" `shouldRespondWith`
|
||||||
[json| [{"bar":"baz"}] |]
|
[json| [{"bar":"baz"}] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
it "rename json subfield two levels (string)" $
|
it "rename json subfield two levels (string)" $
|
||||||
get "/complex_items?id=eq.1&select=myBar:settings->foo->>bar" `shouldRespondWith`
|
get "/complex_items?id=eq.1&select=myBar:settings->foo->>bar" `shouldRespondWith`
|
||||||
[json| [{"myBar":"baz"}] |]
|
[json| [{"myBar":"baz"}] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
|
||||||
it "json subfield two levels with casting (int)" $
|
it "json subfield two levels with casting (int)" $
|
||||||
get "/complex_items?id=eq.1&select=settings->foo->>int::integer" `shouldRespondWith`
|
get "/complex_items?id=eq.1&select=settings->foo->>int::integer" `shouldRespondWith`
|
||||||
[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
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
it "rename json subfield two levels with casting (int)" $
|
it "rename json subfield two levels with casting (int)" $
|
||||||
get "/complex_items?id=eq.1&select=myInt:settings->foo->>int::integer" `shouldRespondWith`
|
get "/complex_items?id=eq.1&select=myInt:settings->foo->>int::integer" `shouldRespondWith`
|
||||||
[json| [{"myInt":1}] |] -- the value in the db is an int, but here we expect a string for now
|
[json| [{"myInt":1}] |] -- the value in the db is an int, but here we expect a string for now
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
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`
|
||||||
@@ -302,70 +296,70 @@ spec = do
|
|||||||
get "/projects?id=in.1,3&select=id,name,client_id,client{id,name}" `shouldRespondWith`
|
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"}}]|]
|
[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"}}]|]
|
||||||
|
|
||||||
|
it "can detect fk relations through views to tables in the public schema" $
|
||||||
|
get "/consumers_view?select=*,orders_view{*}" `shouldRespondWith` 200
|
||||||
|
|
||||||
|
|
||||||
describe "ordering response" $ do
|
describe "ordering response" $ do
|
||||||
it "by a column asc" $
|
it "by a column asc" $
|
||||||
get "/items?id=lte.2&order=id.asc"
|
get "/items?id=lte.2&order=id.asc"
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` [json| [{"id":1},{"id":2}] |]
|
||||||
matchBody = Just [json| [{"id":1},{"id":2}] |]
|
{ matchStatus = 200
|
||||||
, matchStatus = 200
|
|
||||||
, matchHeaders = ["Content-Range" <:> "0-1/*"]
|
, matchHeaders = ["Content-Range" <:> "0-1/*"]
|
||||||
}
|
}
|
||||||
it "by a column desc" $
|
it "by a column desc" $
|
||||||
get "/items?id=lte.2&order=id.desc"
|
get "/items?id=lte.2&order=id.desc"
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` [json| [{"id":2},{"id":1}] |]
|
||||||
matchBody = Just [json| [{"id":2},{"id":1}] |]
|
{ matchStatus = 200
|
||||||
, matchStatus = 200
|
|
||||||
, matchHeaders = ["Content-Range" <:> "0-1/*"]
|
, matchHeaders = ["Content-Range" <:> "0-1/*"]
|
||||||
}
|
}
|
||||||
|
|
||||||
it "by a column with nulls first" $
|
it "by a column with nulls first" $
|
||||||
get "/no_pk?order=a.nullsfirst"
|
get "/no_pk?order=a.nullsfirst"
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` [json| [{"a":null,"b":null},
|
||||||
matchBody = Just [json| [{"a":null,"b":null},
|
|
||||||
{"a":"1","b":"0"},
|
{"a":"1","b":"0"},
|
||||||
{"a":"2","b":"0"}
|
{"a":"2","b":"0"}
|
||||||
] |]
|
] |]
|
||||||
, matchStatus = 200
|
{ matchStatus = 200
|
||||||
, matchHeaders = ["Content-Range" <:> "0-2/*"]
|
, matchHeaders = ["Content-Range" <:> "0-2/*"]
|
||||||
}
|
}
|
||||||
|
|
||||||
it "by a column asc with nulls last" $
|
it "by a column asc with nulls last" $
|
||||||
get "/no_pk?order=a.asc.nullslast"
|
get "/no_pk?order=a.asc.nullslast"
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` [json| [{"a":"1","b":"0"},
|
||||||
matchBody = Just [json| [{"a":"1","b":"0"},
|
|
||||||
{"a":"2","b":"0"},
|
{"a":"2","b":"0"},
|
||||||
{"a":null,"b":null}] |]
|
{"a":null,"b":null}] |]
|
||||||
, matchStatus = 200
|
{ matchStatus = 200
|
||||||
, matchHeaders = ["Content-Range" <:> "0-2/*"]
|
, matchHeaders = ["Content-Range" <:> "0-2/*"]
|
||||||
}
|
}
|
||||||
|
|
||||||
it "by a column desc with nulls first" $
|
it "by a column desc with nulls first" $
|
||||||
get "/no_pk?order=a.desc.nullsfirst"
|
get "/no_pk?order=a.desc.nullsfirst"
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` [json| [{"a":null,"b":null},
|
||||||
matchBody = Just [json| [{"a":null,"b":null},
|
|
||||||
{"a":"2","b":"0"},
|
{"a":"2","b":"0"},
|
||||||
{"a":"1","b":"0"}] |]
|
{"a":"1","b":"0"}] |]
|
||||||
, matchStatus = 200
|
{ matchStatus = 200
|
||||||
, matchHeaders = ["Content-Range" <:> "0-2/*"]
|
, matchHeaders = ["Content-Range" <:> "0-2/*"]
|
||||||
}
|
}
|
||||||
|
|
||||||
it "by a column desc with nulls last" $
|
it "by a column desc with nulls last" $
|
||||||
get "/no_pk?order=a.desc.nullslast"
|
get "/no_pk?order=a.desc.nullslast"
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` [json| [{"a":"2","b":"0"},
|
||||||
matchBody = Just [json| [{"a":"2","b":"0"},
|
|
||||||
{"a":"1","b":"0"},
|
{"a":"1","b":"0"},
|
||||||
{"a":null,"b":null}] |]
|
{"a":null,"b":null}] |]
|
||||||
, matchStatus = 200
|
{ matchStatus = 200
|
||||||
, matchHeaders = ["Content-Range" <:> "0-2/*"]
|
, matchHeaders = ["Content-Range" <:> "0-2/*"]
|
||||||
}
|
}
|
||||||
|
|
||||||
it "by a json column property asc" $
|
it "by a json column property asc" $
|
||||||
get "/json?order=data->>id.asc" `shouldRespondWith`
|
get "/json?order=data->>id.asc" `shouldRespondWith`
|
||||||
[json| [{"data": {"id": 0}}, {"data": {"id": 1, "foo": {"bar": "baz"}}}, {"data": {"id": 3}}] |]
|
[json| [{"data": {"id": 0}}, {"data": {"id": 1, "foo": {"bar": "baz"}}}, {"data": {"id": 3}}] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
it "by a json column with two level property nulls first" $
|
it "by a json column with two level property nulls first" $
|
||||||
get "/json?order=data->foo->>bar.nullsfirst" `shouldRespondWith`
|
get "/json?order=data->foo->>bar.nullsfirst" `shouldRespondWith`
|
||||||
[json| [{"data": {"id": 3}}, {"data": {"id": 0}}, {"data": {"id": 1, "foo": {"bar": "baz"}}}] |]
|
[json| [{"data": {"id": 3}}, {"data": {"id": 0}}, {"data": {"id": 1, "foo": {"bar": "baz"}}}] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
it "without other constraints" $
|
it "without other constraints" $
|
||||||
get "/items?order=id.asc" `shouldRespondWith` 200
|
get "/items?order=id.asc" `shouldRespondWith` 200
|
||||||
@@ -432,18 +426,16 @@ spec = do
|
|||||||
it "should respond with CSV to 'text/csv' request" $
|
it "should respond with CSV to 'text/csv' request" $
|
||||||
request methodGet "/simple_pk"
|
request methodGet "/simple_pk"
|
||||||
(acceptHdrs "text/csv; version=1") ""
|
(acceptHdrs "text/csv; version=1") ""
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` "k,extra\nxyyx,u\nxYYx,v"
|
||||||
matchBody = Just "k,extra\nxyyx,u\nxYYx,v"
|
{ matchStatus = 200
|
||||||
, matchStatus = 200
|
|
||||||
, matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8"]
|
, matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8"]
|
||||||
}
|
}
|
||||||
|
|
||||||
describe "Canonical location" $ do
|
describe "Canonical location" $ do
|
||||||
it "Sets Content-Location with alphabetized params" $
|
it "Sets Content-Location with alphabetized params" $
|
||||||
get "/no_pk?b=eq.1&a=eq.1"
|
get "/no_pk?b=eq.1&a=eq.1"
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` "[]"
|
||||||
matchBody = Just "[]"
|
{ matchStatus = 200
|
||||||
, matchStatus = 200
|
|
||||||
, matchHeaders = ["Content-Location" <:> "/no_pk?a=eq.1&b=eq.1"]
|
, matchHeaders = ["Content-Location" <:> "/no_pk?a=eq.1&b=eq.1"]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -458,23 +450,26 @@ spec = do
|
|||||||
it "can filter by properties inside json column" $ do
|
it "can filter by properties inside json column" $ do
|
||||||
get "/json?data->foo->>bar=eq.baz" `shouldRespondWith`
|
get "/json?data->foo->>bar=eq.baz" `shouldRespondWith`
|
||||||
[json| [{"data": {"id": 1, "foo": {"bar": "baz"}}}] |]
|
[json| [{"data": {"id": 1, "foo": {"bar": "baz"}}}] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
get "/json?data->foo->>bar=eq.fake" `shouldRespondWith`
|
get "/json?data->foo->>bar=eq.fake" `shouldRespondWith`
|
||||||
[json| [] |]
|
[json| [] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
it "can filter by properties inside json column using not" $
|
it "can filter by properties inside json column using not" $
|
||||||
get "/json?data->foo->>bar=not.eq.baz" `shouldRespondWith`
|
get "/json?data->foo->>bar=not.eq.baz" `shouldRespondWith`
|
||||||
[json| [] |]
|
[json| [] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
it "can filter by properties inside json column using ->>" $
|
it "can filter by properties inside json column using ->>" $
|
||||||
get "/json?data->>id=eq.1" `shouldRespondWith`
|
get "/json?data->>id=eq.1" `shouldRespondWith`
|
||||||
[json| [{"data": {"id": 1, "foo": {"bar": "baz"}}}] |]
|
[json| [{"data": {"id": 1, "foo": {"bar": "baz"}}}] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
describe "remote procedure call" $ do
|
describe "remote procedure call" $ do
|
||||||
context "a proc that returns a set" $ do
|
context "a proc that returns a set" $ do
|
||||||
it "returns paginated results" $
|
it "returns paginated results" $
|
||||||
request methodPost "/rpc/getitemrange"
|
request methodPost "/rpc/getitemrange"
|
||||||
(rangeHdrs (ByteRangeFromTo 0 0)) [json| { "min": 2, "max": 4 } |]
|
(rangeHdrs (ByteRangeFromTo 0 0)) [json| { "min": 2, "max": 4 } |]
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` [json| [{"id":3}] |]
|
||||||
matchBody = Just [json| [{"id":3}] |]
|
{ matchStatus = 200
|
||||||
, matchStatus = 200
|
|
||||||
, matchHeaders = ["Content-Range" <:> "0-0/*"]
|
, matchHeaders = ["Content-Range" <:> "0-0/*"]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -482,16 +477,24 @@ spec = do
|
|||||||
request methodPost "/rpc/getitemrange"
|
request methodPost "/rpc/getitemrange"
|
||||||
(rangeHdrsWithCount (ByteRangeFromTo 0 0))
|
(rangeHdrsWithCount (ByteRangeFromTo 0 0))
|
||||||
[json| { "min": 2, "max": 4 } |]
|
[json| { "min": 2, "max": 4 } |]
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` [json| [{"id":3}] |]
|
||||||
matchBody = Just [json| [{"id":3}] |]
|
{ matchStatus = 206 -- it now knows the response is partial
|
||||||
, matchStatus = 206 -- it now knows the response is partial
|
|
||||||
, matchHeaders = ["Content-Range" <:> "0-0/2"]
|
, matchHeaders = ["Content-Range" <:> "0-0/2"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
it "returns proper json" $
|
it "returns proper json" $
|
||||||
post "/rpc/getitemrange" [json| { "min": 2, "max": 4 } |] `shouldRespondWith`
|
post "/rpc/getitemrange" [json| { "min": 2, "max": 4 } |] `shouldRespondWith`
|
||||||
[json| [ {"id": 3}, {"id":4} ] |]
|
[json| [ {"id": 3}, {"id":4} ] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "returns CSV" $
|
||||||
|
request methodPost "/rpc/getitemrange"
|
||||||
|
(acceptHdrs "text/csv")
|
||||||
|
[json| { "min": 2, "max": 4 } |]
|
||||||
|
`shouldRespondWith` "id\n3\n4"
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8"]
|
||||||
|
}
|
||||||
|
|
||||||
context "unknown function" $
|
context "unknown function" $
|
||||||
it "returns 404" $
|
it "returns 404" $
|
||||||
@@ -505,12 +508,12 @@ spec = do
|
|||||||
it "can filter proc results" $
|
it "can filter proc results" $
|
||||||
post "/rpc/getallprojects?id=gt.1&id=lt.5&select=id" [json| {} |] `shouldRespondWith`
|
post "/rpc/getallprojects?id=gt.1&id=lt.5&select=id" [json| {} |] `shouldRespondWith`
|
||||||
[json|[{"id":2},{"id":3},{"id":4}]|]
|
[json|[{"id":2},{"id":3},{"id":4}]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
it "can limit proc results" $
|
it "can limit proc results" $
|
||||||
post "/rpc/getallprojects?id=gt.1&id=lt.5&select=id?limit=2&offset=1" [json| {} |]
|
post "/rpc/getallprojects?id=gt.1&id=lt.5&select=id?limit=2&offset=1" [json| {} |]
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` [json|[{"id":3},{"id":4}]|]
|
||||||
matchBody = Just [json|[{"id":3},{"id":4}]|]
|
{ matchStatus = 200
|
||||||
, matchStatus = 200
|
|
||||||
, matchHeaders = ["Content-Range" <:> "1-2/*"]
|
, matchHeaders = ["Content-Range" <:> "1-2/*"]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -518,23 +521,75 @@ spec = do
|
|||||||
post "/rpc/getproject?select=id,name" [json| { "id": 1} |] `shouldRespondWith`
|
post "/rpc/getproject?select=id,name" [json| { "id": 1} |] `shouldRespondWith`
|
||||||
[str|[{"id":1,"name":"Windows 7"}]|]
|
[str|[{"id":1,"name":"Windows 7"}]|]
|
||||||
|
|
||||||
it "can embed foreign entities to the items returned by a proc" $
|
context "foreign entities embedding" $ do
|
||||||
|
it "can embed if related tables are in the exposed schema" $
|
||||||
post "/rpc/getproject?select=id,name,client{id},tasks{id}" [json| { "id": 1} |] `shouldRespondWith`
|
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}]}]|]
|
[str|[{"id":1,"name":"Windows 7","client":{"id":1},"tasks":[{"id":1},{"id":2}]}]|]
|
||||||
|
|
||||||
|
it "cannot embed if the related table is not in the exposed schema" $
|
||||||
|
post "/rpc/single_article?select=*,article_stars{*}" [json|{ "id": 1}|]
|
||||||
|
`shouldRespondWith` 400
|
||||||
|
|
||||||
|
it "can embed if the related tables are in a hidden schema but exposed as views" $
|
||||||
|
post "/rpc/single_article?select=id,articleStars{userId}" [json|{ "id": 2}|]
|
||||||
|
`shouldRespondWith` [json|[{"id": 2, "articleStars": [{"userId": 3}]}]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
context "a proc that returns an empty rowset" $
|
context "a proc that returns an empty rowset" $
|
||||||
it "returns empty json array" $
|
it "returns empty json array" $
|
||||||
post "/rpc/test_empty_rowset" [json| {} |] `shouldRespondWith`
|
post "/rpc/test_empty_rowset" [json| {} |] `shouldRespondWith`
|
||||||
[json| [] |]
|
[json| [] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
context "a proc that returns plain text" $ do
|
context "proc return types" $ do
|
||||||
it "returns proper json" $
|
context "returns text" $ do
|
||||||
post "/rpc/sayhello" [json| { "name": "world" } |] `shouldRespondWith`
|
it "returns proper json" $
|
||||||
[json|"Hello, world"|]
|
post "/rpc/sayhello" [json| { "name": "world" } |] `shouldRespondWith`
|
||||||
|
[json|"Hello, world"|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
it "can handle unicode" $
|
it "can handle unicode" $
|
||||||
post "/rpc/sayhello" [json| { "name": "¥" } |] `shouldRespondWith`
|
post "/rpc/sayhello" [json| { "name": "¥" } |] `shouldRespondWith`
|
||||||
[json|"Hello, ¥"|]
|
[json|"Hello, ¥"|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "returns enum value" $
|
||||||
|
post "/rpc/ret_enum" [json|{ "val": "foo" }|] `shouldRespondWith`
|
||||||
|
[json|"foo"|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "returns domain value" $
|
||||||
|
post "/rpc/ret_domain" [json|{ "val": "8" }|] `shouldRespondWith`
|
||||||
|
[json|8|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "returns range" $
|
||||||
|
post "/rpc/ret_range" [json|{ "low": 10, "up": 20 }|] `shouldRespondWith`
|
||||||
|
[json|"[10,20)"|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "returns row of scalars" $
|
||||||
|
post "/rpc/ret_scalars" [json|{}|] `shouldRespondWith`
|
||||||
|
[json|[{"a":"scalars", "b":"foo", "c":1, "d":"[10,20)"}]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "returns composite type in exposed schema" $
|
||||||
|
post "/rpc/ret_point_2d" [json|{}|] `shouldRespondWith`
|
||||||
|
[json|[{"x": 10, "y": 5}]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "cannot return composite type in hidden schema" $
|
||||||
|
post "/rpc/ret_point_3d" [json|{}|] `shouldRespondWith` 401
|
||||||
|
|
||||||
|
it "returns single row from table" $
|
||||||
|
post "/rpc/single_article?select=id" [json|{"id": 2}|] `shouldRespondWith`
|
||||||
|
[json|[{"id": 2}]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "returns null for void" $
|
||||||
|
post "/rpc/ret_void" [json|{}|] `shouldRespondWith`
|
||||||
|
[json|null|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
context "improper input" $ do
|
context "improper input" $ do
|
||||||
it "rejects unknown content type even if payload is good" $
|
it "rejects unknown content type even if payload is good" $
|
||||||
@@ -573,14 +628,17 @@ spec = do
|
|||||||
it "executes the proc exactly once per request" $ do
|
it "executes the proc exactly once per request" $ do
|
||||||
post "/rpc/callcounter" [json| {} |] `shouldRespondWith`
|
post "/rpc/callcounter" [json| {} |] `shouldRespondWith`
|
||||||
[json|1|]
|
[json|1|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
post "/rpc/callcounter" [json| {} |] `shouldRespondWith`
|
post "/rpc/callcounter" [json| {} |] `shouldRespondWith`
|
||||||
[json|2|]
|
[json|2|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
context "expects a single json object" $ do
|
context "expects a single json object" $ do
|
||||||
it "does not expand posted json into parameters" $
|
it "does not expand posted json into parameters" $
|
||||||
request methodPost "/rpc/singlejsonparam"
|
request methodPost "/rpc/singlejsonparam"
|
||||||
[("Prefer","params=single-object")] [json| { "p1": 1, "p2": "text", "p3" : {"obj":"text"} } |] `shouldRespondWith`
|
[("Prefer","params=single-object")] [json| { "p1": 1, "p2": "text", "p3" : {"obj":"text"} } |] `shouldRespondWith`
|
||||||
[json| { "p1": 1, "p2": "text", "p3" : {"obj":"text"} } |]
|
[json| { "p1": 1, "p2": "text", "p3" : {"obj":"text"} } |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
it "accepts parameters from an html form" $
|
it "accepts parameters from an html form" $
|
||||||
request methodPost "/rpc/singlejsonparam"
|
request methodPost "/rpc/singlejsonparam"
|
||||||
@@ -589,18 +647,205 @@ spec = do
|
|||||||
"boolean=false&date=1900-01-01&money=$3.99&enum=foo") `shouldRespondWith`
|
"boolean=false&date=1900-01-01&money=$3.99&enum=foo") `shouldRespondWith`
|
||||||
[json| { "integer": "7", "double": "2.71828", "varchar" : "forms are fun"
|
[json| { "integer": "7", "double": "2.71828", "varchar" : "forms are fun"
|
||||||
, "boolean":"false", "date":"1900-01-01", "money":"$3.99", "enum":"foo" } |]
|
, "boolean":"false", "date":"1900-01-01", "money":"$3.99", "enum":"foo" } |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
context "a proc that receives no parameters" $
|
||||||
|
it "interprets empty string as empty json object on a post request" $
|
||||||
|
post "/rpc/noparamsproc" BL.empty `shouldRespondWith`
|
||||||
|
[json| "Return value of no parameters procedure." |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
describe "weird requests" $ do
|
describe "weird requests" $ do
|
||||||
it "can query as normal" $ do
|
it "can query as normal" $ do
|
||||||
get "/Escap3e;" `shouldRespondWith`
|
get "/Escap3e;" `shouldRespondWith`
|
||||||
[json| [{"so6meIdColumn":1},{"so6meIdColumn":2},{"so6meIdColumn":3},{"so6meIdColumn":4},{"so6meIdColumn":5}] |]
|
[json| [{"so6meIdColumn":1},{"so6meIdColumn":2},{"so6meIdColumn":3},{"so6meIdColumn":4},{"so6meIdColumn":5}] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
get "/ghostBusters" `shouldRespondWith`
|
get "/ghostBusters" `shouldRespondWith`
|
||||||
[json| [{"escapeId":1},{"escapeId":3},{"escapeId":5}] |]
|
[json| [{"escapeId":1},{"escapeId":3},{"escapeId":5}] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "fails if an operator is not given" $
|
||||||
|
get "/ghostBusters?id=0" `shouldRespondWith` [json| {"details":"unexpected \"0\" expecting \"not\" or operator (eq, gt, ...)","message":"\"failed to parse filter (0)\" (line 1, column 1)"} |]
|
||||||
|
{ matchStatus = 400
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
|
||||||
it "will embed a collection" $
|
it "will embed a collection" $
|
||||||
get "/Escap3e;?select=ghostBusters{*}" `shouldRespondWith`
|
get "/Escap3e;?select=ghostBusters{*}" `shouldRespondWith`
|
||||||
[json| [{"ghostBusters":[{"escapeId":1}]},{"ghostBusters":[]},{"ghostBusters":[{"escapeId":3}]},{"ghostBusters":[]},{"ghostBusters":[{"escapeId":5}]}] |]
|
[json| [{"ghostBusters":[{"escapeId":1}]},{"ghostBusters":[]},{"ghostBusters":[{"escapeId":3}]},{"ghostBusters":[]},{"ghostBusters":[{"escapeId":5}]}] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
it "will embed using a column" $
|
it "will embed using a column" $
|
||||||
get "/ghostBusters?select=escapeId{*}" `shouldRespondWith`
|
get "/ghostBusters?select=escapeId{*}" `shouldRespondWith`
|
||||||
[json| [{"escapeId":{"so6meIdColumn":1}},{"escapeId":{"so6meIdColumn":3}},{"escapeId":{"so6meIdColumn":5}}] |]
|
[json| [{"escapeId":{"so6meIdColumn":1}},{"escapeId":{"so6meIdColumn":3}},{"escapeId":{"so6meIdColumn":5}}] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
describe "binary output" $ do
|
||||||
|
it "can query if a single column is selected" $
|
||||||
|
request methodGet "/images_base64?select=img&name=eq.A.png" (acceptHdrs "application/octet-stream") ""
|
||||||
|
`shouldRespondWith` "iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEUAAAD/AAAb/40iAAAAP0lEQVQI12NgwAbYG2AE/wEYwQMiZB4ACQkQYZEAIgqAhAGIKLCAEQ8kgMT/P1CCEUwc4IMSzA3sUIIdCHECAGSQEkeOTUyCAAAAAElFTkSuQmCC"
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = ["Content-Type" <:> "application/octet-stream; charset=utf-8"]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "fails if a single column is not selected" $ do
|
||||||
|
request methodGet "/images?select=img,name&name=eq.A.png" (acceptHdrs "application/octet-stream") ""
|
||||||
|
`shouldRespondWith` 406
|
||||||
|
request methodGet "/images?select=*&name=eq.A.png" (acceptHdrs "application/octet-stream") ""
|
||||||
|
`shouldRespondWith` 406
|
||||||
|
request methodGet "/images?name=eq.A.png" (acceptHdrs "application/octet-stream") ""
|
||||||
|
`shouldRespondWith` 406
|
||||||
|
|
||||||
|
it "concatenates results if more than one row is returned" $
|
||||||
|
request methodGet "/images_base64?select=img&name=in.A.png,B.png" (acceptHdrs "application/octet-stream") ""
|
||||||
|
`shouldRespondWith` "iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEUAAAD/AAAb/40iAAAAP0lEQVQI12NgwAbYG2AE/wEYwQMiZB4ACQkQYZEAIgqAhAGIKLCAEQ8kgMT/P1CCEUwc4IMSzA3sUIIdCHECAGSQEkeOTUyCAAAAAElFTkSuQmCCiVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEX///8AAP94wDzzAAAAL0lEQVQIW2NgwAb+HwARH0DEDyDxwAZEyGAhLODqHmBRzAcn5GAS///A1IF14AAA5/Adbiiz/0gAAAAASUVORK5CYII="
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = ["Content-Type" <:> "application/octet-stream; charset=utf-8"]
|
||||||
|
}
|
||||||
|
describe "HTTP request env vars" $ do
|
||||||
|
it "custom header is set" $
|
||||||
|
request methodPost "/rpc/get_guc_value"
|
||||||
|
[("Custom-Header", "test")]
|
||||||
|
[json| { "name": "request.header.custom-header" } |]
|
||||||
|
`shouldRespondWith`
|
||||||
|
[str|"test"|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = [ matchContentTypeJson ]
|
||||||
|
}
|
||||||
|
it "standard header is set" $
|
||||||
|
request methodPost "/rpc/get_guc_value"
|
||||||
|
[("Origin", "http://example.com")]
|
||||||
|
[json| { "name": "request.header.origin" } |]
|
||||||
|
`shouldRespondWith`
|
||||||
|
[str|"http://example.com"|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = [ matchContentTypeJson ]
|
||||||
|
}
|
||||||
|
it "current role is available as GUC claim" $
|
||||||
|
request methodPost "/rpc/get_guc_value" []
|
||||||
|
[json| { "name": "request.jwt.claim.role" } |]
|
||||||
|
`shouldRespondWith`
|
||||||
|
[str|"postgrest_test_anonymous"|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = [ matchContentTypeJson ]
|
||||||
|
}
|
||||||
|
it "single cookie ends up as claims" $
|
||||||
|
request methodPost "/rpc/get_guc_value" [("Cookie","acookie=cookievalue")]
|
||||||
|
[json| {"name":"request.cookie.acookie"} |]
|
||||||
|
`shouldRespondWith`
|
||||||
|
[str|"cookievalue"|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = []
|
||||||
|
}
|
||||||
|
|
||||||
|
it "multiple cookies ends up as claims" $
|
||||||
|
request methodPost "/rpc/get_guc_value" [("Cookie","acookie=cookievalue;secondcookie=anothervalue")]
|
||||||
|
[json| {"name":"request.cookie.secondcookie"} |]
|
||||||
|
`shouldRespondWith`
|
||||||
|
[str|"anothervalue"|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = []
|
||||||
|
}
|
||||||
|
|
||||||
|
describe "values with quotes in IN and NOTIN operators" $ do
|
||||||
|
it "succeeds when only quoted values are present" $ do
|
||||||
|
get "/w_or_wo_comma_names?name=in.\"Hebdon, John\"" `shouldRespondWith`
|
||||||
|
[json| [{"name":"Hebdon, John"}] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
get "/w_or_wo_comma_names?name=in.\"Hebdon, John\",\"Williams, Mary\",\"Smith, Joseph\"" `shouldRespondWith`
|
||||||
|
[json| [{"name":"Hebdon, John"},{"name":"Williams, Mary"},{"name":"Smith, Joseph"}] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
get "/w_or_wo_comma_names?name=notin.\"Hebdon, John\",\"Williams, Mary\",\"Smith, Joseph\"" `shouldRespondWith`
|
||||||
|
[json| [{"name":"David White"},{"name":"Larry Thompson"}] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
get "/w_or_wo_comma_names?name=not.in.\"Hebdon, John\",\"Williams, Mary\",\"Smith, Joseph\"" `shouldRespondWith`
|
||||||
|
[json| [{"name":"David White"},{"name":"Larry Thompson"}] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "succeeds w/ and w/o quoted values" $ do
|
||||||
|
get "/w_or_wo_comma_names?name=in.David White,\"Hebdon, John\"" `shouldRespondWith`
|
||||||
|
[json| [{"name":"Hebdon, John"},{"name":"David White"}] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
get "/w_or_wo_comma_names?name=not.in.\"Hebdon, John\",Larry Thompson,\"Smith, Joseph\"" `shouldRespondWith`
|
||||||
|
[json| [{"name":"Williams, Mary"},{"name":"David White"}] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
get "/w_or_wo_comma_names?name=notin.\"Hebdon, John\",David White,\"Williams, Mary\",Larry Thompson" `shouldRespondWith`
|
||||||
|
[json| [{"name":"Smith, Joseph"}] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "checks well formed quoted values" $ do
|
||||||
|
get "/w_or_wo_comma_names?name=in.\"\"Hebdon, John\"" `shouldRespondWith`
|
||||||
|
[json| [] |] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
get "/w_or_wo_comma_names?name=in.\"\"Hebdon, John\"\"Mary" `shouldRespondWith`
|
||||||
|
[json| [] |] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
get "/w_or_wo_comma_names?name=in.Williams\"Hebdon, John\"" `shouldRespondWith`
|
||||||
|
[json| [] |] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
describe "IN and NOT IN empty set" $ do
|
||||||
|
context "returns an empty result for IN when no value is present" $ do
|
||||||
|
it "works for integer" $
|
||||||
|
get "/items_with_different_col_types?int_data=in." `shouldRespondWith`
|
||||||
|
[json| [] |] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
it "works for text" $
|
||||||
|
get "/items_with_different_col_types?text_data=in." `shouldRespondWith`
|
||||||
|
[json| [] |] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
it "works for bool" $
|
||||||
|
get "/items_with_different_col_types?bool_data=in." `shouldRespondWith`
|
||||||
|
[json| [] |] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
it "works for bytea" $
|
||||||
|
get "/items_with_different_col_types?bin_data=in." `shouldRespondWith`
|
||||||
|
[json| [] |] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
it "works for char" $
|
||||||
|
get "/items_with_different_col_types?char_data=in." `shouldRespondWith`
|
||||||
|
[json| [] |] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
it "works for date" $
|
||||||
|
get "/items_with_different_col_types?date_data=in." `shouldRespondWith`
|
||||||
|
[json| [] |] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
it "works for real" $
|
||||||
|
get "/items_with_different_col_types?real_data=in." `shouldRespondWith`
|
||||||
|
[json| [] |] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
it "works for time" $
|
||||||
|
get "/items_with_different_col_types?time_data=in." `shouldRespondWith`
|
||||||
|
[json| [] |] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "returns all results for notin when no value is present" $
|
||||||
|
get "/items_with_different_col_types?int_data=notin.&select=int_data" `shouldRespondWith`
|
||||||
|
[json| [{int_data: 1}] |] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "returns all results for not.in when no value is present" $
|
||||||
|
get "/items_with_different_col_types?int_data=not.in.&select=int_data" `shouldRespondWith`
|
||||||
|
[json| [{int_data: 1}] |] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "returns an empty result ignoring spaces" $
|
||||||
|
get "/items_with_different_col_types?int_data=in. " `shouldRespondWith`
|
||||||
|
[json| [] |] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "only returns an empty result set if the in value is empty" $
|
||||||
|
get "/items_with_different_col_types?int_data=in. ,3,4" `shouldRespondWith` 400
|
||||||
|
|
||||||
|
it "returns empty result when the in value is empty between parentheses" $
|
||||||
|
get "/items_with_different_col_types?int_data=in.()" `shouldRespondWith`
|
||||||
|
[json| [] |] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "returns all results when the notin value is empty between parentheses" $ do
|
||||||
|
get "/items_with_different_col_types?int_data=notin.()&select=int_data" `shouldRespondWith`
|
||||||
|
[json| [{int_data: 1}] |] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
get "/items_with_different_col_types?int_data=not.in.()&select=int_data" `shouldRespondWith`
|
||||||
|
[json| [{int_data: 1}] |] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
describe "Transition to url safe characters" $ do
|
||||||
|
context "top level in operator" $ do
|
||||||
|
it "works with parentheses" $
|
||||||
|
get "/entities?id=in.(1,2,3)&select=id" `shouldRespondWith`
|
||||||
|
[json| [{"id": 1}, {"id": 2}, {"id": 3}] |] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
it "works without parentheses" $
|
||||||
|
get "/entities?id=in.1,2,3&select=id" `shouldRespondWith`
|
||||||
|
[json| [{"id": 1}, {"id": 2}, {"id": 3}] |] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
context "select query param" $ do
|
||||||
|
it "works with parentheses" $
|
||||||
|
get "/entities?id=eq.2&select=id,child_entities(id)" `shouldRespondWith`
|
||||||
|
[json| [{"id": 2, "child_entities": [{"id": 3}]}] |] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
it "works with brackets" $
|
||||||
|
get "/entities?id=eq.2&select=id,child_entities{id}" `shouldRespondWith`
|
||||||
|
[json| [{"id": 2, "child_entities": [{"id": 3}]}] |] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
|||||||
+33
-56
@@ -31,19 +31,12 @@ spec = do
|
|||||||
context "when I don't want the count" $ do
|
context "when I don't want the count" $ do
|
||||||
it "returns range Content-Range with */* for empty range" $
|
it "returns range Content-Range with */* for empty range" $
|
||||||
request methodPost "/rpc/getitemrange" [] emptyRange
|
request methodPost "/rpc/getitemrange" [] emptyRange
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` [json| [] |] {matchHeaders = ["Content-Range" <:> "*/*"]}
|
||||||
matchBody = Just [json| [] |]
|
|
||||||
, matchStatus = 200
|
|
||||||
, matchHeaders = ["Content-Range" <:> "*/*"]
|
|
||||||
}
|
|
||||||
|
|
||||||
it "returns range Content-Range with range/*" $
|
it "returns range Content-Range with range/*" $
|
||||||
request methodPost "/rpc/getitemrange" [] defaultRange
|
request methodPost "/rpc/getitemrange" [] defaultRange
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`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}] |]
|
||||||
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}] |]
|
{ matchHeaders = ["Content-Range" <:> "0-14/*"] }
|
||||||
, matchStatus = 200
|
|
||||||
, matchHeaders = ["Content-Range" <:> "0-14/*"]
|
|
||||||
}
|
|
||||||
|
|
||||||
context "with range headers" $ do
|
context "with range headers" $ do
|
||||||
|
|
||||||
@@ -64,9 +57,8 @@ spec = do
|
|||||||
it "returns an empty body when there are no results" $
|
it "returns an empty body when there are no results" $
|
||||||
request methodPost "/rpc/getitemrange"
|
request methodPost "/rpc/getitemrange"
|
||||||
(rangeHdrs $ ByteRangeFromTo 0 1) emptyRange
|
(rangeHdrs $ ByteRangeFromTo 0 1) emptyRange
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` "[]"
|
||||||
matchBody = Just "[]"
|
{ matchStatus = 200
|
||||||
, matchStatus = 200
|
|
||||||
, matchHeaders = ["Content-Range" <:> "*/*"]
|
, matchHeaders = ["Content-Range" <:> "*/*"]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,18 +87,16 @@ spec = do
|
|||||||
it "refuses a range with nonzero start when there are no items" $
|
it "refuses a range with nonzero start when there are no items" $
|
||||||
request methodPost "/rpc/getitemrange"
|
request methodPost "/rpc/getitemrange"
|
||||||
(rangeHdrsWithCount $ ByteRangeFromTo 1 2) emptyRange
|
(rangeHdrsWithCount $ ByteRangeFromTo 1 2) emptyRange
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` "[]"
|
||||||
matchBody = Nothing
|
{ matchStatus = 416
|
||||||
, matchStatus = 416
|
|
||||||
, matchHeaders = ["Content-Range" <:> "*/0"]
|
, matchHeaders = ["Content-Range" <:> "*/0"]
|
||||||
}
|
}
|
||||||
|
|
||||||
it "refuses a range requesting start past last item" $
|
it "refuses a range requesting start past last item" $
|
||||||
request methodPost "/rpc/getitemrange"
|
request methodPost "/rpc/getitemrange"
|
||||||
(rangeHdrsWithCount $ ByteRangeFromTo 100 199) defaultRange
|
(rangeHdrsWithCount $ ByteRangeFromTo 100 199) defaultRange
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` "[]"
|
||||||
matchBody = Nothing
|
{ matchStatus = 416
|
||||||
, matchStatus = 416
|
|
||||||
, matchHeaders = ["Content-Range" <:> "*/15"]
|
, matchHeaders = ["Content-Range" <:> "*/15"]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,68 +110,58 @@ spec = do
|
|||||||
it "returns range Content-Range with /*" $
|
it "returns range Content-Range with /*" $
|
||||||
request methodGet "/menagerie"
|
request methodGet "/menagerie"
|
||||||
[("Prefer", "count=none")] ""
|
[("Prefer", "count=none")] ""
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` "[]"
|
||||||
matchBody = Just "[]"
|
{ matchStatus = 200
|
||||||
, matchStatus = 200
|
|
||||||
, matchHeaders = ["Content-Range" <:> "*/*"]
|
, matchHeaders = ["Content-Range" <:> "*/*"]
|
||||||
}
|
}
|
||||||
|
|
||||||
it "returns range Content-Range with range/*" $
|
it "returns range Content-Range with range/*" $
|
||||||
request methodGet "/items?order=id"
|
request methodGet "/items?order=id"
|
||||||
[("Prefer", "count=none")] ""
|
[("Prefer", "count=none")] ""
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`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}] |]
|
||||||
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}] |]
|
{ matchHeaders = ["Content-Range" <:> "0-14/*"] }
|
||||||
, matchStatus = 200
|
|
||||||
, matchHeaders = ["Content-Range" <:> "0-14/*"]
|
|
||||||
}
|
|
||||||
|
|
||||||
it "returns range Content-Range with range/* even using other filters" $
|
it "returns range Content-Range with range/* even using other filters" $
|
||||||
request methodGet "/items?id=eq.1&order=id"
|
request methodGet "/items?id=eq.1&order=id"
|
||||||
[("Prefer", "count=none")] ""
|
[("Prefer", "count=none")] ""
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` [json| [{"id":1}] |]
|
||||||
matchBody = Just [json| [{"id":1}] |]
|
{ matchHeaders = ["Content-Range" <:> "0-0/*"] }
|
||||||
, matchStatus = 200
|
|
||||||
, matchHeaders = ["Content-Range" <:> "0-0/*"]
|
|
||||||
}
|
|
||||||
|
|
||||||
context "with limit/offset parameters" $ do
|
context "with limit/offset parameters" $ do
|
||||||
it "no parameters return everything" $
|
it "no parameters return everything" $
|
||||||
get "/items?select=id&order=id.asc"
|
get "/items?select=id&order=id.asc"
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith`
|
||||||
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}]|]
|
[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
|
{ matchStatus = 200
|
||||||
, matchHeaders = ["Content-Range" <:> "0-14/*"]
|
, matchHeaders = ["Content-Range" <:> "0-14/*"]
|
||||||
}
|
}
|
||||||
it "top level limit with parameter" $
|
it "top level limit with parameter" $
|
||||||
get "/items?select=id&order=id.asc&limit=3"
|
get "/items?select=id&order=id.asc&limit=3"
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` [str|[{"id":1},{"id":2},{"id":3}]|]
|
||||||
matchBody = Just [str|[{"id":1},{"id":2},{"id":3}]|]
|
{ matchStatus = 200
|
||||||
, matchStatus = 200
|
|
||||||
, matchHeaders = ["Content-Range" <:> "0-2/*"]
|
, matchHeaders = ["Content-Range" <:> "0-2/*"]
|
||||||
}
|
}
|
||||||
it "headers override get parameters" $
|
it "headers override get parameters" $
|
||||||
request methodGet "/items?select=id&order=id.asc&limit=3"
|
request methodGet "/items?select=id&order=id.asc&limit=3"
|
||||||
(rangeHdrs $ ByteRangeFromTo 0 1) ""
|
(rangeHdrs $ ByteRangeFromTo 0 1) ""
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` [str|[{"id":1},{"id":2}]|]
|
||||||
matchBody = Just [str|[{"id":1},{"id":2}]|]
|
{ matchStatus = 200
|
||||||
, matchStatus = 200
|
|
||||||
, matchHeaders = ["Content-Range" <:> "0-1/*"]
|
, matchHeaders = ["Content-Range" <:> "0-1/*"]
|
||||||
}
|
}
|
||||||
|
|
||||||
it "limit works on all levels" $
|
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=2&projects.tasks.order=id.asc&projects.tasks.limit=1"
|
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 {
|
`shouldRespondWith`
|
||||||
matchBody = Just [str|[{"id":1,"projects":[{"id":1,"tasks":[{"id":1}]},{"id":2,"tasks":[{"id":3}]}]}]|]
|
[str|[{"id":1,"projects":[{"id":1,"tasks":[{"id":1}]},{"id":2,"tasks":[{"id":3}]}]}]|]
|
||||||
, matchStatus = 200
|
{ matchStatus = 200
|
||||||
, matchHeaders = ["Content-Range" <:> "0-0/*"]
|
, matchHeaders = ["Content-Range" <:> "0-0/*"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
it "limit and offset works on first level" $
|
it "limit and offset works on first level" $
|
||||||
get "/items?select=id&order=id.asc&limit=3&offset=2"
|
get "/items?select=id&order=id.asc&limit=3&offset=2"
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` [str|[{"id":3},{"id":4},{"id":5}]|]
|
||||||
matchBody = Just [str|[{"id":3},{"id":4},{"id":5}]|]
|
{ matchStatus = 200
|
||||||
, matchStatus = 200
|
|
||||||
, matchHeaders = ["Content-Range" <:> "2-4/*"]
|
, matchHeaders = ["Content-Range" <:> "2-4/*"]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,9 +184,8 @@ spec = do
|
|||||||
it "returns an empty body when there are no results" $
|
it "returns an empty body when there are no results" $
|
||||||
request methodGet "/menagerie"
|
request methodGet "/menagerie"
|
||||||
(rangeHdrs $ ByteRangeFromTo 0 1) ""
|
(rangeHdrs $ ByteRangeFromTo 0 1) ""
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` "[]"
|
||||||
matchBody = Just "[]"
|
{ matchStatus = 200
|
||||||
, matchStatus = 200
|
|
||||||
, matchHeaders = ["Content-Range" <:> "*/*"]
|
, matchHeaders = ["Content-Range" <:> "*/*"]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,17 +214,15 @@ spec = do
|
|||||||
it "refuses a range with nonzero start when there are no items" $
|
it "refuses a range with nonzero start when there are no items" $
|
||||||
request methodGet "/menagerie"
|
request methodGet "/menagerie"
|
||||||
(rangeHdrsWithCount $ ByteRangeFromTo 1 2) ""
|
(rangeHdrsWithCount $ ByteRangeFromTo 1 2) ""
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` "[]"
|
||||||
matchBody = Nothing
|
{ matchStatus = 416
|
||||||
, matchStatus = 416
|
|
||||||
, matchHeaders = ["Content-Range" <:> "*/0"]
|
, matchHeaders = ["Content-Range" <:> "*/0"]
|
||||||
}
|
}
|
||||||
|
|
||||||
it "refuses a range requesting start past last item" $
|
it "refuses a range requesting start past last item" $
|
||||||
request methodGet "/items"
|
request methodGet "/items"
|
||||||
(rangeHdrsWithCount $ ByteRangeFromTo 100 199) ""
|
(rangeHdrsWithCount $ ByteRangeFromTo 100 199) ""
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` "[]"
|
||||||
matchBody = Nothing
|
{ matchStatus = 416
|
||||||
, matchStatus = 416
|
|
||||||
, matchHeaders = ["Content-Range" <:> "*/15"]
|
, matchHeaders = ["Content-Range" <:> "*/15"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,16 +86,14 @@ spec =
|
|||||||
request methodPost "/addresses"
|
request methodPost "/addresses"
|
||||||
[("Prefer", "return=minimal"), singular]
|
[("Prefer", "return=minimal"), singular]
|
||||||
[json| [ { id: 101, address: "xxx" } ] |]
|
[json| [ { id: 101, address: "xxx" } ] |]
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` ""
|
||||||
matchBody = Just ""
|
{ matchStatus = 201
|
||||||
, matchStatus = 201
|
|
||||||
, matchHeaders = ["Content-Range" <:> "*/*"]
|
, matchHeaders = ["Content-Range" <:> "*/*"]
|
||||||
}
|
}
|
||||||
-- and the element should exist
|
-- and the element should exist
|
||||||
get "/addresses?id=eq.101"
|
get "/addresses?id=eq.101"
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` [str|[{"id":101,"address":"xxx"}]|]
|
||||||
matchBody = Just [str|[{"id":101,"address":"xxx"}]|]
|
{ matchStatus = 200
|
||||||
, matchStatus = 200
|
|
||||||
, matchHeaders = []
|
, matchHeaders = []
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,9 +111,8 @@ spec =
|
|||||||
request methodPost "/addresses"
|
request methodPost "/addresses"
|
||||||
[("Prefer", "return=minimal"), singular]
|
[("Prefer", "return=minimal"), singular]
|
||||||
[json| [ { id: 200, address: "xxx" }, { id: 201, address: "yyy" } ] |]
|
[json| [ { id: 200, address: "xxx" }, { id: 201, address: "yyy" } ] |]
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` ""
|
||||||
matchBody = Just ""
|
{ matchStatus = 201
|
||||||
, matchStatus = 201
|
|
||||||
, matchHeaders = ["Content-Range" <:> "*/*"]
|
, matchHeaders = ["Content-Range" <:> "*/*"]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,11 +139,9 @@ spec =
|
|||||||
[("Prefer", "return=representation"), singular] ""
|
[("Prefer", "return=representation"), singular] ""
|
||||||
`shouldRespondWith` 406
|
`shouldRespondWith` 406
|
||||||
|
|
||||||
-- the rows should not exist, either
|
|
||||||
get firstItems
|
get firstItems
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` [json| [{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10}] |]
|
||||||
matchBody = Nothing
|
{ matchStatus = 200
|
||||||
, matchStatus = 200
|
|
||||||
, matchHeaders = ["Content-Range" <:> "0-9/*"]
|
, matchHeaders = ["Content-Range" <:> "0-9/*"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import Test.Hspec.Wai.JSON
|
|||||||
import Network.Wai (Application)
|
import Network.Wai (Application)
|
||||||
import Control.Monad (void)
|
import Control.Monad (void)
|
||||||
|
|
||||||
|
import SpecHelper
|
||||||
|
|
||||||
import Protolude hiding (get)
|
import Protolude hiding (get)
|
||||||
|
|
||||||
spec :: SpecWith Application
|
spec :: SpecWith Application
|
||||||
@@ -20,3 +22,4 @@ spec =
|
|||||||
|
|
||||||
get "/%D9%85%D9%88%D8%A7%D8%B1%D8%AF"
|
get "/%D9%85%D9%88%D8%A7%D8%B1%D8%AF"
|
||||||
`shouldRespondWith` [json| [{ "هویت": 1 }] |]
|
`shouldRespondWith` [json| [{ "هویت": 1 }] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|||||||
+9
-7
@@ -26,6 +26,7 @@ import qualified Feature.StructureSpec
|
|||||||
import qualified Feature.SingularSpec
|
import qualified Feature.SingularSpec
|
||||||
import qualified Feature.UnicodeSpec
|
import qualified Feature.UnicodeSpec
|
||||||
import qualified Feature.ProxySpec
|
import qualified Feature.ProxySpec
|
||||||
|
import qualified Feature.AndOrParamsSpec
|
||||||
|
|
||||||
import Protolude
|
import Protolude
|
||||||
|
|
||||||
@@ -41,13 +42,13 @@ main = do
|
|||||||
|
|
||||||
|
|
||||||
result <- P.use pool $ getDbStructure "test"
|
result <- P.use pool $ getDbStructure "test"
|
||||||
refDbStructure <- newIORef $ either (panic.show) id result
|
refDbStructure <- newIORef $ Just $ either (panic.show) id result
|
||||||
let withApp = return $ postgrest (testCfg testDbConn) refDbStructure pool getTime
|
let withApp = return $ postgrest (testCfg testDbConn) refDbStructure pool getTime $ pure ()
|
||||||
ltdApp = return $ postgrest (testLtdRowsCfg testDbConn) refDbStructure pool getTime
|
ltdApp = return $ postgrest (testLtdRowsCfg testDbConn) refDbStructure pool getTime $ pure ()
|
||||||
unicodeApp = return $ postgrest (testUnicodeCfg testDbConn) refDbStructure pool getTime
|
unicodeApp = return $ postgrest (testUnicodeCfg testDbConn) refDbStructure pool getTime $ pure ()
|
||||||
proxyApp = return $ postgrest (testProxyCfg testDbConn) refDbStructure pool getTime
|
proxyApp = return $ postgrest (testProxyCfg testDbConn) refDbStructure pool getTime $ pure ()
|
||||||
noJwtApp = return $ postgrest (testCfgNoJWT testDbConn) refDbStructure pool getTime
|
noJwtApp = return $ postgrest (testCfgNoJWT testDbConn) refDbStructure pool getTime $ pure ()
|
||||||
binaryJwtApp = return $ postgrest (testCfgBinaryJWT testDbConn) refDbStructure pool getTime
|
binaryJwtApp = return $ postgrest (testCfgBinaryJWT testDbConn) refDbStructure pool getTime $ pure ()
|
||||||
|
|
||||||
let reset = resetDb testDbConn
|
let reset = resetDb testDbConn
|
||||||
hspec $ do
|
hspec $ do
|
||||||
@@ -84,4 +85,5 @@ main = do
|
|||||||
, ("Feature.RangeSpec" , Feature.RangeSpec.spec)
|
, ("Feature.RangeSpec" , Feature.RangeSpec.spec)
|
||||||
, ("Feature.SingularSpec" , Feature.SingularSpec.spec)
|
, ("Feature.SingularSpec" , Feature.SingularSpec.spec)
|
||||||
, ("Feature.StructureSpec" , Feature.StructureSpec.spec)
|
, ("Feature.StructureSpec" , Feature.StructureSpec.spec)
|
||||||
|
, ("Feature.AndOrParamsSpec" , Feature.AndOrParamsSpec.spec)
|
||||||
]
|
]
|
||||||
|
|||||||
+7
-1
@@ -25,10 +25,16 @@ import Network.Wai.Test (SResponse(simpleStatus, simpleHeaders, simpleBody))
|
|||||||
|
|
||||||
import Data.Maybe (fromJust)
|
import Data.Maybe (fromJust)
|
||||||
import Data.Aeson (decode, Value(..))
|
import Data.Aeson (decode, Value(..))
|
||||||
import qualified Data.JsonSchema.Draft4 as D4
|
import qualified JSONSchema.Draft4 as D4
|
||||||
|
|
||||||
import Protolude
|
import Protolude
|
||||||
|
|
||||||
|
matchContentTypeJson :: MatchHeader
|
||||||
|
matchContentTypeJson = "Content-Type" <:> "application/json; charset=utf-8"
|
||||||
|
|
||||||
|
matchContentTypeSingular :: MatchHeader
|
||||||
|
matchContentTypeSingular = "Content-Type" <:> "application/vnd.pgrst.object+json; charset=utf-8"
|
||||||
|
|
||||||
validateOpenApiResponse :: [Header] -> WaiSession ()
|
validateOpenApiResponse :: [Header] -> WaiSession ()
|
||||||
validateOpenApiResponse headers = do
|
validateOpenApiResponse headers = do
|
||||||
r <- request methodGet "/" headers ""
|
r <- request methodGet "/" headers ""
|
||||||
|
|||||||
+3
-1
@@ -43,8 +43,10 @@ WHERE pg_stat_activity.datname = '$DB'
|
|||||||
|
|
||||||
DROP DATABASE IF EXISTS $DB;
|
DROP DATABASE IF EXISTS $DB;
|
||||||
DROP ROLE IF EXISTS $TEST_USER_NAME;
|
DROP ROLE IF EXISTS $TEST_USER_NAME;
|
||||||
CREATE USER $TEST_USER_NAME WITH LOGIN NOINHERIT PASSWORD '$TEST_USER_PASS' CREATEROLE;
|
CREATE USER $TEST_USER_NAME WITH SUPERUSER LOGIN NOINHERIT PASSWORD '$TEST_USER_PASS' CREATEROLE;
|
||||||
CREATE DATABASE $DB OWNER $TEST_USER_NAME;
|
CREATE DATABASE $DB OWNER $TEST_USER_NAME;
|
||||||
|
\\connect $DB
|
||||||
|
ALTER SCHEMA public OWNER TO $TEST_USER_NAME;
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
PGDATABASE=$DB PGOPTIONS='-c client_min_messages=WARNING' psql "$URI" --set=db=$DB -Xq <<EOF
|
PGDATABASE=$DB PGOPTIONS='-c client_min_messages=WARNING' psql "$URI" --set=db=$DB -Xq <<EOF
|
||||||
|
|||||||
Vendored
+32
@@ -283,6 +283,38 @@ TRUNCATE TABLE orders CASCADE;
|
|||||||
INSERT INTO orders VALUES (1, 'order 1', 1, 2);
|
INSERT INTO orders VALUES (1, 'order 1', 1, 2);
|
||||||
INSERT INTO orders VALUES (2, 'order 2', 3, 4);
|
INSERT INTO orders VALUES (2, 'order 2', 3, 4);
|
||||||
|
|
||||||
|
TRUNCATE TABLE images CASCADE;
|
||||||
|
INSERT INTO images(name, img) VALUES ('A.png', decode('iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEUAAAD/AAAb/40iAAAAP0lEQVQI12NgwAbYG2AE/wEYwQMiZB4ACQkQYZEAIgqAhAGIKLCAEQ8kgMT/P1CCEUwc4IMSzA3sUIIdCHECAGSQEkeOTUyCAAAAAElFTkSuQmCC', 'base64'));
|
||||||
|
INSERT INTO images(name, img) VALUES ('B.png', decode('iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEX///8AAP94wDzzAAAAL0lEQVQIW2NgwAb+HwARH0DEDyDxwAZEyGAhLODqHmBRzAcn5GAS///A1IF14AAA5/Adbiiz/0gAAAAASUVORK5CYII=', 'base64'));
|
||||||
|
|
||||||
|
TRUNCATE TABLE w_or_wo_comma_names CASCADE;
|
||||||
|
INSERT INTO w_or_wo_comma_names VALUES ('Hebdon, John');
|
||||||
|
INSERT INTO w_or_wo_comma_names VALUES ('Williams, Mary');
|
||||||
|
INSERT INTO w_or_wo_comma_names VALUES ('Smith, Joseph');
|
||||||
|
INSERT INTO w_or_wo_comma_names VALUES ('David White');
|
||||||
|
INSERT INTO w_or_wo_comma_names VALUES ('Larry Thompson');
|
||||||
|
|
||||||
|
TRUNCATE TABLE items_with_different_col_types CASCADE;
|
||||||
|
INSERT INTO items_with_different_col_types VALUES (1, null, null, null, null, null, null, null);
|
||||||
|
|
||||||
|
TRUNCATE TABLE entities CASCADE;
|
||||||
|
INSERT INTO entities VALUES (1, 'entity 1', '{1}', '''bar'':2 ''foo'':1');
|
||||||
|
INSERT INTO entities VALUES (2, 'entity 2', '{1,2}', '''baz'':1 ''qux'':2');
|
||||||
|
INSERT INTO entities VALUES (3, 'entity 3', '{1,2,3}', null);
|
||||||
|
INSERT INTO entities VALUES (4, null, null, null);
|
||||||
|
|
||||||
|
TRUNCATE TABLE child_entities CASCADE;
|
||||||
|
INSERT INTO child_entities VALUES (1, 'child entity 1', 1);
|
||||||
|
INSERT INTO child_entities VALUES (2, 'child entity 2', 1);
|
||||||
|
INSERT INTO child_entities VALUES (3, 'child entity 3', 2);
|
||||||
|
|
||||||
|
TRUNCATE TABLE grandchild_entities CASCADE;
|
||||||
|
INSERT INTO grandchild_entities VALUES (1, 'grandchild entity 1', 1, null, null, null);
|
||||||
|
INSERT INTO grandchild_entities VALUES (2, 'grandchild entity 2', 1, null, null, null);
|
||||||
|
INSERT INTO grandchild_entities VALUES (3, 'grandchild entity 3', 2, null, null, null);
|
||||||
|
INSERT INTO grandchild_entities VALUES (4, '(grandchild,entity,4)', 2, null, null, '{"a": {"b":"foo"}}');
|
||||||
|
INSERT INTO grandchild_entities VALUES (5, '(grandchild,entity,5)', 2, null, null, '{"b":"bar"}');
|
||||||
|
|
||||||
--
|
--
|
||||||
-- PostgreSQL database dump complete
|
-- PostgreSQL database dump complete
|
||||||
--
|
--
|
||||||
|
|||||||
Vendored
+2
-2
@@ -1,3 +1,3 @@
|
|||||||
set client_min_messages to warning;
|
set client_min_messages to warning;
|
||||||
DROP SCHEMA IF EXISTS test, private, postgrest, jwt, تست CASCADE;
|
DROP SCHEMA IF EXISTS test, private, postgrest, jwt, public, تست CASCADE;
|
||||||
DROP TYPE IF EXISTS jwt_token CASCADE;
|
DROP TYPE IF EXISTS jwt_token CASCADE;
|
||||||
|
|||||||
Vendored
+12
@@ -3,6 +3,7 @@ GRANT USAGE ON SCHEMA
|
|||||||
postgrest
|
postgrest
|
||||||
, test
|
, test
|
||||||
, jwt
|
, jwt
|
||||||
|
, public
|
||||||
, "تست"
|
, "تست"
|
||||||
TO postgrest_test_anonymous;
|
TO postgrest_test_anonymous;
|
||||||
|
|
||||||
@@ -44,6 +45,17 @@ GRANT ALL ON TABLE
|
|||||||
, "موارد"
|
, "موارد"
|
||||||
, addresses
|
, addresses
|
||||||
, orders
|
, orders
|
||||||
|
, public.public_consumers
|
||||||
|
, public.public_orders
|
||||||
|
, consumers_view
|
||||||
|
, orders_view
|
||||||
|
, images
|
||||||
|
, images_base64
|
||||||
|
, w_or_wo_comma_names
|
||||||
|
, items_with_different_col_types
|
||||||
|
, entities
|
||||||
|
, child_entities
|
||||||
|
, grandchild_entities
|
||||||
TO postgrest_test_anonymous;
|
TO postgrest_test_anonymous;
|
||||||
|
|
||||||
GRANT INSERT ON TABLE insertonly TO postgrest_test_anonymous;
|
GRANT INSERT ON TABLE insertonly TO postgrest_test_anonymous;
|
||||||
|
|||||||
Vendored
+1
-1
@@ -4,4 +4,4 @@ CREATE ROLE postgrest_test_anonymous;
|
|||||||
CREATE ROLE postgrest_test_default_role;
|
CREATE ROLE postgrest_test_default_role;
|
||||||
CREATE ROLE postgrest_test_author;
|
CREATE ROLE postgrest_test_author;
|
||||||
|
|
||||||
GRANT postgrest_test_anonymous, postgrest_test_default_role, postgrest_test_author TO :USER;
|
GRANT postgrest_test_anonymous, postgrest_test_default_role, postgrest_test_author TO :USER;
|
||||||
|
|||||||
Vendored
+124
-23
@@ -12,31 +12,10 @@ SET standard_conforming_strings = on;
|
|||||||
SET check_function_bodies = false;
|
SET check_function_bodies = false;
|
||||||
SET client_min_messages = warning;
|
SET client_min_messages = warning;
|
||||||
|
|
||||||
--
|
CREATE SCHEMA public;
|
||||||
-- Name: postgrest; Type: SCHEMA; Schema: -; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE SCHEMA postgrest;
|
CREATE SCHEMA postgrest;
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: private; Type: SCHEMA; Schema: -; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE SCHEMA private;
|
CREATE SCHEMA private;
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: test; Type: SCHEMA; Schema: -; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE SCHEMA test;
|
CREATE SCHEMA test;
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: تست; Type: SCHEMA; Schema: -; Owner: -
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE SCHEMA تست;
|
CREATE SCHEMA تست;
|
||||||
|
|
||||||
|
|
||||||
@@ -48,11 +27,13 @@ CREATE EXTENSION IF NOT EXISTS plpgsql WITH SCHEMA pg_catalog;
|
|||||||
|
|
||||||
SET search_path = public, pg_catalog;
|
SET search_path = public, pg_catalog;
|
||||||
|
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||||
|
|
||||||
--
|
--
|
||||||
-- Name: jwt_token; Type: TYPE; Schema: public; Owner: -
|
-- Name: jwt_token; Type: TYPE; Schema: public; Owner: -
|
||||||
--
|
--
|
||||||
|
|
||||||
CREATE TYPE jwt_token AS (
|
CREATE TYPE public.jwt_token AS (
|
||||||
token text
|
token text
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -142,6 +123,19 @@ CREATE FUNCTION always_true(test.items) RETURNS boolean
|
|||||||
AS $$ SELECT true $$;
|
AS $$ SELECT true $$;
|
||||||
|
|
||||||
|
|
||||||
|
create table public_consumers (
|
||||||
|
id serial not null unique,
|
||||||
|
name text not null check (name <> ''),
|
||||||
|
primary key (id)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table public_orders (
|
||||||
|
id serial not null unique,
|
||||||
|
consumer integer not null references public_consumers(id),
|
||||||
|
number integer not null,
|
||||||
|
primary key (id)
|
||||||
|
);
|
||||||
|
|
||||||
--
|
--
|
||||||
-- Name: anti_id(test.items); Type: FUNCTION; Schema: public; Owner: -
|
-- Name: anti_id(test.items); Type: FUNCTION; Schema: public; Owner: -
|
||||||
--
|
--
|
||||||
@@ -161,6 +155,14 @@ CREATE TABLE موارد (
|
|||||||
|
|
||||||
SET search_path = test, pg_catalog;
|
SET search_path = test, pg_catalog;
|
||||||
|
|
||||||
|
|
||||||
|
create view orders_view as
|
||||||
|
select * from public.public_orders;
|
||||||
|
|
||||||
|
create view consumers_view as
|
||||||
|
select * from public.public_consumers;
|
||||||
|
|
||||||
|
|
||||||
--
|
--
|
||||||
-- Name: getitemrange(bigint, bigint); Type: FUNCTION; Schema: test; Owner: -
|
-- Name: getitemrange(bigint, bigint); Type: FUNCTION; Schema: test; Owner: -
|
||||||
--
|
--
|
||||||
@@ -171,6 +173,15 @@ CREATE FUNCTION getitemrange(min bigint, max bigint) RETURNS SETOF items
|
|||||||
SELECT * FROM test.items WHERE id > $1 AND id <= $2;
|
SELECT * FROM test.items WHERE id > $1 AND id <= $2;
|
||||||
$_$;
|
$_$;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: version(); Type: FUNCTION; Schema: test; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE FUNCTION noparamsproc() RETURNS text
|
||||||
|
LANGUAGE sql
|
||||||
|
AS $$
|
||||||
|
SELECT a FROM (VALUES ('Return value of no parameters procedure.')) s(a);
|
||||||
|
$$;
|
||||||
|
|
||||||
--
|
--
|
||||||
-- Name: insert_insertable_view_with_join(); Type: FUNCTION; Schema: test; Owner: -
|
-- Name: insert_insertable_view_with_join(); Type: FUNCTION; Schema: test; Owner: -
|
||||||
@@ -305,6 +316,7 @@ $$;
|
|||||||
|
|
||||||
CREATE FUNCTION sayhello(name text) RETURNS text
|
CREATE FUNCTION sayhello(name text) RETURNS text
|
||||||
LANGUAGE sql
|
LANGUAGE sql
|
||||||
|
IMMUTABLE
|
||||||
AS $_$
|
AS $_$
|
||||||
SELECT 'Hello, ' || $1;
|
SELECT 'Hello, ' || $1;
|
||||||
$_$;
|
$_$;
|
||||||
@@ -1097,6 +1109,95 @@ CREATE FUNCTION setprojects(id_l int, id_h int, name text) RETURNS SETOF project
|
|||||||
update test.projects set name = $3 WHERE id >= $1 AND id <= $2 returning *;
|
update test.projects set name = $3 WHERE id >= $1 AND id <= $2 returning *;
|
||||||
$_$;
|
$_$;
|
||||||
|
|
||||||
|
create table images (
|
||||||
|
name text not null,
|
||||||
|
img bytea not null
|
||||||
|
);
|
||||||
|
|
||||||
|
create view images_base64 as (
|
||||||
|
select name, replace(encode(img, 'base64'), E'\n', '') as img from images
|
||||||
|
);
|
||||||
|
|
||||||
|
create function test.ret_enum(val text) returns test.enum_menagerie_type as $$
|
||||||
|
select val::test.enum_menagerie_type;
|
||||||
|
$$ language sql;
|
||||||
|
|
||||||
|
create domain one_nine as integer check (value >= 1 and value <= 9);
|
||||||
|
|
||||||
|
create function test.ret_domain(val integer) returns test.one_nine as $$
|
||||||
|
select val::test.one_nine;
|
||||||
|
$$ language sql;
|
||||||
|
|
||||||
|
create function test.ret_range(low integer, up integer) returns int4range as $$
|
||||||
|
select int4range(low, up);
|
||||||
|
$$ language sql;
|
||||||
|
|
||||||
|
create function test.ret_scalars() returns table(
|
||||||
|
a text, b test.enum_menagerie_type, c test.one_nine, d int4range
|
||||||
|
) as $$
|
||||||
|
select row('scalars'::text, enum_first(null::test.enum_menagerie_type),
|
||||||
|
1::test.one_nine, int4range(10, 20));
|
||||||
|
$$ language sql;
|
||||||
|
|
||||||
|
create type test.point_2d as (x integer, y integer);
|
||||||
|
|
||||||
|
create function test.ret_point_2d() returns test.point_2d as $$
|
||||||
|
select row(10, 5)::test.point_2d;
|
||||||
|
$$ language sql;
|
||||||
|
|
||||||
|
create type private.point_3d as (x integer, y integer, z integer);
|
||||||
|
|
||||||
|
create function test.ret_point_3d() returns private.point_3d as $$
|
||||||
|
select row(7, -3, 4)::private.point_3d;
|
||||||
|
$$ language sql;
|
||||||
|
|
||||||
|
create function test.ret_void() returns void as '' language sql;
|
||||||
|
|
||||||
|
create function test.single_article(id integer) returns test.articles as $$
|
||||||
|
select a.* from test.articles a where a.id = $1;
|
||||||
|
$$ language sql;
|
||||||
|
|
||||||
|
create function test.get_guc_value(name text) returns text as $$
|
||||||
|
select nullif(current_setting(name), '')::text;
|
||||||
|
$$ language sql;
|
||||||
|
|
||||||
|
create table w_or_wo_comma_names ( name text );
|
||||||
|
|
||||||
|
create table items_with_different_col_types (
|
||||||
|
int_data integer,
|
||||||
|
text_data text,
|
||||||
|
bool_data bool,
|
||||||
|
bin_data bytea,
|
||||||
|
char_data character varying,
|
||||||
|
date_data date,
|
||||||
|
real_data real,
|
||||||
|
time_data time
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Tables used for testing complex boolean logic with and/or query params
|
||||||
|
|
||||||
|
create table entities (
|
||||||
|
id integer primary key,
|
||||||
|
name text,
|
||||||
|
arr integer[],
|
||||||
|
text_search_vector tsvector
|
||||||
|
);
|
||||||
|
|
||||||
|
create table child_entities (
|
||||||
|
id integer primary key,
|
||||||
|
name text,
|
||||||
|
parent_id integer references entities(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table grandchild_entities (
|
||||||
|
id integer primary key,
|
||||||
|
name text,
|
||||||
|
parent_id integer references child_entities(id),
|
||||||
|
or_starting_col text,
|
||||||
|
and_starting_col text,
|
||||||
|
jsonb_col jsonb
|
||||||
|
);
|
||||||
|
|
||||||
--
|
--
|
||||||
-- PostgreSQL database dump complete
|
-- PostgreSQL database dump complete
|
||||||
--
|
--
|
||||||
|
|||||||
Reference in New Issue
Block a user