diff --git a/main/Main.hs b/main/Main.hs
--- a/main/Main.hs
+++ b/main/Main.hs
@@ -9,7 +9,7 @@
                                                        minimumPgVersion,
                                                        prettyVersion,
                                                        readOptions)
-import           PostgREST.Error                      (prettyUsageError)
+import           PostgREST.Error                      (encodeError)
 import           PostgREST.OpenAPI                    (isMalformedProxyUri)
 import           PostgREST.DbStructure
 
@@ -57,6 +57,7 @@
       appSettings = setHost ((fromString . toS) host)
                   . setPort port
                   . setServerName (toS $ "postgrest/" <> prettyVersion)
+                  . setTimeout 3600
                   $ defaultSettings
 
   when (isMalformedProxyUri $ toS <$> proxy) $ panic
@@ -74,7 +75,7 @@
     getDbStructure (toS $ configSchema conf)
 
   forM_ (lefts [result]) $ \e -> do
-    hPutStrLn stderr (prettyUsageError e)
+    hPutStrLn stderr (toS $ encodeError e)
     exitFailure
 
   refDbStructure <- newIORef $ either (panic . show) id result
@@ -124,4 +125,3 @@
     setSecret bs = conf { configJwtSecret = Just bs }
 
     replaceUrlChars = replace "_" "/" . replace "-" "+" . replace "." "="
-        
diff --git a/postgrest.cabal b/postgrest.cabal
--- a/postgrest.cabal
+++ b/postgrest.cabal
@@ -2,7 +2,7 @@
 description:           Reads the schema of a PostgreSQL database and creates RESTful routes
                        for the tables and views, supporting all HTTP verbs that security
                        permits.
-version:               0.4.0.0
+version:               0.4.1.0
 synopsis:              REST API for any Postgres database
 license:               MIT
 license-file:          LICENSE
@@ -70,7 +70,7 @@
                      , lens
                      , lens-aeson
                      , network-uri
-                     , optparse-applicative >= 0.12.0.0 && < 0.13.0.0
+                     , optparse-applicative >= 0.13 && < 0.14
                      , parsec
                      , protolude
                      , Ranged-sets == 0.3.0
@@ -86,6 +86,7 @@
                      , wai-cors
                      , wai-extra
                      , wai-middleware-static
+                     , cookie
 
   Other-Modules:       Paths_postgrest
   Exposed-Modules:     PostgREST.ApiRequest
@@ -143,7 +144,7 @@
                      , hjsonpointer
                      , hjsonschema
                      , hspec
-                     , hspec-wai
+                     , hspec-wai >= 0.7.0
                      , hspec-wai-json
                      , http-types
                      , lens
diff --git a/src/PostgREST/ApiRequest.hs b/src/PostgREST/ApiRequest.hs
--- a/src/PostgREST/ApiRequest.hs
+++ b/src/PostgREST/ApiRequest.hs
@@ -3,19 +3,17 @@
 Description : PostgREST functions to translate HTTP request to a domain type called ApiRequest.
 -}
 module PostgREST.ApiRequest ( ApiRequest(..)
-                            , ApiRequestError(..)
                             , ContentType(..)
                             , Action(..)
                             , Target(..)
                             , PreferRepresentation (..)
                             , mutuallyAgreeable
-                            , toHeader
                             , userApiRequest
-                            , toMime
                             ) where
 
 import           Protolude
 import qualified Data.Aeson                as JSON
+import           Data.Aeson.Types          (emptyObject)
 import qualified Data.ByteString           as BS
 import qualified Data.ByteString.Internal  as BS (c2w)
 import qualified Data.ByteString.Lazy      as BL
@@ -29,16 +27,21 @@
 import qualified Data.Text                 as T
 import qualified Data.Vector               as V
 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.Wai               (Request (..))
 import           Network.Wai.Parse         (parseHttpAccept)
 import           PostgREST.RangeQuery      (NonnegRange, rangeRequested, restrictRange, rangeGeq, allRange, rangeLimit, rangeOffset)
 import           Data.Ranged.Boundaries
-import           PostgREST.Types           (QualifiedIdentifier (..),
-                                            Schema,
-                                            PayloadJSON(..))
+import           PostgREST.Types           ( QualifiedIdentifier (..)
+                                           , Schema
+                                           , PayloadJSON(..)
+                                           , ContentType(..)
+                                           , ApiRequestError(..)
+                                           , toMime)
 import           Data.Ranged.Ranges        (Range(..), rangeIntersection, emptyRange)
+import qualified Data.CaseInsensitive      as CI
+import           Web.Cookie                (parseCookiesText)
 
 type RequestBody = BL.ByteString
 
@@ -57,29 +60,6 @@
 -- | How to return the inserted data
 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
   translation of the raw elements of an HTTP request into domain
@@ -114,14 +94,18 @@
   , iCanonicalQS :: ByteString
   -- | JSON Web Token
   , iJWT :: Text
+  -- | HTTP request headers
+  , iHeaders :: [(Text, Text)]
+  -- | Request Cookies
+  , iCookies :: [(Text, Text)]
   }
 
 -- | Examines HTTP request and translates it into user intent.
 userApiRequest :: Schema -> Request -> RequestBody -> Either ApiRequestError ApiRequest
 userApiRequest schema req reqBody
-  | isTargetingProc && method /= "POST" = Left ErrorActionInappropriate
-  | topLevelRange == emptyRange = Left ErrorInvalidRange
-  | shouldParsePayload && isLeft payload = either (Left . ErrorInvalidBody . toS) undefined payload
+  | isTargetingProc && method /= "POST" = Left ActionInappropriate
+  | topLevelRange == emptyRange = Left InvalidRange
+  | shouldParsePayload && isLeft payload = either (Left . InvalidBody . toS) undefined payload
   | otherwise = Right ApiRequest {
       iAction = action
       , iTarget = target
@@ -141,19 +125,21 @@
         . parseSimpleQuery
         $ rawQueryString req
       , iJWT = tokenStr
+      , iHeaders = [ (toS $ CI.foldedCase k, toS v) | (k,v) <- hdrs, k /= hAuthorization, k /= hCookie]
+      , iCookies = fromMaybe [] $ parseCookiesText <$> lookupHeader "Cookie"
       }
  where
   isTargetingProc = fromMaybe False $ (== "rpc") <$> listToMaybe path
   payload =
     case decodeContentType . fromMaybe "application/json" $ lookupHeader "content-type" of
       CTApplicationJSON ->
-          either Left (\val -> case ensureUniform (pluralize val) of
-            Nothing -> Left "All object keys must match"
-            Just json -> Right json) (JSON.eitherDecode reqBody)
+        note "All object keys must match" . ensureUniform . pluralize
+          =<< if BL.null reqBody && isTargetingProc
+               then Right emptyObject
+               else JSON.eitherDecode reqBody
       CTTextCSV ->
-          either Left (\val -> case ensureUniform (csvToJson val) of
-            Nothing -> Left "All lines must have same number of fields"
-            Just json -> Right json) (CSV.decodeByName reqBody)
+        note "All lines must have same number of fields" . ensureUniform . csvToJson
+          =<< CSV.decodeByName reqBody
       CTOther "application/x-www-form-urlencoded" ->
         Right . PayloadJSON . V.singleton . M.fromList
                     . map (toS *** JSON.String . toS) . parseSimpleQuery
@@ -247,6 +233,7 @@
     "application/openapi+json"          -> CTOpenAPI
     "application/vnd.pgrst.object+json" -> CTSingularJSON
     "application/vnd.pgrst.object"      -> CTSingularJSON
+    "application/octet-stream"          -> CTOctetStream
     "*/*"                               -> CTAny
     ct'                                 -> CTOther ct'
 
diff --git a/src/PostgREST/App.hs b/src/PostgREST/App.hs
--- a/src/PostgREST/App.hs
+++ b/src/PostgREST/App.hs
@@ -8,6 +8,7 @@
 
 import           Control.Applicative
 import qualified Data.ByteString.Char8     as BS
+import           Data.Maybe
 import           Data.IORef                (IORef, readIORef)
 import           Data.Text                 (intercalate)
 import           Data.Time.Clock.POSIX     (POSIXTime)
@@ -32,15 +33,19 @@
                                         , Action(..), Target(..)
                                         , PreferRepresentation (..)
                                         , mutuallyAgreeable
-                                        , toHeader
                                         , userApiRequest
-                                        , toMime
                                         )
 import           PostgREST.Auth            (jwtClaims, containsRole)
 import           PostgREST.Config          (AppConfig (..))
 import           PostgREST.DbStructure
-import           PostgREST.DbRequestBuilder(readRequest, mutateRequest)
-import           PostgREST.Error           (errResponse, pgErrResponse, apiRequestErrResponse, singularityError)
+import           PostgREST.DbRequestBuilder( readRequest
+                                           , mutateRequest
+                                           , fieldNames
+                                           )
+import           PostgREST.Error           ( simpleError, pgError
+                                           , apiRequestError
+                                           , singularityError, binaryFieldError
+                                           )
 import           PostgREST.RangeQuery      (allRange, rangeOffset)
 import           PostgREST.Middleware
 import           PostgREST.QueryBuilder ( callProc
@@ -54,7 +59,8 @@
 import           PostgREST.OpenAPI
 
 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 ->
              Application
@@ -67,21 +73,35 @@
     dbStructure <- readIORef refDbStructure
 
     response <- case userApiRequest (configSchema conf) req body of
-      Left err -> return $ apiRequestErrResponse err
+      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 $ iAction apiRequest
+            txMode = transactionMode dbStructure
+              (iTarget apiRequest) (iAction apiRequest)
         response <- P.use pool $ HT.transaction HT.ReadCommitted txMode handleReq
-        return $ either (pgErrResponse authed) identity response
+        return $ either (pgError authed) identity response
     respond response
 
-transactionMode :: Action -> H.Mode
-transactionMode ActionRead = HT.Read
-transactionMode ActionInfo = HT.Read
-transactionMode _ = HT.Write
+transactionMode :: DbStructure -> Target -> Action -> H.Mode
+transactionMode structure target action =
+  case action of
+    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 conf apiRequest =
@@ -91,10 +111,13 @@
       case (iAction apiRequest, iTarget apiRequest, iPayload apiRequest) of
 
         (ActionRead, TargetIdent qi, Nothing) ->
-          case readSqlParts of
+          let partsField = (,) <$> readSqlParts
+                <*> (binaryField contentType =<< fldNames) in
+          case partsField of
             Left errorResponse -> return errorResponse
-            Right (q, cq) -> do
-              let stm = createReadStatement q cq (contentType == CTSingularJSON) shouldCount (contentType == CTTextCSV)
+            Right ((q, cq), bField) -> do
+              let stm = createReadStatement q cq (contentType == CTSingularJSON) shouldCount
+                                            (contentType == CTTextCSV) bField
               row <- H.query () stm
               let (tableTotal, queryTotal, _ , body) = row
                   (status, contentRange) = rangeHeader queryTotal tableTotal
@@ -142,10 +165,12 @@
                     if iPreferRepresentation apiRequest == Full
                       then toS body else ""
 
-        (ActionUpdate, TargetIdent _, Just payload) ->
-          case mutateSqlParts of
-            Left errorResponse -> return errorResponse
-            Right (sq, mq) -> do
+        (ActionUpdate, TargetIdent _, Just payload@(PayloadJSON rows)) ->
+          case (mutateSqlParts, null <$> rows V.!? 0, iPreferRepresentation apiRequest == Full) of
+            (Left errorResponse, _, _) -> return errorResponse
+            (_, 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
                     (contentType == CTSingularJSON) False (contentType == CTTextCSV)
                     (iPreferRepresentation apiRequest) []
@@ -223,7 +248,7 @@
               uri Nothing = ("http", host, port, "/")
               uri (Just Proxy { proxyScheme = s, proxyHost = h, proxyPort = p, proxyPath = b }) = (s, h, p, b)
               uri' = uri proxy
-              encodeApi ti = encodeOpenAPI (map snd $ dbProcs dbStructure) ti uri'
+              encodeApi ti = encodeOpenAPI (M.elems $ dbProcs dbStructure) ti uri'
           body <- encodeApi . toTableInfo <$> H.query schema accessibleTables
           return $ responseLBS status200 [toHeader CTOpenAPI] $ toS body
 
@@ -241,7 +266,6 @@
       filterPk sc table pk = sc == (tableSchema . pkTable) pk && table == (tableName . pkTable) pk
       filterCol :: Schema -> TableName -> Column -> Bool
       filterCol sc tb Column{colTable=Table{tableSchema=s, tableName=t}} = s==sc && t==tb
-      filterCol _ _ _ =  False
       allPrKeys = dbPrimaryKeys dbStructure
       allOrigins = ("Access-Control-Allow-Origin", "*") :: Header
       jsonH = toHeader CTApplicationJSON
@@ -255,10 +279,10 @@
             status = rangeStatus lower upper (toInteger <$> tableTotal)
         in (status, contentRange)
 
-      mapSnd f (a, b) = (a, f b)
-      readReq = readRequest (configMaxRows conf) (dbRelations dbStructure) (map (mapSnd pdReturnType) $ dbProcs dbStructure) apiRequest
+      readReq = readRequest (configMaxRows conf) (dbRelations dbStructure) (dbProcs dbStructure) apiRequest
+      fldNames = fieldNames <$> readReq
       readDbRequest = DbRead <$> readReq
-      mutateDbRequest = DbMutate <$> (mutateRequest apiRequest =<< readReq)
+      mutateDbRequest = DbMutate <$> (mutateRequest apiRequest =<< fldNames)
       selectQuery = requestToQuery schema False <$> readDbRequest
       mutateQuery = requestToQuery schema False <$> mutateDbRequest
       countQuery = requestToCountQuery schema <$> readDbRequest
@@ -270,20 +294,29 @@
   where
     contentTypesForRequest =
       case action of
-        ActionRead ->    [CTApplicationJSON, CTSingularJSON, CTTextCSV]
+        ActionRead ->    [CTApplicationJSON, CTSingularJSON, CTTextCSV, CTOctetStream]
         ActionCreate ->  [CTApplicationJSON, CTSingularJSON, CTTextCSV]
         ActionUpdate ->  [CTApplicationJSON, CTSingularJSON, CTTextCSV]
         ActionDelete ->  [CTApplicationJSON, CTSingularJSON, CTTextCSV]
         ActionInvoke ->  [CTApplicationJSON, CTSingularJSON]
-        ActionInspect -> [CTOpenAPI]
+        ActionInspect -> [CTOpenAPI, CTApplicationJSON]
         ActionInfo ->    [CTTextCSV]
     serves sProduces cAccepts =
       case mutuallyAgreeable sProduces cAccepts of
         Nothing -> do
           let failed = intercalate ", " $ map (toS . toMime) cAccepts
-          Left $ errResponse status415 $
+          Left $ simpleError status415 $
             "None of these Content-Types are available: " <> failed
         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 kv = (k, BS.tail v)
diff --git a/src/PostgREST/Auth.hs b/src/PostgREST/Auth.hs
--- a/src/PostgREST/Auth.hs
+++ b/src/PostgREST/Auth.hs
@@ -12,8 +12,7 @@
 very simple authentication system inside the PostgreSQL database.
 -}
 module PostgREST.Auth (
-    claimsToSQL
-  , containsRole
+    containsRole
   , jwtClaims
   , tokenJWT
   , JWTAttempt(..)
@@ -28,24 +27,8 @@
 import qualified Data.HashMap.Strict     as M
 import           Data.Maybe              (fromJust)
 import           Data.Time.Clock         (NominalDiffTime)
-import           PostgREST.QueryBuilder  (pgFmtIdent, pgFmtLit, unquoted)
 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
diff --git a/src/PostgREST/Config.hs b/src/PostgREST/Config.hs
--- a/src/PostgREST/Config.hs
+++ b/src/PostgREST/Config.hs
@@ -29,6 +29,7 @@
 import qualified Data.Configurator           as C
 import qualified Data.Configurator.Types     as C
 import           Data.List                   (lookup)
+import           Data.Monoid
 import           Data.Text                   (strip, intercalate, lines)
 import           Data.Text.Encoding          (encodeUtf8)
 import           Data.Text.IO                (hPutStrLn)
@@ -39,9 +40,8 @@
 import           Paths_postgrest             (version)
 import           Text.Heredoc
 import           Text.PrettyPrint.ANSI.Leijen hiding ((<>), (<$>))
-
-import           Protolude hiding            (intercalate
-                                             , (<>))
+import qualified Text.PrettyPrint.ANSI.Leijen as L
+import           Protolude hiding            (intercalate, (<>))
 
 -- | Config file settings for the server
 data AppConfig = AppConfig {
@@ -128,7 +128,7 @@
              )
            <> footerDoc (Just $
                text "Example Config File:"
-               <> nest 2 (hardline <> exampleCfg)
+               L.<> nest 2 (hardline L.<> exampleCfg)
              )
 
   parserPrefs = prefs showHelpOnError
@@ -143,6 +143,8 @@
   missingKeyHint (C.KeyError n) = do
     hPutStrLn stderr $
       "Required config parameter \"" <> n <> "\" is missing or of wrong type.\n" <>
+      "Documentation for configuration options available at\n" <>
+      "\thttp://postgrest.com/en/v0.4/admin.html#configuration\n\n" <>
       "Try the --example-config option to see how to configure PostgREST."
     exitFailure
 
diff --git a/src/PostgREST/DbRequestBuilder.hs b/src/PostgREST/DbRequestBuilder.hs
--- a/src/PostgREST/DbRequestBuilder.hs
+++ b/src/PostgREST/DbRequestBuilder.hs
@@ -2,31 +2,30 @@
 module PostgREST.DbRequestBuilder (
   readRequest
 , mutateRequest
+, fieldNames
 ) where
 
 import           Control.Applicative
 import           Control.Lens.Getter       (view)
 import           Control.Lens.Tuple        (_1)
 import qualified Data.ByteString.Char8     as BS
-import           Data.List                 (delete, lookup)
+import           Data.List                 (delete)
 import           Data.Maybe                (fromJust)
-import           Data.Text                 (isInfixOf, dropWhile, drop)
+import           Data.Text                 (isInfixOf)
 import           Data.Tree
 import           Data.Either.Combinators   (mapLeft)
 
-import           Text.Parsec.Error
-
-import           Network.HTTP.Types.Status
 import           Network.Wai
 
 import           Data.Foldable (foldr1)
 import qualified Data.HashMap.Strict       as M
 
-import           PostgREST.ApiRequest   ( ApiRequest(..) 
+import           PostgREST.ApiRequest   ( ApiRequest(..)
+                                        , PreferRepresentation(..)
                                         , Action(..), Target(..)
                                         , PreferRepresentation (..)
                                         )
-import           PostgREST.Error           (errResponse, formatParserError)
+import           PostgREST.Error           (apiRequestError)
 import           PostgREST.Parsers
 import           PostgREST.RangeQuery      (NonnegRange, restrictRange)
 import           PostgREST.QueryBuilder (getJoinConditions, sourceCTEName)
@@ -36,31 +35,31 @@
 import           Text.Regex.TDFA         ((=~))
 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  =
-  mapLeft (errResponse status400) $
+  mapLeft apiRequestError $
   treeRestrictRange maxRows =<<
   augumentRequestWithJoin schema relations =<<
-  first formatParserError parseReadRequest
+  parseReadRequest
   where
     (schema, rootTableName) = fromJust $ -- Make it safe
       let target = iTarget apiRequest in
       case target of
         (TargetIdent (QualifiedIdentifier s t) ) -> Just (s, t)
-        (TargetProc  (QualifiedIdentifier s p) ) -> Just (s, t)
+        (TargetProc  (QualifiedIdentifier s proc) ) -> Just (s, tName)
           where
-            returnType = fromMaybe "" $ lookup p allProcs
-            -- we are looking for results looking like "SETOF schema.tablename" and want to extract tablename
-            t = if "SETOF " `isInfixOf` returnType
-              then drop 1 $ dropWhile (/= '.') returnType
-              else p
+            retType = pdReturnType <$> M.lookup proc allProcs
+            tName = case retType of
+              Just (SetOf (Composite qi)) -> qiName qi
+              Just (Single (Composite qi)) -> qiName qi
+              _ -> proc
 
         _ -> Nothing
 
     action :: Action
     action = iAction apiRequest
 
-    parseReadRequest :: Either ParseError ReadRequest
+    parseReadRequest :: Either ApiRequestError ReadRequest
     parseReadRequest = addFiltersOrdersRanges apiRequest <*>
       pRequestSelect rootName selStr
       where
@@ -78,20 +77,18 @@
       _       -> allRels
       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
   where
     nodeRestrictRange :: Maybe Integer -> ReadNode -> ReadNode
     nodeRestrictRange m (q@Select {range_=r}, i) = (q{range_=restrictRange m r }, i)
 
-augumentRequestWithJoin :: Schema ->  [Relation] ->  ReadRequest -> Either Text ReadRequest
+augumentRequestWithJoin :: Schema ->  [Relation] ->  ReadRequest -> Either ApiRequestError ReadRequest
 augumentRequestWithJoin schema allRels request =
-  (first formatRelationError . addRelations schema allRels Nothing) request
+  addRelations schema allRels Nothing request
   >>= addJoinConditions schema
-  where
-    formatRelationError = ("could not find foreign keys between these entities, " <>)
 
-addRelations :: Schema -> [Relation] -> Maybe ReadRequest -> ReadRequest -> Either Text ReadRequest
+addRelations :: Schema -> [Relation] -> Maybe ReadRequest -> ReadRequest -> Either ApiRequestError ReadRequest
 addRelations schema allRelations parentNode (Node readNode@(query, (name, _, alias)) forest) =
   case parentNode of
     (Just (Node (Select{from=[parentNodeTable]}, (_, _, _)) _)) ->
@@ -100,8 +97,8 @@
         forest' = updateForest $ hush node'
         node' = Node <$> readNode' <*> pure forest
         readNode' = addRel readNode <$> rel
-        rel :: Either Text Relation
-        rel = note ("no relation between " <> parentNodeTable <> " and " <> name)
+        rel :: Either ApiRequestError Relation
+        rel = note (NoRelationBetween parentNodeTable name)
             $ findRelation schema name parentNodeTable
 
             where
@@ -153,10 +150,10 @@
         t = Table schema name True -- !!! TODO find another way to get the table from the query
         r = Relation t [] t [] Root Nothing Nothing Nothing
   where
-    updateForest :: Maybe ReadRequest -> Either Text [ReadRequest]
+    updateForest :: Maybe ReadRequest -> Either ApiRequestError [ReadRequest]
     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) =
   case r of
     Just Relation{relType=Root} -> Node nn  <$> updatedForest -- this is the root node
@@ -167,12 +164,12 @@
       where
          query' = addCond query (getJoinConditions rel)
          qq = query'{from=tableName linkTable : from query'}
-    _ -> Left "unknown relation"
+    _ -> Left UnknownRelation
   where
     updatedForest = mapM (addJoinConditions schema) forest
     addCond query' con = query'{flt_=con ++ flt_ query'}
 
-addFiltersOrdersRanges :: ApiRequest -> Either ParseError (ReadRequest -> ReadRequest)
+addFiltersOrdersRanges :: ApiRequest -> Either ApiRequestError (ReadRequest -> ReadRequest)
 addFiltersOrdersRanges apiRequest = foldr1 (liftA2 (.)) [
     flip (foldr addFilter) <$> filters,
     flip (foldr addOrder) <$> orders,
@@ -183,7 +180,7 @@
   of type (ReadRequest->ReadRequest) that are in (Either ParseError a) context
   -}
   where
-    filters :: Either ParseError [(Path, Filter)]
+    filters :: Either ApiRequestError [(Path, Filter)]
     filters = mapM pRequestFilter flts
       where
         action = iAction apiRequest
@@ -191,9 +188,9 @@
           | action == ActionRead = iFilters apiRequest
           | action == ActionInvoke = iFilters apiRequest
           | otherwise = filter (( "." `isInfixOf` ) . fst) $ iFilters apiRequest -- there can be no filters on the root table whre we are doing insert/update
-    orders :: Either ParseError [(Path, [OrderTerm])]
+    orders :: Either ApiRequestError [(Path, [OrderTerm])]
     orders = mapM pRequestOrder $ iOrder apiRequest
-    ranges :: Either ParseError [(Path, NonnegRange)]
+    ranges :: Either ApiRequestError [(Path, NonnegRange)]
     ranges = mapM pRequestRange $ M.toList $ iRange apiRequest
 
 addFilterToNode :: Filter -> ReadRequest -> ReadRequest
@@ -247,13 +244,13 @@
   | Just mt == (tableName <$> rt) = Just $ r {relLTable=(\tbl -> tbl {tableName=sourceCTEName}) <$> rt}
   | otherwise = Nothing
 
-mutateRequest :: ApiRequest -> ReadRequest -> Either Response MutateRequest
-mutateRequest apiRequest readReq = mapLeft (errResponse status400) $
+mutateRequest :: ApiRequest -> [FieldName] -> Either Response MutateRequest
+mutateRequest apiRequest fldNames = mapLeft apiRequestError $
   case action of
     ActionCreate -> Right $ Insert rootTableName payload returnings
     ActionUpdate -> Update rootTableName <$> pure payload <*> filters <*> pure returnings
     ActionDelete -> Delete rootTableName <$> filters <*> pure returnings
-    _        -> Left "Unsupported HTTP verb"
+    _        -> Left UnsupportedVerb
   where
     action = iAction apiRequest
     payload = fromJust $ iPayload apiRequest
@@ -262,14 +259,14 @@
       case target of
         (TargetIdent (QualifiedIdentifier _ t) ) -> t
         _ -> undefined
-    fieldNames :: ReadRequest -> PreferRepresentation -> [FieldName]
-    fieldNames _ None = []
-    fieldNames (Node (sel, _) forest) _ =
-      map (fst . view _1) (select sel) ++ map colName fks
-      where
-        fks = concatMap (fromMaybe [] . f) forest
-        f (Node (_, (_, Just Relation{relFColumns=cols, relType=Parent}, _)) _) = Just cols
-        f _ = Nothing
-    returnings = fieldNames readReq (iPreferRepresentation apiRequest) 
-    filters = first formatParserError $ map snd <$> mapM pRequestFilter mutateFilters
+    returnings = if iPreferRepresentation apiRequest == None then [] else fldNames
+    filters = map snd <$> mapM pRequestFilter mutateFilters
       where mutateFilters = filter (not . ( "." `isInfixOf` ) . fst) $ iFilters apiRequest -- update/delete filters can be only on the root table
+
+fieldNames :: ReadRequest -> [FieldName]
+fieldNames (Node (sel, _) forest) =
+  map (fst . view _1) (select sel) ++ map colName fks
+  where
+    fks = concatMap (fromMaybe [] . f) forest
+    f (Node (_, (_, Just Relation{relFColumns=cols, relType=Parent}, _)) _) = Just cols
+    f _ = Nothing
diff --git a/src/PostgREST/DbStructure.hs b/src/PostgREST/DbStructure.hs
--- a/src/PostgREST/DbStructure.hs
+++ b/src/PostgREST/DbStructure.hs
@@ -13,6 +13,7 @@
 import qualified Hasql.Query                   as H
 
 import           Control.Applicative
+import qualified Data.HashMap.Strict           as M
 import           Data.List                     (elemIndex)
 import           Data.Maybe                    (fromJust)
 import           Data.Text                     (split, strip,
@@ -96,12 +97,22 @@
     <*> 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 =
   H.statement sql (HE.value HE.text)
-    (map addName <$> HD.rowsList (ProcDescription <$> HD.value HD.text
-                                <*> (parseArgs <$> HD.value HD.text)
-                                <*> HD.value HD.text)) True
+    (M.fromList . map addName <$>
+      HD.rowsList (
+        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
   addName :: ProcDescription -> (Text, ProcDescription)
   addName pd = (pdName pd, pd)
@@ -118,14 +129,37 @@
        else Just $
          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|
-    SELECT p.proname as "proc_name",
-           pg_get_function_arguments(p.oid) as "args",
-           pg_get_function_result(p.oid) as "return_type"
-    FROM   pg_namespace n
-    JOIN   pg_proc p
-    ON     pronamespace = n.oid
-    WHERE  n.nspname = $1|]
+  SELECT p.proname as "proc_name",
+         pg_get_function_arguments(p.oid) as "args",
+         tn.nspname as "rettype_schema",
+         coalesce(comp.relname, t.typname) as "rettype_name",
+         p.proretset as "rettype_is_setof",
+         t.typtype as "rettype_typ",
+         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 =
@@ -606,7 +640,7 @@
                 select case when match is not null then coalesce(match[8], match[7], match[4]) end
                 from regexp_matches(
                     CONCAT('SELECT ', SPLIT_PART(vcu.view_definition, 'SELECT', 2)),
-                    CONCAT('SELECT.*?((',vcu.table_name,')|(\w+))\.(', vcu.column_name, ')(\s+AS\s+("([^"]+)"|([^, \n\t]+)))?.*?FROM.*?',vcu.table_schema,'\.(\2|',vcu.table_name,'\s+(as\s)?\3)'),
+                    CONCAT('SELECT.*?((',vcu.table_name,')|(\w+))\.(', vcu.column_name, ')(\s+AS\s+("([^"]+)"|([^, \n\t]+)))?.*?FROM.*?(',vcu.table_schema,'\.|)(\2|',vcu.table_name,'\s+(as\s)?\3)'),
                     'nsi'
                 ) match
             ) as view_column_name
diff --git a/src/PostgREST/Error.hs b/src/PostgREST/Error.hs
--- a/src/PostgREST/Error.hs
+++ b/src/PostgREST/Error.hs
@@ -2,46 +2,55 @@
 {-# LANGUAGE FlexibleInstances    #-}
 {-# LANGUAGE TypeSynonymInstances #-}
 
-module PostgREST.Error (apiRequestErrResponse, pgErrResponse, errResponse, prettyUsageError, singularityError, formatGeneralError, formatParserError) where
+module PostgREST.Error (
+  apiRequestError
+, pgError
+, simpleError
+, singularityError
+, binaryFieldError
+, encodeError
+) where
 
 import           Protolude
 import           Data.Aeson                ((.=))
 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.Session             as H
 import qualified Network.HTTP.Types.Status as HT
 import           Network.Wai               (Response, responseLBS)
-import           PostgREST.ApiRequest      (toHeader, toMime, ContentType(..), ApiRequestError(..))
-import           Text.Parsec.Error
+import           PostgREST.Types
 
-apiRequestErrResponse :: ApiRequestError -> Response
-apiRequestErrResponse err =
-  case err of
-    ErrorActionInappropriate -> errResponse HT.status405 "Bad Request"
-    ErrorInvalidBody errorMessage -> errResponse HT.status400 $ toS errorMessage
-    ErrorInvalidRange -> errResponse HT.status416 "HTTP Range error"
+apiRequestError :: ApiRequestError -> Response
+apiRequestError err = errorResponse status err
+  where
+    status =
+      case err of
+        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
-errResponse status message = jsonErrResponse status $ JSON.object ["message" .= message]
+simpleError :: HT.Status -> Text -> Response
+simpleError status message =
+  errorResponse status $ JSON.object ["message" .= message]
 
-jsonErrResponse :: HT.Status -> JSON.Value -> Response
-jsonErrResponse status message = responseLBS status [toHeader CTApplicationJSON] $ JSON.encode message
+errorResponse :: JSON.ToJSON a => HT.Status -> a -> Response
+errorResponse status e =
+  responseLBS status [toHeader CTApplicationJSON] $ encodeError e
 
-pgErrResponse :: Bool -> P.UsageError -> Response
-pgErrResponse authed e =
+pgError :: Bool -> P.UsageError -> Response
+pgError authed e =
   let status = httpStatus authed e
       jsonType = toHeader CTApplicationJSON
       wwwAuth = ("WWW-Authenticate", "Bearer")
       hdrs = if status == HT.status401
                 then [jsonType, wwwAuth]
                 else [jsonType] in
-  responseLBS status hdrs (JSON.encode e)
-
-prettyUsageError :: P.UsageError -> Text
-prettyUsageError (P.ConnectionError e) =
-  "Database connection error:\n" <> toS (fromMaybe "" e)
-prettyUsageError e = show $ JSON.encode e
+  responseLBS status hdrs (encodeError e)
 
 singularityError :: Integer -> Response
 singularityError numRows =
@@ -53,22 +62,40 @@
         [ "Results contain", show numRows, "rows,"
         , toS (toMime CTSingularJSON), "requires 1 row"
         ]
-
-formatParserError :: ParseError -> Text
-formatParserError e = formatGeneralError message details
   where
-     message = show $ errorPos e
-     details = strip $ replace "\n" " " $ toS
-       $ showErrorMessages "or" "unknown parse error" "expecting" "unexpected" "end of input" (errorMessages e)
+    formatGeneralError :: Text -> Text -> Text
+    formatGeneralError message details = toS . JSON.encode $
+      JSON.object ["message" .= message, "details" .= details]
 
-formatGeneralError :: Text -> Text -> Text
-formatGeneralError message details = toS . JSON.encode $
-  JSON.object ["message" .= message, "details" .= details]
 
+binaryFieldError :: Response
+binaryFieldError =
+  simpleError HT.status406 (toS (toMime CTOctetStream) <>
+  " requested but a single column was not selected")
+
+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
   toJSON (P.ConnectionError e) = JSON.object [
     "code" .= ("" :: Text),
-    "message" .= ("Connection error" :: Text),
+    "message" .= ("Database connection error" :: Text),
     "details" .= (toS $ fromMaybe "" e :: Text)]
   toJSON (P.SessionError e) = JSON.toJSON e -- H.Error
 
diff --git a/src/PostgREST/Middleware.hs b/src/PostgREST/Middleware.hs
--- a/src/PostgREST/Middleware.hs
+++ b/src/PostgREST/Middleware.hs
@@ -1,5 +1,6 @@
 {-# OPTIONS_GHC -fno-warn-orphans #-}
 {-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE FlexibleContexts #-}
 
 module PostgREST.Middleware where
 
@@ -14,11 +15,12 @@
 import           Network.Wai.Middleware.Gzip   (def, gzip)
 import           Network.Wai.Middleware.Static (only, staticPolicy)
 
-import           PostgREST.ApiRequest          (ApiRequest(..), ContentType(..),
-                                                toHeader)
-import           PostgREST.Auth                (claimsToSQL, JWTAttempt(..))
+import           PostgREST.ApiRequest          (ApiRequest(..))
+import           PostgREST.Auth                (JWTAttempt(..))
 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)
 
@@ -29,16 +31,22 @@
   case eClaims of
     JWTExpired -> return $ unauthed "JWT expired"
     JWTInvalid -> return $ unauthed "JWT invalid"
-    JWTMissingSecret -> return $ errResponse status500 "Server lacks JWT secret"
+    JWTMissingSecret -> return $ simpleError status500 "Server lacks JWT secret"
     JWTClaims claims -> do
-      -- role claim defaults to anon if not specified in jwt
-      let setClaims = claimsToSQL (M.union claims (M.singleton "role" anon))
-      H.sql $ mconcat setClaims
+      H.sql $ toS.mconcat $ setRoleSql ++ claimsSql ++ headersSql ++ cookiesSql
       mapM_ H.sql customReqCheck
       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
-    anon = String . toS $ configAnonRole conf
-    customReqCheck = (\f -> "select " <> toS f <> "();") <$> configReqCheck conf
     unauthed message = responseLBS unauthorized401
       [ toHeader CTApplicationJSON
       , ( "WWW-Authenticate"
diff --git a/src/PostgREST/OpenAPI.hs b/src/PostgREST/OpenAPI.hs
--- a/src/PostgREST/OpenAPI.hs
+++ b/src/PostgREST/OpenAPI.hs
@@ -20,11 +20,10 @@
 
 import           Data.Swagger
 
-import           PostgREST.ApiRequest        (ContentType(..), toMime)
+import           PostgREST.ApiRequest        (ContentType(..))
 import           PostgREST.Config            (prettyVersion)
-import           PostgREST.QueryBuilder      (operators)
 import           PostgREST.Types             (Table(..), Column(..), PgArg(..),
-                                              Proxy(..), ProcDescription(..))
+                                              Proxy(..), ProcDescription(..), toMime, Operator(..))
 
 makeMimeList :: [ContentType] -> MimeList
 makeMimeList cs = MimeList $ map (fromString . toS . toMime) cs
@@ -73,7 +72,7 @@
   intercalate "|"
   [ concat ["^", x, y, "[.]"] |
     x <- ["not[.]", ""],
-    y <- map fst operators ]
+    y <- map show [Equals ..] ]
 
 makeRowFilter :: Column -> Param
 makeRowFilter c =
@@ -237,7 +236,7 @@
   where
     getOp = (mempty :: Operation)
       & tags .~ Set.fromList ["/"]
-      & produces ?~ makeMimeList [CTOpenAPI]
+      & produces ?~ makeMimeList [CTOpenAPI, CTApplicationJSON]
       & at 200 ?~ "OK"
     pr = (mempty :: PathItem) & get ?~ getOp
     p = pr
diff --git a/src/PostgREST/Parsers.hs b/src/PostgREST/Parsers.hs
--- a/src/PostgREST/Parsers.hs
+++ b/src/PostgREST/Parsers.hs
@@ -2,37 +2,38 @@
 
 import           Protolude                     hiding (try, intercalate)
 import           Control.Monad                ((>>))
-import           Data.Text                     (intercalate)
+import           Data.Foldable                (foldl1)
+import           Data.Text                     (intercalate, replace, strip)
 import           Data.List                     (init, last)
 import           Data.Tree
-import           PostgREST.QueryBuilder        (operators)
+import           Data.Either.Combinators       (mapLeft)
 import           PostgREST.Types
 import           Text.ParserCombinators.Parsec hiding (many, (<|>))
+import           Text.Read                    (read)
 import           PostgREST.RangeQuery      (NonnegRange,allRange)
+import           Text.Parsec.Error
 
-pRequestSelect :: Text -> Text -> Either ParseError ReadRequest
-pRequestSelect rootName selStr = 
-  parse (pReadRequest rootName) ("failed to parse select parameter (" <> toS selStr <> ")") (toS selStr)
+pRequestSelect :: Text -> Text -> Either ApiRequestError ReadRequest
+pRequestSelect rootName selStr =
+  mapError $ parse (pReadRequest rootName) ("failed to parse select parameter (" <> toS selStr <> ")") (toS selStr)
 
-pRequestFilter :: (Text, Text) -> Either ParseError (Path, Filter)
-pRequestFilter (k, v) = (,) <$> path <*> (Filter <$> fld <*> op <*> val)
+pRequestFilter :: (Text, Text) -> Either ApiRequestError (Path, Filter)
+pRequestFilter (k, v) = mapError $ (,) <$> path <*> (Filter <$> fld <*> oper)
   where
     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 ("failed to parse filter (" ++ toS v ++ ")") $ toS v
     path = fst <$> treePath
     fld = snd <$> treePath
-    op = fst <$> opVal
-    val = snd <$> opVal
 
-pRequestOrder :: (Text, Text) -> Either ParseError (Path, [OrderTerm])
-pRequestOrder (k, v) = (,) <$> path <*> ord'
+pRequestOrder :: (Text, Text) -> Either ApiRequestError (Path, [OrderTerm])
+pRequestOrder (k, v) = mapError $ (,) <$> path <*> ord'
   where
     treePath = parse pTreePath ("failed to parser tree path (" ++ toS k ++ ")") $ toS k
     path = fst <$> treePath
     ord' = parse pOrder ("failed to parse order (" ++ toS v ++ ")") $ toS v
 
-pRequestRange :: (ByteString, NonnegRange) -> Either ParseError (Path, NonnegRange)
-pRequestRange (k, v) = (,) <$> path <*> pure v
+pRequestRange :: (ByteString, NonnegRange) -> Either ApiRequestError (Path, NonnegRange)
+pRequestRange (k, v) = mapError $ (,) <$> path <*> pure v
   where
     treePath = parse pTreePath ("failed to parser tree path (" ++ toS k ++ ")") $ toS k
     path = fst <$> treePath
@@ -118,22 +119,29 @@
     s <- pStar
     return ((s, Nothing), Nothing, Nothing)
 
-pOperator :: Parser Operator
-pOperator = toS <$> (pOp <?> "operator (eq, gt, ...)")
-  where pOp = foldl (<|>) empty $ map (try . string . toS . fst) operators
+pOperation :: Parser Operation
+pOperation = try ( string "not" *> pDelimiter *> (Operation True <$> pExpr)) <|> Operation False <$> pExpr
+  where
+    pExpr :: Parser (Operator, Operand)
+    pExpr =
+          ((,) <$> (read <$> foldl1 (<|>) (try . string . show <$> notInOps)) <*> (pDelimiter *> pVText))
+      <|> try (string (show In) *> pDelimiter *> ((,) <$> pure In <*> pVTextL))
+      <|> try (string (show NotIn) *> pDelimiter *> ((,) <$> pure NotIn <*> pVTextL))
+      <?> "operator (eq, gt, ...)"
+    notInOps = [Equals .. Contained]
 
-pValue :: Parser FValue
-pValue = VText <$> (toS <$> many anyChar)
+pVText :: Parser Operand
+pVText = VText . toS <$> many anyChar
 
+pVTextL :: Parser Operand
+pVTextL = VTextL <$> pLValue `sepBy1` char ','
+  where
+    pLValue :: Parser Text
+    pLValue = toS <$> (try (char '"' *> many (noneOf "\"") <* char '"' <* notFollowedBy (noneOf ",") ) <|> many (noneOf ","))
+
 pDelimiter :: Parser Char
 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 = lexeme pOrderTerm `sepBy` char ','
 
@@ -152,3 +160,13 @@
     return $ OrderTerm c d nls
   )
   <|> OrderTerm <$> pField <*> pure Nothing <*> pure Nothing
+
+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)
diff --git a/src/PostgREST/QueryBuilder.hs b/src/PostgREST/QueryBuilder.hs
--- a/src/PostgREST/QueryBuilder.hs
+++ b/src/PostgREST/QueryBuilder.hs
@@ -16,7 +16,6 @@
   , createReadStatement
   , createWriteStatement
   , getJoinConditions
-  , operators
   , pgFmtIdent
   , pgFmtLit
   , requestToQuery
@@ -24,6 +23,7 @@
   , sourceCTEName
   , unquoted
   , ResultsWithCount
+  , pgFmtEnvVar
   ) where
 
 import qualified Hasql.Query             as H
@@ -35,13 +35,13 @@
 import           PostgREST.RangeQuery    (NonnegRange, rangeLimit, rangeOffset, allRange)
 import           Data.Functor.Contravariant (contramap)
 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.Encoding as T
 import           Data.Tree               (Tree(..))
 import qualified Data.Vector as V
 import           PostgREST.Types
-import qualified Data.Map as M
 import           Text.InterpolatedString.Perl6 (qc)
 import qualified Data.ByteString.Char8   as BS
 import           Data.Scientific         ( FPFormat (..)
@@ -86,9 +86,9 @@
 encodeUniformObjs =
   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
-createReadStatement selectQuery countQuery isSingle countTotal asCsv =
+createReadStatement selectQuery countQuery isSingle countTotal asCsv binaryField =
   unicodeStatement sql HE.unit decodeStandard False
  where
   sql = [qc|
@@ -104,6 +104,7 @@
   bodyF
     | asCsv = asCsvF
     | isSingle = asJsonSingleF
+    | isJust binaryField = asBinaryF $ fromJust binaryField
     | otherwise = asJsonF
 
 createWriteStatement :: SqlQuery -> SqlQuery -> Bool -> Bool -> Bool ->
@@ -179,25 +180,6 @@
      | isSingle = asJsonSingleF
      | 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 x = "\"" <> replace "\"" "\"\"" (trimNullChars $ toS x) <> "\""
 
@@ -216,31 +198,27 @@
  unwords [
    "SELECT pg_catalog.count(*)",
    "FROM ", fromQi qi,
-   ("WHERE " <> intercalate " AND " ( map (pgFmtCondition qi) localConditions )) `emptyOnNull` localConditions
+   ("WHERE " <> intercalate " AND " ( map (pgFmtFilter qi) localConditions )) `emptyOnNull` localConditions
    ]
  where
-   qi = if mainTbl == sourceCTEName
-     then QualifiedIdentifier "" mainTbl
-     else QualifiedIdentifier schema mainTbl
-   fn Filter{value=VText _} = True
-   fn Filter{value=VForeignKey _ _} = False
+   qi = removeSourceCTESchema schema mainTbl
+   fn Filter{operation=Operation{expr=(_, VText _)}} = True
+   fn Filter{operation=Operation{expr=(_, VTextL _)}} = True
+   fn Filter{operation=Operation{expr=(_, VForeignKey _ _)}} = False
    localConditions = filter fn conditions
 
 requestToQuery :: Schema -> Bool -> DbRequest -> SqlQuery
 requestToQuery schema isParent (DbRead (Node (Select colSelects tbls conditions ord range, (nodeName, maybeRelation, _)) forest)) =
   query
   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)
-    tblSchema tbl = if tbl == sourceCTEName then "" else schema
-    qi = QualifiedIdentifier (tblSchema mainTbl) mainTbl
-    toQi t = QualifiedIdentifier (tblSchema t) t
+    qi = removeSourceCTESchema schema mainTbl
+    toQi = removeSourceCTESchema schema
     query = unwords [
       "SELECT ", intercalate ", " (map (pgFmtSelectItem qi) colSelects ++ selects),
       "FROM ", intercalate ", " (map (fromQi . toQi) tbls),
       unwords joins,
-      ("WHERE " <> intercalate " AND " ( map (pgFmtCondition qi ) conditions )) `emptyOnNull` conditions,
+      ("WHERE " <> intercalate " AND " ( map (pgFmtFilter qi ) conditions )) `emptyOnNull` conditions,
       orderF (fromMaybe [] ord),
       if isParent then "" else limitF range
       ]
@@ -269,11 +247,11 @@
       where
         node_name = fromMaybe name alias
         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
         sel = "row_to_json(" <> pgFmtIdent local_table_name <> ".*) AS " <> pgFmtIdent node_name
         joi = " LEFT OUTER JOIN ( " <> subquery <> " ) AS " <> pgFmtIdent local_table_name  <>
-              " ON " <> intercalate " AND " ( map (pgFmtCondition qi . replaceTableName local_table_name) (getJoinConditions r) )
+              " ON " <> intercalate " AND " ( map (pgFmtFilter qi . replaceTableName local_table_name) (getJoinConditions r) )
           where subquery = requestToQuery schema True (DbRead (Node n forst))
     getQueryParts (Node n@(_, (name, Just Relation{relType=Many,relTable=Table{tableName=table}}, alias)) forst) (j,s) = (j,sel:s)
       where
@@ -298,7 +276,7 @@
           if T.null colsString
             then if V.null rows then ["SELECT null WHERE false"] else ["DEFAULT VALUES"]
             else ["SELECT", colsString, "FROM json_populate_recordset(null::" , fromQi qi, ", $1)"]
-        ret = if null returnings 
+        ret = if null returnings
                   then ""
                   else unwords [" RETURNING ", intercalate ", " (map (pgFmtColumn qi) returnings)]
 requestToQuery schema _ (DbMutate (Update mainTbl (PayloadJSON rows) conditions returnings)) =
@@ -309,7 +287,7 @@
       unwords [
         "UPDATE ", fromQi qi,
         " SET " <> intercalate "," assignments <> " ",
-        ("WHERE " <> intercalate " AND " ( map (pgFmtCondition qi ) conditions )) `emptyOnNull` conditions,
+        ("WHERE " <> intercalate " AND " ( map (pgFmtFilter qi ) conditions )) `emptyOnNull` conditions,
         ("RETURNING " <> intercalate ", " (map (pgFmtColumn qi) returnings)) `emptyOnNull` returnings
         ]
     Nothing -> undefined
@@ -321,13 +299,16 @@
     qi = QualifiedIdentifier schema mainTbl
     query = unwords [
       "DELETE FROM ", fromQi qi,
-      ("WHERE " <> intercalate " AND " ( map (pgFmtCondition qi ) conditions )) `emptyOnNull` conditions,
+      ("WHERE " <> intercalate " AND " ( map (pgFmtFilter qi ) conditions )) `emptyOnNull` conditions,
       ("RETURNING " <> intercalate ", " (map (pgFmtColumn qi) returnings)) `emptyOnNull` returnings
       ]
 
 sourceCTEName :: SqlFragment
 sourceCTEName = "pg_source"
 
+removeSourceCTESchema :: Schema -> TableName -> QualifiedIdentifier
+removeSourceCTESchema schema tbl = QualifiedIdentifier (if tbl == sourceCTEName then "" else schema) tbl
+
 unquoted :: JSON.Value -> Text
 unquoted (JSON.String t) = t
 unquoted (JSON.Number n) =
@@ -356,6 +337,9 @@
 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 "
 
+asBinaryF :: FieldName -> SqlFragment
+asBinaryF fieldName = "coalesce(string_agg(_postgrest_t." <> pgFmtIdent fieldName <> ", ''), '')"
+
 locationF :: [Text] -> SqlFragment
 locationF pKeys =
     "(" <>
@@ -395,7 +379,7 @@
     ftN = tableName ft
     ltN = fromMaybe "" (tableName <$> lt)
     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 (Equals, 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 = H.statement . T.encodeUtf8
@@ -411,11 +395,6 @@
 insertableValueWithType t v =
   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 table "*" = fromQi table <> ".*"
 pgFmtColumn table c = fromQi table <> "." <> pgFmtIdent c
@@ -427,45 +406,40 @@
 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
 
-pgFmtCondition :: QualifiedIdentifier -> Filter -> SqlFragment
-pgFmtCondition table (Filter (col,jp) ops val) =
-  notOp <> " " <> sqlCol  <> " " <> pgFmtOperator opCode <> " " <>
-    if opCode `elem` ["is","isnot"] then whiteList (getInner val) else sqlValue
+pgFmtFilter :: QualifiedIdentifier -> Filter -> SqlFragment
+pgFmtFilter table (Filter fld (Operation hasNot_ ex@(op, operand))) = notOp <> " " <> case operand of
+  VForeignKey fQi (ForeignKey Column{colTable=Table{tableName=fTableName}, colName=fColName}) ->
+    pgFmtField fQi fld <> " " <> opToSqlFragment op <> " " <> pgFmtColumn (removeSourceCTESchema (qiSchema fQi) fTableName) fColName
+  _ -> pgFmtField table fld <> " " <> pgFmtExpr ex
   where
-    headPredicate:rest = split (=='.') ops
-    hasNot caseTrue caseFalse = if headPredicate == "not" then caseTrue else caseFalse
-    opCode      = hasNot (headDef "eq" rest) headPredicate
-    notOp       = hasNot headPredicate ""
-    sqlCol = case val of
-      VText _ -> pgFmtColumn table col <> pgFmtJsonPath jp
-      VForeignKey qi _ -> pgFmtColumn qi col
-    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
-      _ -> ""
+    notOp = if hasNot_ then "NOT" else ""
 
-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
+pgFmtExpr :: (Operator, Operand) -> SqlFragment
+pgFmtExpr ex =
+ case ex of
+   (Like, VText val)    -> opToSqlFragment Like <> " " <> unknownLiteral (T.map star val)
+   (ILike, VText val)   -> opToSqlFragment ILike <> " " <> unknownLiteral (T.map star val)
+   (TSearch, VText val) -> opToSqlFragment TSearch <> " " <> "to_tsquery(" <> unknownLiteral val <> ") "
+   (Is, VText val)      -> opToSqlFragment Is <> " " <> whiteList val
+   (In, VTextL vals)    -> exprForIn vals
+   (NotIn, VTextL vals) -> opToSqlFragment NotIn <> " " <> "(" <> intercalate ", " (map unknownLiteral vals) <> ") "
+   (op, VText val)      -> opToSqlFragment op <> " " <> unknownLiteral val
+   _ -> "" -- should not happen, all possible combinations are defined in Parsers
  where
    star c = if c == '*' then '%' else c
    unknownLiteral = (<> "::unknown ") . pgFmtLit
-
-pgFmtOperator :: Text -> SqlFragment
-pgFmtOperator opCode = fromMaybe "=" $ M.lookup opCode operatorsMap
-  where
-    operatorsMap = M.fromList operators
+   whiteList :: Text -> SqlFragment
+   whiteList v = fromMaybe
+     (toS (pgFmtLit v) <> "::unknown ")
+     (find ((==) . toLower $ v) ["null","true","false"])
+   exprForIn :: [Text] -> SqlFragment
+   exprForIn vals =
+    let emptyValForIn = "= any('{}') " in
+    case T.null <$> headMay vals of
+      Just isNull -> if isNull && length vals == 1
+                        then emptyValForIn
+                        else opToSqlFragment In <> " " <> "(" <> intercalate ", " (map unknownLiteral vals) <> ") "
+      Nothing -> emptyValForIn
 
 pgFmtJsonPath :: Maybe JsonPath -> SqlFragment
 pgFmtJsonPath (Just [x]) = "->>" <> pgFmtLit x
@@ -478,6 +452,10 @@
   Just alias -> " AS " <> pgFmtIdent alias
   Nothing -> ""
 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 = T.takeWhile (/= '\x0')
diff --git a/src/PostgREST/Types.hs b/src/PostgREST/Types.hs
--- a/src/PostgREST/Types.hs
+++ b/src/PostgREST/Types.hs
@@ -1,18 +1,35 @@
 module PostgREST.Types where
 import           Protolude
 import qualified GHC.Show
+import qualified GHC.Read
 import           Data.Aeson
 import qualified Data.ByteString.Lazy as BL
+import           Data.HashMap.Strict  as M
 import           Data.Tree
 import qualified Data.Vector          as V
 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 {
   dbTables      :: [Table]
 , dbColumns     :: [Column]
 , dbRelations   :: [Relation]
 , dbPrimaryKeys :: [PrimaryKey]
-, dbProcs       :: [(Text,ProcDescription)]
+, dbProcs       :: M.HashMap Text ProcDescription
 } deriving (Show, Eq)
 
 data PgArg = PgArg {
@@ -21,10 +38,18 @@
 , pgaReq  :: Bool
 } 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 {
   pdName       :: Text
 , pdArgs       :: [PgArg]
-, pdReturnType :: Text
+, pdReturnType :: RetType
+, pdVolatility :: ProcVolatility
 } deriving (Show, Eq)
 
 type Schema = Text
@@ -39,7 +64,7 @@
 , tableInsertable :: Bool
 } deriving (Show, Ord)
 
-data ForeignKey = ForeignKey { fkCol :: Column } deriving (Show, Eq, Ord)
+newtype ForeignKey = ForeignKey { fkCol :: Column } deriving (Show, Eq, Ord)
 
 data Column =
     Column {
@@ -54,9 +79,7 @@
     , colDefault   :: Maybe Text
     , colEnum      :: [Text]
     , colFK        :: Maybe ForeignKey
-    }
-  | Star { colTable :: Table }
-  deriving (Show, Ord)
+    } deriving (Show, Ord)
 
 type Synonym = (Column,Column)
 
@@ -114,8 +137,66 @@
 , proxyPath       :: Text
 } deriving (Show, Eq)
 
-type Operator = Text
-data FValue = VText Text | VForeignKey QualifiedIdentifier ForeignKey deriving (Show, Eq)
+data Operator = Equals | Gte | Gt | Lte | Lt | Neq | Like | ILike | Is | IsNot |
+                TSearch | Contains | Contained | In | NotIn deriving (Eq, Enum)
+
+instance Show Operator where
+  show op =  case op of
+    Equals -> "eq"
+    Gte -> "gte"
+    Gt -> "gt"
+    Lte -> "lte"
+    Lt -> "lt"
+    Neq -> "neq"
+    Like -> "like"
+    ILike -> "ilike"
+    In -> "in"
+    NotIn -> "notin"
+    IsNot -> "isnot"
+    Is -> "is"
+    TSearch -> "@@"
+    Contains -> "@>"
+    Contained -> "<@"
+
+instance Read Operator where
+  readsPrec _ op =  case op of
+    "eq" -> [(Equals, "")]
+    "gte" -> [(Gte, "")]
+    "gt" -> [(Gt, "")]
+    "lte" -> [(Lte, "")]
+    "lt" -> [(Lt, "")]
+    "neq" -> [(Neq, "")]
+    "like" -> [(Like, "")]
+    "ilike" -> [(ILike, "")]
+    "in" -> [(In, "")]
+    "notin" -> [(NotIn, "")]
+    "isnot" -> [(IsNot, "")]
+    "is" -> [(Is, "")]
+    "@@" -> [(TSearch, "")]
+    "@>" -> [(Contains, "")]
+    "<@" -> [(Contained, "")]
+    _ -> []
+
+opToSqlFragment :: Operator -> SqlFragment
+opToSqlFragment op = case op of
+  Equals -> "="
+  Gte -> ">="
+  Gt -> ">"
+  Lte -> "<="
+  Lt -> "<"
+  Neq -> "<>"
+  Like -> "LIKE"
+  ILike -> "ILIKE"
+  In -> "IN"
+  NotIn -> "NOT IN"
+  IsNot -> "IS NOT"
+  Is -> "IS"
+  TSearch -> "@@"
+  Contains -> "@>"
+  Contained -> "<@"
+
+data Operation = Operation{ hasNot::Bool, expr::(Operator, Operand) } deriving (Eq, Show)
+data Operand = VText Text | VTextL [Text] | VForeignKey QualifiedIdentifier ForeignKey deriving (Show, Eq)
 type FieldName = Text
 type JsonPath = [Text]
 type Field = (FieldName, Maybe JsonPath)
@@ -128,13 +209,12 @@
 data MutateQuery = Insert { in_::TableName, qPayload::PayloadJSON, returning::[FieldName] }
                  | Delete { in_::TableName, where_::[Filter], returning::[FieldName] }
                  | Update { in_::TableName, qPayload::PayloadJSON, where_::[Filter], returning::[FieldName] } deriving (Show, Eq)
-data Filter = Filter {field::Field, operator::Operator, value::FValue} deriving (Show, Eq)
+data Filter = Filter { field::Field, operation::Operation } deriving (Show, Eq)
 type ReadNode = (ReadQuery, (NodeName, Maybe Relation, Maybe Alias))
 type ReadRequest = Tree ReadNode
 type MutateRequest = MutateQuery
 data DbRequest = DbRead ReadRequest | DbMutate MutateRequest
 
-
 instance ToJSON Column where
   toJSON c = object [
       "schema"    .= tableSchema t
@@ -171,4 +251,17 @@
 
 instance Eq Column where
   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
diff --git a/test/Feature/AuthSpec.hs b/test/Feature/AuthSpec.hs
--- a/test/Feature/AuthSpec.hs
+++ b/test/Feature/AuthSpec.hs
@@ -18,44 +18,40 @@
   let single = ("Accept","application/vnd.pgrst.object+json")
 
   it "denies access to tables that anonymous does not own" $
-    get "/authors_only" `shouldRespondWith` ResponseMatcher {
-        matchBody = Just [json| {
+    get "/authors_only" `shouldRespondWith` [json| {
           "hint":null,
           "details":null,
           "code":"42501",
           "message":"permission denied for relation authors_only"} |]
-      , matchStatus = 401
+      { matchStatus = 401
       , matchHeaders = ["WWW-Authenticate" <:> "Bearer"]
       }
 
   it "denies access to tables that postgrest_test_author does not own" $
     let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0" in
     request methodGet "/private_table" [auth] ""
-      `shouldRespondWith` ResponseMatcher {
-        matchBody = Just [json| {
+      `shouldRespondWith` [json| {
           "hint":null,
           "details":null,
           "code":"42501",
           "message":"permission denied for relation private_table"} |]
-      , matchStatus = 403
+      { matchStatus = 403
       , matchHeaders = []
       }
 
   it "returns jwt functions as jwt tokens" $
     request methodPost "/rpc/login" [single]
       [json| { "id": "jdoe", "pass": "1234" } |]
-      `shouldRespondWith` ResponseMatcher {
-          matchBody = Just [json| {"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xuYW1lIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.P2G9EVSVI22MWxXWFuhEYd9BZerLS1WDlqzdqplM15s"} |]
-        , matchStatus = 200
-        , matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8"]
+      `shouldRespondWith` [json| {"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xuYW1lIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.P2G9EVSVI22MWxXWFuhEYd9BZerLS1WDlqzdqplM15s"} |]
+        { matchStatus = 200
+        , matchHeaders = [matchContentTypeJson]
         }
 
   it "sql functions can encode custom and standard claims" $
     request methodPost  "/rpc/jwt_test" [single] "{}"
-      `shouldRespondWith` ResponseMatcher {
-          matchBody = Just [json| {"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJqb2UiLCJzdWIiOiJmdW4iLCJhdWQiOiJldmVyeW9uZSIsImV4cCI6MTMwMDgxOTM4MCwibmJmIjoxMzAwODE5MzgwLCJpYXQiOjEzMDA4MTkzODAsImp0aSI6ImZvbyIsInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdCIsImh0dHA6Ly9wb3N0Z3Jlc3QuY29tL2ZvbyI6dHJ1ZX0.IHF16ZSU6XTbOnUWO8CCpUn2fJwt8P00rlYVyXQjpWc"} |]
-        , matchStatus = 200
-        , matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8"]
+      `shouldRespondWith` [json| {"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJqb2UiLCJzdWIiOiJmdW4iLCJhdWQiOiJldmVyeW9uZSIsImV4cCI6MTMwMDgxOTM4MCwibmJmIjoxMzAwODE5MzgwLCJpYXQiOjEzMDA4MTkzODAsImp0aSI6ImZvbyIsInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdCIsImh0dHA6Ly9wb3N0Z3Jlc3QuY29tL2ZvbyI6dHJ1ZX0.IHF16ZSU6XTbOnUWO8CCpUn2fJwt8P00rlYVyXQjpWc"} |]
+        { matchStatus = 200
+        , matchHeaders = [matchContentTypeJson]
         }
 
   it "sql functions can read custom and standard claims variables" $ do
@@ -82,9 +78,8 @@
   it "fails with an expired token" $ do
     let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE0NDY2NzgxNDksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UifQ.enk_qZ_u6gZsXY4R8bREKB_HNExRpM0lIWSLktk9JJQ"
     request methodGet "/authors_only" [auth] ""
-      `shouldRespondWith` ResponseMatcher {
-          matchBody = Nothing
-        , matchStatus = 401
+      `shouldRespondWith` [json| {"message":"JWT expired"} |]
+        { matchStatus = 401
         , matchHeaders = [
             "WWW-Authenticate" <:>
             "Bearer error=\"invalid_token\", error_description=\"JWT expired\""
@@ -94,9 +89,8 @@
   it "hides tables from users with invalid JWT" $ do
     let auth = authHeaderJWT "ey9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0"
     request methodGet "/authors_only" [auth] ""
-      `shouldRespondWith` ResponseMatcher {
-          matchBody = Nothing
-        , matchStatus = 401
+      `shouldRespondWith` [json| {"message":"JWT invalid"} |]
+        { matchStatus = 401
         , matchHeaders = [
             "WWW-Authenticate" <:>
             "Bearer error=\"invalid_token\", error_description=\"JWT invalid\""
@@ -126,9 +120,8 @@
       let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MX0.mI2HNoOum6xM3sc4oHLxU4yLv-_WV5W1kqBfY_wEvLw" in
       request methodPost "/rpc/get_current_user" [auth]
         [json| {} |]
-         `shouldRespondWith` ResponseMatcher {
-            matchBody    = Just [str|"postgrest_test_author"|]
-          , matchStatus = 200
+         `shouldRespondWith` [str|"postgrest_test_author"|]
+          { matchStatus = 200
           , matchHeaders = []
           }
 
@@ -136,9 +129,8 @@
       let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Mn0.W7jLsG-zswM91AJkCvZeIMHrnz7_6ceY2jnscVl3Yhk" in
       request methodPost "/rpc/get_current_user" [auth]
         [json| {} |]
-         `shouldRespondWith` ResponseMatcher {
-            matchBody    = Just [str|"postgrest_test_default_role"|]
-          , matchStatus = 200
+         `shouldRespondWith` [str|"postgrest_test_default_role"|]
+          { matchStatus = 200
           , matchHeaders = []
           }
 
@@ -146,8 +138,7 @@
       let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6M30.15Gy8PezQhJIaHYDJVLa-Gmz9T3sJnW66EKAYIsXc7c" in
       request methodPost "/rpc/get_current_user" [auth]
         [json| {} |]
-         `shouldRespondWith` ResponseMatcher {
-            matchBody    = Just [str|{"hint":"Please contact administrator","details":null,"code":"P0001","message":"Disabled ID --> 3"}|]
-          , matchStatus = 400
+         `shouldRespondWith` [str|{"hint":"Please contact administrator","details":null,"code":"P0001","message":"Disabled ID --> 3"}|]
+          { matchStatus = 400
           , matchHeaders = []
           }
diff --git a/test/Feature/ConcurrentSpec.hs b/test/Feature/ConcurrentSpec.hs
--- a/test/Feature/ConcurrentSpec.hs
+++ b/test/Feature/ConcurrentSpec.hs
@@ -24,14 +24,13 @@
     it "should not raise 'transaction in progress' error" $
       raceTest 10 $
         get "/fakefake"
-          `shouldRespondWith` ResponseMatcher {
-            matchBody    = Just [json|
+          `shouldRespondWith` [json|
               { "hint": null,
                 "details":null,
                 "code":"42P01",
                 "message":"relation \"test.fakefake\" does not exist"
               } |]
-          , matchStatus  = 404
+          { matchStatus  = 404
           , matchHeaders = []
           }
 
diff --git a/test/Feature/DeleteSpec.hs b/test/Feature/DeleteSpec.hs
--- a/test/Feature/DeleteSpec.hs
+++ b/test/Feature/DeleteSpec.hs
@@ -15,24 +15,21 @@
     context "existing record" $ do
       it "succeeds with 204 and deletion count" $
         request methodDelete "/items?id=eq.1" [] ""
-          `shouldRespondWith` ResponseMatcher {
-            matchBody    = Nothing
-          , matchStatus  = 204
+          `shouldRespondWith` ""
+          { matchStatus  = 204
           , matchHeaders = ["Content-Range" <:> "*/*"]
           }
 
       it "returns the deleted item and count if requested" $
         request methodDelete "/items?id=eq.2" [("Prefer", "return=representation"), ("Prefer", "count=exact")] ""
-          `shouldRespondWith` ResponseMatcher {
-            matchBody    = Just [str|[{"id":2}]|]
-          , matchStatus  = 200
+          `shouldRespondWith` [str|[{"id":2}]|]
+          { matchStatus  = 200
           , matchHeaders = ["Content-Range" <:> "*/1"]
           }
       it "returns the deleted item and shapes the response" $
         request methodDelete "/complex_items?id=eq.2&select=id,name" [("Prefer", "return=representation")] ""
-          `shouldRespondWith` ResponseMatcher {
-            matchBody    = Just [str|[{"id":2,"name":"Two"}]|]
-          , matchStatus  = 200
+          `shouldRespondWith` [str|[{"id":2,"name":"Two"}]|]
+          { matchStatus  = 200
           , matchHeaders = ["Content-Range" <:> "*/*"]
           }
       it "can rename and cast the selected columns" $
@@ -40,18 +37,16 @@
           `shouldRespondWith` [str|[{"ciId":"3","ciName":"Three"}]|]
       it "can embed (parent) entities" $
         request methodDelete "/tasks?id=eq.8&select=id,name,project{id}" [("Prefer", "return=representation")] ""
-          `shouldRespondWith` ResponseMatcher {
-            matchBody    = Just [str|[{"id":8,"name":"Code OSX","project":{"id":4}}]|]
-          , matchStatus  = 200
+          `shouldRespondWith` [str|[{"id":8,"name":"Code OSX","project":{"id":4}}]|]
+          { matchStatus  = 200
           , matchHeaders = ["Content-Range" <:> "*/*"]
           }
 
       it "actually clears items ouf the db" $ do
         _ <- request methodDelete "/items?id=lt.15" [] ""
         get "/items"
-          `shouldRespondWith` ResponseMatcher {
-            matchBody    = Just [str|[{"id":15}]|]
-          , matchStatus  = 200
+          `shouldRespondWith` [str|[{"id":15}]|]
+          { matchStatus  = 200
           , matchHeaders = ["Content-Range" <:> "0-0/*"]
           }
 
@@ -59,9 +54,8 @@
       it "includes [] body if return=rep" $
         request methodDelete "/items?id=eq.101"
           [("Prefer", "return=representation")] ""
-          `shouldRespondWith` ResponseMatcher {
-            matchBody    = Just "[]"
-          , matchStatus  = 200
+          `shouldRespondWith` "[]"
+          { matchStatus  = 200
           , matchHeaders = ["Content-Range" <:> "*/*"]
           }
 
diff --git a/test/Feature/InsertSpec.hs b/test/Feature/InsertSpec.hs
--- a/test/Feature/InsertSpec.hs
+++ b/test/Feature/InsertSpec.hs
@@ -3,6 +3,7 @@
 import Test.Hspec hiding (pendingWith)
 import Test.Hspec.Wai
 import Test.Hspec.Wai.JSON
+import Test.Hspec.Wai.Matcher (bodyEquals)
 import Network.Wai.Test (SResponse(simpleBody,simpleHeaders,simpleStatus))
 
 import SpecHelper
@@ -43,31 +44,29 @@
             "integer": 14, "double": 3.14159, "varchar": "testing!"
           , "boolean": false, "date": "1900-01-01", "money": "$3.99"
           , "enum": "foo"
-          }] |] `shouldRespondWith` ResponseMatcher {
-            matchBody    = Just [str|[{"integer":14,"varchar":"testing!"}]|]
-          , matchStatus  = 201
-          , matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8"]
+          }] |] `shouldRespondWith` [str|[{"integer":14,"varchar":"testing!"}]|]
+          { matchStatus  = 201
+          , matchHeaders = [matchContentTypeJson]
           }
 
     context "requesting full representation" $ do
       it "includes related data after insert" $
         request methodPost "/projects?select=id,name,clients{id,name}"
                 [("Prefer", "return=representation"), ("Prefer", "count=exact")]
-          [str|{"id":6,"name":"New Project","client_id":2}|] `shouldRespondWith` ResponseMatcher {
-            matchBody    = Just [str|[{"id":6,"name":"New Project","clients":{"id":2,"name":"Apple"}}]|]
-          , matchStatus  = 201
-          , matchHeaders = [ "Content-Type" <:> "application/json; charset=utf-8"
+          [str|{"id":6,"name":"New Project","client_id":2}|] `shouldRespondWith` [str|[{"id":6,"name":"New Project","clients":{"id":2,"name":"Apple"}}]|]
+          { matchStatus  = 201
+          , matchHeaders = [ matchContentTypeJson
                            , "Location" <:> "/projects?id=eq.6"
                            , "Content-Range" <:> "*/1" ]
           }
 
       it "can rename and cast the selected columns" $
         request methodPost "/projects?select=pId:id::text,pName:name,cId:client_id::text"
-                [("Prefer", "return=representation")] 
-          [str|{"id":7,"name":"New Project","client_id":2}|] `shouldRespondWith` ResponseMatcher {
-            matchBody    = Just [str|[{"pId":"7","pName":"New Project","cId":"2"}]|]
-          , matchStatus  = 201
-          , matchHeaders = [ "Content-Type" <:> "application/json; charset=utf-8"
+                [("Prefer", "return=representation")]
+          [str|{"id":7,"name":"New Project","client_id":2}|] `shouldRespondWith`
+          [str|[{"pId":"7","pName":"New Project","cId":"2"}]|]
+          { matchStatus  = 201
+          , matchHeaders = [ matchContentTypeJson
                            , "Location" <:> "/projects?id=eq.7"
                            , "Content-Range" <:> "*/*" ]
           }
@@ -201,9 +200,8 @@
         request methodPost "/json"
                      [("Prefer", "return=representation")]
                      inserted
-          `shouldRespondWith` ResponseMatcher {
-            matchBody    = Just [str|[{"data":{"foo":"bar"}}]|]
-          , matchStatus  = 201
+          `shouldRespondWith` [str|[{"data":{"foo":"bar"}}]|]
+          { matchStatus  = 201
           , matchHeaders = ["Location" <:> location]
           }
 
@@ -213,39 +211,35 @@
         request methodPost "/json"
                      [("Prefer", "return=representation")]
                      inserted
-          `shouldRespondWith` ResponseMatcher {
-            matchBody    = Just [str|[{"data":[1,2,3]}]|]
-          , matchStatus  = 201
+          `shouldRespondWith` [str|[{"data":[1,2,3]}]|]
+          { matchStatus  = 201
           , matchHeaders = ["Location" <:> location]
           }
 
     context "empty object" $
       it "successfully populates table with all-default columns" $
-        post "/items" "{}" `shouldRespondWith` ResponseMatcher {
-            matchBody    = Just ""
-          , matchStatus  = 201
+        post "/items" "{}" `shouldRespondWith` ""
+          { matchStatus  = 201
           , matchHeaders = []
           }
     context "table with limited privileges" $ do
       it "succeeds if correct select is applied" $
         request methodPost "/limited_article_stars?select=article_id,user_id" [("Prefer", "return=representation")]
-          [json| {"article_id": 2, "user_id": 1} |] `shouldRespondWith` ResponseMatcher {
-            matchBody    = Just [str|[{"article_id":2,"user_id":1}]|]
-          , matchStatus  = 201
+          [json| {"article_id": 2, "user_id": 1} |] `shouldRespondWith` [str|[{"article_id":2,"user_id":1}]|]
+          { matchStatus  = 201
           , matchHeaders = []
           }
       it "fails if more columns are selected" $
         request methodPost "/limited_article_stars?select=article_id,user_id,created_at" [("Prefer", "return=representation")]
-          [json| {"article_id": 2, "user_id": 2} |] `shouldRespondWith` ResponseMatcher {
-            matchBody    = Just [str|{"hint":null,"details":null,"code":"42501","message":"permission denied for relation limited_article_stars"}|]
-          , matchStatus  = 401
+          [json| {"article_id": 2, "user_id": 2} |] `shouldRespondWith`
+          [str|{"hint":null,"details":null,"code":"42501","message":"permission denied for relation limited_article_stars"}|]
+          { matchStatus  = 401
           , matchHeaders = []
           }
       it "fails if select is not specified" $
         request methodPost "/limited_article_stars" [("Prefer", "return=representation")]
-          [json| {"article_id": 3, "user_id": 1} |] `shouldRespondWith` ResponseMatcher {
-            matchBody    = Just [str|{"hint":null,"details":null,"code":"42501","message":"permission denied for relation limited_article_stars"}|]
-          , matchStatus  = 401
+          [json| {"article_id": 3, "user_id": 1} |] `shouldRespondWith` [str|{"hint":null,"details":null,"code":"42501","message":"permission denied for relation limited_article_stars"}|]
+          { matchStatus  = 401
           , matchHeaders = []
           }
 
@@ -259,11 +253,10 @@
             |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
-
-           `shouldRespondWith` ResponseMatcher {
-             matchBody    = Just inserted
-           , matchStatus  = 201
+           `shouldRespondWith` ResponseMatcher
+           { matchStatus  = 201
            , matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8"]
+           , matchBody = bodyEquals inserted
            }
 
     context "requesting full representation" $ do
@@ -271,9 +264,8 @@
         request methodPost "/no_pk"
                      [("Content-Type", "text/csv"), ("Accept", "text/csv"),  ("Prefer", "return=representation")]
                      "a,b\nbar,baz"
-          `shouldRespondWith` ResponseMatcher {
-            matchBody    = Just "a,b\nbar,baz"
-          , matchStatus  = 201
+          `shouldRespondWith` "a,b\nbar,baz"
+          { matchStatus  = 201
           , matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8",
                             "Location" <:> "/no_pk?a=eq.bar&b=eq.baz"]
           }
@@ -282,9 +274,8 @@
         request methodPost "/no_pk"
                      [("Content-Type", "text/csv"), ("Accept", "text/csv"), ("Prefer", "return=representation")]
                      "a,b\nNULL,foo"
-          `shouldRespondWith` ResponseMatcher {
-            matchBody    = Just "a,b\n,foo"
-          , matchStatus  = 201
+          `shouldRespondWith` "a,b\n,foo"
+          { matchStatus  = 201
           , matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8",
                             "Location" <:> "/no_pk?a=is.null&b=eq.foo"]
           }
@@ -293,9 +284,8 @@
         request methodPost "/projects?select=id"
                      [("Content-Type", "text/csv"), ("Accept", "text/csv"), ("Prefer", "return=representation")]
                      "id,name,client_id\n8,Xenix,1\n9,Windows NT,1"
-          `shouldRespondWith` ResponseMatcher {
-            matchBody    = Just "id\n8\n9"
-          , matchStatus  = 201
+          `shouldRespondWith` "id\n8\n9"
+          { matchStatus  = 201
           , matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8",
                             "Content-Range" <:> "*/*"]
           }
@@ -334,9 +324,8 @@
       it "indicates no records found to update" $
         request methodPatch "/empty_table" []
           [json| { "extra":20 } |]
-          `shouldRespondWith` ResponseMatcher {
-            matchBody    = Just "",
-            matchStatus  = 204,
+          `shouldRespondWith` ""
+          { matchStatus  = 204,
             matchHeaders = ["Content-Range" <:> "*/*"]
           }
 
@@ -346,9 +335,8 @@
         liftIO $ simpleHeaders g
           `shouldSatisfy` matchHeader "Content-Range" "\\*/\\*"
         p <- request methodPatch "/items?id=eq.2" [] [json| { "id":42 } |]
-        pure p `shouldRespondWith` ResponseMatcher {
-            matchBody    = Nothing,
-            matchStatus  = 204,
+        pure p `shouldRespondWith` ""
+          { matchStatus  = 204,
             matchHeaders = ["Content-Range" <:> "0-0/*"]
           }
         liftIO $ lookup hContentType (simpleHeaders p) `shouldBe` Nothing
@@ -363,8 +351,8 @@
       it "returns empty array when no rows updated and return=rep" $
         request methodPatch "/items?id=eq.999999"
           [("Prefer", "return=representation")] [json| { "id":999999 } |]
-          `shouldRespondWith` ResponseMatcher {
-            matchBody    = Just "[]",
+          `shouldRespondWith` "[]"
+          {
             matchStatus  = 200,
             matchHeaders = ["Content-Range" <:> "*/*"]
           }
@@ -372,9 +360,8 @@
       it "returns updated object as array when return=rep" $
         request methodPatch "/items?id=eq.2"
           [("Prefer", "return=representation")] [json| { "id":2 } |]
-          `shouldRespondWith` ResponseMatcher {
-            matchBody    = Just [str|[{"id":2}]|],
-            matchStatus  = 200,
+          `shouldRespondWith` [str|[{"id":2}]|]
+          { matchStatus  = 200,
             matchHeaders = ["Content-Range" <:> "0-0/*"]
           }
 
@@ -395,15 +382,15 @@
         _ <- request methodPatch "/no_pk?b=eq.nullme" [] [json| { b: null } |]
         get "/no_pk?a=eq.keepme" `shouldRespondWith`
           [json| [{ a: "keepme", b: null }] |]
+          { matchHeaders = [matchContentTypeJson] }
 
       it "can set a json column to escaped value" $ do
         _ <- post "/json" [json| { data: {"escaped":"bar"} } |]
         request methodPatch "/json?data->>escaped=eq.bar"
                      [("Prefer", "return=representation")]
                      [json| { "data": { "escaped":" \"bar" } } |]
-          `shouldRespondWith` ResponseMatcher {
-            matchBody    = Just [json| [{ "data": { "escaped":" \"bar" } }] |]
-          , matchStatus  = 200
+          `shouldRespondWith` [json| [{ "data": { "escaped":" \"bar" } }] |]
+          { matchStatus  = 200
           , matchHeaders = []
           }
 
@@ -412,9 +399,8 @@
           "/items?always_true=eq.false"
           [("Prefer", "return=representation")]
           [json| { id: 100 } |]
-          `shouldRespondWith` ResponseMatcher {
-            matchBody    = Just "[]",
-            matchStatus  = 200,
+          `shouldRespondWith` "[]"
+          { matchStatus  = 200,
             matchHeaders = ["Content-Range" <:> "*/*"]
           }
 
@@ -426,6 +412,31 @@
           [("Prefer", "return=representation")]
           [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" $
       it "succeeds and returns values intact" $ do
diff --git a/test/Feature/QueryLimitedSpec.hs b/test/Feature/QueryLimitedSpec.hs
--- a/test/Feature/QueryLimitedSpec.hs
+++ b/test/Feature/QueryLimitedSpec.hs
@@ -16,9 +16,8 @@
   describe "Requesting many items with server limits enabled" $ do
     it "restricts results" $
       get "/items"
-        `shouldRespondWith` ResponseMatcher {
-          matchBody    = Just [json| [{"id":1},{"id":2}] |]
-        , matchStatus  = 200
+        `shouldRespondWith` [json| [{"id":1},{"id":2}] |]
+        { matchStatus  = 200
         , matchHeaders = ["Content-Range" <:> "0-1/*"]
         }
 
@@ -32,17 +31,14 @@
 
     it "limit works on all levels" $
       get "/users?select=id,tasks{id}&order=id.asc&tasks.order=id.asc"
-        `shouldRespondWith` ResponseMatcher {
-          matchBody    = Just [str|[{"id":1,"tasks":[{"id":1},{"id":2}]},{"id":2,"tasks":[{"id":5},{"id":6}]}]|]
-        , matchStatus  = 200
+        `shouldRespondWith` [str|[{"id":1,"tasks":[{"id":1},{"id":2}]},{"id":2,"tasks":[{"id":5},{"id":6}]}]|]
+        { matchStatus  = 200
         , matchHeaders = ["Content-Range" <:> "0-1/*"]
         }
 
     it "limit is not applied to parent embeds" $
       get "/tasks?select=id,project{id}&id=gt.5"
-        `shouldRespondWith` ResponseMatcher {
-          matchBody    = Just [str|[{"id":6,"project":{"id":3}},{"id":7,"project":{"id":4}}]|]
-        , matchStatus  = 200
+        `shouldRespondWith` [str|[{"id":6,"project":{"id":3}},{"id":7,"project":{"id":4}}]|]
+        { matchStatus  = 200
         , matchHeaders = ["Content-Range" <:> "0-1/*"]
         }
-
diff --git a/test/Feature/QuerySpec.hs b/test/Feature/QuerySpec.hs
--- a/test/Feature/QuerySpec.hs
+++ b/test/Feature/QuerySpec.hs
@@ -5,6 +5,7 @@
 import Test.Hspec.Wai.JSON
 import Network.HTTP.Types
 import Network.Wai.Test (SResponse(simpleHeaders,simpleStatus,simpleBody))
+import qualified Data.ByteString.Lazy as BL (empty)
 
 import SpecHelper
 import Text.Heredoc
@@ -30,68 +31,49 @@
   describe "Filtering response" $ do
     it "matches with equality" $
       get "/items?id=eq.5"
-        `shouldRespondWith` ResponseMatcher {
-          matchBody    = Just [json| [{"id":5}] |]
-        , matchStatus  = 200
-        , matchHeaders = ["Content-Range" <:> "0-0/*"]
-        }
+        `shouldRespondWith` [json| [{"id":5}] |]
+        { matchHeaders = ["Content-Range" <:> "0-0/*"] }
 
     it "matches with equality using not operator" $
       get "/items?id=not.eq.5"
-        `shouldRespondWith` ResponseMatcher {
-          matchBody    = Just [json| [{"id":1},{"id":2},{"id":3},{"id":4},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}] |]
-        , matchStatus  = 200
-        , matchHeaders = ["Content-Range" <:> "0-13/*"]
-        }
+        `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}] |]
+        { matchHeaders = ["Content-Range" <:> "0-13/*"] }
 
     it "matches with more than one condition using not operator" $
       get "/simple_pk?k=like.*yx&extra=not.eq.u" `shouldRespondWith` "[]"
 
     it "matches with inequality using not operator" $ do
       get "/items?id=not.lt.14&order=id.asc"
-        `shouldRespondWith` ResponseMatcher {
-          matchBody    = Just [json| [{"id":14},{"id":15}] |]
-        , matchStatus  = 200
-        , matchHeaders = ["Content-Range" <:> "0-1/*"]
-        }
+        `shouldRespondWith` [json| [{"id":14},{"id":15}] |]
+        { matchHeaders = ["Content-Range" <:> "0-1/*"] }
       get "/items?id=not.gt.2&order=id.asc"
-        `shouldRespondWith` ResponseMatcher {
-          matchBody    = Just [json| [{"id":1},{"id":2}] |]
-        , matchStatus  = 200
-        , matchHeaders = ["Content-Range" <:> "0-1/*"]
-        }
+        `shouldRespondWith` [json| [{"id":1},{"id":2}] |]
+        { matchHeaders = ["Content-Range" <:> "0-1/*"] }
 
     it "matches items IN" $
       get "/items?id=in.1,3,5"
-        `shouldRespondWith` ResponseMatcher {
-          matchBody    = Just [json| [{"id":1},{"id":3},{"id":5}] |]
-        , matchStatus  = 200
-        , matchHeaders = ["Content-Range" <:> "0-2/*"]
-        }
+        `shouldRespondWith` [json| [{"id":1},{"id":3},{"id":5}] |]
+        { matchHeaders = ["Content-Range" <:> "0-2/*"] }
 
     it "matches items NOT IN" $
       get "/items?id=notin.2,4,6,7,8,9,10,11,12,13,14,15"
-        `shouldRespondWith` ResponseMatcher {
-          matchBody    = Just [json| [{"id":1},{"id":3},{"id":5}] |]
-        , matchStatus  = 200
-        , matchHeaders = ["Content-Range" <:> "0-2/*"]
-        }
+        `shouldRespondWith` [json| [{"id":1},{"id":3},{"id":5}] |]
+        { matchHeaders = ["Content-Range" <:> "0-2/*"] }
 
     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"
-        `shouldRespondWith` ResponseMatcher {
-          matchBody    = Just [json| [{"id":1},{"id":3},{"id":5}] |]
-        , matchStatus  = 200
-        , matchHeaders = ["Content-Range" <:> "0-2/*"]
-        }
+        `shouldRespondWith` [json| [{"id":1},{"id":3},{"id":5}] |]
+        { matchHeaders = ["Content-Range" <:> "0-2/*"] }
 
     it "matches nulls using not operator" $
       get "/no_pk?a=not.is.null" `shouldRespondWith`
         [json| [{"a":"1","b":"0"},{"a":"2","b":"0"}] |]
+        { matchHeaders = [matchContentTypeJson] }
 
     it "matches nulls in varchar and numeric fields alike" $ do
       get "/no_pk?a=is.null" `shouldRespondWith`
         [json| [{"a": null, "b": null}] |]
+        { matchHeaders = [matchContentTypeJson] }
 
       get "/nullable_integer?a=is.null" `shouldRespondWith` [str|[{"a":null}]|]
 
@@ -119,25 +101,28 @@
     it "matches with tsearch @@" $
       get "/tsearch?text_search_vector=@@.foo" `shouldRespondWith`
         [json| [{"text_search_vector":"'bar':2 'foo':1"}] |]
+        { matchHeaders = [matchContentTypeJson] }
 
     it "matches with tsearch @@ using not operator" $
       get "/tsearch?text_search_vector=not.@@.foo" `shouldRespondWith`
         [json| [{"text_search_vector":"'baz':1 'qux':2"}] |]
+        { matchHeaders = [matchContentTypeJson] }
 
     it "matches with computed column" $
       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}] |]
+        { matchHeaders = [matchContentTypeJson] }
 
     it "order by computed column" $
       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}] |]
+        { matchHeaders = [matchContentTypeJson] }
 
     it "matches filtering nested items 2" $
       get "/clients?select=id,projects{id,tasks2{id,name}}&projects.tasks.name=like.Design*"
-        `shouldRespondWith` ResponseMatcher {
-          matchBody    = Just [json| {"message":"could not find foreign keys between these entities, no relation between projects and tasks2"}|]
-        , matchStatus  = 400
-        , matchHeaders = []
+        `shouldRespondWith` [json| {"message":"Could not find foreign keys between these entities, No relation found between projects and tasks2"}|]
+        { matchStatus  = 400
+        , matchHeaders = [matchContentTypeJson]
         }
 
     it "matches filtering nested items" $
@@ -166,45 +151,50 @@
     it "one simple column" $
       get "/complex_items?select=id" `shouldRespondWith`
         [json| [{"id":1},{"id":2},{"id":3}] |]
+        { matchHeaders = [matchContentTypeJson] }
 
     it "rename simple column" $
       get "/complex_items?id=eq.1&select=myId:id" `shouldRespondWith`
         [json| [{"myId":1}] |]
+        { matchHeaders = [matchContentTypeJson] }
 
 
     it "one simple column with casting (text)" $
       get "/complex_items?select=id::text" `shouldRespondWith`
         [json| [{"id":"1"},{"id":"2"},{"id":"3"}] |]
+        { matchHeaders = [matchContentTypeJson] }
 
     it "rename simple column with casting" $
       get "/complex_items?id=eq.1&select=myId:id::text" `shouldRespondWith`
         [json| [{"myId":"1"}] |]
+        { matchHeaders = [matchContentTypeJson] }
 
     it "json column" $
       get "/complex_items?id=eq.1&select=settings" `shouldRespondWith`
         [json| [{"settings":{"foo":{"int":1,"bar":"baz"}}}] |]
+        { matchHeaders = [matchContentTypeJson] }
 
     it "json subfield one level with casting (json)" $
       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"
+        { matchHeaders = [matchContentTypeJson] }
 
     it "rename json subfield one level with casting (json)" $
       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"
+        { matchHeaders = [matchContentTypeJson] }
 
     it "fails on bad casting (data of the wrong format)" $
       get "/complex_items?select=settings->foo->>bar::integer"
-        `shouldRespondWith` ResponseMatcher {
-          matchBody    = Just [json| {"hint":null,"details":null,"code":"22P02","message":"invalid input syntax for integer: \"baz\""} |]
-        , matchStatus  = 400
+        `shouldRespondWith` [json| {"hint":null,"details":null,"code":"22P02","message":"invalid input syntax for integer: \"baz\""} |]
+        { matchStatus  = 400
         , matchHeaders = []
         }
 
     it "fails on bad casting (wrong cast type)" $
       get "/complex_items?select=id::fakecolumntype"
-        `shouldRespondWith` ResponseMatcher {
-          matchBody    = Just [json| {"hint":null,"details":null,"code":"42704","message":"type \"fakecolumntype\" does not exist"} |]
-        , matchStatus  = 400
+        `shouldRespondWith` [json| {"hint":null,"details":null,"code":"42704","message":"type \"fakecolumntype\" does not exist"} |]
+        { matchStatus  = 400
         , matchHeaders = []
         }
 
@@ -212,19 +202,23 @@
     it "json subfield two levels (string)" $
       get "/complex_items?id=eq.1&select=settings->foo->>bar" `shouldRespondWith`
         [json| [{"bar":"baz"}] |]
+        { matchHeaders = [matchContentTypeJson] }
 
     it "rename json subfield two levels (string)" $
       get "/complex_items?id=eq.1&select=myBar:settings->foo->>bar" `shouldRespondWith`
         [json| [{"myBar":"baz"}] |]
+        { matchHeaders = [matchContentTypeJson] }
 
 
     it "json subfield two levels with casting (int)" $
       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
+        { matchHeaders = [matchContentTypeJson] }
 
     it "rename json subfield two levels with casting (int)" $
       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
+        { matchHeaders = [matchContentTypeJson] }
 
     it "requesting parents and children" $
       get "/projects?id=eq.1&select=id, name, clients{*}, tasks{id, name}" `shouldRespondWith`
@@ -302,70 +296,70 @@
       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"}}]|]
 
+    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
     it "by a column asc" $
       get "/items?id=lte.2&order=id.asc"
-        `shouldRespondWith` ResponseMatcher {
-          matchBody    = Just [json| [{"id":1},{"id":2}] |]
-        , matchStatus  = 200
+        `shouldRespondWith` [json| [{"id":1},{"id":2}] |]
+        { matchStatus  = 200
         , matchHeaders = ["Content-Range" <:> "0-1/*"]
         }
     it "by a column desc" $
       get "/items?id=lte.2&order=id.desc"
-        `shouldRespondWith` ResponseMatcher {
-          matchBody    = Just [json| [{"id":2},{"id":1}] |]
-        , matchStatus  = 200
+        `shouldRespondWith` [json| [{"id":2},{"id":1}] |]
+        { matchStatus  = 200
         , matchHeaders = ["Content-Range" <:> "0-1/*"]
         }
 
     it "by a column with nulls first" $
       get "/no_pk?order=a.nullsfirst"
-        `shouldRespondWith` ResponseMatcher {
-          matchBody = Just [json| [{"a":null,"b":null},
+        `shouldRespondWith` [json| [{"a":null,"b":null},
                               {"a":"1","b":"0"},
                               {"a":"2","b":"0"}
                               ] |]
-        , matchStatus = 200
+        { matchStatus = 200
         , matchHeaders = ["Content-Range" <:> "0-2/*"]
         }
 
     it "by a column asc with nulls last" $
       get "/no_pk?order=a.asc.nullslast"
-        `shouldRespondWith` ResponseMatcher {
-          matchBody = Just [json| [{"a":"1","b":"0"},
+        `shouldRespondWith` [json| [{"a":"1","b":"0"},
                               {"a":"2","b":"0"},
                               {"a":null,"b":null}] |]
-        , matchStatus = 200
+        { matchStatus = 200
         , matchHeaders = ["Content-Range" <:> "0-2/*"]
         }
 
     it "by a column desc with nulls first" $
       get "/no_pk?order=a.desc.nullsfirst"
-        `shouldRespondWith` ResponseMatcher {
-          matchBody = Just [json| [{"a":null,"b":null},
+        `shouldRespondWith` [json| [{"a":null,"b":null},
                               {"a":"2","b":"0"},
                               {"a":"1","b":"0"}] |]
-        , matchStatus = 200
+        { matchStatus = 200
         , matchHeaders = ["Content-Range" <:> "0-2/*"]
         }
 
     it "by a column desc with nulls last" $
       get "/no_pk?order=a.desc.nullslast"
-        `shouldRespondWith` ResponseMatcher {
-          matchBody = Just [json| [{"a":"2","b":"0"},
+        `shouldRespondWith` [json| [{"a":"2","b":"0"},
                               {"a":"1","b":"0"},
                               {"a":null,"b":null}] |]
-        , matchStatus = 200
+        { matchStatus = 200
         , matchHeaders = ["Content-Range" <:> "0-2/*"]
         }
 
     it "by a json column property asc" $
       get "/json?order=data->>id.asc" `shouldRespondWith`
         [json| [{"data": {"id": 0}}, {"data": {"id": 1, "foo": {"bar": "baz"}}}, {"data": {"id": 3}}] |]
+        { matchHeaders = [matchContentTypeJson] }
 
     it "by a json column with two level property nulls first" $
       get "/json?order=data->foo->>bar.nullsfirst" `shouldRespondWith`
         [json| [{"data": {"id": 3}}, {"data": {"id": 0}}, {"data": {"id": 1, "foo": {"bar": "baz"}}}] |]
+        { matchHeaders = [matchContentTypeJson] }
 
     it "without other constraints" $
       get "/items?order=id.asc" `shouldRespondWith` 200
@@ -432,18 +426,16 @@
     it "should respond with CSV to 'text/csv' request" $
       request methodGet "/simple_pk"
               (acceptHdrs "text/csv; version=1") ""
-        `shouldRespondWith` ResponseMatcher {
-          matchBody    = Just "k,extra\nxyyx,u\nxYYx,v"
-        , matchStatus  = 200
+        `shouldRespondWith` "k,extra\nxyyx,u\nxYYx,v"
+        { matchStatus  = 200
         , matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8"]
         }
 
   describe "Canonical location" $ do
     it "Sets Content-Location with alphabetized params" $
       get "/no_pk?b=eq.1&a=eq.1"
-        `shouldRespondWith` ResponseMatcher {
-          matchBody    = Just "[]"
-        , matchStatus  = 200
+        `shouldRespondWith` "[]"
+        { matchStatus  = 200
         , matchHeaders = ["Content-Location" <:> "/no_pk?a=eq.1&b=eq.1"]
         }
 
@@ -458,23 +450,26 @@
     it "can filter by properties inside json column" $ do
       get "/json?data->foo->>bar=eq.baz" `shouldRespondWith`
         [json| [{"data": {"id": 1, "foo": {"bar": "baz"}}}] |]
+        { matchHeaders = [matchContentTypeJson] }
       get "/json?data->foo->>bar=eq.fake" `shouldRespondWith`
         [json| [] |]
+        { matchHeaders = [matchContentTypeJson] }
     it "can filter by properties inside json column using not" $
       get "/json?data->foo->>bar=not.eq.baz" `shouldRespondWith`
         [json| [] |]
+        { matchHeaders = [matchContentTypeJson] }
     it "can filter by properties inside json column using ->>" $
       get "/json?data->>id=eq.1" `shouldRespondWith`
         [json| [{"data": {"id": 1, "foo": {"bar": "baz"}}}] |]
+        { matchHeaders = [matchContentTypeJson] }
 
   describe "remote procedure call" $ do
     context "a proc that returns a set" $ do
       it "returns paginated results" $
         request methodPost "/rpc/getitemrange"
                 (rangeHdrs (ByteRangeFromTo 0 0))  [json| { "min": 2, "max": 4 } |]
-           `shouldRespondWith` ResponseMatcher {
-              matchBody    = Just [json| [{"id":3}] |]
-            , matchStatus = 200
+           `shouldRespondWith` [json| [{"id":3}] |]
+            { matchStatus = 200
             , matchHeaders = ["Content-Range" <:> "0-0/*"]
             }
 
@@ -482,16 +477,15 @@
         request methodPost "/rpc/getitemrange"
                 (rangeHdrsWithCount (ByteRangeFromTo 0 0))
                 [json| { "min": 2, "max": 4 } |]
-           `shouldRespondWith` ResponseMatcher {
-              matchBody    = Just [json| [{"id":3}] |]
-            , matchStatus = 206 -- it now knows the response is partial
+           `shouldRespondWith` [json| [{"id":3}] |]
+            { matchStatus = 206 -- it now knows the response is partial
             , matchHeaders = ["Content-Range" <:> "0-0/2"]
             }
 
-
       it "returns proper json" $
         post "/rpc/getitemrange" [json| { "min": 2, "max": 4 } |] `shouldRespondWith`
           [json| [ {"id": 3}, {"id":4} ] |]
+          { matchHeaders = [matchContentTypeJson] }
 
     context "unknown function" $
       it "returns 404" $
@@ -505,12 +499,12 @@
       it "can filter proc results" $
         post "/rpc/getallprojects?id=gt.1&id=lt.5&select=id" [json| {} |] `shouldRespondWith`
           [json|[{"id":2},{"id":3},{"id":4}]|]
+          { matchHeaders = [matchContentTypeJson] }
 
       it "can limit proc results" $
         post "/rpc/getallprojects?id=gt.1&id=lt.5&select=id?limit=2&offset=1" [json| {} |]
-          `shouldRespondWith` ResponseMatcher {
-               matchBody    = Just [json|[{"id":3},{"id":4}]|]
-             , matchStatus = 200
+          `shouldRespondWith` [json|[{"id":3},{"id":4}]|]
+             { matchStatus = 200
              , matchHeaders = ["Content-Range" <:> "1-2/*"]
              }
 
@@ -518,24 +512,76 @@
         post "/rpc/getproject?select=id,name" [json| { "id": 1} |] `shouldRespondWith`
           [str|[{"id":1,"name":"Windows 7"}]|]
 
-      it "can embed foreign entities to the items returned by a proc" $
+    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`
           [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" $
       it "returns empty json array" $
         post "/rpc/test_empty_rowset" [json| {} |] `shouldRespondWith`
           [json| [] |]
+          { matchHeaders = [matchContentTypeJson] }
 
-    context "a proc that returns plain text" $ do
-      it "returns proper json" $
-        post "/rpc/sayhello" [json| { "name": "world" } |] `shouldRespondWith`
-          [json|"Hello, world"|]
+    context "proc return types" $ do
+      context "returns text" $ do
+        it "returns proper json" $
+          post "/rpc/sayhello" [json| { "name": "world" } |] `shouldRespondWith`
+            [json|"Hello, world"|]
+            { matchHeaders = [matchContentTypeJson] }
 
-      it "can handle unicode" $
-        post "/rpc/sayhello" [json| { "name": "￥" } |] `shouldRespondWith`
-          [json|"Hello, ￥"|]
+        it "can handle unicode" $
+          post "/rpc/sayhello" [json| { "name": "￥" } |] `shouldRespondWith`
+            [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
       it "rejects unknown content type even if payload is good" $
         request methodPost "/rpc/sayhello"
@@ -573,14 +619,17 @@
     it "executes the proc exactly once per request" $ do
       post "/rpc/callcounter" [json| {} |] `shouldRespondWith`
         [json|1|]
+        { matchHeaders = [matchContentTypeJson] }
       post "/rpc/callcounter" [json| {} |] `shouldRespondWith`
         [json|2|]
+        { matchHeaders = [matchContentTypeJson] }
 
     context "expects a single json object" $ do
       it "does not expand posted json into parameters" $
         request methodPost "/rpc/singlejsonparam"
           [("Prefer","params=single-object")] [json| { "p1": 1, "p2": "text", "p3" : {"obj":"text"} } |] `shouldRespondWith`
           [json| { "p1": 1, "p2": "text", "p3" : {"obj":"text"} } |]
+          { matchHeaders = [matchContentTypeJson] }
 
       it "accepts parameters from an html form" $
         request methodPost "/rpc/singlejsonparam"
@@ -589,18 +638,169 @@
            "boolean=false&date=1900-01-01&money=$3.99&enum=foo") `shouldRespondWith`
           [json| { "integer": "7", "double": "2.71828", "varchar" : "forms are fun"
                  , "boolean":"false", "date":"1900-01-01", "money":"$3.99", "enum":"foo" } |]
+                 { 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
     it "can query as normal" $ do
       get "/Escap3e;" `shouldRespondWith`
         [json| [{"so6meIdColumn":1},{"so6meIdColumn":2},{"so6meIdColumn":3},{"so6meIdColumn":4},{"so6meIdColumn":5}] |]
+        { matchHeaders = [matchContentTypeJson] }
       get "/ghostBusters" `shouldRespondWith`
         [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" $
       get "/Escap3e;?select=ghostBusters{*}" `shouldRespondWith`
         [json| [{"ghostBusters":[{"escapeId":1}]},{"ghostBusters":[]},{"ghostBusters":[{"escapeId":3}]},{"ghostBusters":[]},{"ghostBusters":[{"escapeId":5}]}] |]
+        { matchHeaders = [matchContentTypeJson] }
 
     it "will embed using a column" $
       get "/ghostBusters?select=escapeId{*}" `shouldRespondWith`
         [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 empty set" $ do
+    context "returns an empty result set 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 an empty result ignoring spaces" $
+      get "/items_with_different_col_types?int_data=in.    " `shouldRespondWith` 400
+
+    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
diff --git a/test/Feature/RangeSpec.hs b/test/Feature/RangeSpec.hs
--- a/test/Feature/RangeSpec.hs
+++ b/test/Feature/RangeSpec.hs
@@ -31,19 +31,12 @@
       context "when I don't want the count" $ do
         it "returns range Content-Range with */* for empty range" $
           request methodPost "/rpc/getitemrange" [] emptyRange
-            `shouldRespondWith` ResponseMatcher {
-              matchBody    = Just [json| [] |]
-            , matchStatus  = 200
-            , matchHeaders = ["Content-Range" <:> "*/*"]
-            }
+            `shouldRespondWith` [json| [] |] {matchHeaders = ["Content-Range" <:> "*/*"]}
 
         it "returns range Content-Range with range/*" $
           request methodPost "/rpc/getitemrange" [] defaultRange
-            `shouldRespondWith` ResponseMatcher {
-              matchBody    = Just [json| [{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}] |]
-            , matchStatus  = 200
-            , matchHeaders = ["Content-Range" <:> "0-14/*"]
-            }
+            `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}] |]
+            { matchHeaders = ["Content-Range" <:> "0-14/*"] }
 
     context "with range headers" $ do
 
@@ -64,9 +57,8 @@
         it "returns an empty body when there are no results" $
           request methodPost "/rpc/getitemrange"
                   (rangeHdrs $ ByteRangeFromTo 0 1) emptyRange
-            `shouldRespondWith` ResponseMatcher {
-              matchBody    = Just "[]"
-            , matchStatus  = 200
+            `shouldRespondWith` "[]"
+            { matchStatus  = 200
             , matchHeaders = ["Content-Range" <:> "*/*"]
             }
 
@@ -95,18 +87,16 @@
         it "refuses a range with nonzero start when there are no items" $
           request methodPost "/rpc/getitemrange"
                   (rangeHdrsWithCount $ ByteRangeFromTo 1 2) emptyRange
-            `shouldRespondWith` ResponseMatcher {
-              matchBody    = Nothing
-            , matchStatus  = 416
+            `shouldRespondWith` "[]"
+            { matchStatus  = 416
             , matchHeaders = ["Content-Range" <:> "*/0"]
             }
 
         it "refuses a range requesting start past last item" $
           request methodPost "/rpc/getitemrange"
                   (rangeHdrsWithCount $ ByteRangeFromTo 100 199) defaultRange
-            `shouldRespondWith` ResponseMatcher {
-              matchBody    = Nothing
-            , matchStatus  = 416
+            `shouldRespondWith` "[]"
+            { matchStatus  = 416
             , matchHeaders = ["Content-Range" <:> "*/15"]
             }
 
@@ -120,68 +110,58 @@
         it "returns range Content-Range with /*" $
           request methodGet "/menagerie"
                   [("Prefer", "count=none")] ""
-            `shouldRespondWith` ResponseMatcher {
-              matchBody    = Just "[]"
-            , matchStatus  = 200
+            `shouldRespondWith` "[]"
+            { matchStatus  = 200
             , matchHeaders = ["Content-Range" <:> "*/*"]
             }
 
         it "returns range Content-Range with range/*" $
           request methodGet "/items?order=id"
                   [("Prefer", "count=none")] ""
-            `shouldRespondWith` ResponseMatcher {
-              matchBody    = Just [json| [{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}] |]
-            , matchStatus  = 200
-            , matchHeaders = ["Content-Range" <:> "0-14/*"]
-            }
+            `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}] |]
+            { matchHeaders = ["Content-Range" <:> "0-14/*"] }
 
         it "returns range Content-Range with range/* even using other filters" $
           request methodGet "/items?id=eq.1&order=id"
                   [("Prefer", "count=none")] ""
-            `shouldRespondWith` ResponseMatcher {
-              matchBody    = Just [json| [{"id":1}] |]
-            , matchStatus  = 200
-            , matchHeaders = ["Content-Range" <:> "0-0/*"]
-            }
+            `shouldRespondWith` [json| [{"id":1}] |]
+            { matchHeaders = ["Content-Range" <:> "0-0/*"] }
 
     context "with limit/offset parameters" $ do
       it "no parameters return everything" $
         get "/items?select=id&order=id.asc"
-          `shouldRespondWith` ResponseMatcher {
-            matchBody    = Just [str|[{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}]|]
-          , matchStatus  = 200
+          `shouldRespondWith`
+          [str|[{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}]|]
+          { matchStatus  = 200
           , matchHeaders = ["Content-Range" <:> "0-14/*"]
           }
       it "top level limit with parameter" $
         get "/items?select=id&order=id.asc&limit=3"
-          `shouldRespondWith` ResponseMatcher {
-            matchBody    = Just [str|[{"id":1},{"id":2},{"id":3}]|]
-          , matchStatus  = 200
+          `shouldRespondWith` [str|[{"id":1},{"id":2},{"id":3}]|]
+          { matchStatus  = 200
           , matchHeaders = ["Content-Range" <:> "0-2/*"]
           }
       it "headers override get parameters" $
         request methodGet  "/items?select=id&order=id.asc&limit=3"
                      (rangeHdrs $ ByteRangeFromTo 0 1) ""
-          `shouldRespondWith` ResponseMatcher {
-            matchBody    = Just [str|[{"id":1},{"id":2}]|]
-          , matchStatus  = 200
+          `shouldRespondWith` [str|[{"id":1},{"id":2}]|]
+          { matchStatus  = 200
           , matchHeaders = ["Content-Range" <:> "0-1/*"]
           }
 
       it "limit works on all levels" $
         get "/clients?select=id,projects{id,tasks{id}}&order=id.asc&limit=1&projects.order=id.asc&projects.limit=2&projects.tasks.order=id.asc&projects.tasks.limit=1"
-          `shouldRespondWith` ResponseMatcher {
-            matchBody    = Just [str|[{"id":1,"projects":[{"id":1,"tasks":[{"id":1}]},{"id":2,"tasks":[{"id":3}]}]}]|]
-          , matchStatus  = 200
+          `shouldRespondWith`
+          [str|[{"id":1,"projects":[{"id":1,"tasks":[{"id":1}]},{"id":2,"tasks":[{"id":3}]}]}]|]
+          { matchStatus  = 200
           , matchHeaders = ["Content-Range" <:> "0-0/*"]
           }
 
 
       it "limit and offset works on first level" $
         get "/items?select=id&order=id.asc&limit=3&offset=2"
-          `shouldRespondWith` ResponseMatcher {
-            matchBody    = Just [str|[{"id":3},{"id":4},{"id":5}]|]
-          , matchStatus  = 200
+          `shouldRespondWith` [str|[{"id":3},{"id":4},{"id":5}]|]
+          { matchStatus  = 200
           , matchHeaders = ["Content-Range" <:> "2-4/*"]
           }
 
@@ -204,9 +184,8 @@
         it "returns an empty body when there are no results" $
           request methodGet "/menagerie"
                   (rangeHdrs $ ByteRangeFromTo 0 1) ""
-            `shouldRespondWith` ResponseMatcher {
-              matchBody    = Just "[]"
-            , matchStatus  = 200
+            `shouldRespondWith` "[]"
+            { matchStatus  = 200
             , matchHeaders = ["Content-Range" <:> "*/*"]
             }
 
@@ -235,17 +214,15 @@
         it "refuses a range with nonzero start when there are no items" $
           request methodGet "/menagerie"
                   (rangeHdrsWithCount $ ByteRangeFromTo 1 2) ""
-            `shouldRespondWith` ResponseMatcher {
-              matchBody    = Nothing
-            , matchStatus  = 416
+            `shouldRespondWith` "[]"
+            { matchStatus  = 416
             , matchHeaders = ["Content-Range" <:> "*/0"]
             }
 
         it "refuses a range requesting start past last item" $
           request methodGet "/items"
                   (rangeHdrsWithCount $ ByteRangeFromTo 100 199) ""
-            `shouldRespondWith` ResponseMatcher {
-              matchBody    = Nothing
-            , matchStatus  = 416
+            `shouldRespondWith` "[]"
+            { matchStatus  = 416
             , matchHeaders = ["Content-Range" <:> "*/15"]
             }
diff --git a/test/Feature/SingularSpec.hs b/test/Feature/SingularSpec.hs
--- a/test/Feature/SingularSpec.hs
+++ b/test/Feature/SingularSpec.hs
@@ -86,16 +86,14 @@
         request methodPost "/addresses"
           [("Prefer", "return=minimal"), singular]
           [json| [ { id: 101, address: "xxx" } ] |]
-          `shouldRespondWith` ResponseMatcher {
-            matchBody    = Just ""
-          , matchStatus  = 201
+          `shouldRespondWith` ""
+          { matchStatus  = 201
           , matchHeaders = ["Content-Range" <:> "*/*"]
           }
         -- and the element should exist
         get "/addresses?id=eq.101"
-          `shouldRespondWith` ResponseMatcher {
-            matchBody    = Just [str|[{"id":101,"address":"xxx"}]|]
-          , matchStatus  = 200
+          `shouldRespondWith` [str|[{"id":101,"address":"xxx"}]|]
+          { matchStatus  = 200
           , matchHeaders = []
           }
 
@@ -113,9 +111,8 @@
         request methodPost "/addresses"
           [("Prefer", "return=minimal"), singular]
           [json| [ { id: 200, address: "xxx" }, { id: 201, address: "yyy" } ] |]
-          `shouldRespondWith` ResponseMatcher {
-            matchBody    = Just ""
-          , matchStatus  = 201
+          `shouldRespondWith` ""
+          { matchStatus  = 201
           , matchHeaders = ["Content-Range" <:> "*/*"]
           }
 
@@ -142,11 +139,9 @@
           [("Prefer", "return=representation"), singular] ""
           `shouldRespondWith` 406
 
-        -- the rows should not exist, either
         get firstItems
-          `shouldRespondWith` ResponseMatcher {
-            matchBody    = Nothing
-          , matchStatus  = 200
+          `shouldRespondWith` [json| [{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10}] |]
+          { matchStatus  = 200
           , matchHeaders = ["Content-Range" <:> "0-9/*"]
           }
 
diff --git a/test/Feature/UnicodeSpec.hs b/test/Feature/UnicodeSpec.hs
--- a/test/Feature/UnicodeSpec.hs
+++ b/test/Feature/UnicodeSpec.hs
@@ -6,6 +6,8 @@
 import Network.Wai (Application)
 import Control.Monad (void)
 
+import SpecHelper
+
 import Protolude hiding (get)
 
 spec :: SpecWith Application
@@ -20,3 +22,4 @@
 
       get "/%D9%85%D9%88%D8%A7%D8%B1%D8%AF"
         `shouldRespondWith` [json| [{ "هویت": 1 }] |]
+        { matchHeaders = [matchContentTypeJson] }
diff --git a/test/SpecHelper.hs b/test/SpecHelper.hs
--- a/test/SpecHelper.hs
+++ b/test/SpecHelper.hs
@@ -25,9 +25,12 @@
 
 import Data.Maybe (fromJust)
 import Data.Aeson (decode, Value(..))
-import qualified Data.JsonSchema.Draft4 as D4
+import qualified JSONSchema.Draft4 as D4
 
 import Protolude
+
+matchContentTypeJson :: MatchHeader
+matchContentTypeJson = "Content-Type" <:> "application/json; charset=utf-8"
 
 validateOpenApiResponse :: [Header] -> WaiSession ()
 validateOpenApiResponse headers = do
