diff --git a/src/Main/JsonConverters.fs b/src/Main/JsonConverters.fs index 3012254..bd5fab3 100644 --- a/src/Main/JsonConverters.fs +++ b/src/Main/JsonConverters.fs @@ -20,15 +20,40 @@ type GraphQLWsMessageConverter() = else failwithf "was expecting a value for property \"%s\"" propertyName + let readSubscribePayload (reader : byref) : GraphQLWsMessageSubscribePayloadRaw = + let mutable operationName : string option = None + let mutable query : string option = None + let mutable variables : string option = None + let mutable extensions : string option = None + while reader.Read() && (not <| reader.TokenType.Equals(JsonTokenType.EndObject)) do + match reader.GetString() with + | "operationName" -> + operationName <- readPropertyValueAsAString "operationName" &reader + | "query" -> + query <- readPropertyValueAsAString "query" &reader + | "variables" -> + variables <- readPropertyValueAsAString "variables" &reader + | "extensions" -> + extensions <- readPropertyValueAsAString "extensions" &reader + | other -> + failwithf "unexpected property \"%s\" in payload object" other + { OperationName = operationName + Query = query + Variables = variables + Extensions = extensions } + let readPayload (reader : byref) : GraphQLWsMessagePayloadRaw option = if reader.Read() then if reader.TokenType.Equals(JsonTokenType.String) then StringPayload (reader.GetString()) |> Some + elif reader.TokenType.Equals(JsonTokenType.StartObject) then + SubscribePayload (readSubscribePayload &reader) + |> Some elif reader.TokenType.Equals(JsonTokenType.Null) then failwith "was expecting a value for property \"payload\"" else - None + failwith "Not implemented yet. Uh-oh, this is a bug." else failwith "was expecting a value for property \"payload\"" diff --git a/src/Main/WebSocketMessagesMappers.fs b/src/Main/WebSocketMessagesMappers.fs index 17cea3c..883e08b 100644 --- a/src/Main/WebSocketMessagesMappers.fs +++ b/src/Main/WebSocketMessagesMappers.fs @@ -1,6 +1,12 @@ namespace GraphQLTransportWS module GraphQLWsMessageRawMapping = + open FSharp.Data.GraphQL + + let requireId (raw : GraphQLWsMessageRaw) : string = + match raw.Id with + | Some s -> s + | None -> failwith "property \"id\" is required but was not there" let requirePayloadToBeAnOptionalString (payload : GraphQLWsMessagePayloadRaw option) : string option = match payload with @@ -10,12 +16,23 @@ module GraphQLWsMessageRawMapping = | _ -> failwith "payload was expected to be a string, but it wasn't" | None -> None - let requireId (raw : GraphQLWsMessageRaw) : string = - match raw.Id with - | Some s -> s - | None -> failwith "property \"id\" is required but was not there" + let requireSubscribePayload (executor : Executor<'a>) (payload : GraphQLWsMessagePayloadRaw option) : GraphQLQuery = + match payload with + | Some p -> + match p with + | SubscribePayload rawSubsPayload -> + match rawSubsPayload.Query with + | Some query -> + { ExecutionPlan = executor.CreateExecutionPlan(query) + Variables = Map.empty } + | None -> + failwith "there was no query in subscribe message!" + | _ -> + failwith "payload was expected to be a subscribe payload object, but it wasn't." + | None -> + failwith "payload is required for this message, but none was available" - let toWebSocketClientMessage (raw : GraphQLWsMessageRaw) : WebSocketClientMessage = + let toWebSocketClientMessage (executor : Executor<'a>) (raw : GraphQLWsMessageRaw) : WebSocketClientMessage = match raw.Type with | None -> failwithf "property \"type\" was not found in the client message" @@ -27,6 +44,10 @@ module GraphQLWsMessageRawMapping = ClientPong (raw.Payload |> requirePayloadToBeAnOptionalString) | Some "complete" -> ClientComplete (raw |> requireId) + | Some "subscribe" -> + let id = raw |> requireId + let payload = raw.Payload |> requireSubscribePayload executor + Subscribe (id, payload) | Some other -> failwithf "type \"%s\" is not supported as a client message type" other diff --git a/tests/unit-tests/Main.UnitTests.fsproj b/tests/unit-tests/Main.UnitTests.fsproj index 27a7483..f228968 100644 --- a/tests/unit-tests/Main.UnitTests.fsproj +++ b/tests/unit-tests/Main.UnitTests.fsproj @@ -8,6 +8,7 @@ + diff --git a/tests/unit-tests/SerializationTests.fs b/tests/unit-tests/SerializationTests.fs index 54e61b5..a292087 100644 --- a/tests/unit-tests/SerializationTests.fs +++ b/tests/unit-tests/SerializationTests.fs @@ -1,9 +1,11 @@ module Tests +open UnitTest open GraphQLTransportWS open System open System.Text.Json open Xunit +open FSharp.Data.GraphQL.Ast [] let ``Serializes ServerPing correctly`` () = @@ -37,7 +39,9 @@ let ``Deserializes ConnectionInit correctly`` () = let input = "{\"type\":\"connection_init\"}" let resultRaw = JsonSerializer.Deserialize(input, serializerOptions) - let result = resultRaw |> GraphQLWsMessageRawMapping.toWebSocketClientMessage + let result = + resultRaw + |> GraphQLWsMessageRawMapping.toWebSocketClientMessage (TestSchema.executor) match result with | ConnectionInit None -> () // <-- expected @@ -52,7 +56,9 @@ let ``Deserializes ConnectionInit with payload correctly`` () = let input = "{\"type\":\"connection_init\", \"payload\":\"hello\"}" let resultRaw = JsonSerializer.Deserialize(input, serializerOptions) - let result = resultRaw |> GraphQLWsMessageRawMapping.toWebSocketClientMessage + let result = + resultRaw + |> GraphQLWsMessageRawMapping.toWebSocketClientMessage (TestSchema.executor) match result with | ConnectionInit (Some "hello") -> () // <-- expected @@ -67,7 +73,9 @@ let ``Deserializes ClientPing correctly`` () = let input = "{\"type\":\"ping\"}" let resultRaw = JsonSerializer.Deserialize(input, serializerOptions) - let result = resultRaw |> GraphQLWsMessageRawMapping.toWebSocketClientMessage + let result = + resultRaw + |> GraphQLWsMessageRawMapping.toWebSocketClientMessage (TestSchema.executor) match result with | ClientPing None -> () // <-- expected @@ -82,7 +90,9 @@ let ``Deserializes ClientPing with payload correctly`` () = let input = "{\"type\":\"ping\", \"payload\":\"ping!\"}" let resultRaw = JsonSerializer.Deserialize(input, serializerOptions) - let result = resultRaw |> GraphQLWsMessageRawMapping.toWebSocketClientMessage + let result = + resultRaw + |> GraphQLWsMessageRawMapping.toWebSocketClientMessage (TestSchema.executor) match result with | ClientPing (Some "ping!") -> () // <-- expected @@ -97,7 +107,9 @@ let ``Deserializes ClientPong correctly`` () = let input = "{\"type\":\"pong\"}" let resultRaw = JsonSerializer.Deserialize(input, serializerOptions) - let result = resultRaw |> GraphQLWsMessageRawMapping.toWebSocketClientMessage + let result = + resultRaw + |> GraphQLWsMessageRawMapping.toWebSocketClientMessage (TestSchema.executor) match result with | ClientPong None -> () // <-- expected @@ -112,7 +124,9 @@ let ``Deserializes ClientPong with payload correctly`` () = let input = "{\"type\":\"pong\", \"payload\": \"pong!\"}" let resultRaw = JsonSerializer.Deserialize(input, serializerOptions) - let result = resultRaw |> GraphQLWsMessageRawMapping.toWebSocketClientMessage + let result = + resultRaw + |> GraphQLWsMessageRawMapping.toWebSocketClientMessage (TestSchema.executor) match result with | ClientPong (Some "pong!") -> () // <-- expected @@ -127,10 +141,69 @@ let ``Deserializes ClientComplete correctly``() = let input = "{\"id\": \"65fca2b5-f149-4a70-a055-5123dea4628f\", \"type\":\"complete\"}" let resultRaw = JsonSerializer.Deserialize(input, serializerOptions) - let result = resultRaw |> GraphQLWsMessageRawMapping.toWebSocketClientMessage + let result = + resultRaw + |> GraphQLWsMessageRawMapping.toWebSocketClientMessage (TestSchema.executor) match result with | ClientComplete id -> Assert.Equal("65fca2b5-f149-4a70-a055-5123dea4628f", id) | other -> Assert.Fail(sprintf "unexpected actual value: '%A'" other) + +[] +let ``Deserializes client subscription correctly`` () = + let serializerOptions = new JsonSerializerOptions() + serializerOptions.Converters.Add(new GraphQLWsMessageConverter()) + + let input = + """{ + "id": "b5d4d2ff-d262-4882-a7b9-d6aec5e4faa6", + "type": "subscribe", + "payload" : { + "query": "subscription { watchMoon(id: \"1\") { id name isMoon } }" + } + } + """ + + let resultRaw = JsonSerializer.Deserialize(input, serializerOptions) + let result = + resultRaw + |> GraphQLWsMessageRawMapping.toWebSocketClientMessage (TestSchema.executor) + + match result with + | Subscribe (id, payload) -> + Assert.Equal("b5d4d2ff-d262-4882-a7b9-d6aec5e4faa6", id) + Assert.Equal(1, payload.ExecutionPlan.Operation.SelectionSet.Length) + let watchMoonSelection = payload.ExecutionPlan.Operation.SelectionSet |> List.head + match watchMoonSelection with + | Field watchMoonField -> + Assert.Equal("watchMoon", watchMoonField.Name) + Assert.Equal(1, watchMoonField.Arguments.Length) + let watchMoonFieldArg = watchMoonField.Arguments |> List.head + Assert.Equal("id", watchMoonFieldArg.Name) + match watchMoonFieldArg.Value with + | StringValue theValue -> + Assert.Equal("1", theValue) + | other -> + Assert.Fail(sprintf "expected arg to be a StringValue, but it was: %A" other) + Assert.Equal(3, watchMoonField.SelectionSet.Length) + match watchMoonField.SelectionSet.[0] with + | Field firstField -> + Assert.Equal("id", firstField.Name) + | other -> + Assert.Fail(sprintf "expected field to be a Field, but it was: %A" other) + match watchMoonField.SelectionSet.[1] with + | Field secondField -> + Assert.Equal("name", secondField.Name) + | other -> + Assert.Fail(sprintf "expected field to be a Field, but it was: %A" other) + match watchMoonField.SelectionSet.[2] with + | Field thirdField -> + Assert.Equal("isMoon", thirdField.Name) + | other -> + Assert.Fail(sprintf "expected field to be a Field, but it was: %A" other) + | somethingElse -> + Assert.Fail(sprintf "expected it to be a field, but it was: %A" somethingElse) + | other -> + Assert.Fail(sprintf "unexpected actual value: '%A" other) \ No newline at end of file diff --git a/tests/unit-tests/TestSchema.fs b/tests/unit-tests/TestSchema.fs new file mode 100644 index 0000000..6c07711 --- /dev/null +++ b/tests/unit-tests/TestSchema.fs @@ -0,0 +1,229 @@ +namespace UnitTest + +open FSharp.Data.GraphQL +open FSharp.Data.GraphQL.Types + +#nowarn "40" + +type Episode = + | NewHope = 1 + | Empire = 2 + | Jedi = 3 + +type Human = + { Id : string + Name : string option + Friends : string list + AppearsIn : Episode list + HomePlanet : string option } + +type Droid = + { Id : string + Name : string option + Friends : string list + AppearsIn : Episode list + PrimaryFunction : string option } + +type Planet = + { Id : string + Name : string option + mutable IsMoon : bool option } + member x.SetMoon b = + x.IsMoon <- b + x + +type Root = + { RequestId: string } + +type Character = + | Human of Human + | Droid of Droid + +module TestSchema = + let humans = + [ { Id = "1000" + Name = Some "Luke Skywalker" + Friends = [ "1002"; "1003"; "2000"; "2001" ] + AppearsIn = [ Episode.NewHope; Episode.Empire; Episode.Jedi ] + HomePlanet = Some "Tatooine" } + { Id = "1001" + Name = Some "Darth Vader" + Friends = [ "1004" ] + AppearsIn = [ Episode.NewHope; Episode.Empire; Episode.Jedi ] + HomePlanet = Some "Tatooine" } + { Id = "1002" + Name = Some "Han Solo" + Friends = [ "1000"; "1003"; "2001" ] + AppearsIn = [ Episode.NewHope; Episode.Empire; Episode.Jedi ] + HomePlanet = None } + { Id = "1003" + Name = Some "Leia Organa" + Friends = [ "1000"; "1002"; "2000"; "2001" ] + AppearsIn = [ Episode.NewHope; Episode.Empire; Episode.Jedi ] + HomePlanet = Some "Alderaan" } + { Id = "1004" + Name = Some "Wilhuff Tarkin" + Friends = [ "1001" ] + AppearsIn = [ Episode.NewHope ] + HomePlanet = None } ] + + let droids = + [ { Id = "2000" + Name = Some "C-3PO" + Friends = [ "1000"; "1002"; "1003"; "2001" ] + AppearsIn = [ Episode.NewHope; Episode.Empire; Episode.Jedi ] + PrimaryFunction = Some "Protocol" } + { Id = "2001" + Name = Some "R2-D2" + Friends = [ "1000"; "1002"; "1003" ] + AppearsIn = [ Episode.NewHope; Episode.Empire; Episode.Jedi ] + PrimaryFunction = Some "Astromech" } ] + + let planets = + [ { Id = "1" + Name = Some "Tatooine" + IsMoon = Some false} + { Id = "2" + Name = Some "Endor" + IsMoon = Some true} + { Id = "3" + Name = Some "Death Star" + IsMoon = Some false}] + + let getHuman id = + humans |> List.tryFind (fun h -> h.Id = id) + + let getDroid id = + droids |> List.tryFind (fun d -> d.Id = id) + + let getPlanet id = + planets |> List.tryFind (fun p -> p.Id = id) + + let characters = + (humans |> List.map Human) @ (droids |> List.map Droid) + + let matchesId id = function + | Human h -> h.Id = id + | Droid d -> d.Id = id + + let getCharacter id = + characters |> List.tryFind (matchesId id) + + let EpisodeType = + Define.Enum( + name = "Episode", + description = "One of the films in the Star Wars Trilogy.", + options = [ + Define.EnumValue("NewHope", Episode.NewHope, "Released in 1977.") + Define.EnumValue("Empire", Episode.Empire, "Released in 1980.") + Define.EnumValue("Jedi", Episode.Jedi, "Released in 1983.") ]) + + let rec CharacterType = + Define.Union( + name = "Character", + description = "A character in the Star Wars Trilogy.", + options = [ HumanType; DroidType ], + resolveValue = (fun o -> + match o with + | Human h -> box h + | Droid d -> upcast d), + resolveType = (fun o -> + match o with + | Human _ -> upcast HumanType + | Droid _ -> upcast DroidType)) + + and HumanType : ObjectDef = + Define.Object( + name = "Human", + description = "A humanoid creature in the Star Wars universe.", + isTypeOf = (fun o -> o :? Human), + fieldsFn = fun () -> + [ + Define.Field("id", String, "The id of the human.", fun _ (h : Human) -> h.Id) + Define.Field("name", Nullable String, "The name of the human.", fun _ (h : Human) -> h.Name) + Define.Field("friends", ListOf (Nullable CharacterType), "The friends of the human, or an empty list if they have none.", + fun _ (h : Human) -> h.Friends |> List.map getCharacter |> List.toSeq) + Define.Field("appearsIn", ListOf EpisodeType, "Which movies they appear in.", fun _ (h : Human) -> h.AppearsIn) + Define.Field("homePlanet", Nullable String, "The home planet of the human, or null if unknown.", fun _ h -> h.HomePlanet) + ]) + + and DroidType = + Define.Object( + name = "Droid", + description = "A mechanical creature in the Star Wars universe.", + isTypeOf = (fun o -> o :? Droid), + fieldsFn = fun () -> + [ + Define.Field("id", String, "The id of the droid.", fun _ (d : Droid) -> d.Id) + Define.Field("name", Nullable String, "The name of the Droid.", fun _ (d : Droid) -> d.Name) + Define.Field("friends", ListOf (Nullable CharacterType), "The friends of the Droid, or an empty list if they have none.", + fun _ (d : Droid) -> d.Friends |> List.map getCharacter |> List.toSeq) + Define.Field("appearsIn", ListOf EpisodeType, "Which movies they appear in.", fun _ d -> d.AppearsIn) + Define.Field("primaryFunction", Nullable String, "The primary function of the droid.", fun _ d -> d.PrimaryFunction) + ]) + + and PlanetType = + Define.Object( + name = "Planet", + description = "A planet in the Star Wars universe.", + isTypeOf = (fun o -> o :? Planet), + fieldsFn = fun () -> + [ + Define.Field("id", String, "The id of the planet", fun _ p -> p.Id) + Define.Field("name", Nullable String, "The name of the planet.", fun _ p -> p.Name) + Define.Field("isMoon", Nullable Boolean, "Is that a moon?", fun _ p -> p.IsMoon) + ]) + + and RootType = + Define.Object( + name = "Root", + description = "The Root type to be passed to all our resolvers.", + isTypeOf = (fun o -> o :? Root), + fieldsFn = fun () -> + [ + Define.Field("requestId", String, "The ID of the client.", fun _ (r : Root) -> r.RequestId) + ]) + + let Query = + Define.Object( + name = "Query", + fields = [ + Define.Field("hero", Nullable HumanType, "Gets human hero", [ Define.Input("id", String) ], fun ctx _ -> getHuman (ctx.Arg("id"))) + Define.Field("droid", Nullable DroidType, "Gets droid", [ Define.Input("id", String) ], fun ctx _ -> getDroid (ctx.Arg("id"))) + Define.Field("planet", Nullable PlanetType, "Gets planet", [ Define.Input("id", String) ], fun ctx _ -> getPlanet (ctx.Arg("id"))) + Define.Field("characters", ListOf CharacterType, "Gets characters", fun _ _ -> characters) ]) + + let Subscription = + Define.SubscriptionObject( + name = "Subscription", + fields = [ + Define.SubscriptionField( + "watchMoon", + RootType, + PlanetType, + "Watches to see if a planet is a moon.", + [ Define.Input("id", String) ], + (fun ctx _ p -> if ctx.Arg("id") = p.Id then Some p else None)) ]) + + let schemaConfig = SchemaConfig.Default + + let Mutation = + Define.Object( + name = "Mutation", + fields = [ + Define.Field( + "setMoon", + Nullable PlanetType, + "Defines if a planet is actually a moon or not.", + [ Define.Input("id", String); Define.Input("isMoon", Boolean) ], + fun ctx _ -> + getPlanet (ctx.Arg("id")) + |> Option.map (fun x -> + x.SetMoon(Some(ctx.Arg("isMoon"))) |> ignore + schemaConfig.SubscriptionProvider.Publish "watchMoon" x + schemaConfig.LiveFieldSubscriptionProvider.Publish "Planet" "isMoon" x + x))]) + + let schema : ISchema = upcast Schema(Query, Mutation, Subscription, schemaConfig) + + let executor = Executor(schema, []) \ No newline at end of file