From f803b44311e5c2af3c1a5ff4883c68efdc406e7f Mon Sep 17 00:00:00 2001 From: Patrick Stevens <3138005+Smaug123@users.noreply.github.com> Date: Mon, 29 Jan 2024 21:24:41 +0000 Subject: [PATCH] Implement RestEase variable headers (#76) --- ConsumePlugin/GeneratedRestClient.fs | 63 ++ ConsumePlugin/RestApiExample.fs | 12 + README.md | 754 +++++++++--------- .../TestHttpClient/TestVariableHeader.fs | 108 +++ .../WoofWare.Myriad.Plugins.Test.fsproj | 1 + WoofWare.Myriad.Plugins/AstHelper.fs | 211 +++-- .../HttpClientGenerator.fs | 204 ++++- WoofWare.Myriad.Plugins/List.fs | 14 + .../WoofWare.Myriad.Plugins.fsproj | 1 + 9 files changed, 886 insertions(+), 482 deletions(-) create mode 100644 WoofWare.Myriad.Plugins.Test/TestHttpClient/TestVariableHeader.fs create mode 100644 WoofWare.Myriad.Plugins/List.fs diff --git a/ConsumePlugin/GeneratedRestClient.fs b/ConsumePlugin/GeneratedRestClient.fs index 5a090d7..0ba6fb9 100644 --- a/ConsumePlugin/GeneratedRestClient.fs +++ b/ConsumePlugin/GeneratedRestClient.fs @@ -1047,3 +1047,66 @@ module ApiWithBasePathAndAddress = } |> (fun a -> Async.StartAsTask (a, ?cancellationToken = ct)) } +namespace PureGym + +open System +open System.Threading +open System.Threading.Tasks +open System.IO +open System.Net +open System.Net.Http +open RestEase + +/// Module for constructing a REST client. +[] +[] +module ApiWithHeaders = + /// Create a REST client. The input functions will be re-evaluated on every HTTP request to obtain the required values for the corresponding header properties. + let make + (someHeader : unit -> string) + (someOtherHeader : unit -> int) + (client : System.Net.Http.HttpClient) + : IApiWithHeaders + = + { new IApiWithHeaders with + member _.SomeHeader : string = someHeader () + member _.SomeOtherHeader : int = someOtherHeader () + + member this.GetPathParam (parameter : string, ct : CancellationToken option) = + async { + let! ct = Async.CancellationToken + + let uri = + System.Uri ( + (match client.BaseAddress with + | null -> + raise ( + System.ArgumentNullException ( + nameof (client.BaseAddress), + "No base address was supplied on the type, and no BaseAddress was on the HttpClient." + ) + ) + | v -> v), + System.Uri ( + "endpoint/{param}" + .Replace ("{param}", parameter.ToString () |> System.Web.HttpUtility.UrlEncode), + System.UriKind.Relative + ) + ) + + let httpMessage = + new System.Net.Http.HttpRequestMessage ( + Method = System.Net.Http.HttpMethod.Get, + RequestUri = uri + ) + + do httpMessage.Headers.Add ("X-Foo", this.SomeHeader.ToString ()) + do httpMessage.Headers.Add ("Authorization", this.SomeOtherHeader.ToString ()) + do httpMessage.Headers.Add ("Header-Name", "Header-Value") + let! response = client.SendAsync (httpMessage, ct) |> Async.AwaitTask + let response = response.EnsureSuccessStatusCode () + let! responseString = response.Content.ReadAsStringAsync ct |> Async.AwaitTask + return responseString + } + |> (fun a -> Async.StartAsTask (a, ?cancellationToken = ct)) + } diff --git a/ConsumePlugin/RestApiExample.fs b/ConsumePlugin/RestApiExample.fs index f475cf3..6a62c46 100644 --- a/ConsumePlugin/RestApiExample.fs +++ b/ConsumePlugin/RestApiExample.fs @@ -125,3 +125,15 @@ type IApiWithBasePath = type IApiWithBasePathAndAddress = [] abstract GetPathParam : [] parameter : string * ?ct : CancellationToken -> Task + +[] +[] +type IApiWithHeaders = + [
] + abstract SomeHeader : string + + [
] + abstract SomeOtherHeader : int + + [] + abstract GetPathParam : [] parameter : string * ?ct : CancellationToken -> Task diff --git a/README.md b/README.md index e586540..9c9fbfa 100644 --- a/README.md +++ b/README.md @@ -1,375 +1,379 @@ -# WoofWare.Myriad.Plugins - -[![NuGet version](https://img.shields.io/nuget/v/WoofWare.Myriad.Plugins.svg?style=flat-square)](https://www.nuget.org/packages/WoofWare.Myriad.Plugins) -[![GitHub Actions status](https://github.com/Smaug123/WoofWare.Myriad/actions/workflows/dotnet.yaml/badge.svg)](https://github.com/Smaug123/WoofWare.Myriad/actions?query=branch%3Amain) -[![License file](https://img.shields.io/github/license/Smaug123/WoofWare.Myriad)](./LICENSE) - -![Project logo: the face of a cartoon Shiba Inu, staring with powerful cyborg eyes directly at the viewer, with a background of stylised plugs.](./WoofWare.Myriad.Plugins/logo.png) - -Some helpers in [Myriad](https://github.com/MoiraeSoftware/myriad/) which might be useful. - -These are currently somewhat experimental, and I personally am their primary customer. -The `RemoveOptions` generator in particular is extremely half-baked. - -If you would like to ensure that your particular use-case remains unbroken, please do contribute tests to this repository. -The `ConsumePlugin` assembly contains a number of invocations of these source generators, -so you just need to add copies of your types to that assembly to ensure that I will at least notice if I break the build; -and if you add tests to `WoofWare.Myriad.Plugins.Test` then I will also notice if I break the runtime semantics of the generated code. - -Currently implemented: - -* `JsonParse` (to stamp out `jsonParse : JsonNode -> 'T` methods); -* `JsonSerialize` (to stamp out `toJsonNode : 'T -> JsonNode` methods); -* `RemoveOptions` (to strip `option` modifiers from a type). -* `HttpClient` (to stamp out a [RestEase](https://github.com/canton7/RestEase)-style HTTP client). -* `GenerateMock` (to stamp out a record type corresponding to an interface). - -## `JsonParse` - -Takes records like this: - -```fsharp -[] -type InnerType = - { - [] - Thing : string - } - -/// My whatnot -[] -type JsonRecordType = - { - /// A thing! - A : int - /// Another thing! - B : string - [] - C : int list - D : InnerType - } - -``` - -and stamps out parsing methods like this: - -```fsharp -/// Module containing JSON parsing methods for the InnerType type -[] -[] -module InnerType = - /// Parse from a JSON node. - let jsonParse (node: System.Text.Json.Nodes.JsonNode) : InnerType = - let Thing = node.["something"].AsValue().GetValue() - { Thing = Thing } -namespace UsePlugin - -/// Module containing JSON parsing methods for the JsonRecordType type -[] -[] -module JsonRecordType = - /// Parse from a JSON node. - let jsonParse (node: System.Text.Json.Nodes.JsonNode) : JsonRecordType = - let D = InnerType.jsonParse node.["d"] - - let C = - node.["hi"].AsArray() |> Seq.map (fun elt -> elt.GetValue()) |> List.ofSeq - - let B = node.["b"].AsValue().GetValue() - let A = node.["a"].AsValue().GetValue() - { A = A; B = B; C = C; D = D } -``` - -You can optionally supply the boolean `true` to the attribute, -which will cause Myriad to stamp out an extension method rather than a module with the same name as the type. -This is useful if you want to reuse the type name as a module name yourself, -or if you want to apply multiple source generators which each want to use the module name. - -### What's the point? - -`System.Text.Json`, in a `PublishAot` context, relies on C# source generators. -The default reflection-heavy implementations have the necessary code trimmed away, and result in a runtime exception. -But C# source generators [are entirely unsupported in F#](https://github.com/dotnet/fsharp/issues/14300). - -This Myriad generator expects you to use `System.Text.Json` to construct a `JsonNode`, -and then the generator takes over to construct a strongly-typed object. - -### Limitations - -This source generator is enough for what I first wanted to use it for. -However, there is *far* more that could be done. - -* Make it possible to give an exact format and cultural info in date and time parsing. -* Make it possible to reject parsing if extra fields are present. -* Generally support all the `System.Text.Json` attributes. - -For an example of using both `JsonParse` and `JsonSerialize` together with complex types, see [the type definitions](./ConsumePlugin/SerializationAndDeserialization.fs) and [tests](./WoofWare.Myriad.Plugins.Test/TestJsonSerialize/TestJsonSerde.fs). - -## `JsonSerialize` - -Takes records like this: -```fsharp -[] -type InnerTypeWithBoth = - { - [] - Thing : string - ReadOnlyDict : IReadOnlyDictionary - } -``` - -and stamps out modules like this: -```fsharp -module InnerTypeWithBoth = - let toJsonNode (input : InnerTypeWithBoth) : System.Text.Json.Nodes.JsonNode = - let node = System.Text.Json.Nodes.JsonObject () - - do - node.Add (("it's-a-me"), System.Text.Json.Nodes.JsonValue.Create input.Thing) - - node.Add ( - "ReadOnlyDict", - (fun field -> - let ret = System.Text.Json.Nodes.JsonObject () - - for (KeyValue (key, value)) in field do - ret.Add (key.ToString (), System.Text.Json.Nodes.JsonValue.Create value) - - ret - ) input.Map - ) - - node -``` - -As in `JsonParse`, you can optionally supply the boolean `true` to the attribute, -which will cause Myriad to stamp out an extension method rather than a module with the same name as the type. - -The same limitations generally apply to `JsonSerialize` as do to `JsonParse`. - -For an example of using both `JsonParse` and `JsonSerialize` together with complex types, see [the type definitions](./ConsumePlugin/SerializationAndDeserialization.fs) and [tests](./WoofWare.Myriad.Plugins.Test/TestJsonSerialize/TestJsonSerde.fs). - -## `RemoveOptions` - -Takes a record like this: - -```fsharp -type Foo = - { - A : int option - B : string - C : float list - } -``` - -and stamps out a record like this: - -```fsharp -[] -module Foo = - type Short = - { - A : int - B : string - C : float list - } -``` - -### What's the point? - -The motivating example is argument parsing. -An argument parser naturally wants to express "the user did not supply this, so I will provide a default". -But it's not a very ergonomic experience for the programmer to deal with all these options, -so this Myriad generator stamps out a type *without* any options, -and also stamps out an appropriate constructor function. - -### Limitations - -This generator is *far* from where I want it, because I haven't really spent any time on it. - -* It really wants to be able to recurse into the types within the record, to strip options from them. -* It needs some sort of attribute to mark a field as *not* receiving this treatment. -* What do we do about discriminated unions? - -## `HttpClient` - -Takes a type like this: - -```fsharp -[] -type IPureGymApi = - [] - abstract GetGyms : ?ct : CancellationToken -> Task - - [] - abstract GetGymAttendance : [] gymId : int * ?ct : CancellationToken -> Task - - [] - abstract GetMember : ?ct : CancellationToken -> Task - - [] - abstract GetGym : [] gymId : int * ?ct : CancellationToken -> Task - - [] - abstract GetMemberActivity : ?ct : CancellationToken -> Task - - [] - abstract GetSessions : - [] fromDate : DateTime * [] toDate : DateTime * ?ct : CancellationToken -> Task -``` - -and stamps out a type like this: - -```fsharp -/// Module for constructing a REST client. -[] -[] -module PureGymApi = - /// Create a REST client. - let make (client : System.Net.Http.HttpClient) : IPureGymApi = - { new IPureGymApi with - member _.GetGyms (ct : CancellationToken option) = - async { - let! ct = Async.CancellationToken - - let httpMessage = - new System.Net.Http.HttpRequestMessage ( - Method = System.Net.Http.HttpMethod.Get, - RequestUri = System.Uri (client.BaseAddress.ToString () + "v1/gyms/") - ) - - let! response = client.SendAsync (httpMessage, ct) |> Async.AwaitTask - let response = response.EnsureSuccessStatusCode () - let! stream = response.Content.ReadAsStreamAsync ct |> Async.AwaitTask - - let! node = - System.Text.Json.Nodes.JsonNode.ParseAsync (stream, cancellationToken = ct) - |> Async.AwaitTask - - return node.AsArray () |> Seq.map (fun elt -> Gym.jsonParse elt) |> List.ofSeq - } - |> (fun a -> Async.StartAsTask (a, ?cancellationToken = ct)) - - // (more methods here) - } -``` - -### What's the point? - -The motivating example is again ahead-of-time compilation: we wish to avoid the reflection which RestEase does. - -### Limitations - -RestEase is complex, and handles a lot of different stuff. - -* If you set the `BaseAddress` on your input `HttpClient`, make sure to end with a trailing slash - on any trailing directories (so `"blah/foo/"` rather than `"blah/foo"`). - We combine URIs using `UriKind.Relative`, so without a trailing slash, the last component may be chopped off. -* Parameters are serialised naively with `toJsonNode` as though the `JsonSerialize` generator were applied, - and you can't control the serialisation. You can't yet serialise e.g. a primitive type this way (other than `String`); - all body parameters must be types which have a suitable `toJsonNode : 'a -> JsonNode` method. -* Deserialisation follows the same logic as the `JsonParse` generator, - and it generally assumes you're using types which `JsonParse` is applied to. -* Headers are not yet supported. -* Anonymous parameters are currently forbidden. - -There are also some design decisions: - -* Every function must take an optional `CancellationToken` (which is good practice anyway); - so arguments are forced to be tupled. - -## `GenerateMock` - -Takes a type like this: - -```fsharp -[] -type IPublicType = - abstract Mem1 : string * int -> string list - abstract Mem2 : string -> int -``` - -and stamps out a type like this: - -```fsharp -/// Mock record type for an interface -type internal PublicTypeMock = - { - Mem1 : string * int -> string list - Mem2 : string -> int - } - - static member Empty : PublicTypeMock = - { - Mem1 = (fun x -> raise (System.NotImplementedException "Unimplemented mock function")) - Mem2 = (fun x -> raise (System.NotImplementedException "Unimplemented mock function")) - } - - interface IPublicType with - member this.Mem1 (arg0, arg1) = this.Mem1 (arg0, arg1) - member this.Mem2 (arg0) = this.Mem2 (arg0) -``` - -### What's the point? - -Reflective mocking libraries like [Foq](https://github.com/fsprojects/Foq) in my experience are a rich source of flaky tests. -The [Grug-brained developer](https://grugbrain.dev/) would prefer to do this without reflection, and this reduces the rate of strange one-in-ten-thousand "failed to generate IL" errors. -But since F# does not let you partially update an interface definition, we instead stamp out a record, -thereby allowing the programmer to use F#'s record-update syntax. - -### Limitations - -* We make the resulting record type at most internal (never public), since this is intended only to be used in tests. - You will therefore need an `AssemblyInfo.fs` file [like the one in WoofWare.Myriad's own tests](./ConsumePlugin/AssemblyInfo.fs). - -# Detailed examples - -See the tests. -For example, [PureGymDto.fs](./ConsumePlugin/PureGymDto.fs) is a real-world set of DTOs. - -## How to use - -* In your `.fsproj` file, define a helper variable so that subsequent steps don't all have to be kept in sync: - ```xml - - 1.3.5 - - ``` -* Take a reference on `WoofWare.Myriad.Plugins`: - ```xml - - - - ``` -* Point Myriad to the DLL within the NuGet package which is the source of the plugins: - ```xml - - - - ``` - -Now you are ready to start using the generators. -For example, this specifies that Myriad is to use the contents of `Client.fs` to generate the file `GeneratedClient.fs`: - -```xml - - - - Client.fs - - -``` - -### Myriad Gotchas - -* MsBuild doesn't always realise that it needs to invoke Myriad during rebuild. - You can always save a whitespace change to the source file (e.g. `Client.fs` above), - and MsBuild will then execute Myriad during the next build. -* [Fantomas](https://github.com/fsprojects/fantomas), the F# source formatter which powers Myriad, - is customisable with [editorconfig](https://editorconfig.org/), - but it [does not easily expose](https://github.com/fsprojects/fantomas/issues/3031) this customisation - except through the standalone Fantomas client. - So Myriad's output is formatted without respect to any conventions which may hold in the rest of your repository. - You should probably add these files to your [fantomasignore](https://github.com/fsprojects/fantomas/blob/a999b77ca5a024fbc3409955faac797e29b39d27/docs/docs/end-users/IgnoreFiles.md) - if you use Fantomas to format your repo; - the alternative is to manually reformat every time Myriad changes the generated files. +# WoofWare.Myriad.Plugins + +[![NuGet version](https://img.shields.io/nuget/v/WoofWare.Myriad.Plugins.svg?style=flat-square)](https://www.nuget.org/packages/WoofWare.Myriad.Plugins) +[![GitHub Actions status](https://github.com/Smaug123/WoofWare.Myriad/actions/workflows/dotnet.yaml/badge.svg)](https://github.com/Smaug123/WoofWare.Myriad/actions?query=branch%3Amain) +[![License file](https://img.shields.io/github/license/Smaug123/WoofWare.Myriad)](./LICENSE) + +![Project logo: the face of a cartoon Shiba Inu, staring with powerful cyborg eyes directly at the viewer, with a background of stylised plugs.](./WoofWare.Myriad.Plugins/logo.png) + +Some helpers in [Myriad](https://github.com/MoiraeSoftware/myriad/) which might be useful. + +These are currently somewhat experimental, and I personally am their primary customer. +The `RemoveOptions` generator in particular is extremely half-baked. + +If you would like to ensure that your particular use-case remains unbroken, please do contribute tests to this repository. +The `ConsumePlugin` assembly contains a number of invocations of these source generators, +so you just need to add copies of your types to that assembly to ensure that I will at least notice if I break the build; +and if you add tests to `WoofWare.Myriad.Plugins.Test` then I will also notice if I break the runtime semantics of the generated code. + +Currently implemented: + +* `JsonParse` (to stamp out `jsonParse : JsonNode -> 'T` methods); +* `JsonSerialize` (to stamp out `toJsonNode : 'T -> JsonNode` methods); +* `RemoveOptions` (to strip `option` modifiers from a type). +* `HttpClient` (to stamp out a [RestEase](https://github.com/canton7/RestEase)-style HTTP client). +* `GenerateMock` (to stamp out a record type corresponding to an interface). + +## `JsonParse` + +Takes records like this: + +```fsharp +[] +type InnerType = + { + [] + Thing : string + } + +/// My whatnot +[] +type JsonRecordType = + { + /// A thing! + A : int + /// Another thing! + B : string + [] + C : int list + D : InnerType + } + +``` + +and stamps out parsing methods like this: + +```fsharp +/// Module containing JSON parsing methods for the InnerType type +[] +[] +module InnerType = + /// Parse from a JSON node. + let jsonParse (node: System.Text.Json.Nodes.JsonNode) : InnerType = + let Thing = node.["something"].AsValue().GetValue() + { Thing = Thing } +namespace UsePlugin + +/// Module containing JSON parsing methods for the JsonRecordType type +[] +[] +module JsonRecordType = + /// Parse from a JSON node. + let jsonParse (node: System.Text.Json.Nodes.JsonNode) : JsonRecordType = + let D = InnerType.jsonParse node.["d"] + + let C = + node.["hi"].AsArray() |> Seq.map (fun elt -> elt.GetValue()) |> List.ofSeq + + let B = node.["b"].AsValue().GetValue() + let A = node.["a"].AsValue().GetValue() + { A = A; B = B; C = C; D = D } +``` + +You can optionally supply the boolean `true` to the attribute, +which will cause Myriad to stamp out an extension method rather than a module with the same name as the type. +This is useful if you want to reuse the type name as a module name yourself, +or if you want to apply multiple source generators which each want to use the module name. + +### What's the point? + +`System.Text.Json`, in a `PublishAot` context, relies on C# source generators. +The default reflection-heavy implementations have the necessary code trimmed away, and result in a runtime exception. +But C# source generators [are entirely unsupported in F#](https://github.com/dotnet/fsharp/issues/14300). + +This Myriad generator expects you to use `System.Text.Json` to construct a `JsonNode`, +and then the generator takes over to construct a strongly-typed object. + +### Limitations + +This source generator is enough for what I first wanted to use it for. +However, there is *far* more that could be done. + +* Make it possible to give an exact format and cultural info in date and time parsing. +* Make it possible to reject parsing if extra fields are present. +* Generally support all the `System.Text.Json` attributes. + +For an example of using both `JsonParse` and `JsonSerialize` together with complex types, see [the type definitions](./ConsumePlugin/SerializationAndDeserialization.fs) and [tests](./WoofWare.Myriad.Plugins.Test/TestJsonSerialize/TestJsonSerde.fs). + +## `JsonSerialize` + +Takes records like this: +```fsharp +[] +type InnerTypeWithBoth = + { + [] + Thing : string + ReadOnlyDict : IReadOnlyDictionary + } +``` + +and stamps out modules like this: +```fsharp +module InnerTypeWithBoth = + let toJsonNode (input : InnerTypeWithBoth) : System.Text.Json.Nodes.JsonNode = + let node = System.Text.Json.Nodes.JsonObject () + + do + node.Add (("it's-a-me"), System.Text.Json.Nodes.JsonValue.Create input.Thing) + + node.Add ( + "ReadOnlyDict", + (fun field -> + let ret = System.Text.Json.Nodes.JsonObject () + + for (KeyValue (key, value)) in field do + ret.Add (key.ToString (), System.Text.Json.Nodes.JsonValue.Create value) + + ret + ) input.Map + ) + + node +``` + +As in `JsonParse`, you can optionally supply the boolean `true` to the attribute, +which will cause Myriad to stamp out an extension method rather than a module with the same name as the type. + +The same limitations generally apply to `JsonSerialize` as do to `JsonParse`. + +For an example of using both `JsonParse` and `JsonSerialize` together with complex types, see [the type definitions](./ConsumePlugin/SerializationAndDeserialization.fs) and [tests](./WoofWare.Myriad.Plugins.Test/TestJsonSerialize/TestJsonSerde.fs). + +## `RemoveOptions` + +Takes a record like this: + +```fsharp +type Foo = + { + A : int option + B : string + C : float list + } +``` + +and stamps out a record like this: + +```fsharp +[] +module Foo = + type Short = + { + A : int + B : string + C : float list + } +``` + +### What's the point? + +The motivating example is argument parsing. +An argument parser naturally wants to express "the user did not supply this, so I will provide a default". +But it's not a very ergonomic experience for the programmer to deal with all these options, +so this Myriad generator stamps out a type *without* any options, +and also stamps out an appropriate constructor function. + +### Limitations + +This generator is *far* from where I want it, because I haven't really spent any time on it. + +* It really wants to be able to recurse into the types within the record, to strip options from them. +* It needs some sort of attribute to mark a field as *not* receiving this treatment. +* What do we do about discriminated unions? + +## `HttpClient` + +Takes a type like this: + +```fsharp +[] +type IPureGymApi = + [] + abstract GetGyms : ?ct : CancellationToken -> Task + + [] + abstract GetGymAttendance : [] gymId : int * ?ct : CancellationToken -> Task + + [] + abstract GetMember : ?ct : CancellationToken -> Task + + [] + abstract GetGym : [] gymId : int * ?ct : CancellationToken -> Task + + [] + abstract GetMemberActivity : ?ct : CancellationToken -> Task + + [] + abstract GetSessions : + [] fromDate : DateTime * [] toDate : DateTime * ?ct : CancellationToken -> Task +``` + +and stamps out a type like this: + +```fsharp +/// Module for constructing a REST client. +[] +[] +module PureGymApi = + /// Create a REST client. + let make (client : System.Net.Http.HttpClient) : IPureGymApi = + { new IPureGymApi with + member _.GetGyms (ct : CancellationToken option) = + async { + let! ct = Async.CancellationToken + + let httpMessage = + new System.Net.Http.HttpRequestMessage ( + Method = System.Net.Http.HttpMethod.Get, + RequestUri = System.Uri (client.BaseAddress.ToString () + "v1/gyms/") + ) + + let! response = client.SendAsync (httpMessage, ct) |> Async.AwaitTask + let response = response.EnsureSuccessStatusCode () + let! stream = response.Content.ReadAsStreamAsync ct |> Async.AwaitTask + + let! node = + System.Text.Json.Nodes.JsonNode.ParseAsync (stream, cancellationToken = ct) + |> Async.AwaitTask + + return node.AsArray () |> Seq.map (fun elt -> Gym.jsonParse elt) |> List.ofSeq + } + |> (fun a -> Async.StartAsTask (a, ?cancellationToken = ct)) + + // (more methods here) + } +``` + +### What's the point? + +The motivating example is again ahead-of-time compilation: we wish to avoid the reflection which RestEase does. + +### Features + +* Variable and constant header values are supported: + see [the definition of `IApiWithHeaders`](./ConsumePlugin/RestApiExample.fs). + +### Limitations + +RestEase is complex, and handles a lot of different stuff. + +* If you set the `BaseAddress` on your input `HttpClient`, make sure to end with a trailing slash + on any trailing directories (so `"blah/foo/"` rather than `"blah/foo"`). + We combine URIs using `UriKind.Relative`, so without a trailing slash, the last component may be chopped off. +* Parameters are serialised naively with `toJsonNode` as though the `JsonSerialize` generator were applied, + and you can't control the serialisation. You can't yet serialise e.g. a primitive type this way (other than `String`); + all body parameters must be types which have a suitable `toJsonNode : 'a -> JsonNode` method. +* Deserialisation follows the same logic as the `JsonParse` generator, + and it generally assumes you're using types which `JsonParse` is applied to. +* Anonymous parameters are currently forbidden. + +There are also some design decisions: + +* Every function must take an optional `CancellationToken` (which is good practice anyway); + so arguments are forced to be tupled. + +## `GenerateMock` + +Takes a type like this: + +```fsharp +[] +type IPublicType = + abstract Mem1 : string * int -> string list + abstract Mem2 : string -> int +``` + +and stamps out a type like this: + +```fsharp +/// Mock record type for an interface +type internal PublicTypeMock = + { + Mem1 : string * int -> string list + Mem2 : string -> int + } + + static member Empty : PublicTypeMock = + { + Mem1 = (fun x -> raise (System.NotImplementedException "Unimplemented mock function")) + Mem2 = (fun x -> raise (System.NotImplementedException "Unimplemented mock function")) + } + + interface IPublicType with + member this.Mem1 (arg0, arg1) = this.Mem1 (arg0, arg1) + member this.Mem2 (arg0) = this.Mem2 (arg0) +``` + +### What's the point? + +Reflective mocking libraries like [Foq](https://github.com/fsprojects/Foq) in my experience are a rich source of flaky tests. +The [Grug-brained developer](https://grugbrain.dev/) would prefer to do this without reflection, and this reduces the rate of strange one-in-ten-thousand "failed to generate IL" errors. +But since F# does not let you partially update an interface definition, we instead stamp out a record, +thereby allowing the programmer to use F#'s record-update syntax. + +### Limitations + +* We make the resulting record type at most internal (never public), since this is intended only to be used in tests. + You will therefore need an `AssemblyInfo.fs` file [like the one in WoofWare.Myriad's own tests](./ConsumePlugin/AssemblyInfo.fs). + +# Detailed examples + +See the tests. +For example, [PureGymDto.fs](./ConsumePlugin/PureGymDto.fs) is a real-world set of DTOs. + +## How to use + +* In your `.fsproj` file, define a helper variable so that subsequent steps don't all have to be kept in sync: + ```xml + + 1.3.5 + + ``` +* Take a reference on `WoofWare.Myriad.Plugins`: + ```xml + + + + ``` +* Point Myriad to the DLL within the NuGet package which is the source of the plugins: + ```xml + + + + ``` + +Now you are ready to start using the generators. +For example, this specifies that Myriad is to use the contents of `Client.fs` to generate the file `GeneratedClient.fs`: + +```xml + + + + Client.fs + + +``` + +### Myriad Gotchas + +* MsBuild doesn't always realise that it needs to invoke Myriad during rebuild. + You can always save a whitespace change to the source file (e.g. `Client.fs` above), + and MsBuild will then execute Myriad during the next build. +* [Fantomas](https://github.com/fsprojects/fantomas), the F# source formatter which powers Myriad, + is customisable with [editorconfig](https://editorconfig.org/), + but it [does not easily expose](https://github.com/fsprojects/fantomas/issues/3031) this customisation + except through the standalone Fantomas client. + So Myriad's output is formatted without respect to any conventions which may hold in the rest of your repository. + You should probably add these files to your [fantomasignore](https://github.com/fsprojects/fantomas/blob/a999b77ca5a024fbc3409955faac797e29b39d27/docs/docs/end-users/IgnoreFiles.md) + if you use Fantomas to format your repo; + the alternative is to manually reformat every time Myriad changes the generated files. diff --git a/WoofWare.Myriad.Plugins.Test/TestHttpClient/TestVariableHeader.fs b/WoofWare.Myriad.Plugins.Test/TestHttpClient/TestVariableHeader.fs new file mode 100644 index 0000000..4c3368f --- /dev/null +++ b/WoofWare.Myriad.Plugins.Test/TestHttpClient/TestVariableHeader.fs @@ -0,0 +1,108 @@ +namespace WoofWare.Myriad.Plugins.Test + +open System +open System.Net +open System.Net.Http +open System.Threading +open NUnit.Framework +open FsUnitTyped +open PureGym + +[] +module TestVariableHeader = + + [] + let ``Headers are set`` () : unit = + let proc (message : HttpRequestMessage) : HttpResponseMessage Async = + async { + message.Method |> shouldEqual HttpMethod.Get + + message.RequestUri.ToString () + |> shouldEqual "https://example.com/endpoint/param" + + let headers = + [ + for h in message.Headers do + yield $"%s{h.Key}: %s{Seq.exactlyOne h.Value}" + ] + |> String.concat "\n" + + let content = new StringContent (headers) + let resp = new HttpResponseMessage (HttpStatusCode.OK) + resp.Content <- content + return resp + } + + use client = HttpClientMock.make (Uri "https://example.com") proc + + let someHeaderCount = ref 10 + + let someHeader () = + (Interlocked.Increment someHeaderCount : int).ToString () + + let someOtherHeaderCount = ref -100 + + let someOtherHeader () = + Interlocked.Increment someOtherHeaderCount + + let api = ApiWithHeaders.make someHeader someOtherHeader client + + someHeaderCount.Value |> shouldEqual 10 + someOtherHeaderCount.Value |> shouldEqual -100 + + api.GetPathParam("param").Result.Split "\n" + |> Array.sort + |> shouldEqual [| "Authorization: -99" ; "Header-Name: Header-Value" ; "X-Foo: 11" |] + + someHeaderCount.Value |> shouldEqual 11 + someOtherHeaderCount.Value |> shouldEqual -99 + + [] + let ``Headers get re-evaluated every time`` () : unit = + let proc (message : HttpRequestMessage) : HttpResponseMessage Async = + async { + message.Method |> shouldEqual HttpMethod.Get + + message.RequestUri.ToString () + |> shouldEqual "https://example.com/endpoint/param" + + let headers = + [ + for h in message.Headers do + yield $"%s{h.Key}: %s{Seq.exactlyOne h.Value}" + ] + |> String.concat "\n" + + let content = new StringContent (headers) + let resp = new HttpResponseMessage (HttpStatusCode.OK) + resp.Content <- content + return resp + } + + use client = HttpClientMock.make (Uri "https://example.com") proc + + let someHeaderCount = ref 10 + + let someHeader () = + (Interlocked.Increment someHeaderCount : int).ToString () + + let someOtherHeaderCount = ref -100 + + let someOtherHeader () = + Interlocked.Increment someOtherHeaderCount + + let api = ApiWithHeaders.make someHeader someOtherHeader client + + someHeaderCount.Value |> shouldEqual 10 + someOtherHeaderCount.Value |> shouldEqual -100 + + api.GetPathParam("param").Result.Split "\n" + |> Array.sort + |> shouldEqual [| "Authorization: -99" ; "Header-Name: Header-Value" ; "X-Foo: 11" |] + + api.GetPathParam("param").Result.Split "\n" + |> Array.sort + |> shouldEqual [| "Authorization: -98" ; "Header-Name: Header-Value" ; "X-Foo: 12" |] + + someHeaderCount.Value |> shouldEqual 12 + someOtherHeaderCount.Value |> shouldEqual -98 diff --git a/WoofWare.Myriad.Plugins.Test/WoofWare.Myriad.Plugins.Test.fsproj b/WoofWare.Myriad.Plugins.Test/WoofWare.Myriad.Plugins.Test.fsproj index 8eea57e..7a3f780 100644 --- a/WoofWare.Myriad.Plugins.Test/WoofWare.Myriad.Plugins.Test.fsproj +++ b/WoofWare.Myriad.Plugins.Test/WoofWare.Myriad.Plugins.Test.fsproj @@ -19,6 +19,7 @@ + diff --git a/WoofWare.Myriad.Plugins/AstHelper.fs b/WoofWare.Myriad.Plugins/AstHelper.fs index dc195b7..9339fde 100644 --- a/WoofWare.Myriad.Plugins/AstHelper.fs +++ b/WoofWare.Myriad.Plugins/AstHelper.fs @@ -33,11 +33,29 @@ type internal MemberInfo = IsMutable : bool } +[] +type internal PropertyAccessors = + | Get + | Set + | GetSet + +type internal PropertyInfo = + { + Type : SynType + Accessibility : SynAccess option + Attributes : SynAttribute list + XmlDoc : PreXmlDoc option + Accessors : PropertyAccessors + IsInline : bool + Identifier : Ident + } + type internal InterfaceType = { Attributes : SynAttribute list Name : LongIdent Members : MemberInfo list + Properties : PropertyInfo list Generics : SynTyparDecls option Accessibility : SynAccess option } @@ -230,6 +248,108 @@ module internal AstHelper = ((toFun (List.map fst inputArgs) inputRet), hasParen) :: args, ret | _ -> [], ty + let private parseMember (slotSig : SynValSig) (flags : SynMemberFlags) : Choice = + if not flags.IsInstance then + failwith "member was not an instance member" + + let propertyAccessors = + match flags.MemberKind with + | SynMemberKind.Member -> None + | SynMemberKind.PropertyGet -> Some PropertyAccessors.Get + | SynMemberKind.PropertySet -> Some PropertyAccessors.Set + | SynMemberKind.PropertyGetSet -> Some PropertyAccessors.GetSet + | kind -> failwithf "Unrecognised member kind: %+A" kind + + match slotSig with + | SynValSig (attrs, + SynIdent.SynIdent (ident, _), + _typeParams, + synType, + _arity, + isInline, + isMutable, + xmlDoc, + accessibility, + synExpr, + _, + _) -> + + match synExpr with + | Some _ -> failwith "literal members are not supported" + | None -> () + + let attrs = attrs |> List.collect _.Attributes + + let args, ret = getType synType + + let args = + args + |> List.map (fun (args, hasParen) -> + match args with + | SynType.Tuple (false, path, _) -> extractTupledTypes path + | SynType.SignatureParameter _ -> + let arg, hasParen = convertSigParam args + + { + HasParen = hasParen + Args = [ arg ] + } + | SynType.LongIdent (SynLongIdent (ident, _, _)) -> + { + HasParen = false + Args = + { + Attributes = [] + IsOptional = false + Id = None + Type = SynType.CreateLongIdent (SynLongIdent.CreateFromLongIdent ident) + } + |> List.singleton + } + | SynType.Var (typar, _) -> + { + HasParen = false + Args = + { + Attributes = [] + IsOptional = false + Id = None + Type = SynType.Var (typar, range0) + } + |> List.singleton + } + | _ -> failwith $"Unrecognised args in interface method declaration: %+A{args}" + |> fun ty -> + { ty with + HasParen = ty.HasParen || hasParen + } + ) + + match propertyAccessors with + | None -> + { + ReturnType = ret + Args = args + Identifier = ident + Attributes = attrs + XmlDoc = Some xmlDoc + Accessibility = accessibility + IsInline = isInline + IsMutable = isMutable + } + |> Choice1Of2 + | Some accessors -> + { + Type = ret + Accessibility = accessibility + Attributes = attrs + XmlDoc = Some xmlDoc + Accessors = accessors + IsInline = isInline + Identifier = ident + } + |> Choice2Of2 + /// Assumes that the input type is an ObjectModel, i.e. a `type Foo = member ...` let parseInterface (interfaceType : SynTypeDefn) : InterfaceType = let (SynTypeDefn (SynComponentInfo (attrs, typars, _, interfaceName, _, _, accessibility, _), @@ -242,104 +362,21 @@ module internal AstHelper = let attrs = attrs |> List.collect (fun s -> s.Attributes) - let members = + let members, properties = match synTypeDefnRepr with | SynTypeDefnRepr.ObjectModel (_kind, members, _) -> members |> List.map (fun defn -> match defn with - | SynMemberDefn.AbstractSlot (slotSig, flags, _, _) -> - match flags.MemberKind with - | SynMemberKind.Member -> () - | kind -> failwithf "Unrecognised member kind: %+A" kind - - if not flags.IsInstance then - failwith "member was not an instance member" - - match slotSig with - | SynValSig (attrs, - SynIdent.SynIdent (ident, _), - _typeParams, - synType, - arity, - isInline, - isMutable, - xmlDoc, - accessibility, - synExpr, - _, - _) -> - - match synExpr with - | Some _ -> failwith "literal members are not supported" - | None -> () - - let attrs = attrs |> List.collect (fun attr -> attr.Attributes) - - let args, ret = getType synType - - let args = - args - |> List.map (fun (args, hasParen) -> - match args with - | SynType.Tuple (false, path, _) -> extractTupledTypes path - | SynType.SignatureParameter _ -> - let arg, hasParen = convertSigParam args - - { - HasParen = hasParen - Args = [ arg ] - } - | SynType.LongIdent (SynLongIdent (ident, _, _)) -> - { - HasParen = false - Args = - { - Attributes = [] - IsOptional = false - Id = None - Type = - SynType.CreateLongIdent ( - SynLongIdent.CreateFromLongIdent ident - ) - } - |> List.singleton - } - | SynType.Var (typar, _) -> - { - HasParen = false - Args = - { - Attributes = [] - IsOptional = false - Id = None - Type = SynType.Var (typar, range0) - } - |> List.singleton - } - | _ -> failwith $"Unrecognised args in interface method declaration: %+A{args}" - |> fun ty -> - { ty with - HasParen = ty.HasParen || hasParen - } - ) - - { - ReturnType = ret - Args = args - Identifier = ident - Attributes = attrs - XmlDoc = Some xmlDoc - Accessibility = accessibility - IsInline = isInline - IsMutable = isMutable - } + | SynMemberDefn.AbstractSlot (slotSig, flags, _, _) -> parseMember slotSig flags | _ -> failwith $"Unrecognised member definition: %+A{defn}" ) | _ -> failwith $"Unrecognised SynTypeDefnRepr for an interface type: %+A{synTypeDefnRepr}" + |> List.partitionChoice { Members = members + Properties = properties Name = interfaceName Attributes = attrs Generics = typars diff --git a/WoofWare.Myriad.Plugins/HttpClientGenerator.fs b/WoofWare.Myriad.Plugins/HttpClientGenerator.fs index 982da34..17ebd55 100644 --- a/WoofWare.Myriad.Plugins/HttpClientGenerator.fs +++ b/WoofWare.Myriad.Plugins/HttpClientGenerator.fs @@ -2,7 +2,9 @@ namespace WoofWare.Myriad.Plugins open System open System.Net.Http +open System.Text open Fantomas.FCS.Syntax +open Fantomas.FCS.SyntaxTrivia open Fantomas.FCS.Xml open Myriad.Core @@ -125,6 +127,20 @@ module internal HttpClientGenerator = | matchingAttrs -> failwith $"Required exactly one recognised RestEase attribute on member, but got %i{matchingAttrs.Length}" + /// Get the args associated with the Header attributes within the list. + let extractHeaderInformation (attrs : SynAttribute list) : SynExpr list list = + attrs + |> List.choose (fun attr -> + match attr.TypeName.AsString with + | "Header" + | "RestEase.Header" -> + match attr.ArgExpr with + | SynExpr.Paren (SynExpr.Tuple (_, [ v1 ; v2 ], _, _), _, _, _) -> + Some [ SynExpr.stripOptionalParen v1 ; SynExpr.stripOptionalParen v2 ] + | e -> Some [ SynExpr.stripOptionalParen e ] + | _ -> None + ) + let shouldAllowAnyStatusCode (attrs : SynAttribute list) : bool = attrs |> List.exists (fun attr -> @@ -136,7 +152,14 @@ module internal HttpClientGenerator = | _ -> false ) - let constructMember (info : MemberInfo) : SynMemberDefn = + /// constantHeaders are a list of (headerName, headerValue) + /// variableHeaders are a list of (headerName, selfPropertyToGetValueOf) + let constructMember + (constantHeaders : (SynExpr * SynExpr) list) + (variableHeaders : (SynExpr * Ident) list) + (info : MemberInfo) + : SynMemberDefn + = let valInfo = SynValInfo.SynValInfo ( [ @@ -194,8 +217,10 @@ module internal HttpClientGenerator = |> SynArgPats.Pats let headPat = + let thisIdent = if variableHeaders.IsEmpty then "_" else "this" + SynPat.LongIdent ( - SynLongIdent.CreateFromLongIdent [ Ident.Create "_" ; info.Identifier ], + SynLongIdent.CreateFromLongIdent [ Ident.Create thisIdent ; info.Identifier ], None, None, argPats, @@ -561,6 +586,38 @@ module internal HttpClientGenerator = ) ) + let setVariableHeaders = + variableHeaders + |> List.map (fun (headerName, callToGetValue) -> + Do ( + SynExpr.CreateApp ( + SynExpr.CreateLongIdent (SynLongIdent.Create [ "httpMessage" ; "Headers" ; "Add" ]), + SynExpr.CreateParenedTuple + [ + headerName + SynExpr.CreateApp ( + SynExpr.CreateLongIdent ( + SynLongIdent.CreateFromLongIdent + [ Ident.Create "this" ; callToGetValue ; Ident.Create "ToString" ] + ), + SynExpr.CreateConst SynConst.Unit + ) + ] + ) + ) + ) + + let setConstantHeaders = + constantHeaders + |> List.map (fun (headerName, headerValue) -> + Do ( + SynExpr.CreateApp ( + SynExpr.CreateLongIdent (SynLongIdent.Create [ "httpMessage" ; "Headers" ; "Add" ]), + SynExpr.CreateParenedTuple [ headerName ; headerValue ] + ) + ) + ) + [ yield LetBang ("ct", SynExpr.CreateLongIdent (SynLongIdent.Create [ "Async" ; "CancellationToken" ])) yield Let ("uri", requestUri) @@ -579,6 +636,9 @@ module internal HttpClientGenerator = yield! handleBodyParams + yield! setVariableHeaders + yield! setConstantHeaders + yield LetBang ( "response", @@ -682,6 +742,12 @@ module internal HttpClientGenerator = | _ -> None ) + let lowerFirstLetter (x : Ident) : Ident = + let result = StringBuilder x.idText.Length + result.Append (Char.ToLowerInvariant x.idText.[0]) |> ignore + result.Append x.idText.[1..] |> ignore + Ident.Create ((result : StringBuilder).ToString ()) + let createModule (opens : SynOpenDeclTarget list) (ns : LongIdent) @@ -690,10 +756,48 @@ module internal HttpClientGenerator = = let interfaceType = AstHelper.parseInterface interfaceType + let constantHeaders = + interfaceType.Attributes + |> extractHeaderInformation + |> List.map (fun exprs -> + match exprs with + | [ key ; value ] -> key, value + | [] -> + failwith + "Expected constant header parameters to be of the form [
], but got no args" + | [ _ ] -> + failwith + "Expected constant header parameters to be of the form [
], but got only one arg" + | _ -> + failwith + "Expected constant header parameters to be of the form [
], but got more than two args" + ) + let baseAddress = extractBaseAddress interfaceType.Attributes let basePath = extractBasePath interfaceType.Attributes - let members = + let properties = + interfaceType.Properties + |> List.map (fun pi -> + let headerInfo = + match extractHeaderInformation pi.Attributes with + | [ [ x ] ] -> x + | [ xs ] -> + failwith + "Expected exactly one Header parameter on the member, with exactly one arg; got one Header parameter with non-1-many args" + | [] -> + failwith + "Expected exactly one Header parameter on the member, with exactly one arg; got no Header parameters" + | _ -> + failwith + "Expected exactly one Header parameter on the member, with exactly one arg; got multiple Header parameters" + + headerInfo, pi + ) + + let nonPropertyMembers = + let properties = properties |> List.map (fun (header, pi) -> header, pi.Identifier) + interfaceType.Members |> List.map (fun mem -> let httpMethod, url = extractHttpInformation mem.Attributes @@ -740,8 +844,57 @@ module internal HttpClientGenerator = Accessibility = mem.Accessibility } ) + |> List.map (constructMember constantHeaders properties) + + let propertyMembers = + properties + |> List.map (fun (_, pi) -> + SynMemberDefn.Member ( + SynBinding.SynBinding ( + pi.Accessibility, + SynBindingKind.Normal, + pi.IsInline, + false, + [], + PreXmlDoc.Empty, + SynValData.SynValData ( + Some + { + IsInstance = true + IsDispatchSlot = false + IsOverrideOrExplicitImpl = true + IsFinal = false + GetterOrSetterIsCompilerGenerated = false + MemberKind = SynMemberKind.Member + }, + SynValInfo.SynValInfo ([ [ SynArgInfo.Empty ] ; [] ], SynArgInfo.Empty), + None + ), + SynPat.CreateLongIdent ( + SynLongIdent.CreateFromLongIdent [ Ident.Create "_" ; pi.Identifier ], + [] + ), + Some (SynBindingReturnInfo.Create pi.Type), + SynExpr.CreateApp ( + SynExpr.CreateLongIdent ( + SynLongIdent.CreateFromLongIdent [ lowerFirstLetter pi.Identifier ] + ), + SynExpr.CreateConst SynConst.Unit + ), + range0, + DebugPointAtBinding.Yes range0, + { + LeadingKeyword = SynLeadingKeyword.Member range0 + InlineKeyword = if pi.IsInline then Some range0 else None + EqualsRange = Some range0 + } + ), + range0 + ) + ) + + let members = propertyMembers @ nonPropertyMembers - let constructed = members |> List.map constructMember let docString = PreXmlDoc.Create " Module for constructing a REST client." let interfaceImpl = @@ -750,12 +903,35 @@ module internal HttpClientGenerator = None, Some range0, [], - constructed, + members, [], range0, range0 ) + let headerArgs = + properties + |> List.map (fun (_, pi) -> + SynPat.CreateTyped ( + SynPat.CreateNamed (lowerFirstLetter pi.Identifier), + SynType.CreateFun (SynType.CreateLongIdent "unit", pi.Type) + ) + |> SynPat.CreateParen + ) + + let clientCreationArg = + SynPat.CreateTyped ( + SynPat.CreateNamed (Ident.Create "client"), + SynType.CreateLongIdent (SynLongIdent.Create [ "System" ; "Net" ; "Http" ; "HttpClient" ]) + ) + |> SynPat.CreateParen + + let xmlDoc = + if properties.IsEmpty then + " Create a REST client." + else + " Create a REST client. The input functions will be re-evaluated on every HTTP request to obtain the required values for the corresponding header properties." + let createFunc = SynBinding.SynBinding ( None, @@ -763,7 +939,7 @@ module internal HttpClientGenerator = false, false, [], - PreXmlDoc.Create " Create a REST client.", + PreXmlDoc.Create xmlDoc, SynValData.SynValData ( None, SynValInfo.SynValInfo ( @@ -772,19 +948,7 @@ module internal HttpClientGenerator = ), None ), - SynPat.CreateLongIdent ( - SynLongIdent.CreateString "make", - [ - SynPat.CreateParen ( - SynPat.CreateTyped ( - SynPat.CreateNamed (Ident.Create "client"), - SynType.CreateLongIdent ( - SynLongIdent.Create [ "System" ; "Net" ; "Http" ; "HttpClient" ] - ) - ) - ) - ] - ), + SynPat.CreateLongIdent (SynLongIdent.CreateString "make", headerArgs @ [ clientCreationArg ]), Some ( SynBindingReturnInfo.Create ( SynType.LongIdent (SynLongIdent.CreateFromLongIdent interfaceType.Name) @@ -800,7 +964,7 @@ module internal HttpClientGenerator = let moduleName : LongIdent = List.last interfaceType.Name - |> fun ident -> ident.idText + |> _.idText |> fun s -> if s.StartsWith 'I' then s.[1..] diff --git a/WoofWare.Myriad.Plugins/List.fs b/WoofWare.Myriad.Plugins/List.fs new file mode 100644 index 0000000..a723fc0 --- /dev/null +++ b/WoofWare.Myriad.Plugins/List.fs @@ -0,0 +1,14 @@ +namespace WoofWare.Myriad.Plugins + +[] +module private List = + let partitionChoice<'a, 'b> (xs : Choice<'a, 'b> list) : 'a list * 'b list = + let xs, ys = + (([], []), xs) + ||> List.fold (fun (xs, ys) v -> + match v with + | Choice1Of2 x -> x :: xs, ys + | Choice2Of2 y -> xs, y :: ys + ) + + List.rev xs, List.rev ys diff --git a/WoofWare.Myriad.Plugins/WoofWare.Myriad.Plugins.fsproj b/WoofWare.Myriad.Plugins/WoofWare.Myriad.Plugins.fsproj index d98c43d..70154a3 100644 --- a/WoofWare.Myriad.Plugins/WoofWare.Myriad.Plugins.fsproj +++ b/WoofWare.Myriad.Plugins/WoofWare.Myriad.Plugins.fsproj @@ -24,6 +24,7 @@ +