Compare commits

...
70 Commits
Author SHA1 Message Date
Joe Nelson 6e6769ea16 Bump version 2015-03-03 23:29:22 -08:00
Joe Nelson 1e417aef90 Note schema override in changelog 2015-03-03 23:20:13 -08:00
Joe Nelson 60eb826cb0 Merge branch 'v1-schema-override'
Affects #155
Affects #158
Fixes #117
2015-03-03 23:18:59 -08:00
Joe Nelson 3d3c7277c7 Allow user to override schema used for v1 of api 2015-03-03 22:29:37 -08:00
Joe Nelson 5be0b5d5ae Return inserted object from POST when Prefer: return=representation
Fixes #27
Fixes #159
2015-03-01 21:42:05 -08:00
Joe Nelson 11e5918690 Merge branch 'select-in'
Fixes #98
Affects #158
2015-03-01 19:18:21 -08:00
Joe Nelson c207e7ee30 Add IN filter to changelog 2015-03-01 18:38:36 -08:00
Joe Nelson 6474a83221 Support IN query param operator 2015-03-01 18:30:08 -08:00
Joe Nelson 9b15071f23 Log requests
Fixes #141
2015-02-28 09:31:00 -08:00
Joe Nelson 51b379d78b Clarify the --secure command line option 2015-02-19 21:28:07 -08:00
Joe Nelson cac5daca01 Merge pull request #154 from brikou/patch-1
Fix URL to video
2015-02-19 10:30:20 -08:00
Brikou CARRE 4bc66c4ca3 Fix URL to video 2015-02-19 10:04:45 +01:00
Joe Nelson eafb1a848f Bump minor version, add changelog 2015-02-18 11:39:13 -08:00
Joe Nelson 328f27e7bd Merge pull request #152 from brikou/expose_location_header
Add 'Location' to exposed headers
2015-02-18 08:41:00 -08:00
Brikou Carré a93e6c050d Add 'Location' to exposed headers 2015-02-18 10:22:24 +01:00
Joe Nelson 337b49f386 Expose Content-Range response header (and others) in CORS
Fixes #148
2015-02-16 19:35:25 -08:00
Joe Nelson 92daa7d11a Merge branch 'like'
Fixes #132
2015-02-15 17:37:40 -08:00
Joe Nelson fa48c86195 Logically simplify (i)like test cases 2015-02-15 17:34:26 -08:00
Joe Nelson b88192f95a Remove lint 2015-02-15 16:31:53 -08:00
Joe Nelson 5a1ae934b9 Put array open bracket nearer to the json 2015-02-15 16:14:25 -08:00
Joe Nelson 58f18181c2 Update order by params to new style introduced from master 2015-02-15 16:13:06 -08:00
Joe Nelson eea1bc0cac Quasiquote json to fix vim syntax highlighting 2015-02-15 16:03:31 -08:00
Joe Nelson e37d5d9b25 Style tweak 2015-02-15 15:59:49 -08:00
Adam C. BakerandJoe Nelson 5cc77709ba Add like/ilike 2015-02-15 15:23:18 -08:00
Joe Nelson 5280b9fd6d Merge pull request #138 from jcristovao/patch-1
order does not work
2015-02-12 08:41:40 -08:00
João Cristóvão 9cb1ced010 Change order test to match docs 2015-02-12 09:44:49 +00:00
João Cristóvão 0a3057e92d Update PgQuery.hs
I'm a bit baffled nobody noticed this before :P

Anyhow, without it order does not work.

Thanks,
Cheers
2015-02-11 19:49:31 +00:00
Joe Nelson 699d6d78dc Bump patch version 2015-02-07 15:27:56 -08:00
Joe Nelson 942ba52f56 Merge branch 'update-deps' 2015-02-07 14:48:26 -08:00
Joe Nelson d77b6df851 Use new optparse-applicative and bcrypt
Fixes #131 and affects #129
2015-02-07 14:21:40 -08:00
Joe Nelson 3662c1d428 Lint and check for restrictive package locks on ci 2015-02-07 13:48:04 -08:00
Joe Nelson e909ef3e62 Mention all modules in .cabal file
per #129
2015-02-04 14:50:33 -08:00
Joe Nelson 9a393c8603 Bump patch version 2015-01-31 12:48:02 -08:00
Joe Nelson ea429e077a Merge pull request #128 from begriffs/hasql-7
Upgrade to Hasql 0.7
2015-01-31 12:45:45 -08:00
Joe Nelson 90f9b00e5e Include JSON content type for errors 2015-01-31 12:33:44 -08:00
Joe Nelson f06f3e3394 Isolate tests
Credentials from AuthSpec were interfering with StructureSpec
2015-01-31 11:32:08 -08:00
Joe Nelson 88af46fbe3 Reduce test settings duplication
and better variable name
2015-01-31 11:31:11 -08:00
Joe Nelson 27450fae66 Fix NULL input problem
Relates to #127

Still auth problems though
2015-01-28 23:58:17 -08:00
Joe Nelson da6318f5a2 Fix warnings, lint, and ambiguous hasql imports 2015-01-28 20:37:19 -08:00
Adam C. Baker 3151aa2ebc fix table parsing 2015-01-26 18:42:46 -08:00
Adam C. Baker e1ad5cb1ae handle errors in the app too 2015-01-26 18:07:38 -08:00
Adam C. Baker ead346816b reporting errors, but not passing tests. 2015-01-26 17:31:59 -08:00
Joe Nelson b504473790 WIP: Upgrade to hasql 7
Still fails handling query errors
2015-01-25 18:33:50 -08:00
Joe Nelson 8073c84809 More informative cabal file for hackage release 2015-01-17 11:08:46 -08:00
Joe Nelson d28dee41a1 Heroku deployment button on README 2015-01-17 10:46:58 -08:00
Joe Nelson 003685ff46 Add support for DELETE verb
Fixes #125
2015-01-17 00:48:49 -08:00
Joe Nelson 61f8f41a36 Temporarily remove heroku button while I get it working properly 2015-01-07 23:45:58 -08:00
Joe Nelson 55f6318dcd Deploy button and thanks 2015-01-07 23:09:10 -08:00
Joe Nelson 96d108d724 Merge pull request #121 from begriffs/fix-order-by
Do not add WHERE clause if only param is order
2015-01-05 23:07:29 -08:00
Joe Nelson d77924a9d1 Update links to binaries 2015-01-05 23:03:51 -08:00
Joe Nelson 4c74e54ae1 Do not add WHERE clause if only param is order
Fixes #119
2015-01-05 22:37:49 -08:00
Joe Nelson 591f0eb78d Video link 2014-12-30 14:52:58 -08:00
Adam C. Baker c9b960f955 only create schema once 2014-12-29 17:57:31 -08:00
Adam C. Baker 23a75aefcc bump hspec version.
use new version for before_
2014-12-29 17:57:31 -08:00
Adam C. Baker 3756309b22 create items in test, not in the schema. 2014-12-29 17:57:31 -08:00
Adam C. Baker 2ae99daa12 resetDb once for each set of tests. 2014-12-29 17:57:31 -08:00
Joe Nelson 796de39762 More prominent demo server link 2014-12-29 16:12:17 -08:00
Joe Nelson c541f83cef Link to demo server and its schema 2014-12-29 15:27:29 -08:00
Joe Nelson a142128915 Fix logo typo 2014-12-29 15:07:09 -08:00
Joe Nelson ee8f754d7a Consolidate guides to make them easier to spot 2014-12-29 11:45:43 -08:00
Joe Nelson 3d02abc844 Link to binaries 2014-12-29 11:18:09 -08:00
Joe Nelson a049a8d5cd Update performance stats 2014-12-29 10:31:19 -08:00
Joe Nelson 2c635cfde8 Optimization when auth role coincides with anon role
No need to set/reset role because it is already correct
2014-12-29 09:39:13 -08:00
Joe Nelson 24f70f5bdf Add @casalaina's awesome logo 2014-12-29 08:58:35 -08:00
Joe Nelson d7d5473664 Link to load test graph 2014-12-28 21:13:58 -08:00
Joe Nelson d7e0fb948e Tweak future features 2014-12-28 11:11:33 -08:00
Joe Nelson 8200f54ae4 Bump version 2014-12-25 17:47:23 -08:00
Joe Nelson 5eca8668ef Better docs 2014-12-25 14:28:06 -08:00
Joe Nelson 30e9733719 Hide db details on connection failure 2014-12-22 15:40:35 -08:00
Joe Nelson 0830772384 Provide better diagnostics for exception parse failure 2014-12-22 15:40:20 -08:00
28 changed files with 807 additions and 585 deletions
+22
View File
@@ -0,0 +1,22 @@
# Change Log
All notable changes to this project will be documented in this file.
This project adheres to [Semantic Versioning](http://semver.org/).
## [0.2.7.0] - 2015-03-03
### Added
- Server response logging
- Filter IN values, e.g. `?col=in.1,2,3`
- Return POSTed resource if header "Prefer: return=representation"
- Allow override of default (v1) schema
## [0.2.6.0] - 2015-02-18
### Added
- A changelog
- Filter by substring match, e.g. `?col=like.*hello*` (or ilike for
case insensitivity).
- Access-Control-Expose-Headers for CORS
### Fixed
- Make filter position match docs, e.g. `?order=col.asc` rather
than `?order=asc.col`.
+145 -37
View File
@@ -1,56 +1,164 @@
## Serve a RESTful API from any Postgres database ![Logo](static/logo.png "Logo")
[![Build Status](https://circleci.com/gh/begriffs/postgrest.png?circle-token=f723c01686abf0364de1e2eaae5aff1f68bd3ff2)](https://circleci.com/gh/begriffs/postgrest/tree/master) [![Build Status](https://circleci.com/gh/begriffs/postgrest.png?circle-token=f723c01686abf0364de1e2eaae5aff1f68bd3ff2)](https://circleci.com/gh/begriffs/postgrest/tree/master)
<a href="https://heroku.com/deploy?template=https://github.com/begriffs/postgrest">
<img src="static/heroku.png" alt="Deploy">
</a>
### Installation PostgREST serves a fully RESTful API from any existing PostgreSQL
database. It provides a cleaner, more standards-compliant, faster
API than you are likely to write from scratch.
```sh ### Demo [postgrest.herokuapp.com](https://postgrest.herokuapp.com) | Watch [Video](http://begriffs.com/posts/2014-12-30-intro-to-postgrest.html)
brew install postgres
cabal install -j --enable-tests Try making requests to the live demo server 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.
### Usage
Download the binary ([OS X](http://bin.begriffs.com/dbapi/osx/postgrest-0.2.7.0.tar.xz) / [Linux](http://bin.begriffs.com/dbapi/heroku/postgrest-0.2.7.0.tar.xz)) and invoke like so:
```bash
postgrest --db-host localhost --db-port 5432 \
--db-name my_db --db-user postgres \
--db-pass foobar --db-pool 200 \
--anonymous postgres --port 3000 \
--v1schema public
``` ```
Example usage: In production include the `--secure` option which redirects all
requests to HTTPS. Note that PostgREST does not handle the SSL
internally and must be put behind another server that does (such
as nginx or the Heroku load balancer).
```sh ### Performance
cabal run -d [database] -U [auth-role] -a [anonymous-role]
```
This will connect to a postgres DB at the url TLDR; subsecond response times for up to 2000 requests/sec on Heroku free tier. ([see the load test](https://github.com/begriffs/postgrest/wiki/Performance-and-Scaling))
`postgres://[auth-role]:@localhost:5432/[database]`.
You will need to provide two database roles (which are allowed to If you're used to servers written in interpreted languages (or named
be the same). One is called the authenticator role (`auth-role` after precious gems), prepare to be pleasantly surprised by PostgREST
above) which should have enough privileges to read the `auth` table performance.
in the `postgrest` schema if you intend to support multi-user
applications.
The other role is for anonymous access (`anonymous-role` above). Three factors contribute to the speed. First the server is written
Immediately upon acceping any unauthenticated HTTP connection postgrest in [Haskell](https://new-www.haskell.org/) using the
assumes this role in its queries to postgres. Give this role as [Warp](http://www.yesodweb.com/blog/2011/03/preliminary-warp-cross-language-benchmarks)
much or little permissions as you would like. HTTP server (aka a compiled language with lightweight threads).
Next it delegates as much calculation as possible to the database
including
### Running tests * Serializing JSON responses directly in SQL
* Data validation
* Authorization
* Combined row counting and retrieval
* Data post in single command (`returning *`)
```sh Finally it uses the database efficiently with the
createuser --superuser --no-password postgrest_test [Hasql](https://nikita-volkov.github.io/hasql-benchmarks/) library
createdb -O postgrest_test -U postgres postgrest_test by
cabal test --show-details=always --test-options="--color" * Reusing prepared statements
``` * Keeping a pool of db connections
* Using the Postgres binary protocol
* Being stateless to allow horizontal scaling
### Distributing Heroku build Ultimately the server (when load balanced) is constrained by database
performance. This may make it inappropriate for very large traffic
load. To learn more about scaling with Heroku and Amazon RDS see
the [performance guide](https://github.com/begriffs/postgrest/wiki/Performance-and-Scaling).
```sh Other optimizations are possible, and some are outlined in the
heroku create --stack=cedar --buildpack https://github.com/begriffs/heroku-buildpack-ghc.git [Future Features](#future-features).
git push heroku master
heroku config:set S3_ACCESS_KEY=abc ### Security
heroku config:set S3_SECRET_KEY=123
heroku config:set S3_BUCKET=s3://foo/bar
heroku run scripts/release_s3.sh PostgREST handles authentication (HTTP Basic over SSL) and delegates
``` authorization to the role information defined in the database. This
ensures there is a single declarative source of truth for security.
When dealing with the database the server assumes the identity of
the currently authenticated user, and for the duration of the
connection cannot do anything the user themselves couldn't.
### Acknowledgements Postgres 9.5 will soon support true [row-level
security](http://michael.otacoo.com/postgresql-2/postgres-9-5-feature-highlight-row-level-security/).
In the meantime what isn't yet implemented can be simulated with
triggers and security-barrier views. Because the possible queries
to the database are limited to certain templates using
[leakproof](http://blog.2ndquadrant.com/how-do-postgresql-security_barrier-views-work/)
functions, the trigger workaround does not compromise row-level
security.
Thanks to [Adam Baker](https://github.com/adambaker) for code contributions and many fundamental design discussions. Also thanks to [Loop/Recur](https://looprecur.com) for open-source Fridays to advance the code, and for their courage to use this thing in real projects. For example security patterns see the [security
guide](https://github.com/begriffs/postgrest/wiki/Security-and-Permissions).
### Versioning
A robust long-lived API needs the freedom to exist in multiple
versions. PostgREST supports versioning through HTTP content
negotiation. Requests for a certain version translate into switching
which database schema to search for tables. PostgreSQL schema search
paths allow tables from earlier versions to be reused verbatim in
later versions.
To learn more, see the [guide to versioning](https://github.com/begriffs/postgrest/wiki/API-Versioning).
### Self-documention
Rather than writing and maintaining separate docs yourself let the
API explain its own affordances using HTTP. All PostgREST endpoints
respond to the OPTIONS verb and explain what they support as well
as the data format of their JSON payload.
The number of rows returned by an endpoint is reported by - and
limited with - range headers. More about
[that](http://begriffs.com/posts/2014-03-06-beyond-http-header-links.html).
There are more opportunities for self-documentation listed in [Future
Features](#future-features).
### Data Integrity
Rather than relying on an Object Relational Mapper and custom
imperative coding, this system requires you put declarative constraints
directly into your database. Hence no application can corrupt your
data (including your API server).
The PostgREST exposes HTTP interface with safeguards to prevent
surprises, such as enforcing idempotent PUT requests, and
See examples of [Postgres
constraints](http://www.tutorialspoint.com/postgresql/postgresql_constraints.htm)
and the [guide to routing](https://github.com/begriffs/postgrest/wiki/Routing).
### Future Features
* Watching endpoint changes with sockets and Postgres pubsub
* Specifying per-view HTTP caching
* Inferring good default caching policies from the Postgres stats collector
* Generating mock data for test clients
* Maintaining separate connection pools per role to avoid "set/reset
role" performance penalty
* Describe more relationships with Link headers
* Depending on accept headers, render OPTIONS as [RAML](http://raml.org/) or a
relational diagram
* Add two-legged auth with OAuth 1.0a(?)
* ... the other [issues](https://github.com/begriffs/postgrest/issues)
### Guides
* [Routing](https://github.com/begriffs/postgrest/wiki/Routing)
* [Versioning](https://github.com/begriffs/postgrest/wiki/API-Versioning)
* [Performance](https://github.com/begriffs/postgrest/wiki/Performance-and-Scaling)
* [Security](https://github.com/begriffs/postgrest/wiki/Security-and-Permissions)
### Thanks
* [Adam Baker](https://github.com/adambaker) for code
contributions and many fundamental design discussions
* [Nikita Volkov](https://github.com/nikita-volkov) for writing the
wonderful [Hasql](https://github.com/nikita-volkov/hasql) library
and helping me use it
* [Mikey Casalaina](https://github.com/casalaina) for the cool logo
+46
View File
@@ -0,0 +1,46 @@
{
"name": "PostgREST",
"description": "RESTful API for any PostgreSQL database.",
"logo": "https://halcyon.sh/logo.svg",
"repository": "https://github.com/begriffs/postgrest",
"env": {
"BUILDPACK_URL": {
"description": "Heroku buildpack for deploying Haskell applications",
"value": "https://github.com/begriffs/postgrest-heroku"
},
"POSTGREST_VER": {
"description": "Version of PostgREST to deploy",
"value": "0.2.7.0"
},
"DB_NAME": {
"description": "Database name",
"required": true
},
"AUTH_ROLE": {
"description": "Database role to use checking client authentication",
"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": {
"description": "Maximum number of connections in database pool",
"required": false,
"value": "10"
}
}
}
+4
View File
@@ -4,3 +4,7 @@ machine:
- createdb -O postgrest_test -U ubuntu postgrest_test - createdb -O postgrest_test -U ubuntu postgrest_test
ghc: ghc:
version: 7.8.3 version: 7.8.3
test:
post:
- cabal exec hlint -- -X QuasiQuotes src/*.hs test/**/*.hs
- cabal exec packdeps postgrest.cabal
+43 -31
View File
@@ -1,9 +1,13 @@
name: postgrest name: postgrest
version: 0.2.4.7 description: Reads the schema of a PostgreSQL database and creates RESTful routes
synopsis: The database is your api for the tables and views, supporting all HTTP verbs that security
permits.
version: 0.2.7.0
synopsis: REST API for any Postgres database
license: MIT license: MIT
license-file: LICENSE license-file: LICENSE
author: Joe Nelson, Adam Baker author: Joe Nelson, Adam Baker
homepage: https://github.com/begriffs/postgrest
maintainer: cred+github@begriffs.com maintainer: cred+github@begriffs.com
category: Web category: Web
build-type: Simple build-type: Simple
@@ -11,13 +15,12 @@ cabal-version: >=1.10
executable postgrest executable postgrest
main-is: Main.hs main-is: Main.hs
ghc-options: -Wall -W -Werror -O2 ghc-options: -Wall -W -O2
default-language: Haskell2010 default-language: Haskell2010
default-extensions: OverloadedStrings default-extensions: OverloadedStrings, ScopedTypeVariables, QuasiQuotes
other-extensions: QuasiQuotes
build-depends: base >=4.6 && <5 build-depends: base >=4.6 && <5
, hasql == 0.4.*, hasql-backend , hasql == 0.7.*, hasql-backend
, hasql-postgres == 0.8.* , hasql-postgres == 0.10.*
, warp >= 3.0.2, wai >= 3.0.1 , warp >= 3.0.2, wai >= 3.0.1
, wai-extra, wai-cors , wai-extra, wai-cors
, wai-middleware-static >= 0.6.0 , wai-middleware-static >= 0.6.0
@@ -26,14 +29,14 @@ executable postgrest
, scientific, time , scientific, time
, aeson, network >= 2.6 , aeson, network >= 2.6
, bytestring, text, split, string-conversions , bytestring, text, split, string-conversions
, stringsearch, parsec , stringsearch
, containers, unordered-containers , containers, unordered-containers
, optparse-applicative >= 0.9.1 && < 0.10 , optparse-applicative == 0.11.*
, regex-base, regex-tdfa , regex-base, regex-tdfa
, regex-tdfa-text , regex-tdfa-text
, Ranged-sets , Ranged-sets
, transformers , transformers, MissingH
, bcrypt, base64-string , bcrypt >= 0.0.6, base64-string
, network-uri >= 2.6 , network-uri >= 2.6
, resource-pool , resource-pool
, blaze-builder , blaze-builder
@@ -42,46 +45,55 @@ executable postgrest
Other-Modules: App Other-Modules: App
, Auth , Auth
, Config , Config
, PgStructure , Error
, PgQuery
, PgError
, RangeQuery
, Middleware , Middleware
, PgQuery
, PgStructure
, RangeQuery
, Types
hs-source-dirs: src hs-source-dirs: src
Test-Suite spec Test-Suite spec
Type: exitcode-stdio-1.0 Type: exitcode-stdio-1.0
Default-Language: Haskell2010 Default-Language: Haskell2010
default-extensions: OverloadedStrings default-extensions: OverloadedStrings, ScopedTypeVariables, QuasiQuotes
other-extensions: QuasiQuotes
Hs-Source-Dirs: test, src Hs-Source-Dirs: test, src
ghc-options: -Wall -W -Werror ghc-options: -Wall -W -Werror
Main-Is: Spec.hs Main-Is: Main.hs
Other-Modules: App, Auth, Config, Spec, SpecHelper Other-Modules: App
Build-Depends: base, hspec >= 2.0, QuickCheck , Auth
, Config
, Error
, Middleware
, PgQuery
, PgStructure
, RangeQuery
, Types
, Spec
, SpecHelper
Build-Depends: base, hspec >= 2.1.2, QuickCheck
, hspec-wai >= 0.5.0, hspec-wai-json , hspec-wai >= 0.5.0, hspec-wai-json
, hasql == 0.4.*, hasql-backend , hasql, hasql-backend
, hasql-postgres == 0.8.* , hasql-postgres
, warp >= 3.0.2, wai >= 3.0.1 , warp, wai
, packdeps, hlint
, HTTP, convertible , HTTP, convertible
, case-insensitive , case-insensitive
, wai-extra, wai-cors, containers , wai-extra, wai-cors, containers
, wai-middleware-static >= 0.6.0 , wai-middleware-static
, http-types, scientific, time , http-types, scientific, time
, bytestring, aeson, network >= 2.6 , bytestring, aeson, network
, text, optparse-applicative , text, optparse-applicative
, stringsearch, parsec , stringsearch
, unordered-containers , unordered-containers
, regex-base , regex-base
, string-conversions , string-conversions
, http-media, regex-tdfa , http-media, regex-tdfa
, regex-tdfa-text , regex-tdfa-text
, Ranged-sets , Ranged-sets
, transformers , transformers, MissingH, split
, bcrypt , bcrypt, base64-string
, base64-string , network-uri
, split
, network-uri >= 2.6
, resource-pool , resource-pool
, blaze-builder , blaze-builder
, vector , vector
+37 -50
View File
@@ -25,18 +25,18 @@ import Network.Wai
import Data.Aeson import Data.Aeson
import Data.Monoid import Data.Monoid
import qualified Data.Vector as V
import qualified Hasql as H import qualified Hasql as H
import qualified Hasql.Postgres as H import qualified Hasql.Backend as B
import qualified Hasql.Postgres as P
import Auth import Auth
import PgQuery import PgQuery
import RangeQuery import RangeQuery
import PgStructure import PgStructure
import PgError
import Text.Parsec hiding (Column)
app :: BL.ByteString -> Request -> H.Tx H.Postgres s Response app :: Text -> BL.ByteString -> Request -> H.Tx P.Postgres s Response
app reqBody req = app v1schema reqBody req =
case (path, verb) of case (path, verb) of
([], _) -> do ([], _) -> do
body <- encode <$> tables (cs schema) body <- encode <$> tables (cs schema)
@@ -54,8 +54,7 @@ app reqBody req =
then return $ responseLBS status416 [] "HTTP Range error" then return $ responseLBS status416 [] "HTTP Range error"
else do else do
let qt = QualifiedTable schema (cs table) let qt = QualifiedTable schema (cs table)
let select = coerce $ let select = B.Stmt "select " V.empty True <>
("select ",[],mempty) <>
parentheticT ( parentheticT (
whereT qq $ countRows qt whereT qq $ countRows qt
) <> commaq <> ( ) <> commaq <> (
@@ -65,7 +64,7 @@ app reqBody req =
. whereT qq . whereT qq
$ selectStar qt $ selectStar qt
) )
row <- H.single select row <- H.maybeEx select
let (tableTotal, queryTotal, body) = let (tableTotal, queryTotal, body) =
fromMaybe (0, 0, Just "" :: Maybe Text) row fromMaybe (0, 0, Just "" :: Maybe Text) row
from = fromMaybe 0 $ rangeOffset <$> range from = fromMaybe 0 $ rangeOffset <$> range
@@ -102,9 +101,9 @@ app reqBody req =
([table], "POST") -> ([table], "POST") ->
handleJsonObj reqBody $ \obj -> do handleJsonObj reqBody $ \obj -> do
let qt = QualifiedTable schema (cs table) let qt = QualifiedTable schema (cs table)
query = coerce $ query = insertInto qt (map cs $ keys obj) (elems obj)
insertInto qt (map cs $ keys obj) (elems obj) echoRequested = lookup "Prefer" hdrs == Just "return=representation"
row <- H.single query row <- H.maybeEx query
let (Identity insertedJson) = fromMaybe (Identity "{}" :: Identity Text) row let (Identity insertedJson) = fromMaybe (Identity "{}" :: Identity Text) row
Just inserted = decode (cs insertedJson) :: Maybe Object Just inserted = decode (cs insertedJson) :: Maybe Object
@@ -118,7 +117,7 @@ app reqBody req =
return $ responseLBS status201 return $ responseLBS status201
[ jsonH [ jsonH
, (hLocation, "/" <> cs table <> "?" <> cs params) , (hLocation, "/" <> cs table <> "?" <> cs params)
] "" ] $ if echoRequested then cs insertedJson else ""
([table], "PUT") -> ([table], "PUT") ->
handleJsonObj reqBody $ \obj -> do handleJsonObj reqBody $ \obj -> do
@@ -134,7 +133,7 @@ app reqBody req =
if S.fromList tableCols == S.fromList cols if S.fromList tableCols == S.fromList cols
then do then do
let vals = elems obj let vals = elems obj
H.unit . coerce $ iffNotT H.unitEx $ iffNotT
(whereT qq $ update qt cols vals) (whereT qq $ update qt cols vals)
(insertSelect qt cols vals) (insertSelect qt cols vals)
return $ responseLBS status204 [ jsonH ] "" return $ responseLBS status204 [ jsonH ] ""
@@ -147,12 +146,23 @@ app reqBody req =
([table], "PATCH") -> ([table], "PATCH") ->
handleJsonObj reqBody $ \obj -> do handleJsonObj reqBody $ \obj -> do
let qt = QualifiedTable schema (cs table) let qt = QualifiedTable schema (cs table)
H.unit H.unitEx
$ coerce
$ whereT qq $ whereT qq
$ update qt (map cs $ keys obj) (elems obj) $ update qt (map cs $ keys obj) (elems obj)
return $ responseLBS status204 [ jsonH ] "" return $ responseLBS status204 [ jsonH ] ""
([table], "DELETE") -> do
let qt = QualifiedTable schema (cs table)
let del = countT
. returningStarT
. whereT qq
$ deleteFrom qt
row <- H.maybeEx del
let (Identity deletedCount) = fromMaybe (Identity 0 :: Identity Int) row
return $ if deletedCount == 0
then responseLBS status404 [] ""
else responseLBS status204 [("Content-Range", "*/"<> cs (show deletedCount))] ""
(_, _) -> (_, _) ->
return $ responseLBS status404 [] "" return $ responseLBS status404 [] ""
@@ -161,39 +171,15 @@ app reqBody req =
verb = requestMethod req verb = requestMethod req
qq = queryString req qq = queryString req
hdrs = requestHeaders req hdrs = requestHeaders req
schema = requestedSchema hdrs schema = requestedSchema v1schema hdrs
range = rangeRequested hdrs range = rangeRequested hdrs
allOrigins = ("Access-Control-Allow-Origin", "*") :: Header allOrigins = ("Access-Control-Allow-Origin", "*") :: Header
coerce (q, args, All b) = (q, args, b)
sqlError :: t
sqlError = undefined
isSqlError :: H.Error -> Maybe H.Error isSqlError :: t
isSqlError = Just isSqlError = undefined
sqlError :: H.Error -> Response
sqlError err =
let inside = case err of
H.CantConnect t -> t
H.ConnectionLost t -> t
H.ErroneousResult t -> t
H.UnexpectedResult t -> t
H.UnparsableTemplate t -> t
H.UnparsableRow t -> t
H.NotInTransaction -> "An operation which requires a"
<> "database transaction was executed without one"
p = parse message
"{\"message\": \"failed to parse exception\" }" inside in
either
(\nope ->
responseLBS status500
[(hContentType, "application/json")]
(cs . show $ nope))
(\msg ->
responseLBS (httpStatus msg)
[(hContentType, "application/json")]
(encode msg))
p
rangeStatus :: Int -> Int -> Int -> Status rangeStatus :: Int -> Int -> Int -> Status
rangeStatus from to total rangeStatus from to total
@@ -211,11 +197,11 @@ contentRangeH from to total =
<> cs (show total) <> cs (show total)
) )
requestedSchema :: RequestHeaders -> Text requestedSchema :: Text -> RequestHeaders -> Text
requestedSchema hdrs = requestedSchema v1schema hdrs =
case verStr of case verStr of
Just [[_, ver]] -> ver Just [[_, ver]] -> if ver == "1" then v1schema else ver
_ -> "1" _ -> v1schema
where verRegex = "version[ ]*=[ ]*([0-9]+)" :: String where verRegex = "version[ ]*=[ ]*([0-9]+)" :: String
accept = cs <$> lookup hAccept hdrs :: Maybe Text accept = cs <$> lookup hAccept hdrs :: Maybe Text
@@ -224,8 +210,8 @@ requestedSchema hdrs =
jsonH :: Header jsonH :: Header
jsonH = (hContentType, "application/json") jsonH = (hContentType, "application/json")
handleJsonObj :: BL.ByteString -> (Object -> H.Tx H.Postgres s Response) handleJsonObj :: BL.ByteString -> (Object -> H.Tx P.Postgres s Response)
-> H.Tx H.Postgres s Response -> H.Tx P.Postgres s Response
handleJsonObj reqBody handler = do handleJsonObj reqBody handler = do
let p = eitherDecode reqBody let p = eitherDecode reqBody
case p of case p of
@@ -241,6 +227,7 @@ handleJsonObj reqBody handler = do
jErr = encode . object $ jErr = encode . object $
[("message", String "Expecting a JSON object")] [("message", String "Expecting a JSON object")]
data TableOptions = TableOptions { data TableOptions = TableOptions {
tblOptcolumns :: [Column] tblOptcolumns :: [Column]
, tblOptpkey :: [Text] , tblOptpkey :: [Text]
+12 -10
View File
@@ -7,8 +7,10 @@ import Control.Applicative ( (<*>), (<$>) )
import Crypto.BCrypt import Crypto.BCrypt
import Data.Text import Data.Text
import Data.Monoid import Data.Monoid
import qualified Data.Vector as V
import qualified Hasql as H import qualified Hasql as H
import qualified Hasql.Postgres as H import qualified Hasql.Backend as B
import qualified Hasql.Postgres as P
import Data.String.Conversions (cs) import Data.String.Conversions (cs)
import PgQuery (pgFmtLit) import PgQuery (pgFmtLit)
@@ -45,22 +47,22 @@ data LoginAttempt =
checkPass :: Text -> Text -> Bool checkPass :: Text -> Text -> Bool
checkPass = (. cs) . validatePassword . cs checkPass = (. cs) . validatePassword . cs
setRole :: Text -> H.Tx H.Postgres s () setRole :: Text -> H.Tx P.Postgres s ()
setRole role = H.unit ("set role " <> cs (pgFmtLit role), [], True) setRole role = H.unitEx $ B.Stmt ("set role " <> cs (pgFmtLit role)) V.empty True
resetRole :: H.Tx H.Postgres s () resetRole :: H.Tx P.Postgres s ()
resetRole = H.unit [H.q|reset role|] resetRole = H.unitEx [H.stmt|reset role|]
addUser :: Text -> Text -> Text -> H.Tx H.Postgres s () addUser :: Text -> Text -> Text -> H.Tx P.Postgres s ()
addUser identity pass role = do addUser identity pass role = do
let Just hashed = unsafePerformIO $ hashPasswordUsingPolicy fastBcryptHashingPolicy (cs pass) let Just hashed = unsafePerformIO $ hashPasswordUsingPolicy fastBcryptHashingPolicy (cs pass)
H.unit $ H.unitEx $
[H.q|insert into postgrest.auth (id, pass, rolname) values (?, ?, ?)|] [H.stmt|insert into postgrest.auth (id, pass, rolname) values (?, ?, ?)|]
identity (cs hashed :: Text) role identity (cs hashed :: Text) role
signInRole :: Text -> Text -> H.Tx H.Postgres s LoginAttempt signInRole :: Text -> Text -> H.Tx P.Postgres s LoginAttempt
signInRole user pass = do signInRole user pass = do
u <- H.single $ [H.q|select pass, rolname from postgrest.auth where id = ?|] user u <- H.maybeEx $ [H.stmt|select pass, rolname from postgrest.auth where id = ?|] user
return $ maybe LoginFailed (\r -> return $ maybe LoginFailed (\r ->
let (hashed, role) = r in let (hashed, role) = r in
if checkPass hashed pass if checkPass hashed pass
+15 -9
View File
@@ -20,20 +20,22 @@ data AppConfig = AppConfig {
, configAnonRole :: String , configAnonRole :: String
, configSecure :: Bool , configSecure :: Bool
, configPool :: Int , configPool :: Int
, configV1Schema :: String
} }
argParser :: Parser AppConfig argParser :: Parser AppConfig
argParser = AppConfig argParser = AppConfig
<$> strOption (long "db-name" <> short 'd' <> help "name of database") <$> strOption (long "db-name" <> short 'd' <> metavar "NAME" <> help "name of database")
<*> option (long "db-port" <> short 'P' <> value 5432 <> help "postgres server port") <*> option auto (long "db-port" <> short 'P' <> metavar "PORT" <> value 5432 <> help "postgres server port" <> showDefault)
<*> strOption (long "db-user" <> short 'U' <> help "postgres authenticator role") <*> strOption (long "db-user" <> short 'U' <> metavar "ROLE" <> help "postgres authenticator role")
<*> strOption (long "db-pass" <> value "" <> help "password for authenticator role") <*> strOption (long "db-pass" <> metavar "PASS" <> value "" <> help "password for authenticator role")
<*> strOption (long "db-host" <> short 'h' <> value "localhost" <> help "postgres server hostname") <*> strOption (long "db-host" <> metavar "HOST" <> value "localhost" <> help "postgres server hostname" <> showDefault)
<*> option (long "port" <> short 'p' <> value 3000 <> help "port number on which to run HTTP server") <*> option auto (long "port" <> short 'p' <> metavar "PORT" <> value 3000 <> help "port number on which to run HTTP server" <> showDefault)
<*> strOption (long "anonymous" <> short 'a' <> help "postgres role to use for non-authenticated requests") <*> strOption (long "anonymous" <> short 'a' <> metavar "ROLE" <> help "postgres role to use for non-authenticated requests")
<*> switch (long "secure" <> short 's' <> help "Redirect all requests to HTTPS") <*> switch (long "secure" <> short 's' <> help "Redirect all requests to HTTPS")
<*> option (long "db-pool" <> value 10 <> help "Max connections in database pool") <*> option auto (long "db-pool" <> metavar "COUNT" <> value 10 <> help "Max connections in database pool" <> showDefault)
<*> strOption (long "v1schema" <> metavar "NAME" <> value "1" <> help "Schema to use for nonspecified version (or explicit v1)" <> showDefault)
defaultCorsPolicy :: CorsResourcePolicy defaultCorsPolicy :: CorsResourcePolicy
defaultCorsPolicy = CorsResourcePolicy Nothing defaultCorsPolicy = CorsResourcePolicy Nothing
@@ -45,6 +47,10 @@ corsPolicy req = case lookup "origin" headers of
Just origin -> Just defaultCorsPolicy { Just origin -> Just defaultCorsPolicy {
corsOrigins = Just ([origin], True) corsOrigins = Just ([origin], True)
, corsRequestHeaders = "Authentication":accHeaders , corsRequestHeaders = "Authentication":accHeaders
, corsExposedHeaders = Just [
"Content-Encoding", "Content-Location", "Content-Range", "Content-Type"
, "Date", "Location", "Server", "Transfer-Encoding", "Range-Unit"
]
} }
Nothing -> Nothing Nothing -> Nothing
where where
+71
View File
@@ -0,0 +1,71 @@
{-# OPTIONS_GHC -fno-warn-orphans #-}
{-# LANGUAGE FlexibleInstances, TypeSynonymInstances #-}
module Error (PgError, errResponse) where
import qualified Hasql as H
import qualified Hasql.Postgres as P
import qualified Network.HTTP.Types.Status as HT
import qualified Data.Aeson as JSON
import qualified Data.Text as T
import Data.Aeson ((.=))
import Data.String.Conversions (cs)
import Data.String.Utils(replace)
import Network.Wai(Response, responseLBS)
import Network.HTTP.Types.Header
type PgError = H.SessionError P.Postgres
errResponse :: PgError -> Response
errResponse e = responseLBS (httpStatus e)
[(hContentType, "application/json")] (JSON.encode e)
instance JSON.ToJSON PgError where
toJSON (H.TxError (P.ErroneousResult c m d h)) = JSON.object [
"code" .= (cs c::T.Text),
"message" .= (cs m::T.Text),
"details" .= (fmap cs d::Maybe T.Text),
"hint" .= (fmap cs h::Maybe T.Text)]
toJSON (H.TxError (P.NoResult d)) = JSON.object [
"message" .= ("No response from server"::T.Text),
"details" .= (fmap cs d::Maybe T.Text)]
toJSON (H.TxError (P.UnexpectedResult m)) = JSON.object ["message" .= m]
toJSON (H.TxError P.NotInTransaction) = JSON.object [
"message" .= ("Not in transaction"::T.Text)]
toJSON (H.CxError (P.CantConnect d)) = JSON.object [
"message" .= ("Can't connect to the database"::T.Text),
"details" .= (fmap cs d::Maybe T.Text)]
toJSON (H.CxError (P.UnsupportedVersion v)) = JSON.object [
"message" .= ("Postgres version "++version++" is not supported") ]
where version = replace "0" "." (show v)
toJSON (H.ResultError m) = JSON.object ["message" .= m]
httpStatus :: PgError -> HT.Status
httpStatus (H.TxError (P.ErroneousResult codeBS _ _ _)) =
let code = cs codeBS in
case code of
'0':'8':_ -> HT.status503 -- pg connection err
'0':'9':_ -> HT.status500 -- triggered action exception
'0':'L':_ -> HT.status403 -- invalid grantor
'0':'P':_ -> HT.status403 -- invalid role specification
'2':'5':_ -> HT.status500 -- invalid tx state
'2':'8':_ -> HT.status403 -- invalid auth specification
'2':'D':_ -> HT.status500 -- invalid tx termination
'3':'8':_ -> HT.status500 -- external routine exception
'3':'9':_ -> HT.status500 -- external routine invocation
'3':'B':_ -> HT.status500 -- savepoint exception
'4':'0':_ -> HT.status500 -- tx rollback
'5':'3':_ -> HT.status503 -- insufficient resources
'5':'4':_ -> HT.status413 -- too complex
'5':'5':_ -> HT.status500 -- obj not on prereq state
'5':'7':_ -> HT.status500 -- operator intervention
'5':'8':_ -> HT.status500 -- system error
'F':'0':_ -> HT.status500 -- conf file error
'H':'V':_ -> HT.status500 -- foreign data wrapper error
'P':'0':_ -> HT.status500 -- PL/pgSQL Error
'X':'X':_ -> HT.status500 -- internal Error
"42P01" -> HT.status404 -- undefined table
"42501" -> HT.status404 -- insufficient privilege
_ -> HT.status400
httpStatus (H.TxError (P.NoResult _)) = HT.status503
httpStatus _ = HT.status500
+26 -19
View File
@@ -4,27 +4,35 @@ import Paths_postgrest (version)
import App import App
import Middleware import Middleware
import Error(errResponse)
import Control.Monad (unless) import Control.Monad (unless)
import Control.Monad.IO.Class (liftIO) import Control.Monad.IO.Class (liftIO)
import Control.Exception
import Data.String.Conversions (cs) import Data.String.Conversions (cs)
import Network.Wai (strictRequestBody) import Network.Wai (strictRequestBody)
import Network.Wai.Middleware.Cors (cors) import Network.Wai.Middleware.Cors (cors)
import Network.Wai.Handler.Warp hiding (Connection) import Network.Wai.Handler.Warp hiding (Connection)
import Network.Wai.Middleware.Gzip (gzip, def) import Network.Wai.Middleware.Gzip (gzip, def)
import Network.Wai.Middleware.Static (staticPolicy, only) import Network.Wai.Middleware.Static (staticPolicy, only)
import Network.Wai.Middleware.RequestLogger (logStdout)
import Data.List (intercalate) import Data.List (intercalate)
import Data.Version (versionBranch) import Data.Version (versionBranch)
import qualified Hasql as H import qualified Hasql as H
import qualified Hasql.Postgres as H import qualified Hasql.Postgres as P
import Options.Applicative hiding (columns) import Options.Applicative hiding (columns)
import Config (AppConfig(..), argParser, corsPolicy) import Config (AppConfig(..), argParser, corsPolicy)
main :: IO () main :: IO ()
main = do main = do
conf <- execParser (info (helper <*> argParser) describe) let opts = info (helper <*> argParser) $
fullDesc
<> progDesc (
"PostgREST "
<> prettyVersion
<> " / create a REST API to an existing Postgres database"
)
conf <- execParser opts
let port = configPort conf let port = configPort conf
unless (configSecure conf) $ unless (configSecure conf) $
@@ -32,32 +40,31 @@ main = do
Prelude.putStrLn $ "Listening on port " ++ Prelude.putStrLn $ "Listening on port " ++
(show $ configPort conf :: String) (show $ configPort conf :: String)
let pgSettings = H.ParamSettings (cs $ configDbHost conf) let pgSettings = P.ParamSettings (cs $ configDbHost conf)
(fromIntegral $ configDbPort conf) (fromIntegral $ configDbPort conf)
(cs $ configDbUser conf) (cs $ configDbUser conf)
(cs $ configDbPass conf) (cs $ configDbPass conf)
(cs $ configDbName conf) (cs $ configDbName conf)
appSettings = setPort port
sessSettings <- maybe (fail "Improper session settings") return $
H.sessionSettings (fromIntegral $ configPool conf) 30
let appSettings = setPort port
. setServerName (cs $ "postgrest/" <> prettyVersion) . setServerName (cs $ "postgrest/" <> prettyVersion)
$ defaultSettings $ defaultSettings
middle = middle = logStdout
(if configSecure conf then redirectInsecure else id) . (if configSecure conf then redirectInsecure else id)
. gzip def . cors corsPolicy . gzip def . cors corsPolicy
. staticPolicy (only [("favicon.ico", "static/favicon.ico")]) . staticPolicy (only [("favicon.ico", "static/favicon.ico")])
anonRole = cs $ configAnonRole conf anonRole = cs $ configAnonRole conf
currRole = cs $ configDbUser conf
H.session pgSettings sessSettings $ H.sessionUnlifter >>= \unlift -> poolSettings <- maybe (fail "Improper session settings") return $
liftIO $ runSettings appSettings $ middle $ \req respond -> do H.poolSettings (fromIntegral $ configPool conf) 30
body <- strictRequestBody req pool :: H.Pool P.Postgres
respond =<< catchJust isSqlError <- H.acquirePool pgSettings poolSettings
(unlift $ H.tx Nothing
$ authenticated anonRole (app body) req) runSettings appSettings $ middle $ \req respond -> do
(return . sqlError) body <- strictRequestBody req
resOrError <- liftIO $ H.session pool $ H.tx Nothing $
authenticated currRole anonRole (app (cs $ configV1Schema conf) body) req
either (respond . errResponse) respond resOrError
where where
describe = progDesc "create a REST API to an existing Postgres database"
prettyVersion = intercalate "." $ map show $ versionBranch version prettyVersion = intercalate "." $ map show $ versionBranch version
+9 -21
View File
@@ -9,7 +9,7 @@ import Data.Text
-- import Data.Pool(withResource, Pool) -- import Data.Pool(withResource, Pool)
import qualified Hasql as H import qualified Hasql as H
import qualified Hasql.Postgres as H import qualified Hasql.Postgres as P
import Data.String.Conversions(cs) import Data.String.Conversions(cs)
import Network.HTTP.Types.Header (hLocation, hAuthorization) import Network.HTTP.Types.Header (hLocation, hAuthorization)
@@ -22,33 +22,21 @@ import Network.URI (URI(..), parseURI)
import Auth (LoginAttempt(..), signInRole, setRole, resetRole) import Auth (LoginAttempt(..), signInRole, setRole, resetRole)
import Codec.Binary.Base64.String (decode) import Codec.Binary.Base64.String (decode)
-- data Environment = Test | Production deriving (Eq) authenticated :: forall s. Text -> Text ->
(Request -> H.Tx P.Postgres s Response) ->
-- safeAction :: Request -> Bool Request -> H.Tx P.Postgres s Response
-- safeAction = (`notElem` ["PATCH", "PUT"]) . requestMethod authenticated currentRole anon app req = do
-- withSavepoint :: Environment -> (Connection -> Application) ->
-- Connection -> Application
-- withSavepoint env app conn req respond =
-- if env == Production && safeAction req
-- then go
-- else Database.PostgreSQL.Simple.withSavepoint conn go
-- where go = app conn req respond
authenticated :: forall s. Text -> (Request -> H.Tx H.Postgres s Response) ->
Request -> H.Tx H.Postgres s Response
authenticated anon app req = do
attempt <- httpRequesterRole (requestHeaders req) attempt <- httpRequesterRole (requestHeaders req)
case attempt of case attempt of
MalformedAuth -> MalformedAuth ->
return $ responseLBS status400 [] "Malformed basic auth header" return $ responseLBS status400 [] "Malformed basic auth header"
LoginFailed -> LoginFailed ->
return $ responseLBS status401 [] "Invalid username or password" return $ responseLBS status401 [] "Invalid username or password"
LoginSuccess role -> runInRole role LoginSuccess role -> if role /= currentRole then runInRole role else app req
NoCredentials -> runInRole anon NoCredentials -> if anon /= currentRole then runInRole anon else app req
where where
httpRequesterRole :: RequestHeaders -> H.Tx H.Postgres s LoginAttempt httpRequesterRole :: RequestHeaders -> H.Tx P.Postgres s LoginAttempt
httpRequesterRole hdrs = do httpRequesterRole hdrs = do
let auth = fromMaybe "" $ lookup hAuthorization hdrs let auth = fromMaybe "" $ lookup hAuthorization hdrs
case split (==' ') (cs auth) of case split (==' ') (cs auth) of
@@ -58,7 +46,7 @@ authenticated anon app req = do
_ -> return MalformedAuth _ -> return MalformedAuth
_ -> return NoCredentials _ -> return NoCredentials
runInRole :: Text -> H.Tx H.Postgres s Response runInRole :: Text -> H.Tx P.Postgres s Response
runInRole r = do runInRole r = do
setRole r setRole r
res <- app req res <- app req
-86
View File
@@ -1,86 +0,0 @@
{-# LANGUAGE OverloadedStrings #-}
module PgError (Message(..), message, httpStatus) where
import Text.Parsec
import Text.Parsec.Text
import qualified Data.Map as M
import Text.Regex.TDFA.Text ()
import Data.Text hiding (drop, concat, head)
import Data.Aeson
import Data.Maybe
import Control.Monad (void)
import Data.String.Conversions (cs)
import Data.CaseInsensitive (CI, mk)
import Network.HTTP.Types.Status
data Message = Message {
msgStatus :: Maybe Text
, msgCode :: Text
, msgText :: Maybe Text
, msgHint :: Maybe Text
} deriving (Show, Eq)
message :: Parser Message
message = do
ps <- sepBy valPair (char ';')
let m = M.fromList ps
return $ Message
(M.lookup "status" m)
(fromMaybe "" $ M.lookup "code" m)
(M.lookup "message" m)
(M.lookup "hint" m)
valPair :: Parser (CI Text, Text)
valPair = do
_ <- spaces
name <- many1 letter
_ <- char ':'
spaces
_ <- many $ char '"'
val <- manyTill anyChar $
try
(void $ many (char '"') >> (
(void . lookAhead $ (char ';'))
<|> ((optional $ char '.') >> eof)
))
return (mk (cs name), cs val)
instance ToJSON Message where
toJSON t = object [
"message" .= msgText t
, "code" .= msgCode t
, "status" .= msgStatus t
, "hint" .= msgHint t
]
httpStatus :: Message -> Status
httpStatus m =
let code = cs $ msgCode m :: String in
case code of
'0' : '8' : _ -> status503 -- pg connection err
'0' : '9' : _ -> status500 -- triggered action exception
'0' : 'L' : _ -> status403 -- invalid grantor
'0' : 'P' : _ -> status403 -- invalid role specification
'2' : '5' : _ -> status500 -- invalid tx state
'2' : '8' : _ -> status403 -- invalid auth specification
'2' : 'D' : _ -> status500 -- invalid tx termination
'3' : '8' : _ -> status500 -- external routine exception
'3' : '9' : _ -> status500 -- external routine invocation
'3' : 'B' : _ -> status500 -- savepoint exception
'4' : '0' : _ -> status500 -- tx rollback
'5' : '3' : _ -> status503 -- insufficient resources
'5' : '4' : _ -> status413 -- too complex
'5' : '5' : _ -> status500 -- obj not on prereq state
'5' : '7' : _ -> status500 -- operator intervention
'5' : '8' : _ -> status500 -- system error
'F' : '0' : _ -> status500 -- conf file error
'H' : 'V' : _ -> status500 -- foreign data wrapper error
'P' : '0' : _ -> status500 -- PL/pgSQL Error
'X' : 'X' : _ -> status500 -- internal Error
"42P01" -> status404 -- undefined table
"42501" -> status404 -- insufficient privilege
_ -> status400
+117 -117
View File
@@ -1,17 +1,21 @@
{-# LANGUAGE TypeSynonymInstances, FlexibleInstances #-} {-# LANGUAGE TypeSynonymInstances, FlexibleInstances #-}
{-# OPTIONS_GHC -fno-warn-orphans #-}
module PgQuery where module PgQuery where
import RangeQuery import RangeQuery
import qualified Hasql.Postgres as H import qualified Hasql as H
import qualified Hasql.Backend as H import qualified Hasql.Postgres as P
import qualified Hasql.Backend as B
import Data.Text hiding (map) import qualified Data.Text as T
import Text.Regex.TDFA ( (=~) ) import Text.Regex.TDFA ( (=~) )
import Text.Regex.TDFA.Text () import Text.Regex.TDFA.Text ()
import qualified Network.HTTP.Types.URI as Net import qualified Network.HTTP.Types.URI as Net
import qualified Data.ByteString.Char8 as BS import qualified Data.ByteString.Char8 as BS
import Data.Monoid import Data.Monoid
import Data.Vector (empty)
import Data.Maybe (fromMaybe, mapMaybe) import Data.Maybe (fromMaybe, mapMaybe)
import Data.Functor ( (<$>) ) import Data.Functor ( (<$>) )
import Control.Monad (join) import Control.Monad (join)
@@ -20,32 +24,35 @@ import qualified Data.Aeson as JSON
import qualified Data.List as L import qualified Data.List as L
import Data.Scientific (isInteger, formatScientific, FPFormat(..)) import Data.Scientific (isInteger, formatScientific, FPFormat(..))
type DynamicSQL = (BS.ByteString, [H.StatementArgument H.Postgres], All) type PStmt = H.Stmt P.Postgres
instance Monoid PStmt where
type StatementT = DynamicSQL -> DynamicSQL mappend (B.Stmt query params prep) (B.Stmt query' params' prep') =
B.Stmt (query <> query') (params <> params') (prep && prep')
mempty = B.Stmt "" empty True
type StatementT = PStmt -> PStmt
data QualifiedTable = QualifiedTable { data QualifiedTable = QualifiedTable {
qtSchema :: Text qtSchema :: T.Text
, qtName :: Text , qtName :: T.Text
} deriving (Show) } deriving (Show)
data OrderTerm = OrderTerm { data OrderTerm = OrderTerm {
otTerm :: Text otTerm :: T.Text
, otDirection :: BS.ByteString , otDirection :: BS.ByteString
} }
limitT :: Maybe NonnegRange -> StatementT limitT :: Maybe NonnegRange -> StatementT
limitT r q = limitT r q =
q <> (" LIMIT " <> limit <> " OFFSET " <> offset <> " ", [], mempty) q <> B.Stmt (" LIMIT " <> limit <> " OFFSET " <> offset <> " ") empty True
where where
limit = maybe "ALL" (cs . show) $ join $ rangeLimit <$> r limit = maybe "ALL" (cs . show) $ join $ rangeLimit <$> r
offset = cs . show $ fromMaybe 0 $ rangeOffset <$> r offset = cs . show $ fromMaybe 0 $ rangeOffset <$> r
whereT :: Net.Query -> StatementT whereT :: Net.Query -> StatementT
whereT params q = whereT params q =
if L.null params if L.null cols
then q then q
else q <> (" where ",[],mempty) <> conjunction else q <> B.Stmt " where " empty True <> conjunction
where where
cols = [ col | col <- params, fst col `notElem` ["order"] ] cols = [ col | col <- params, fst col `notElem` ["order"] ]
conjunction = mconcat $ L.intersperse andq (map wherePred cols) conjunction = mconcat $ L.intersperse andq (map wherePred cols)
@@ -54,98 +61,99 @@ orderT :: [OrderTerm] -> StatementT
orderT ts q = orderT ts q =
if L.null ts if L.null ts
then q then q
else q <> (" order by ",[],mempty) <> clause else q <> B.Stmt " order by " empty True <> clause
where where
clause = mconcat $ L.intersperse commaq (map queryTerm ts) clause = mconcat $ L.intersperse commaq (map queryTerm ts)
queryTerm :: OrderTerm -> DynamicSQL queryTerm :: OrderTerm -> PStmt
queryTerm t = (" " <> cs (pgFmtIdent $ otTerm t) <> " " queryTerm t = B.Stmt
<> otDirection t <> " " (" " <> cs (pgFmtIdent $ otTerm t) <> " "
, [], mempty) <> cs (otDirection t) <> " ")
empty True
parentheticT :: StatementT parentheticT :: StatementT
parentheticT (sql, params, pre) = parentheticT s =
(" (" <> sql <> ") ", params, pre) s { B.stmtTemplate = " (" <> B.stmtTemplate s <> ") " }
iffNotT :: DynamicSQL -> StatementT iffNotT :: PStmt -> StatementT
iffNotT (aq, ap, apre) (bq, bp, bpre) = iffNotT (B.Stmt aq ap apre) (B.Stmt bq bp bpre) =
("WITH aaa AS (" <> aq <> " returning *) " <> B.Stmt
bq <> " WHERE NOT EXISTS (SELECT * FROM aaa)" ("WITH aaa AS (" <> aq <> " returning *) " <>
, ap ++ bp bq <> " WHERE NOT EXISTS (SELECT * FROM aaa)")
, All $ getAll apre && getAll bpre (ap <> bp)
) (apre && bpre)
countRows :: QualifiedTable -> DynamicSQL countT :: StatementT
countRows t = countT s =
("select count(1) from " <> fromQt t, [], mempty) s { B.stmtTemplate = "WITH qqq AS (" <> B.stmtTemplate s <> ") SELECT count(1) FROM qqq" }
countRows :: QualifiedTable -> PStmt
countRows t = B.Stmt ("select count(1) from " <> fromQt t) empty True
asJsonWithCount :: StatementT asJsonWithCount :: StatementT
asJsonWithCount (sql, params, pre) = ( asJsonWithCount s = s { B.stmtTemplate =
"count(t), array_to_json(array_agg(row_to_json(t)))::character varying from (" <> sql <> ") t" "count(t), array_to_json(array_agg(row_to_json(t)))::character varying from ("
, params, pre <> B.stmtTemplate s <> ") t" }
)
asJsonRow :: StatementT asJsonRow :: StatementT
asJsonRow (sql, params, pre) = ( asJsonRow s = s { B.stmtTemplate = "row_to_json(t) from (" <> B.stmtTemplate s <> ") t" }
"row_to_json(t) from (" <> sql <> ") t", params, pre
)
selectStar :: QualifiedTable -> DynamicSQL selectStar :: QualifiedTable -> PStmt
selectStar t = selectStar t = B.Stmt ("select * from " <> fromQt t) empty True
("select * from " <> fromQt t, [], mempty)
insertInto :: QualifiedTable -> [Text] -> [JSON.Value] -> DynamicSQL returningStarT :: StatementT
insertInto t [] _ = returningStarT s = s { B.stmtTemplate = B.stmtTemplate s <> " RETURNING *" }
("insert into " <> fromQt t <> " default values returning *", [], mempty)
insertInto t cols vals = deleteFrom :: QualifiedTable -> PStmt
deleteFrom t = B.Stmt ("delete from " <> fromQt t) empty True
insertInto :: QualifiedTable -> [T.Text] -> [JSON.Value] -> PStmt
insertInto t [] _ = B.Stmt
("insert into " <> fromQt t <> " default values returning *") empty True
insertInto t cols vals = B.Stmt
("insert into " <> fromQt t <> " (" <> ("insert into " <> fromQt t <> " (" <>
cs (intercalate ", " (map pgFmtIdent cols)) <> T.intercalate ", " (map pgFmtIdent cols) <>
") values (" <> ") values ("
cs ( <> T.intercalate ", " (map ((<> "::unknown") . pgFmtLit . unquoted) vals)
intercalate ", " (map <> ") returning row_to_json(" <> fromQt t <> ".*)")
((<> "::unknown") . pgFmtLit . unquoted) empty True
vals)
) <> ") returning row_to_json(" <> fromQt t <> ".*)"
, []
, mempty
)
insertSelect :: QualifiedTable -> [Text] -> [JSON.Value] -> DynamicSQL insertSelect :: QualifiedTable -> [T.Text] -> [JSON.Value] -> PStmt
insertSelect t [] _ = insertSelect t [] _ = B.Stmt
("insert into " <> fromQt t <> " default values returning *", [], mempty) ("insert into " <> fromQt t <> " default values returning *") empty True
insertSelect t cols vals = insertSelect t cols vals = B.Stmt
("insert into " <> fromQt t <> " (" <> ("insert into " <> fromQt t <> " ("
cs (intercalate ", " (map pgFmtIdent cols)) <> <> T.intercalate ", " (map pgFmtIdent cols)
") select " <> <> ") select "
cs ( <> T.intercalate ", " (map ((<> "::unknown") . pgFmtLit . unquoted) vals))
intercalate ", " (map empty True
((<> "::unknown") . pgFmtLit . unquoted)
vals)
)
, []
, mempty
)
update :: QualifiedTable -> [Text] -> [JSON.Value] -> DynamicSQL update :: QualifiedTable -> [T.Text] -> [JSON.Value] -> PStmt
update t cols vals = update t cols vals = B.Stmt
("update " <> fromQt t <> " set (" <> ("update " <> fromQt t <> " set ("
cs (intercalate ", " (map pgFmtIdent cols)) <> <> T.intercalate ", " (map pgFmtIdent cols)
") = (" <> <> ") = ("
cs ( <> T.intercalate ", " (map ((<> "::unknown") . pgFmtLit . unquoted) vals)
intercalate ", " (map <> ")")
((<> "::unknown") . pgFmtLit . unquoted) empty True
vals)
) <> ")"
, []
, mempty
)
wherePred :: Net.QueryItem -> DynamicSQL wherePred :: Net.QueryItem -> PStmt
wherePred (col, predicate) = wherePred (col, predicate) = B.Stmt
(" " <> cs (pgFmtIdent $ cs col) <> " " <> op <> " " <> cs (pgFmtLit value) <> "::unknown ", [], mempty) (" " <> cs (pgFmtIdent $ cs col) <> " " <> op <> " " <> cs sqlValue)
empty True
where where
opCode:rest = split (=='.') $ cs $ fromMaybe "." predicate opCode:rest = T.split (=='.') $ cs $ fromMaybe "." predicate
value = intercalate "." rest value = T.intercalate "." rest
star c = if c == '*' then '%' else c
unknownLiteral = (<> "::unknown ") . pgFmtLit
sqlValue = case opCode of
"like" -> unknownLiteral $ T.map star value
"ilike" -> unknownLiteral $ T.map star value
"in" -> "(" <> T.intercalate ", " (map unknownLiteral $ T.split (==',') value) <> ") "
_ -> unknownLiteral value
op = case opCode of op = case opCode of
"eq" -> "=" "eq" -> "="
"gt" -> ">" "gt" -> ">"
@@ -153,68 +161,60 @@ wherePred (col, predicate) =
"gte" -> ">=" "gte" -> ">="
"lte" -> "<=" "lte" -> "<="
"neq" -> "<>" "neq" -> "<>"
"like"-> "like"
"ilike"-> "ilike"
"in" -> "in"
_ -> "=" _ -> "="
orderParse :: Net.Query -> [OrderTerm] orderParse :: Net.Query -> [OrderTerm]
orderParse q = orderParse q =
mapMaybe orderParseTerm . split (==',') $ cs order mapMaybe orderParseTerm . T.split (==',') $ cs order
where where
order = fromMaybe "" $ join (lookup "order" q) order = fromMaybe "" $ join (lookup "order" q)
orderParseTerm :: Text -> Maybe OrderTerm orderParseTerm :: T.Text -> Maybe OrderTerm
orderParseTerm s = orderParseTerm s =
case split (=='.') s of case T.split (=='.') s of
[d,c] -> [c,d] ->
if d `elem` ["asc", "desc"] if d `elem` ["asc", "desc"]
then Just $ OrderTerm c $ then Just $ OrderTerm c $
if d == "asc" then "asc" else "desc" if d == "asc" then "asc" else "desc"
else Nothing else Nothing
_ -> Nothing _ -> Nothing
commaq :: DynamicSQL commaq :: PStmt
commaq = (", ", [], mempty) commaq = B.Stmt ", " empty True
andq :: DynamicSQL andq :: PStmt
andq = (" and ", [], mempty) andq = B.Stmt " and " empty True
pgFmtIdent :: Text -> Text pgFmtIdent :: T.Text -> T.Text
pgFmtIdent x = pgFmtIdent x =
let escaped = replace "\"" "\"\"" (trimNullChars $ cs x) in let escaped = T.replace "\"" "\"\"" (trimNullChars $ cs x) in
if escaped =~ danger if escaped =~ danger
then "\"" <> escaped <> "\"" then "\"" <> escaped <> "\""
else escaped else escaped
where danger = "^$|^[^a-z_]|[^a-z_0-9]" :: Text where danger = "^$|^[^a-z_]|[^a-z_0-9]" :: T.Text
pgFmtLit :: Text -> Text pgFmtLit :: T.Text -> T.Text
pgFmtLit x = pgFmtLit x =
let trimmed = trimNullChars x let trimmed = trimNullChars x
escaped = "'" <> replace "'" "''" trimmed <> "'" escaped = "'" <> T.replace "'" "''" trimmed <> "'"
slashed = replace "\\" "\\\\" escaped in slashed = T.replace "\\" "\\\\" escaped in
cs $ if escaped =~ ("\\\\" :: Text) cs $ if escaped =~ ("\\\\" :: T.Text)
then "E" <> slashed then "E" <> slashed
else slashed else slashed
trimNullChars :: Text -> Text trimNullChars :: T.Text -> T.Text
trimNullChars = Data.Text.takeWhile (/= '\x0') trimNullChars = T.takeWhile (/= '\x0')
fromQt :: QualifiedTable -> BS.ByteString fromQt :: QualifiedTable -> T.Text
fromQt t = cs $ pgFmtIdent (qtSchema t) <> "." <> pgFmtIdent (qtName t) fromQt t = pgFmtIdent (qtSchema t) <> "." <> pgFmtIdent (qtName t)
unquoted :: JSON.Value -> Text unquoted :: JSON.Value -> T.Text
unquoted (JSON.String t) = t unquoted (JSON.String t) = t
unquoted (JSON.Number n) = unquoted (JSON.Number n) =
cs $ formatScientific Fixed (if isInteger n then Just 0 else Nothing) n cs $ formatScientific Fixed (if isInteger n then Just 0 else Nothing) n
unquoted (JSON.Bool b) = cs . show $ b unquoted (JSON.Bool b) = cs . show $ b
unquoted _ = "" unquoted _ = ""
pgParam :: JSON.Value -> H.StatementArgument H.Postgres
pgParam (JSON.Number n) = H.renderValue
(cs $ formatScientific Fixed
(if isInteger n then Just 0 else Nothing) n :: Text)
pgParam (JSON.String s) = H.renderValue s
pgParam (JSON.Bool b) = H.renderValue $
if b then "t" else "f" :: Text
pgParam JSON.Null = H.renderValue (Nothing :: Maybe Text)
pgParam (JSON.Object o) = H.renderValue $ JSON.encode o
pgParam (JSON.Array a) = H.renderValue $ JSON.encode a
+61 -77
View File
@@ -3,25 +3,21 @@
module PgStructure where module PgStructure where
import PgQuery (QualifiedTable(..)) import PgQuery (QualifiedTable(..))
import Data.Functor ( (<$>) )
import Data.Text hiding (foldl, map, zipWith, concat) import Data.Text hiding (foldl, map, zipWith, concat)
import Data.Aeson import Data.Aeson
import Data.Functor.Identity import Data.Functor.Identity
import qualified Data.Vector as V
import Data.String.Conversions (cs) import Data.String.Conversions (cs)
import Data.Maybe (fromMaybe)
import Control.Applicative ( (<$>) )
import Control.Applicative ( (<*>) )
import qualified Data.List as L
import qualified Data.Map as Map import qualified Data.Map as Map
import qualified Hasql as H import qualified Hasql as H
import qualified Hasql.Backend as H import qualified Hasql.Postgres as P
import qualified Hasql.Postgres as H
foreignKeys :: QualifiedTable -> H.Tx H.Postgres s (Map.Map Text ForeignKey) foreignKeys :: QualifiedTable -> H.Tx P.Postgres s (Map.Map Text ForeignKey)
foreignKeys table = do foreignKeys table = do
r :: [(Text, Text, Text)] <- H.list $ [H.q| r <- H.listEx $ [H.stmt|
select kcu.column_name, ccu.table_name AS foreign_table_name, select kcu.column_name, ccu.table_name AS foreign_table_name,
ccu.column_name AS foreign_column_name ccu.column_name AS foreign_column_name
from information_schema.table_constraints AS tc from information_schema.table_constraints AS tc
@@ -36,58 +32,65 @@ foreignKeys table = do
return $ foldl addKey Map.empty r return $ foldl addKey Map.empty r
where where
addKey m (col, ftab, fcol) = Map.insert col (ForeignKey (cs ftab) (cs fcol)) m addKey :: Map.Map Text ForeignKey -> (Text, Text, Text) -> Map.Map Text ForeignKey
addKey m (col, ftab, fcol) = Map.insert col (ForeignKey ftab fcol) m
tables :: Text -> H.Tx H.Postgres s [Table] tables :: Text -> H.Tx P.Postgres s [Table]
tables schema = tables schema = do
H.list $ [H.q| rows <- H.listEx $
[H.stmt|
select table_schema, table_name, select table_schema, table_name,
is_insertable_into is_insertable_into
from information_schema.tables from information_schema.tables
where table_schema = ? where table_schema = ?
order by table_name order by table_name
|] schema |] schema
return $ map tableFromRow rows
columns :: QualifiedTable -> H.Tx H.Postgres s [Column] columns :: QualifiedTable -> H.Tx P.Postgres s [Column]
columns table = do columns table = do
cols <- H.list $ [H.q| cols <- H.listEx $ [H.stmt|
select info.table_schema as schema, info.table_name as table_name, select info.table_schema as schema, info.table_name as table_name,
info.column_name as name, info.ordinal_position as position, info.column_name as name, info.ordinal_position as position,
info.is_nullable as nullable, info.data_type as col_type, info.is_nullable as nullable, info.data_type as col_type,
info.is_updatable as updatable, info.is_updatable as updatable,
info.character_maximum_length as max_len, info.character_maximum_length as max_len,
info.numeric_precision as precision, info.numeric_precision as precision,
info.column_default as default_value, info.column_default as default_value,
array_to_string(enum_info.vals, ',') as enum array_to_string(enum_info.vals, ',') as enum
from ( from (
select table_schema, table_name, column_name, ordinal_position, select table_schema, table_name, column_name, ordinal_position,
is_nullable, data_type, is_updatable, is_nullable, data_type, is_updatable,
character_maximum_length, numeric_precision, character_maximum_length, numeric_precision,
column_default, udt_name column_default, udt_name
from information_schema.columns from information_schema.columns
where table_schema = ? and table_name = ? where table_schema = ? and table_name = ?
) as info ) as info
left outer join ( left outer join (
select n.nspname as s, select n.nspname as s,
t.typname as n, t.typname as n,
array_agg(e.enumlabel ORDER BY e.enumsortorder) as vals array_agg(e.enumlabel ORDER BY e.enumsortorder) as vals
from pg_type t from pg_type t
join pg_enum e on t.oid = e.enumtypid join pg_enum e on t.oid = e.enumtypid
join pg_catalog.pg_namespace n ON n.oid = t.typnamespace join pg_catalog.pg_namespace n ON n.oid = t.typnamespace
group by s, n group by s, n
) as enum_info ) as enum_info
on (info.udt_name = enum_info.n) on (info.udt_name = enum_info.n)
order by position |] (qtSchema table) (qtName table) order by position |]
(qtSchema table) (qtName table)
fks <- foreignKeys table fks <- foreignKeys table
return $ map (\col -> col { colFK = Map.lookup (cs . colName $ col) fks }) cols return $ map (addFK fks . columnFromRow) cols
where
addFK fks col = col { colFK = Map.lookup (cs . colName $ col) fks }
primaryKeyColumns :: QualifiedTable -> H.Tx H.Postgres s [Text] primaryKeyColumns :: QualifiedTable -> H.Tx P.Postgres s [Text]
primaryKeyColumns table = do primaryKeyColumns table = do
r :: [Identity Text] <- H.list $ [H.q| r <- H.listEx $ [H.stmt|
select kc.column_name select kc.column_name
from from
information_schema.table_constraints tc, information_schema.table_constraints tc,
@@ -101,9 +104,6 @@ primaryKeyColumns table = do
return $ map runIdentity r return $ map runIdentity r
vanishNull :: [a] -> Maybe [a]
vanishNull xs = if L.null xs then Nothing else Just xs
toBool :: Text -> Bool toBool :: Text -> Bool
toBool = (== "YES") toBool = (== "YES")
@@ -132,37 +132,21 @@ data Column = Column {
, colFK :: Maybe ForeignKey , colFK :: Maybe ForeignKey
} deriving (Show) } deriving (Show)
instance H.RowParser H.Postgres Column where tableFromRow :: (Text, Text, Text) -> Table
parseRow r = tableFromRow (s, n, i) = Table s n (toBool i)
let schema = H.parseResult $ r V.! 0
table = H.parseResult $ r V.! 1
name = H.parseResult $ r V.! 2
position = H.parseResult $ r V.! 3
nullable = toBool <$> (H.parseResult $ r V.! 4 :: Either Text Text)
typ = H.parseResult $ r V.! 5
updatable = toBool <$> (H.parseResult $ r V.! 6 :: Either Text Text)
maxLen = H.parseResult $ r V.! 7
precision = H.parseResult $ r V.! 8
defValue = H.parseResult $ r V.! 9
enum = either (const $ Right []) (Right . split (==','))
(H.parseResult $ r V.! 10 :: Either Text Text)
in
if V.length r /= 11
then Left "Wrong number of fields in Column"
else Column <$> schema <*> table <*> name <*> position <*> nullable
<*> typ <*> updatable <*> maxLen <*> precision
<*> defValue <*> enum
<*> return Nothing
columnFromRow :: (Text, Text, Text,
Int, Text, Text,
Text, Maybe Int, Maybe Int,
Maybe Text, Maybe Text)
-> Column
columnFromRow (s, t, n, pos, nul, typ, u, l, p, d, e) =
Column s t n pos (toBool nul) typ (toBool u) l p d (parseEnum e) Nothing
where
parseEnum :: Maybe Text -> [Text]
parseEnum str = fromMaybe [] $ split (==',') <$> str
instance H.RowParser H.Postgres Table where
parseRow r =
let schema = H.parseResult $ r V.! 0
name = H.parseResult $ r V.! 1
insertable = toBool <$> (H.parseResult $ r V.! 2 :: Either Text Text) in
if V.length r /= 3
then Left "Wrong number of fields in Table"
else Table <$> schema <*> name <*> insertable
instance ToJSON Column where instance ToJSON Column where
toJSON c = object [ toJSON c = object [
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

+18 -14
View File
@@ -1,4 +1,3 @@
{-# LANGUAGE QuasiQuotes #-}
module Feature.AuthSpec where module Feature.AuthSpec where
-- {{{ Imports -- {{{ Imports
@@ -11,16 +10,21 @@ import SpecHelper
-- }}} -- }}}
spec :: Spec spec :: Spec
spec = before resetDb $ around withApp $ spec = beforeAll
describe "authorization" $ do (clearTable "postgrest.auth") . afterAll_ (clearTable "postgrest.auth")
it "hides tables that anonymous does not own" $ $ around withApp
get "/authors_only" `shouldRespondWith` 404 $ describe "authorization" $ do
it "indicates login failure" $ do
let auth = authHeader "postgrest_test_author" "fakefake" it "hides tables that anonymous does not own" $
request methodGet "/authors_only" [auth] "" get "/authors_only" `shouldRespondWith` 404
`shouldRespondWith` 401
it "allows users with permissions to see their tables" $ do it "indicates login failure" $ do
_ <- post "/postgrest/users" [json| { "id":"jdoe", "pass": "1234", "role": "postgrest_test_author" } |] let auth = authHeader "postgrest_test_author" "fakefake"
let auth = authHeader "jdoe" "1234" request methodGet "/authors_only" [auth] ""
request methodGet "/authors_only" [auth] "" `shouldRespondWith` 401
`shouldRespondWith` 200
it "allows users with permissions to see their tables" $ do
_ <- post "/postgrest/users" [json| { "id":"jdoe", "pass": "1234", "role": "postgrest_test_author" } |]
let auth = authHeader "jdoe" "1234"
request methodGet "/authors_only" [auth] ""
`shouldRespondWith` 200
+9 -2
View File
@@ -12,8 +12,7 @@ import Network.HTTP.Types
-- }}} -- }}}
spec :: Spec spec :: Spec
spec = before resetDb $ around withApp $ spec = around withApp $ describe "CORS" $ do
describe "CORS" $ do
let preflightHeaders = [ let preflightHeaders = [
("Accept", "*/*"), ("Accept", "*/*"),
("Origin", "http://example.com"), ("Origin", "http://example.com"),
@@ -54,6 +53,14 @@ spec = before resetDb $ around withApp $
r <- request methodOptions "/" preflightHeaders "" r <- request methodOptions "/" preflightHeaders ""
liftIO $ simpleBody r `shouldBe` "" liftIO $ simpleBody r `shouldBe` ""
describe "regular request" $
it "exposes necesssary response headers" $ do
r <- request methodGet "/items" [("Origin", "http://example.com")] ""
liftIO $ simpleHeaders r `shouldSatisfy` matchHeader
"Access-Control-Expose-Headers"
"Content-Encoding, Content-Location, Content-Range, Content-Type, \
\Date, Location, Server, Transfer-Encoding, Range-Unit"
describe "postflight request" $ describe "postflight request" $
it "allows INFO body through even with CORS request headers present" $ do it "allows INFO body through even with CORS request headers present" $ do
r <- request methodOptions "/items" normalCors "" r <- request methodOptions "/items" normalCors ""
+37
View File
@@ -0,0 +1,37 @@
module Feature.DeleteSpec where
import Test.Hspec
import Test.Hspec.Wai
import SpecHelper
import Network.HTTP.Types
spec :: Spec
spec = beforeAll (clearTable "items" >> createItems 15) . afterAll_ (clearTable "items")
. around withApp $
describe "Deleting" $ do
context "existing record" $ do
it "succeeds with 204 and deletion count" $
request methodDelete "/items?id=eq.1" [] ""
`shouldRespondWith` ResponseMatcher {
matchBody = Nothing
, matchStatus = 204
, matchHeaders = ["Content-Range" <:> "*/1"]
}
it "actually clears items ouf the db" $ do
_ <- request methodDelete "/items?id=lt.15" [] ""
get "/items"
`shouldRespondWith` ResponseMatcher {
matchBody = Just "[{\"id\":15}]"
, matchStatus = 200
, matchHeaders = ["Content-Range" <:> "0-0/1"]
}
context "known route, unknown record" $
it "fails with 404" $
request methodDelete "/items?id=eq.101" [] "" `shouldRespondWith` 404
context "totally unknown route" $
it "fails with 404" $
request methodDelete "/foozle?id=eq.101" [] "" `shouldRespondWith` 404
+18 -11
View File
@@ -1,4 +1,3 @@
{-# LANGUAGE QuasiQuotes #-}
module Feature.InsertSpec where module Feature.InsertSpec where
import Test.Hspec import Test.Hspec
@@ -16,12 +15,10 @@ import Control.Monad (replicateM_)
import TestTypes(IncPK(..), CompoundPK(..)) import TestTypes(IncPK(..), CompoundPK(..))
--import Debug.Trace
spec :: Spec spec :: Spec
spec = before resetDb $ around withApp $ do spec = afterAll_ resetDb $ around withApp $ do
describe "Posting new record" $ do describe "Posting new record" $ do
it "accepts disparate json types" $ do after_ (clearTable "menagerie") . it "accepts disparate json types" $ do
p <- post "/menagerie" p <- post "/menagerie"
[json| { [json| {
"integer": 13, "double": 3.14159, "varchar": "testing!" "integer": 13, "double": 3.14159, "varchar": "testing!"
@@ -33,7 +30,7 @@ spec = before resetDb $ around withApp $ do
simpleStatus p `shouldBe` created201 simpleStatus p `shouldBe` created201
context "with no pk supplied" $ do context "with no pk supplied" $ do
context "into a table with auto-incrementing pk" $ context "into a table with auto-incrementing pk" . after_ (clearTable "auto_incrementing_pk") $
it "succeeds with 201 and link" $ do it "succeeds with 201 and link" $ do
p <- post "/auto_incrementing_pk" [json| { "non_nullable_string":"not null"} |] p <- post "/auto_incrementing_pk" [json| { "non_nullable_string":"not null"} |]
liftIO $ do liftIO $ do
@@ -52,7 +49,7 @@ spec = before resetDb $ around withApp $ do
post "/simple_pk" [json| { "extra":"foo"} |] post "/simple_pk" [json| { "extra":"foo"} |]
`shouldRespondWith` 400 `shouldRespondWith` 400
context "into a table with no pk" $ context "into a table with no pk" . after_ (clearTable "no_pk") $ do
it "succeeds with 201 and a link including all fields" $ do it "succeeds with 201 and a link including all fields" $ do
p <- post "/no_pk" [json| { "a":"foo", "b":"bar" } |] p <- post "/no_pk" [json| { "a":"foo", "b":"bar" } |]
liftIO $ do liftIO $ do
@@ -60,7 +57,16 @@ spec = before resetDb $ around withApp $ do
simpleHeaders p `shouldSatisfy` matchHeader hLocation "/no_pk\\?a=eq.foo&b=eq.bar" simpleHeaders p `shouldSatisfy` matchHeader hLocation "/no_pk\\?a=eq.foo&b=eq.bar"
simpleStatus p `shouldBe` created201 simpleStatus p `shouldBe` created201
context "with compound pk supplied" $ it "returns full details of inserted record if asked" $ do
p <- request methodPost "/no_pk"
[("Prefer", "return=representation")]
[json| { "a":"bar", "b":"baz" } |]
liftIO $ do
simpleBody p `shouldBe` [json| { "a":"bar", "b":"baz" } |]
simpleHeaders p `shouldSatisfy` matchHeader hLocation "/no_pk\\?a=eq.bar&b=eq.baz"
simpleStatus p `shouldBe` created201
context "with compound pk supplied" . after_ (clearTable "compound_pk") $
it "builds response location header appropriately" $ it "builds response location header appropriately" $
post "/compound_pk" [json| { "k1":12, "k2":42 } |] post "/compound_pk" [json| { "k1":12, "k2":42 } |]
`shouldRespondWith` ResponseMatcher { `shouldRespondWith` ResponseMatcher {
@@ -101,7 +107,7 @@ spec = before resetDb $ around withApp $ do
[json| { "k1":12, "k2":42 } |] [json| { "k1":12, "k2":42 } |]
`shouldRespondWith` 400 `shouldRespondWith` 400
context "specifying every column in the table" $ do context "specifying every column in the table" . after_ (clearTable "compound_pk") $ do
it "can create a new record" $ do it "can create a new record" $ do
p <- request methodPut "/compound_pk?k1=eq.12&k2=eq.42" [] p <- request methodPut "/compound_pk?k1=eq.12&k2=eq.42" []
[json| { "k1":12, "k2":42, "extra":3 } |] [json| { "k1":12, "k2":42, "extra":3 } |]
@@ -131,7 +137,7 @@ spec = before resetDb $ around withApp $ do
let record = head rows let record = head rows
compoundExtra record `shouldBe` Just 5 compoundExtra record `shouldBe` Just 5
context "with an auto-incrementing primary key" $ context "with an auto-incrementing primary key" . after_ (clearTable "auto_incrementing_pk") $
it "succeeds with 204" $ it "succeeds with 204" $
request methodPut "/auto_incrementing_pk?id=eq.1" [] request methodPut "/auto_incrementing_pk?id=eq.1" []
@@ -161,7 +167,8 @@ spec = before resetDb $ around withApp $ do
[json| { "extra":20 } |] [json| { "extra":20 } |]
`shouldRespondWith` 204 `shouldRespondWith` 204
context "in a nonempty table" $ do context "in a nonempty table" . before_ (clearTable "items" >> createItems 15) .
after_ (clearTable "items") $ do
it "can update a single item" $ do it "can update a single item" $ do
g <- get "/items?id=eq.42" g <- get "/items?id=eq.42"
liftIO $ simpleHeaders g liftIO $ simpleHeaders g
+57 -16
View File
@@ -2,42 +2,83 @@ module Feature.QuerySpec where
import Test.Hspec import Test.Hspec
import Test.Hspec.Wai import Test.Hspec.Wai
import Test.Hspec.Wai.JSON
import Hasql as H
import Hasql.Postgres as H
import Control.Monad (void)
import Data.Text(Text)
import SpecHelper import SpecHelper
testSet :: IO ()
testSet = do
clearTable "items" >> clearTable "no_pk"
createItems 15
pool <- H.acquirePool pgSettings testPoolOpts
void . liftIO $ H.session pool $ H.tx Nothing $ do
H.unitEx $ insertNoPk "xyyx" "u"
H.unitEx $ insertNoPk "xYYx" "v"
where
insertNoPk :: Text -> Text -> H.Stmt H.Postgres
insertNoPk = [H.stmt|insert into "1".no_pk (a, b) values (?,?)|]
spec :: Spec spec :: Spec
spec = before resetDb $ around withApp $ do spec = beforeAll testSet . afterAll_ (clearTable "items") . around withApp $ do
describe "Querying a nonexistent table" $ describe "Querying a nonexistent table" $
it "causes a 404" $ it "causes a 404" $
get "/faketable" `shouldRespondWith` 404 get "/faketable" `shouldRespondWith` 404
describe "Filtering response" $ describe "Filtering response" $ do
context "column equality" $ it "matches with equality" $
get "/items?id=eq.5"
`shouldRespondWith` ResponseMatcher {
matchBody = Just [json| [{"id":5}] |]
, matchStatus = 200
, matchHeaders = ["Content-Range" <:> "0-0/1"]
}
it "matches the predicate" $ it "matches items IN" $
get "/items?id=eq.5" get "/items?id=in.1,3,5"
`shouldRespondWith` ResponseMatcher { `shouldRespondWith` ResponseMatcher {
matchBody = Just "[{\"id\":5}]" matchBody = Just [json| [{"id":1},{"id":3},{"id":5}] |]
, matchStatus = 200 , matchStatus = 200
, matchHeaders = ["Content-Range" <:> "0-0/1"] , matchHeaders = ["Content-Range" <:> "0-2/3"]
} }
it "matches with like" $ do
get "/no_pk?a=like.*yx" `shouldRespondWith` [json|
[{"a":"xyyx","b":"u"}]|]
get "/no_pk?a=like.xy*" `shouldRespondWith` [json|
[{"a":"xyyx","b":"u"}]|]
get "/no_pk?a=like.*YY*" `shouldRespondWith` [json|
[{"a":"xYYx","b":"v"}]|]
it "matches with ilike" $ do
get "/no_pk?a=ilike.xy*&order=b.asc" `shouldRespondWith` [json|
[{"a":"xyyx","b":"u"},{"a":"xYYx","b":"v"}]|]
get "/no_pk?a=ilike.*YY*&order=b.asc" `shouldRespondWith` [json|
[{"a":"xyyx","b":"u"},{"a":"xYYx","b":"v"}]|]
describe "ordering response" $ do describe "ordering response" $ do
it "by a column asc" $ it "by a column asc" $
get "/items?id=lte.2&order=asc.id" get "/items?id=lte.2&order=id.asc"
`shouldRespondWith` ResponseMatcher { `shouldRespondWith` ResponseMatcher {
matchBody = Just "[{\"id\":1},{\"id\":2}]" matchBody = Just [json| [{"id":1},{"id":2}] |]
, matchStatus = 200 , matchStatus = 200
, matchHeaders = ["Content-Range" <:> "0-1/2"] , matchHeaders = ["Content-Range" <:> "0-1/2"]
} }
it "by a column desc" $ it "by a column desc" $
get "/items?id=lte.2&order=desc.id" get "/items?id=lte.2&order=id.desc"
`shouldRespondWith` ResponseMatcher { `shouldRespondWith` ResponseMatcher {
matchBody = Just "[{\"id\":2},{\"id\":1}]" matchBody = Just [json| [{"id":2},{"id":1}] |]
, matchStatus = 200 , matchStatus = 200
, matchHeaders = ["Content-Range" <:> "0-1/2"] , matchHeaders = ["Content-Range" <:> "0-1/2"]
} }
it "without other constraints" $
get "/items?order=asc.id" `shouldRespondWith` 200
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"
@@ -48,9 +89,9 @@ spec = before resetDb $ around withApp $ do
} }
it "Omits question mark when there are no params" $ it "Omits question mark when there are no params" $
get "/no_pk" get "/simple_pk"
`shouldRespondWith` ResponseMatcher { `shouldRespondWith` ResponseMatcher {
matchBody = Just "[]" matchBody = Just "[]"
, matchStatus = 200 , matchStatus = 200
, matchHeaders = ["Content-Location" <:> "/no_pk"] , matchHeaders = ["Content-Location" <:> "/simple_pk"]
} }
+2 -1
View File
@@ -8,7 +8,8 @@ import Network.Wai.Test (SResponse(simpleHeaders,simpleStatus))
import SpecHelper import SpecHelper
spec :: Spec spec :: Spec
spec = before resetDb $ around withApp $ spec = beforeAll (clearTable "items" >> createItems 15) . afterAll_ (clearTable "items")
. around withApp $
describe "GET /items" $ do describe "GET /items" $ do
context "without range headers" $ context "without range headers" $
+1 -2
View File
@@ -1,4 +1,3 @@
{-# LANGUAGE OverloadedStrings, QuasiQuotes #-}
module Feature.StructureSpec where module Feature.StructureSpec where
import Test.Hspec hiding (pendingWith) import Test.Hspec hiding (pendingWith)
@@ -10,7 +9,7 @@ import SpecHelper
import Network.HTTP.Types import Network.HTTP.Types
spec :: Spec spec :: Spec
spec = before resetDb $ around withApp $ do spec = around withApp $ do
describe "GET /" $ do describe "GET /" $ do
it "lists views in schema" $ it "lists views in schema" $
request methodGet "/" [] "" request methodGet "/" [] ""
+8
View File
@@ -0,0 +1,8 @@
module Main where
import Test.Hspec
import SpecHelper
import Spec
main :: IO ()
main = resetDb >> hspec spec
+1 -1
View File
@@ -1 +1 @@
{-# OPTIONS_GHC -F -pgmF hspec-discover #-} {-# OPTIONS_GHC -F -pgmF hspec-discover -optF --no-main #-}
+48 -24
View File
@@ -1,5 +1,3 @@
{-# LANGUAGE QuasiQuotes, OverloadedStrings #-}
module SpecHelper where module SpecHelper where
import Network.Wai import Network.Wai
@@ -7,12 +5,14 @@ import Test.Hspec
import Test.Hspec.Wai import Test.Hspec.Wai
import Hasql as H import Hasql as H
import Hasql.Backend as H
import Hasql.Postgres as H import Hasql.Postgres as H
import Data.String.Conversions (cs) import Data.String.Conversions (cs)
-- import Control.Exception.Base (bracket, finally) import Data.Monoid
import Data.Text hiding (map)
import qualified Data.Vector as V
import Control.Monad (void) import Control.Monad (void)
import Control.Exception
import Network.HTTP.Types.Header (Header, ByteRange, renderByteRange, import Network.HTTP.Types.Header (Header, ByteRange, renderByteRange,
hRange, hAuthorization) hRange, hAuthorization)
@@ -24,9 +24,10 @@ import qualified Data.ByteString.Char8 as BS
import Network.Wai.Middleware.Cors (cors) import Network.Wai.Middleware.Cors (cors)
import System.Process (readProcess) import System.Process (readProcess)
import App (app, sqlError, isSqlError) import App (app)
import Config (AppConfig(..), corsPolicy) import Config (AppConfig(..), corsPolicy)
import Middleware import Middleware
import Error(errResponse)
-- import Auth (addUser) -- import Auth (addUser)
isLeft :: Either a b -> Bool isLeft :: Either a b -> Bool
@@ -34,36 +35,43 @@ isLeft (Left _ ) = True
isLeft _ = False isLeft _ = False
cfg :: AppConfig cfg :: AppConfig
cfg = AppConfig "postgrest_test" 5432 "postgrest_test" "" "localhost" 3000 "postgrest_anonymous" False 10 cfg = AppConfig "postgrest_test" 5432 "postgrest_test" "" "localhost" 3000 "postgrest_anonymous" False 10 "1"
testSettings :: SessionSettings testPoolOpts :: PoolSettings
testSettings = fromMaybe (error "bad settings") $ H.sessionSettings 1 30 testPoolOpts = fromMaybe (error "bad settings") $ H.poolSettings 1 30
pgSettings :: Postgres pgSettings :: H.Settings
pgSettings = H.ParamSettings "localhost" 5432 "postgrest_test" "" "postgrest_test" pgSettings = H.ParamSettings (cs $ configDbHost cfg)
(fromIntegral $ configDbPort cfg)
(cs $ configDbUser cfg)
(cs $ configDbPass cfg)
(cs $ configDbName cfg)
withApp :: ActionWith Application -> IO () withApp :: ActionWith Application -> IO ()
withApp perform = withApp perform = do
let anonRole = cs $ configAnonRole cfg in let anonRole = cs $ configAnonRole cfg
perform $ middle $ \req resp -> currRole = cs $ configDbUser cfg
H.session pgSettings testSettings $ H.sessionUnlifter >>= \unlift -> pool :: H.Pool H.Postgres
liftIO $ do <- H.acquirePool pgSettings testPoolOpts
body <- strictRequestBody req
resp =<< catchJust isSqlError perform $ middle $ \req resp -> do
(unlift $ H.tx Nothing body <- strictRequestBody req
$ authenticated anonRole (app body) req) result <- liftIO $ H.session pool $ H.tx Nothing
(return . sqlError) $ authenticated currRole anonRole (app (cs $ configV1Schema cfg) body) req
either (resp . errResponse) resp result
where middle = cors corsPolicy where middle = cors corsPolicy
resetDb :: IO () resetDb :: IO ()
resetDb = do resetDb = do
H.session pgSettings testSettings $ pool :: H.Pool H.Postgres
<- H.acquirePool pgSettings testPoolOpts
void . liftIO $ H.session pool $
H.tx Nothing $ do H.tx Nothing $ do
H.unit [H.q| drop schema if exists "1" cascade |] H.unitEx [H.stmt| drop schema if exists "1" cascade |]
H.unit [H.q| drop schema if exists private cascade |] H.unitEx [H.stmt| drop schema if exists private cascade |]
H.unit [H.q| drop schema if exists postgrest cascade |] H.unitEx [H.stmt| drop schema if exists postgrest cascade |]
loadFixture "roles" loadFixture "roles"
loadFixture "schema" loadFixture "schema"
@@ -88,6 +96,22 @@ authHeader :: String -> String -> Header
authHeader u p = authHeader u p =
(hAuthorization, cs $ "Basic " ++ encode (u ++ ":" ++ p)) (hAuthorization, cs $ "Basic " ++ encode (u ++ ":" ++ p))
clearTable :: Text -> IO ()
clearTable table = do
pool :: H.Pool H.Postgres
<- H.acquirePool pgSettings testPoolOpts
void . liftIO $ H.session pool $ H.tx Nothing $
H.unitEx $ H.Stmt ("delete from \"1\"."<>table) V.empty True
createItems :: Int -> IO ()
createItems n = do
pool :: H.Pool H.Postgres
<- H.acquirePool pgSettings testPoolOpts
void . liftIO $ H.session pool $ H.tx Nothing txn
where
txn = sequence_ $ map H.unitEx stmts
stmts = map [H.stmt|insert into "1".items (id) values (?)|] [1..n]
-- for hspec-wai -- for hspec-wai
pending_ :: WaiSession () pending_ :: WaiSession ()
pending_ = liftIO Test.Hspec.pending pending_ = liftIO Test.Hspec.pending
-41
View File
@@ -1,41 +0,0 @@
module Unit.ErrorsSpec where
import Test.Hspec
import Text.Parsec
import PgError
import Data.Either (rights)
spec :: Spec
spec =
describe "Parsing Hasql errors" $ do
it "can handle status and code" $
let p = parse message "" "Status: \"foo\"; Code: \"abc\"." in
rights [p] `shouldBe` [
Message (Just "foo") "abc" Nothing Nothing
]
it "can handle weird redundant quotes in status" $
let p = parse message "" "Status: \"\"foo\"\"; Code: \"abc\"." in
rights [p] `shouldBe` [
Message (Just "foo") "abc" Nothing Nothing
]
it "can handle text and code" $
let p = parse message "" "Message: \"foo\"; Code: \"abc\"." in
rights [p] `shouldBe` [
Message Nothing "abc" (Just "foo") Nothing
]
it "can handle status, text and code" $
let p = parse message "" "Status: \"hi\"; Message: \"foo\"; Code: \"abc\"." in
rights [p] `shouldBe` [
Message (Just "hi") "abc" (Just "foo") Nothing
]
it "can handle unescaped quotes in message" $
let p = parse message "" "Status: \"hi\"; Message: \"unknown \"foo\"!\"; Code: \"abc\"." in
rights [p] `shouldBe` [
Message (Just "hi") "abc" (Just "unknown \"foo\"!") Nothing
]
it "can handle periods in message" $
let p = parse message "" "Message: \"unknown \"foo\".bar\"; Code: \"42P01\"." in
rights [p] `shouldBe` [
Message Nothing "42P01" (Just "unknown \"foo\".bar") Nothing
]
-16
View File
@@ -453,22 +453,6 @@ SELECT pg_catalog.setval('has_fk_id_seq', 1, false);
-- --
INSERT INTO items (id) VALUES (1); INSERT INTO items (id) VALUES (1);
INSERT INTO items (id) VALUES (2);
INSERT INTO items (id) VALUES (3);
INSERT INTO items (id) VALUES (4);
INSERT INTO items (id) VALUES (5);
INSERT INTO items (id) VALUES (6);
INSERT INTO items (id) VALUES (7);
INSERT INTO items (id) VALUES (8);
INSERT INTO items (id) VALUES (9);
INSERT INTO items (id) VALUES (10);
INSERT INTO items (id) VALUES (11);
INSERT INTO items (id) VALUES (12);
INSERT INTO items (id) VALUES (13);
INSERT INTO items (id) VALUES (14);
INSERT INTO items (id) VALUES (15);
-- --
-- TOC entry 2339 (class 0 OID 0) -- TOC entry 2339 (class 0 OID 0)
-- Dependencies: 198 -- Dependencies: 198