diff --git a/ConsumePlugin/GeneratedPureGymDto.fs b/ConsumePlugin/GeneratedPureGymDto.fs index 192b7dd..411a9c5 100644 --- a/ConsumePlugin/GeneratedPureGymDto.fs +++ b/ConsumePlugin/GeneratedPureGymDto.fs @@ -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 () - let Visits = node.["visits"].AsValue().GetValue () - let Activities = node.["activities"].AsValue().GetValue () + let Duration = node.["Duration"].AsValue().GetValue () + let Visits = node.["Visits"].AsValue().GetValue () + let Activities = node.["Activities"].AsValue().GetValue () { 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 () - let Name = node.["name"].AsValue().GetValue () - let Id = node.["id"].AsValue().GetValue () + let Status = node.["Status"].AsValue().GetValue () + let Name = node.["Name"].AsValue().GetValue () + let Id = node.["Id"].AsValue().GetValue () { 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 () + let Gym = VisitGym.jsonParse node.["Gym"] + let Duration = node.["Duration"].AsValue().GetValue () let StartTime = - node.["startTime"].AsValue().GetValue () |> System.DateTime.Parse + node.["StartTime"].AsValue().GetValue () |> System.DateTime.Parse - let IsDurationEstimated = node.["isDurationEstimated"].AsValue().GetValue () + let IsDurationEstimated = node.["IsDurationEstimated"].AsValue().GetValue () { 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 diff --git a/ConsumePlugin/GeneratedRestClient.fs b/ConsumePlugin/GeneratedRestClient.fs index 2325ade..6c44f90 100644 --- a/ConsumePlugin/GeneratedRestClient.fs +++ b/ConsumePlugin/GeneratedRestClient.fs @@ -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 diff --git a/ConsumePlugin/PureGymDto.fs b/ConsumePlugin/PureGymDto.fs index 7659052..455ed88 100644 --- a/ConsumePlugin/PureGymDto.fs +++ b/ConsumePlugin/PureGymDto.fs @@ -128,38 +128,52 @@ type MemberActivityDto = [] type SessionsAggregate = { + [] Activities : int + [] Visits : int + [] Duration : int } [] type VisitGym = { + [] Id : int + [] Name : string + [] Status : string } [] type Visit = { + [] IsDurationEstimated : bool + [] StartTime : DateTime + [] Duration : int + [] Gym : VisitGym } [] type SessionsSummary = { + [] Total : SessionsAggregate + [] ThisWeek : SessionsAggregate } [] type Sessions = { + [] Summary : SessionsSummary + [] Visits : Visit list } diff --git a/ConsumePlugin/RestApiExample.fs b/ConsumePlugin/RestApiExample.fs index 950c1d0..c1ccc68 100644 --- a/ConsumePlugin/RestApiExample.fs +++ b/ConsumePlugin/RestApiExample.fs @@ -22,6 +22,7 @@ type IPureGymApi = [] abstract GetMemberActivity : ?ct : CancellationToken -> Task - [] + // We'll use this one to check handling of absolute URIs too + [] abstract GetSessions : - [] fromDate : DateTime * [] toDate : DateTime * ?ct : CancellationToken -> Task + [] fromDate : DateOnly * [] toDate : DateOnly * ?ct : CancellationToken -> Task diff --git a/MyriadPlugin.Test/HttpClient.fs b/MyriadPlugin.Test/HttpClient.fs new file mode 100644 index 0000000..a978424 --- /dev/null +++ b/MyriadPlugin.Test/HttpClient.fs @@ -0,0 +1,17 @@ +namespace MyriadPlugin.Test + +open System.Net.Http + +/// Simple implementation of an HttpClient. +type HttpClientMock (result : HttpRequestMessage -> Async) = + inherit HttpClient () + + override this.SendAsync (message, ct) = + Async.StartAsTask (result message, cancellationToken = ct) + +[] +module HttpClientMock = + let make (baseUrl : System.Uri) (handler : HttpRequestMessage -> Async) = + let result = new HttpClientMock (handler) + result.BaseAddress <- baseUrl + result diff --git a/MyriadPlugin.Test/MyriadPlugin.Test.fsproj b/MyriadPlugin.Test/MyriadPlugin.Test.fsproj index 30a1efa..437dcf9 100644 --- a/MyriadPlugin.Test/MyriadPlugin.Test.fsproj +++ b/MyriadPlugin.Test/MyriadPlugin.Test.fsproj @@ -8,10 +8,13 @@ - - - - + + + + + + + diff --git a/MyriadPlugin.Test/PureGymDtos.fs b/MyriadPlugin.Test/PureGymDtos.fs new file mode 100644 index 0000000..2effb70 --- /dev/null +++ b/MyriadPlugin.Test/PureGymDtos.fs @@ -0,0 +1,264 @@ +namespace MyriadPlugin.Test + +open PureGym +open System + +[] +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 ] diff --git a/MyriadPlugin.Test/TestPureGymJson.fs b/MyriadPlugin.Test/TestPureGymJson.fs index ce1f949..16b388b 100644 --- a/MyriadPlugin.Test/TestPureGymJson.fs +++ b/MyriadPlugin.Test/TestPureGymJson.fs @@ -1,4 +1,4 @@ -namespace PureGym.Test +namespace MyriadPlugin.Test open System open System.Text.Json.Nodes @@ -9,300 +9,59 @@ open PureGym [] 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 - [] + [] 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 - - [] + [] 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 - [] + [] 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 - [] + [] 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 - - [] + [] 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 [] 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 [] 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 [] 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 [] let ``Sessions JSON parse`` (json : string, expected : Sessions) = diff --git a/MyriadPlugin.Test/TestRestApi.fs b/MyriadPlugin.Test/TestRestApi.fs new file mode 100644 index 0000000..6192975 --- /dev/null +++ b/MyriadPlugin.Test/TestRestApi.fs @@ -0,0 +1,238 @@ +namespace MyriadPlugin.Test + +open System +open System.Net +open System.Net.Http +open NUnit.Framework +open PureGym +open FsUnitTyped + +[] +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 + + [] + 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 + + [] + 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 + + [] + 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 + + [] + 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 + + [] + 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}" + + [] + 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 diff --git a/README.md b/README.md index 20d051f..374f34e 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/WoofWare.Myriad.Plugins/HttpClientGenerator.fs b/WoofWare.Myriad.Plugins/HttpClientGenerator.fs index 03f9832..917fa77 100644 --- a/WoofWare.Myriad.Plugins/HttpClientGenerator.fs +++ b/WoofWare.Myriad.Plugins/HttpClientGenerator.fs @@ -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 - )) - requestUriTrailer - ), + 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",