namespace PureGym open System open System.Net.Http open System.Text.Json.Serialization open System.Threading.Tasks open RestEase /// Describes the opening hours of a given gym. type GymOpeningHours = { /// If this is true, there should be no OpeningHours (but nothing enforces that). IsAlwaysOpen : bool /// This is a pretty unstructured list, which is in general not really parseable: it's human-readable only. OpeningHours : string list } /// Human-readable representation override this.ToString () = if this.IsAlwaysOpen then "always open" else this.OpeningHours |> String.concat ", " /// How a human can authenticate with a gym when they physically try to enter it type GymAccessOptions = { /// This gym has PIN entry pads PinAccess : bool /// This gym has a QR code scanner. QR codes can be generated with the PureGym app. QrCodeAccess : bool } /// Human-readable representation override this.ToString () = $"Pin access: %c{Char.emoji this.PinAccess}; QR code access: %c{Char.emoji this.QrCodeAccess}" /// Where a gym is on the Earth type GymLocation = { /// Measured in degrees Longitude : float /// Measured in degrees Latitude : float } /// The postal address of a gym type GymAddress = { /// E.g. "Canterbury Court" [] AddressLine1 : string /// E.g. "Units 4, 4A, 5 And 5A" AddressLine2 : string /// E.g. "Kennington Park" AddressLine3 : string /// E.g. "LONDON" [] Town : string County : string /// E.g. "SW9 6DE" [] Postcode : string } /// Human-readable statement of the address override this.ToString () = [ yield Some this.AddressLine1 yield this.AddressLine2 |> Option.ofObj yield this.AddressLine3 |> Option.ofObj match this.County with | null -> yield Some $"%s{this.Town} %s{this.Postcode}" | county -> yield Some this.Town yield Some $"%s{county} %s{this.Postcode}" ] |> Seq.choose id |> String.concat "\n" /// Metadata about a physical gym type Gym = { // The following fields are returned but are always null // ReasonsToJoin : string // VirtualTourUrl : Uri // PersonalTrainersUrl : Uri // WebViewUrl : Uri // FloorPlanUrl : Uri // StaffMembers : string /// The name of this gym, e.g. "London Oval" [] Name : string /// This gym's ID in the PureGym system, e.g. 19 [] Id : int /// I don't know what this status is. Please tell me if you know! [] Status : int /// Postal address of this gym [] Address : GymAddress /// Phone number of this gym, e.g. "+44 1234 567890" [] PhoneNumber : string /// Contact email address for this gym's staff [] EmailAddress : string /// When this gym is open [] GymOpeningHours : GymOpeningHours /// How a human can physically authenticate when they physically enter this gym [] AccessOptions : GymAccessOptions /// Where this gym is physically located [] Location : GymLocation /// The IANA time zone this gym observes, e.g. "Europe/London" [] TimeZone : string /// This is a date-time in the format yyyy-MM-ddTHH:mm:ss+01 Europe/London ReopenDate : string } /// Human-readable representation of the most important information about this gym override this.ToString () = $"""%s{this.Name} (%i{this.Id}) {this.Address} %s{this.EmailAddress} %s{this.PhoneNumber} Opening hours: %s{string this.GymOpeningHours} %s{string this.AccessOptions} """ /// A human member of PureGym type Member = { /// This member's ID. This is a fairly large number. Id : int /// No idea what this is - please tell me if you know! CompoundMemberId : string /// First name, e.g. "Patrick" FirstName : string /// Last name, e.g. "Stevens" LastName : string /// ID of the gym designated as this user's home gym. This is also the "Id" field of the appropriate Gym object. HomeGymId : int /// The name of the gym designated as this user's home gym. This is also the "Name" field of the appropriate /// Gym object. HomeGymName : string /// This user's email address EmailAddress : string /// This user's gym access pin, probably 8 digits GymAccessPin : string /// This user's recorded date of birth DateOfBirth : DateOnly /// This user's phone number, human-readable MobileNumber : string /// This user's registered home postcode Postcode : string /// E.g. "Corporate" MembershipName : string MembershipLevel : int SuspendedReason : int MemberStatus : int } /// Statistics for how many people are currently at a gym type GymAttendance = { /// This appears always to be just equal to TotalPeopleInGym, but a string. [] Description : string /// How many people are in the gym as of this statistics snapshot [] TotalPeopleInGym : int /// How many people are in classes at the gym as of this statistics snapshot [] TotalPeopleInClasses : int TotalPeopleSuffix : string [] IsApproximate : bool /// When the query was received (I think) AttendanceTime : DateTime /// When the "total people in gym" snapshot was taken that is reported here LastRefreshed : DateTime /// When the "number of people in classes" snapshot was taken that is reported here LastRefreshedPeopleInClasses : DateTime /// Maximum capacity of the gym, or 0 if no listed capacity MaximumCapacity : int } /// Human-readable representation override this.ToString () = if not (Object.ReferenceEquals (this.TotalPeopleSuffix, null)) then failwith $"Unexpectedly got Total People Suffix: %s{this.TotalPeopleSuffix}" let capacity = if this.MaximumCapacity = 0 then "" else $" out of %i{this.MaximumCapacity} maximum" let classes = if this.TotalPeopleInClasses = 0 then "" else $"\n%i{this.TotalPeopleInClasses} in classes" $"""%i{this.TotalPeopleInGym} in gym%s{capacity} (is exact: %c{Char.emoji (not this.IsApproximate)})%s{classes} Query made at %s{this.AttendanceTime.ToString "s"}%s{this.AttendanceTime.ToString "zzz"} Snapshot correct as of %s{this.LastRefreshed.ToString "s"}%s{this.LastRefreshed.ToString "zzz"} Classes info correct as of %s{this.LastRefreshedPeopleInClasses.ToString "s"}%s{this.LastRefreshedPeopleInClasses.ToString "zzz"}""" /// The visit statistics for a particular human to a particular gym. /// The semantics of this class are basically unknown. type MemberActivityThisMonth = { /// How many minutes, including classes, have been logged so far this month TotalDurationMinutes : int /// How long, in minutes, each visit has been on average this month AverageDurationMinutes : int /// How many visits have been made this month, excluding classes TotalVisits : int /// How many classes have been attended this month TotalClasses : int /// Whether this block of statistics is estimated rather than exact IsEstimated : bool /// When this data was constructed LastRefreshed : DateTime } /// Don't use this type. It's public because System.Text.Json can't do private types. type MemberActivityDto = { [] TotalDuration : int [] AverageDuration : int [] TotalVisits : int [] TotalClasses : int [] IsEstimated : bool [] LastRefreshed : DateTime } member this.ToMemberActivity () = { TotalDurationMinutes = this.TotalDuration AverageDurationMinutes = this.AverageDuration TotalVisits = this.TotalVisits TotalClasses = this.TotalClasses IsEstimated = this.IsEstimated LastRefreshed = this.LastRefreshed } /// The PureGym REST API. You probably want to instantiate one of these with `Api.make`. [] type IPureGymApi = /// Get the complete list of all gyms known to PureGym. [] abstract GetGyms : unit -> Task /// Get information about the PureGym human whose credentials this client is authenticated with. [] abstract GetMember : unit -> Task /// Get information about how full the given gym currently is. The gym ID can be found from `GetGyms`. [] abstract GetGymAttendance : [] gymId : int -> Task /// Get information about a specific gym. [] abstract GetGym : [] gymId : int -> Task /// Get information about the activities logged against the currently authenticated PureGym human. [] abstract GetMemberActivity : unit -> Task // [] // abstract GetMemberActivityAll : unit -> Task /// Methods for interacting with the PureGym REST API. [] module Api = /// Create a REST client, authenticated as the specified user. let make (auth : Auth) : IPureGymApi Task = task { let! token = match auth with | Auth.Token t -> Task.FromResult<_> t | Auth.User cred -> AuthToken.get cred let client = new HttpClient () client.BaseAddress <- Uri "https://capi.puregym.com/api" client.DefaultRequestHeaders.Authorization <- Headers.AuthenticationHeaderValue ("Bearer", token.AccessToken) client.DefaultRequestHeaders.Add ("User-Agent", "PureGym/1523 CFNetwork/1312 Darwin/21.0.0") return RestClient.For client }