Per-route content negotiation, and OpenAPI only for root (#693)

* WIP: remove non-openapi root spec

* Refactor ContentType

Different endpoints will favor one type over another

* Permit different Accept headers per endpoint

* Lint

* Add charset to Content-Type only when used as a header

Keep it out of error messages

* Accept: */* is last resort, not first

* Changelog

* makeMimeList consistently

* Remove schema description from OPTIONS response
This commit is contained in:
Joe Nelson
2016-08-19 16:07:22 -07:00
committed by GitHub
parent 35c5b190b4
commit 1d5a0e4316
9 changed files with 126 additions and 493 deletions
+2
View File
@@ -15,9 +15,11 @@ This project adheres to [Semantic Versioning](http://semver.org/).
### Fixed ### Fixed
- Do not apply limit to parent items - @ruslantalpa - Do not apply limit to parent items - @ruslantalpa
- Customize content negotiation per route - @begriffs
### Changed ### Changed
- Use HTTP 400 for raise\_exception - @begriffs - Use HTTP 400 for raise\_exception - @begriffs
- Remove non-OpenAPI schema description - @begriffs
## [0.3.2.0] - 2016-06-10 ## [0.3.2.0] - 2016-06-10
+49 -46
View File
@@ -4,9 +4,10 @@ import Prelude
import qualified Data.Aeson as JSON import qualified Data.Aeson as JSON
import qualified Data.ByteString as BS import qualified Data.ByteString as BS
import qualified Data.ByteString.Internal as BS (c2w)
import qualified Data.ByteString.Lazy as BL import qualified Data.ByteString.Lazy as BL
import qualified Data.Csv as CSV import qualified Data.Csv as CSV
import Data.List (find, sortBy) import qualified Data.List as L
import qualified Data.HashMap.Strict as M import qualified Data.HashMap.Strict as M
import qualified Data.Set as S import qualified Data.Set as S
import Data.Maybe (fromMaybe, isJust, isNothing, import Data.Maybe (fromMaybe, isJust, isNothing,
@@ -20,7 +21,7 @@ import qualified Data.Text as T
import Text.Read (readMaybe) import Text.Read (readMaybe)
import qualified Data.Vector as V import qualified Data.Vector as V
import Network.HTTP.Base (urlEncodeVars) import Network.HTTP.Base (urlEncodeVars)
import Network.HTTP.Types.Header (hAuthorization) import Network.HTTP.Types.Header (hAuthorization, hContentType, Header)
import Network.HTTP.Types.URI (parseSimpleQuery) import Network.HTTP.Types.URI (parseSimpleQuery)
import Network.Wai (Request (..)) import Network.Wai (Request (..))
import Network.Wai.Parse (parseHttpAccept) import Network.Wai.Parse (parseHttpAccept)
@@ -45,13 +46,18 @@ data Target = TargetIdent QualifiedIdentifier
| TargetUnknown [T.Text] | TargetUnknown [T.Text]
-- | How to return the inserted data -- | How to return the inserted data
data PreferRepresentation = Full | HeadersOnly | None deriving Eq data PreferRepresentation = Full | HeadersOnly | None deriving Eq
-- | Enumeration of currently supported content types for -- | Enumeration of currently supported response content types
-- route responses and upload payloads data ContentType = CTApplicationJSON | CTTextCSV | CTOpenAPI
data ContentType = ApplicationJSON | TextCSV | OpenAPI deriving Eq | CTAny | CTOther BS.ByteString deriving Eq
instance Show ContentType where instance Show ContentType where
show ApplicationJSON = "application/json; charset=utf-8" show CTApplicationJSON = "application/json"
show TextCSV = "text/csv; charset=utf-8" show CTTextCSV = "text/csv"
show OpenAPI = "application/openapi+json; charset=utf-8" show CTOpenAPI = "application/openapi+json"
show CTAny = "*/*"
show (CTOther ct) = cs ct
ctToHeader :: ContentType -> Header
ctToHeader ct = (hContentType, cs (show ct) <> "; charset=utf-8")
{-| {-|
Describes what the user wants to do. This data type is a Describes what the user wants to do. This data type is a
@@ -67,8 +73,8 @@ data ApiRequest = ApiRequest {
, iRange :: M.HashMap String NonnegRange , iRange :: M.HashMap String NonnegRange
-- | The target, be it calling a proc or accessing a table -- | The target, be it calling a proc or accessing a table
, iTarget :: Target , iTarget :: Target
-- | The content type the client most desires (or JSON if undecided) -- | Content types the client will accept, [CTAny] if no Accept header
, iAccepts :: Either BS.ByteString ContentType , iAccepts :: [ContentType]
-- | Data sent by client and used for mutation actions -- | Data sent by client and used for mutation actions
, iPayload :: Maybe Payload , iPayload :: Maybe Payload
-- | If client wants created items echoed back -- | If client wants created items echoed back
@@ -113,31 +119,27 @@ userApiRequest schema req reqBody =
["rpc", proc] -> TargetProc ["rpc", proc] -> TargetProc
$ QualifiedIdentifier schema proc $ QualifiedIdentifier schema proc
other -> TargetUnknown other other -> TargetUnknown other
payload = case pickContentType (lookupHeader "content-type") of payload = case decodeContentType
Right ApplicationJSON -> . fromMaybe "application/json"
$ lookupHeader "content-type" of
CTApplicationJSON ->
either (PayloadParseError . cs) either (PayloadParseError . cs)
(\val -> case ensureUniform (pluralize val) of (\val -> case ensureUniform (pluralize val) of
Nothing -> PayloadParseError "All object keys must match" Nothing -> PayloadParseError "All object keys must match"
Just json -> PayloadJSON json) Just json -> PayloadJSON json)
(JSON.eitherDecode reqBody) (JSON.eitherDecode reqBody)
Right TextCSV -> CTTextCSV ->
either (PayloadParseError . cs) either (PayloadParseError . cs)
(\val -> case ensureUniform (csvToJson val) of (\val -> case ensureUniform (csvToJson val) of
Nothing -> PayloadParseError "All lines must have same number of fields" Nothing -> PayloadParseError "All lines must have same number of fields"
Just json -> PayloadJSON json) Just json -> PayloadJSON json)
(CSV.decodeByName reqBody) (CSV.decodeByName reqBody)
Right oa@OpenAPI -> CTOther "application/x-www-form-urlencoded" ->
PayloadParseError $ "Content-type not acceptable: " <> cs (show oa)
-- This is a Left value because form-urlencoded is not a content
-- type which we ever use for responses, only something we handle
-- just this once for requests
Left "application/x-www-form-urlencoded" ->
PayloadJSON . UniformObjects . V.singleton . M.fromList PayloadJSON . UniformObjects . V.singleton . M.fromList
. map (cs *** JSON.String . cs) . parseSimpleQuery . map (cs *** JSON.String . cs) . parseSimpleQuery
$ cs reqBody $ cs reqBody
Left accept -> ct ->
PayloadParseError $ PayloadParseError $ "Content-Type not acceptable: " <> cs (show ct)
"Content-type not acceptable: " <> accept
relevantPayload = case action of relevantPayload = case action of
ActionCreate -> Just payload ActionCreate -> Just payload
ActionUpdate -> Just payload ActionUpdate -> Just payload
@@ -149,7 +151,8 @@ userApiRequest schema req reqBody =
, iTarget = target , iTarget = target
, iRange = M.insert "limit" (rangeIntersection headerRange urlRange) $ , iRange = M.insert "limit" (rangeIntersection headerRange urlRange) $
M.fromList [ (cs k, restrictRange (readMaybe =<< v) allRange) | (k,v) <- qParams, isJust v, endingIn ["limit"] k ] M.fromList [ (cs k, restrictRange (readMaybe =<< v) allRange) | (k,v) <- qParams, isJust v, endingIn ["limit"] k ]
, iAccepts = pickContentType $ lookupHeader "accept" , iAccepts = fromMaybe [CTAny] $
map decodeContentType . parseHttpAccept <$> lookupHeader "accept"
, iPayload = relevantPayload , iPayload = relevantPayload
, iPreferRepresentation = representation , iPreferRepresentation = representation
, iPreferSingular = singular , iPreferSingular = singular
@@ -158,7 +161,7 @@ userApiRequest schema req reqBody =
, iSelect = fromMaybe "*" $ fromMaybe (Just "*") $ lookup "select" qParams , iSelect = fromMaybe "*" $ fromMaybe (Just "*") $ lookup "select" qParams
, iOrder = [(cs k, fromJust v) | (k,v) <- qParams, isJust v, endingIn ["order"] k ] , iOrder = [(cs k, fromJust v) | (k,v) <- qParams, isJust v, endingIn ["order"] k ]
, iCanonicalQS = urlEncodeVars , iCanonicalQS = urlEncodeVars
. sortBy (comparing fst) . L.sortBy (comparing fst)
. map (join (***) cs) . map (join (***) cs)
. parseSimpleQuery . parseSimpleQuery
$ rawQueryString req $ rawQueryString req
@@ -197,32 +200,32 @@ userApiRequest schema req reqBody =
(readMaybe =<< join (lookup "limit" qParams)) (readMaybe =<< join (lookup "limit" qParams))
urlOffsetRange urlOffsetRange
{-|
Find the best match from a list of content types accepted by the
client in order of decreasing preference and a list of types
producible by the server. If there is no match but the client
accepts */* then return the top server pick.
-}
mutuallyAgreeable :: [ContentType] -> [ContentType] -> Maybe ContentType
mutuallyAgreeable sProduces cAccepts =
let exact = listToMaybe $ L.intersect cAccepts sProduces in
if isNothing exact && CTAny `elem` cAccepts
then listToMaybe sProduces
else exact
-- PRIVATE --------------------------------------------------------------- -- PRIVATE ---------------------------------------------------------------
{-| {-|
Picks a preferred content type from an Accept header (or from Warning: discards MIME parameters
Content-Type as a degenerate case).
For example
text/csv -> TextCSV
*/* -> ApplicationJSON
text/csv, application/json -> TextCSV
application/json, text/csv -> ApplicationJSON
-} -}
pickContentType :: Maybe BS.ByteString -> Either BS.ByteString ContentType decodeContentType :: BS.ByteString -> ContentType
pickContentType accept decodeContentType ct =
| isNothing accept || has ctAll || has ctJson = Right ApplicationJSON case BS.takeWhile (/= BS.c2w ';') ct of
| has ctCsv = Right TextCSV "application/json" -> CTApplicationJSON
| has ctOpenAPI = Right OpenAPI "text/csv" -> CTTextCSV
| otherwise = Left accept' "application/openapi+json" -> CTOpenAPI
where "*/*" -> CTAny
ctAll = "*/*" ct' -> CTOther ct'
ctCsv = "text/csv"
ctJson = "application/json"
ctOpenAPI = "application/openapi+json"
Just accept' = accept
findInAccept = flip find $ parseHttpAccept accept'
has = isJust . findInAccept . BS.isPrefixOf
type CsvData = V.Vector (M.HashMap T.Text BL.ByteString) type CsvData = V.Vector (M.HashMap T.Text BL.ByteString)
+38 -47
View File
@@ -12,7 +12,7 @@ import Data.IORef (IORef, readIORef)
import Data.List (delete, lookup) import Data.List (delete, lookup)
import Data.Maybe (fromJust) import Data.Maybe (fromJust)
import Data.Ranged.Ranges (emptyRange) import Data.Ranged.Ranges (emptyRange)
import Data.Text (replace, strip, pack, isInfixOf, dropWhile, drop) import Data.Text (replace, strip, pack, isInfixOf, dropWhile, drop, intercalate)
import Data.Tree import Data.Tree
import qualified Hasql.Pool as P import qualified Hasql.Pool as P
@@ -41,7 +41,8 @@ import qualified Data.HashMap.Strict as M
import PostgREST.ApiRequest (ApiRequest(..), ContentType(..) import PostgREST.ApiRequest (ApiRequest(..), ContentType(..)
, Action(..), Target(..) , Action(..), Target(..)
, PreferRepresentation (..) , PreferRepresentation (..)
, userApiRequest) , userApiRequest, mutuallyAgreeable
, ctToHeader)
import PostgREST.Auth (tokenJWT, jwtClaims, containsRole) import PostgREST.Auth (tokenJWT, jwtClaims, containsRole)
import PostgREST.Config (AppConfig (..)) import PostgREST.Config (AppConfig (..))
import PostgREST.DbStructure import PostgREST.DbStructure
@@ -64,7 +65,7 @@ import PostgREST.OpenAPI
import Data.Foldable (foldr1) import Data.Foldable (foldr1)
import Data.Function (id) import Data.Function (id)
import Protolude hiding (dropWhile, drop, Proxy) import Protolude hiding (dropWhile, drop, intercalate, Proxy)
postgrest :: AppConfig -> IORef DbStructure -> P.Pool -> Application postgrest :: AppConfig -> IORef DbStructure -> P.Pool -> Application
postgrest conf refDbStructure pool = postgrest conf refDbStructure pool =
@@ -93,32 +94,28 @@ transactionMode _ = HT.Write
app :: DbStructure -> AppConfig -> ApiRequest -> H.Transaction Response app :: DbStructure -> AppConfig -> ApiRequest -> H.Transaction Response
app dbStructure conf apiRequest = app dbStructure conf apiRequest =
let
-- TODO: blow up for Left values (there is a middleware that checks the headers)
contentType = either (const ApplicationJSON) id (iAccepts apiRequest)
contentTypeH = (hContentType, show contentType) in
case (iAction apiRequest, iTarget apiRequest, iPayload apiRequest) of case (iAction apiRequest, iTarget apiRequest, iPayload apiRequest) of
(ActionRead, TargetIdent qi, Nothing) -> (ActionRead, TargetIdent qi, Nothing) ->
serves [CTApplicationJSON, CTTextCSV] (iAccepts apiRequest) $ \contentType ->
case readSqlParts of case readSqlParts of
Left e -> return $ responseLBS status400 [jsonH] $ toS e Left e -> return $ responseLBS status400 [jsonH] $ toS e
Right (q, cq) -> do Right (q, cq) -> do
let singular = iPreferSingular apiRequest let singular = iPreferSingular apiRequest
stm = createReadStatement q cq singular stm = createReadStatement q cq singular
shouldCount (contentType == TextCSV) shouldCount (contentType == CTTextCSV)
respondToRange $ do respondToRange $ do
row <- H.query () stm row <- H.query () stm
let (tableTotal, queryTotal, _ , body) = row let (tableTotal, queryTotal, _ , body) = row
if singular if singular
then return $ if queryTotal <= 0 then return $ if queryTotal <= 0
then responseLBS status404 [] "" then responseLBS status404 [] ""
else responseLBS status200 [contentTypeH] (toS body) else responseLBS status200 [ctToHeader contentType] (toS body)
else do else do
let (status, contentRange) = rangeHeader queryTotal tableTotal let (status, contentRange) = rangeHeader queryTotal tableTotal
canonical = iCanonicalQS apiRequest canonical = iCanonicalQS apiRequest
return $ responseLBS status return $ responseLBS status
[contentTypeH, contentRange, [ctToHeader contentType, contentRange,
("Content-Location", ("Content-Location",
"/" <> toS (qiName qi) <> "/" <> toS (qiName qi) <>
if Protolude.null canonical then "" else "?" <> toS canonical if Protolude.null canonical then "" else "?" <> toS canonical
@@ -127,6 +124,7 @@ app dbStructure conf apiRequest =
(ActionCreate, TargetIdent qi@(QualifiedIdentifier _ table), (ActionCreate, TargetIdent qi@(QualifiedIdentifier _ table),
Just payload@(PayloadJSON uniform@(UniformObjects rows))) -> Just payload@(PayloadJSON uniform@(UniformObjects rows))) ->
serves [CTApplicationJSON, CTTextCSV] (iAccepts apiRequest) $ \contentType ->
case mutateSqlParts of case mutateSqlParts of
Left e -> return $ responseLBS status400 [jsonH] $ toS e Left e -> return $ responseLBS status400 [jsonH] $ toS e
Right (sq,mq) -> do Right (sq,mq) -> do
@@ -139,7 +137,7 @@ app dbStructure conf apiRequest =
END $$; END $$;
|] |]
let pKeys = map pkName $ filter (filterPk schema table) allPrKeys -- would it be ok to move primary key detection in the query itself? let pKeys = map pkName $ filter (filterPk schema table) allPrKeys -- would it be ok to move primary key detection in the query itself?
let stm = createWriteStatement qi sq mq isSingle (iPreferRepresentation apiRequest) pKeys (contentType == TextCSV) payload let stm = createWriteStatement qi sq mq isSingle (iPreferRepresentation apiRequest) pKeys (contentType == CTTextCSV) payload
row <- H.query uniform stm row <- H.query uniform stm
let (_, _, fs, body) = extractQueryResult row let (_, _, fs, body) = extractQueryResult row
header = header =
@@ -147,16 +145,16 @@ app dbStructure conf apiRequest =
else [(hLocation, "/" <> toS table <> renderLocationFields fs)] else [(hLocation, "/" <> toS table <> renderLocationFields fs)]
return $ if iPreferRepresentation apiRequest == Full return $ if iPreferRepresentation apiRequest == Full
then responseLBS status201 (contentTypeH : header) (toS body) then responseLBS status201 (ctToHeader contentType : header) (toS body)
else responseLBS status201 header "" else responseLBS status201 header ""
(ActionUpdate, TargetIdent qi, Just payload@(PayloadJSON uniform)) -> (ActionUpdate, TargetIdent qi, Just payload@(PayloadJSON uniform)) ->
serves [CTApplicationJSON, CTTextCSV] (iAccepts apiRequest) $ \contentType ->
case mutateSqlParts of case mutateSqlParts of
Left e -> return $ responseLBS status400 [jsonH] $ toS e Left e -> return $ responseLBS status400 [jsonH] $ toS e
Right (sq,mq) -> do Right (sq,mq) -> do
let singular = iPreferSingular apiRequest let singular = iPreferSingular apiRequest
let representation = iPreferRepresentation apiRequest stm = createWriteStatement qi sq mq singular (iPreferRepresentation apiRequest) [] (contentType == CTTextCSV) payload
let stm = createWriteStatement qi sq mq singular representation [] (contentType == TextCSV) payload
row <- H.query uniform stm row <- H.query uniform stm
let (_, queryTotal, _, body) = extractQueryResult row let (_, queryTotal, _, body) = extractQueryResult row
when (singular && queryTotal > 1) $ when (singular && queryTotal > 1) $
@@ -171,23 +169,24 @@ app dbStructure conf apiRequest =
| iPreferRepresentation apiRequest == Full -> status200 | iPreferRepresentation apiRequest == Full -> status200
| otherwise -> status204 | otherwise -> status204
return $ if iPreferRepresentation apiRequest == Full return $ if iPreferRepresentation apiRequest == Full
then responseLBS s [contentTypeH, r] (toS body) then responseLBS s [ctToHeader contentType, r] (toS body)
else responseLBS s [r] "" else responseLBS s [r] ""
(ActionDelete, TargetIdent qi, Nothing) -> (ActionDelete, TargetIdent qi, Nothing) ->
serves [CTApplicationJSON, CTTextCSV] (iAccepts apiRequest) $ \contentType ->
case mutateSqlParts of case mutateSqlParts of
Left e -> return $ responseLBS status400 [jsonH] $ toS e Left e -> return $ responseLBS status400 [jsonH] $ toS e
Right (sq,mq) -> do Right (sq,mq) -> do
let emptyUniform = UniformObjects V.empty let emptyUniform = UniformObjects V.empty
fakeload = PayloadJSON emptyUniform fakeload = PayloadJSON emptyUniform
stm = createWriteStatement qi sq mq False (iPreferRepresentation apiRequest) [] (contentType == TextCSV) fakeload stm = createWriteStatement qi sq mq False (iPreferRepresentation apiRequest) [] (contentType == CTTextCSV) fakeload
row <- H.query emptyUniform stm row <- H.query emptyUniform stm
let (_, queryTotal, _, body) = extractQueryResult row let (_, queryTotal, _, body) = extractQueryResult row
r = contentRangeH 1 0 (toInteger <$> Just queryTotal) r = contentRangeH 1 0 (toInteger <$> Just queryTotal)
return $ if queryTotal == 0 return $ if queryTotal == 0
then notFound then notFound
else if iPreferRepresentation apiRequest == Full else if iPreferRepresentation apiRequest == Full
then responseLBS status200 [contentTypeH, r] (toS body) then responseLBS status200 [ctToHeader contentType, r] (toS body)
else responseLBS status204 [r] "" else responseLBS status204 [r] ""
(ActionInfo, TargetIdent (QualifiedIdentifier tSchema tTable), Nothing) -> (ActionInfo, TargetIdent (QualifiedIdentifier tSchema tTable), Nothing) ->
@@ -195,11 +194,8 @@ app dbStructure conf apiRequest =
case mTable of case mTable of
Nothing -> return notFound Nothing -> return notFound
Just table -> Just table ->
let cols = filter (filterCol tSchema tTable) $ dbColumns dbStructure let acceptH = (hAllow, if tableInsertable table then "GET,POST,PATCH,DELETE" else "GET") in
pkeys = map pkName $ filter (filterPk tSchema tTable) allPrKeys return $ responseLBS status200 [allOrigins, acceptH] ""
body = encode (TableOptions cols pkeys)
acceptH = (hAllow, if tableInsertable table then "GET,POST,PATCH,DELETE" else "GET") in
return $ responseLBS status200 [jsonH, allOrigins, acceptH] $ toS body
(ActionInvoke, TargetProc qi, (ActionInvoke, TargetProc qi,
Just (PayloadJSON (UniformObjects payload))) -> do Just (PayloadJSON (UniformObjects payload))) -> do
@@ -208,7 +204,7 @@ app dbStructure conf apiRequest =
jwtSecret = configJwtSecret conf jwtSecret = configJwtSecret conf
returnType = lookup (qiName qi) $ dbProcs dbStructure returnType = lookup (qiName qi) $ dbProcs dbStructure
returnsJWT = fromMaybe False $ isInfixOf "jwt_claims" <$> returnType returnsJWT = fromMaybe False $ isInfixOf "jwt_claims" <$> returnType
case readSqlParts of serves [CTApplicationJSON] (iAccepts apiRequest) $ \_ -> case readSqlParts of
Left e -> return $ responseLBS status400 [jsonH] $ toS e Left e -> return $ responseLBS status400 [jsonH] $ toS e
Right (q,cq) -> respondToRange $ do Right (q,cq) -> respondToRange $ do
row <- H.query () (callProc qi p q cq topLevelRange shouldCount singular) row <- H.query () (callProc qi p q cq topLevelRange shouldCount singular)
@@ -221,17 +217,16 @@ app dbStructure conf apiRequest =
else toS $ encode body) else toS $ encode body)
(ActionRead, TargetRoot, Nothing) -> do (ActionRead, TargetRoot, Nothing) -> do
let encodeApi ti = encodeOpenAPI ti uri' let host = configHost conf
host = configHost conf
port = toInteger $ configPort conf port = toInteger $ configPort conf
proxy = pickProxy $ toS <$> configProxyUri conf proxy = pickProxy $ toS <$> configProxyUri conf
uri Nothing = ("http", pack host, port, "/") uri Nothing = ("http", pack host, port, "/")
uri (Just Proxy { proxyScheme = s, proxyHost = h, proxyPort = p, proxyPath = b }) = (s, h, p, b) uri (Just Proxy { proxyScheme = s, proxyHost = h, proxyPort = p, proxyPath = b }) = (s, h, p, b)
uri' = uri proxy uri' = uri proxy
encodeFn = if contentType == OpenAPI then encodeApi . toTableInfo else encode encodeApi ti = encodeOpenAPI ti uri'
header = if contentType == OpenAPI then openapiH else jsonH serves [CTOpenAPI] (iAccepts apiRequest) $ \_ -> do
body <- encodeFn <$> H.query schema accessibleTables body <- encodeApi . toTableInfo <$> H.query schema accessibleTables
return $ responseLBS status200 [header] $ toS body return $ responseLBS status200 [openapiH] $ toS body
(ActionInappropriate, _, _) -> return $ responseLBS status405 [] "" (ActionInappropriate, _, _) -> return $ responseLBS status405 [] ""
@@ -259,6 +254,8 @@ app dbStructure conf apiRequest =
filterCol _ _ _ = False filterCol _ _ _ = False
allPrKeys = dbPrimaryKeys dbStructure allPrKeys = dbPrimaryKeys dbStructure
allOrigins = ("Access-Control-Allow-Origin", "*") :: Header allOrigins = ("Access-Control-Allow-Origin", "*") :: Header
jsonH = ctToHeader CTApplicationJSON
openapiH = ctToHeader CTOpenAPI
schema = toS $ configSchema conf schema = toS $ configSchema conf
shouldCount = iPreferCount apiRequest shouldCount = iPreferCount apiRequest
topLevelRange = fromMaybe allRange $ M.lookup "limit" $ iRange apiRequest topLevelRange = fromMaybe allRange $ M.lookup "limit" $ iRange apiRequest
@@ -278,6 +275,17 @@ app dbStructure conf apiRequest =
status = rangeStatus lower upper (toInteger <$> tableTotal) status = rangeStatus lower upper (toInteger <$> tableTotal)
in (status, contentRange) in (status, contentRange)
serves :: Monad m => [ContentType] -> [ContentType] ->
(ContentType -> m Response) -> m Response
serves sProduces cAccepts resp =
case mutuallyAgreeable sProduces cAccepts of
Nothing -> do
let failed = intercalate ", " $ map show cAccepts
return $ errResponse status415 $
"None of these Content-Types are available: " <> failed
Just ct -> resp ct
splitKeyValue :: BS.ByteString -> (BS.ByteString, BS.ByteString) splitKeyValue :: BS.ByteString -> (BS.ByteString, BS.ByteString)
splitKeyValue kv = (k, BS.tail v) splitKeyValue kv = (k, BS.tail v)
where (k, v) = BS.break (== '=') kv where (k, v) = BS.break (== '=') kv
@@ -305,12 +313,6 @@ contentRangeH lower upper total =
totalNotZero = fromMaybe True ((/=) 0 <$> total) totalNotZero = fromMaybe True ((/=) 0 <$> total)
fromInRange = lower <= upper fromInRange = lower <= upper
jsonH :: Header
jsonH = (hContentType, "application/json; charset=utf-8")
openapiH :: Header
openapiH = (hContentType, "application/openapi+json; charset=utf-8")
formatRelationError :: Text -> Text formatRelationError :: Text -> Text
formatRelationError = formatGeneralError formatRelationError = formatGeneralError
"could not find foreign keys between these entities" "could not find foreign keys between these entities"
@@ -471,16 +473,5 @@ toSourceRelation mt r@(Relation t _ ft _ _ rt _ _)
| Just mt == (tableName <$> rt) = Just $ r {relLTable=(\tbl -> tbl {tableName=sourceCTEName}) <$> rt} | Just mt == (tableName <$> rt) = Just $ r {relLTable=(\tbl -> tbl {tableName=sourceCTEName}) <$> rt}
| otherwise = Nothing | otherwise = Nothing
data TableOptions = TableOptions {
tblOptcolumns :: [Column]
, tblOptpkey :: [Text]
}
instance ToJSON TableOptions where
toJSON t = object [
"columns" .= tblOptcolumns t
, "pkey" .= tblOptpkey t ]
extractQueryResult :: Maybe ResultsWithCount -> ResultsWithCount extractQueryResult :: Maybe ResultsWithCount -> ResultsWithCount
extractQueryResult = fromMaybe (Nothing, 0, [], "") extractQueryResult = fromMaybe (Nothing, 0, [], "")
+5 -3
View File
@@ -10,17 +10,19 @@ import qualified Data.Aeson as JSON
import qualified Data.Text as T import qualified Data.Text as T
import qualified Hasql.Pool as P import qualified Hasql.Pool as P
import qualified Hasql.Session as H import qualified Hasql.Session as H
import Network.HTTP.Types.Header
import qualified Network.HTTP.Types.Status as HT import qualified Network.HTTP.Types.Status as HT
import Network.Wai (Response, responseLBS) import Network.Wai (Response, responseLBS)
import PostgREST.ApiRequest (ctToHeader, ContentType(..))
errResponse :: HT.Status -> Text -> Response errResponse :: HT.Status -> Text -> Response
errResponse status message = responseLBS status [(hContentType, "application/json")] (toS $ T.concat ["{\"message\":\"",message,"\"}"]) errResponse status message = responseLBS status
[ctToHeader CTApplicationJSON]
(toS $ T.concat ["{\"message\":\"",message,"\"}"])
pgErrResponse :: Bool -> P.UsageError -> Response pgErrResponse :: Bool -> P.UsageError -> Response
pgErrResponse authed e = pgErrResponse authed e =
let status = httpStatus authed e let status = httpStatus authed e
jsonType = (hContentType, "application/json") jsonType = ctToHeader CTApplicationJSON
wwwAuth = ("WWW-Authenticate", "Bearer") wwwAuth = ("WWW-Authenticate", "Bearer")
hdrs = if status == HT.status401 hdrs = if status == HT.status401
then [jsonType, wwwAuth] then [jsonType, wwwAuth]
+3 -18
View File
@@ -8,20 +8,17 @@ import qualified Data.HashMap.Strict as M
import Data.String.Conversions (cs) import Data.String.Conversions (cs)
import qualified Hasql.Transaction as H import qualified Hasql.Transaction as H
import Network.HTTP.Types.Header (hAccept) import Network.HTTP.Types.Status (status400)
import Network.HTTP.Types.Status (status400, status415) import Network.Wai (Application, Response)
import Network.Wai (Application, Request (..),
Response, requestHeaders)
import Network.Wai.Middleware.Cors (cors) import Network.Wai.Middleware.Cors (cors)
import Network.Wai.Middleware.Gzip (def, gzip) import Network.Wai.Middleware.Gzip (def, gzip)
import Network.Wai.Middleware.Static (only, staticPolicy) import Network.Wai.Middleware.Static (only, staticPolicy)
import PostgREST.ApiRequest (ApiRequest(..), ContentType(..), pickContentType) import PostgREST.ApiRequest (ApiRequest(..))
import PostgREST.Auth (claimsToSQL) import PostgREST.Auth (claimsToSQL)
import PostgREST.Config (AppConfig (..), corsPolicy) import PostgREST.Config (AppConfig (..), corsPolicy)
import PostgREST.Error (errResponse) import PostgREST.Error (errResponse)
import Data.Text import Data.Text
import Data.List (lookup)
import Protolude hiding (concat, null) import Protolude hiding (concat, null)
@@ -39,20 +36,8 @@ runWithClaims conf eClaims app req =
anon = String . cs $ configAnonRole conf anon = String . cs $ configAnonRole conf
clientErr = return . errResponse status400 clientErr = return . errResponse status400
unsupportedAccept :: Application -> Application
unsupportedAccept app req respond =
case (isTargetRoot, accept) of
(_, Left _) -> unsupportedAcceptRespond
(False, Right OpenAPI) -> unsupportedAcceptRespond
(_, Right _) -> app req respond
where accept = pickContentType $ lookup hAccept $ requestHeaders req
path = pathInfo req
isTargetRoot = fromMaybe True $ (== "") <$> listToMaybe path
unsupportedAcceptRespond = respond $ errResponse status415 "Unsupported Accept header, try: application/json"
defaultMiddle :: Application -> Application defaultMiddle :: Application -> Application
defaultMiddle = defaultMiddle =
gzip def gzip def
. cors corsPolicy . cors corsPolicy
. staticPolicy (only [("favicon.ico", "static/favicon.ico")]) . staticPolicy (only [("favicon.ico", "static/favicon.ico")])
. unsupportedAccept
+4 -4
View File
@@ -182,17 +182,17 @@ makePathItem (t, cs, _) = ("/" ++ unpack tn, p $ tableInsertable t)
where where
tOp = (mempty :: Operation) tOp = (mempty :: Operation)
& tags .~ Set.fromList [tn] & tags .~ Set.fromList [tn]
& produces ?~ makeMimeList [ApplicationJSON, TextCSV] & produces ?~ makeMimeList [CTApplicationJSON, CTTextCSV]
& at 200 ?~ "OK" & at 200 ?~ "OK"
getOp = tOp getOp = tOp
& parameters .~ map Inline (makeGetParams cs ++ rs) & parameters .~ map Inline (makeGetParams cs ++ rs)
& at 206 ?~ "Partial Content" & at 206 ?~ "Partial Content"
postOp = tOp postOp = tOp
& consumes ?~ makeMimeList [ApplicationJSON, TextCSV] & consumes ?~ makeMimeList [CTApplicationJSON, CTTextCSV]
& parameters .~ map Inline (makePostParams tn) & parameters .~ map Inline (makePostParams tn)
& at 201 ?~ "Created" & at 201 ?~ "Created"
patchOp = tOp patchOp = tOp
& consumes ?~ makeMimeList [ApplicationJSON, TextCSV] & consumes ?~ makeMimeList [CTApplicationJSON, CTTextCSV]
& parameters .~ map Inline (makePostParams tn ++ rs) & parameters .~ map Inline (makePostParams tn ++ rs)
& at 204 ?~ "No Content" & at 204 ?~ "No Content"
deletOp = tOp deletOp = tOp
@@ -209,7 +209,7 @@ makeRootPathItem = ("/", p)
where where
getOp = (mempty :: Operation) getOp = (mempty :: Operation)
& tags .~ Set.fromList ["/"] & tags .~ Set.fromList ["/"]
& produces ?~ makeMimeList [ApplicationJSON, OpenAPI] & produces ?~ makeMimeList [CTOpenAPI]
& at 200 ?~ "OK" & at 200 ?~ "OK"
pr = (mempty :: PathItem) & get ?~ getOp pr = (mempty :: PathItem) & get ?~ getOp
p = pr p = pr
+1 -1
View File
@@ -69,4 +69,4 @@ spec =
liftIO $ do liftIO $ do
simpleHeaders r `shouldSatisfy` matchHeader simpleHeaders r `shouldSatisfy` matchHeader
"Access-Control-Allow-Origin" "\\*" "Access-Control-Allow-Origin" "\\*"
simpleBody r `shouldSatisfy` not . BL.null simpleBody r `shouldSatisfy` BL.null
+21
View File
@@ -395,6 +395,27 @@ spec = do
(acceptHdrs "*/*") "" (acceptHdrs "*/*") ""
`shouldRespondWith` 200 `shouldRespondWith` 200
it "*/* should rescue an unknown type" $
request methodGet "/simple_pk"
(acceptHdrs "text/unknowntype, */*") ""
`shouldRespondWith` 200
it "specific available preference should override */*" $ do
r <- request methodGet "/simple_pk"
(acceptHdrs "text/csv, */*") ""
liftIO $ do
let respHeaders = simpleHeaders r
respHeaders `shouldSatisfy` matchHeader
"Content-Type" "text/csv; charset=utf-8"
it "honors client preference even when opposite of server preference" $ do
r <- request methodGet "/simple_pk"
(acceptHdrs "text/csv, application/json") ""
liftIO $ do
let respHeaders = simpleHeaders r
respHeaders `shouldSatisfy` matchHeader
"Content-Type" "text/csv; charset=utf-8"
it "should respond correctly to multiple types in accept header" $ it "should respond correctly to multiple types in accept header" $
request methodGet "/simple_pk" request methodGet "/simple_pk"
(acceptHdrs "text/unknowntype, text/csv") "" (acceptHdrs "text/unknowntype, text/csv") ""
+3 -374
View File
@@ -2,7 +2,6 @@ module Feature.StructureSpec where
import Test.Hspec hiding (pendingWith) import Test.Hspec hiding (pendingWith)
import Test.Hspec.Wai import Test.Hspec.Wai
import Test.Hspec.Wai.JSON
import Network.HTTP.Types import Network.HTTP.Types
import SpecHelper import SpecHelper
@@ -13,385 +12,15 @@ import Network.Wai.Test (SResponse(simpleHeaders))
spec :: SpecWith Application spec :: SpecWith Application
spec = do spec = do
describe "GET /" $ do describe "OpenAPI" $ do
it "lists views in schema" $ it "root path returns a valid openapi spec" $
request methodGet "/" [] ""
`shouldRespondWith` [json| [
{"schema":"test","name":"Escap3e;","insertable":true}
, {"schema":"test","name":"addresses","insertable":true}
, {"schema":"test","name":"articleStars","insertable":true}
, {"schema":"test","name":"articles","insertable":true}
, {"schema":"test","name":"auto_incrementing_pk","insertable":true}
, {"schema":"test","name":"clients","insertable":true}
, {"schema":"test","name":"comments","insertable":true}
, {"schema":"test","name":"complex_items","insertable":true}
, {"schema":"test","name":"compound_pk","insertable":true}
, {"schema":"test","name":"empty_table","insertable":true}
, {"schema":"test","name":"filtered_tasks","insertable":true}
, {"schema":"test","name":"ghostBusters","insertable":true}
, {"schema":"test","name":"has_count_column","insertable":false}
, {"schema":"test","name":"has_fk","insertable":true}
, {"schema":"test","name":"insertable_view_with_join","insertable":true}
, {"schema":"test","name":"insertonly","insertable":true}
, {"schema":"test","name":"items","insertable":true}
, {"schema":"test","name":"json","insertable":true}
, {"schema":"test","name":"materialized_view","insertable":false}
, {"schema":"test","name":"menagerie","insertable":true}
, {"schema":"test","name":"no_pk","insertable":true}
, {"schema":"test","name":"nullable_integer","insertable":true}
, {"schema":"test","name":"orders","insertable":true}
, {"schema":"test","name":"projects","insertable":true}
, {"schema":"test","name":"projects_view","insertable":true}
, {"schema":"test","name":"simple_pk","insertable":true}
, {"schema":"test","name":"tasks","insertable":true}
, {"schema":"test","name":"tsearch","insertable":true}
, {"schema":"test","name":"users","insertable":true}
, {"schema":"test","name":"users_projects","insertable":true}
, {"schema":"test","name":"users_tasks","insertable":true}
, {"schema":"test","name":"withUnique","insertable":true}
] |]
{matchStatus = 200}
it "lists only views user has permission to see" $ do
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0"
request methodGet "/" [auth] ""
`shouldRespondWith` [json| [
{"schema":"test","name":"authors_only","insertable":true}
] |]
{matchStatus = 200}
it "returns a valid openapi spec" $
validateOpenApiResponse [("Accept", "application/openapi+json")] validateOpenApiResponse [("Accept", "application/openapi+json")]
it "should respond to openapi request on none root path with 415" $ it "should respond to openapi request on none root path with 415" $
request methodGet "/none_root_path" request methodGet "/items"
(acceptHdrs "application/openapi+json") "" (acceptHdrs "application/openapi+json") ""
`shouldRespondWith` 415 `shouldRespondWith` 415
describe "Table info" $ do
it "The structure of complex views is correctly detected" $
request methodOptions "/filtered_tasks" [] "" `shouldRespondWith`
[json|
{
"pkey": [
"myId"
],
"columns": [
{
"references": null,
"default": null,
"precision": 32,
"updatable": true,
"schema": "test",
"name": "myId",
"type": "integer",
"maxLen": null,
"enum": [],
"nullable": true,
"position": 1
},
{
"references": null,
"default": null,
"precision": null,
"updatable": true,
"schema": "test",
"name": "name",
"type": "text",
"maxLen": null,
"enum": [],
"nullable": true,
"position": 2
},
{
"references": {
"schema": "test",
"column": "id",
"table": "projects"
},
"default": null,
"precision": 32,
"updatable": true,
"schema": "test",
"name": "projectID",
"type": "integer",
"maxLen": null,
"enum": [],
"nullable": true,
"position": 3
}
]
}
|]
it "is available with OPTIONS verb" $
request methodOptions "/menagerie" [] "" `shouldRespondWith`
[json|
{
"pkey":["integer"],
"columns":[
{
"default": null,
"precision": 32,
"updatable": true,
"schema": "test",
"name": "integer",
"type": "integer",
"maxLen": null,
"enum": [],
"nullable": false,
"position": 1,
"references": null,
"default": null
}, {
"default": null,
"precision": 53,
"updatable": true,
"schema": "test",
"name": "double",
"type": "double precision",
"maxLen": null,
"enum": [],
"nullable": false,
"references": null,
"position": 2
}, {
"default": null,
"precision": null,
"updatable": true,
"schema": "test",
"name": "varchar",
"type": "character varying",
"maxLen": null,
"enum": [],
"nullable": false,
"position": 3,
"references": null,
"default": null
}, {
"default": null,
"precision": null,
"updatable": true,
"schema": "test",
"name": "boolean",
"type": "boolean",
"maxLen": null,
"enum": [],
"nullable": false,
"references": null,
"position": 4
}, {
"default": null,
"precision": null,
"updatable": true,
"schema": "test",
"name": "date",
"type": "date",
"maxLen": null,
"enum": [],
"nullable": false,
"references": null,
"position": 5
}, {
"default": null,
"precision": null,
"updatable": true,
"schema": "test",
"name": "money",
"type": "money",
"maxLen": null,
"enum": [],
"nullable": false,
"position": 6,
"references": null,
"default": null
}, {
"default": null,
"precision": null,
"updatable": true,
"schema": "test",
"name": "enum",
"type": "test.enum_menagerie_type",
"maxLen": null,
"enum": [
"foo",
"bar"
],
"nullable": false,
"position": 7,
"references": null,
"default": null
}
]
}
|]
it "it includes primary and foreign keys for views" $
request methodOptions "/projects_view" [] "" `shouldRespondWith`
[json|
{
"pkey":[
"id"
],
"columns":[
{
"references":null,
"default":null,
"precision":32,
"updatable":true,
"schema":"test",
"name":"id",
"type":"integer",
"maxLen":null,
"enum":[],
"nullable":true,
"position":1
},
{
"references":null,
"default":null,
"precision":null,
"updatable":true,
"schema":"test",
"name":"name",
"type":"text",
"maxLen":null,
"enum":[],
"nullable":true,
"position":2
},
{
"references": {
"schema":"test",
"column":"id",
"table":"clients"
},
"default":null,
"precision":32,
"updatable":true,
"schema":"test",
"name":"client_id",
"type":"integer",
"maxLen":null,
"enum":[],
"nullable":true,
"position":3
}
]
}
|]
it "includes foreign key data" $
request methodOptions "/has_fk" [] ""
`shouldRespondWith` [json|
{
"pkey": ["id"],
"columns":[
{
"default": "nextval('test.has_fk_id_seq'::regclass)",
"precision": 64,
"updatable": true,
"schema": "test",
"name": "id",
"type": "bigint",
"maxLen": null,
"nullable": false,
"position": 1,
"enum": [],
"references": null
}, {
"default": null,
"precision": 32,
"updatable": true,
"schema": "test",
"name": "auto_inc_fk",
"type": "integer",
"maxLen": null,
"nullable": true,
"position": 2,
"enum": [],
"references": {"schema":"test", "table": "auto_incrementing_pk", "column": "id"}
}, {
"default": null,
"precision": null,
"updatable": true,
"schema": "test",
"name": "simple_fk",
"type": "character varying",
"maxLen": 255,
"nullable": true,
"position": 3,
"enum": [],
"references": {"schema":"test", "table": "simple_pk", "column": "k"}
}
]
}
|]
it "includes all information on views for renamed columns, and raises relations to correct schema" $
request methodOptions "/articleStars" [] ""
`shouldRespondWith` [json|
{
"pkey": [
"articleId",
"userId"
],
"columns": [
{
"references": {
"schema": "test",
"column": "id",
"table": "articles"
},
"default": null,
"precision": 32,
"updatable": true,
"schema": "test",
"name": "articleId",
"type": "integer",
"maxLen": null,
"enum": [],
"nullable": true,
"position": 1
},
{
"references": {
"schema": "test",
"column": "id",
"table": "users"
},
"default": null,
"precision": 32,
"updatable": true,
"schema": "test",
"name": "userId",
"type": "integer",
"maxLen": null,
"enum": [],
"nullable": true,
"position": 2
},
{
"references": null,
"default": null,
"precision": null,
"updatable": true,
"schema": "test",
"name": "createdAt",
"type": "timestamp without time zone",
"maxLen": null,
"enum": [],
"nullable": true,
"position": 3
}
]
}
|]
it "errors for non existant tables" $
request methodOptions "/dne" [] "" `shouldRespondWith` 404
describe "Allow header" $ do describe "Allow header" $ do
it "includes read/write verbs for writeable table" $ do it "includes read/write verbs for writeable table" $ do