Allow string return type, and URL-encode param (#38)

This commit is contained in:
Patrick Stevens
2023-12-29 18:13:39 +00:00
committed by GitHub
parent 548d863baf
commit dc0f0803b3
11 changed files with 282 additions and 121 deletions

View File

@@ -1,41 +1,41 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<MyriadSdkGenerator Include="$(MSBuildThisFileDirectory)..\WoofWare.Myriad.Plugins\bin\$(Configuration)\net6.0\WoofWare.Myriad.Plugins.dll" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<MyriadSdkGenerator Include="$(MSBuildThisFileDirectory)..\WoofWare.Myriad.Plugins\bin\$(Configuration)\net6.0\WoofWare.Myriad.Plugins.dll"/>
</ItemGroup>
<ItemGroup>
<None Include="myriad.toml" />
<Compile Include="RecordFile.fs" />
<Compile Include="GeneratedRecord.fs"> <!--1-->
<MyriadFile>RecordFile.fs</MyriadFile> <!--2-->
</Compile>
<Compile Include="JsonRecord.fs" />
<Compile Include="GeneratedJson.fs"> <!--1-->
<MyriadFile>JsonRecord.fs</MyriadFile> <!--2-->
</Compile>
<Compile Include="PureGymDto.fs" />
<Compile Include="GeneratedPureGymDto.fs">
<MyriadFile>PureGymDto.fs</MyriadFile> <!--2-->
</Compile>
<Compile Include="RestApiExample.fs" />
<Compile Include="GeneratedRestClient.fs">
<MyriadFile>RestApiExample.fs</MyriadFile> <!--2-->
</Compile>
<None Include="..\runmyriad.sh">
<Link>runmyriad.sh</Link>
</None>
</ItemGroup>
<ItemGroup>
<None Include="myriad.toml"/>
<Compile Include="RecordFile.fs"/>
<Compile Include="GeneratedRecord.fs"> <!--1-->
<MyriadFile>RecordFile.fs</MyriadFile> <!--2-->
</Compile>
<Compile Include="JsonRecord.fs"/>
<Compile Include="GeneratedJson.fs"> <!--1-->
<MyriadFile>JsonRecord.fs</MyriadFile> <!--2-->
</Compile>
<Compile Include="PureGymDto.fs"/>
<Compile Include="GeneratedPureGymDto.fs">
<MyriadFile>PureGymDto.fs</MyriadFile> <!--2-->
</Compile>
<Compile Include="RestApiExample.fs"/>
<Compile Include="GeneratedRestClient.fs">
<MyriadFile>RestApiExample.fs</MyriadFile> <!--2-->
</Compile>
<None Include="..\runmyriad.sh">
<Link>runmyriad.sh</Link>
</None>
</ItemGroup>
<ItemGroup>
<PackageReference Include="RestEase" Version="1.6.4" />
<ProjectReference Include="..\WoofWare.Myriad.Plugins\WoofWare.Myriad.Plugins.fsproj" />
<PackageReference Include="Myriad.Sdk" Version="0.8.3" />
<PackageReference Include="Myriad.Core" Version="0.8.3" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="RestEase" Version="1.6.4"/>
<ProjectReference Include="..\WoofWare.Myriad.Plugins\WoofWare.Myriad.Plugins.fsproj"/>
<PackageReference Include="Myriad.Sdk" Version="0.8.3"/>
<PackageReference Include="Myriad.Core" Version="0.8.3"/>
</ItemGroup>
</Project>

View File

@@ -51,7 +51,8 @@ module PureGymApi =
System.Uri (
client.BaseAddress,
System.Uri (
"v1/gyms/{gym_id}/attendance".Replace ("{gym_id}", gymId.ToString ()),
"v1/gyms/{gym_id}/attendance"
.Replace ("{gym_id}", gymId.ToString () |> System.Web.HttpUtility.UrlEncode),
System.UriKind.Relative
)
)
@@ -107,7 +108,8 @@ module PureGymApi =
System.Uri (
client.BaseAddress,
System.Uri (
"v1/gyms/{gym_id}".Replace ("{gym_id}", gymId.ToString ()),
"v1/gyms/{gym_id}"
.Replace ("{gym_id}", gymId.ToString () |> System.Web.HttpUtility.UrlEncode),
System.UriKind.Relative
)
)
@@ -189,4 +191,31 @@ module PureGymApi =
return Sessions.jsonParse node
}
|> (fun a -> Async.StartAsTask (a, ?cancellationToken = ct))
member _.GetPathParam (parameter : string, ct : CancellationToken option) =
async {
let! ct = Async.CancellationToken
let uri =
System.Uri (
client.BaseAddress,
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
)
let! response = client.SendAsync (httpMessage, ct) |> Async.AwaitTask
let response = response.EnsureSuccessStatusCode ()
let! node = response.Content.ReadAsStringAsync ct |> Async.AwaitTask
return node
}
|> (fun a -> Async.StartAsTask (a, ?cancellationToken = ct))
}

View File

@@ -26,3 +26,6 @@ type IPureGymApi =
[<Get "/v2/gymSessions/member">]
abstract GetSessions :
[<Query>] fromDate : DateOnly * [<Query>] toDate : DateOnly * ?ct : CancellationToken -> Task<Sessions>
[<Get "endpoint/{param}">]
abstract GetPathParam : [<Path "param">] parameter : string * ?ct : CancellationToken -> Task<string>

View File

@@ -10,9 +10,9 @@
<DebugType>embedded</DebugType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Nerdbank.GitVersioning" Version="3.6.128" PrivateAssets="all" />
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.1.1" PrivateAssets="All" />
<SourceLinkGitHubHost Include="github.com" ContentUrl="https://raw.githubusercontent.com" />
<PackageReference Include="Nerdbank.GitVersioning" Version="3.6.128" PrivateAssets="all"/>
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.1.1" PrivateAssets="All"/>
<SourceLinkGitHubHost Include="github.com" ContentUrl="https://raw.githubusercontent.com"/>
</ItemGroup>
<!--
SourceLink doesn't support F# deterministic builds out of the box,

View File

@@ -1,36 +1,36 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<Compile Include="HttpClient.fs"/>
<Compile Include="TestPathParam.fs" />
<Compile Include="TestSurface.fs"/>
<Compile Include="TestRemoveOptions.fs"/>
<Compile Include="TestJsonParse.fs"/>
<Compile Include="PureGymDtos.fs"/>
<Compile Include="TestPureGymJson.fs"/>
<Compile Include="TestPureGymRestApi.fs" />
</ItemGroup>
<ItemGroup>
<Compile Include="HttpClient.fs" />
<Compile Include="TestSurface.fs" />
<Compile Include="TestRemoveOptions.fs" />
<Compile Include="TestJsonParse.fs" />
<Compile Include="PureGymDtos.fs" />
<Compile Include="TestPureGymJson.fs" />
<Compile Include="TestRestApi.fs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="ApiSurface" Version="4.0.25"/>
<PackageReference Include="FsCheck" Version="2.16.6"/>
<PackageReference Include="FsUnit" Version="5.6.1"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.5.0"/>
<PackageReference Include="NUnit" Version="3.14.0"/>
<PackageReference Include="NUnit3TestAdapter" Version="4.4.2"/>
<PackageReference Include="NUnit.Analyzers" Version="3.6.1"/>
<PackageReference Include="coverlet.collector" Version="3.2.0"/>
</ItemGroup>
<ItemGroup>
<PackageReference Include="ApiSurface" Version="4.0.25" />
<PackageReference Include="FsCheck" Version="2.16.6" />
<PackageReference Include="FsUnit" Version="5.6.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.5.0" />
<PackageReference Include="NUnit" Version="3.14.0" />
<PackageReference Include="NUnit3TestAdapter" Version="4.4.2" />
<PackageReference Include="NUnit.Analyzers" Version="3.6.1" />
<PackageReference Include="coverlet.collector" Version="3.2.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\WoofWare.Myriad.Plugins\WoofWare.Myriad.Plugins.fsproj" />
<ProjectReference Include="..\ConsumePlugin\ConsumePlugin.fsproj" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\WoofWare.Myriad.Plugins\WoofWare.Myriad.Plugins.fsproj"/>
<ProjectReference Include="..\ConsumePlugin\ConsumePlugin.fsproj"/>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,36 @@
namespace MyriadPlugin.Test
open System
open System.Net
open System.Net.Http
open NUnit.Framework
open FsUnitTyped
open PureGym
[<TestFixture>]
module TestPathParam =
[<Test>]
let ``Path params are escaped`` () =
let proc (message : HttpRequestMessage) : HttpResponseMessage Async =
async {
message.Method |> shouldEqual HttpMethod.Get
let expectedUriPrefix = "https://example.com/endpoint/"
let actualUri = message.RequestUri.ToString ()
if not (actualUri.StartsWith (expectedUriPrefix, StringComparison.Ordinal)) then
failwith $"wrong prefix on %s{actualUri}"
let content = new StringContent (actualUri.Substring expectedUriPrefix.Length)
let resp = new HttpResponseMessage (HttpStatusCode.OK)
resp.Content <- content
return resp
}
use client = HttpClientMock.make (Uri "https://example.com") proc
let api = PureGymApi.make client
api.GetPathParam("hello/world?(hi)").Result
|> shouldEqual "hello%2fworld%3f(hi)"

View File

@@ -8,7 +8,7 @@ open PureGym
open FsUnitTyped
[<TestFixture>]
module TestRestApi =
module TestPureGymRestApi =
// several of these, to check behaviour around treatment of initial slashes
let baseUris =
[

View File

@@ -12,6 +12,7 @@ These are currently somewhat experimental, and I personally am their primary cus
The `RemoveOptions` generator in particular is extremely half-baked.
Currently implemented:
* `JsonParse` (to stamp out `jsonParse : JsonNode -> 'T` methods);
* `RemoveOptions` (to strip `option` modifiers from a type).
* `HttpClient` (to stamp out a [RestEase](https://github.com/canton7/RestEase)-style HTTP client).
@@ -21,7 +22,7 @@ Currently implemented:
Takes records like this:
```fsharp
[<MyriadPlugin.JsonParse>]
[<WoofWare.Myriad.Plugins.JsonParse>]
type InnerType =
{
[<JsonPropertyName "something">]
@@ -29,7 +30,7 @@ type InnerType =
}
/// My whatnot
[<MyriadPlugin.JsonParse>]
[<WoofWare.Myriad.Plugins.JsonParse>]
type JsonRecordType =
{
/// A thing!
@@ -78,7 +79,8 @@ module JsonRecordType =
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.
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
@@ -120,11 +122,13 @@ module Foo =
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.
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?
@@ -199,16 +203,70 @@ The motivating example is again ahead-of-time compilation: we wish to avoid the
### Limitations
RestEase is complex, and handles a lot of different stuff.
* As of this writing, `[<Body>]` is explicitly unsupported (it throws with a TODO).
* Parameters are serialised solely with `ToString`, and there's no control over this; nor is there control over encoding in any sense.
* Deserialisation follows the same logic as the `JsonParse` generator, and it generally assumes you're using types which `JsonParse` is applied to.
* Parameters are serialised solely with `ToString`, and there's no control over this;
nor is there control over encoding in any sense.
* 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.
* You have to specify the `BaseAddress` on the input client yourself, and you can't have the same client talking to a different `BaseAddress` this way unless you manually set it before making any different request.
* I haven't yet worked out how to integrate this with a mocked HTTP client; you can always mock up an `HttpClient`, but I prefer to use a mock which defines a single member `SendAsync`.
* You have to specify the `BaseAddress` on the input client yourself, and you can't have the same client talking to a
different `BaseAddress` this way unless you manually set it before making any different request.
* I haven't yet worked out how to integrate this with a mocked HTTP client; you can always mock up an `HttpClient`,
but I prefer to use a mock which defines a single member `SendAsync`.
* Anonymous parameters are currently forbidden.
* Every function must take an optional `CancellationToken` (which is good practice anyway); so arguments are forced to be tupled. This is a won't-fix for as long as F# requires tupled arguments if any of the args are optional.
* Every function must take an optional `CancellationToken` (which is good practice anyway);
so arguments are forced to be tupled.
This is a won't-fix for as long as F# requires tupled arguments if any of the args are optional.
# 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
<PropertyGroup>
<WoofWareMyriadPluginVersion>1.1.5</WoofWareMyriadPluginVersion>
</PropertyGroup>
```
* Take a reference on `WoofWare.Myriad.Plugins`:
```xml
<ItemGroup>
<PackageReference Include="WoofWare.Myriad.Plugins" Version="$(WoofWareMyriadPluginVersion)" />
</ItemGroup>
```
* Point Myriad to the DLL within the NuGet package which is the source of the plugins:
```xml
<ItemGroup>
<MyriadSdkGenerator Include="$(NuGetPackageRoot)/woofware.myriad.plugins/$(WoofWareMyriadPluginVersion)/lib/net6.0/WoofWare.Myriad.Plugins.dll" />
</ItemGroup>
```
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
<ItemGroup>
<Compile Include="Client.fs" />
<Compile Include="GeneratedClient.fs">
<MyriadFile>Client.fs</MyriadFile>
</Compile>
</ItemGroup>
```
### 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.

View File

@@ -105,6 +105,17 @@ module internal SynTypePatterns =
| _ -> None
| _ -> None
let (|String|_|) (fieldType : SynType) : unit option =
match fieldType with
| SynType.LongIdent ident ->
match ident.LongIdent with
| [ i ] ->
[ "string" ]
|> List.tryFind (fun s -> s = i.idText)
|> Option.map ignore<string>
| _ -> None
| _ -> None
let (|NumberType|_|) (fieldType : SynType) =
match fieldType with
| SynType.LongIdent ident ->

View File

@@ -202,6 +202,11 @@ module internal HttpClientGenerator =
[
SynExpr.CreateConstString ("{" + s + "}")
SynExpr.callMethod "ToString" (SynExpr.CreateIdent varName)
|> SynExpr.pipeThroughFunction (
SynExpr.CreateLongIdent (
SynLongIdent.Create [ "System" ; "Web" ; "HttpUtility" ; "UrlEncode" ]
)
)
])
| _ -> template
)
@@ -325,11 +330,14 @@ module internal HttpClientGenerator =
|> SynExpr.CreateParenedTuple
let returnExpr =
JsonParseGenerator.parseNode
None
JsonParseGenerator.JsonParseOption.None
info.ReturnType
(SynExpr.CreateIdentString "node")
match info.ReturnType with
| String -> SynExpr.CreateIdentString "node"
| _ ->
JsonParseGenerator.parseNode
None
JsonParseGenerator.JsonParseOption.None
info.ReturnType
(SynExpr.CreateIdentString "node")
let implementation =
[
@@ -390,37 +398,53 @@ module internal HttpClientGenerator =
SynExpr.CreateConst SynConst.Unit
)
)
yield
LetBang (
"stream",
SynExpr.awaitTask (
SynExpr.CreateApp (
SynExpr.CreateLongIdent (
SynLongIdent.Create [ "response" ; "Content" ; "ReadAsStreamAsync" ]
),
SynExpr.CreateIdentString "ct"
match info.ReturnType with
| String ->
yield
LetBang (
"node",
SynExpr.awaitTask (
SynExpr.CreateApp (
SynExpr.CreateLongIdent (
SynLongIdent.Create [ "response" ; "Content" ; "ReadAsStringAsync" ]
),
SynExpr.CreateIdentString "ct"
)
)
)
)
yield
LetBang (
"node",
SynExpr.awaitTask (
SynExpr.CreateApp (
SynExpr.CreateLongIdent (
SynLongIdent.Create
[ "System" ; "Text" ; "Json" ; "Nodes" ; "JsonNode" ; "ParseAsync" ]
),
SynExpr.CreateParenedTuple
[
SynExpr.CreateIdentString "stream"
SynExpr.equals
(SynExpr.CreateIdentString "cancellationToken")
(SynExpr.CreateIdentString "ct")
]
| _ ->
yield
LetBang (
"stream",
SynExpr.awaitTask (
SynExpr.CreateApp (
SynExpr.CreateLongIdent (
SynLongIdent.Create [ "response" ; "Content" ; "ReadAsStreamAsync" ]
),
SynExpr.CreateIdentString "ct"
)
)
)
yield
LetBang (
"node",
SynExpr.awaitTask (
SynExpr.CreateApp (
SynExpr.CreateLongIdent (
SynLongIdent.Create
[ "System" ; "Text" ; "Json" ; "Nodes" ; "JsonNode" ; "ParseAsync" ]
),
SynExpr.CreateParenedTuple
[
SynExpr.CreateIdentString "stream"
SynExpr.equals
(SynExpr.CreateIdentString "cancellationToken")
(SynExpr.CreateIdentString "ct")
]
)
)
)
)
]
|> SynExpr.createCompExpr "async" returnExpr
|> SynExpr.startAsTask

View File

@@ -18,20 +18,20 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Myriad.Core" Version="0.8.3" />
<PackageReference Include="Myriad.Core" Version="0.8.3"/>
<!-- the lowest version allowed by Myriad.Core -->
<PackageReference Update="FSharp.Core" Version="6.0.1" />
<PackageReference Update="FSharp.Core" Version="6.0.1"/>
</ItemGroup>
<ItemGroup>
<Compile Include="AstHelper.fs" />
<Compile Include="SynExpr.fs" />
<Compile Include="SynAttribute.fs" />
<Compile Include="RemoveOptionsGenerator.fs" />
<Compile Include="JsonParseGenerator.fs" />
<Compile Include="HttpClientGenerator.fs" />
<EmbeddedResource Include="version.json" />
<EmbeddedResource Include="SurfaceBaseline.txt" />
<Compile Include="AstHelper.fs"/>
<Compile Include="SynExpr.fs"/>
<Compile Include="SynAttribute.fs"/>
<Compile Include="RemoveOptionsGenerator.fs"/>
<Compile Include="JsonParseGenerator.fs"/>
<Compile Include="HttpClientGenerator.fs"/>
<EmbeddedResource Include="version.json"/>
<EmbeddedResource Include="SurfaceBaseline.txt"/>
<None Include="..\README.md">
<Pack>True</Pack>
<PackagePath>\</PackagePath>