From ea7d74710758d82fa939b09c214bc622eec61d04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Steve=20Ch=C3=A1vez?= Date: Fri, 21 Jun 2019 11:51:54 -0500 Subject: [PATCH] Accept `text/plain` and `text/html` for raw output (#1330) --- CHANGELOG.md | 1 + src/PostgREST/ApiRequest.hs | 38 +++++------------- src/PostgREST/App.hs | 30 +++++++------- src/PostgREST/Error.hs | 12 ++++-- src/PostgREST/Types.hs | 39 ++++++++++++++---- test/Feature/QuerySpec.hs | 79 ++++++++++++++----------------------- test/Feature/RpcSpec.hs | 49 +++++++++++++++++++++++ test/fixtures/schema.sql | 17 ++++++++ 8 files changed, 162 insertions(+), 103 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3368eec8f..4c3d9b29b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). - #1278, Add db-pool-timeout config option - @qu4tro - #1285, Abort on wrong database password - @qu4tro - #790, Allow override of OpenAPI spec through `root-spec` config option - @steve-chavez +- #1308, Accept `text/plain` and `text/html` for raw output - @steve-chavez ### Fixed diff --git a/src/PostgREST/ApiRequest.hs b/src/PostgREST/ApiRequest.hs index 3b2b4fd56..dfada17c2 100644 --- a/src/PostgREST/ApiRequest.hs +++ b/src/PostgREST/ApiRequest.hs @@ -14,17 +14,16 @@ module PostgREST.ApiRequest ( , userApiRequest ) where -import qualified Data.Aeson as JSON -import qualified Data.ByteString as BS -import qualified Data.ByteString.Internal as BS (c2w) -import qualified Data.ByteString.Lazy as BL -import qualified Data.CaseInsensitive as CI -import qualified Data.Csv as CSV -import qualified Data.HashMap.Strict as M -import qualified Data.List as L -import qualified Data.Set as S -import qualified Data.Text as T -import qualified Data.Vector as V +import qualified Data.Aeson as JSON +import qualified Data.ByteString as BS +import qualified Data.ByteString.Lazy as BL +import qualified Data.CaseInsensitive as CI +import qualified Data.Csv as CSV +import qualified Data.HashMap.Strict as M +import qualified Data.List as L +import qualified Data.Set as S +import qualified Data.Text as T +import qualified Data.Vector as V import Control.Arrow ((***)) import Data.Aeson.Types (emptyArray, emptyObject) @@ -264,23 +263,6 @@ mutuallyAgreeable sProduces cAccepts = then listToMaybe sProduces else exact --- PRIVATE --------------------------------------------------------------- - -{-| - Warning: discards MIME parameters --} -decodeContentType :: BS.ByteString -> ContentType -decodeContentType ct = - case BS.takeWhile (/= BS.c2w ';') ct of - "application/json" -> CTApplicationJSON - "text/csv" -> CTTextCSV - "application/openapi+json" -> CTOpenAPI - "application/vnd.pgrst.object+json" -> CTSingularJSON - "application/vnd.pgrst.object" -> CTSingularJSON - "application/octet-stream" -> CTOctetStream - "*/*" -> CTAny - ct' -> CTOther ct' - type CsvData = V.Vector (M.HashMap Text BL.ByteString) {-| diff --git a/src/PostgREST/App.hs b/src/PostgREST/App.hs index def755ac7..59c040089 100644 --- a/src/PostgREST/App.hs +++ b/src/PostgREST/App.hs @@ -39,7 +39,7 @@ import PostgREST.DbRequestBuilder (fieldNames, mutateRequest, readRequest) import PostgREST.DbStructure import PostgREST.Error (PgError (..), SimpleError (..), - errorResponseFor) + errorResponseFor, singularityError) import PostgREST.Middleware import PostgREST.OpenAPI import PostgREST.Parsers (pRequestColumns) @@ -275,7 +275,7 @@ app dbStructure proc cols conf apiRequest = callProc qi (specifiedProcArgs cols proc) returnsScalar q cq shouldCount singular (iPreferSingleObjectParameter apiRequest) (contentType == CTTextCSV) - (contentType == CTOctetStream) bField + (contentType `elem` rawContentTypes) bField (pgVersion dbStructure) let (tableTotal, queryTotal, body, jsonHeaders) = fromMaybe (Just 0, 0, "[]", "[]") row @@ -334,11 +334,12 @@ responseContentTypeOrError :: [ContentType] -> Action -> Target -> Either Respon responseContentTypeOrError accepts action target = serves contentTypesForRequest accepts where contentTypesForRequest = case action of - ActionRead -> [CTApplicationJSON, CTSingularJSON, CTTextCSV, CTOctetStream] + ActionRead -> [CTApplicationJSON, CTSingularJSON, CTTextCSV] ++ rawContentTypes ActionCreate -> [CTApplicationJSON, CTSingularJSON, CTTextCSV] ActionUpdate -> [CTApplicationJSON, CTSingularJSON, CTTextCSV] ActionDelete -> [CTApplicationJSON, CTSingularJSON, CTTextCSV] - ActionInvoke _ -> [CTApplicationJSON, CTSingularJSON, CTTextCSV, CTOctetStream] ++ [CTOpenAPI | tpIsRootSpec target] + ActionInvoke _ -> [CTApplicationJSON, CTSingularJSON, CTTextCSV] ++ rawContentTypes ++ + [CTOpenAPI | tpIsRootSpec target] ActionInspect -> [CTOpenAPI, CTApplicationJSON] ActionInfo -> [CTTextCSV] ActionSingleUpsert -> [CTApplicationJSON, CTSingularJSON, CTTextCSV] @@ -347,14 +348,18 @@ responseContentTypeOrError accepts action target = serves contentTypesForRequest Nothing -> Left . errorResponseFor . ContentTypeError . map toMime $ cAccepts Just ct -> Right ct +{- + | If raw(binary) output is requested, check that ContentType is one of the admitted rawContentTypes and that + | `?select=...` contains only one field other than `*` +-} binaryField :: ContentType -> [FieldName] -> Either Response (Maybe FieldName) -binaryField CTOctetStream fldNames = - if length fldNames == 1 && fieldName /= Just "*" - then Right fieldName - else Left . errorResponseFor $ BinaryFieldError - where - fieldName = headMay fldNames -binaryField _ _ = Right Nothing +binaryField ct fldNames + | ct `elem` rawContentTypes = + let fieldName = headMay fldNames in + if length fldNames == 1 && fieldName /= Just "*" + then Right fieldName + else Left . errorResponseFor $ BinaryFieldError ct + | otherwise = Right Nothing splitKeyValue :: BS.ByteString -> (BS.ByteString, BS.ByteString) splitKeyValue kv = (k, BS.tail v) @@ -385,6 +390,3 @@ contentRangeH lower upper total = extractQueryResult :: Maybe ResultsWithCount -> ResultsWithCount extractQueryResult = fromMaybe (Nothing, 0, [], "") - -singularityError :: (Integral a) => a -> SimpleError -singularityError = SingularityError . toInteger diff --git a/src/PostgREST/Error.hs b/src/PostgREST/Error.hs index a2c8d0059..fcf41865c 100644 --- a/src/PostgREST/Error.hs +++ b/src/PostgREST/Error.hs @@ -12,6 +12,7 @@ module PostgREST.Error ( , SimpleError(..) , errorPayload , checkIsFatal +, singularityError ) where import qualified Data.Aeson as JSON @@ -190,7 +191,7 @@ checkIsFatal _ = Nothing data SimpleError = GucHeadersError - | BinaryFieldError + | BinaryFieldError ContentType | ConnectionLostError | PutSingletonError | PutMatchingPkError @@ -204,7 +205,7 @@ data SimpleError instance PgrstError SimpleError where status GucHeadersError = HT.status500 - status BinaryFieldError = HT.status406 + status (BinaryFieldError _) = HT.status406 status ConnectionLostError = HT.status503 status PutSingletonError = HT.status400 status PutMatchingPkError = HT.status400 @@ -222,8 +223,8 @@ instance PgrstError SimpleError where instance JSON.ToJSON SimpleError where toJSON GucHeadersError = JSON.object [ "message" .= ("response.headers guc must be a JSON array composed of objects with a single key and a string value" :: Text)] - toJSON BinaryFieldError = JSON.object [ - "message" .= ((toS (toMime CTOctetStream) <> " requested but a single column was not selected") :: Text)] + toJSON (BinaryFieldError ct) = JSON.object [ + "message" .= ((toS (toMime ct) <> " requested but a single column was not selected") :: Text)] toJSON ConnectionLostError = JSON.object [ "message" .= ("Database connection lost, retrying the connection." :: Text)] @@ -250,3 +251,6 @@ instance JSON.ToJSON SimpleError where invalidTokenHeader :: Text -> Header invalidTokenHeader m = ("WWW-Authenticate", "Bearer error=\"invalid_token\", " <> "error_description=" <> show m) + +singularityError :: (Integral a) => a -> SimpleError +singularityError = SingularityError . toInteger diff --git a/src/PostgREST/Types.hs b/src/PostgREST/Types.hs index c3859cf64..2e8480ffe 100644 --- a/src/PostgREST/Types.hs +++ b/src/PostgREST/Types.hs @@ -6,11 +6,13 @@ Description : PostgREST common types and functions used by the rest of the modul module PostgREST.Types where -import qualified Data.Aeson as JSON -import qualified Data.ByteString.Lazy as BL -import qualified Data.CaseInsensitive as CI -import qualified Data.HashMap.Strict as M -import qualified Data.Set as S +import qualified Data.Aeson as JSON +import qualified Data.ByteString as BS +import qualified Data.ByteString.Internal as BS (c2w) +import qualified Data.ByteString.Lazy as BL +import qualified Data.CaseInsensitive as CI +import qualified Data.HashMap.Strict as M +import qualified Data.Set as S import qualified GHC.Show import Network.HTTP.Types.Header (Header, hContentType) @@ -21,9 +23,10 @@ import PostgREST.RangeQuery (NonnegRange) import Protolude -- | Enumeration of currently supported response content types -data ContentType = CTApplicationJSON | CTTextCSV | CTOpenAPI - | CTSingularJSON | CTOctetStream - | CTAny | CTOther ByteString deriving Eq +data ContentType = CTApplicationJSON | CTSingularJSON + | CTTextCSV | CTTextPlain | CTTextHtml + | CTOpenAPI | CTOctetStream + | CTAny | CTOther ByteString deriving (Show, Eq) -- | Convert from ContentType to a full HTTP Header toHeader :: ContentType -> Header @@ -33,12 +36,32 @@ toHeader ct = (hContentType, toMime ct <> "; charset=utf-8") toMime :: ContentType -> ByteString toMime CTApplicationJSON = "application/json" toMime CTTextCSV = "text/csv" +toMime CTTextPlain = "text/plain" +toMime CTTextHtml = "text/html" toMime CTOpenAPI = "application/openapi+json" toMime CTSingularJSON = "application/vnd.pgrst.object+json" toMime CTOctetStream = "application/octet-stream" toMime CTAny = "*/*" toMime (CTOther ct) = ct +-- | Convert from ByteString to ContentType. Warning: discards MIME parameters +decodeContentType :: BS.ByteString -> ContentType +decodeContentType ct = case BS.takeWhile (/= BS.c2w ';') ct of + "application/json" -> CTApplicationJSON + "text/csv" -> CTTextCSV + "text/plain" -> CTTextPlain + "text/html" -> CTTextHtml + "application/openapi+json" -> CTOpenAPI + "application/vnd.pgrst.object+json" -> CTSingularJSON + "application/vnd.pgrst.object" -> CTSingularJSON + "application/octet-stream" -> CTOctetStream + "*/*" -> CTAny + ct' -> CTOther ct' + +-- | ContentTypes that can get a raw/unwrapped response +rawContentTypes :: [ContentType] +rawContentTypes = [CTOctetStream, CTTextPlain, CTTextHtml] + data PreferResolution = MergeDuplicates | IgnoreDuplicates deriving Eq instance Show PreferResolution where show MergeDuplicates = "resolution=merge-duplicates" diff --git a/test/Feature/QuerySpec.hs b/test/Feature/QuerySpec.hs index 4e185c39b..f9e918f0e 100644 --- a/test/Feature/QuerySpec.hs +++ b/test/Feature/QuerySpec.hs @@ -895,58 +895,39 @@ spec = do [json|[{":arr->ow::cast":" arrow-1 ","(inside,parens)":" parens-1 ","a.dotted.column":" dotted-1 "," col w space ":" space-1"}]|] { matchHeaders = [matchContentTypeJson] } - describe "binary output" $ do - context "on GET" $ 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"] - } + context "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` - [json| {"message":"application/octet-stream requested but a single column was not selected"} |] - { matchStatus = 406 - , matchHeaders = [matchContentTypeJson] - } - 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 "can get raw output with Accept: text/plain" $ + request methodGet "/projects?select=name&id=eq.1" (acceptHdrs "text/plain") "" + `shouldRespondWith` "Windows 7" + { matchStatus = 200 + , matchHeaders = ["Content-Type" <:> "text/plain; charset=utf-8"] + } - 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"] - } + 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` + [json| {"message":"application/octet-stream requested but a single column was not selected"} |] + { matchStatus = 406 + , matchHeaders = [matchContentTypeJson] + } + 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 - context "on RPC" $ do - context "Proc that returns scalar" $ - it "can query without selecting column" $ - request methodPost "/rpc/ret_base64_bin" (acceptHdrs "application/octet-stream") "" - `shouldRespondWith` "iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEUAAAD/AAAb/40iAAAAP0lEQVQI12NgwAbYG2AE/wEYwQMiZB4ACQkQYZEAIgqAhAGIKLCAEQ8kgMT/P1CCEUwc4IMSzA3sUIIdCHECAGSQEkeOTUyCAAAAAElFTkSuQmCC" - { matchStatus = 200 - , matchHeaders = ["Content-Type" <:> "application/octet-stream; charset=utf-8"] - } - - context "Proc that returns rows" $ do - it "can query if a single column is selected" $ - request methodPost "/rpc/ret_rows_with_base64_bin?select=img" (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"] - } - - it "fails if a single column is not selected" $ - request methodPost "/rpc/ret_rows_with_base64_bin" (acceptHdrs "application/octet-stream") "" - `shouldRespondWith` - [json| {"message":"application/octet-stream requested but a single column was not selected"} |] - { matchStatus = 406 - , matchHeaders = [matchContentTypeJson] - } + 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" $ diff --git a/test/Feature/RpcSpec.hs b/test/Feature/RpcSpec.hs index e944b675f..79abfc7b5 100644 --- a/test/Feature/RpcSpec.hs +++ b/test/Feature/RpcSpec.hs @@ -469,6 +469,55 @@ spec actualPgVersion = {"id":4,"name":"OSX"}] |] { matchHeaders = [matchContentTypeJson] } + context "binary output" $ do + context "Proc that returns scalar" $ do + it "can query without selecting column" $ + request methodPost "/rpc/ret_base64_bin" (acceptHdrs "application/octet-stream") "" + `shouldRespondWith` "iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEUAAAD/AAAb/40iAAAAP0lEQVQI12NgwAbYG2AE/wEYwQMiZB4ACQkQYZEAIgqAhAGIKLCAEQ8kgMT/P1CCEUwc4IMSzA3sUIIdCHECAGSQEkeOTUyCAAAAAElFTkSuQmCC" + { matchStatus = 200 + , matchHeaders = ["Content-Type" <:> "application/octet-stream; charset=utf-8"] + } + + it "can get raw output with Accept: text/html" $ + request methodGet "/rpc/welcome.html" (acceptHdrs "text/html") "" + `shouldRespondWith` + [str| + | + | + | PostgREST + | + | + |

Welcome to PostgREST

+ | + | + |] + { matchStatus = 200 + , matchHeaders = ["Content-Type" <:> "text/html; charset=utf-8"] + } + + it "can get raw output with Accept: text/plain" $ + request methodGet "/rpc/welcome" (acceptHdrs "text/plain") "" + `shouldRespondWith` "Welcome to PostgREST" + { matchStatus = 200 + , matchHeaders = ["Content-Type" <:> "text/plain; charset=utf-8"] + } + + context "Proc that returns rows" $ do + it "can query if a single column is selected" $ + request methodPost "/rpc/ret_rows_with_base64_bin?select=img" (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"] + } + + it "fails if a single column is not selected" $ + request methodPost "/rpc/ret_rows_with_base64_bin" (acceptHdrs "application/octet-stream") "" + `shouldRespondWith` + [json| {"message":"application/octet-stream requested but a single column was not selected"} |] + { matchStatus = 406 + , matchHeaders = [matchContentTypeJson] + } + context "only for GET rpc" $ do it "should fail on mutating procs" $ do get "/rpc/callcounter" `shouldRespondWith` 500 diff --git a/test/fixtures/schema.sql b/test/fixtures/schema.sql index 5fec1d49b..2c373329d 100755 --- a/test/fixtures/schema.sql +++ b/test/fixtures/schema.sql @@ -1713,3 +1713,20 @@ case current_setting('request.header.accept', true) end case; end $_$ language plpgsql; + +create or replace function welcome() returns text as $$ +select 'Welcome to PostgREST'::text; +$$ language sql; + +create or replace function "welcome.html"() returns text as $_$ +select $$ + + + PostgREST + + +

Welcome to PostgREST

+ + +$$::text; +$_$ language sql;