diff --git a/CHANGELOG.md b/CHANGELOG.md index f73778911..52af48693 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). ## Unreleased ### Added +- Allow requesting binary output on GET - @steve-chavez ### Fixed diff --git a/src/PostgREST/ApiRequest.hs b/src/PostgREST/ApiRequest.hs index 6992675bd..ad8539b1d 100644 --- a/src/PostgREST/ApiRequest.hs +++ b/src/PostgREST/ApiRequest.hs @@ -59,7 +59,7 @@ data PreferRepresentation = Full | HeadersOnly | None deriving Eq -- -- | Enumeration of currently supported response content types data ContentType = CTApplicationJSON | CTTextCSV | CTOpenAPI - | CTSingularJSON + | CTSingularJSON | CTOctetStream | CTAny | CTOther BS.ByteString deriving Eq data ApiRequestError = ErrorActionInappropriate @@ -77,6 +77,7 @@ 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 @@ -247,6 +248,7 @@ decodeContentType ct = "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 index f804cba4f..6cb4e7a21 100644 --- a/src/PostgREST/App.hs +++ b/src/PostgREST/App.hs @@ -8,6 +8,7 @@ module PostgREST.App ( 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) @@ -39,8 +40,14 @@ import PostgREST.ApiRequest ( ApiRequest(..), ContentType(..) 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 ( errResponse, pgErrResponse + , apiRequestErrResponse + , singularityError, binaryFieldError + ) import PostgREST.RangeQuery (allRange, rangeOffset) import PostgREST.Middleware import PostgREST.QueryBuilder ( callProc @@ -54,7 +61,8 @@ import PostgREST.Types 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 @@ -91,10 +99,13 @@ app dbStructure conf apiRequest = 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 @@ -257,8 +268,9 @@ app dbStructure conf apiRequest = mapSnd f (a, b) = (a, f b) readReq = readRequest (configMaxRows conf) (dbRelations dbStructure) (map (mapSnd pdReturnType) $ 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,7 +282,7 @@ responseContentTypeOrError accepts action = serves contentTypesForRequest accept 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] @@ -285,6 +297,15 @@ responseContentTypeOrError accepts action = serves contentTypesForRequest accept "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) where (k, v) = BS.break (== '=') kv diff --git a/src/PostgREST/DbRequestBuilder.hs b/src/PostgREST/DbRequestBuilder.hs index a51d9cdba..5cd9f75fd 100644 --- a/src/PostgREST/DbRequestBuilder.hs +++ b/src/PostgREST/DbRequestBuilder.hs @@ -2,6 +2,7 @@ module PostgREST.DbRequestBuilder ( readRequest , mutateRequest +, fieldNames ) where import Control.Applicative @@ -23,6 +24,7 @@ import Data.Foldable (foldr1) import qualified Data.HashMap.Strict as M import PostgREST.ApiRequest ( ApiRequest(..) + , PreferRepresentation(..) , Action(..), Target(..) , PreferRepresentation (..) ) @@ -247,8 +249,8 @@ toSourceRelation mt r@(Relation t _ ft _ _ rt _ _) | 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 (errResponse status400) $ case action of ActionCreate -> Right $ Insert rootTableName payload returnings ActionUpdate -> Update rootTableName <$> pure payload <*> filters <*> pure returnings @@ -262,14 +264,14 @@ mutateRequest apiRequest readReq = mapLeft (errResponse status400) $ 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) + returnings = if iPreferRepresentation apiRequest == None then [] else fldNames filters = first formatParserError $ 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/Error.hs b/src/PostgREST/Error.hs index 3284ac57c..c76711f3a 100644 --- a/src/PostgREST/Error.hs +++ b/src/PostgREST/Error.hs @@ -2,7 +2,16 @@ {-# LANGUAGE FlexibleInstances #-} {-# LANGUAGE TypeSynonymInstances #-} -module PostgREST.Error (apiRequestErrResponse, pgErrResponse, errResponse, prettyUsageError, singularityError, formatGeneralError, formatParserError) where +module PostgREST.Error ( + apiRequestErrResponse +, pgErrResponse +, errResponse +, prettyUsageError +, singularityError +, binaryFieldError +, formatGeneralError +, formatParserError +) where import Protolude import Data.Aeson ((.=)) @@ -54,6 +63,11 @@ singularityError numRows = , toS (toMime CTSingularJSON), "requires 1 row" ] +binaryFieldError :: Response +binaryFieldError = + errResponse HT.status406 (toS (toMime CTOctetStream) <> + " requested but a single column was not selected") + formatParserError :: ParseError -> Text formatParserError e = formatGeneralError message details where diff --git a/src/PostgREST/QueryBuilder.hs b/src/PostgREST/QueryBuilder.hs index eb30d90a6..2547f0091 100644 --- a/src/PostgREST/QueryBuilder.hs +++ b/src/PostgREST/QueryBuilder.hs @@ -35,6 +35,7 @@ import qualified Data.Aeson as JSON import PostgREST.RangeQuery (NonnegRange, rangeLimit, rangeOffset, allRange) import Data.Functor.Contravariant (contramap) import qualified Data.HashMap.Strict as HM +import Data.Maybe import Data.Text (intercalate, unwords, replace, isInfixOf, toLower, split) import qualified Data.Text as T (map, takeWhile, null) import qualified Data.Text.Encoding as T @@ -86,9 +87,9 @@ encodeUniformObjs :: HE.Params PayloadJSON 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 +105,7 @@ createReadStatement selectQuery countQuery isSingle countTotal asCsv = bodyF | asCsv = asCsvF | isSingle = asJsonSingleF + | isJust binaryField = asBinaryF $ fromJust binaryField | otherwise = asJsonF createWriteStatement :: SqlQuery -> SqlQuery -> Bool -> Bool -> Bool -> @@ -356,6 +358,9 @@ asJsonF = "coalesce(array_to_json(array_agg(row_to_json(_postgrest_t))), '[]'):: asJsonSingleF :: SqlFragment --TODO! unsafe when the query actually returns multiple rows, used only on inserting and returning single element asJsonSingleF = "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 = "(" <> diff --git a/test/Feature/QuerySpec.hs b/test/Feature/QuerySpec.hs index 44231aee1..65acc2468 100644 --- a/test/Feature/QuerySpec.hs +++ b/test/Feature/QuerySpec.hs @@ -608,3 +608,30 @@ spec = do it "will embed using a column" $ get "/ghostBusters?select=escapeId{*}" `shouldRespondWith` [json| [{"escapeId":{"so6meIdColumn":1}},{"escapeId":{"so6meIdColumn":3}},{"escapeId":{"so6meIdColumn":5}}] |] + + 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` ResponseMatcher { + matchBody = Just "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` ResponseMatcher { + matchBody = Just "iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEUAAAD/AAAb/40iAAAAP0lEQVQI12NgwAbYG2AE/wEYwQMiZB4ACQkQYZEAIgqAhAGIKLCAEQ8kgMT/P1CCEUwc4IMSzA3sUIIdCHECAGSQEkeOTUyCAAAAAElFTkSuQmCCiVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEX///8AAP94wDzzAAAAL0lEQVQIW2NgwAb+HwARH0DEDyDxwAZEyGAhLODqHmBRzAcn5GAS///A1IF14AAA5/Adbiiz/0gAAAAASUVORK5CYII=" + , matchStatus = 200 + , matchHeaders = ["Content-Type" <:> "application/octet-stream; charset=utf-8"] + } + + diff --git a/test/fixtures/data.sql b/test/fixtures/data.sql index 11dd34f1a..af5ed66bc 100644 --- a/test/fixtures/data.sql +++ b/test/fixtures/data.sql @@ -283,6 +283,10 @@ TRUNCATE TABLE orders CASCADE; INSERT INTO orders VALUES (1, 'order 1', 1, 2); INSERT INTO orders VALUES (2, 'order 2', 3, 4); +TRUNCATE TABLE images CASCADE; +INSERT INTO images(name, img) VALUES ('A.png', decode('iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEUAAAD/AAAb/40iAAAAP0lEQVQI12NgwAbYG2AE/wEYwQMiZB4ACQkQYZEAIgqAhAGIKLCAEQ8kgMT/P1CCEUwc4IMSzA3sUIIdCHECAGSQEkeOTUyCAAAAAElFTkSuQmCC', 'base64')); +INSERT INTO images(name, img) VALUES ('B.png', decode('iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEX///8AAP94wDzzAAAAL0lEQVQIW2NgwAb+HwARH0DEDyDxwAZEyGAhLODqHmBRzAcn5GAS///A1IF14AAA5/Adbiiz/0gAAAAASUVORK5CYII=', 'base64')); + -- -- PostgreSQL database dump complete -- diff --git a/test/fixtures/privileges.sql b/test/fixtures/privileges.sql index cf7b69e19..309d6d006 100644 --- a/test/fixtures/privileges.sql +++ b/test/fixtures/privileges.sql @@ -49,6 +49,8 @@ GRANT ALL ON TABLE , public.public_orders , consumers_view , orders_view + , images + , images_base64 TO postgrest_test_anonymous; GRANT INSERT ON TABLE insertonly TO postgrest_test_anonymous; diff --git a/test/fixtures/schema.sql b/test/fixtures/schema.sql index 80c9b7d22..11c96cc64 100755 --- a/test/fixtures/schema.sql +++ b/test/fixtures/schema.sql @@ -1099,6 +1099,15 @@ CREATE FUNCTION setprojects(id_l int, id_h int, name text) RETURNS SETOF project update test.projects set name = $3 WHERE id >= $1 AND id <= $2 returning *; $_$; +create table images ( + name text not null, + img bytea not null +); + +create view images_base64 as ( + select name, replace(encode(img, 'base64'), E'\n', '') as img from images +); + -- -- PostgreSQL database dump complete --