diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 38118dae1..e3c465905 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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" diff --git a/IHP/Controller/Redirect.hs b/IHP/Controller/Redirect.hs index 8d8754f45..4d41db7a7 100644 --- a/IHP/Controller/Redirect.hs +++ b/IHP/Controller/Redirect.hs @@ -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 @@ -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) @@ -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 -- diff --git a/IHP/DataSync/Controller.hs b/IHP/DataSync/Controller.hs index 4673bf525..c1e4706e5 100644 --- a/IHP/DataSync/Controller.hs +++ b/IHP/DataSync/Controller.hs @@ -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 @@ -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 @@ -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 @@ -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) diff --git a/IHP/DataSync/REST/Controller.hs b/IHP/DataSync/REST/Controller.hs index f16ef5196..4a194ca11 100644 --- a/IHP/DataSync/REST/Controller.hs +++ b/IHP/DataSync/REST/Controller.hs @@ -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 ( @@ -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 @@ -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 diff --git a/IHP/DataSync/RowLevelSecurity.hs b/IHP/DataSync/RowLevelSecurity.hs index 77bbeb6ef..91df00f5b 100644 --- a/IHP/DataSync/RowLevelSecurity.hs +++ b/IHP/DataSync/RowLevelSecurity.hs @@ -5,6 +5,7 @@ module IHP.DataSync.RowLevelSecurity , makeCachedEnsureRLSEnabled , sqlQueryWithRLS , sqlExecWithRLS +, sqlQueryWithRLS' ) where @@ -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 @@ -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. -- diff --git a/IHP/DataSync/Types.hs b/IHP/DataSync/Types.hs index 0a0652ff4..814600991 100644 --- a/IHP/DataSync/Types.hs +++ b/IHP/DataSync/Types.hs @@ -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) } @@ -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 @@ -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 } diff --git a/IHP/GraphQL/Analysis.hs b/IHP/GraphQL/Analysis.hs new file mode 100644 index 000000000..3c942660d --- /dev/null +++ b/IHP/GraphQL/Analysis.hs @@ -0,0 +1,226 @@ +module IHP.GraphQL.Analysis where + +import IHP.Prelude +import IHP.GraphQL.Types + +import Data.Set (Set) +import qualified Data.Set as Set +import Data.HashMap.Strict (HashMap) +import qualified Data.HashMap.Strict as HashMap +import qualified Data.Aeson as Aeson +import qualified Data.Aeson.Internal as Aeson +import Data.Aeson ((.:)) +import qualified Data.Vector as Vector +import qualified Data.UUID as UUID +import qualified Data.List as List +import qualified Data.Text as Text + +type TableName = Text + +-- | Returns the database tables used by a GraphQL query +tablesUsedInDocument :: Document -> Set TableName +tablesUsedInDocument Document { definitions } = mconcat (map tablesUsedInDefinition definitions) + where + tablesUsedInDefinition :: Definition -> Set Text + tablesUsedInDefinition ExecutableDefinition { operation } = tablesUsedInOperation operation + + tablesUsedInOperation :: OperationDefinition -> Set Text + tablesUsedInOperation OperationDefinition { selectionSet, operationType } = tablesUsedInSelectionSet operationType selectionSet + + tablesUsedInSelectionSet :: OperationType -> [Selection] -> Set Text + tablesUsedInSelectionSet operationType selectionSet = mconcat (map (tablesUsedInSelection operationType) selectionSet) + + tablesUsedInSelection :: OperationType -> Selection -> Set Text + tablesUsedInSelection _ Field { selectionSet = [] } = Set.empty + tablesUsedInSelection operationType Field { name, selectionSet, arguments } = Set.singleton normalizedName <> tablesUsedInSelectionSet Query selectionSet + where + -- `createTask` => tasks + -- `deleteTask` => tasks + -- `updateTask` => tasks + normalizedName = case operationType of + Mutation -> + case Text.stripPrefix "create" name of + Just suffix -> modelNameToTableName suffix + Nothing -> case Text.stripPrefix "delete" name of + Just suffix -> modelNameToTableName suffix + Nothing -> case Text.stripPrefix "update" name of + Just suffix -> modelNameToTableName suffix + Nothing -> name + _ -> case selectionSet of + [] -> name + _ -> pluralize name -- `project(id: $projectId)` => projects + + +recordIds :: Document -> Aeson.Value -> HashMap TableName (Set UUID) +recordIds Document { definitions } result = mconcat (map recordIdsInDefinition definitions) + where + recordIdsInDefinition :: Definition -> HashMap TableName (Set UUID) + recordIdsInDefinition ExecutableDefinition { operation } = recordIdsInOperation operation + + recordIdsInOperation :: OperationDefinition -> HashMap TableName (Set UUID) + recordIdsInOperation OperationDefinition { selectionSet } = recordIdsInSelectionSet selectionSet result + + recordIdsInSelectionSet :: [Selection] -> Aeson.Value -> HashMap TableName (Set UUID) + recordIdsInSelectionSet selectionSet result = mconcat (map (recordIdsInSelection result) selectionSet) + + recordIdsInSelection :: Aeson.Value -> Selection -> HashMap TableName (Set UUID) + recordIdsInSelection result Field { selectionSet = [] } = HashMap.empty + recordIdsInSelection result Field { name, alias, selectionSet } = mconcat $ + (HashMap.singleton tableName selectionIds):(map (recordIdsInSelection selectedResult) childNodes) + where + (selectionIds, tableName) = selectionIdsAndName + + aliasOrName :: Text + aliasOrName = fromMaybe name alias + + childNodes = selectionSet + |> filter selectionIsNode + + selectedResult :: Aeson.Value + selectedResult = case result of + Aeson.Object hashMap -> hashMap + |> HashMap.lookup aliasOrName + |> \case + Just result -> result + Nothing -> error ("Could not find " <> tshow aliasOrName <> " in result set") + Aeson.Array vector -> vector + |> Vector.toList + |> map (\case + Aeson.Object hashMap -> hashMap + |> HashMap.lookup aliasOrName + |> \case + Just (Aeson.Array result) -> result + Nothing -> error ("Could not find " <> tshow aliasOrName <> " in result set") + otherwise -> error ("selectedResult -> array: Object expxected") + ) + |> map Vector.toList + |> concat + |> Vector.fromList + |> Aeson.Array + otherwise -> error ("selectedResult at " <> name <> ": Expected an object here, got: " <> tshow otherwise) + + selectionIdsAndName :: (Set UUID, Text) + selectionIdsAndName = case selectedResult of + Aeson.Array vector -> + vector + |> Vector.toList + |> map (\case + Aeson.Object record -> extractId record + otherwise -> error ("selectionIds: unexpected " <> tshow selectedResult) + ) + |> Set.fromList + |> \ids -> (ids, name) + Aeson.Object hashMap -> (Set.singleton (extractId hashMap), pluralize name) + _ -> error "unexpected object here" + + extractId :: HashMap Text Aeson.Value -> UUID + extractId record = record + |> HashMap.lookup "id" + |> \case + Just (Aeson.String string) -> + case UUID.fromText string of + Just uuid -> uuid + Nothing -> error "Failed to parse UUID" + Just otherwise -> error "Expected 'id' field to be a string" + Nothing -> error "Could not find 'id' field for record" + +selectionIsNode :: Selection -> Bool +selectionIsNode Field { selectionSet = [] } = False +selectionIsNode otherwise = True + +extractRecordById :: UUID -> Aeson.Value -> Maybe Aeson.Value +extractRecordById id result = + case result of + record@(Aeson.Object hashMap) -> + let traverseObjectKeys = + hashMap + |> HashMap.elems + |> mapMaybe (extractRecordById id) + |> headMay + in case HashMap.lookup "id" hashMap of + Just (Aeson.String idString) -> + case UUID.fromText idString of + Just uuid -> if uuid == id + then Just record + else traverseObjectKeys + Nothing -> error "Failed to parse UUID" + otherwise -> traverseObjectKeys + Aeson.Array vector -> + vector + |> Vector.toList + |> mapMaybe (extractRecordById id) + |> headMay + otherwise -> Nothing + +isSubscriptionDocument :: Document -> Bool +isSubscriptionDocument Document { definitions } = foldl' (&&) True (map isSubscriptionDefinition definitions) + where + isSubscriptionDefinition ExecutableDefinition { operation = OperationDefinition { operationType } } = operationType == Subscription + +newtype Path = Path [Text] + deriving (Eq, Show) + +nodePathsForTable :: Text -> Document -> [Path] +nodePathsForTable tableName Document { definitions } = reversePath <$> mconcat (map nodePathsForTableDefinition definitions) + where + -- e.g. "users" or "userProjects" + targetSelectionName = lcfirst $ tableNameToControllerName tableName + + reversePath :: Path -> Path + reversePath (Path path) = (Path (reverse path)) + + nodePathsForTableDefinition :: Definition -> [Path] + nodePathsForTableDefinition ExecutableDefinition { operation } = nodePathsForTableOperation operation + + nodePathsForTableOperation :: OperationDefinition -> [Path] + nodePathsForTableOperation OperationDefinition { selectionSet } = nodePathsForTableSelectionSet [] selectionSet + + nodePathsForTableSelectionSet :: [Text] -> [Selection] -> [Path] + nodePathsForTableSelectionSet path selectionSet = mconcat (map (nodePathsForTableSelection path) selectionSet) + + nodePathsForTableSelection :: [Text] -> Selection -> [Path] + nodePathsForTableSelection path Field { selectionSet = [] } = [] + nodePathsForTableSelection path Field { name, alias, selectionSet } = + let + nameOrAlias = (fromMaybe name alias) + cur = Path (nameOrAlias:path) + rec = nodePathsForTableSelectionSet (nameOrAlias:path) selectionSet + in + if name == targetSelectionName + then cur:rec + else rec + +applyFunctionAtNode :: (Aeson.Value -> Aeson.Value) -> Path -> Aeson.Value -> Aeson.Value +applyFunctionAtNode function (Path path) json = applyFunctionAtNode' function path json + where + applyFunctionAtNode' function [] value = function value + applyFunctionAtNode' function (curPath:restPath) (Aeson.Object hashMap) = Aeson.Object (HashMap.adjust (applyFunctionAtNode' function restPath) curPath hashMap) + applyFunctionAtNode' function path (Aeson.Array vector) = Aeson.Array (Vector.map (applyFunctionAtNode' function path) vector) + +documentIsExecutable :: Document -> Bool +documentIsExecutable Document { definitions } = isJust (find isExecutableDefinition definitions) + +isExecutableDefinition :: Definition -> Bool +isExecutableDefinition ExecutableDefinition {} = True +isExecutableDefinition _ = False + +splitDocumentIntoResolvableUnits :: Document -> [(Resolver, Document)] +splitDocumentIntoResolvableUnits Document { definitions } = removeEmptyResolvers $ split [] [] definitions + where + isPostgresSelection Field { name = "__schema" } = False + isPostgresSelection otherwise = True + + removeEmptyResolvers :: [(Resolver, Document)] -> [(Resolver, Document)] + removeEmptyResolvers documentsWithResolver = filter (\(resolver, document) -> documentIsExecutable document) documentsWithResolver + + split :: [Definition] -> [Definition] -> [Definition] -> [(Resolver, Document)] + split postgresDefinitions introspectionDefinitions (ed@(ExecutableDefinition { operation = od@(OperationDefinition { selectionSet }) }):rest) = + case List.partition isPostgresSelection selectionSet of + (postgresSelection, []) -> split (postgresDefinitions <> [ed]) introspectionDefinitions rest + ([], introspectionSelection) -> split postgresDefinitions (introspectionDefinitions <> [ed]) rest + (postgresSelection, introspectionSelection) -> split (postgresDefinitions <> [ ExecutableDefinition { operation = od { selectionSet = postgresSelection } } ]) (introspectionDefinitions <> [ ExecutableDefinition { operation = od { selectionSet = introspectionSelection } } ]) rest + split postgresDefinitions introspectionDefinitions (x:xs) = + if isExecutableDefinition x + then split (postgresDefinitions <> [x]) introspectionDefinitions xs + else split (postgresDefinitions <> [x]) (introspectionDefinitions <> [x]) xs -- E.g. fragments need to be in both queries + split postgresDefinitions introspectionDefinitions [] = [(PostgresResolver, Document postgresDefinitions), (IntrospectionResolver, Document introspectionDefinitions)] \ No newline at end of file diff --git a/IHP/GraphQL/Compiler.hs b/IHP/GraphQL/Compiler.hs index 329e45751..24b39f937 100644 --- a/IHP/GraphQL/Compiler.hs +++ b/IHP/GraphQL/Compiler.hs @@ -2,12 +2,14 @@ module IHP.GraphQL.Compiler where import IHP.Prelude import IHP.GraphQL.Types +import qualified IHP.GraphQL.Introspection as Introspection import qualified Database.PostgreSQL.Simple.ToField as PG import qualified Database.PostgreSQL.Simple.Types as PG import Prelude (Semigroup (..)) import qualified Data.Text as Text import qualified Data.HashMap.Strict as HashMap +import qualified Data.List as List data SqlQuery = SqlQuery { query :: Text, params :: [PG.Action]} @@ -16,31 +18,44 @@ data QueryPart = QueryPart { sql :: PG.Query, params :: [PG.Action] } compileDocument :: Variables -> Document -> [(PG.Query, [PG.Action])] compileDocument (Variables arguments) document@(Document { definitions = (definition:rest) }) = case definition of - ExecutableDefinition { operation = OperationDefinition { operationType = Query } } -> - [ unpackQueryPart ("SELECT to_json(_root.data) FROM (" <> compileDefinition document definition arguments <> ") AS _root") ] + ExecutableDefinition { operation = OperationDefinition { operationType } } | operationType == Query || operationType == Subscription -> + [ unpackQueryPart (compileDefinition document definition arguments) ] ExecutableDefinition { operation = OperationDefinition { operationType = Mutation } } -> - map unpackQueryPart $ compileMutationDefinition definition arguments + map unpackQueryPart $ compileMutationDefinition document definition arguments compileDefinition :: Document -> Definition -> [Argument] -> QueryPart -compileDefinition document ExecutableDefinition { operation = OperationDefinition { operationType = Query, selectionSet } } variables = - selectionSet - |> map (compileSelection document variables) - |> unionAll +compileDefinition document ExecutableDefinition { operation = OperationDefinition { operationType, selectionSet } } variables | operationType == Query || operationType == Subscription = + "SELECT json_build_object(" <> commaSep aggregations <> ")" + where + aggregations = map (compileSelection document variables) selectionSet -compileMutationDefinition :: Definition -> [Argument] -> [QueryPart] -compileMutationDefinition ExecutableDefinition { operation = OperationDefinition { operationType = Mutation, selectionSet } } arguments = +compileMutationDefinition :: Document -> Definition -> [Argument] -> [QueryPart] +compileMutationDefinition document ExecutableDefinition { operation = OperationDefinition { operationType = Mutation, selectionSet } } arguments = selectionSet - |> map (compileMutationSelection arguments) + |> map ((compileMutationSelection document) arguments) compileSelection :: Document -> [Argument] -> Selection -> QueryPart compileSelection document variables field@(Field { alias, name = fieldName, arguments }) = - ("(SELECT json_build_object(?, json_agg(?.*)) AS data FROM (SELECT " |> withParams [PG.toField nameOrAlias, PG.toField (PG.Identifier subqueryId)]) - <> selectQueryPieces document (PG.toField (PG.Identifier tableName)) field - <> (" FROM ?" |> withParams [PG.toField (PG.Identifier tableName)]) - <> joins - <> where_ - <> (") AS ?)" |> withParams [ PG.toField (PG.Identifier subqueryId) ]) + aggregation where + query = + "(SELECT " + <> selectQueryPieces document tableName field + <> (" FROM ?" |> withParams [PG.toField (PG.Identifier tableName)]) + <> joins document variables tableName field + <> where_ + <> (") AS ?" |> withParams [ PG.toField (PG.Identifier subqueryId) ]) + + -- | Builds a tuple as used in `json_build_object('users', json_agg(_users), 'tasks', json_agg(_tasks))` + aggregation = ( + if isSingleResult + then + (("?, (SELECT coalesce(row_to_json(?), '[]'::json) FROM " |> withParams [PG.toField nameOrAlias, PG.toField (PG.Identifier subqueryId)]) <> query <> ")") + else + (("?, (SELECT coalesce(json_agg(row_to_json(?)), '[]'::json) FROM " |> withParams [PG.toField nameOrAlias, PG.toField (PG.Identifier subqueryId)]) <> query <> ")")) + + isSingleResult = isJust idArgument + subqueryId = "_" <> fieldName nameOrAlias = fromMaybe fieldName alias @@ -58,79 +73,115 @@ compileSelection document variables field@(Field { alias, name = fieldName, argu [Argument { argumentName = "id", argumentValue }] -> Just argumentValue _ -> Nothing - +joins :: Document -> [Argument] -> Text -> Selection -> QueryPart +joins document variables tableName field = field + |> get #selectionSet + |> filter isJoinField + |> map (fieldToJoin document variables tableName) + |> \case + [] -> "" + joins -> " " <> spaceSep joins + where isJoinField :: Selection -> Bool isJoinField Field { selectionSet } = not (null selectionSet) isJoinField FragmentSpread {} = False -- TODO: Also support fragment spreads in joined tables - joins :: QueryPart - joins = field - |> get #selectionSet - |> filter isJoinField - |> map (fieldToJoin document tableName) - |> \case - [] -> "" - joins -> " " <> spaceSep joins -selectQueryPieces :: Document -> PG.Action -> Selection -> QueryPart -selectQueryPieces document tableName field = field +selectQueryPieces :: Document -> Text -> Selection -> QueryPart +selectQueryPieces document tableName field = + selectFields document tableName field + |> map (\(left, right, isAlias) -> if isAlias + then left <> " AS ?" |> withParams [PG.toField (PG.Identifier right)] + else left + ) + |> commaSep + +returnQueryPieces :: Document -> Text -> Selection -> QueryPart +returnQueryPieces document tableName field = + selectFields document tableName field + |> map (\(left, right, isAlias) -> ("?, " |> withParams [PG.toField right]) <> left ) + |> commaSep + +selectFields :: Document -> Text -> Selection -> [(QueryPart, Text, Bool)] +selectFields document tableName field = + field |> get #selectionSet |> map compileSelection |> mconcat - |> commaSep where + qualified :: Selection -> QueryPart qualified field = if isEmpty (get #selectionSet field) - then "?." |> withParams [tableName] + then "?." |> withParams [PG.toField (PG.Identifier tableName)] else "" - compileSelection :: Selection -> [QueryPart] + compileSelection :: Selection -> [(QueryPart, Text, Bool)] compileSelection field@(Field {}) = [compileField field] compileSelection fragmentSpread@(FragmentSpread {}) = compileFragmentSpread fragmentSpread - compileField :: Selection -> QueryPart - compileField field@(Field { alias = Just alias, name }) = qualified field <> "? AS ?" |> withParams [ PG.toField (PG.Identifier (fieldNameToColumnName name)), PG.toField (PG.Identifier alias) ] - compileField field@(Field { alias = Nothing, name }) = + compileField :: Selection -> (QueryPart, Text, Bool) + compileField field@(Field { alias, name = "__typename" }) = + ( "?" |> withParams [ PG.toField typeName ] + , fromMaybe "__typename" alias + , alias /= "__typename" + ) + where + typeName = tableNameToModelName tableName + compileField field@(Field { alias = Just alias, name }) = + ( qualified field <> ("?" |> withParams [ PG.toField (PG.Identifier (fieldNameToColumnName name)) ]) + , alias + , True + ) + compileField field@(Field { alias = Nothing, name }) = let columnName = fieldNameToColumnName name in - if columnName /= name - then qualified field <> "? AS ?" |> withParams [ PG.toField (PG.Identifier (fieldNameToColumnName name)), PG.toField (PG.Identifier name) ] - else qualified field <> "?" |> withParams [ PG.toField (PG.Identifier (fieldNameToColumnName name)) ] + ( qualified field <> ("?" |> withParams [ PG.toField (PG.Identifier (fieldNameToColumnName name)) ]) + , name + , columnName /= name + ) - compileFragmentSpread :: Selection -> [QueryPart] + compileFragmentSpread :: Selection -> [(QueryPart, Text, Bool)] compileFragmentSpread FragmentSpread { fragmentName } = fragment |> get #selectionSet |> map compileSelection |> mconcat where - fragment :: Fragment - fragment = document - |> get #definitions - |> find (\case - FragmentDefinition (Fragment { name }) -> name == fragmentName - otherwise -> False - ) - |> fromMaybe (error $ "Could not find fragment named " <> fragmentName) - |> \case - FragmentDefinition fragment -> fragment - -fieldToJoin :: Document -> Text -> Selection -> QueryPart -fieldToJoin document rootTableName field@(Field { name }) = + fragment = findFragmentByName document fragmentName + +fieldToJoin :: Document -> [Argument] -> Text -> Selection -> QueryPart +fieldToJoin document variables rootTableName field@(Field { name }) = "LEFT JOIN LATERAL (" - <> "SELECT ARRAY(" - <> "SELECT to_json(_sub) FROM (" + <> when isHasMany "SELECT ARRAY(" + <> when isHasMany "SELECT to_json(_sub) FROM (" <> "SELECT " - <> selectQueryPieces document foreignTable field + <> selectQueryPieces document foreignTableName field <> (" FROM ?" |> withParams [foreignTable]) - <> (" WHERE ?.? = ?.?" |> withParams [foreignTable, foreignTableForeignKey, rootTable, rootTablePrimaryKey]) - <> ") AS _sub" - <> (") AS ?" |> withParams [aliasOrName]) + <> joins document variables rootTableName field + <> (" WHERE ?.? = ?.?" |> withParams conditionParams) + <> when isHasMany ") AS _sub" + <> when isHasMany (") AS ?" |> withParams [aliasOrName]) <> (") ? ON true" |> withParams [aliasOrName]) where - foreignTable = PG.toField (PG.Identifier name) + isHasOne :: Bool + isHasOne = singularize name == name -- Is it a singular name, like `user` instead of `users`? + + isHasMany = not isHasOne + + conditionParams = if isHasMany + then [foreignTable, foreignTableForeignKey, rootTable, rootTablePrimaryKey] + else [foreignTable, PG.toField (PG.Identifier "id"), rootTable, PG.toField (PG.Identifier $ (fieldNameToColumnName name) <> "_id" )] + + when condition then_ = if condition then then_ else "" + + foreignTable = PG.toField (PG.Identifier foreignTableName) + foreignTableName = + if isHasOne + then pluralize name + else name + foreignTableForeignKey = PG.toField (PG.Identifier foreignTableForeignKeyName) foreignTableForeignKeyName = rootTableName |> singularize @@ -143,19 +194,19 @@ fieldToJoin document rootTableName field@(Field { name }) = Just alias -> alias Nothing -> get #name field -compileMutationSelection :: [Argument] -> Selection -> QueryPart -compileMutationSelection queryArguments field@(Field { alias, name = fieldName, arguments, selectionSet }) = fromMaybe (error ("Invalid mutation: " <> tshow fieldName)) do +compileMutationSelection :: Document -> [Argument] -> Selection -> QueryPart +compileMutationSelection document queryArguments field@(Field { alias, name = fieldName, arguments, selectionSet }) = fromMaybe (error ("Invalid mutation: " <> tshow fieldName)) do let create = do modelName <- Text.stripPrefix "create" fieldName - pure $ compileSelectionToInsertStatement queryArguments field modelName + pure $ compileSelectionToInsertStatement document queryArguments field modelName let delete = do modelName <- Text.stripPrefix "delete" fieldName - pure $ compileSelectionToDeleteStatement queryArguments field modelName + pure $ compileSelectionToDeleteStatement document queryArguments field modelName let update = do modelName <- Text.stripPrefix "update" fieldName - pure $ compileSelectionToUpdateStatement queryArguments field modelName + pure $ compileSelectionToUpdateStatement document queryArguments field modelName create <|> delete <|> update @@ -182,8 +233,8 @@ compileMutationSelection queryArguments field@(Field { alias, name = fieldName, -- > VALUES ('dc984c2f-d91c-4143-9091-400ad2333f83', 'Hello World') -- > RETURNING json_build_object('id', projects.id, 'title', projects.title) -- -compileSelectionToInsertStatement :: [Argument] -> Selection -> Text -> QueryPart -compileSelectionToInsertStatement queryArguments field@(Field { alias, name = fieldName, arguments, selectionSet }) modelName = +compileSelectionToInsertStatement :: Document -> [Argument] -> Selection -> Text -> QueryPart +compileSelectionToInsertStatement document queryArguments field@(Field { alias, name = fieldName, arguments, selectionSet }) modelName = ("INSERT INTO ? (" |> withParams [PG.toField $ PG.Identifier tableName]) <> commaSep columns <> ") VALUES (" <> commaSep values <> ") RETURNING " <> returning where tableName = modelNameToTableName modelName @@ -204,10 +255,7 @@ compileSelectionToInsertStatement queryArguments field@(Field { alias, name = fi |> unzip returning :: QueryPart - returning = "json_build_object(" <> returningArgs <> ")" - returningArgs = selectionSet - |> map (\Field { name = fieldName } -> "?, ?.?" |> withParams [PG.toField (fieldNameToColumnName fieldName), PG.toField (PG.Identifier tableName), PG.toField (PG.Identifier (fieldNameToColumnName fieldName))]) - |> commaSep + returning = ("json_build_object(?, json_build_object(" |> withParams [PG.toField (nameOrAlias field) ]) <> returnQueryPieces document tableName field <> "))" -- | Turns a @update..@ mutation into a UPDATE SQL query -- @@ -234,8 +282,8 @@ compileSelectionToInsertStatement queryArguments field@(Field { alias, name = fi -- > WHERE id = 'df1f54d5-ced6-4f65-8aea-fcd5ea6b9df1' -- > RETURNING json_build_object('id', projects.id, 'title', projects.title) -- -compileSelectionToUpdateStatement :: [Argument] -> Selection -> Text -> QueryPart -compileSelectionToUpdateStatement queryArguments field@(Field { alias, name = fieldName, arguments, selectionSet }) modelName = +compileSelectionToUpdateStatement :: Document -> [Argument] -> Selection -> Text -> QueryPart +compileSelectionToUpdateStatement document queryArguments field@(Field { alias, name = fieldName, arguments, selectionSet }) modelName = ("UPDATE ? SET " |> withParams [PG.toField $ PG.Identifier tableName]) <> commaSep setValues <> where_ <> " RETURNING " <> returning where tableName = modelNameToTableName modelName @@ -258,10 +306,7 @@ compileSelectionToUpdateStatement queryArguments field@(Field { alias, name = fi |> map (\(fieldName, value) -> ("? = ?" |> withParams [PG.toField (PG.Identifier (fieldNameToColumnName fieldName)), valueToSQL value])) returning :: QueryPart - returning = "json_build_object(" <> returningArgs <> ")" - returningArgs = selectionSet - |> map (\Field { name = fieldName } -> "?, ?.?" |> withParams [PG.toField (fieldNameToColumnName fieldName), PG.toField (PG.Identifier tableName), PG.toField (PG.Identifier (fieldNameToColumnName fieldName))]) - |> commaSep + returning = ("json_build_object(?, json_build_object(" |> withParams [PG.toField (nameOrAlias field) ]) <> returnQueryPieces document tableName field <> "))" -- | Turns a @delete..@ mutation into a DELETE SQL query -- @@ -283,8 +328,8 @@ compileSelectionToUpdateStatement queryArguments field@(Field { alias, name = fi -- > WHERE project_id = 'dc984c2f-d91c-4143-9091-400ad2333f83' -- > RETURNING json_build_object('id', projects.id, 'title', projects.title) -- -compileSelectionToDeleteStatement :: [Argument] -> Selection -> Text -> QueryPart -compileSelectionToDeleteStatement queryArguments field@(Field { alias, name = fieldName, arguments, selectionSet }) modelName = +compileSelectionToDeleteStatement :: Document -> [Argument] -> Selection -> Text -> QueryPart +compileSelectionToDeleteStatement document queryArguments field@(Field { alias, name = fieldName, arguments, selectionSet }) modelName = ("DELETE FROM ? WHERE id = ?" |> withParams [PG.toField $ PG.Identifier tableName, recordId]) <> " RETURNING " <> returning where tableName = modelNameToTableName modelName @@ -294,10 +339,7 @@ compileSelectionToDeleteStatement queryArguments field@(Field { alias, name = fi Nothing -> error $ "Expected first argument to " <> fieldName <> " to be an ID, got no arguments" returning :: QueryPart - returning = "json_build_object(" <> returningArgs <> ")" - returningArgs = selectionSet - |> map (\Field { name = fieldName } -> "?, ?.?" |> withParams [PG.toField (fieldNameToColumnName fieldName), PG.toField (PG.Identifier tableName), PG.toField (PG.Identifier (fieldNameToColumnName fieldName))]) - |> commaSep + returning = ("json_build_object(?, json_build_object(" |> withParams [PG.toField (nameOrAlias field) ]) <> returnQueryPieces document tableName field <> "))" valueToSQL :: Value -> PG.Action valueToSQL (IntValue int) = PG.toField int @@ -313,11 +355,58 @@ resolveVariables (Variable varName) arguments = Nothing -> error ("Could not resolve variable " <> varName) resolveVariables otherwise _ = otherwise +compileIntrospectionSelection :: GraphQLSchema -> Document -> [Argument] -> Selection -> (Maybe QueryPart, QueryPart) +compileIntrospectionSelection schema document variables field@(Field { name, selectionSet }) = (Nothing, aggregation) + where + + aggregation = ("?, json_build_object(" |> withParams [PG.toField (nameOrAlias field)]) <> buildSchemaSelection <> ")" + + buildSchemaSelection = commaSep (map (compileSchemaSelection (Introspection.introspectionGraph schema)) selectionSet) + + compileSchemaSelection :: StaticGraph -> Selection -> QueryPart + compileSchemaSelection graph field@(Field { name, selectionSet = [] }) = + let + targetLeaf :: Value + targetLeaf = graph + |> (\case + ObjectNode { objectValues } -> objectValues + otherwise -> error $ "expected object node, got " <> tshow otherwise + ) + |> HashMap.lookup name + |> \case + Just (Leaf value) -> value + otherwise -> error $ "expected leaf node at " <> name <> ", got " <> tshow otherwise <> " in graph " <> tshow graph + in + "?, ?" |> withParams [PG.toField (nameOrAlias field), PG.toField targetLeaf] + compileSchemaSelection graph field@(Field { name, selectionSet }) = + let + targetNode :: StaticGraph + targetNode = graph + |> (\case + ObjectNode { objectValues } -> objectValues + otherwise -> error $ "expected object node, got " <> tshow otherwise + ) + |> HashMap.lookup name + |> fromMaybe (error $ "Could not find node " <> name) + in + case targetNode of + ObjectNode {} -> ("?, json_build_object(" |> withParams [PG.toField (nameOrAlias field)]) <> commaSep (map (compileSchemaSelection targetNode) selectionSet) <> ")" + ArrayNode { arrayElements } -> ("?, json_build_object(" |> withParams [PG.toField (nameOrAlias field)]) <> commaSep (map (\targetNode -> commaSep (map (compileSchemaSelection targetNode) selectionSet)) arrayElements) <> ")" + Leaf { value = NullValue } -> "?, null" |> withParams [PG.toField (nameOrAlias field)] + otherwise -> error $ "Expected object or array, got " <> tshow otherwise <> " while trying to access " <> name + compileSchemaSelection graph FragmentSpread { fragmentName } = + let + fragment = findFragmentByName document fragmentName + selectionSet = get #selectionSet fragment + in + commaSep (map (compileSchemaSelection graph) selectionSet) + + unionAll :: [QueryPart] -> QueryPart unionAll list = foldl' (\a b -> if get #sql a == "" then b else a <> " UNION ALL " <> b) "" list commaSep :: [QueryPart] -> QueryPart -commaSep list = foldl' (\a b -> if get #sql a == "" then b else a <> ", " <> b) "" list +commaSep list = foldl' (\a b -> if get #sql a == "" then b else (a <> ", " <> b)) "" list spaceSep :: [QueryPart] -> QueryPart spaceSep list = foldl' (\a b -> if get #sql a == "" then b else a <> " " <> b) "" list @@ -334,4 +423,29 @@ unpackQueryPart :: QueryPart -> (PG.Query, [PG.Action]) unpackQueryPart QueryPart { sql, params } = (sql, params) withParams :: [PG.Action] -> QueryPart -> QueryPart -withParams params queryPart = queryPart { params = (get #params queryPart) <> params } \ No newline at end of file +withParams params queryPart = queryPart { params = (get #params queryPart) <> params } + +nameOrAlias :: Selection -> Text +nameOrAlias field = fromMaybe (get #name field) (get #alias field) + +findFragmentByName :: Document -> Text -> Fragment +findFragmentByName document name = + let + allFragmentNames = document + |> get #definitions + |> mapMaybe (\case FragmentDefinition (Fragment { name }) -> Just name; _ -> Nothing) + couldNotFindFragmentErrorMessage = "Could not find fragment named " <> name <> ". These fragments are defined: " <> Text.intercalate ", " allFragmentNames + in + document + |> get #definitions + |> find (\case + FragmentDefinition (Fragment { name = fragmentName }) -> name == fragmentName + otherwise -> False + ) + |> fromMaybe (error couldNotFindFragmentErrorMessage) + |> \case + FragmentDefinition fragment -> fragment + +instance PG.ToField Value where + toField (StringValue string) = PG.toField string + toField NullValue = PG.toField (Nothing :: Maybe Int) \ No newline at end of file diff --git a/IHP/GraphQL/GraphQLWS.hs b/IHP/GraphQL/GraphQLWS.hs new file mode 100644 index 000000000..65cd250a4 --- /dev/null +++ b/IHP/GraphQL/GraphQLWS.hs @@ -0,0 +1,362 @@ +{-| +Module: IHP.GraphQL.GraphQLWS +Description: Implements a WebSocket server for graphql-ws as described in https://github.com/enisdenjo/graphql-ws/blob/master/PROTOCOL.md +Copyright: (c) digitally induced GmbH, 2020 +-} +module IHP.GraphQL.GraphQLWS where + +import IHP.Prelude +import IHP.GraphQL.Types +import qualified Data.Attoparsec.ByteString.Char8 as Attoparsec + +import IHP.ApplicationContext (ApplicationContext) +import qualified IHP.ApplicationContext as ApplicationContext +import IHP.ControllerPrelude hiding (Error) +import Network.Wai +import qualified Network.Wai.Handler.WebSockets as WebSockets +import qualified Network.WebSockets as WebSockets +import qualified IHP.WebSocket as WebSockets +import qualified Network.HTTP.Types as HTTP +import qualified IHP.Controller.Context as Context +import qualified IHP.Controller.RequestContext +import qualified IHP.Log as Log +import qualified Control.Exception as Exception + +import qualified Data.Aeson as Aeson +import qualified Data.Aeson.Encoding.Internal as Aeson + +import Data.HashMap.Strict (HashMap) +import qualified Data.HashMap.Strict as HashMap +import Control.Concurrent.MVar as MVar + +import qualified Database.PostgreSQL.Simple as PG + +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 qualified IHP.GraphQL.Patch as GraphQL +import IHP.GraphQL.JSON () +import qualified Data.Attoparsec.Text as AttoparsecText +import IHP.DataSync.RowLevelSecurity +import IHP.DataSync.DynamicQuery +import IHP.DataSync.REST.Controller () +import IHP.DataSync.Controller (changesToValue) +import qualified IHP.DataSync.ChangeNotifications as ChangeNotifications +import Data.Set (Set) +import qualified Data.Set as Set +import qualified IHP.PGListener as PGListener +import qualified Web.JWT as JWT +import qualified Data.UUID as UUID + +-- | Cannot be implemented natively in IHP as we need to accept the @graphql-transport-ws@ sub protocol +routeGraphQLWS :: + ( ?application :: application + , ?applicationContext :: ApplicationContext + , ?context :: RequestContext + ) => Attoparsec.Parser (IO ResponseReceived) +routeGraphQLWS = do + Attoparsec.string "/api/graphql-ws" + Attoparsec.endOfInput + + let ?modelContext = ApplicationContext.modelContext ?applicationContext + let ?requestContext = ?context + let respond = ?context |> get #respond + let request = ?context |> get #request + let acceptRequest = WebSockets.AcceptRequest { acceptSubprotocol = "graphql-transport-ws", acceptHeaders = [] } + + let handleConnection pendingConnection = do + connection <- WebSockets.acceptRequestWith pendingConnection acceptRequest + + controllerContext <- Context.newControllerContext + let ?context = controllerContext + + WebSockets.startWSApp @GraphQLWSApp connection + + pure $ request + |> WebSockets.websocketsApp WebSockets.defaultConnectionOptions handleConnection + |> \case + Just response -> respond response + Nothing -> respond $ responseLBS HTTP.status400 [(HTTP.hContentType, "text/plain")] "This endpoint is only available via a WebSocket" + +data GraphQLWSApp = GraphQLWSApp + { subscriptions :: !(HashMap UUID (MVar.MVar ())) + , asyncs :: ![Async ()] + } + +-- | Messages according to https://github.com/enisdenjo/graphql-ws/blob/master/PROTOCOL.md +data Message + = ConnectionInit { connectionInitPayload :: HashMap Text Aeson.Value } -- ^ Direction: Client -> Server + | ConnectionAck -- ^ Direction: Server -> Client + | Ping -- ^ Direction: bidirectional + | Pong -- ^ Direction: bidirectional + | Subscribe + { id :: !UUID + , operationName :: !(Maybe Text) + , query :: !Text + , variables :: !(Maybe GraphQL.Variables) + , extensions :: !(Maybe Aeson.Value) + } -- ^ Direction: Client -> Server + | Next { id :: !UUID, nextPayload :: UndecodedJSON } -- ^ Direction: Server -> Client + | Error { id :: !UUID, errorPayload :: [Text] } -- ^ Direction: Server -> Client + | Complete { id :: !UUID } -- ^ Direction: bidirectional + deriving (Show) + +instance WSApp GraphQLWSApp where + initialState = GraphQLWSApp { subscriptions = HashMap.empty, asyncs = [] } + + run = do + userIdVar <- newIORef Nothing + ensureRLSEnabled <- makeCachedEnsureRLSEnabled + installTableChangeTriggers <- ChangeNotifications.makeCachedInstallTableChangeTriggers + let pgListener = ?applicationContext |> get #pgListener + + forever do + message <- Aeson.eitherDecodeStrict' <$> receiveData @ByteString + + case message of + Right decodedMessage -> do + Exception.mask \restore -> do + -- Handle the messages in an async way + -- This increases throughput as multiple queries can be fetched + -- in parallel + handlerProcess <- async $ restore do + result <- Exception.try (handleMessage userIdVar ensureRLSEnabled installTableChangeTriggers pgListener decodedMessage) + + case result of + Left (e :: Exception.SomeException) -> do + let errorMessage = case fromException e of + Just (enhancedSqlError :: EnhancedSqlError) -> cs (get #sqlErrorMsg (get #sqlError enhancedSqlError)) + Nothing -> cs (displayException e) + Log.error (tshow e) + error errorMessage + Right result -> pure () + + modifyIORef' ?state (\state -> state |> modify #asyncs (handlerProcess:)) + pure () + Left errorMessage -> error ("Invalid message: " <> cs errorMessage) + + onClose = cleanupAllSubscriptions + +cleanupAllSubscriptions :: _ => (?state :: IORef GraphQLWSApp, ?applicationContext :: ApplicationContext) => IO () +cleanupAllSubscriptions = do + state <- getState + + case state of + GraphQLWSApp { asyncs } -> forEach asyncs uninterruptibleCancel + _ -> pure () + +handleMessage :: (?state :: IORef GraphQLWSApp, ?connection :: WebSockets.Connection, ?modelContext :: ModelContext, ?context :: ControllerContext) => IORef (Maybe UUID) -> _ -> _ -> _ -> Message -> IO () +handleMessage userIdVar _ _ _ Ping = sendJSON Pong +handleMessage userIdVar _ _ _ ConnectionInit { connectionInitPayload } = do + initAuth userIdVar connectionInitPayload + sendJSON ConnectionAck +handleMessage userIdVar ensureRLSEnabled installTableChangeTriggers pgListener Subscribe { id, operationName, query, variables, extensions } = + let + subscriptionId = id + handleEnhancedSqlError (exception :: EnhancedSqlError) = sendJSON Error { id = id, errorPayload = [ cs $ get #sqlErrorMsg (get #sqlError exception) ] } + handleSomeException (exception :: SomeException) = sendJSON Error { id = id, errorPayload = [ tshow exception ] } + + handleError :: IO () -> IO () + handleError inner = (inner `Exception.catch` handleEnhancedSqlError) `catch` handleSomeException + in handleError do + let document = case AttoparsecText.parseOnly GraphQL.parseDocument query of + Left parserError -> error (cs $ tshow parserError) + Right statements -> statements + + tablesRLS <- ensureRLSEnabledForGraphQLDocument ensureRLSEnabled document + + let emptyVariables = GraphQL.Variables [] + let [(theQuery, theParams)] = GraphQL.compileDocument (fromMaybe emptyVariables variables) document + + userId <- readIORef userIdVar + [PG.Only (graphQLResult :: UndecodedJSON)] <- sqlQueryWithRLS' userId theQuery theParams + + if GraphQL.isSubscriptionDocument document + then do + ensureBelowSubscriptionsLimit + + let (UndecodedJSON graphQLResultText) = graphQLResult + let (Just decodedGraphQLResult) = Aeson.decode (cs graphQLResultText) + + -- We keep an in-memory version of the result to apply db changes to + graphVar <- newIORef decodedGraphQLResult + + -- 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 decodedGraphQLResult + + -- 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 + + 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)] <- sqlQueryWithRLS' userId theQuery theParams + let (Just graphQLResult) = Aeson.decode (cs graphQLResultText) + + case GraphQL.extractRecordById id graphQLResult of + Just (Aeson.Object 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) + + modifyIORef' graphVar (GraphQL.insertRecord table id newRecord document) + + nextPayload <- UndecodedJSON . cs .Aeson.encode <$> readIORef graphVar + sendJSON Next { id = subscriptionId, nextPayload } + _ -> 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 + let (Aeson.Object patch) = changesToValue changeSet + modifyIORef' graphVar (GraphQL.updateRecord table id patch document) + + nextPayload <- UndecodedJSON . cs . Aeson.encode <$> readIORef graphVar + sendJSON Next { id = subscriptionId, nextPayload } + 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 + modifyIORef' graphVar (GraphQL.deleteRecord table id document) + nextPayload <- UndecodedJSON . cs . Aeson.encode <$> readIORef graphVar + sendJSON Next { id = subscriptionId, nextPayload } + + 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 id close)) + + sendJSON Next { id, nextPayload = graphQLResult } + + MVar.takeMVar close + + startWatchers tablesRLS + else do + sendJSON Next { id, nextPayload = graphQLResult } + sendJSON Complete { id } + + pure () +handleMessage _ _ _ _ message = do + putStrLn (tshow message) + +instance FromJSON Message where + parseJSON = withObject "Message" $ \v -> do + type_ :: Text <- v .: "type" + + case type_ of + "connection_init" -> do + payload <- v .: "payload" + pure ConnectionInit { connectionInitPayload = payload } + "ping" -> pure Ping + "pong" -> pure Pong + "subscribe" -> do + id <- v .: "id" + payload <- v .: "payload" + operationName <- payload .:? "operationName" + query <- payload .: "query" + variables <- payload .:? "variables" + extensions <- payload .:? "extensions" + pure Subscribe { id, operationName, query, variables, extensions } + "complete" -> do + id <- v .: "id" + pure Complete { id } + type_ -> fail "Invalid type" + +instance ToJSON Message where + toJSON ConnectionAck = object [ "type" .= ("connection_ack" :: Text) ] + toJSON Ping = object [ "type" .= ("ping" :: Text) ] + toJSON Pong = object [ "type" .= ("pong" :: Text) ] + toJSON Next { id, nextPayload } = object [ "type" .= ("next" :: Text), "id" .= id, "payload" .= nextPayload ] + toJSON Error { id, errorPayload } = object [ "type" .= ("error" :: Text), "id" .= id, "payload" .= errorPayload ] + toJSON Complete { id } = object [ "type" .= ("complete" :: Text), "id" .= id ] + + toEncoding ConnectionAck = Aeson.unsafeToEncoding "{\"type\":\"connection_ack\"}" + toEncoding Ping = Aeson.unsafeToEncoding "{\"type\":\"ping\"}" + toEncoding Pong = Aeson.unsafeToEncoding "{\"type\":\"pong\"}" + toEncoding Next { id, nextPayload } = Aeson.econcat + [ Aeson.unsafeToEncoding "{\"type\":\"next\",\"id\":" + , Aeson.toEncoding id + , Aeson.unsafeToEncoding ",\"payload\":{\"data\":" + , toEncoding nextPayload + , Aeson.unsafeToEncoding "}}" + ] + toEncoding Error { id, errorPayload } = Aeson.econcat + [ Aeson.unsafeToEncoding "{\"type\":\"error\",\"id\":" + , Aeson.toEncoding id + , Aeson.unsafeToEncoding ",\"payload\":{\"data\":" + , toEncoding errorPayload + , Aeson.unsafeToEncoding "}}" + ] + toEncoding Complete { id } = Aeson.econcat + [ Aeson.unsafeToEncoding "{\"type\":\"complete\",\"id\":" + , Aeson.toEncoding id + , Aeson.unsafeToEncoding "}" + ] + + +instance SetField "subscriptions" GraphQLWSApp (HashMap UUID (MVar.MVar ())) where + setField subscriptions record = record { subscriptions } + +instance SetField "asyncs" GraphQLWSApp [Async ()] where + setField asyncs record = record { asyncs } + +ensureRLSEnabledForGraphQLDocument :: _ -> GraphQL.Document -> IO [TableWithRLS] +ensureRLSEnabledForGraphQLDocument ensureRLSEnabled document = do + let tables = document + |> GraphQL.tablesUsedInDocument + |> Set.toList + mapM ensureRLSEnabled tables + +ensureBelowSubscriptionsLimit :: (?state :: IORef GraphQLWSApp, ?context :: ControllerContext) => IO () +ensureBelowSubscriptionsLimit = do + subscriptions <- get #subscriptions <$> readIORef ?state + let subscriptionsCount = HashMap.size subscriptions + when (subscriptionsCount >= maxSubscriptionsPerConnection) do + error ("You've reached the subscriptions limit of " <> tshow maxSubscriptionsPerConnection <> " subscriptions") + +maxSubscriptionsPerConnection :: _ => Int +maxSubscriptionsPerConnection = + case getAppConfig @DataSyncMaxSubscriptionsPerConnection of + DataSyncMaxSubscriptionsPerConnection value -> value + +initAuth userIdVar options = do + let jwt = HashMap.lookup "jwt" options + case jwt of + Just (Aeson.String jwt) -> loginWithJWT userIdVar jwt + otherwise -> pure () + +loginWithJWT userIdVar jwt = do + let signature = JWT.decodeAndVerifySignature (getAppConfig @JWT.Signer) jwt + + case signature of + Just jwt -> do + let userId = jwt + |> JWT.claims + |> JWT.sub + |> fromMaybe (error "JWT missing sub") + |> JWT.stringOrURIToText + |> UUID.fromText + + writeIORef userIdVar userId + Nothing -> error "Invalid signature" \ No newline at end of file diff --git a/IHP/GraphQL/Introspection.hs b/IHP/GraphQL/Introspection.hs new file mode 100644 index 000000000..ca02a6f47 --- /dev/null +++ b/IHP/GraphQL/Introspection.hs @@ -0,0 +1,159 @@ +module IHP.GraphQL.Introspection where + +import IHP.Prelude +import IHP.GraphQL.Types +import qualified Data.HashMap.Strict as HashMap +import qualified Data.Text as Text + +resolveStaticGraph :: StaticGraph -> Document -> StaticGraph +resolveStaticGraph graph document@(Document { definitions = (ExecutableDefinition { operation = OperationDefinition { selectionSet } }:rest) }) = mergeManyNodes $ map (makeSubGraph graph) selectionSet + where + nameOrAlias :: Selection -> Text + nameOrAlias field = fromMaybe (get #name field) (get #alias field) + + findFragmentByName :: Document -> Text -> Fragment + findFragmentByName document name = + let + allFragmentNames = document + |> get #definitions + |> mapMaybe (\case FragmentDefinition (Fragment { name }) -> Just name; _ -> Nothing) + couldNotFindFragmentErrorMessage = "Could not find fragment named " <> name <> ". These fragments are defined: " <> Text.intercalate ", " allFragmentNames + in + document + |> get #definitions + |> find (\case + FragmentDefinition (Fragment { name = fragmentName }) -> name == fragmentName + otherwise -> False + ) + |> fromMaybe (error couldNotFindFragmentErrorMessage) + |> \case + FragmentDefinition fragment -> fragment + + makeSubGraph :: StaticGraph -> Selection -> StaticGraph + makeSubGraph graph field@(Field { name, selectionSet = [] }) = + let + targetLeaf = graph + |> (\case + ObjectNode { objectValues } -> objectValues + otherwise -> error $ "expected object node, got " <> tshow otherwise + ) + |> HashMap.lookup name + |> \case + Just leaf@(Leaf value) -> leaf + otherwise -> error $ "expected leaf node at " <> name <> ", got " <> tshow otherwise <> " in graph " <> tshow graph + in + ObjectNode { objectValues = HashMap.singleton (nameOrAlias field) targetLeaf } + makeSubGraph graph field@(Field { name, selectionSet }) = + let + targetNode :: StaticGraph + targetNode = graph + |> (\case + ObjectNode { objectValues } -> objectValues + otherwise -> error $ "expected object node, got " <> tshow otherwise + ) + |> HashMap.lookup name + |> fromMaybe (error $ "Could not find node " <> name) + in + case targetNode of + ObjectNode {} -> + ObjectNode { objectValues = HashMap.singleton (nameOrAlias field) (mergeManyNodes $ map (makeSubGraph targetNode) selectionSet) } + ArrayNode { arrayElements } -> + ObjectNode { objectValues = HashMap.singleton (nameOrAlias field) (ArrayNode (map (\targetNode -> mergeManyNodes $ map (makeSubGraph targetNode) selectionSet) arrayElements)) } + Leaf { value = NullValue } -> ObjectNode { objectValues = HashMap.singleton (nameOrAlias field) (Leaf NullValue) } + otherwise -> error $ "Expected object or array, got " <> tshow otherwise <> " while trying to access " <> name + makeSubGraph graph FragmentSpread { fragmentName } = + let + fragment = findFragmentByName document fragmentName + selectionSet = get #selectionSet fragment + in + mergeManyNodes (map (makeSubGraph graph) selectionSet) + +mergeNodes (ObjectNode { objectValues = a }) (ObjectNode { objectValues = b }) = ObjectNode { objectValues = HashMap.union b a } +mergeManyNodes = foldl' mergeNodes (ObjectNode HashMap.empty) + +introspectionGraph :: [Definition] -> StaticGraph +introspectionGraph definitions = + object + [ ("__schema", object + [ ("queryType", object [ ("name", Leaf (StringValue "Query")) ]) + , ("mutationType", object [ ("name", Leaf (StringValue "Mutation")) ] ) + , ("subscriptionType", object [ ("name", Leaf (StringValue "Subscription")) ] ) + , ("types", types definitions) + , ("directives", ArrayNode []) + ] + ) + ] + +types definitions = ArrayNode (mapMaybe introspectType definitions) + where + introspectType TypeSystemDefinition { typeSystemDefinition = TypeDefinition ObjectTypeDefinition { name, fieldDefinitions } } = + Just $ object + [ ("kind", Leaf (StringValue "OBJECT")) + , ("name", Leaf (StringValue name)) + , ("description", Leaf (StringValue "")) + , ("fields", ArrayNode (map introspectFieldDefinition fieldDefinitions)) + , ("inputFields", Leaf NullValue) + , ("interfaces", ArrayNode []) + , ("enumValues", Leaf NullValue) + , ("possibleTypes", Leaf NullValue) + ] + introspectType TypeSystemDefinition { typeSystemDefinition = TypeDefinition InputObjectTypeDefinition { name, fieldDefinitions } } = + Just $ object + [ ("kind", Leaf (StringValue "INPUT_OBJECT")) + , ("name", Leaf (StringValue name)) + , ("description", Leaf (StringValue "")) + , ("fields", Leaf NullValue) + , ("inputFields", ArrayNode (map introspectInputFieldDefinition fieldDefinitions)) + , ("interfaces", Leaf NullValue) + , ("enumValues", Leaf NullValue) + , ("possibleTypes", Leaf NullValue) + ] + introspectType _ = Nothing + +introspectInputFieldDefinition FieldDefinition { description, name, argumentsDefinition, type_ } = + object + [ ("name", Leaf (StringValue name)) + , ("description", Leaf (maybe NullValue StringValue description)) + , ("type", introspectType type_) + , ("defaultValue", Leaf NullValue) + ] + +introspectFieldDefinition FieldDefinition { description, name, argumentsDefinition, type_ } = + object + [ ("name", Leaf (StringValue name)) + , ("description", Leaf (maybe NullValue StringValue description)) + , ("args", ArrayNode (map introspectArgumentDefinition argumentsDefinition )) + , ("type", introspectType type_) + , ("isDeprecated", Leaf NullValue) + , ("deprecationReason", Leaf NullValue) + ] + +introspectArgumentDefinition ArgumentDefinition { name, argumentType, defaultValue } = + object + [ ("name", Leaf (StringValue name)) + , ("description", Leaf NullValue) + , ("type", introspectType argumentType) + , ("defaultValue", Leaf (fromMaybe NullValue defaultValue)) + ] + +introspectType :: Type -> StaticGraph +introspectType (NamedType name) = + object + [ ("kind", Leaf (StringValue "OBJECT")) + , ("name", Leaf (StringValue name)) + , ("ofType", Leaf NullValue) + ] +introspectType (ListType inner) = + object + [ ("kind", Leaf (StringValue "LIST")) + , ("name", Leaf NullValue) + , ("ofType", introspectType inner) + ] +introspectType (NonNullType inner) = + object + [ ("kind", Leaf (StringValue "NON_NULL")) + , ("name", Leaf NullValue) + , ("ofType", introspectType inner) + ] + +object values = ObjectNode (HashMap.fromList values) \ No newline at end of file diff --git a/IHP/GraphQL/JSON.hs b/IHP/GraphQL/JSON.hs index 7d6b3e9cf..cd4de9d81 100644 --- a/IHP/GraphQL/JSON.hs +++ b/IHP/GraphQL/JSON.hs @@ -4,7 +4,7 @@ import IHP.Prelude import qualified IHP.GraphQL.Types as GraphQL import qualified IHP.GraphQL.Parser as GraphQL import qualified Data.Aeson as Aeson -import Data.Aeson ((.:)) +import Data.Aeson ((.:), (.=)) import qualified Data.HashMap.Strict as HashMap import qualified Data.Attoparsec.Text as Attoparsec @@ -33,3 +33,11 @@ aesonValueToGraphQLValue (Aeson.String text) = GraphQL.StringValue text aesonValueToGraphQLValue (Aeson.Bool bool) = GraphQL.BooleanValue bool aesonValueToGraphQLValue (Aeson.Object hashMap) = GraphQL.ObjectValue (HashMap.map aesonValueToGraphQLValue hashMap) aesonValueToGraphQLValue Aeson.Null = GraphQL.NullValue + +instance Aeson.ToJSON GraphQL.GraphQLErrorResponse where + toJSON GraphQL.GraphQLErrorResponse { errors } = Aeson.object + [ "data" .= Aeson.Null + , "errors" .= map errorToObj errors + ] + where + errorToObj text = Aeson.object [ "message" .= text ] diff --git a/IHP/GraphQL/Parser.hs b/IHP/GraphQL/Parser.hs index 9be5432c3..d4cae64c2 100644 --- a/IHP/GraphQL/Parser.hs +++ b/IHP/GraphQL/Parser.hs @@ -12,7 +12,7 @@ import Data.Attoparsec.Text import qualified Data.HashMap.Strict as HashMap parseDocument :: Parser Document -parseDocument = Document <$> many1 parseDefinition +parseDocument = Document <$> (manyTill parseDefinition endOfInput) parseDefinition :: Parser Definition parseDefinition = skipSpace >> (executableDefinition <|> parseFragmentDefinition) @@ -40,7 +40,13 @@ parseFragmentDefinition = do skipSpace name <- parseName skipSpace - selectionSet <- parseSelectionSet + on <- option Nothing do + string "on" + skipSpace + type_ <- parseType + skipSpace + pure (Just type_) + selectionSet <- parseSelectionSet ("fragment " <> cs name) pure (FragmentDefinition Fragment { name, selectionSet }) @@ -125,6 +131,7 @@ parseArgument = do char ':' skipSpace argumentValue <- parseValue + skipSpace pure Argument { argumentName, argumentValue } parseValue :: Parser Value @@ -142,13 +149,21 @@ parseValue = do |> map (\Argument { argumentName, argumentValue } -> (argumentName, argumentValue)) |> HashMap.fromList pure (ObjectValue hashMap) - let string = do + let true = do + string "true" + skipSpace + pure $ BooleanValue True + let false = do + string "false" + skipSpace + pure $ BooleanValue False + let stringLit = do char '"' body <- takeTill (== '\"') char '"' skipSpace pure (StringValue body) - (variable "Variable") <|> (object "Object") <|> (string "String") + (variable "Variable") <|> (object "Object") <|> (stringLit "String") <|> true <|> false parseName :: Parser Text parseName = takeWhile1 isNameChar "Name" diff --git a/IHP/GraphQL/Patch.hs b/IHP/GraphQL/Patch.hs new file mode 100644 index 000000000..48a2bf2dd --- /dev/null +++ b/IHP/GraphQL/Patch.hs @@ -0,0 +1,40 @@ +module IHP.GraphQL.Patch where + +import IHP.Prelude +import IHP.GraphQL.Types +import IHP.GraphQL.Analysis +import qualified Data.Aeson as Aeson +import qualified Data.HashMap.Strict as HashMap +import qualified Data.Vector as Vector +import qualified Data.UUID as UUID + +insertRecord :: Text -> UUID -> HashMap Text Aeson.Value -> Document -> Aeson.Value -> Aeson.Value +insertRecord tableName id object document result = foldl' (\json path -> applyFunctionAtNode insertRecordsAtNode path json) result paths + where + paths = nodePathsForTable tableName document + insertRecordsAtNode (Aeson.Array vector) = Aeson.Array (Vector.snoc vector (Aeson.Object object)) + +updateRecord :: Text -> UUID -> HashMap Text Aeson.Value -> Document -> Aeson.Value -> Aeson.Value +updateRecord tableName id patch document result = foldl' (\json path -> applyFunctionAtNode updateRecordsAtNode path json) result paths + where + paths = nodePathsForTable tableName document + updateRecordsAtNode (Aeson.Array vector) = Aeson.Array (Vector.map updateRecordAtNode vector) + updateRecordAtNode value@(Aeson.Object hashMap) = + if isRecordIdEq id value + then Aeson.Object (HashMap.union patch hashMap) + else value + +deleteRecord :: Text -> UUID -> Document -> Aeson.Value -> Aeson.Value +deleteRecord tableName id document result = foldl' (\json path -> applyFunctionAtNode deleteRecordAtNode path json) result paths + where + paths = nodePathsForTable tableName document + deleteRecordAtNode (Aeson.Array vector) = Aeson.Array (Vector.filter (not . isRecordIdEq id) vector) + +isRecordIdEq :: UUID -> Aeson.Value -> Bool +isRecordIdEq id (Aeson.Object hashMap) = + case HashMap.lookup "id" hashMap of + Just (Aeson.String uuid) -> + case UUID.fromText uuid of + Just uuid -> uuid == id + Nothing -> False + otherwise -> False \ No newline at end of file diff --git a/IHP/GraphQL/Resolver.hs b/IHP/GraphQL/Resolver.hs new file mode 100644 index 000000000..2c9c781db --- /dev/null +++ b/IHP/GraphQL/Resolver.hs @@ -0,0 +1,54 @@ +module IHP.GraphQL.Resolver where + +import IHP.Prelude +import IHP.GraphQL.Types +import qualified IHP.GraphQL.Introspection as Introspection +import qualified IHP.GraphQL.Analysis as Analysis +import qualified IHP.GraphQL.Compiler as Compiler +import IHP.DataSync.DynamicQuery (UndecodedJSON (UndecodedJSON)) +import qualified Data.Aeson as Aeson +import qualified Data.Vector as Vector +import qualified Data.HashMap.Strict as HashMap +import qualified Database.PostgreSQL.Simple as PG + +resolve schema sqlQueryWithRLS graphQLRequest = do + let rootQuery = get #query graphQLRequest + let variables = get #variables graphQLRequest + + rootQuery + |> Analysis.splitDocumentIntoResolvableUnits + |> \case + -- Avoid decoding the JSON in the common fast-path with a single resolver + [(PostgresResolver, document)] -> resolvePostgres sqlQueryWithRLS variables document + multipleResolvers -> do + results <- forM multipleResolvers \(resolver, document) -> do + case resolver of + PostgresResolver -> undecodedJSONToAesonValue <$> (resolvePostgres sqlQueryWithRLS variables document) + IntrospectionResolver -> pure $ staticGraphToAesonValue (resolveIntrospection schema document) + let mergedResult = (foldl1 mergeAeson results) + pure $ UndecodedJSON (cs $ Aeson.encode mergedResult) + +resolvePostgres sqlQueryWithRLS variables document = do + let [(theQuery, theParams)] = Compiler.compileDocument variables document + result <- sqlQueryWithRLS theQuery theParams + case result of + [PG.Only graphQLResult] -> pure graphQLResult + otherwise -> error "resolvePostgres: Unexpected result" + +resolveIntrospection schema document = Introspection.resolveStaticGraph (Introspection.introspectionGraph schema) document + +undecodedJSONToAesonValue :: UndecodedJSON -> Aeson.Value +undecodedJSONToAesonValue (UndecodedJSON json) = case Aeson.decode (cs json) of + Just result -> result + Nothing -> error "undecodedJSONToAesonValue: Failed to decode postgres result" + +staticGraphToAesonValue :: StaticGraph -> Aeson.Value +staticGraphToAesonValue ObjectNode { objectValues } = Aeson.Object (HashMap.map staticGraphToAesonValue objectValues) +staticGraphToAesonValue ArrayNode { arrayElements } = Aeson.Array (Vector.fromList $ map staticGraphToAesonValue arrayElements) +staticGraphToAesonValue Leaf { value } = valueToAeson value + where + valueToAeson (StringValue string) = Aeson.toJSON string + valueToAeson (BooleanValue boolean) = Aeson.toJSON boolean + valueToAeson NullValue = Aeson.Null + +mergeAeson (Aeson.Object a) (Aeson.Object b) = Aeson.Object (HashMap.union b a) \ No newline at end of file diff --git a/IHP/GraphQL/SchemaCompiler.hs b/IHP/GraphQL/SchemaCompiler.hs index f425c9c45..7662d359b 100644 --- a/IHP/GraphQL/SchemaCompiler.hs +++ b/IHP/GraphQL/SchemaCompiler.hs @@ -7,7 +7,6 @@ import IHP.IDE.SchemaDesigner.Types type SqlSchema = [Statement] -type GraphQLSchema = [Definition] sqlSchemaToGraphQLSchema :: SqlSchema -> GraphQLSchema sqlSchemaToGraphQLSchema statements = @@ -56,15 +55,22 @@ mutationDefinition statements = TypeSystemDefinition { typeSystemDefinition = Ty statementToQueryField :: Statement -> [FieldDefinition] statementToQueryField (StatementCreateTable CreateTable { name }) = - [ manyRecordsField ] + [ manyRecordsField, singleRecordField ] where manyRecordsField = FieldDefinition { description = Just ("Returns all records from the `" <> name <> "` table") , name = lcfirst (tableNameToControllerName name) , argumentsDefinition = [] - , type_ + , type_ = NonNullType (ListType (NonNullType (NamedType (tableNameToModelName name)))) + } + singleRecordField = FieldDefinition + { description = Just ("Returns a single record from the `" <> name <> "` table") + , name = lcfirst (tableNameToModelName name) + , argumentsDefinition = [ + ArgumentDefinition { name = "id", argumentType = NonNullType (NamedType "UUID"), defaultValue = Nothing } + ] + , type_ = NonNullType (NamedType (tableNameToModelName name)) } - type_ = NonNullType (ListType (NonNullType (NamedType (tableNameToModelName name)))) statementToQueryField _ = [] statementToMutationFields :: Statement -> [FieldDefinition] diff --git a/IHP/GraphQL/Types.hs b/IHP/GraphQL/Types.hs index 16d23022a..ba5568295 100644 --- a/IHP/GraphQL/Types.hs +++ b/IHP/GraphQL/Types.hs @@ -8,6 +8,22 @@ data GraphQLRequest = GraphQLRequest , variables :: !Variables } +type GraphQLSchema = [Definition] + +-- An error response that renders to JSON like this: +-- +-- > { +-- > "data": null, +-- > "errors": [ +-- > { "message": "error 1" } +-- > ] +-- > } +-- +-- We don't support partial responses, so @data@ will always be @null@ in an error case +data GraphQLErrorResponse = GraphQLErrorResponse + { errors :: ![Text] + } + -- https://spec.graphql.org/June2018/#sec-Appendix-Grammar-Summary.Document newtype Document = Document { definitions :: [Definition] } @@ -122,4 +138,15 @@ data Type = NamedType !Text | ListType !Type | NonNullType !Type + deriving (Eq, Show) + +data StaticGraph + = ObjectNode { objectValues :: !(HashMap Text StaticGraph) } + | ArrayNode { arrayElements :: ![StaticGraph] } + | Leaf { value :: !Value } + deriving (Eq, Show) + +data Resolver + = PostgresResolver + | IntrospectionResolver deriving (Eq, Show) \ No newline at end of file diff --git a/IHP/IDE/CodeGen/MigrationGenerator.hs b/IHP/IDE/CodeGen/MigrationGenerator.hs index 66f52f1cf..f46c799bb 100644 --- a/IHP/IDE/CodeGen/MigrationGenerator.hs +++ b/IHP/IDE/CodeGen/MigrationGenerator.hs @@ -23,8 +23,8 @@ import IHP.IDE.SchemaDesigner.Compiler (compileSql) import IHP.IDE.CodeGen.Types import qualified IHP.LibDir as LibDir -buildPlan :: Text -> Maybe Text -> IO (Int, [GeneratorAction]) -buildPlan description sqlStatements = do +buildPlan :: ByteString -> Text -> Maybe Text -> IO (Int, [GeneratorAction]) +buildPlan databaseUrl description sqlStatements = do revision <- round <$> POSIX.getPOSIXTime let slug = NameSupport.toSlug description let migrationFile = tshow revision <> (if isEmpty slug then "" else "-" <> slug) <> ".sql" @@ -32,7 +32,7 @@ buildPlan description sqlStatements = do migrationSql <- case sqlStatements of Just sql -> pure sql Nothing -> do - appDiff <- diffAppDatabase + appDiff <- diffAppDatabase databaseUrl pure $ if isEmpty appDiff then "-- Write your SQL migration code in here\n" else compileSql appDiff @@ -41,10 +41,10 @@ buildPlan description sqlStatements = do , CreateFile { filePath = "Application/Migration/" <> migrationFile, fileContent = migrationSql } ]) -diffAppDatabase = do +diffAppDatabase databaseUrl = do (Right schemaSql) <- Parser.parseSchemaSql (Right ihpSchemaSql) <- parseIHPSchema - actualSchema <- getAppDBSchema + actualSchema <- getAppDBSchema databaseUrl let targetSchema = ihpSchemaSql <> schemaSql @@ -310,9 +310,9 @@ migrateEnum CreateEnumType { name, values = targetValues } CreateEnumType { valu addValue :: Text -> Statement addValue value = AddValueToEnumType { enumName = name, newValue = value, ifNotExists = True } -getAppDBSchema :: IO [Statement] -getAppDBSchema = do - sql <- dumpAppDatabaseSchema +getAppDBSchema :: ByteString -> IO [Statement] +getAppDBSchema databaseUrl = do + sql <- dumpAppDatabaseSchema databaseUrl case parseDumpedSql sql of Left error -> fail (cs error) Right result -> pure result @@ -320,10 +320,10 @@ getAppDBSchema = do -- | Returns the DDL statements of the locally running dev db -- -- Basically does the same as @make dumpdb@ but returns the output as a string -dumpAppDatabaseSchema :: IO Text -dumpAppDatabaseSchema = do +dumpAppDatabaseSchema :: ByteString -> IO Text +dumpAppDatabaseSchema databaseUrl = do projectDir <- Directory.getCurrentDirectory - cs <$> Process.readProcess "pg_dump" ["-s", "--no-owner", "--no-acl", "-h", projectDir <> "/build/db", "app"] [] + cs <$> Process.readProcess "pg_dump" ["-s", "--no-owner", "--no-acl", cs databaseUrl] [] parseDumpedSql :: Text -> (Either ByteString [Statement]) parseDumpedSql sql = diff --git a/IHP/IDE/Graph/Controller.hs b/IHP/IDE/Graph/Controller.hs new file mode 100644 index 000000000..b93425c71 --- /dev/null +++ b/IHP/IDE/Graph/Controller.hs @@ -0,0 +1,75 @@ +module IHP.IDE.Graph.Controller where + +import IHP.ControllerPrelude +import IHP.IDE.ToolServer.Types + +import IHP.IDE.Graph.View.Explore +import IHP.IDE.Graph.View.Schema + +import qualified IHP.IDE.SchemaDesigner.Parser as SchemaDesigner +import qualified IHP.GraphQL.ToText as GraphQL +import qualified IHP.GraphQL.SchemaCompiler as GraphQL +import qualified Database.PostgreSQL.Simple as PG +import IHP.IDE.Data.Controller (connectToAppDb, fetchRowsPage) + +import qualified Web.JWT as JWT +import qualified Data.Time.Clock.POSIX as Time +import qualified Control.Exception as Exception +import qualified Data.Maybe as Maybe +import qualified Data.ByteString as BS + +instance Controller GraphController where + action ExploreAction = do + SchemaDesigner.parseSchemaSql >>= \case + Left parserError -> fail (cs parserError) + Right sqlSchema -> do + let schema = GraphQL.sqlSchemaToGraphQLSchema sqlSchema + render ExploreView { .. } + + action SchemaAction = do + SchemaDesigner.parseSchemaSql >>= \case + Left parserError -> fail (cs parserError) + Right sqlSchema -> do + let schema = GraphQL.sqlSchemaToGraphQLSchema sqlSchema + render SchemaView { .. } + + action GraphUsersAction = do + connection <- connectToAppDb + rows :: [[DynamicField]] <- fetchRowsPage connection "users" 1 50 + + PG.close connection + + renderJson rows + + action GetJWTForUserId { userId } = do + let lifetime = 60 * 60 * 24 * 3 + + createdAt <- getCurrentTime + expiredAt <- addUTCTime lifetime <$> getCurrentTime + + let claimsSet = mempty + { JWT.iss = (JWT.stringOrURI "https://ihp-dev-identity.digitallyinduced.com/") + , JWT.sub = JWT.stringOrURI (tshow userId) + , JWT.iat = JWT.numericDate (Time.utcTimeToPOSIXSeconds createdAt) + , JWT.exp = JWT.numericDate (Time.utcTimeToPOSIXSeconds expiredAt) + } + + jwtSigner <- initJWTSigner + let token = JWT.encodeSigned jwtSigner mempty claimsSet + renderPlain (cs token) + +initJWTSigner :: IO JWT.Signer +initJWTSigner = do + appJwt <- Exception.try @Exception.SomeException (BS.readFile "Application/jwt.key") + + let privateKeyText = + case appJwt of + Left _ -> error "Could not find JWT" + Right result -> result + + + privateKeyText + |> JWT.readRsaSecret + |> Maybe.fromJust + |> JWT.RSAPrivateKey + |> pure diff --git a/IHP/IDE/Graph/View/Explore.hs b/IHP/IDE/Graph/View/Explore.hs new file mode 100644 index 000000000..3738de7bf --- /dev/null +++ b/IHP/IDE/Graph/View/Explore.hs @@ -0,0 +1,20 @@ +module IHP.IDE.Graph.View.Explore where + +import IHP.ViewPrelude +import IHP.IDE.ToolServer.Types +import qualified IHP.GraphQL.Types as GraphQL +import qualified IHP.GraphQL.SchemaCompiler as GraphQL +import qualified IHP.GraphQL.ToText as GraphQL +import IHP.IDE.Graph.View.Layout + +data ExploreView + = ExploreView + { schema :: GraphQL.GraphQLSchema } + +instance View ExploreView where + html ExploreView { .. } = [hsx| +
+ {headerNav} +
+
+ |] \ No newline at end of file diff --git a/IHP/IDE/Graph/View/Layout.hs b/IHP/IDE/Graph/View/Layout.hs new file mode 100644 index 000000000..3d24670e5 --- /dev/null +++ b/IHP/IDE/Graph/View/Layout.hs @@ -0,0 +1,45 @@ +module IHP.IDE.Graph.View.Layout +( headerNav +) where + +import IHP.ViewPrelude +import IHP.IDE.ToolServer.Types +import IHP.IDE.ToolServer.Routes +import qualified Data.Text as Text +import IHP.IDE.ToolServer.Helper.View + +headerNav :: Html +headerNav = [hsx| +
+
+ + Explore + + + + Schema + + +
+
+ {url} +
+
+
+
+|] + where + exploreActive :: Bool + exploreActive = isActivePath ExploreAction + + schemaActive :: Bool + schemaActive = isActivePath SchemaAction + + url :: Text + url = "http://localhost:8000/api/graphql" \ No newline at end of file diff --git a/IHP/IDE/Graph/View/Schema.hs b/IHP/IDE/Graph/View/Schema.hs new file mode 100644 index 000000000..c7c2d23ba --- /dev/null +++ b/IHP/IDE/Graph/View/Schema.hs @@ -0,0 +1,18 @@ +module IHP.IDE.Graph.View.Schema where + +import IHP.ViewPrelude +import IHP.IDE.ToolServer.Types +import qualified IHP.GraphQL.Types as GraphQL +import qualified IHP.GraphQL.SchemaCompiler as GraphQL +import qualified IHP.GraphQL.ToText as GraphQL +import IHP.IDE.Graph.View.Layout + +data SchemaView + = SchemaView + { schema :: GraphQL.GraphQLSchema } + +instance View SchemaView where + html SchemaView { .. } = [hsx| + {headerNav} +
{GraphQL.toText schema}
+ |] \ No newline at end of file diff --git a/IHP/IDE/SchemaDesigner/Controller/Migrations.hs b/IHP/IDE/SchemaDesigner/Controller/Migrations.hs index 600056c65..87dda6ecb 100644 --- a/IHP/IDE/SchemaDesigner/Controller/Migrations.hs +++ b/IHP/IDE/SchemaDesigner/Controller/Migrations.hs @@ -45,14 +45,14 @@ instance Controller MigrationsController where action NewMigrationAction = do let description = paramOrDefault "" "description" - (_, plan) <- MigrationGenerator.buildPlan description Nothing + (_, plan) <- MigrationGenerator.buildPlan theDatabaseUrl description Nothing let runMigration = paramOrDefault True "runMigration" render NewView { .. } action CreateMigrationAction = do let description = paramOrDefault "" "description" let sqlStatements = paramOrNothing "sqlStatements" - (revision, plan) <- MigrationGenerator.buildPlan description sqlStatements + (revision, plan) <- MigrationGenerator.buildPlan theDatabaseUrl description sqlStatements let path = MigrationGenerator.migrationPathFromPlan plan executePlan plan @@ -63,7 +63,18 @@ instance Controller MigrationsController where setSuccessMessage ("Migration generated: " <> path) openEditor path 0 0 else do - migrateAppDB revision + result <- Exception.try (migrateAppDB revision) + case result of + Left (exception :: SomeException) -> do + let errorMessage = case fromException exception of + Just (exception :: EnhancedSqlError) -> cs $ get #sqlErrorMsg (get #sqlError exception) + Nothing -> tshow exception + + setErrorMessage errorMessage + redirectTo MigrationsAction + Right _ -> do + clearDatabaseNeedsMigration + redirectTo MigrationsAction clearDatabaseNeedsMigration @@ -153,4 +164,10 @@ withAppModelContext inner = pure (frameworkConfig, logger, modelContext) cleanupModelContext (frameworkConfig, logger, modelContext) = do - logger |> cleanup \ No newline at end of file + logger |> cleanup + +theDatabaseUrl :: (?context :: ControllerContext) => ByteString +theDatabaseUrl = + ?context + |> getFrameworkConfig + |> get #databaseUrl \ No newline at end of file diff --git a/IHP/IDE/SchemaDesigner/View/Layout.hs b/IHP/IDE/SchemaDesigner/View/Layout.hs index 510986ea8..bdafae6f5 100644 --- a/IHP/IDE/SchemaDesigner/View/Layout.hs +++ b/IHP/IDE/SchemaDesigner/View/Layout.hs @@ -350,6 +350,7 @@ suggestedColumnsSection tableName indexAndColumns = unless isUsersTable [hsx| +