Fix treatment of slashes and add tests (#28)

This commit is contained in:
Patrick Stevens
2023-12-29 11:07:32 +00:00
committed by GitHub
parent b7a3f167b7
commit d4212ca887
11 changed files with 638 additions and 320 deletions

View File

@@ -266,9 +266,9 @@ namespace PureGym
module SessionsAggregate =
/// Parse from a JSON node.
let jsonParse (node : System.Text.Json.Nodes.JsonNode) : SessionsAggregate =
let Duration = node.["duration"].AsValue().GetValue<int> ()
let Visits = node.["visits"].AsValue().GetValue<int> ()
let Activities = node.["activities"].AsValue().GetValue<int> ()
let Duration = node.["Duration"].AsValue().GetValue<int> ()
let Visits = node.["Visits"].AsValue().GetValue<int> ()
let Activities = node.["Activities"].AsValue().GetValue<int> ()
{
Activities = Activities
@@ -283,9 +283,9 @@ namespace PureGym
module VisitGym =
/// Parse from a JSON node.
let jsonParse (node : System.Text.Json.Nodes.JsonNode) : VisitGym =
let Status = node.["status"].AsValue().GetValue<string> ()
let Name = node.["name"].AsValue().GetValue<string> ()
let Id = node.["id"].AsValue().GetValue<int> ()
let Status = node.["Status"].AsValue().GetValue<string> ()
let Name = node.["Name"].AsValue().GetValue<string> ()
let Id = node.["Id"].AsValue().GetValue<int> ()
{
Id = Id
@@ -300,13 +300,13 @@ namespace PureGym
module Visit =
/// Parse from a JSON node.
let jsonParse (node : System.Text.Json.Nodes.JsonNode) : Visit =
let Gym = VisitGym.jsonParse node.["gym"]
let Duration = node.["duration"].AsValue().GetValue<int> ()
let Gym = VisitGym.jsonParse node.["Gym"]
let Duration = node.["Duration"].AsValue().GetValue<int> ()
let StartTime =
node.["startTime"].AsValue().GetValue<string> () |> System.DateTime.Parse
node.["StartTime"].AsValue().GetValue<string> () |> System.DateTime.Parse
let IsDurationEstimated = node.["isDurationEstimated"].AsValue().GetValue<bool> ()
let IsDurationEstimated = node.["IsDurationEstimated"].AsValue().GetValue<bool> ()
{
IsDurationEstimated = IsDurationEstimated
@@ -322,8 +322,8 @@ namespace PureGym
module SessionsSummary =
/// Parse from a JSON node.
let jsonParse (node : System.Text.Json.Nodes.JsonNode) : SessionsSummary =
let ThisWeek = SessionsAggregate.jsonParse node.["thisWeek"]
let Total = SessionsAggregate.jsonParse node.["total"]
let ThisWeek = SessionsAggregate.jsonParse node.["ThisWeek"]
let Total = SessionsAggregate.jsonParse node.["Total"]
{
Total = Total
@@ -338,11 +338,11 @@ module Sessions =
/// Parse from a JSON node.
let jsonParse (node : System.Text.Json.Nodes.JsonNode) : Sessions =
let Visits =
node.["visits"].AsArray ()
node.["Visits"].AsArray ()
|> Seq.map (fun elt -> Visit.jsonParse elt)
|> List.ofSeq
let Summary = SessionsSummary.jsonParse node.["summary"]
let Summary = SessionsSummary.jsonParse node.["Summary"]
{
Summary = Summary

View File

@@ -22,10 +22,13 @@ module PureGymApi =
async {
let! ct = Async.CancellationToken
let uri =
System.Uri (client.BaseAddress, System.Uri ("v1/gyms/", System.UriKind.Relative))
let httpMessage =
new System.Net.Http.HttpRequestMessage (
Method = System.Net.Http.HttpMethod.Get,
RequestUri = System.Uri (client.BaseAddress.ToString () + "/v1/gyms/")
RequestUri = uri
)
let! response = client.SendAsync (httpMessage, ct) |> Async.AwaitTask
@@ -44,14 +47,19 @@ module PureGymApi =
async {
let! ct = Async.CancellationToken
let uri =
System.Uri (
client.BaseAddress,
System.Uri (
"v1/gyms/{gym_id}/attendance".Replace ("{gym_id}", gymId.ToString ()),
System.UriKind.Relative
)
)
let httpMessage =
new System.Net.Http.HttpRequestMessage (
Method = System.Net.Http.HttpMethod.Get,
RequestUri =
System.Uri (
client.BaseAddress.ToString ()
+ "/v1/gyms/{gym_id}/attendance".Replace ("{gym_id}", gymId.ToString ())
)
RequestUri = uri
)
let! response = client.SendAsync (httpMessage, ct) |> Async.AwaitTask
@@ -70,10 +78,13 @@ module PureGymApi =
async {
let! ct = Async.CancellationToken
let uri =
System.Uri (client.BaseAddress, System.Uri ("v1/member", System.UriKind.Relative))
let httpMessage =
new System.Net.Http.HttpRequestMessage (
Method = System.Net.Http.HttpMethod.Get,
RequestUri = System.Uri (client.BaseAddress.ToString () + "/v1/member")
RequestUri = uri
)
let! response = client.SendAsync (httpMessage, ct) |> Async.AwaitTask
@@ -92,14 +103,19 @@ module PureGymApi =
async {
let! ct = Async.CancellationToken
let uri =
System.Uri (
client.BaseAddress,
System.Uri (
"v1/gyms/{gym_id}".Replace ("{gym_id}", gymId.ToString ()),
System.UriKind.Relative
)
)
let httpMessage =
new System.Net.Http.HttpRequestMessage (
Method = System.Net.Http.HttpMethod.Get,
RequestUri =
System.Uri (
client.BaseAddress.ToString ()
+ "/v1/gyms/{gym_id}".Replace ("{gym_id}", gymId.ToString ())
)
RequestUri = uri
)
let! response = client.SendAsync (httpMessage, ct) |> Async.AwaitTask
@@ -118,10 +134,13 @@ module PureGymApi =
async {
let! ct = Async.CancellationToken
let uri =
System.Uri (client.BaseAddress, System.Uri ("v1/member/activity", System.UriKind.Relative))
let httpMessage =
new System.Net.Http.HttpRequestMessage (
Method = System.Net.Http.HttpMethod.Get,
RequestUri = System.Uri (client.BaseAddress.ToString () + "/v1/member/activity")
RequestUri = uri
)
let! response = client.SendAsync (httpMessage, ct) |> Async.AwaitTask
@@ -136,22 +155,27 @@ module PureGymApi =
}
|> (fun a -> Async.StartAsTask (a, ?cancellationToken = ct))
member _.GetSessions (fromDate : DateTime, toDate : DateTime, ct : CancellationToken option) =
member _.GetSessions (fromDate : DateOnly, toDate : DateOnly, ct : CancellationToken option) =
async {
let! ct = Async.CancellationToken
let uri =
System.Uri (
client.BaseAddress,
System.Uri (
("/v2/gymSessions/member"
+ "?fromDate="
+ ((fromDate.ToString "yyyy-MM-dd") |> System.Web.HttpUtility.UrlEncode)
+ "&toDate="
+ ((toDate.ToString "yyyy-MM-dd") |> System.Web.HttpUtility.UrlEncode)),
System.UriKind.Relative
)
)
let httpMessage =
new System.Net.Http.HttpRequestMessage (
Method = System.Net.Http.HttpMethod.Get,
RequestUri =
System.Uri (
client.BaseAddress.ToString ()
+ ("/v2/gymSessions/member"
+ "?fromDate="
+ ((fromDate.ToString "yyyy-MM-ddTHH:mm:ss") |> System.Web.HttpUtility.UrlEncode)
+ "&toDate="
+ ((toDate.ToString "yyyy-MM-ddTHH:mm:ss") |> System.Web.HttpUtility.UrlEncode))
)
RequestUri = uri
)
let! response = client.SendAsync (httpMessage, ct) |> Async.AwaitTask

View File

@@ -128,38 +128,52 @@ type MemberActivityDto =
[<WoofWare.Myriad.Plugins.JsonParse>]
type SessionsAggregate =
{
[<JsonPropertyName "Activities">]
Activities : int
[<JsonPropertyName "Visits">]
Visits : int
[<JsonPropertyName "Duration">]
Duration : int
}
[<WoofWare.Myriad.Plugins.JsonParse>]
type VisitGym =
{
[<JsonPropertyName "Id">]
Id : int
[<JsonPropertyName "Name">]
Name : string
[<JsonPropertyName "Status">]
Status : string
}
[<WoofWare.Myriad.Plugins.JsonParse>]
type Visit =
{
[<JsonPropertyName "IsDurationEstimated">]
IsDurationEstimated : bool
[<JsonPropertyName "StartTime">]
StartTime : DateTime
[<JsonPropertyName "Duration">]
Duration : int
[<JsonPropertyName "Gym">]
Gym : VisitGym
}
[<WoofWare.Myriad.Plugins.JsonParse>]
type SessionsSummary =
{
[<JsonPropertyName "Total">]
Total : SessionsAggregate
[<JsonPropertyName "ThisWeek">]
ThisWeek : SessionsAggregate
}
[<WoofWare.Myriad.Plugins.JsonParse>]
type Sessions =
{
[<JsonPropertyName "Summary">]
Summary : SessionsSummary
[<JsonPropertyName "Visits">]
Visits : Visit list
}

View File

@@ -22,6 +22,7 @@ type IPureGymApi =
[<Get "v1/member/activity">]
abstract GetMemberActivity : ?ct : CancellationToken -> Task<MemberActivityDto>
[<Get "v2/gymSessions/member">]
// We'll use this one to check handling of absolute URIs too
[<Get "/v2/gymSessions/member">]
abstract GetSessions :
[<Query>] fromDate : DateTime * [<Query>] toDate : DateTime * ?ct : CancellationToken -> Task<Sessions>
[<Query>] fromDate : DateOnly * [<Query>] toDate : DateOnly * ?ct : CancellationToken -> Task<Sessions>

View File

@@ -0,0 +1,17 @@
namespace MyriadPlugin.Test
open System.Net.Http
/// Simple implementation of an HttpClient.
type HttpClientMock (result : HttpRequestMessage -> Async<HttpResponseMessage>) =
inherit HttpClient ()
override this.SendAsync (message, ct) =
Async.StartAsTask (result message, cancellationToken = ct)
[<RequireQualifiedAccess>]
module HttpClientMock =
let make (baseUrl : System.Uri) (handler : HttpRequestMessage -> Async<HttpResponseMessage>) =
let result = new HttpClientMock (handler)
result.BaseAddress <- baseUrl
result

View File

@@ -8,10 +8,13 @@
</PropertyGroup>
<ItemGroup>
<Compile Include="TestPureGymJson.fs" />
<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>

View File

@@ -0,0 +1,264 @@
namespace MyriadPlugin.Test
open PureGym
open System
[<RequireQualifiedAccess>]
module PureGymDtos =
let gymOpeningHoursCases =
[
"""{"openingHours": [], "isAlwaysOpen": false}""",
{
GymOpeningHours.OpeningHours = []
IsAlwaysOpen = false
}
"""{"openingHours": ["something"], "isAlwaysOpen": false}""",
{
GymOpeningHours.OpeningHours = [ "something" ]
IsAlwaysOpen = false
}
]
let gymAccessOptionsCases =
List.allPairs [ true ; false ] [ true ; false ]
|> List.map (fun (a, b) ->
let s = sprintf """{"pinAccess": %b, "qrCodeAccess": %b}""" a b
s,
{
GymAccessOptions.PinAccess = a
QrCodeAccess = b
}
)
let gymAddressCases =
[
"""{"addressLine1": "", "postCode": "hi", "town": ""}""",
{
GymAddress.AddressLine1 = ""
AddressLine2 = None
AddressLine3 = None
County = None
Postcode = "hi"
Town = ""
}
"""{"addressLine1": "", "addressLine2": null, "postCode": "hi", "town": ""}""",
{
GymAddress.AddressLine1 = ""
AddressLine2 = None
AddressLine3 = None
County = None
Postcode = "hi"
Town = ""
}
]
let gymLocationCases =
[
"""{"latitude": 1.0, "longitude": 3.0}""",
{
GymLocation.Latitude = 1.0
Longitude = 3.0
}
]
let gymCases =
let ovalJson =
"""{"name":"London Oval","id":19,"status":2,"address":{"addressLine1":"Canterbury Court","addressLine2":"Units 4, 4A, 5 And 5A","addressLine3":"Kennington Park","town":"LONDON","county":null,"postcode":"SW9 6DE"},"phoneNumber":"+44 3444770005","emailAddress":"info.londonoval@puregym.com","staffMembers":null,"gymOpeningHours":{"isAlwaysOpen":true,"openingHours":[]},"reasonsToJoin":null,"accessOptions":{"pinAccess":true,"qrCodeAccess":true},"virtualTourUrl":null,"personalTrainersUrl":null,"webViewUrl":null,"floorPlanUrl":null,"location":{"longitude":"-0.110252","latitude":"51.480401"},"timeZone":"Europe/London","reopenDate":"2021-04-12T00:00:00+01 Europe/London"}"""
let oval =
{
Gym.Name = "London Oval"
Id = 19
Status = 2
Address =
{
AddressLine1 = "Canterbury Court"
AddressLine2 = Some "Units 4, 4A, 5 And 5A"
AddressLine3 = Some "Kennington Park"
Town = "LONDON"
County = None
Postcode = "SW9 6DE"
}
PhoneNumber = "+44 3444770005"
EmailAddress = "info.londonoval@puregym.com"
GymOpeningHours =
{
IsAlwaysOpen = true
OpeningHours = []
}
AccessOptions =
{
PinAccess = true
QrCodeAccess = true
}
Location =
{
Longitude = -0.110252
Latitude = 51.480401
}
TimeZone = "Europe/London"
ReopenDate = "2021-04-12T00:00:00+01 Europe/London"
}
[ ovalJson, oval ]
let memberCases =
let me =
{
Id = 1234567
CompoundMemberId = "12A123456"
FirstName = "Patrick"
LastName = "Stevens"
HomeGymId = 19
HomeGymName = "London Oval"
EmailAddress = "someone@somewhere"
GymAccessPin = "00000000"
DateOfBirth = DateOnly (1994, 01, 02)
MobileNumber = "+44 1234567"
Postcode = "W1A 1AA"
MembershipName = "Corporate"
MembershipLevel = 12
SuspendedReason = 0
MemberStatus = 2
}
let meJson =
"""{
"id": 1234567,
"compoundMemberId": "12A123456",
"firstName": "Patrick",
"lastName": "Stevens",
"homeGymId": 19,
"homeGymName": "London Oval",
"emailAddress": "someone@somewhere",
"gymAccessPin": "00000000",
"dateofBirth": "1994-01-02",
"mobileNumber": "+44 1234567",
"postCode": "W1A 1AA",
"membershipName": "Corporate",
"membershipLevel": 12,
"suspendedReason": 0,
"memberStatus": 2
}"""
[ meJson, me ]
let gymAttendanceCases =
let json =
"""{
"description": "65",
"totalPeopleInGym": 65,
"totalPeopleInClasses": 2,
"totalPeopleSuffix": null,
"isApproximate": false,
"attendanceTime": "2023-12-27T18:54:09.5101697",
"lastRefreshed": "2023-12-27T18:54:09.5101697Z",
"lastRefreshedPeopleInClasses": "2023-12-27T18:50:26.0782286Z",
"maximumCapacity": 0
}"""
let expected =
{
Description = "65"
TotalPeopleInGym = 65
TotalPeopleInClasses = 2
TotalPeopleSuffix = None
IsApproximate = false
AttendanceTime =
DateTime (2023, 12, 27, 18, 54, 09, 510, 169, DateTimeKind.Utc)
+ TimeSpan.FromTicks 7L
LastRefreshed =
DateTime (2023, 12, 27, 18, 54, 09, 510, 169, DateTimeKind.Utc)
+ TimeSpan.FromTicks 7L
LastRefreshedPeopleInClasses =
DateTime (2023, 12, 27, 18, 50, 26, 078, 228, DateTimeKind.Utc)
+ TimeSpan.FromTicks 6L
MaximumCapacity = 0
}
[ json, expected ]
let memberActivityDtoCases =
let json =
"""{"totalDuration":2217,"averageDuration":48,"totalVisits":46,"totalClasses":0,"isEstimated":false,"lastRefreshed":"2023-12-27T19:00:56.0309892Z"}"""
let value =
{
TotalDuration = 2217
AverageDuration = 48
TotalVisits = 46
TotalClasses = 0
IsEstimated = false
LastRefreshed =
DateTime (2023, 12, 27, 19, 00, 56, 030, 989, DateTimeKind.Utc)
+ TimeSpan.FromTicks 2L
}
[ json, value ]
let sessionsCases =
let json =
"""{
"Summary":{"Total":{"Activities":0,"Visits":10,"Duration":445},"ThisWeek":{"Activities":0,"Visits":0,"Duration":0}},
"Visits":[
{"IsDurationEstimated":false,"Gym":{"Id":19,"Name":"London Oval","Status":"Blocked","Location":null,"GymAccess":null,"ContactInfo":null,"TimeZone":null},"StartTime":"2023-12-21T10:12:00","Duration":50,"Name":null},
{"IsDurationEstimated":false,"Gym":{"Id":19,"Name":"London Oval","Status":"Blocked","Location":null,"GymAccess":null,"ContactInfo":null,"TimeZone":null},"StartTime":"2023-12-20T12:05:00","Duration":80,"Name":null},
{"IsDurationEstimated":false,"Gym":{"Id":19,"Name":"London Oval","Status":"Blocked","Location":null,"GymAccess":null,"ContactInfo":null,"TimeZone":null},"StartTime":"2023-12-17T19:37:00","Duration":46,"Name":null},
{"IsDurationEstimated":false,"Gym":{"Id":19,"Name":"London Oval","Status":"Blocked","Location":null,"GymAccess":null,"ContactInfo":null,"TimeZone":null},"StartTime":"2023-12-16T12:19:00","Duration":37,"Name":null},
{"IsDurationEstimated":false,"Gym":{"Id":19,"Name":"London Oval","Status":"Blocked","Location":null,"GymAccess":null,"ContactInfo":null,"TimeZone":null},"StartTime":"2023-12-15T11:14:00","Duration":47,"Name":null},
{"IsDurationEstimated":false,"Gym":{"Id":19,"Name":"London Oval","Status":"Blocked","Location":null,"GymAccess":null,"ContactInfo":null,"TimeZone":null},"StartTime":"2023-12-13T10:30:00","Duration":36,"Name":null},
{"IsDurationEstimated":false,"Gym":{"Id":19,"Name":"London Oval","Status":"Blocked","Location":null,"GymAccess":null,"ContactInfo":null,"TimeZone":null},"StartTime":"2023-12-10T16:18:00","Duration":32,"Name":null},
{"IsDurationEstimated":false,"Gym":{"Id":19,"Name":"London Oval","Status":"Blocked","Location":null,"GymAccess":null,"ContactInfo":null,"TimeZone":null},"StartTime":"2023-12-05T22:36:00","Duration":40,"Name":null},
{"IsDurationEstimated":false,"Gym":{"Id":19,"Name":"London Oval","Status":"Blocked","Location":null,"GymAccess":null,"ContactInfo":null,"TimeZone":null},"StartTime":"2023-12-03T17:59:00","Duration":48,"Name":null},
{"IsDurationEstimated":false,"Gym":{"Id":19,"Name":"London Oval","Status":"Blocked","Location":null,"GymAccess":null,"ContactInfo":null,"TimeZone":null},"StartTime":"2023-12-01T21:41:00","Duration":29,"Name":null}],
"Activities":[]}
"""
let singleVisit startTime duration =
{
IsDurationEstimated = false
Gym =
{
Id = 19
Name = "London Oval"
Status = "Blocked"
}
StartTime = startTime
Duration = duration
}
let expected =
{
Summary =
{
Total =
{
Activities = 0
Visits = 10
Duration = 445
}
ThisWeek =
{
Activities = 0
Visits = 0
Duration = 0
}
}
Visits =
[
singleVisit (DateTime (2023, 12, 21, 10, 12, 00)) 50
singleVisit (DateTime (2023, 12, 20, 12, 05, 00)) 80
singleVisit (DateTime (2023, 12, 17, 19, 37, 00)) 46
singleVisit (DateTime (2023, 12, 16, 12, 19, 00)) 37
singleVisit (DateTime (2023, 12, 15, 11, 14, 00)) 47
singleVisit (DateTime (2023, 12, 13, 10, 30, 00)) 36
singleVisit (DateTime (2023, 12, 10, 16, 18, 00)) 32
singleVisit (DateTime (2023, 12, 05, 22, 36, 00)) 40
singleVisit (DateTime (2023, 12, 03, 17, 59, 00)) 48
singleVisit (DateTime (2023, 12, 01, 21, 41, 00)) 29
]
}
[ json, expected ]

View File

@@ -1,4 +1,4 @@
namespace PureGym.Test
namespace MyriadPlugin.Test
open System
open System.Text.Json.Nodes
@@ -9,300 +9,59 @@ open PureGym
[<TestFixture>]
module TestPureGymJson =
let gymOpeningHoursCases =
[
"""{"openingHours": [], "isAlwaysOpen": false}""",
{
GymOpeningHours.OpeningHours = []
IsAlwaysOpen = false
}
"""{"openingHours": ["something"], "isAlwaysOpen": false}""",
{
GymOpeningHours.OpeningHours = [ "something" ]
IsAlwaysOpen = false
}
]
|> List.map TestCaseData
let gymOpeningHoursCases = PureGymDtos.gymOpeningHoursCases |> List.map TestCaseData
[<TestCaseSource(nameof (gymOpeningHoursCases))>]
[<TestCaseSource(nameof gymOpeningHoursCases)>]
let ``GymOpeningHours JSON parse`` (json : string, expected : GymOpeningHours) =
JsonNode.Parse json |> GymOpeningHours.jsonParse |> shouldEqual expected
let gymAccessOptionsCases =
List.allPairs [ true ; false ] [ true ; false ]
|> List.map (fun (a, b) ->
let s = sprintf """{"pinAccess": %b, "qrCodeAccess": %b}""" a b
PureGymDtos.gymAccessOptionsCases |> List.map TestCaseData
s,
{
GymAccessOptions.PinAccess = a
QrCodeAccess = b
}
)
|> List.map TestCaseData
[<TestCaseSource(nameof (gymAccessOptionsCases))>]
[<TestCaseSource(nameof gymAccessOptionsCases)>]
let ``GymAccessOptions JSON parse`` (json : string, expected : GymAccessOptions) =
JsonNode.Parse json |> GymAccessOptions.jsonParse |> shouldEqual expected
let gymLocationCases =
[
"""{"latitude": 1.0, "longitude": 3.0}""",
{
GymLocation.Latitude = 1.0
Longitude = 3.0
}
]
|> List.map TestCaseData
let gymLocationCases = PureGymDtos.gymLocationCases |> List.map TestCaseData
[<TestCaseSource(nameof (gymLocationCases))>]
[<TestCaseSource(nameof gymLocationCases)>]
let ``GymLocation JSON parse`` (json : string, expected : GymLocation) =
JsonNode.Parse json |> GymLocation.jsonParse |> shouldEqual expected
let gymAddressCases =
[
"""{"addressLine1": "", "postCode": "hi", "town": ""}""",
{
GymAddress.AddressLine1 = ""
AddressLine2 = None
AddressLine3 = None
County = None
Postcode = "hi"
Town = ""
}
"""{"addressLine1": "", "addressLine2": null, "postCode": "hi", "town": ""}""",
{
GymAddress.AddressLine1 = ""
AddressLine2 = None
AddressLine3 = None
County = None
Postcode = "hi"
Town = ""
}
]
|> List.map TestCaseData
let gymAddressCases = PureGymDtos.gymAddressCases |> List.map TestCaseData
[<TestCaseSource(nameof (gymAddressCases))>]
[<TestCaseSource(nameof gymAddressCases)>]
let ``GymAddress JSON parse`` (json : string, expected : GymAddress) =
JsonNode.Parse (json, Nullable (JsonNodeOptions (PropertyNameCaseInsensitive = true)))
|> GymAddress.jsonParse
|> shouldEqual expected
let gymCases =
let ovalJson =
"""{"name":"London Oval","id":19,"status":2,"address":{"addressLine1":"Canterbury Court","addressLine2":"Units 4, 4A, 5 And 5A","addressLine3":"Kennington Park","town":"LONDON","county":null,"postcode":"SW9 6DE"},"phoneNumber":"+44 3444770005","emailAddress":"info.londonoval@puregym.com","staffMembers":null,"gymOpeningHours":{"isAlwaysOpen":true,"openingHours":[]},"reasonsToJoin":null,"accessOptions":{"pinAccess":true,"qrCodeAccess":true},"virtualTourUrl":null,"personalTrainersUrl":null,"webViewUrl":null,"floorPlanUrl":null,"location":{"longitude":"-0.110252","latitude":"51.480401"},"timeZone":"Europe/London","reopenDate":"2021-04-12T00:00:00+01 Europe/London"}"""
let gymCases = PureGymDtos.gymCases |> List.map TestCaseData
let oval =
{
Gym.Name = "London Oval"
Id = 19
Status = 2
Address =
{
AddressLine1 = "Canterbury Court"
AddressLine2 = Some "Units 4, 4A, 5 And 5A"
AddressLine3 = Some "Kennington Park"
Town = "LONDON"
County = None
Postcode = "SW9 6DE"
}
PhoneNumber = "+44 3444770005"
EmailAddress = "info.londonoval@puregym.com"
GymOpeningHours =
{
IsAlwaysOpen = true
OpeningHours = []
}
AccessOptions =
{
PinAccess = true
QrCodeAccess = true
}
Location =
{
Longitude = -0.110252
Latitude = 51.480401
}
TimeZone = "Europe/London"
ReopenDate = "2021-04-12T00:00:00+01 Europe/London"
}
[ ovalJson, oval ] |> List.map TestCaseData
[<TestCaseSource(nameof (gymCases))>]
[<TestCaseSource(nameof gymCases)>]
let ``Gym JSON parse`` (json : string, expected : Gym) =
JsonNode.Parse json |> Gym.jsonParse |> shouldEqual expected
let memberCases =
let me =
{
Id = 1234567
CompoundMemberId = "12A123456"
FirstName = "Patrick"
LastName = "Stevens"
HomeGymId = 19
HomeGymName = "London Oval"
EmailAddress = "someone@somewhere"
GymAccessPin = "00000000"
DateOfBirth = DateOnly (1994, 01, 02)
MobileNumber = "+44 1234567"
Postcode = "W1A 1AA"
MembershipName = "Corporate"
MembershipLevel = 12
SuspendedReason = 0
MemberStatus = 2
}
let meJson =
"""{
"id": 1234567,
"compoundMemberId": "12A123456",
"firstName": "Patrick",
"lastName": "Stevens",
"homeGymId": 19,
"homeGymName": "London Oval",
"emailAddress": "someone@somewhere",
"gymAccessPin": "00000000",
"dateofBirth": "1994-01-02",
"mobileNumber": "+44 1234567",
"postCode": "W1A 1AA",
"membershipName": "Corporate",
"membershipLevel": 12,
"suspendedReason": 0,
"memberStatus": 2
}"""
[ meJson, me ] |> List.map TestCaseData
let memberCases = PureGymDtos.memberCases |> List.map TestCaseData
[<TestCaseSource(nameof memberCases)>]
let ``Member JSON parse`` (json : string, expected : Member) =
json |> JsonNode.Parse |> Member.jsonParse |> shouldEqual expected
let gymAttendanceCases =
let json =
"""{
"description": "65",
"totalPeopleInGym": 65,
"totalPeopleInClasses": 2,
"totalPeopleSuffix": null,
"isApproximate": false,
"attendanceTime": "2023-12-27T18:54:09.5101697",
"lastRefreshed": "2023-12-27T18:54:09.5101697Z",
"lastRefreshedPeopleInClasses": "2023-12-27T18:50:26.0782286Z",
"maximumCapacity": 0
}"""
let expected =
{
Description = "65"
TotalPeopleInGym = 65
TotalPeopleInClasses = 2
TotalPeopleSuffix = None
IsApproximate = false
AttendanceTime =
DateTime (2023, 12, 27, 18, 54, 09, 510, 169, DateTimeKind.Utc)
+ TimeSpan.FromTicks 7L
LastRefreshed =
DateTime (2023, 12, 27, 18, 54, 09, 510, 169, DateTimeKind.Utc)
+ TimeSpan.FromTicks 7L
LastRefreshedPeopleInClasses =
DateTime (2023, 12, 27, 18, 50, 26, 078, 228, DateTimeKind.Utc)
+ TimeSpan.FromTicks 6L
MaximumCapacity = 0
}
[ json, expected ] |> List.map TestCaseData
let gymAttendanceCases = PureGymDtos.gymAttendanceCases |> List.map TestCaseData
[<TestCaseSource(nameof gymAttendanceCases)>]
let ``GymAttendance JSON parse`` (json : string, expected : GymAttendance) =
json |> JsonNode.Parse |> GymAttendance.jsonParse |> shouldEqual expected
let memberActivityDtoCases =
let json =
"""{"totalDuration":2217,"averageDuration":48,"totalVisits":46,"totalClasses":0,"isEstimated":false,"lastRefreshed":"2023-12-27T19:00:56.0309892Z"}"""
let value =
{
TotalDuration = 2217
AverageDuration = 48
TotalVisits = 46
TotalClasses = 0
IsEstimated = false
LastRefreshed =
DateTime (2023, 12, 27, 19, 00, 56, 030, 989, DateTimeKind.Utc)
+ TimeSpan.FromTicks 2L
}
[ json, value ] |> List.map TestCaseData
PureGymDtos.memberActivityDtoCases |> List.map TestCaseData
[<TestCaseSource(nameof memberActivityDtoCases)>]
let ``MemberActivityDto JSON parse`` (json : string, expected : MemberActivityDto) =
json |> JsonNode.Parse |> MemberActivityDto.jsonParse |> shouldEqual expected
let sessionsCases =
let json =
"""{
"Summary":{"Total":{"Activities":0,"Visits":10,"Duration":445},"ThisWeek":{"Activities":0,"Visits":0,"Duration":0}},
"Visits":[
{"IsDurationEstimated":false,"Gym":{"Id":19,"Name":"London Oval","Status":"Blocked","Location":null,"GymAccess":null,"ContactInfo":null,"TimeZone":null},"StartTime":"2023-12-21T10:12:00","Duration":50,"Name":null},
{"IsDurationEstimated":false,"Gym":{"Id":19,"Name":"London Oval","Status":"Blocked","Location":null,"GymAccess":null,"ContactInfo":null,"TimeZone":null},"StartTime":"2023-12-20T12:05:00","Duration":80,"Name":null},
{"IsDurationEstimated":false,"Gym":{"Id":19,"Name":"London Oval","Status":"Blocked","Location":null,"GymAccess":null,"ContactInfo":null,"TimeZone":null},"StartTime":"2023-12-17T19:37:00","Duration":46,"Name":null},
{"IsDurationEstimated":false,"Gym":{"Id":19,"Name":"London Oval","Status":"Blocked","Location":null,"GymAccess":null,"ContactInfo":null,"TimeZone":null},"StartTime":"2023-12-16T12:19:00","Duration":37,"Name":null},
{"IsDurationEstimated":false,"Gym":{"Id":19,"Name":"London Oval","Status":"Blocked","Location":null,"GymAccess":null,"ContactInfo":null,"TimeZone":null},"StartTime":"2023-12-15T11:14:00","Duration":47,"Name":null},
{"IsDurationEstimated":false,"Gym":{"Id":19,"Name":"London Oval","Status":"Blocked","Location":null,"GymAccess":null,"ContactInfo":null,"TimeZone":null},"StartTime":"2023-12-13T10:30:00","Duration":36,"Name":null},
{"IsDurationEstimated":false,"Gym":{"Id":19,"Name":"London Oval","Status":"Blocked","Location":null,"GymAccess":null,"ContactInfo":null,"TimeZone":null},"StartTime":"2023-12-10T16:18:00","Duration":32,"Name":null},
{"IsDurationEstimated":false,"Gym":{"Id":19,"Name":"London Oval","Status":"Blocked","Location":null,"GymAccess":null,"ContactInfo":null,"TimeZone":null},"StartTime":"2023-12-05T22:36:00","Duration":40,"Name":null},
{"IsDurationEstimated":false,"Gym":{"Id":19,"Name":"London Oval","Status":"Blocked","Location":null,"GymAccess":null,"ContactInfo":null,"TimeZone":null},"StartTime":"2023-12-03T17:59:00","Duration":48,"Name":null},
{"IsDurationEstimated":false,"Gym":{"Id":19,"Name":"London Oval","Status":"Blocked","Location":null,"GymAccess":null,"ContactInfo":null,"TimeZone":null},"StartTime":"2023-12-01T21:41:00","Duration":29,"Name":null}],
"Activities":[]}
"""
let singleVisit startTime duration =
{
IsDurationEstimated = false
Gym =
{
Id = 19
Name = "London Oval"
Status = "Blocked"
}
StartTime = startTime
Duration = duration
}
let expected =
{
Summary =
{
Total =
{
Activities = 0
Visits = 10
Duration = 445
}
ThisWeek =
{
Activities = 0
Visits = 0
Duration = 0
}
}
Visits =
[
singleVisit (DateTime (2023, 12, 21, 10, 12, 00)) 50
singleVisit (DateTime (2023, 12, 20, 12, 05, 00)) 80
singleVisit (DateTime (2023, 12, 17, 19, 37, 00)) 46
singleVisit (DateTime (2023, 12, 16, 12, 19, 00)) 37
singleVisit (DateTime (2023, 12, 15, 11, 14, 00)) 47
singleVisit (DateTime (2023, 12, 13, 10, 30, 00)) 36
singleVisit (DateTime (2023, 12, 10, 16, 18, 00)) 32
singleVisit (DateTime (2023, 12, 05, 22, 36, 00)) 40
singleVisit (DateTime (2023, 12, 03, 17, 59, 00)) 48
singleVisit (DateTime (2023, 12, 01, 21, 41, 00)) 29
]
}
[ json, expected ] |> List.map TestCaseData
let sessionsCases = PureGymDtos.sessionsCases |> List.map TestCaseData
[<TestCaseSource(nameof sessionsCases)>]
let ``Sessions JSON parse`` (json : string, expected : Sessions) =

View File

@@ -0,0 +1,238 @@
namespace MyriadPlugin.Test
open System
open System.Net
open System.Net.Http
open NUnit.Framework
open PureGym
open FsUnitTyped
[<TestFixture>]
module TestRestApi =
// several of these, to check behaviour around treatment of initial slashes
let baseUris =
[
// Everything is relative to the root:
"https://example.com"
// Everything is also relative to the root, because `foo` is not a subdir:
"https://example.com/foo"
// Everything is relative to `foo`, because `foo` is a subdir
"https://example.com/foo/"
]
|> List.map Uri
let gymsCases =
PureGymDtos.gymCases
|> List.collect (fun (json, gym) -> [ $"[%s{json}]", [ gym ] ; $"[%s{json}, %s{json}]", [ gym ; gym ] ])
|> List.allPairs baseUris
|> List.map TestCaseData
[<TestCaseSource(nameof gymsCases)>]
let ``Test GetGyms`` (baseUri : Uri, (json : string, expected : Gym list)) =
let proc (message : HttpRequestMessage) : HttpResponseMessage Async =
async {
message.Method |> shouldEqual HttpMethod.Get
// URI is relative in the attribute on the IPureGymApi member,
// so this never gets redirected
let expectedUri =
match baseUri.ToString () with
| "https://example.com/" -> "https://example.com/v1/gyms/"
| "https://example.com/foo" -> "https://example.com/v1/gyms/"
| "https://example.com/foo/" -> "https://example.com/foo/v1/gyms/"
| s -> failwith $"Unrecognised base URI: %s{s}"
message.RequestUri.ToString () |> shouldEqual expectedUri
let content = new StringContent (json)
let resp = new HttpResponseMessage (HttpStatusCode.OK)
resp.Content <- content
return resp
}
use client = HttpClientMock.make baseUri proc
let api = PureGymApi.make client
api.GetGyms().Result |> shouldEqual expected
let gymAttendanceCases =
PureGymDtos.gymAttendanceCases
|> List.allPairs baseUris
|> List.map TestCaseData
[<TestCaseSource(nameof gymAttendanceCases)>]
let ``Test GetGymAttendance`` (baseUri : Uri, (json : string, expected : GymAttendance)) =
let requestedGym = 3
let proc (message : HttpRequestMessage) : HttpResponseMessage Async =
async {
message.Method |> shouldEqual HttpMethod.Get
// URI is relative in the attribute on the IPureGymApi member,
// so this never gets redirected
let expectedUri =
match baseUri.ToString () with
| "https://example.com/" -> $"https://example.com/v1/gyms/%i{requestedGym}/attendance"
| "https://example.com/foo" -> $"https://example.com/v1/gyms/%i{requestedGym}/attendance"
| "https://example.com/foo/" -> $"https://example.com/foo/v1/gyms/%i{requestedGym}/attendance"
| s -> failwith $"Unrecognised base URI: %s{s}"
message.RequestUri.ToString () |> shouldEqual expectedUri
let content = new StringContent (json)
let resp = new HttpResponseMessage (HttpStatusCode.OK)
resp.Content <- content
return resp
}
use client = HttpClientMock.make baseUri proc
let api = PureGymApi.make client
api.GetGymAttendance(requestedGym).Result |> shouldEqual expected
let memberCases =
PureGymDtos.memberCases |> List.allPairs baseUris |> List.map TestCaseData
[<TestCaseSource(nameof memberCases)>]
let ``Test GetMember`` (baseUri : Uri, (json : string, expected : Member)) =
let proc (message : HttpRequestMessage) : HttpResponseMessage Async =
async {
message.Method |> shouldEqual HttpMethod.Get
// URI is relative in the attribute on the IPureGymApi member,
// so this never gets redirected
let expectedUri =
match baseUri.ToString () with
| "https://example.com/" -> "https://example.com/v1/member"
| "https://example.com/foo" -> "https://example.com/v1/member"
| "https://example.com/foo/" -> "https://example.com/foo/v1/member"
| s -> failwith $"Unrecognised base URI: %s{s}"
message.RequestUri.ToString () |> shouldEqual expectedUri
let content = new StringContent (json)
let resp = new HttpResponseMessage (HttpStatusCode.OK)
resp.Content <- content
return resp
}
use client = HttpClientMock.make baseUri proc
let api = PureGymApi.make client
api.GetMember().Result |> shouldEqual expected
let gymCases =
PureGymDtos.gymCases |> List.allPairs baseUris |> List.map TestCaseData
[<TestCaseSource(nameof gymCases)>]
let ``Test GetGym`` (baseUri : Uri, (json : string, expected : Gym)) =
let requestedGym = 3
let proc (message : HttpRequestMessage) : HttpResponseMessage Async =
async {
message.Method |> shouldEqual HttpMethod.Get
// URI is relative in the attribute on the IPureGymApi member,
// so this never gets redirected
let expectedUri =
match baseUri.ToString () with
| "https://example.com/" -> $"https://example.com/v1/gyms/%i{requestedGym}"
| "https://example.com/foo" -> $"https://example.com/v1/gyms/%i{requestedGym}"
| "https://example.com/foo/" -> $"https://example.com/foo/v1/gyms/%i{requestedGym}"
| s -> failwith $"Unrecognised base URI: %s{s}"
message.RequestUri.ToString () |> shouldEqual expectedUri
let content = new StringContent (json)
let resp = new HttpResponseMessage (HttpStatusCode.OK)
resp.Content <- content
return resp
}
use client = HttpClientMock.make baseUri proc
let api = PureGymApi.make client
api.GetGym(requestedGym).Result |> shouldEqual expected
let memberActivityCases =
PureGymDtos.memberActivityDtoCases
|> List.allPairs baseUris
|> List.map TestCaseData
[<TestCaseSource(nameof memberActivityCases)>]
let ``Test GetMemberActivity`` (baseUri : Uri, (json : string, expected : MemberActivityDto)) =
let proc (message : HttpRequestMessage) : HttpResponseMessage Async =
async {
message.Method |> shouldEqual HttpMethod.Get
// URI is relative in the attribute on the IPureGymApi member,
// so this never gets redirected
let expectedUri =
match baseUri.ToString () with
| "https://example.com/" -> "https://example.com/v1/member/activity"
| "https://example.com/foo" -> "https://example.com/v1/member/activity"
| "https://example.com/foo/" -> "https://example.com/foo/v1/member/activity"
| s -> failwith $"Unrecognised base URI: %s{s}"
message.RequestUri.ToString () |> shouldEqual expectedUri
let content = new StringContent (json)
let resp = new HttpResponseMessage (HttpStatusCode.OK)
resp.Content <- content
return resp
}
use client = HttpClientMock.make baseUri proc
let api = PureGymApi.make client
api.GetMemberActivity().Result |> shouldEqual expected
let dates =
[
for month = 1 to 3 do
// span the number 12, to catch muddling up month and day
for day = 11 to 13 do
yield DateOnly (2023, month, day)
]
let sessionsCases =
PureGymDtos.sessionsCases
|> List.allPairs dates
|> List.allPairs dates
|> List.allPairs baseUris
|> List.map TestCaseData
let inline dateOnlyToString (d : DateOnly) : string =
let month = if d.Month < 10 then $"0%i{d.Month}" else $"%i{d.Month}"
let day = if d.Day < 10 then $"0%i{d.Day}" else $"%i{d.Day}"
$"{d.Year}-{month}-{day}"
[<TestCaseSource(nameof sessionsCases)>]
let ``Test GetSessions``
(
baseUri : Uri,
(startDate : DateOnly, (endDate : DateOnly, (json : string, expected : Sessions)))
)
=
let proc (message : HttpRequestMessage) : HttpResponseMessage Async =
async {
message.Method |> shouldEqual HttpMethod.Get
// This one is specified as being absolute, in its attribute on the IPureGymApi type
let expectedUri =
let fromDate = dateOnlyToString startDate
let toDate = dateOnlyToString endDate
$"https://example.com/v2/gymSessions/member?fromDate=%s{fromDate}&toDate=%s{toDate}"
message.RequestUri.ToString () |> shouldEqual expectedUri
let content = new StringContent (json)
let resp = new HttpResponseMessage (HttpStatusCode.OK)
resp.Content <- content
return resp
}
use client = HttpClientMock.make baseUri proc
let api = PureGymApi.make client
api.GetSessions(startDate, endDate).Result |> shouldEqual expected

View File

@@ -204,6 +204,7 @@ RestEase is complex, and handles a lot of different stuff.
* 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`.
* 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.

View File

@@ -184,8 +184,7 @@ module internal HttpClientGenerator =
)
let requestUriTrailer =
// TODO: more principled treatment of the slash
(SynExpr.CreateConstString ("/" + info.UrlTemplate.TrimStart '/'), info.Args)
(SynExpr.CreateConstString info.UrlTemplate, info.Args)
||> List.fold (fun template arg ->
(template, arg.Attributes)
||> List.fold (fun template attr ->
@@ -278,27 +277,24 @@ module internal HttpClientGenerator =
|> SynExpr.CreateParen
let requestUri =
let uriIdent = SynExpr.CreateLongIdent (SynLongIdent.Create [ "System" ; "Uri" ])
SynExpr.App (
ExprAtomicFlag.Atomic,
false,
SynExpr.CreateLongIdent (SynLongIdent.Create [ "System" ; "Uri" ]),
SynExpr.CreateParen (
SynExpr.plus
(SynExpr.App (
ExprAtomicFlag.Atomic,
false,
SynExpr.CreateLongIdent (
SynLongIdent.SynLongIdent (
[ Ident.Create "client" ; Ident.Create "BaseAddress" ; Ident.Create "ToString" ],
[ range0 ; range0 ],
[ None ; None ; None ]
)
),
SynExpr.CreateConst SynConst.Unit,
range0
))
uriIdent,
SynExpr.CreateParenedTuple
[
SynExpr.CreateLongIdent (SynLongIdent.Create [ "client" ; "BaseAddress" ])
SynExpr.CreateApp (
uriIdent,
SynExpr.CreateParenedTuple
[
requestUriTrailer
),
SynExpr.CreateLongIdent (SynLongIdent.Create [ "System" ; "UriKind" ; "Relative" ])
]
)
],
range0
)
@@ -324,7 +320,7 @@ module internal HttpClientGenerator =
SynLongIdent.Create
[ "System" ; "Net" ; "Http" ; "HttpMethod" ; httpMethodString info.HttpMethod ]
))
SynExpr.equals (SynExpr.CreateIdentString "RequestUri") requestUri
SynExpr.equals (SynExpr.CreateIdentString "RequestUri") (SynExpr.CreateIdentString "uri")
]
|> SynExpr.CreateParenedTuple
@@ -337,6 +333,7 @@ module internal HttpClientGenerator =
let implementation =
[
yield LetBang ("ct", SynExpr.CreateLongIdent (SynLongIdent.Create [ "Async" ; "CancellationToken" ]))
yield Let ("uri", requestUri)
yield
Use (
"httpMessage",