Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/publish-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ jobs:

- name: Add version to global.json
run: |
$version = "9.0.200"
$version = "9.0.304"
$globalJsonPath = "global.json"
$globalJson = Get-Content -Raw -Path $globalJsonPath | ConvertFrom-Json
if ($null -eq $globalJson.sdk.version) {
Expand All @@ -46,7 +46,7 @@ jobs:
- name: Install .NET Core
uses: actions/setup-dotnet@v4
with:
dotnet-version: 9.0.200
dotnet-version: 9.0.304

- name: Add the GitHub source
run: dotnet nuget add source --username USERNAME --password ${{secrets.GITHUB_TOKEN}} --store-password-in-clear-text --name "github.com" "https://nuget.pkg.github.com/fsprojects/index.json"
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/publish-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ jobs:

- name: Add version to global.json
run: |
$version = "9.0.200"
$version = "9.0.304"
$globalJsonPath = "global.json"
$globalJson = Get-Content -Raw -Path $globalJsonPath | ConvertFrom-Json
if ($null -eq $globalJson.sdk.version) {
Expand All @@ -48,7 +48,7 @@ jobs:
- name: Install .NET Core
uses: actions/setup-dotnet@v4
with:
dotnet-version: 9.0.200
dotnet-version: 9.0.304

- name: Install local tools
run: dotnet tool restore
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/pull-request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:
fail-fast: false
matrix:
os: [ubuntu-22.04, windows-latest, macOS-latest]
dotnet: [9.0.200]
dotnet: [9.0.304]
runs-on: ${{ matrix.os }}

steps:
Expand Down
105 changes: 63 additions & 42 deletions src/FSharp.Data.GraphQL.Client.DesignTime/ProvidedTypesHelper.fs

Large diffs are not rendered by default.

158 changes: 91 additions & 67 deletions src/FSharp.Data.GraphQL.Client/GraphQLClient.fs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ namespace FSharp.Data.GraphQL

open System
open System.Collections.Generic
open System.Collections.Immutable
open System.Net.Http
open System.Text
open System.Threading
Expand All @@ -15,40 +16,42 @@ open FSharp.Data.GraphQL.Client
open ReflectionPatterns

/// A requrest object for making GraphQL calls using the GraphQL client module.
type GraphQLRequest =
{ /// Gets the URL of the GraphQL server which will be called.
ServerUrl : string
/// Gets custom HTTP Headers to pass with each call using this request.
HttpHeaders: seq<string * string>
/// Gets the name of the operation that should run on the server.
OperationName : string option
/// Gets the query string which should be executed on the GraphQL server.
Query : string
/// Gets variables to be sent with the query.
Variables : (string * obj) [] }
type GraphQLRequest = {
/// Gets the URL of the GraphQL server which will be called.
ServerUrl : string
/// Gets custom HTTP Headers to pass with each call using this request.
HttpHeaders : seq<string * string>
/// Gets the name of the operation that should run on the server.
OperationName : string option
/// Gets the query string which should be executed on the GraphQL server.
Query : string
/// Gets variables to be sent with the query.
Variables : (string * obj)[]
}

/// Executes calls to GraphQL servers and return their responses.
module GraphQLClient =

let private ensureSuccessCode (response : Task<HttpResponseMessage>) = task {
let! response = response
return response.EnsureSuccessStatusCode()
return response.EnsureSuccessStatusCode ()
}

let private addHeaders (httpHeaders : seq<string * string>) (requestMessage : HttpRequestMessage) =
if not (isNull httpHeaders)
then httpHeaders |> Seq.iter (fun (name, value) -> requestMessage.Headers.Add(name, value))
if not (isNull httpHeaders) then
httpHeaders
|> Seq.iter (fun (name, value) -> requestMessage.Headers.Add (name, value))

let private postAsync ct (invoker : HttpMessageInvoker) (serverUrl : string) (httpHeaders : seq<string * string>) (content : HttpContent) = task {
use requestMessage = new HttpRequestMessage(HttpMethod.Post, serverUrl)
use requestMessage = new HttpRequestMessage (HttpMethod.Post, serverUrl)
requestMessage.Content <- content
addHeaders httpHeaders requestMessage
return! invoker.SendAsync(requestMessage, ct) |> ensureSuccessCode
return! invoker.SendAsync (requestMessage, ct) |> ensureSuccessCode
}

let private getAsync ct (invoker : HttpMessageInvoker) (serverUrl : string) = task {
use requestMessage = new HttpRequestMessage(HttpMethod.Get, serverUrl)
return! invoker.SendAsync(requestMessage, ct) |> ensureSuccessCode
use requestMessage = new HttpRequestMessage (HttpMethod.Get, serverUrl)
return! invoker.SendAsync (requestMessage, ct) |> ensureSuccessCode
}

/// Sends a request to a GraphQL server asynchronously.
Expand All @@ -63,108 +66,129 @@ module GraphQLClient =
| Some x -> JsonValue.String x
| None -> JsonValue.Null
let requestJson =
[| "operationName", operationName
"query", JsonValue.String request.Query
"variables", variables |]
[|
"operationName", operationName
"query", JsonValue.String request.Query
"variables", variables
|]
|> JsonValue.Record
let content = new StringContent(requestJson.ToString(), Encoding.UTF8, "application/json")
let content = new StringContent (requestJson.ToString (), Encoding.UTF8, "application/json")
return! postAsync ct invoker request.ServerUrl request.HttpHeaders content
}

/// Sends a request to a GraphQL server.
let sendRequest client request = (sendRequestAsync CancellationToken.None client request).GetAwaiter().GetResult()
let sendRequest client request =
(sendRequestAsync CancellationToken.None client request).GetAwaiter().GetResult ()

/// Executes an introspection schema request to a GraphQL server asynchronously.
let sendIntrospectionRequestAsync ct (connection : GraphQLClientConnection) (serverUrl : string) httpHeaders =
let sendGet() = getAsync ct connection.Invoker serverUrl
let sendGet () = getAsync ct connection.Invoker serverUrl
let rethrow (exns : exn list) =
let rec mapper (acc : string) (exns : exn list) =
let aggregateMapper (ex : AggregateException) = mapper "" (List.ofSeq ex.InnerExceptions)
match exns with
| [] -> acc.TrimEnd()
| [] -> acc.TrimEnd ()
| ex :: tail ->
match ex with
| :? AggregateException as ex -> mapper (acc + aggregateMapper ex + " ") tail
| ex -> mapper (acc + ex.Message + " ") tail
failwith $"""Failure trying to recover introspection schema from server at "%s{serverUrl}". Errors: %s{mapper "" exns}"""
task {
try return! sendGet()
try
return! sendGet ()
with getex ->
let request =
{ ServerUrl = serverUrl
HttpHeaders = httpHeaders
OperationName = None
Query = IntrospectionQuery.Definition
Variables = [||] }
try return! sendRequestAsync ct connection request
with postex -> return rethrow [getex; postex]
let request = {
ServerUrl = serverUrl
HttpHeaders = httpHeaders
OperationName = None
Query = IntrospectionQuery.Definition
Variables = [||]
}
try
return! sendRequestAsync ct connection request
with postex ->
return rethrow [ getex; postex ]
}

/// Executes an introspection schema request to a GraphQL server.
let sendIntrospectionRequest client serverUrl httpHeaders =
(sendIntrospectionRequestAsync CancellationToken.None client serverUrl httpHeaders).GetAwaiter().GetResult()
(sendIntrospectionRequestAsync CancellationToken.None client serverUrl httpHeaders).GetAwaiter().GetResult ()

/// Executes a multipart request to a GraphQL server asynchronously.
let sendMultipartRequestAsync ct (connection : GraphQLClientConnection) (request : GraphQLRequest) = task {
let invoker = connection.Invoker
let boundary = "----GraphQLProviderBoundary" + (Guid.NewGuid().ToString("N"))
let content = new MultipartContent("form-data", boundary)
let boundary =
"----GraphQLProviderBoundary"
+ (Guid.NewGuid().ToString ("N"))
let content = new MultipartContent ("form-data", boundary)
let files =
let rec tryMapFileVariable (name: string, value : obj) =
let rec tryMapFileVariable (name : string, value : obj) =
match value with
| null | :? string -> None
| :? Upload as x -> Some [|name, x|]
| OptionValue x ->
x |> Option.bind (fun x -> tryMapFileVariable (name, x))
| null
| :? string -> None
| :? Upload as x -> Some [| name, x |]
| OptionValue x -> x |> Option.bind (fun x -> tryMapFileVariable (name, x))
| :? IDictionary<string, obj> as x ->
x |> Seq.collect (fun kvp -> tryMapFileVariable (name + "." + (kvp.Key.FirstCharLower()), kvp.Value) |> Option.defaultValue [||])
|> Array.ofSeq
|> Some
x
|> Seq.collect (fun kvp ->
tryMapFileVariable (name + "." + (kvp.Key.FirstCharLower ()), kvp.Value)
|> Option.defaultValue [||])
|> Array.ofSeq
|> Some
| EnumerableValue x ->
x |> Array.mapi (fun ix x -> tryMapFileVariable ($"%s{name}.%i{ix}", x))
|> Array.collect (Option.defaultValue [||])
|> Some
x
|> Array.mapi (fun ix x -> tryMapFileVariable ($"%s{name}.%i{ix}", x))
|> Array.collect (Option.defaultValue [||])
|> Some
| _ -> None
request.Variables |> Array.collect (tryMapFileVariable >> (Option.defaultValue [||]))
request.Variables
|> Array.collect (tryMapFileVariable >> (Option.defaultValue [||]))

let operationContent =
let variables =
match request.Variables with
| null | [||] -> JsonValue.Null
| _ -> request.Variables |> Map.ofArray |> Serialization.toJsonValue
| null
| [||] -> JsonValue.Null
| _ ->
request.Variables
|> Map.ofArray
|> Serialization.toJsonValue
let operationName =
match request.OperationName with
| Some x -> JsonValue.String x
| None -> JsonValue.Null
let json =
[| "operationName", operationName
"query", JsonValue.String request.Query
"variables", variables |]
[|
"operationName", operationName
"query", JsonValue.String request.Query
"variables", variables
|]
|> JsonValue.Record
let content = new StringContent(json.ToString(JsonSaveOptions.DisableFormatting))
content.Headers.Add("Content-Disposition", "form-data; name=\"operations\"")
let content = new StringContent (json.ToString (JsonSaveOptions.DisableFormatting))
content.Headers.Add ("Content-Disposition", "form-data; name=\"operations\"")
content
content.Add(operationContent)
content.Add (operationContent)
let mapContent =
let files =
files
|> Array.mapi (fun ix (name, _) -> ix.ToString(), JsonValue.Array [| JsonValue.String ("variables." + name) |])
|> Array.mapi (fun ix (name, _) -> ix.ToString (), JsonValue.Array [| JsonValue.String ("variables." + name) |])
|> JsonValue.Record
let content = new StringContent(files.ToString(JsonSaveOptions.DisableFormatting))
content.Headers.Add("Content-Disposition", "form-data; name=\"map\"")
let content = new StringContent (files.ToString (JsonSaveOptions.DisableFormatting))
content.Headers.Add ("Content-Disposition", "form-data; name=\"map\"")
content
content.Add(mapContent)
content.Add (mapContent)
let fileContents =
files
|> Array.mapi (fun ix (_, value) ->
let content = new StreamContent(value.Stream)
content.Headers.Add("Content-Disposition", $"form-data; name=\"%i{ix}\"; filename=\"%s{value.FileName}\"")
content.Headers.Add("Content-Type", value.ContentType)
|> Seq.mapi (fun _ (_, value) ->
let content = new StreamContent (value.Stream)
content.Headers.Add ("Content-Disposition", $"form-data; name=\"%s{value.Name}\"; filename=\"%s{value.FileName}\"")
content.Headers.Add ("Content-Type", value.ContentType)
content)
fileContents |> Array.iter content.Add
fileContents |> Seq.iter content.Add
let! result = postAsync ct invoker request.ServerUrl request.HttpHeaders content
return result
}

/// Executes a multipart request to a GraphQL server.
let sendMultipartRequest connection request =
(sendMultipartRequestAsync CancellationToken.None connection request).GetAwaiter().GetResult()
(sendMultipartRequestAsync CancellationToken.None connection request).GetAwaiter().GetResult ()
4 changes: 2 additions & 2 deletions src/FSharp.Data.GraphQL.Client/MimeTypes.fs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ open System.Collections.ObjectModel
open System

let private dictBuilder() : IReadOnlyDictionary<string, string> =
let types =
[| ".323", "text/h323"
let types = [|
".323", "text/h323"
".3g2", "video/3gpp2"
".3gp", "video/3gpp"
".3gp2", "video/3gpp2"
Expand Down
7 changes: 4 additions & 3 deletions src/FSharp.Data.GraphQL.Client/Serialization.fs
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@
namespace FSharp.Data.GraphQL.Client

open System
open Microsoft.FSharp.Reflection
open System.Reflection
open System.Collections.Generic
open System.Diagnostics
open System.Globalization
open System.Reflection
open Microsoft.FSharp.Reflection
open FSharp.Data.GraphQL
open FSharp.Data.GraphQL.Client.ReflectionPatterns

Expand Down Expand Up @@ -172,7 +173,7 @@ module Serialization =
| :? DateTimeOffset as x -> JsonValue.String (x.ToString(isoDateTimeFormat))
| :? bool as x -> JsonValue.Boolean x
| :? Uri as x -> JsonValue.String (x.ToString())
| :? Upload -> JsonValue.Null
| :? Upload as u -> JsonValue.String u.Name
| :? IDictionary<string, obj> as items ->
items
|> Seq.map (fun (KeyValue (k, v)) -> k.FirstCharLower(), toJsonValue v)
Expand Down
42 changes: 30 additions & 12 deletions src/FSharp.Data.GraphQL.Client/Upload.fs
Original file line number Diff line number Diff line change
Expand Up @@ -5,36 +5,54 @@ namespace FSharp.Data.GraphQL

open System
open System.IO
open System.Net.Mime
open System.Runtime.InteropServices
open FSharp.Data.GraphQL.Client

/// The base type for all GraphQLProvider upload types.
/// Upload types are used in GraphQL multipart request spec, mostly for file uploading features.
type Upload (stream : Stream, fileName : string, ?contentType : string, ?ownsStream : bool) =
new(bytes : byte [], fileName, ?contentType) =
let stream = new MemoryStream(bytes)
type Upload
(stream : Stream, fileName : string, [<Optional>] name : string | null, [<Optional>] contentType : string | null, [<Optional>] ownsStream : bool)
=

new (bytes : byte[], fileName, [<Optional>] name, [<Optional>] contentType) =
let stream = new MemoryStream (bytes)
match contentType with
| Some ct -> new Upload(stream, fileName, ct, true)
| None -> new Upload(stream, fileName, ownsStream = true)
| null -> new Upload (stream, fileName, name, ownsStream = true)
| ct -> new Upload (stream, fileName, name, ct, true)

new (bytes : byte[], fileName, contentType) = new Upload (bytes = bytes, fileName = fileName, name = null, contentType = contentType)

new (stream, fileName, [<Optional>] name, [<Optional>] contentType) = new Upload (stream, fileName, name, contentType, ownsStream = false)

/// Gets the stream associated to this Upload type.
member _.Stream = stream

/// Gets the content type of this Upload type.
member _.ContentType =
match contentType with
| Some ct -> ct
| None ->
let ext = Path.GetExtension(fileName)
match MimeTypes.dict.Force().TryGetValue(ext) with
| null ->
let ext = Path.GetExtension (fileName)
match MimeTypes.dict.Force().TryGetValue (ext) with
| (true, mime) -> mime
| _ -> "application/octet-stream"
| _ -> MediaTypeNames.Application.Octet
| ct -> ct

/// Gets the name used to uniquily identify upload throuout multiple uploads
/// and within a request.
member val Name =
name
|> ValueOption.ofObj
|> ValueOption.defaultWith (Guid.NewGuid >> string)

/// Gets the name of the file which contained on the stream.
member _.FileName = fileName

/// Gets a boolean value indicating if this Upload type owns the stream associated with it.
/// If true, it will dispose the stream when this Upload type is disposed.
member _.OwnsStream = defaultArg ownsStream false
member _.OwnsStream = ownsStream

interface IDisposable with
member x.Dispose() = if x.OwnsStream then x.Stream.Dispose()
member x.Dispose () =
if x.OwnsStream then
x.Stream.Dispose ()
Loading