commit 42eb1f7726cefcec9d5c5c9d55f9c9a9836b6723 Author: Smaug123 Date: Wed Oct 11 00:17:48 2023 +0100 Initial commit diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..2a80148 --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "fantomas": { + "version": "6.2.0", + "commands": [ + "fantomas" + ] + } + } +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..9ef5fed --- /dev/null +++ b/.editorconfig @@ -0,0 +1,41 @@ +root=true + +[*] +charset=utf-8 +end_of_line=crlf +trim_trailing_whitespace=true +insert_final_newline=true +indent_style=space +indent_size=4 + +# ReSharper properties +resharper_xml_indent_size=2 +resharper_xml_max_line_length=100 +resharper_xml_tab_width=2 + +[*.{csproj,fsproj,sqlproj,targets,props,ts,tsx,css,json}] +indent_style=space +indent_size=2 + +[*.{fs,fsi}] +fsharp_bar_before_discriminated_union_declaration=true +fsharp_space_before_uppercase_invocation=true +fsharp_space_before_class_constructor=true +fsharp_space_before_member=true +fsharp_space_before_colon=true +fsharp_space_before_semicolon=true +fsharp_multiline_bracket_style=aligned +fsharp_newline_between_type_definition_and_members=true +fsharp_align_function_signature_to_indentation=true +fsharp_alternative_long_member_definitions=true +fsharp_multi_line_lambda_closing_newline=true +fsharp_experimental_keep_indent_in_branch=true +fsharp_max_value_binding_width=80 +fsharp_max_record_width=0 +max_line_length=120 +end_of_line=lf + +[*.{appxmanifest,build,dtd,nuspec,xaml,xamlx,xoml,xsd}] +indent_style=space +indent_size=2 +tab_width=2 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f32682c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +* eol=auto +*.sh text eol=lf +*.nix text eol=lf +hooks/pre-push text eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4a10cb9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +bin/ +obj/ +/packages/ +riderModule.iml +/_ReSharper.Caches/ +.idea/ +*.user +*.DotSettings +.DS_Store +result +.profile* + +node_modules/ +package.json +package-lock.json diff --git a/.woodpecker/.all-checks-complete.yml b/.woodpecker/.all-checks-complete.yml new file mode 100644 index 0000000..5a7fdd7 --- /dev/null +++ b/.woodpecker/.all-checks-complete.yml @@ -0,0 +1,10 @@ +steps: + echo: + image: alpine + commands: + - echo "All required checks complete" + +depends_on: + - build + +skip_clone: true diff --git a/.woodpecker/.build.yml b/.woodpecker/.build.yml new file mode 100644 index 0000000..0983b09 --- /dev/null +++ b/.woodpecker/.build.yml @@ -0,0 +1,22 @@ +steps: + build: + image: nixos/nix + commands: + - echo 'experimental-features = flakes nix-command' >> /etc/nix/nix.conf + # Lint + - "nix flake check" + # Test + - nix build + - nix run . -- --help + - nix run . -- auth --help + - nix run . -- lookup-gym --help + - nix run . -- fullness --help + - nix run . -- activity --help + - nix develop --command markdown-link-check README.md + - nix develop --command dotnet test + - nix develop --command dotnet test --configuration Release + + when: + - event: "push" + evaluate: 'CI_COMMIT_BRANCH == CI_REPO_DEFAULT_BRANCH' + - event: "pull_request" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e1d31cd --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Patrick Stevens + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/PureGym.App/ArgsCrate.fs b/PureGym.App/ArgsCrate.fs new file mode 100644 index 0000000..51aace5 --- /dev/null +++ b/PureGym.App/ArgsCrate.fs @@ -0,0 +1,21 @@ +namespace PureGym.App + +open System.Threading.Tasks +open Argu + +type ArgsEvaluator<'ret> = + abstract Eval<'a, 'b when 'b :> IArgParserTemplate> : + (ParseResults<'b> -> Result<'a, ArguParseException>) -> ('a -> Task) -> 'ret + +type ArgsCrate = + abstract Apply<'ret> : ArgsEvaluator<'ret> -> 'ret + +[] +module ArgsCrate = + let make<'a, 'b when 'b :> IArgParserTemplate> + (ofResult : ParseResults<'b> -> Result<'a, ArguParseException>) + (run : 'a -> Task) + = + { new ArgsCrate with + member _.Apply e = e.Eval ofResult run + } diff --git a/PureGym.App/AuthArg.fs b/PureGym.App/AuthArg.fs new file mode 100644 index 0000000..0ca15ec --- /dev/null +++ b/PureGym.App/AuthArg.fs @@ -0,0 +1,41 @@ +namespace PureGym.App + +open Argu +open PureGym + +type AuthArg = + | [] Bearer_Token of string + | [] User_Email of string + | [] Pin of string + | [] Others of string + + interface IArgParserTemplate with + member s.Usage = + match s with + | AuthArg.Bearer_Token _ -> "A bearer token for the PureGym API" + | AuthArg.User_Email _ -> "PureGym user's email address" + | AuthArg.Pin _ -> "Eight-digit PureGym user's PIN" + | AuthArg.Others _ -> "" + + static member Parse (args : ParseResults) : Result = + let unmatchedArgs = args.GetResults AuthArg.Others |> List.toArray + + match + args.TryGetResult AuthArg.User_Email, args.TryGetResult AuthArg.Pin, args.TryGetResult AuthArg.Bearer_Token + with + | Some email, Some pin, _ -> + let auth = + Auth.User + { + Username = email + Pin = pin + } + + Ok (auth, unmatchedArgs) + | Some _email, None, _ -> failwith "Supplied --user-email but no --pin; either both or neither are required." + | None, Some _pin, _ -> failwith "Supplied --pin but no --user-email; either both or neither are required." + | None, None, None -> + failwith "No creds given: expected at least one of `--bearer-token` or `--user-email --pin`" + | None, None, Some token -> + let auth = Auth.Token (AuthToken.ofBearerToken token) + Ok (auth, unmatchedArgs) diff --git a/PureGym.App/Authenticate.fs b/PureGym.App/Authenticate.fs new file mode 100644 index 0000000..ff0084a --- /dev/null +++ b/PureGym.App/Authenticate.fs @@ -0,0 +1,40 @@ +namespace PureGym.App + +open Argu +open System +open PureGym + +type GetTokenArg = + | [] User_Email of string + | [] Pin of string + + interface IArgParserTemplate with + member s.Usage = + match s with + | GetTokenArg.Pin _ -> "Eight-digit PureGym user's PIN" + | GetTokenArg.User_Email _ -> "PureGym user's email address" + + static member Parse (args : ParseResults) : Result = + try + { + Username = args.GetResult GetTokenArg.User_Email + Pin = args.GetResult GetTokenArg.Pin + } + |> Ok + with :? ArguParseException as e -> + Error e + +[] +module Authenticate = + + let run (creds : UsernamePin) = + task { + let! cred = AuthToken.get creds + Console.WriteLine cred.AccessToken + + match cred.ExpiryTime with + | None -> () + | Some expiry -> Console.Error.WriteLine $"Expires at {expiry}" + + return 0 + } diff --git a/PureGym.App/Exception.fs b/PureGym.App/Exception.fs new file mode 100644 index 0000000..dd6f16b --- /dev/null +++ b/PureGym.App/Exception.fs @@ -0,0 +1,11 @@ +namespace PureGym.App + +open System.Runtime.ExceptionServices + +[] +module Exception = + + let reraiseWithOriginalStackTrace<'a> (e : exn) : 'a = + let edi = ExceptionDispatchInfo.Capture e + edi.Throw () + failwith "unreachable" diff --git a/PureGym.App/Fullness.fs b/PureGym.App/Fullness.fs new file mode 100644 index 0000000..c303910 --- /dev/null +++ b/PureGym.App/Fullness.fs @@ -0,0 +1,61 @@ +namespace PureGym.App + +open Argu +open PureGym + +type FullnessArgsFragment = + | [] Gym_Id of int + | [] Gym_Name of string + | [] Terse of bool + + interface IArgParserTemplate with + member s.Usage = + match s with + | FullnessArgsFragment.Gym_Id _ -> "ID of the gym to look up, according to PureGym's internal mapping" + | FullnessArgsFragment.Gym_Name _ -> "Name of a gym (best-guess fuzzy matching)" + | FullnessArgsFragment.Terse _ -> "If true, output only the single number 'how many people are in the gym'." + +type FullnessArgs = + { + Creds : Auth + Gym : GymSelector + Terse : bool + } + + static member Parse + (auth : Auth) + (args : FullnessArgsFragment ParseResults) + : Result + = + let gym = + match args.TryGetResult FullnessArgsFragment.Gym_Id, args.TryGetResult FullnessArgsFragment.Gym_Name with + | Some _, Some _ -> failwith "--gym-id and --gym-name are mutually exclusive" + | None, None -> + System.Console.Error.WriteLine "No gym ID given and no gym named; assuming the user's home gym" + GymSelector.Home + | Some id, None -> GymSelector.Id id + | None, Some name -> GymSelector.Name name + + { + Creds = auth + Gym = gym + Terse = args.TryGetResult FullnessArgsFragment.Terse |> Option.defaultValue false + } + |> Ok + +[] +module Fullness = + + let run (args : FullnessArgs) = + task { + let! client = Api.make args.Creds + let! id = GymSelector.canonicalId client args.Gym + let! attendance = client.GetGymAttendance id + + if args.Terse then + System.Console.WriteLine attendance.TotalPeopleInGym + else + System.Console.WriteLine (string attendance) + + return 0 + } diff --git a/PureGym.App/LookupGym.fs b/PureGym.App/LookupGym.fs new file mode 100644 index 0000000..8803e4e --- /dev/null +++ b/PureGym.App/LookupGym.fs @@ -0,0 +1,51 @@ +namespace PureGym.App + +open Argu +open PureGym + +type LookupGymArgsFragment = + | [] Gym_Id of int + | [] Gym_Name of string + + interface IArgParserTemplate with + member s.Usage = + match s with + | LookupGymArgsFragment.Gym_Id _ -> "ID of the gym to look up, according to PureGym's internal mapping" + | LookupGymArgsFragment.Gym_Name _ -> "Name of a gym (best-guess fuzzy matching)" + +type LookupGymArgs = + { + Creds : Auth + Gym : GymSelector + } + + static member Parse + (auth : Auth) + (args : LookupGymArgsFragment ParseResults) + : Result + = + let gym = + match args.TryGetResult LookupGymArgsFragment.Gym_Id, args.TryGetResult LookupGymArgsFragment.Gym_Name with + | Some _, Some _ -> failwith "--gym-id and --gym-name are mutually exclusive" + | None, None -> + System.Console.Error.WriteLine "No gym ID given and no gym named; assuming the user's home gym" + GymSelector.Home + | Some id, None -> GymSelector.Id id + | None, Some name -> GymSelector.Name name + + { + Creds = auth + Gym = gym + } + |> Ok + +[] +module LookupGym = + + let run (args : LookupGymArgs) = + task { + let! client = Api.make args.Creds + let! s = client.GetGym 19 + System.Console.WriteLine (string s) + return 0 + } diff --git a/PureGym.App/MemberActivity.fs b/PureGym.App/MemberActivity.fs new file mode 100644 index 0000000..9c079b0 --- /dev/null +++ b/PureGym.App/MemberActivity.fs @@ -0,0 +1,38 @@ +namespace PureGym.App + +open Argu +open PureGym + +type MemberActivityArgsFragment = + | MemberActivityArgsFragment of bool + + interface IArgParserTemplate with + member s.Usage = + match s with + | MemberActivityArgsFragment _ -> "dummy argument: this subcommand has no args" + +type MemberActivityArgs = + { + Creds : Auth + } + + static member Parse + (auth : Auth) + (_ : MemberActivityArgsFragment ParseResults) + : Result + = + { + Creds = auth + } + |> Ok + +[] +module MemberActivity = + + let run (args : MemberActivityArgs) = + task { + let! client = Api.make args.Creds + let! activity = client.GetMemberActivity () + System.Console.WriteLine (string activity) + return 0 + } diff --git a/PureGym.App/Program.fs b/PureGym.App/Program.fs new file mode 100644 index 0000000..7443d71 --- /dev/null +++ b/PureGym.App/Program.fs @@ -0,0 +1,118 @@ +namespace PureGym.App + +open System +open Argu + +type Subcommand = + | RequiresAuth of (PureGym.Auth -> ArgsCrate) + | NoAuth of ArgsCrate + +module Program = + let subcommands = + [| + "auth", ("Get an authentication token", NoAuth (ArgsCrate.make GetTokenArg.Parse Authenticate.run)) + + "lookup-gym", + ("Get information about the physical instantiation of a gym", + RequiresAuth (fun auth -> ArgsCrate.make (LookupGymArgs.Parse auth) LookupGym.run)) + + "fullness", + ("Determine how full a gym is", + RequiresAuth (fun auth -> ArgsCrate.make (FullnessArgs.Parse auth) Fullness.run)) + + "activity", + ("Get information about your gym usage", + RequiresAuth (fun auth -> ArgsCrate.make (MemberActivityArgs.Parse auth) MemberActivity.run)) + |] + |> Map.ofArray + + [] + let main argv = + // It looks like Argu doesn't really support the combination of subcommands and read-from-env-vars, so we just + // roll our own. + + match Array.tryHead argv with + | None + | Some "--help" -> + subcommands.Keys + |> String.concat "," + |> eprintfn "Subcommands (try each with `--help`): %s" + + 0 + + | Some commandName -> + + match Map.tryFind commandName subcommands with + | None -> + subcommands.Keys + |> String.concat "," + |> eprintfn "Unrecognised command '%s'. Subcommands (try each with `--help`): %s" commandName + + 127 + + | Some (_help, command) -> + + let argv = Array.tail argv + let config = ConfigurationReader.FromEnvironmentVariables () + + let argv, command = + match command with + | RequiresAuth command -> + let authParser = ArgumentParser.Create () + + let extractedAuthParse, helpRequested = + try + authParser.Parse (argv, config, raiseOnUsage = true, ignoreUnrecognized = true), false + with :? ArguParseException as e -> + if e.Message.StartsWith ("USAGE:", StringComparison.OrdinalIgnoreCase) then + authParser.Parse (argv, config, raiseOnUsage = false, ignoreUnrecognized = true), true + else + reraise () + + let authArgs, argv = + if helpRequested then + let subcommandArgs = + ("--help" :: extractedAuthParse.GetResults AuthArg.Others) |> List.toArray + + Unchecked.defaultof<_>, subcommandArgs + else + match AuthArg.Parse extractedAuthParse with + | Ok a -> a + | Error e -> Exception.reraiseWithOriginalStackTrace e + + argv, command authArgs + | NoAuth command -> argv, command + + { new ArgsEvaluator<_> with + member _.Eval<'a, 'b when 'b :> IArgParserTemplate> (ofResult : ParseResults<'b> -> Result<'a, _>) run = + let parser = ArgumentParser.Create<'b> () + + let parsed = + try + parser.Parse (argv, config, raiseOnUsage = true) |> Ok + with :? ArguParseException as e -> + e.Message.Replace ("PureGym.App ", $"PureGym.App %s{commandName} ") + |> Console.Error.WriteLine + + if e.Message.StartsWith ("USAGE:", StringComparison.OrdinalIgnoreCase) then + Error true + else + Error false + + match parsed with + | Error false -> Error 127 + | Error true -> Error 0 + | Ok parsed -> + + match ofResult parsed with + | Error e -> + e.Message.Replace ("PureGym.App ", $"PureGym.App %s{commandName} ") + |> Console.Error.WriteLine + + Error 127 + | Ok args -> + + run args |> Ok + } + |> command.Apply + |> Result.cata (fun t -> t.Result) id diff --git a/PureGym.App/PureGym.App.fsproj b/PureGym.App/PureGym.App.fsproj new file mode 100644 index 0000000..1e026b5 --- /dev/null +++ b/PureGym.App/PureGym.App.fsproj @@ -0,0 +1,28 @@ + + + + Exe + net7.0 + + + + + + + + + + + + + + + + + + + + + + + diff --git a/PureGym.App/Result.fs b/PureGym.App/Result.fs new file mode 100644 index 0000000..57e2756 --- /dev/null +++ b/PureGym.App/Result.fs @@ -0,0 +1,9 @@ +namespace PureGym.App + +[] +module Result = + + let cata<'ok, 'err, 'result> onOk onError (r : Result<'ok, 'err>) : 'result = + match r with + | Ok ok -> onOk ok + | Error e -> onError e diff --git a/PureGym.Test/PureGym.Test.fsproj b/PureGym.Test/PureGym.Test.fsproj new file mode 100644 index 0000000..05ad172 --- /dev/null +++ b/PureGym.Test/PureGym.Test.fsproj @@ -0,0 +1,27 @@ + + + + net7.0 + + false + true + + + + + + + + + + + + + + + + + + + + diff --git a/PureGym.Test/TestSurface.fs b/PureGym.Test/TestSurface.fs new file mode 100644 index 0000000..b88460e --- /dev/null +++ b/PureGym.Test/TestSurface.fs @@ -0,0 +1,21 @@ +namespace PureGym.Test + +open PureGym +open NUnit.Framework +open ApiSurface + +[] +module TestSurface = + + let assembly = typeof.Assembly + + [] + let ``Ensure API surface has not been modified`` () = ApiSurface.assertIdentical assembly + + [] + let ``Update API surface`` () = + ApiSurface.writeAssemblyBaseline assembly + + [] + let ``Ensure public API is fully documented`` () = + DocCoverage.assertFullyDocumented assembly diff --git a/PureGym.sln b/PureGym.sln new file mode 100644 index 0000000..034b8a5 --- /dev/null +++ b/PureGym.sln @@ -0,0 +1,28 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "PureGym", "PureGym\PureGym.fsproj", "{BDF66CFC-5F2C-4AC4-A9FE-FB3B135E47FF}" +EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "PureGym.App", "PureGym.App\PureGym.App.fsproj", "{B4848668-4CB2-4919-987D-8C95F865C81E}" +EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "PureGym.Test", "PureGym.Test\PureGym.Test.fsproj", "{F09DF609-5F53-4BB3-BD64-DDB136CD4D2E}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {BDF66CFC-5F2C-4AC4-A9FE-FB3B135E47FF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BDF66CFC-5F2C-4AC4-A9FE-FB3B135E47FF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BDF66CFC-5F2C-4AC4-A9FE-FB3B135E47FF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BDF66CFC-5F2C-4AC4-A9FE-FB3B135E47FF}.Release|Any CPU.Build.0 = Release|Any CPU + {B4848668-4CB2-4919-987D-8C95F865C81E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B4848668-4CB2-4919-987D-8C95F865C81E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B4848668-4CB2-4919-987D-8C95F865C81E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B4848668-4CB2-4919-987D-8C95F865C81E}.Release|Any CPU.Build.0 = Release|Any CPU + {F09DF609-5F53-4BB3-BD64-DDB136CD4D2E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F09DF609-5F53-4BB3-BD64-DDB136CD4D2E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F09DF609-5F53-4BB3-BD64-DDB136CD4D2E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F09DF609-5F53-4BB3-BD64-DDB136CD4D2E}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/PureGym/Api.fs b/PureGym/Api.fs new file mode 100644 index 0000000..54944f6 --- /dev/null +++ b/PureGym/Api.fs @@ -0,0 +1,272 @@ +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 = + { + [] + 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 MemberActivity = + { + /// ??? semantics unknown; this was 2852 for me + [] + TotalDurationMinutes : int + [] + AverageDurationMinutes : int + TotalVisits : int + TotalClasses : int + IsEstimated : bool + LastRefreshed : DateTime + } + +/// 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 + +/// 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 + } diff --git a/PureGym/Auth.fs b/PureGym/Auth.fs new file mode 100644 index 0000000..4aca107 --- /dev/null +++ b/PureGym/Auth.fs @@ -0,0 +1,97 @@ +namespace PureGym + +open System +open System.Collections.Generic +open System.Net.Http +open System.Text.Json +open System.Text.Json.Serialization +open System.Threading.Tasks + +// System.Text.Json does not support internal F# records as of .NET 8, presumably because it can't find the constructor. +// So we end up with this gruesome soup of C#. +// TODO(net8): make this internal +type AuthResponseRaw [] (access_token : string, expires_in : int, token_type : string, scope : string) + = + member _.access_token = access_token + member _.expires_in = expires_in + member _.token_type = token_type + member _.scope = scope + +/// A token which can be used to authenticate with the PureGym API. +type AuthToken = + { + /// A string which can be passed as the "Bearer" in an Authorization header + AccessToken : string + /// Time that this token expires, if known + ExpiryTime : DateTime option + } + + static member internal Parse (response : AuthResponseRaw) : AuthToken = + if response.scope <> "pgcapi" then + failwithf $"unexpected scope: %s{response.scope}" + + if response.token_type <> "Bearer" then + failwithf $"unexpected type: %s{response.token_type}" + + { + AccessToken = response.access_token + ExpiryTime = Some (DateTime.Now + TimeSpan.FromSeconds response.expires_in) + } + +/// Authentication credentials as known to a human user +type UsernamePin = + { + /// The user's email address + Username : string + /// Eight-digit PIN + Pin : string + } + +/// Any way to authenticate with the PureGym API. +type Auth = + /// Authenticate with credentials which are known to the user + | User of UsernamePin + /// An AuthToken (that is, a bearer token) + | Token of AuthToken + +/// Methods for constructing PureGym authentication tokens. +[] +module AuthToken = + /// Construct an AuthToken given that you already have a bearer token. + let ofBearerToken (token : string) : AuthToken = + { + AccessToken = token + ExpiryTime = None + } + + let private options = JsonSerializerOptions (IncludeFields = true) + + /// Get an AuthToken for the given user email address with the given eight-digit PureGym PIN. + let get (creds : UsernamePin) : Task = + task { + use client = new HttpClient () + client.BaseAddress <- Uri "https://auth.puregym.com" + client.DefaultRequestHeaders.Add ("User-Agent", "PureGym/1523 CFNetwork/1312 Darwin/21.0.0") + + let request = + [ + "grant_type", "password" + "username", creds.Username + "password", creds.Pin + "scope", "pgcapi" + "client_id", "ro.client" + ] + |> List.map KeyValuePair + + use content = new FormUrlEncodedContent (request) + let! response = client.PostAsync (Uri "https://auth.puregym.com/connect/token", content) + + if response.IsSuccessStatusCode then + let! content = response.Content.ReadAsStreamAsync () + let! response = JsonSerializer.DeserializeAsync (content, options) + // let! response = JsonSerializer.DeserializeAsync (content, options) + return AuthToken.Parse response + else + let! content = response.Content.ReadAsStringAsync () + return failwithf $"bad status code: %+A{response.StatusCode}\n%s{content}" + } diff --git a/PureGym/GymSelector.fs b/PureGym/GymSelector.fs new file mode 100644 index 0000000..0694551 --- /dev/null +++ b/PureGym/GymSelector.fs @@ -0,0 +1,48 @@ +namespace PureGym + +open System.Threading.Tasks +open Fastenshtein + +/// Identifies a gym, possibly non-uniquely and possibly ambiguously. +[] +type GymSelector = + /// The ID of this gym, according to the PureGym internal mapping. + | Id of int + /// The user-specified name of this gym. + | Name of string + /// The home gym of the authenticated user. + | Home + +/// Methods for manipulating GymSelector. +[] +module GymSelector = + + /// Get the canonical PureGym ID for this user-specified gym. + let canonicalId (client : IPureGymApi) (gym : GymSelector) : int Task = + match gym with + | GymSelector.Home -> + task { + let! self = client.GetMember () + return self.HomeGymId + } + | GymSelector.Id i -> Task.FromResult<_> i + | GymSelector.Name name -> + task { + let! allGyms = client.GetGyms () + + if allGyms.IsEmpty then + return failwith "PureGym API returned no gyms!" + else + let distance = Levenshtein (name.ToLowerInvariant ()) + + let bestDistance, bestGym = + allGyms + |> Seq.map (fun gym -> distance.DistanceFrom (gym.Name.ToLowerInvariant ()), gym) + |> Seq.sortBy fst + |> Seq.head + + if bestDistance <> 0 then + System.Console.Error.WriteLine $"Autocorrected gym from %s{name} to %s{bestGym.Name}" + + return bestGym.Id + } diff --git a/PureGym/PureGym.fsproj b/PureGym/PureGym.fsproj new file mode 100644 index 0000000..7523f84 --- /dev/null +++ b/PureGym/PureGym.fsproj @@ -0,0 +1,24 @@ + + + + net6.0 + true + + + + + + + + + + + + + + + + + + + diff --git a/PureGym/String.fs b/PureGym/String.fs new file mode 100644 index 0000000..8adb935 --- /dev/null +++ b/PureGym/String.fs @@ -0,0 +1,5 @@ +namespace PureGym + +[] +module internal Char = + let emoji bool = if bool then '✅' else '❌' diff --git a/PureGym/SurfaceBaseline.txt b/PureGym/SurfaceBaseline.txt new file mode 100644 index 0000000..3100d4f --- /dev/null +++ b/PureGym/SurfaceBaseline.txt @@ -0,0 +1,198 @@ +PureGym.Api inherit obj +PureGym.Api.make [static method]: PureGym.Auth -> PureGym.IPureGymApi System.Threading.Tasks.Task +PureGym.Auth inherit obj, implements PureGym.Auth System.IEquatable, System.Collections.IStructuralEquatable, PureGym.Auth System.IComparable, System.IComparable, System.Collections.IStructuralComparable - union type with 2 cases +PureGym.Auth+Tags inherit obj +PureGym.Auth+Tags.Token [static field]: int = 1 +PureGym.Auth+Tags.User [static field]: int = 0 +PureGym.Auth+Token inherit PureGym.Auth +PureGym.Auth+Token.get_Item [method]: unit -> PureGym.AuthToken +PureGym.Auth+Token.Item [property]: [read-only] PureGym.AuthToken +PureGym.Auth+User inherit PureGym.Auth +PureGym.Auth+User.get_Item [method]: unit -> PureGym.UsernamePin +PureGym.Auth+User.Item [property]: [read-only] PureGym.UsernamePin +PureGym.Auth.get_IsToken [method]: unit -> bool +PureGym.Auth.get_IsUser [method]: unit -> bool +PureGym.Auth.get_Tag [method]: unit -> int +PureGym.Auth.IsToken [property]: [read-only] bool +PureGym.Auth.IsUser [property]: [read-only] bool +PureGym.Auth.NewToken [static method]: PureGym.AuthToken -> PureGym.Auth +PureGym.Auth.NewUser [static method]: PureGym.UsernamePin -> PureGym.Auth +PureGym.Auth.Tag [property]: [read-only] int +PureGym.AuthResponseRaw inherit obj +PureGym.AuthResponseRaw..ctor [constructor]: (string, int, string, string) +PureGym.AuthResponseRaw.access_token [property]: [read-only] string +PureGym.AuthResponseRaw.expires_in [property]: [read-only] int +PureGym.AuthResponseRaw.get_access_token [method]: unit -> string +PureGym.AuthResponseRaw.get_expires_in [method]: unit -> int +PureGym.AuthResponseRaw.get_scope [method]: unit -> string +PureGym.AuthResponseRaw.get_token_type [method]: unit -> string +PureGym.AuthResponseRaw.scope [property]: [read-only] string +PureGym.AuthResponseRaw.token_type [property]: [read-only] string +PureGym.AuthToken inherit obj, implements PureGym.AuthToken System.IEquatable, System.Collections.IStructuralEquatable, PureGym.AuthToken System.IComparable, System.IComparable, System.Collections.IStructuralComparable +PureGym.AuthToken..ctor [constructor]: (string, System.DateTime option) +PureGym.AuthToken.AccessToken [property]: [read-only] string +PureGym.AuthToken.ExpiryTime [property]: [read-only] System.DateTime option +PureGym.AuthToken.get_AccessToken [method]: unit -> string +PureGym.AuthToken.get_ExpiryTime [method]: unit -> System.DateTime option +PureGym.AuthTokenModule inherit obj +PureGym.AuthTokenModule.get [static method]: PureGym.UsernamePin -> PureGym.AuthToken System.Threading.Tasks.Task +PureGym.AuthTokenModule.ofBearerToken [static method]: string -> PureGym.AuthToken +PureGym.Gym inherit obj, implements PureGym.Gym System.IEquatable, System.Collections.IStructuralEquatable, PureGym.Gym System.IComparable, System.IComparable, System.Collections.IStructuralComparable +PureGym.Gym..ctor [constructor]: (string, int, int, PureGym.GymAddress, string, string, PureGym.GymOpeningHours, PureGym.GymAccessOptions, PureGym.GymLocation, string, string) +PureGym.Gym.AccessOptions [property]: [read-only] PureGym.GymAccessOptions +PureGym.Gym.Address [property]: [read-only] PureGym.GymAddress +PureGym.Gym.EmailAddress [property]: [read-only] string +PureGym.Gym.get_AccessOptions [method]: unit -> PureGym.GymAccessOptions +PureGym.Gym.get_Address [method]: unit -> PureGym.GymAddress +PureGym.Gym.get_EmailAddress [method]: unit -> string +PureGym.Gym.get_GymOpeningHours [method]: unit -> PureGym.GymOpeningHours +PureGym.Gym.get_Id [method]: unit -> int +PureGym.Gym.get_Location [method]: unit -> PureGym.GymLocation +PureGym.Gym.get_Name [method]: unit -> string +PureGym.Gym.get_PhoneNumber [method]: unit -> string +PureGym.Gym.get_ReopenDate [method]: unit -> string +PureGym.Gym.get_Status [method]: unit -> int +PureGym.Gym.get_TimeZone [method]: unit -> string +PureGym.Gym.GymOpeningHours [property]: [read-only] PureGym.GymOpeningHours +PureGym.Gym.Id [property]: [read-only] int +PureGym.Gym.Location [property]: [read-only] PureGym.GymLocation +PureGym.Gym.Name [property]: [read-only] string +PureGym.Gym.PhoneNumber [property]: [read-only] string +PureGym.Gym.ReopenDate [property]: [read-only] string +PureGym.Gym.Status [property]: [read-only] int +PureGym.Gym.TimeZone [property]: [read-only] string +PureGym.GymAccessOptions inherit obj, implements PureGym.GymAccessOptions System.IEquatable, System.Collections.IStructuralEquatable, PureGym.GymAccessOptions System.IComparable, System.IComparable, System.Collections.IStructuralComparable +PureGym.GymAccessOptions..ctor [constructor]: (bool, bool) +PureGym.GymAccessOptions.get_PinAccess [method]: unit -> bool +PureGym.GymAccessOptions.get_QrCodeAccess [method]: unit -> bool +PureGym.GymAccessOptions.PinAccess [property]: [read-only] bool +PureGym.GymAccessOptions.QrCodeAccess [property]: [read-only] bool +PureGym.GymAddress inherit obj, implements PureGym.GymAddress System.IEquatable, System.Collections.IStructuralEquatable, PureGym.GymAddress System.IComparable, System.IComparable, System.Collections.IStructuralComparable +PureGym.GymAddress..ctor [constructor]: (string, string, string, string, string, string) +PureGym.GymAddress.AddressLine1 [property]: [read-only] string +PureGym.GymAddress.AddressLine2 [property]: [read-only] string +PureGym.GymAddress.AddressLine3 [property]: [read-only] string +PureGym.GymAddress.County [property]: [read-only] string +PureGym.GymAddress.get_AddressLine1 [method]: unit -> string +PureGym.GymAddress.get_AddressLine2 [method]: unit -> string +PureGym.GymAddress.get_AddressLine3 [method]: unit -> string +PureGym.GymAddress.get_County [method]: unit -> string +PureGym.GymAddress.get_Postcode [method]: unit -> string +PureGym.GymAddress.get_Town [method]: unit -> string +PureGym.GymAddress.Postcode [property]: [read-only] string +PureGym.GymAddress.Town [property]: [read-only] string +PureGym.GymAttendance inherit obj, implements PureGym.GymAttendance System.IEquatable, System.Collections.IStructuralEquatable, PureGym.GymAttendance System.IComparable, System.IComparable, System.Collections.IStructuralComparable +PureGym.GymAttendance..ctor [constructor]: (string, int, int, string, bool, System.DateTime, System.DateTime, System.DateTime, int) +PureGym.GymAttendance.AttendanceTime [property]: [read-only] System.DateTime +PureGym.GymAttendance.Description [property]: [read-only] string +PureGym.GymAttendance.get_AttendanceTime [method]: unit -> System.DateTime +PureGym.GymAttendance.get_Description [method]: unit -> string +PureGym.GymAttendance.get_IsApproximate [method]: unit -> bool +PureGym.GymAttendance.get_LastRefreshed [method]: unit -> System.DateTime +PureGym.GymAttendance.get_LastRefreshedPeopleInClasses [method]: unit -> System.DateTime +PureGym.GymAttendance.get_MaximumCapacity [method]: unit -> int +PureGym.GymAttendance.get_TotalPeopleInClasses [method]: unit -> int +PureGym.GymAttendance.get_TotalPeopleInGym [method]: unit -> int +PureGym.GymAttendance.get_TotalPeopleSuffix [method]: unit -> string +PureGym.GymAttendance.IsApproximate [property]: [read-only] bool +PureGym.GymAttendance.LastRefreshed [property]: [read-only] System.DateTime +PureGym.GymAttendance.LastRefreshedPeopleInClasses [property]: [read-only] System.DateTime +PureGym.GymAttendance.MaximumCapacity [property]: [read-only] int +PureGym.GymAttendance.TotalPeopleInClasses [property]: [read-only] int +PureGym.GymAttendance.TotalPeopleInGym [property]: [read-only] int +PureGym.GymAttendance.TotalPeopleSuffix [property]: [read-only] string +PureGym.GymLocation inherit obj, implements PureGym.GymLocation System.IEquatable, System.Collections.IStructuralEquatable, PureGym.GymLocation System.IComparable, System.IComparable, System.Collections.IStructuralComparable +PureGym.GymLocation..ctor [constructor]: (float, float) +PureGym.GymLocation.get_Latitude [method]: unit -> float +PureGym.GymLocation.get_Longitude [method]: unit -> float +PureGym.GymLocation.Latitude [property]: [read-only] float +PureGym.GymLocation.Longitude [property]: [read-only] float +PureGym.GymOpeningHours inherit obj, implements PureGym.GymOpeningHours System.IEquatable, System.Collections.IStructuralEquatable, PureGym.GymOpeningHours System.IComparable, System.IComparable, System.Collections.IStructuralComparable +PureGym.GymOpeningHours..ctor [constructor]: (bool, string list) +PureGym.GymOpeningHours.get_IsAlwaysOpen [method]: unit -> bool +PureGym.GymOpeningHours.get_OpeningHours [method]: unit -> string list +PureGym.GymOpeningHours.IsAlwaysOpen [property]: [read-only] bool +PureGym.GymOpeningHours.OpeningHours [property]: [read-only] string list +PureGym.GymSelector inherit obj, implements PureGym.GymSelector System.IEquatable, System.Collections.IStructuralEquatable, PureGym.GymSelector System.IComparable, System.IComparable, System.Collections.IStructuralComparable - union type with 3 cases +PureGym.GymSelector+Id inherit PureGym.GymSelector +PureGym.GymSelector+Id.get_Item [method]: unit -> int +PureGym.GymSelector+Id.Item [property]: [read-only] int +PureGym.GymSelector+Name inherit PureGym.GymSelector +PureGym.GymSelector+Name.get_Item [method]: unit -> string +PureGym.GymSelector+Name.Item [property]: [read-only] string +PureGym.GymSelector+Tags inherit obj +PureGym.GymSelector+Tags.Home [static field]: int = 2 +PureGym.GymSelector+Tags.Id [static field]: int = 0 +PureGym.GymSelector+Tags.Name [static field]: int = 1 +PureGym.GymSelector.get_Home [static method]: unit -> PureGym.GymSelector +PureGym.GymSelector.get_IsHome [method]: unit -> bool +PureGym.GymSelector.get_IsId [method]: unit -> bool +PureGym.GymSelector.get_IsName [method]: unit -> bool +PureGym.GymSelector.get_Tag [method]: unit -> int +PureGym.GymSelector.Home [static property]: [read-only] PureGym.GymSelector +PureGym.GymSelector.IsHome [property]: [read-only] bool +PureGym.GymSelector.IsId [property]: [read-only] bool +PureGym.GymSelector.IsName [property]: [read-only] bool +PureGym.GymSelector.NewId [static method]: int -> PureGym.GymSelector +PureGym.GymSelector.NewName [static method]: string -> PureGym.GymSelector +PureGym.GymSelector.Tag [property]: [read-only] int +PureGym.GymSelectorModule inherit obj +PureGym.GymSelectorModule.canonicalId [static method]: PureGym.IPureGymApi -> PureGym.GymSelector -> int System.Threading.Tasks.Task +PureGym.IPureGymApi - interface with 5 member(s) +PureGym.IPureGymApi.GetGym [method]: int -> PureGym.Gym System.Threading.Tasks.Task +PureGym.IPureGymApi.GetGymAttendance [method]: int -> PureGym.GymAttendance System.Threading.Tasks.Task +PureGym.IPureGymApi.GetGyms [method]: unit -> PureGym.Gym list System.Threading.Tasks.Task +PureGym.IPureGymApi.GetMember [method]: unit -> PureGym.Member System.Threading.Tasks.Task +PureGym.IPureGymApi.GetMemberActivity [method]: unit -> PureGym.MemberActivity System.Threading.Tasks.Task +PureGym.Member inherit obj, implements PureGym.Member System.IEquatable, System.Collections.IStructuralEquatable, PureGym.Member System.IComparable, System.IComparable, System.Collections.IStructuralComparable +PureGym.Member..ctor [constructor]: (int, string, string, string, int, string, string, string, System.DateOnly, string, string, string, int, int, int) +PureGym.Member.CompoundMemberId [property]: [read-only] string +PureGym.Member.DateOfBirth [property]: [read-only] System.DateOnly +PureGym.Member.EmailAddress [property]: [read-only] string +PureGym.Member.FirstName [property]: [read-only] string +PureGym.Member.get_CompoundMemberId [method]: unit -> string +PureGym.Member.get_DateOfBirth [method]: unit -> System.DateOnly +PureGym.Member.get_EmailAddress [method]: unit -> string +PureGym.Member.get_FirstName [method]: unit -> string +PureGym.Member.get_GymAccessPin [method]: unit -> string +PureGym.Member.get_HomeGymId [method]: unit -> int +PureGym.Member.get_HomeGymName [method]: unit -> string +PureGym.Member.get_Id [method]: unit -> int +PureGym.Member.get_LastName [method]: unit -> string +PureGym.Member.get_MembershipLevel [method]: unit -> int +PureGym.Member.get_MembershipName [method]: unit -> string +PureGym.Member.get_MemberStatus [method]: unit -> int +PureGym.Member.get_MobileNumber [method]: unit -> string +PureGym.Member.get_Postcode [method]: unit -> string +PureGym.Member.get_SuspendedReason [method]: unit -> int +PureGym.Member.GymAccessPin [property]: [read-only] string +PureGym.Member.HomeGymId [property]: [read-only] int +PureGym.Member.HomeGymName [property]: [read-only] string +PureGym.Member.Id [property]: [read-only] int +PureGym.Member.LastName [property]: [read-only] string +PureGym.Member.MembershipLevel [property]: [read-only] int +PureGym.Member.MembershipName [property]: [read-only] string +PureGym.Member.MemberStatus [property]: [read-only] int +PureGym.Member.MobileNumber [property]: [read-only] string +PureGym.Member.Postcode [property]: [read-only] string +PureGym.Member.SuspendedReason [property]: [read-only] int +PureGym.MemberActivity inherit obj, implements PureGym.MemberActivity System.IEquatable, System.Collections.IStructuralEquatable, PureGym.MemberActivity System.IComparable, System.IComparable, System.Collections.IStructuralComparable +PureGym.MemberActivity..ctor [constructor]: (int, int, int, int, bool, System.DateTime) +PureGym.MemberActivity.AverageDurationMinutes [property]: [read-only] int +PureGym.MemberActivity.get_AverageDurationMinutes [method]: unit -> int +PureGym.MemberActivity.get_IsEstimated [method]: unit -> bool +PureGym.MemberActivity.get_LastRefreshed [method]: unit -> System.DateTime +PureGym.MemberActivity.get_TotalClasses [method]: unit -> int +PureGym.MemberActivity.get_TotalDurationMinutes [method]: unit -> int +PureGym.MemberActivity.get_TotalVisits [method]: unit -> int +PureGym.MemberActivity.IsEstimated [property]: [read-only] bool +PureGym.MemberActivity.LastRefreshed [property]: [read-only] System.DateTime +PureGym.MemberActivity.TotalClasses [property]: [read-only] int +PureGym.MemberActivity.TotalDurationMinutes [property]: [read-only] int +PureGym.MemberActivity.TotalVisits [property]: [read-only] int +PureGym.UsernamePin inherit obj, implements PureGym.UsernamePin System.IEquatable, System.Collections.IStructuralEquatable, PureGym.UsernamePin System.IComparable, System.IComparable, System.Collections.IStructuralComparable +PureGym.UsernamePin..ctor [constructor]: (string, string) +PureGym.UsernamePin.get_Pin [method]: unit -> string +PureGym.UsernamePin.get_Username [method]: unit -> string +PureGym.UsernamePin.Pin [property]: [read-only] string +PureGym.UsernamePin.Username [property]: [read-only] string \ No newline at end of file diff --git a/PureGym/version.json b/PureGym/version.json new file mode 100644 index 0000000..5b7dc86 --- /dev/null +++ b/PureGym/version.json @@ -0,0 +1,7 @@ +{ + "version": "1.0", + "publicReleaseRefSpec": [ + "^refs/heads/main$" + ], + "pathFilters": null +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..f635ea8 --- /dev/null +++ b/README.md @@ -0,0 +1,30 @@ +# Unofficial PureGym client + +![Status](https://woodpecker.patrickstevens.co.uk/api/badges/44/status.svg) + +*With thanks to [Tom Hollingsworth](https://github.com/2t6h/puregym-attendance/blob/64dcd830bd874dc0150c7767f5cc6c75ed0b9dad/puregym.py).* + +## Usage + +* With Nix: `nix run` (you can refer to this flake). +* Manually: `git clone` and then `dotnet run --project PureGym.App/PureGym.App.fsproj`. + +(Something is up on Darwin: `nix run` currently produces an executable which dies instantly. +Workaround: `nix build` and then `nix develop --command dotnet exec ./result/lib/puregym/PureGym.App.dll`.) + +The available subcommands can be viewed in the `subcommands` map defined in [Program.fs](./PureGym.App/Program.fs). +As of this writing, the following are implemented: + +* `activity`: get the logged-in user's activity stats. I have no idea what the semantics of these numbers are! +* `fullness`: determine how full a given gym is right now. +* `lookup-gym`: give information about the gym's physical instantiation (e.g. its address). + +### Authentication + +You can authenticate with your PureGym email address and PIN combination, or (probably better) you can call `PureGym.App auth` to obtain a token. +Use this token in subsequent commands by setting the `PUREGYM_BEARER_TOKEN` environment variable or supplying it as `--bearer-token`. + +## Structure + +* The REST client is at [Api.fs](./PureGym/Api.fs). +* The standalone application is at [PureGym.App](./PureGym.App). It uses a ghastly mix of hand-rolled argument parsing and [Argu](https://fsprojects.github.io/Argu/), because Argu does not *quite* want to do what I want an argument parser to do. diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..2e18027 --- /dev/null +++ b/flake.lock @@ -0,0 +1,60 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1694529238, + "narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "ff7b65b44d01cf9ba6a71320833626af21126384", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1696981262, + "narHash": "sha256-YaCOjdqhbjBeyMjxlgFWt4XD/b9pGKWURgS3uEwNLtc=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "a2b87a4f66f309d2f4b789fd0457f5fc5db0a9a6", + "type": "github" + }, + "original": { + "owner": "nixos", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..6abaf4f --- /dev/null +++ b/flake.nix @@ -0,0 +1,114 @@ +{ + inputs = { + nixpkgs.url = "github:nixos/nixpkgs"; + flake-utils = { + url = "github:numtide/flake-utils"; + }; + }; + + outputs = inputs @ { + self, + nixpkgs, + flake-utils, + ... + }: + flake-utils.lib.eachDefaultSystem (system: let + pkgs = nixpkgs.legacyPackages.${system}; + projectFile = "./PureGym.App/PureGym.App.fsproj"; + testProjectFile = "./PureGym.Test/PureGym.Test.fsproj"; + pname = "puregym"; + dotnet-sdk = pkgs.dotnet-sdk_7; + dotnet-runtime = pkgs.dotnetCorePackages.runtime_7_0; + version = "0.1"; + dotnetTool = toolName: toolVersion: sha256: + pkgs.stdenvNoCC.mkDerivation rec { + name = toolName; + version = toolVersion; + nativeBuildInputs = [pkgs.makeWrapper]; + src = pkgs.fetchNuGet { + pname = name; + version = version; + sha256 = sha256; + installPhase = ''mkdir -p $out/bin && cp -r tools/net6.0/any/* $out/bin''; + }; + installPhase = '' + runHook preInstall + mkdir -p "$out/lib" + cp -r ./bin/* "$out/lib" + makeWrapper "${dotnet-runtime}/bin/dotnet" "$out/bin/${name}" --add-flags "$out/lib/${name}.dll" + runHook postInstall + ''; + }; + fantomas = dotnetTool "fantomas" (builtins.fromJSON (builtins.readFile ./.config/dotnet-tools.json)).tools.fantomas.version "sha256-83RodORaC3rkYfbFMHsYLEtl0+8+akZXcKoSJdgwuUo="; + in { + packages = { + fantomas = fantomas; + fetchDeps = let + flags = []; + runtimeIds = ["win-x64"] ++ map (system: pkgs.dotnetCorePackages.systemToDotnetRid system) dotnet-sdk.meta.platforms; + in + pkgs.writeShellScriptBin "fetch-${pname}-deps" (builtins.readFile (pkgs.substituteAll { + src = ./nix/fetchDeps.sh; + pname = pname; + binPath = pkgs.lib.makeBinPath [pkgs.coreutils dotnet-sdk (pkgs.nuget-to-nix.override {inherit dotnet-sdk;})]; + projectFiles = toString (pkgs.lib.toList projectFile); + testProjectFiles = toString (pkgs.lib.toList testProjectFile); + rids = pkgs.lib.concatStringsSep "\" \"" runtimeIds; + packages = dotnet-sdk.packages; + storeSrc = pkgs.srcOnly { + src = ./.; + pname = pname; + version = version; + }; + })); + default = pkgs.buildDotnetModule { + pname = pname; + name = "puregym"; + version = version; + src = ./.; + projectFile = projectFile; + nugetDeps = ./nix/deps.nix; + doCheck = true; + dotnet-sdk = dotnet-sdk; + dotnet-runtime = dotnet-runtime; + }; + }; + apps = { + default = { + type = "app"; + program = "${self.packages.${system}.default}/bin/PureGym.App"; + }; + }; + devShells.default = pkgs.mkShell { + buildInputs = + [pkgs.alejandra pkgs.dotnet-sdk_7 pkgs.python3 pkgs.nodePackages.markdown-link-check] + ++ ( + if pkgs.stdenv.isDarwin + then [pkgs.darwin.apple_sdk.frameworks.CoreServices] + else [] + ); + }; + checks = { + alejandra = pkgs.stdenvNoCC.mkDerivation { + name = "alejandra-check"; + src = ./.; + checkPhase = '' + ${pkgs.alejandra}/bin/alejandra --check . + ''; + installPhase = "mkdir $out"; + dontBuild = true; + doCheck = true; + }; + fantomas = pkgs.stdenvNoCC.mkDerivation { + name = "fantomas-check"; + src = ./.; + checkPhase = '' + ${fantomas}/bin/fantomas --check . + ''; + installPhase = "mkdir $out"; + dontBuild = true; + doCheck = true; + }; + }; + }); +} diff --git a/nix/deps.nix b/nix/deps.nix new file mode 100644 index 0000000..176513c --- /dev/null +++ b/nix/deps.nix @@ -0,0 +1,364 @@ +# This file was automatically generated by passthru.fetch-deps. +# Please don't edit it manually, your changes might get overwritten! +{fetchNuGet}: [ + (fetchNuGet { + pname = "Fastenshtein"; + version = "1.0.0.8"; + sha256 = "1rvw27rz7qb2n68i0jvvcr224fcpy5yzzxaj1bp89jw41cpdabp2"; + }) + (fetchNuGet { + pname = "Argu"; + version = "6.1.1"; + sha256 = "1v996g0760qhiys2ahdpnvkldaxr2jn5f1falf789glnk4a6f3xl"; + }) + (fetchNuGet { + pname = "System.Configuration.ConfigurationManager"; + version = "4.4.0"; + sha256 = "1hjgmz47v5229cbzd2pwz2h0dkq78lb2wp9grx8qr72pb5i0dk7v"; + }) + (fetchNuGet { + pname = "fantomas"; + version = "6.2.0"; + sha256 = "sha256-83RodORaC3rkYfbFMHsYLEtl0+8+akZXcKoSJdgwuUo="; + }) + (fetchNuGet { + pname = "ApiSurface"; + version = "4.0.12"; + sha256 = "0v56sv4cz8bgrfqjjg0q96619qs9dvvi0a6lp7hzz2mi82i1inmq"; + }) + (fetchNuGet { + pname = "coverlet.collector"; + version = "3.2.0"; + sha256 = "1qxpv8v10p5wn162lzdm193gdl6c5f81zadj8h889dprlnj3g8yr"; + }) + (fetchNuGet { + pname = "FSharp.Core"; + version = "6.0.0"; + sha256 = "1hjhvr39c1vpgrdmf8xln5q86424fqkvy9nirkr29vl2461d2039"; + }) + (fetchNuGet { + pname = "FSharp.Core"; + version = "7.0.400"; + sha256 = "1pl6iqqcpm9djfn7f6ms5j1xbcyz00nb808qd6pmsjrnylflalgp"; + }) + (fetchNuGet { + pname = "Microsoft.AspNetCore.App.Ref"; + version = "6.0.22"; + sha256 = "0fqpl1fr213b4fb3c6xw3fy6669yxqcp1bzcnayw80yrskw8lpxs"; + }) + (fetchNuGet { + pname = "Microsoft.AspNetCore.App.Runtime.linux-arm64"; + version = "6.0.22"; + sha256 = "1xvqqc7bzj764g3scp0saqxlfiv866crgi8chz57vhjp9sgd61jw"; + }) + (fetchNuGet { + pname = "Microsoft.AspNetCore.App.Runtime.linux-arm64"; + version = "7.0.11"; + sha256 = "0hmsqy4yc3023mcp5rg0h59yv3f8cnjhxw1g4i8md67vm5y04lfv"; + }) + (fetchNuGet { + pname = "Microsoft.AspNetCore.App.Runtime.linux-x64"; + version = "6.0.22"; + sha256 = "1gcv99y295fnhy12fyx8wqvbhbj6mz8p5bm66ppwdxb3zykjg2l8"; + }) + (fetchNuGet { + pname = "Microsoft.AspNetCore.App.Runtime.linux-x64"; + version = "7.0.11"; + sha256 = "18sk9wka8z5354ca77q43hi0615yjssdjbyi0hqq92w6zmg43vgc"; + }) + (fetchNuGet { + pname = "Microsoft.AspNetCore.App.Runtime.osx-arm64"; + version = "6.0.22"; + sha256 = "1ib0x1w33wqy7lgzjf14dvgx981xpjffjqd800d7wgxisgmakrmr"; + }) + (fetchNuGet { + pname = "Microsoft.AspNetCore.App.Runtime.osx-arm64"; + version = "7.0.11"; + sha256 = "1j0zbd4rmmd3ylgixsvyj145g2r6px6b9d9k4yxxg6d61x90c165"; + }) + (fetchNuGet { + pname = "Microsoft.AspNetCore.App.Runtime.osx-x64"; + version = "6.0.22"; + sha256 = "026r38a7by7wdfd3virjdaah3y2sjjmnabgf5l25vdnwpwc7c31d"; + }) + (fetchNuGet { + pname = "Microsoft.AspNetCore.App.Runtime.osx-x64"; + version = "7.0.11"; + sha256 = "0wxw7vgygg6hqzq479n0pfjizr69wq7ja03a0qh8bma8b9q2mn6f"; + }) + (fetchNuGet { + pname = "Microsoft.AspNetCore.App.Runtime.win-x64"; + version = "6.0.22"; + sha256 = "0ygdqsd312kqpykwb0k2942n45q1w3yn1nia6m1ahf7b74926qb5"; + }) + (fetchNuGet { + pname = "Microsoft.AspNetCore.App.Runtime.win-x64"; + version = "7.0.11"; + sha256 = "05ywwfn5lzx6y999f7gwmablkxi2zvska4sg20ihmjzp3xakcmk0"; + }) + (fetchNuGet { + pname = "Microsoft.CodeCoverage"; + version = "17.6.0"; + sha256 = "02s98d8nwz5mg4mymcr86qdamy71a29g2091xg452czmd3s3x2di"; + }) + (fetchNuGet { + pname = "Microsoft.NET.Test.Sdk"; + version = "17.6.0"; + sha256 = "1bnwpwg7k72z06027ip4yi222863r8sv14ck9nj8h64ckiw2r256"; + }) + (fetchNuGet { + pname = "Microsoft.NETCore.App.Host.linux-arm64"; + version = "6.0.22"; + sha256 = "0gri1gqznm5c8fsb6spqb3j88a3b0br0iy50y66fh4hz9wc4fwzm"; + }) + (fetchNuGet { + pname = "Microsoft.NETCore.App.Host.linux-arm64"; + version = "7.0.11"; + sha256 = "03nkxjn4wq30rw0163rqi8sngfxmcvwgm0wg7sgyb1cdh0q1ai68"; + }) + (fetchNuGet { + pname = "Microsoft.NETCore.App.Host.linux-x64"; + version = "6.0.22"; + sha256 = "0k1i74wn6j7nq0bd8m6jrpl65wda6qc9pglppvz4ybk0n2ab1rbi"; + }) + (fetchNuGet { + pname = "Microsoft.NETCore.App.Host.linux-x64"; + version = "7.0.11"; + sha256 = "12hh69sr4wf8sjcw3q71vky51sn854ffahbq6rgz3njzvbvc0dbj"; + }) + (fetchNuGet { + pname = "Microsoft.NETCore.App.Host.osx-arm64"; + version = "6.0.22"; + sha256 = "0166gwarhhnary19lf80ff33bkx00mkm24f17bc8j6v7g3a7zvq6"; + }) + (fetchNuGet { + pname = "Microsoft.NETCore.App.Host.osx-x64"; + version = "6.0.22"; + sha256 = "038bjwk201p2kzs3jflrkhlnszf7cwalafq0nvs2v8bp7jlnx5ib"; + }) + (fetchNuGet { + pname = "Microsoft.NETCore.App.Host.osx-x64"; + version = "7.0.11"; + sha256 = "1j1k735gkwba93n5yck87wppfpsbny979hppcygwrk81myf3fv03"; + }) + (fetchNuGet { + pname = "Microsoft.NETCore.App.Host.win-x64"; + version = "6.0.22"; + sha256 = "1bjy3zmrmaq97xp0f3nzs3ax330ji632avrfpg8xz4vc5p8s1xpc"; + }) + (fetchNuGet { + pname = "Microsoft.NETCore.App.Host.win-x64"; + version = "7.0.11"; + sha256 = "0ifshdx19bgnbgynbk6iy6gybnxmp63nylrn7068x66hvcavh7kh"; + }) + (fetchNuGet { + pname = "Microsoft.NETCore.App.Ref"; + version = "6.0.22"; + sha256 = "0km8184kma8kgz7iyl3j6apj1n7vskzdhzmq3myy3y36ysqrb4wf"; + }) + (fetchNuGet { + pname = "Microsoft.NETCore.App.Runtime.linux-arm64"; + version = "6.0.22"; + sha256 = "01gbl9dgky4h7ijxryz3527l39v23lkcvk4fs4w91ra4pris2n8p"; + }) + (fetchNuGet { + pname = "Microsoft.NETCore.App.Runtime.linux-arm64"; + version = "7.0.11"; + sha256 = "1gzwc96fs222ddia0k1924cn7gxm2a4anqgcxhmavx56x76wsy6f"; + }) + (fetchNuGet { + pname = "Microsoft.NETCore.App.Runtime.linux-x64"; + version = "6.0.22"; + sha256 = "09gfqdxbh36bjx20fw9k94b9qa9bwffhrq0ldwn834mx31bgrfs8"; + }) + (fetchNuGet { + pname = "Microsoft.NETCore.App.Runtime.linux-x64"; + version = "7.0.11"; + sha256 = "0vxza49wwiia0d3m887yiaprp3xnax2bgzhj5bf080b4ayapzkf9"; + }) + (fetchNuGet { + pname = "Microsoft.NETCore.App.Runtime.osx-arm64"; + version = "6.0.22"; + sha256 = "1x7wclv93q8wp7rip5nwnsxbqcami92yilvzbp0yn42ddkw177ds"; + }) + (fetchNuGet { + pname = "Microsoft.NETCore.App.Runtime.osx-arm64"; + version = "7.0.11"; + sha256 = "15b62hxrpfy19xvyxlyligixxpa9sysfgi47xi4imx5055fhwphh"; + }) + (fetchNuGet { + pname = "Microsoft.NETCore.App.Runtime.osx-x64"; + version = "6.0.22"; + sha256 = "1sq1ygsrpv2sl85wrs8382wgkjic0zylaj1y8kcvhczcmkpk3wr5"; + }) + (fetchNuGet { + pname = "Microsoft.NETCore.App.Runtime.osx-x64"; + version = "7.0.11"; + sha256 = "018qf23b0jixfh3fm74zqaakk01qx6yq21gk2mdn68b0xhnvlzma"; + }) + (fetchNuGet { + pname = "Microsoft.NETCore.App.Runtime.win-x64"; + version = "6.0.22"; + sha256 = "1nn254xv1hi5c4rg38fbfkln3031vv545lv9f4df31i8c1yfzz24"; + }) + (fetchNuGet { + pname = "Microsoft.NETCore.App.Runtime.win-x64"; + version = "7.0.11"; + sha256 = "12xmw2kcpf5rh8sv4y0mqzp917f7q8g4mfh5navqw4jmnxyb26qq"; + }) + (fetchNuGet { + pname = "Microsoft.NETCore.Platforms"; + version = "1.1.0"; + sha256 = "08vh1r12g6ykjygq5d3vq09zylgb84l63k49jc4v8faw9g93iqqm"; + }) + (fetchNuGet { + pname = "Microsoft.NETCore.Platforms"; + version = "2.0.0"; + sha256 = "1fk2fk2639i7nzy58m9dvpdnzql4vb8yl8vr19r2fp8lmj9w2jr0"; + }) + (fetchNuGet { + pname = "Microsoft.TestPlatform.ObjectModel"; + version = "17.6.0"; + sha256 = "1rz22chnis11dwjrqrcvvmfw80fi2a7756a7ahwy6jlnr250zr61"; + }) + (fetchNuGet { + pname = "Microsoft.TestPlatform.TestHost"; + version = "17.6.0"; + sha256 = "16vpicp4q2kbpgr3qwpsxg7srabxqszx23x6smjvvrvz7qmr5v8i"; + }) + (fetchNuGet { + pname = "NETStandard.Library"; + version = "2.0.0"; + sha256 = "1bc4ba8ahgk15m8k4nd7x406nhi0kwqzbgjk2dmw52ss553xz7iy"; + }) + (fetchNuGet { + pname = "Newtonsoft.Json"; + version = "13.0.1"; + sha256 = "0fijg0w6iwap8gvzyjnndds0q4b8anwxxvik7y8vgq97dram4srb"; + }) + (fetchNuGet { + pname = "NuGet.Common"; + version = "6.6.1"; + sha256 = "1q7k5rqwchxgs5pnrn22d1rkdb7l2qblvsb9hy046ll69i71vv45"; + }) + (fetchNuGet { + pname = "NuGet.Configuration"; + version = "6.6.1"; + sha256 = "0pw4ikd8784iya920wxigacqn5g2v0zlpwxjlswyq5mnj2ha7gpk"; + }) + (fetchNuGet { + pname = "NuGet.Frameworks"; + version = "5.11.0"; + sha256 = "0wv26gq39hfqw9md32amr5771s73f5zn1z9vs4y77cgynxr73s4z"; + }) + (fetchNuGet { + pname = "NuGet.Frameworks"; + version = "6.6.1"; + sha256 = "1zq79mklzq7qyiyhcv3w8pznw6rq1ddcl8fvy7j1c6n8qh3mglhx"; + }) + (fetchNuGet { + pname = "NuGet.Packaging"; + version = "6.6.1"; + sha256 = "1lmx8kgpg220q8kic4wm8skccj53cbkdqggirq9js34gnxxi9b88"; + }) + (fetchNuGet { + pname = "NuGet.Protocol"; + version = "6.6.1"; + sha256 = "01n8cw114npvzfk3m3803lb8plk0wm1zg496gpq9az8hw20nmd8g"; + }) + (fetchNuGet { + pname = "NuGet.Versioning"; + version = "6.6.1"; + sha256 = "0n2p05y8ciw6jc5s238rlnx6q4dgxvm14v06pcd84ji5j1iirc30"; + }) + (fetchNuGet { + pname = "NUnit"; + version = "3.13.3"; + sha256 = "0wdzfkygqnr73s6lpxg5b1pwaqz9f414fxpvpdmf72bvh4jaqzv6"; + }) + (fetchNuGet { + pname = "NUnit.Analyzers"; + version = "3.6.1"; + sha256 = "16dw5375k2wyhiw9x387y7pjgq6zms30y036qb8z7idx4lxw9yi9"; + }) + (fetchNuGet { + pname = "NUnit3TestAdapter"; + version = "4.4.2"; + sha256 = "1n2jlc16vjdd81cb1by4qbp75sq73zsjz5w3zc61ssmbdci1q2ri"; + }) + (fetchNuGet { + pname = "RestEase"; + version = "1.6.4"; + sha256 = "1mvi3nbrr450g3fgd1y4wg3bwl9k1agyjfd9wdkqk12714bsln8l"; + }) + (fetchNuGet { + pname = "System.Formats.Asn1"; + version = "5.0.0"; + sha256 = "1axc8z0839yvqi2cb63l73l6d9j6wd20lsbdymwddz9hvrsgfwpn"; + }) + (fetchNuGet { + pname = "System.IO.Abstractions"; + version = "4.2.13"; + sha256 = "0s784iphsmj4vhkrzq9q3w39vsn76w44zclx3hsygsw458zbyh4y"; + }) + (fetchNuGet { + pname = "System.IO.FileSystem.AccessControl"; + version = "4.5.0"; + sha256 = "1gq4s8w7ds1sp8f9wqzf8nrzal40q5cd2w4pkf4fscrl2ih3hkkj"; + }) + (fetchNuGet { + pname = "System.Reflection.Metadata"; + version = "1.6.0"; + sha256 = "1wdbavrrkajy7qbdblpbpbalbdl48q3h34cchz24gvdgyrlf15r4"; + }) + (fetchNuGet { + pname = "System.Runtime.CompilerServices.Unsafe"; + version = "6.0.0"; + sha256 = "0qm741kh4rh57wky16sq4m0v05fxmkjjr87krycf5vp9f0zbahbc"; + }) + (fetchNuGet { + pname = "System.Security.AccessControl"; + version = "4.5.0"; + sha256 = "1wvwanz33fzzbnd2jalar0p0z3x0ba53vzx1kazlskp7pwyhlnq0"; + }) + (fetchNuGet { + pname = "System.Security.Cryptography.Cng"; + version = "5.0.0"; + sha256 = "06hkx2za8jifpslkh491dfwzm5dxrsyxzj5lsc0achb6yzg4zqlw"; + }) + (fetchNuGet { + pname = "System.Security.Cryptography.Pkcs"; + version = "5.0.0"; + sha256 = "0hb2mndac3xrw3786bsjxjfh19bwnr991qib54k6wsqjhjyyvbwj"; + }) + (fetchNuGet { + pname = "System.Security.Cryptography.ProtectedData"; + version = "4.4.0"; + sha256 = "1q8ljvqhasyynp94a1d7jknk946m20lkwy2c3wa8zw2pc517fbj6"; + }) + (fetchNuGet { + pname = "System.Security.Principal.Windows"; + version = "4.5.0"; + sha256 = "0rmj89wsl5yzwh0kqjgx45vzf694v9p92r4x4q6yxldk1cv1hi86"; + }) + (fetchNuGet { + pname = "System.Text.Encodings.Web"; + version = "6.0.0"; + sha256 = "06n9ql3fmhpjl32g3492sj181zjml5dlcc5l76xq2h38c4f87sai"; + }) + (fetchNuGet { + pname = "System.Text.Encodings.Web"; + version = "7.0.0"; + sha256 = "1151hbyrcf8kyg1jz8k9awpbic98lwz9x129rg7zk1wrs6vjlpxl"; + }) + (fetchNuGet { + pname = "System.Text.Json"; + version = "6.0.0"; + sha256 = "1si2my1g0q0qv1hiqnji4xh9wd05qavxnzj9dwgs23iqvgjky0gl"; + }) + (fetchNuGet { + pname = "System.Text.Json"; + version = "7.0.3"; + sha256 = "0zjrnc9lshagm6kdb9bdh45dmlnkpwcpyssa896sda93ngbmj8k9"; + }) +] diff --git a/nix/fetchDeps.sh b/nix/fetchDeps.sh new file mode 100755 index 0000000..e15b822 --- /dev/null +++ b/nix/fetchDeps.sh @@ -0,0 +1,73 @@ +#!/bin/bash + +# This file was adapted from +# https://github.com/NixOS/nixpkgs/blob/b981d811453ab84fb3ea593a9b33b960f1ab9147/pkgs/build-support/dotnet/build-dotnet-module/default.nix#L173 +set -euo pipefail +export PATH="@binPath@" +for arg in "$@"; do + case "$arg" in + --keep-sources|-k) + keepSources=1 + shift + ;; + --help|-h) + echo "usage: $0 [--keep-sources] [--help] " + echo " The path to write the lockfile to. A temporary file is used if this is not set" + echo " --keep-sources Don't remove temporary directories upon exit, useful for debugging" + echo " --help Show this help message" + exit + ;; + esac +done +tmp=$(mktemp -td "@pname@-tmp-XXXXXX") +export tmp +HOME=$tmp/home +exitTrap() { + test -n "${ranTrap-}" && return + ranTrap=1 + if test -n "${keepSources-}"; then + echo -e "Path to the source: $tmp/src\nPath to the fake home: $tmp/home" + else + rm -rf "$tmp" + fi + # Since mktemp is used this will be empty if the script didnt succesfully complete + if ! test -s "$depsFile"; then + rm -rf "$depsFile" + fi +} +trap exitTrap EXIT INT TERM +dotnetRestore() { + local -r project="${1-}" + local -r rid="$2" + dotnet restore "${project-}" \ + -p:ContinuousIntegrationBuild=true \ + -p:Deterministic=true \ + --packages "$tmp/nuget_pkgs" \ + --runtime "$rid" \ + --no-cache \ + --force +} +declare -a projectFiles=( @projectFiles@ ) +declare -a testProjectFiles=( @testProjectFiles@ ) +export DOTNET_NOLOGO=1 +export DOTNET_CLI_TELEMETRY_OPTOUT=1 +depsFile=$(realpath "${1:-$(mktemp -t "@pname@-deps-XXXXXX.nix")}") +mkdir -p "$tmp/nuget_pkgs" +storeSrc="@storeSrc@" +src="$tmp/src" +cp -rT "$storeSrc" "$src" +chmod -R +w "$src" +cd "$src" +echo "Restoring project..." +rids=("@rids@") +for rid in "${rids[@]}"; do + (( ${#projectFiles[@]} == 0 )) && dotnetRestore "" "$rid" + for project in "${projectFiles[@]-}" "${testProjectFiles[@]-}"; do + dotnetRestore "$project" "$rid" + done +done +echo "Successfully restored project" +echo "Writing lockfile..." +echo -e "# This file was automatically generated by passthru.fetch-deps.\n# Please don't edit it manually, your changes might get overwritten!\n" > "$depsFile" +nuget-to-nix "$tmp/nuget_pkgs" "@packages@" >> "$depsFile" +echo "Successfully wrote lockfile to $depsFile"