Skip to content

Commit

Permalink
feat: add timezone in Prefer header (#3024)
Browse files Browse the repository at this point in the history
* increase reloading timeout in io-tests
* increase memory test by 1M
  • Loading branch information
taimoorzaeem authored Nov 13, 2023
1 parent f10d413 commit 3c1a7f2
Show file tree
Hide file tree
Showing 14 changed files with 142 additions and 40 deletions.
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ This project adheres to [Semantic Versioning](http://semver.org/).
- #2983, Add more data to `Server-Timing` header - @develop7
- #2441, Add config `server-cors-allowed-origins` to specify CORS origins - @taimoorzaeem
- #2825, SQL handlers for custom media types - @steve-chavez
- Solves #1548, #2699, #2763, #2170, #1462, #1102, #1374, #2901
+ Solves #1548, #2699, #2763, #2170, #1462, #1102, #1374, #2901
- #2799, Add timezone in Prefer header - @taimoorzaeem

### Fixed

Expand Down
2 changes: 1 addition & 1 deletion nix/tools/withTools.nix
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ let
# We try to make the database cluster as independent as possible from the host
# by specifying the timezone, locale and encoding.
# initdb -U creates a superuser(man initdb)
PGTZ=UTC initdb --no-locale --encoding=UTF8 --nosync -U "${superuserRole}" --auth=trust \
TZ=$PGTZ initdb --no-locale --encoding=UTF8 --nosync -U "${superuserRole}" --auth=trust \
>> "$setuplog"
log "Starting the database cluster..."
Expand Down
7 changes: 4 additions & 3 deletions src/PostgREST/ApiRequest.hs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import PostgREST.RangeQuery (NonnegRange, allRange,
convertToLimitZeroRange,
hasLimitZero,
rangeRequested)
import PostgREST.SchemaCache (SchemaCache (..))
import PostgREST.SchemaCache.Identifiers (FieldName,
QualifiedIdentifier (..),
Schema)
Expand Down Expand Up @@ -134,8 +135,8 @@ data ApiRequest = ApiRequest {
}

-- | Examines HTTP request and translates it into user intent.
userApiRequest :: AppConfig -> Request -> RequestBody -> Either ApiRequestError ApiRequest
userApiRequest conf req reqBody = do
userApiRequest :: AppConfig -> Request -> RequestBody -> SchemaCache -> Either ApiRequestError ApiRequest
userApiRequest conf req reqBody sCache = do
pInfo@PathInfo{..} <- getPathInfo conf $ pathInfo req
act <- getAction pInfo method
qPrms <- first QueryParamError $ QueryParams.parse (pathIsProc && act `elem` [ActionInvoke InvGet, ActionInvoke InvHead]) $ rawQueryString req
Expand All @@ -150,7 +151,7 @@ userApiRequest conf req reqBody = do
, iRange = ranges
, iTopLevelRange = topLevelRange
, iPayload = payload
, iPreferences = Preferences.fromHeaders (configDbTxAllowOverride conf) hdrs
, iPreferences = Preferences.fromHeaders (configDbTxAllowOverride conf) (dbTimezones sCache) hdrs
, iQueryParams = qPrms
, iColumns = columns
, iHeaders = iHdrs
Expand Down
49 changes: 37 additions & 12 deletions src/PostgREST/ApiRequest/Preferences.hs
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,25 @@
module PostgREST.ApiRequest.Preferences
( Preferences(..)
, PreferCount(..)
, PreferHandling(..)
, PreferMissing(..)
, PreferParameters(..)
, PreferRepresentation(..)
, PreferResolution(..)
, PreferTransaction(..)
, PreferHandling(..)
, PreferTimezone(..)
, fromHeaders
, shouldCount
, prefAppliedHeader
) where

import qualified Data.ByteString.Char8 as BS
import qualified Data.Map as Map
import qualified Data.Set as S
import qualified Network.HTTP.Types.Header as HTTP

import PostgREST.Config.Database (TimezoneNames)

import Protolude

-- $setup
Expand All @@ -37,6 +41,7 @@ import Protolude
-- >>> deriving instance Show PreferTransaction
-- >>> deriving instance Show PreferMissing
-- >>> deriving instance Show PreferHandling
-- >>> deriving instance Show PreferTimezone
-- >>> deriving instance Show Preferences

-- | Preferences recognized by the application.
Expand All @@ -49,15 +54,17 @@ data Preferences
, preferTransaction :: Maybe PreferTransaction
, preferMissing :: Maybe PreferMissing
, preferHandling :: Maybe PreferHandling
, preferTimezone :: Maybe PreferTimezone
, invalidPrefs :: [ByteString]
}

-- |
-- Parse HTTP headers based on RFC7240[1] to identify preferences.
--
-- One header with comma-separated values can be used to set multiple preferences:
-- >>> let sc = S.fromList ["America/Los_Angeles"]
--
-- >>> pPrint $ fromHeaders True [("Prefer", "resolution=ignore-duplicates, count=exact")]
-- One header with comma-separated values can be used to set multiple preferences:
-- >>> pPrint $ fromHeaders True sc [("Prefer", "resolution=ignore-duplicates, count=exact, timezone=America/Los_Angeles")]
-- Preferences
-- { preferResolution = Just IgnoreDuplicates
-- , preferRepresentation = Nothing
Expand All @@ -66,12 +73,14 @@ data Preferences
-- , preferTransaction = Nothing
-- , preferMissing = Nothing
-- , preferHandling = Nothing
-- , preferTimezone = Just
-- ( PreferTimezone "America/Los_Angeles" )
-- , invalidPrefs = []
-- }
--
-- Multiple headers can also be used:
--
-- >>> pPrint $ fromHeaders True [("Prefer", "resolution=ignore-duplicates"), ("Prefer", "count=exact"), ("Prefer", "missing=null"), ("Prefer", "handling=lenient"), ("Prefer", "invalid")]
-- >>> pPrint $ fromHeaders True sc [("Prefer", "resolution=ignore-duplicates"), ("Prefer", "count=exact"), ("Prefer", "missing=null"), ("Prefer", "handling=lenient"), ("Prefer", "invalid")]
-- Preferences
-- { preferResolution = Just IgnoreDuplicates
-- , preferRepresentation = Nothing
Expand All @@ -80,18 +89,19 @@ data Preferences
-- , preferTransaction = Nothing
-- , preferMissing = Just ApplyNulls
-- , preferHandling = Just Lenient
-- , preferTimezone = Nothing
-- , invalidPrefs = [ "invalid" ]
-- }
--
-- If a preference is set more than once, only the first is used:
--
-- >>> preferTransaction $ fromHeaders True [("Prefer", "tx=commit, tx=rollback")]
-- >>> preferTransaction $ fromHeaders True sc [("Prefer", "tx=commit, tx=rollback")]
-- Just Commit
--
-- This is also the case across multiple headers:
--
-- >>> :{
-- preferResolution . fromHeaders True $
-- preferResolution . fromHeaders True sc $
-- [ ("Prefer", "resolution=ignore-duplicates")
-- , ("Prefer", "resolution=merge-duplicates")
-- ]
Expand All @@ -101,7 +111,7 @@ data Preferences
--
-- Preferences can be separated by arbitrary amounts of space, lower-case header is also recognized:
--
-- >>> pPrint $ fromHeaders True [("prefer", "count=exact, tx=commit ,return=representation , missing=default, handling=strict, anything")]
-- >>> pPrint $ fromHeaders True sc [("prefer", "count=exact, tx=commit ,return=representation , missing=default, handling=strict, anything")]
-- Preferences
-- { preferResolution = Nothing
-- , preferRepresentation = Just Full
Expand All @@ -110,20 +120,22 @@ data Preferences
-- , preferTransaction = Just Commit
-- , preferMissing = Just ApplyDefaults
-- , preferHandling = Just Strict
-- , preferTimezone = Nothing
-- , invalidPrefs = [ "anything" ]
-- }
--
fromHeaders :: Bool -> [HTTP.Header] -> Preferences
fromHeaders allowTxEndOverride headers =
fromHeaders :: Bool -> TimezoneNames -> [HTTP.Header] -> Preferences
fromHeaders allowTxDbOverride acceptedTzNames headers =
Preferences
{ preferResolution = parsePrefs [MergeDuplicates, IgnoreDuplicates]
, preferRepresentation = parsePrefs [Full, None, HeadersOnly]
, preferParameters = parsePrefs [SingleObject]
, preferCount = parsePrefs [ExactCount, PlannedCount, EstimatedCount]
, preferTransaction = if allowTxEndOverride then parsePrefs [Commit, Rollback] else Nothing
, preferTransaction = if allowTxDbOverride then parsePrefs [Commit, Rollback] else Nothing
, preferMissing = parsePrefs [ApplyDefaults, ApplyNulls]
, preferHandling = parsePrefs [Strict, Lenient]
, invalidPrefs = filter (`notElem` acceptedPrefs) prefs
, preferTimezone = if isTimezonePrefAccepted then PreferTimezone <$> timezonePref else Nothing
, invalidPrefs = filter checkPrefs prefs
}
where
mapToHeadVal :: ToHeaderValue a => [a] -> [ByteString]
Expand All @@ -139,6 +151,11 @@ fromHeaders allowTxEndOverride headers =
prefHeaders = filter ((==) HTTP.hPrefer . fst) headers
prefs = fmap BS.strip . concatMap (BS.split ',' . snd) $ prefHeaders

timezonePref = listToMaybe $ mapMaybe (BS.stripPrefix "timezone=") prefs
isTimezonePrefAccepted = (S.member <$> timezonePref <*> pure acceptedTzNames) == Just True

checkPrefs p = p `notElem` acceptedPrefs && not isTimezonePrefAccepted

parsePrefs :: ToHeaderValue a => [a] -> Maybe a
parsePrefs vals =
head $ mapMaybe (flip Map.lookup $ prefMap vals) prefs
Expand All @@ -147,7 +164,7 @@ fromHeaders allowTxEndOverride headers =
prefMap = Map.fromList . fmap (\pref -> (toHeaderValue pref, pref))

prefAppliedHeader :: Preferences -> Maybe HTTP.Header
prefAppliedHeader Preferences {preferResolution, preferRepresentation, preferParameters, preferCount, preferTransaction, preferMissing, preferHandling } =
prefAppliedHeader Preferences {preferResolution, preferRepresentation, preferParameters, preferCount, preferTransaction, preferMissing, preferHandling, preferTimezone } =
if null prefsVals
then Nothing
else Just (HTTP.hPreferenceApplied, combined)
Expand All @@ -161,6 +178,7 @@ prefAppliedHeader Preferences {preferResolution, preferRepresentation, preferPar
, toHeaderValue <$> preferCount
, toHeaderValue <$> preferTransaction
, toHeaderValue <$> preferHandling
, toHeaderValue <$> preferTimezone
]

-- |
Expand Down Expand Up @@ -253,3 +271,10 @@ data PreferHandling
instance ToHeaderValue PreferHandling where
toHeaderValue Strict = "handling=strict"
toHeaderValue Lenient = "handling=lenient"

-- |
-- Change timezone
newtype PreferTimezone = PreferTimezone ByteString

instance ToHeaderValue PreferTimezone where
toHeaderValue (PreferTimezone tz) = "timezone=" <> tz
2 changes: 1 addition & 1 deletion src/PostgREST/App.hs
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ postgrestResponse appState conf@AppConfig{..} maybeSchemaCache pgVer authResult@

apiRequest <-
liftEither . mapLeft Error.ApiRequestError $
ApiRequest.userApiRequest conf req body
ApiRequest.userApiRequest conf req body sCache

let jwtTiming = (SMJwt, if configDbPlanEnabled then Auth.getJwtDur req else Nothing)
handleRequest authResult conf appState (Just authRole /= configDbAnonRole) configDbPreparedStatements pgVer apiRequest sCache jwtTiming
Expand Down
4 changes: 3 additions & 1 deletion src/PostgREST/Config/Database.hs
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
module PostgREST.Config.Database
( pgVersionStatement
, queryDbSettings
, queryRoleSettings
, queryPgVersion
, queryRoleSettings
, RoleSettings
, RoleIsolationLvl
, TimezoneNames
, toIsolationLevel
) where

Expand All @@ -29,6 +30,7 @@ import Protolude

type RoleSettings = (HM.HashMap ByteString (HM.HashMap ByteString ByteString))
type RoleIsolationLvl = HM.HashMap ByteString SQL.IsolationLevel
type TimezoneNames = Set ByteString -- cache timezone names for prefer timezone=

toIsolationLevel :: (Eq a, IsString a) => a -> SQL.IsolationLevel
toIsolationLevel a = case a of
Expand Down
16 changes: 9 additions & 7 deletions src/PostgREST/Query.hs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import qualified PostgREST.SchemaCache as SchemaCache

import PostgREST.ApiRequest (ApiRequest (..))
import PostgREST.ApiRequest.Preferences (PreferCount (..),
PreferTimezone (..),
PreferTransaction (..),
Preferences (..),
shouldCount)
Expand Down Expand Up @@ -235,21 +236,22 @@ optionalRollback AppConfig{..} ApiRequest{iPreferences=Preferences{..}} = do
-- | Runs local (transaction scoped) GUCs for every request.
setPgLocals :: AppConfig -> KM.KeyMap JSON.Value -> BS.ByteString -> [(ByteString, ByteString)] ->
ApiRequest -> DbHandler ()
setPgLocals AppConfig{..} claims role roleSettings req = lift $
setPgLocals AppConfig{..} claims role roleSettings ApiRequest{..} = lift $
SQL.statement mempty $ SQL.dynamicallyParameterized
("select " <> intercalateSnippet ", " (searchPathSql : roleSql ++ roleSettingsSql ++ claimsSql ++ [methodSql, pathSql] ++ headersSql ++ cookiesSql ++ appSettingsSql))
("select " <> intercalateSnippet ", " (searchPathSql : roleSql ++ roleSettingsSql ++ claimsSql ++ [methodSql, pathSql] ++ headersSql ++ cookiesSql ++ timezoneSql ++ appSettingsSql))
HD.noResult configDbPreparedStatements
where
methodSql = setConfigWithConstantName ("request.method", iMethod req)
pathSql = setConfigWithConstantName ("request.path", iPath req)
headersSql = setConfigWithConstantNameJSON "request.headers" (iHeaders req)
cookiesSql = setConfigWithConstantNameJSON "request.cookies" (iCookies req)
methodSql = setConfigWithConstantName ("request.method", iMethod)
pathSql = setConfigWithConstantName ("request.path", iPath)
headersSql = setConfigWithConstantNameJSON "request.headers" iHeaders
cookiesSql = setConfigWithConstantNameJSON "request.cookies" iCookies
claimsSql = [setConfigWithConstantName ("request.jwt.claims", LBS.toStrict $ JSON.encode claims)]
roleSql = [setConfigWithConstantName ("role", role)]
roleSettingsSql = setConfigWithDynamicName <$> roleSettings
appSettingsSql = setConfigWithDynamicName <$> (join bimap toUtf8 <$> configAppSettings)
timezoneSql = maybe mempty (\(PreferTimezone tz) -> [setConfigWithConstantName ("timezone", tz)]) $ preferTimezone iPreferences
searchPathSql =
let schemas = escapeIdentList (iSchema req : configDbExtraSearchPath) in
let schemas = escapeIdentList (iSchema : configDbExtraSearchPath) in
setConfigWithConstantName ("search_path", schemas)

-- | Runs the pre-request function.
Expand Down
12 changes: 6 additions & 6 deletions src/PostgREST/Response.hs
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ readResponse WrappedReadPlan{wrMedia} headersOnly identifier ctxApiRequest@ApiRe
RSStandard{..} -> do
let
(status, contentRange) = RangeQuery.rangeStatusHeader iTopLevelRange rsQueryTotal rsTableTotal
prefHeader = maybeToList . prefAppliedHeader $ Preferences Nothing Nothing Nothing preferCount preferTransaction Nothing preferHandling []
prefHeader = maybeToList . prefAppliedHeader $ Preferences Nothing Nothing Nothing preferCount preferTransaction Nothing preferHandling preferTimezone []
headers =
[ contentRange
, ( "Content-Location"
Expand Down Expand Up @@ -105,7 +105,7 @@ createResponse QualifiedIdentifier{..} MutateReadPlan{mrMutatePlan, mrMedia} ctx
pkCols = case mrMutatePlan of { Insert{insPkCols} -> insPkCols; _ -> mempty;}
prefHeader = prefAppliedHeader $
Preferences (if null pkCols && isNothing (qsOnConflict iQueryParams) then Nothing else preferResolution)
preferRepresentation Nothing preferCount preferTransaction preferMissing preferHandling []
preferRepresentation Nothing preferCount preferTransaction preferMissing preferHandling preferTimezone []
headers =
catMaybes
[ if null rsLocation then
Expand Down Expand Up @@ -146,7 +146,7 @@ updateResponse MutateReadPlan{mrMedia} ctxApiRequest@ApiRequest{iPreferences=Pre
contentRangeHeader =
Just . RangeQuery.contentRangeH 0 (rsQueryTotal - 1) $
if shouldCount preferCount then Just rsQueryTotal else Nothing
prefHeader = prefAppliedHeader $ Preferences Nothing preferRepresentation Nothing preferCount preferTransaction preferMissing preferHandling []
prefHeader = prefAppliedHeader $ Preferences Nothing preferRepresentation Nothing preferCount preferTransaction preferMissing preferHandling preferTimezone []
headers = catMaybes [contentRangeHeader, prefHeader]

let (status, headers', body) =
Expand All @@ -166,7 +166,7 @@ singleUpsertResponse :: MutateReadPlan -> ApiRequest -> ResultSet -> Either Erro
singleUpsertResponse MutateReadPlan{mrMedia} ctxApiRequest@ApiRequest{iPreferences=Preferences{..}} resultSet = case resultSet of
RSStandard {..} -> do
let
prefHeader = maybeToList . prefAppliedHeader $ Preferences Nothing preferRepresentation Nothing preferCount preferTransaction Nothing preferHandling []
prefHeader = maybeToList . prefAppliedHeader $ Preferences Nothing preferRepresentation Nothing preferCount preferTransaction Nothing preferHandling preferTimezone []
cTHeader = contentTypeHeaders mrMedia ctxApiRequest

let isInsertIfGTZero i = if i > 0 then HTTP.status201 else HTTP.status200
Expand All @@ -190,7 +190,7 @@ deleteResponse MutateReadPlan{mrMedia} ctxApiRequest@ApiRequest{iPreferences=Pre
contentRangeHeader =
RangeQuery.contentRangeH 1 0 $
if shouldCount preferCount then Just rsQueryTotal else Nothing
prefHeader = maybeToList . prefAppliedHeader $ Preferences Nothing preferRepresentation Nothing preferCount preferTransaction Nothing preferHandling []
prefHeader = maybeToList . prefAppliedHeader $ Preferences Nothing preferRepresentation Nothing preferCount preferTransaction Nothing preferHandling preferTimezone []
headers = contentRangeHeader : prefHeader

let (status, headers', body) =
Expand Down Expand Up @@ -243,7 +243,7 @@ invokeResponse CallReadPlan{crMedia} invMethod proc ctxApiRequest@ApiRequest{iPr
then Error.errorPayload $ Error.ApiRequestError $ ApiRequestTypes.InvalidRange
$ ApiRequestTypes.OutOfBounds (show $ RangeQuery.rangeOffset iTopLevelRange) (maybe "0" show rsTableTotal)
else LBS.fromStrict rsBody
prefHeader = maybeToList . prefAppliedHeader $ Preferences Nothing Nothing preferParameters preferCount preferTransaction Nothing preferHandling []
prefHeader = maybeToList . prefAppliedHeader $ Preferences Nothing Nothing preferParameters preferCount preferTransaction Nothing preferHandling preferTimezone []
headers = contentRange : prefHeader

let (status', headers', body) =
Expand Down
Loading

0 comments on commit 3c1a7f2

Please sign in to comment.