forward response headers from remote servers (fix #1654) (#1664)

This commit is contained in:
Anon Ray 2019-02-28 11:45:07 +00:00 committed by Shahidh K Muhammed
parent a002d3ad2a
commit c19fe35f4e
11 changed files with 164 additions and 35 deletions

View File

@ -137,6 +137,14 @@ community tooling to write your own client-facing GraphQL gateway that interacts
it out of the box** (*by as much as 4x*). If you need any help with remodeling these kind of use cases to use the
built-in remote schemas feature, please get in touch with us on `Discord <https://discord.gg/vBPpJkS>`__.
Response headers from your remote GraphQL servers
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Response headers from your remote schema servers are sent back to the client
over HTTP transport. **Over websocket transport, the response headers are not
sent.** If you require the response headers from remote servers, use the HTTP
transport.
Bypassing Hasura's authorization system for remote schema queries
-----------------------------------------------------------------

View File

@ -142,6 +142,7 @@ library
, Hasura.Server.Auth
, Hasura.Server.Auth.JWT
, Hasura.Server.Init
, Hasura.Server.Context
, Hasura.Server.Middleware
, Hasura.Server.Logging
, Hasura.Server.Query

View File

@ -27,6 +27,10 @@ import Hasura.GraphQL.Transport.HTTP.Protocol
import Hasura.HTTP
import Hasura.RQL.DDL.Headers
import Hasura.RQL.Types
import Hasura.Server.Context
import Hasura.Server.Utils (bsToTxt,
filterRequestHeaders,
filterResponseHeaders)
import qualified Hasura.GraphQL.Resolve as R
import qualified Hasura.GraphQL.Validate as VQ
@ -42,7 +46,7 @@ runGQ
-> [N.Header]
-> GraphQLRequest
-> BL.ByteString -- this can be removed when we have a pretty-printer
-> m BL.ByteString
-> m HResponse
runGQ pool isoL userInfo sc manager reqHdrs req rawReq = do
(gCtx, _) <- flip runStateT sc $ getGCtx (userRole userInfo) gCtxRoleMap
@ -104,7 +108,7 @@ runHasuraGQ
-> UserInfo
-> SchemaCache
-> VQ.QueryParts
-> m BL.ByteString
-> m HResponse
runHasuraGQ pool isoL userInfo sc queryParts = do
(gCtx, _) <- flip runStateT sc $ getGCtx (userRole userInfo) gCtxMap
(opTy, fields) <- runReaderT (VQ.validateGQ queryParts) gCtx
@ -112,7 +116,7 @@ runHasuraGQ pool isoL userInfo sc queryParts = do
"subscriptions are not supported over HTTP, use websockets instead"
let tx = R.resolveSelSet userInfo gCtx opTy fields
resp <- liftIO (runExceptT $ runTx tx) >>= liftEither
return $ encodeGQResp $ GQSuccess resp
return $ HResponse (encodeGQResp $ GQSuccess resp) Nothing
where
gCtxMap = scGCtxMap sc
runTx tx = runLazyTx pool isoL $ withUserInfo userInfo tx
@ -126,7 +130,7 @@ runRemoteGQ
-- ^ the raw request string
-> RemoteSchemaInfo
-> G.TypedOperationDefinition
-> m BL.ByteString
-> m HResponse
runRemoteGQ manager userInfo reqHdrs q rsi opDef = do
let opTy = G._todType opDef
when (opTy == G.OperationTypeSubscription) $
@ -138,7 +142,9 @@ runRemoteGQ manager userInfo reqHdrs q rsi opDef = do
res <- liftIO $ try $ Wreq.postWith options (show url) q
resp <- either httpThrow return res
return $ resp ^. Wreq.responseBody
let respHdrs = map (\(k, v) -> Header (bsToTxt $ CI.original k, bsToTxt v)) $
filterResponseHeaders $ resp ^. Wreq.responseHeaders
return $ HResponse (resp ^. Wreq.responseBody) (Just respHdrs)
where
RemoteSchemaInfo url hdrConf fwdClientHdrs = rsi
@ -147,9 +153,4 @@ runRemoteGQ manager userInfo reqHdrs q rsi opDef = do
userInfoToHdrs = map (\(k, v) -> (CI.mk $ CS.cs k, CS.cs v)) $
userInfoToList userInfo
filteredHeaders = flip filter reqHdrs $ \(n, _) ->
n `notElem` [ "Content-Length", "Content-MD5", "User-Agent", "Host"
, "Origin", "Referer" , "Accept", "Accept-Encoding"
, "Accept-Language", "Accept-Datetime"
, "Cache-Control", "Connection", "DNT"
]
filteredHeaders = filterRequestHeaders reqHdrs

View File

@ -24,6 +24,7 @@ import qualified STMContainers.Map as STMMap
import Control.Concurrent (threadDelay)
import qualified Data.IORef as IORef
import Hasura.Server.Context
import Hasura.GraphQL.Resolve (resolveSelSet)
import Hasura.GraphQL.Resolve.Context (LazyRespTx)
@ -197,7 +198,8 @@ onStart serverEnv wsConn (StartMsg opId q) msgRaw = catchAndIgnore $ do
withComplete $ sendConnErr "subscription to remote server is not supported"
resp <- runExceptT $ TH.runRemoteGQ httpMgr userInfo reqHdrs
msgRaw rsi opDef
either postExecErr sendSuccResp resp
-- ignore headers when sending response on websocket
either postExecErr sendSuccResp (_hrBody <$> resp)
sendCompleted
where

View File

@ -43,6 +43,7 @@ import Hasura.RQL.DML.QueryTemplate
import Hasura.RQL.Types
import Hasura.Server.Auth (AuthMode (..),
getUserInfo)
import Hasura.Server.Context
import Hasura.Server.Cors
import Hasura.Server.Init
import Hasura.Server.Logging
@ -146,11 +147,12 @@ logError
logError userInfoM req reqBody sc qErr =
logResult userInfoM req reqBody sc (Left qErr) Nothing
mkSpockAction
:: (MonadIO m)
=> (Bool -> QErr -> Value)
-> ServerCtx
-> Handler BL.ByteString
-> Handler HResponse
-> ActionT m ()
mkSpockAction qErrEncoder serverCtx handler = do
req <- request
@ -169,7 +171,8 @@ mkSpockAction qErrEncoder serverCtx handler = do
t2 <- liftIO getCurrentTime -- for measuring response time purposes
-- log result
logResult (Just userInfo) req reqBody serverCtx result $ Just (t1, t2)
logResult (Just userInfo) req reqBody serverCtx (_hrBody <$> result) $
Just (t1, t2)
either (qErrToResp $ userRole userInfo == adminRole) resToResp result
where
@ -184,8 +187,9 @@ mkSpockAction qErrEncoder serverCtx handler = do
logError Nothing req reqBody serverCtx qErr
qErrToResp includeInternal qErr
resToResp resp = do
resToResp (HResponse resp mHdrs) = do
uncurry setHeader jsonHeader
onJust mHdrs $ mapM_ (uncurry setHeader . unHeader)
lazyBytes resp
withLock :: (MonadIO m, MonadError e m)
@ -200,11 +204,12 @@ withLock lk action = do
acquireLock = liftIO $ takeMVar lk
releaseLock = liftIO $ putMVar lk ()
v1QueryHandler :: RQLQuery -> Handler BL.ByteString
v1QueryHandler :: RQLQuery -> Handler HResponse
v1QueryHandler query = do
lk <- scCacheLock . hcServerCtx <$> ask
bool (fst <$> dbAction) (withLock lk dbActionReload) $
res <- bool (fst <$> dbAction) (withLock lk dbActionReload) $
queryNeedsReload query
return $ HResponse res Nothing
where
-- Hit postgres
dbAction = do
@ -230,7 +235,7 @@ v1QueryHandler query = do
liftIO $ writeIORef scRef newSc'
return resp
v1Alpha1GQHandler :: GH.GraphQLRequest -> Handler BL.ByteString
v1Alpha1GQHandler :: GH.GraphQLRequest -> Handler HResponse
v1Alpha1GQHandler query = do
userInfo <- asks hcUser
reqBody <- asks hcReqBody
@ -242,14 +247,15 @@ v1Alpha1GQHandler query = do
isoL <- scIsolation . hcServerCtx <$> ask
GH.runGQ pool isoL userInfo sc manager reqHeaders query reqBody
gqlExplainHandler :: GE.GQLExplain -> Handler BL.ByteString
gqlExplainHandler :: GE.GQLExplain -> Handler HResponse
gqlExplainHandler query = do
onlyAdmin
scRef <- scCacheRef . hcServerCtx <$> ask
sc <- liftIO $ readIORef scRef
pool <- scPGPool . hcServerCtx <$> ask
isoL <- scIsolation . hcServerCtx <$> ask
GE.explainGQLQuery pool isoL sc query
res <- GE.explainGQLQuery pool isoL sc query
return $ HResponse res Nothing
newtype QueryParser
= QueryParser { getQueryParser :: QualifiedTable -> Handler RQLQuery }
@ -271,7 +277,7 @@ queryParsers =
q <- decodeValue val
return $ f q
legacyQueryHandler :: TableName -> T.Text -> Handler BL.ByteString
legacyQueryHandler :: TableName -> T.Text -> Handler HResponse
legacyQueryHandler tn queryType =
case M.lookup queryType queryParsers of
Just queryParser -> getQueryParser queryParser qt >>= v1QueryHandler

View File

@ -219,12 +219,7 @@ userInfoFromAuthHook logger manager hook reqHeaders = do
(Just $ HttpException err) Nothing
throw500 "Internal Server Error"
filteredHeaders = flip filter reqHeaders $ \(n, _) ->
n `notElem` [ "Content-Length", "Content-MD5", "User-Agent", "Host"
, "Origin", "Referer" , "Accept", "Accept-Encoding"
, "Accept-Language", "Accept-Datetime"
, "Cache-Control", "Connection", "DNT"
]
filteredHeaders = filterRequestHeaders reqHeaders
getUserInfo
@ -241,7 +236,7 @@ getUserInfo logger manager rawHeaders = \case
AMAdminSecret adminScrt unAuthRole ->
case adminSecretM of
Just givenAdminScrt -> userInfoWhenAdminSecret adminScrt givenAdminScrt
Nothing -> userInfoWhenNoAdminSecret unAuthRole
Nothing -> userInfoWhenNoAdminSecret unAuthRole
AMAdminSecretAndHook accKey hook ->
whenAdminSecretAbsent accKey (userInfoFromAuthHook logger manager hook rawHeaders)

View File

@ -0,0 +1,22 @@
module Hasura.Server.Context
( HResponse(..)
, Header (..)
, Headers
)
where
import Hasura.Prelude
import qualified Data.ByteString.Lazy as BL
newtype Header
= Header { unHeader :: (Text, Text) }
deriving (Show, Eq)
type Headers = [Header]
data HResponse
= HResponse
{ _hrBody :: !BL.ByteString
, _hrHeaders :: !(Maybe Headers)
} deriving (Show, Eq)

View File

@ -9,11 +9,13 @@ import System.Exit
import System.Process
import qualified Data.ByteString as B
import qualified Data.HashSet as Set
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import qualified Data.Text.Encoding.Error as TE
import qualified Data.Text.IO as TI
import qualified Language.Haskell.TH.Syntax as TH
import qualified Network.HTTP.Types as HTTP
import qualified Text.Ginger as TG
import qualified Text.Regex.TDFA as TDFA
import qualified Text.Regex.TDFA.ByteString as TDFA
@ -134,3 +136,31 @@ matchRegex regex caseSensitive src =
fmapL :: (a -> a') -> Either a b -> Either a' b
fmapL fn (Left e) = Left (fn e)
fmapL _ (Right x) = pure x
-- ignore the following request headers from the client
filterRequestHeaders :: [HTTP.Header] -> [HTTP.Header]
filterRequestHeaders = filterHeaders reqHeaders
where
reqHeaders = Set.fromList
[ "Content-Length", "Content-MD5", "User-Agent", "Host"
, "Origin", "Referer" , "Accept", "Accept-Encoding"
, "Accept-Language", "Accept-Datetime"
, "Cache-Control", "Connection", "DNT"
]
-- ignore the following response headers from remote
filterResponseHeaders :: [HTTP.Header] -> [HTTP.Header]
filterResponseHeaders = filterHeaders respHeaders
where
respHeaders = Set.fromList
[ "Server", "Transfer-Encoding", "Cache-Control"
, "Access-Control-Allow-Credentials"
, "Access-Control-Allow-Methods"
, "Access-Control-Allow-Origin"
, "Content-Type"
]
filterHeaders :: Set.HashSet HTTP.HeaderName -> [HTTP.Header] -> [HTTP.Header]
filterHeaders list = filter (\(n, _) -> not $ n `Set.member` list)

View File

@ -169,7 +169,30 @@ class PersonGraphQL(RequestHandler):
res = person_schema.execute(req.json['query'])
return mkJSONResp(res)
#GraphQL server with interfaces
# GraphQL server that returns Set-Cookie response header
class SampleAuth(graphene.ObjectType):
hello = graphene.String(arg=graphene.String(default_value="world"))
def resolve_hello(self, info, arg):
return "Hello " + arg
sample_auth_schema = graphene.Schema(query=SampleAuth,
subscription=SampleAuth)
class SampleAuthGraphQL(RequestHandler):
def get(self, request):
return Response(HTTPStatus.METHOD_NOT_ALLOWED)
def post(self, request):
if not request.json:
return Response(HTTPStatus.BAD_REQUEST)
res = hello_schema.execute(request.json['query'])
resp = mkJSONResp(res)
resp.headers['Set-Cookie'] = 'abcd'
return resp
# GraphQL server with interfaces
class Character(graphene.Interface):
id = graphene.ID(required=True)
@ -567,15 +590,15 @@ class EchoGraphQL(RequestHandler):
if not req.json:
return Response(HTTPStatus.BAD_REQUEST)
res = echo_schema.execute(req.json['query'])
respDict = res.to_dict()
typesList = respDict.get('data',{}).get('__schema',{}).get('types',None)
resp_dict = res.to_dict()
types_list = resp_dict.get('data',{}).get('__schema',{}).get('types', None)
#Hack around enum default_value serialization issue: https://github.com/graphql-python/graphql-core/issues/166
if typesList is not None:
for t in filter(lambda ty: ty['name'] == 'EchoQuery', typesList):
if types_list is not None:
for t in filter(lambda ty: ty['name'] == 'EchoQuery', types_list):
for f in filter(lambda fld: fld['name'] == 'echo', t['fields']):
for a in filter(lambda arg: arg['name'] == 'enumInput', f['args']):
a['defaultValue'] = 'RED'
return Response(HTTPStatus.OK, respDict,
return Response(HTTPStatus.OK, resp_dict,
{'Content-Type': 'application/json'})
handlers = MkHandlers({
@ -597,7 +620,8 @@ handlers = MkHandlers({
'/union-graphql-err-no-member-types' : UnionGraphQLSchemaErrNoMemberTypes,
'/union-graphql-err-wrapped-type' : UnionGraphQLSchemaErrWrappedType,
'/default-value-echo-graphql' : EchoGraphQL,
'/person-graphql': PersonGraphQL
'/person-graphql': PersonGraphQL,
'/auth-graphql': SampleAuthGraphQL
})

View File

@ -0,0 +1,13 @@
description: Check response headers from remote
url: /v1alpha1/graphql
status: 200
response:
data:
hello: Hello me
response_headers:
'Set-Cookie': abcd
query:
query: |
query {
hello(arg: "me")
}

View File

@ -228,6 +228,33 @@ class TestAddRemoteSchemaTbls:
assert st_code == 200, resp
class TestRemoteSchemaResponseHeaders():
teardown = {"type": "clear_metadata", "args": {}}
dir = 'queries/remote_schemas'
@pytest.fixture(autouse=True)
def transact(self, hge_ctx):
q = mk_add_remote_q('sample-auth', 'http://localhost:5000/auth-graphql')
st_code, resp = hge_ctx.v1q(q)
assert st_code == 200, resp
yield
hge_ctx.v1q(self.teardown)
def test_response_headers_from_remote(self, hge_ctx):
headers = {}
if hge_ctx.hge_key:
headers = {'x-hasura-admin-secret': hge_ctx.hge_key}
q = {'query': 'query { hello (arg: "me") }'}
resp = hge_ctx.http.post(hge_ctx.hge_url + '/v1alpha1/graphql', json=q,
headers=headers)
assert resp.status_code == 200
print(resp.headers)
assert ('Set-Cookie' in resp.headers and
resp.headers['Set-Cookie'] == 'abcd')
res = resp.json()
assert res['data']['hello'] == "Hello me"
class TestAddRemoteSchemaCompareRootQueryFields:
remote = 'http://localhost:5000/default-value-echo-graphql'