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

IHP GraphQL Standalone #1392

Open
wants to merge 60 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
d50f175
Merge branch 'master' into ihp-graphql
mpscholten Mar 5, 2022
701bad8
Disabled not useful parts of the navigation
mpscholten Mar 5, 2022
e0e322c
Added input types to graphql schema
mpscholten Mar 5, 2022
bc411ef
Merge branch 'ihp-graphql' into ihp-graphql-standalone
mpscholten Mar 5, 2022
466ebb7
Added missing modules to cabal file
mpscholten Mar 6, 2022
3e765c6
Merge branch 'ihp-graphql' into ihp-graphql-standalone
mpscholten Mar 6, 2022
276349b
Merge branch 'graphql' into ihp-graphql-standalone
mpscholten Mar 6, 2022
f5045c1
Initial prototype of the standalone GraphQL edition of IHP
mpscholten Mar 8, 2022
52842de
Added GraphQL Standalone IHP App
mpscholten Mar 8, 2022
0ee64df
Moved GraphQL standalone app to it's own repo
mpscholten Mar 9, 2022
b1a01dc
Bundle Graph Explorer JS Assets during nix build
mpscholten Mar 9, 2022
772b333
Merge remote-tracking branch 'origin/master' into ihp-graphql-standalone
mpscholten Mar 10, 2022
7005c28
Disable nix sandbox for build as we need npm to work
mpscholten Mar 10, 2022
9aeb054
Merge remote-tracking branch 'origin/master' into ihp-graphql-standalone
mpscholten Mar 10, 2022
03ef59f
Fixed GraphQL queries returning null on empty result set
mpscholten Mar 12, 2022
4909b54
Added basic support for live queries
mpscholten Mar 13, 2022
a98adc9
added new module to cabal file
mpscholten Mar 14, 2022
525140f
Merge remote-tracking branch 'origin/master' into ihp-graphql-standalone
mpscholten Mar 15, 2022
1f919ab
Enable autoPolicy for user_id column button
mpscholten Mar 15, 2022
29959b7
Implemented graphql-ws protocol
mpscholten Mar 16, 2022
87d0bbb
Added new modules to cabal file
mpscholten Mar 16, 2022
efb192e
fixed missing jwt dep
mpscholten Mar 16, 2022
499a201
Also make jwt available for tests
mpscholten Mar 17, 2022
1f6ff94
Allow customizing the DATABASE_URL for the migration generator
mpscholten Mar 19, 2022
6422095
Fixed migration erorr handling not working as expected when the migra…
mpscholten Mar 19, 2022
31d6ea2
Improved dark mode of GraphiQL
mpscholten Mar 20, 2022
a3a596c
Fixed Graph Explorer disappear when doubleclicking the GRAPH button
mpscholten Mar 20, 2022
cd8c3fb
Fixed GraphiQL Query Variables & Headers tab not always in view
mpscholten Mar 21, 2022
b5f40b8
Added user selector to GraphQL explorer
mpscholten Mar 21, 2022
d777d49
Improved placement of copyright
mpscholten Mar 22, 2022
536b595
Fixed help popover styling and links
mpscholten Mar 22, 2022
25893cf
Added favicon and changed titte
mpscholten Mar 22, 2022
93614d3
Merge remote-tracking branch 'origin/master' into ihp-graphql-standalone
mpscholten Mar 22, 2022
41793f0
Merge branch 'master' into ihp-graphql-standalone
mpscholten Mar 23, 2022
ede753b
Removed compiled JS files
mpscholten Mar 23, 2022
fccb3ba
GraphQL errors are returned as json
mpscholten Mar 23, 2022
30a01be
Updated graph explorer
mpscholten Mar 23, 2022
a285442
Improved error handling for GraphQL queries over WS
mpscholten Mar 23, 2022
2a88ce7
Fixed error handling
mpscholten Mar 23, 2022
d0f681e
Fixed GraphQL query sometimes returning multiple rows
mpscholten Mar 23, 2022
b7fbc80
Fixed "Deselecting last field for query doesn't remove query from exp…
mpscholten Mar 23, 2022
65cb66b
Persist GraphiQL query
mpscholten Mar 23, 2022
9634c73
Added /api prefix to grapql-ws endpoint
mpscholten Mar 24, 2022
294ca42
Improved colors of docs in Grapql explorer
mpscholten Mar 24, 2022
e0c4828
Added support for the GraphQL introspection query
mpscholten Mar 25, 2022
2744751
Added new modules to cabal file
mpscholten Mar 25, 2022
6dc117c
Removed introspection test
mpscholten Mar 25, 2022
a67c2f9
Fixed generated sql statements causing a cross join which sometimes n…
mpscholten Mar 27, 2022
9494af6
Fixed GraphQLWS responses not wrapped in "data" attribute and ids wrong
mpscholten Mar 27, 2022
685567e
Added support for __typename selections
mpscholten Mar 27, 2022
b24c439
Fixed row level security accidentally checked for table "createUser" …
mpscholten Mar 27, 2022
b2284eb
Support __typename also in mutation results
mpscholten Mar 27, 2022
dadedee
Fixed mutation results not nested as expected
mpscholten Mar 27, 2022
072f7a8
Fixed RLS security not correctly dealing with single access tables
mpscholten Mar 27, 2022
c4d60cf
Fixed 'extractRecordById' not returning correct results for nested ob…
mpscholten Mar 28, 2022
ce4b77c
Added `user(id: ..)` to GraphQL schema. Fixes https://github.com/digi…
mpscholten Mar 28, 2022
a0f5936
Fixed recordIdsInSelection not working with single record nodes
mpscholten Mar 28, 2022
a1c5d78
Fixed nested has one queries not working as join is missing
mpscholten Mar 29, 2022
ff2a592
pdated tests
mpscholten Mar 29, 2022
20c8ff9
fixed tablesUsedInDocument not correctly pluralizing when dealing wit…
mpscholten Mar 29, 2022
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
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,5 +41,5 @@ jobs:
cat default.nix
mv Makefile Makefile.old
echo 'GHC_OPTIONS+= -rtsopts=all\n.SHELLFLAGS := -eu -o pipefail -c\n\n'|cat - Makefile.old > Makefile
nix-shell --run "new-application Web && make build/bin/RunUnoptimizedProdServer"
nix-shell --option sandbox false --run "new-application Web && make build/bin/RunUnoptimizedProdServer"

17 changes: 16 additions & 1 deletion IHP/Controller/Redirect.hs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import IHP.Controller.RequestContext
import IHP.RouterSupport (HasPath (pathTo))
import IHP.FrameworkConfig
import Network.HTTP.Types.Status
import qualified Network.Wai as Wai

import IHP.Controller.Context
import IHP.ControllerSupport
Expand All @@ -43,7 +44,7 @@ redirectTo action = redirectToPath (pathTo action)
--
-- Use 'redirectTo' if you want to redirect to a controller action.
redirectToPath :: (?context :: ControllerContext) => Text -> IO ()
redirectToPath path = redirectToUrl (fromConfig baseUrl <> path)
redirectToPath path = redirectToUrl (appHost <> path)
{-# INLINABLE redirectToPath #-}

-- | Redirects to a url (given as a string)
Expand All @@ -65,6 +66,20 @@ redirectToUrl url = do
respondAndExit redirectResponse
{-# INLINABLE redirectToUrl #-}

appHost :: (?context :: ControllerContext) => Text
appHost =
let
request = ?context
|> get #requestContext
|> get #request
protocol = if Wai.isSecure request
then "https"
else "http"
in request
|> Wai.requestHeaderHost
|> \case
Just host -> protocol <> "://" <> cs host
Nothing -> fromConfig baseUrl

-- | Redirects back to the last page
--
Expand Down
88 changes: 88 additions & 0 deletions IHP/DataSync/Controller.hs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import qualified Data.Pool as Pool
import qualified IHP.GraphQL.Types as GraphQL
import qualified IHP.GraphQL.Parser as GraphQL
import qualified IHP.GraphQL.Compiler as GraphQL
import qualified IHP.GraphQL.Analysis as GraphQL
import IHP.GraphQL.JSON ()
import qualified Data.Attoparsec.Text as Attoparsec

Expand Down Expand Up @@ -68,6 +69,8 @@ instance (
Left parserError -> error (cs $ tshow parserError)
Right statements -> statements

ensureRLSEnabledForGraphQLDocument ensureRLSEnabled document

let [(theQuery, theParams)] = GraphQL.compileDocument variables document

[PG.Only graphQLResult] <- sqlQueryWithRLSAndTransactionId transactionId theQuery theParams
Expand Down Expand Up @@ -154,6 +157,84 @@ instance (

sendJSON DidDeleteDataSubscription { subscriptionId, requestId }

handleMessage CreateGraphQLLiveQuery { requestId, gql, variables } = do
let document = case Attoparsec.parseOnly GraphQL.parseDocument gql of
Left parserError -> error (cs $ tshow parserError)
Right statements -> statements

tablesRLS <- ensureRLSEnabledForGraphQLDocument ensureRLSEnabled document

-- Fetch the initial data
let [(theQuery, theParams)] = GraphQL.compileDocument variables document
[PG.Only (UndecodedJSON graphQLResultText)] <- sqlQueryWithRLSAndTransactionId Nothing theQuery theParams

let (Just graphQLResult) = Aeson.decode (cs graphQLResultText)

-- We need to keep track of all the ids of entities we're watching to make
-- sure that we only send update notifications to clients that can actually
-- access the record (e.g. if a RLS policy denies access)
let watchedRecordIds = GraphQL.recordIds document graphQLResult

-- Store it in IORef as an INSERT requires us to add an id
watchedRecordIdsRef <- newIORef watchedRecordIds

-- Make sure the database triggers are there
forEach tablesRLS installTableChangeTriggers

liveQueryId <- UUID.nextRandom

let callback table notification = case notification of
ChangeNotifications.DidInsert { id } -> do
-- The new record could not be accessible to the current user with a RLS policy
-- E.g. it could be a new record in a 'projects' table, but the project belongs
-- to a different user, and thus the current user should not be able to see it.
--
-- The new record could also be not part of the WHERE condition of the initial query.
-- Therefore we need to use the subscriptions WHERE condition to fetch the new record here.
--
-- To honor the RLS policies we therefore need to fetch the record as the current user
-- If the result set is empty, we know the record is not accesible to us
[PG.Only (UndecodedJSON graphQLResultText)] <- sqlQueryWithRLSAndTransactionId Nothing theQuery theParams
let (Just graphQLResult) = Aeson.decode (cs graphQLResultText)

case GraphQL.extractRecordById id graphQLResult of
Just newRecord -> do
-- Add the new record to 'watchedRecordIdsRef'
-- Otherwise the updates and deletes will not be dispatched to the client
modifyIORef' watchedRecordIdsRef (HashMap.adjust (Set.insert id) table)

sendJSON LiveQueryDidInsert { liveQueryId, newRecord, table }
Nothing -> pure ()
ChangeNotifications.DidUpdate { id, changeSet } -> do
-- Only send the notifcation if the deleted record was part of the initial
-- results set
isWatchingRecord <- Set.member id . HashMap.lookupDefault Set.empty table <$> readIORef watchedRecordIdsRef
when isWatchingRecord do
sendJSON LiveQueryDidUpdate { liveQueryId, id, changeSet = changesToValue changeSet }
ChangeNotifications.DidDelete { id } -> do
-- Only send the notifcation if the deleted record was part of the initial
-- results set
isWatchingRecord <- Set.member id . HashMap.lookupDefault Set.empty table <$> readIORef watchedRecordIdsRef
when isWatchingRecord do
sendJSON LiveQueryDidDelete { liveQueryId, table, id }

let startWatchers tablesRLS = case tablesRLS of
(tableNameRLS:rest) -> do
let subscribe = PGListener.subscribeJSON (ChangeNotifications.channelName tableNameRLS) (callback (get #tableName tableNameRLS)) pgListener
let unsubscribe subscription = PGListener.unsubscribe subscription pgListener

Exception.bracket subscribe unsubscribe (\_ -> startWatchers rest)
[] -> do
close <- MVar.newEmptyMVar
modifyIORef' ?state (\state -> state |> modify #subscriptions (HashMap.insert liveQueryId close))

-- sendJSON DidCreateDataSubscription { subscriptionId, requestId, result }
sendJSON DidCreateLiveQuery { liveQueryId, graphQLResult, requestId }

MVar.takeMVar close

startWatchers tablesRLS

handleMessage CreateRecordMessage { table, record, requestId, transactionId } = do
ensureRLSEnabled table

Expand Down Expand Up @@ -453,6 +534,13 @@ sqlExecWithRLSAndTransactionId ::
) => Maybe UUID -> PG.Query -> parameters -> IO Int64
sqlExecWithRLSAndTransactionId transactionId theQuery theParams = runInModelContextWithTransaction (sqlExecWithRLS theQuery theParams) transactionId

ensureRLSEnabledForGraphQLDocument :: _ -> GraphQL.Document -> IO [TableWithRLS]
ensureRLSEnabledForGraphQLDocument ensureRLSEnabled document = do
let tables = document
|> GraphQL.tablesUsedInDocument
|> Set.toList
mapM ensureRLSEnabled tables

$(deriveFromJSON defaultOptions 'DataSyncQuery)
$(deriveToJSON defaultOptions 'DataSyncResult)

Expand Down
27 changes: 21 additions & 6 deletions IHP/DataSync/REST/Controller.hs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ import qualified Data.Aeson.Encoding.Internal as Aeson
import qualified IHP.GraphQL.Types as GraphQL
import qualified IHP.GraphQL.Parser as GraphQL
import qualified IHP.GraphQL.Compiler as GraphQL
import qualified IHP.GraphQL.SchemaCompiler as GraphQL
import IHP.GraphQL.JSON ()
import qualified IHP.GraphQL.Resolver as GraphQL
import qualified IHP.IDE.SchemaDesigner.Parser as SchemaDesigner
import qualified Data.Attoparsec.Text as Attoparsec

instance (
Expand Down Expand Up @@ -156,14 +159,17 @@ instance (

action GraphQLQueryAction = do
graphQLRequest :: GraphQL.GraphQLRequest <- case fromJSON requestBodyJSON of
Error errorMessage -> error (cs errorMessage)
Error errorMessage -> do
renderJson GraphQL.GraphQLErrorResponse { errors = [ cs errorMessage ] }
pure undefined -- Unreachable
Data.Aeson.Success value -> pure value

let [(theQuery, theParams)] = GraphQL.compileDocument (get #variables graphQLRequest) (get #query graphQLRequest)

[PG.Only graphQLResult] <- sqlQueryWithRLS theQuery theParams

renderJson (graphQLResult :: UndecodedJSON)
(Right sqlSchema) <- SchemaDesigner.parseSchemaSql
let schema = GraphQL.sqlSchemaToGraphQLSchema sqlSchema
result <- handleGraphQLError (GraphQL.resolve schema sqlQueryWithRLS graphQLRequest)
case result of
(Left error) -> renderJson error
(Right graphQLResult) -> renderJson graphQLResult

buildDynamicQueryFromRequest table = DynamicSQLQuery
{ table
Expand Down Expand Up @@ -254,3 +260,12 @@ instance ToJSON GraphQLResult where
instance ToJSON UndecodedJSON where
toJSON (UndecodedJSON _) = error "Not implemented"
toEncoding (UndecodedJSON json) = Aeson.unsafeToEncoding (ByteString.byteString json)

handleGraphQLError runGraphQLHandler = do
result <- Exception.try runGraphQLHandler
pure case result of
Left (exception :: SomeException) ->
case Exception.fromException exception of
Just (exception :: EnhancedSqlError) -> Left GraphQL.GraphQLErrorResponse { errors = [ cs $ get #sqlErrorMsg (get #sqlError exception) ] }
Nothing -> Left GraphQL.GraphQLErrorResponse { errors = [ tshow exception ] }
Right result -> Right result
31 changes: 17 additions & 14 deletions IHP/DataSync/RowLevelSecurity.hs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ module IHP.DataSync.RowLevelSecurity
, makeCachedEnsureRLSEnabled
, sqlQueryWithRLS
, sqlExecWithRLS
, sqlQueryWithRLS'
)
where

Expand Down Expand Up @@ -33,11 +34,21 @@ sqlQueryWithRLS ::
, PG.ToField userId
, FromRow result
) => PG.Query -> parameters -> IO [result]
sqlQueryWithRLS query parameters = sqlQuery queryWithRLS parametersWithRLS
where
(queryWithRLS, parametersWithRLS) = wrapStatementWithRLS query parameters
sqlQueryWithRLS query parameters = sqlQueryWithRLS' (get #id <$> currentUserOrNothing) query parameters
{-# INLINE sqlQueryWithRLS #-}

sqlQueryWithRLS' ::
( ?modelContext :: ModelContext
, PG.ToRow parameters
, PG.ToField userId
, FromRow result
, ?context :: ControllerContext
) => Maybe userId -> PG.Query -> parameters -> IO [result]
sqlQueryWithRLS' userId query parameters = sqlQuery queryWithRLS parametersWithRLS
where
(queryWithRLS, parametersWithRLS) = wrapStatementWithRLS userId query parameters
{-# INLINE sqlQueryWithRLS' #-}

sqlExecWithRLS ::
( ?modelContext :: ModelContext
, PG.ToRow parameters
Expand All @@ -52,27 +63,19 @@ sqlExecWithRLS ::
) => PG.Query -> parameters -> IO Int64
sqlExecWithRLS query parameters = sqlExec queryWithRLS parametersWithRLS
where
(queryWithRLS, parametersWithRLS) = wrapStatementWithRLS query parameters
(queryWithRLS, parametersWithRLS) = wrapStatementWithRLS (get #id <$> currentUserOrNothing) query parameters
{-# INLINE sqlExecWithRLS #-}

wrapStatementWithRLS ::
( ?modelContext :: ModelContext
, PG.ToRow parameters
, ?context :: ControllerContext
, userId ~ Id CurrentUserRecord
, Show (PrimaryKey (GetTableName CurrentUserRecord))
, HasNewSessionUrl CurrentUserRecord
, Typeable CurrentUserRecord
, ?context :: ControllerContext
, HasField "id" CurrentUserRecord (Id' (GetTableName CurrentUserRecord))
, PG.ToField userId
) => PG.Query -> parameters -> (PG.Query, [PG.Action])
wrapStatementWithRLS query parameters = (queryWithRLS, parametersWithRLS)
) => Maybe userId -> PG.Query -> parameters -> (PG.Query, [PG.Action])
wrapStatementWithRLS maybeUserId query parameters = (queryWithRLS, parametersWithRLS)
where
queryWithRLS = "SET LOCAL ROLE ?; SET LOCAL rls.ihp_user_id = ?; " <> query <> ";"

maybeUserId = get #id <$> currentUserOrNothing

-- When the user is not logged in and maybeUserId is Nothing, we cannot
-- just pass @NULL@ to postgres. The @SET LOCAL@ values can only be strings.
--
Expand Down
9 changes: 8 additions & 1 deletion IHP/DataSync/Types.hs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import qualified IHP.PGListener as PGListener
import qualified Database.PostgreSQL.Simple as PG
import Control.Concurrent.MVar as MVar
import qualified IHP.GraphQL.Types as GraphQL

import qualified Data.Aeson as Aeson

data DataSyncMessage
= DataSyncQuery { query :: !DynamicSQLQuery, requestId :: !Int, transactionId :: !(Maybe UUID) }
Expand All @@ -25,6 +25,8 @@ data DataSyncMessage
| StartTransaction { requestId :: !Int }
| RollbackTransaction { requestId :: !Int, id :: !UUID }
| CommitTransaction { requestId :: !Int, id :: !UUID }
| CreateGraphQLLiveQuery { gql :: !Text, requestId :: !Int, variables :: !GraphQL.Variables }
| DeleteGraphQLLiveQuery { liveQueryId :: !UUID, requestId :: !Int }
deriving (Eq, Show)

data DataSyncResponse
Expand All @@ -45,6 +47,11 @@ data DataSyncResponse
| DidStartTransaction { requestId :: !Int, transactionId :: !UUID }
| DidRollbackTransaction { requestId :: !Int, transactionId :: !UUID }
| DidCommitTransaction { requestId :: !Int, transactionId :: !UUID }
| DidCreateLiveQuery { requestId :: !Int, liveQueryId :: !UUID, graphQLResult :: !Aeson.Value }
| DidDeleteLiveQuery { requestId :: !Int, liveQueryId :: !UUID }
| LiveQueryDidInsert { liveQueryId :: !UUID, newRecord :: !Aeson.Value, table :: !Text }
| LiveQueryDidUpdate { liveQueryId :: !UUID, id :: UUID, changeSet :: !Value }
| LiveQueryDidDelete { liveQueryId :: !UUID, id :: !UUID, table :: !Text }

data GraphQLResult = GraphQLResult { graphQLResult :: !UndecodedJSON, requestId :: !Int }

Expand Down
Loading