Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add timezone in Prefer header #3024

Merged
merged 8 commits into from
Nov 13, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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