diff --git a/.credo.exs b/.credo.exs new file mode 100644 index 0000000..d95ad67 --- /dev/null +++ b/.credo.exs @@ -0,0 +1,73 @@ +%{ + configs: [ + %{ + name: "default", + files: %{ + included: ["lib/", "src/"], + excluded: [] + }, + plugins: [], + requires: [], + strict: false, + parse_timeout: 5000, + color: true, + checks: %{ + disabled: [ + # Styler Rewrites + # + # The following rules are automatically rewritten by Styler and so disabled here to save time + # Some of the rules have `priority: :high`, meaning Credo runs them unless we explicitly disable them + # (removing them from this file wouldn't be enough, the `false` is required) + # + # Some rules have a comment before them explaining ways Styler deviates from the Credo rule. + # + # always expands `A.{B, C}` + # {Credo.Check.Consistency.MultiAliasImportRequireUse, false}, + # including `case`, `fn` and `with` statements + {Credo.Check.Consistency.ParameterPatternMatching, false}, + # Styler implements this rule with a depth of 3 and minimum repetition of 2 + {Credo.Check.Design.AliasUsage, false}, + {Credo.Check.Readability.AliasOrder, false}, + {Credo.Check.Readability.BlockPipe, false}, + # goes further than formatter - fixes bad underscores, eg: `100_00` -> `10_000` + {Credo.Check.Readability.LargeNumbers, false}, + # adds `@moduledoc false` + {Credo.Check.Readability.ModuleDoc, false}, + {Credo.Check.Readability.MultiAlias, false}, + {Credo.Check.Readability.OneArityFunctionInPipe, false}, + # removes parens + {Credo.Check.Readability.ParenthesesOnZeroArityDefs, false}, + {Credo.Check.Readability.PipeIntoAnonymousFunctions, false}, + {Credo.Check.Readability.PreferImplicitTry, false}, + {Credo.Check.Readability.SinglePipe, false}, + # **potentially breaks compilation** - see **Troubleshooting** section below + {Credo.Check.Readability.StrictModuleLayout, false}, + {Credo.Check.Readability.StringSigils, false}, + {Credo.Check.Readability.UnnecessaryAliasExpansion, false}, + {Credo.Check.Readability.WithSingleClause, false}, + {Credo.Check.Refactor.CaseTrivialMatches, false}, + {Credo.Check.Refactor.CondStatements, false}, + # in pipes only + {Credo.Check.Refactor.FilterCount, false}, + # in pipes only + {Credo.Check.Refactor.MapInto, false}, + # in pipes only + {Credo.Check.Refactor.MapJoin, false}, + # {Credo.Check.Refactor.NegatedConditionsInUnless, false}, + # {Credo.Check.Refactor.NegatedConditionsWithElse, false}, + # allows ecto's `from + {Credo.Check.Refactor.PipeChainStart, false}, + {Credo.Check.Refactor.RedundantWithClauseResult, false}, + {Credo.Check.Refactor.UnlessWithElse, false}, + {Credo.Check.Refactor.WithClauses, false}, + + # custom ext_fit rules + {Credo.Check.Refactor.Nesting, false}, + {Credo.Check.Refactor.CyclomaticComplexity, false}, + {Credo.Check.Design.TagFIXME, false}, + {Credo.Check.Design.TagTODO, false} + ] + } + } + ] +} diff --git a/.dialyzer_ignore.exs b/.dialyzer_ignore.exs new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/.dialyzer_ignore.exs @@ -0,0 +1 @@ +[] diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..b69748e --- /dev/null +++ b/.envrc @@ -0,0 +1,2 @@ +dotenv +dotenv_if_exists .env.private diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..c92bed1 --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,5 @@ +# Used by "mix format" +[ + line_length: 99, + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/.github/actions/elixir-setup/action.yml b/.github/actions/elixir-setup/action.yml new file mode 100644 index 0000000..8e8ae9e --- /dev/null +++ b/.github/actions/elixir-setup/action.yml @@ -0,0 +1,132 @@ +name: Setup Elixir Project +description: Checks out the code, configures Elixir, fetches dependencies, and manages build caching. +inputs: + elixir-version: + required: true + type: string + description: Elixir version to set up + otp-version: + required: true + type: string + description: OTP version to set up + ################################################################# + # Everything below this line is optional. + # + # It's designed to make compiling a reasonably standard Elixir + # codebase "just work," though there may be speed gains to be had + # by tweaking these flags. + ################################################################# + build-deps: + required: false + type: boolean + default: true + description: True if we should compile dependencies + build-app: + required: false + type: boolean + default: true + description: True if we should compile the application itself + build-flags: + required: false + type: string + default: '--all-warnings' + description: Flags to pass to mix compile + install-rebar: + required: false + type: boolean + default: true + description: By default, we will install Rebar (mix local.rebar --force). + install-hex: + required: false + type: boolean + default: true + description: By default, we will install Hex (mix local.hex --force). + cache-key: + required: false + type: string + default: 'v1' + description: If you need to reset the cache for some reason, you can change this key. +outputs: + otp-version: + description: "Exact OTP version selected by the BEAM setup step" + value: ${{ steps.beam.outputs.otp-version }} + elixir-version: + description: "Exact Elixir version selected by the BEAM setup step" + value: ${{ steps.beam.outputs.elixir-version }} +runs: + using: "composite" + steps: + - name: Setup elixir + uses: erlef/setup-beam@v1 + id: beam + with: + elixir-version: ${{ inputs.elixir-version }} + otp-version: ${{ inputs.otp-version }} + + - name: Get deps cache + uses: actions/cache@v2 + with: + path: deps/ + key: deps-${{ inputs.cache-key }}-${{ runner.os }}-${{ hashFiles('**/mix.lock') }} + restore-keys: | + deps-${{ inputs.cache-key }}-${{ runner.os }}- + + - name: Get build cache + uses: actions/cache@v2 + id: build-cache + with: + path: _build/${{env.MIX_ENV}}/ + key: build-${{ inputs.cache-key }}-${{ runner.os }}-${{ inputs.otp-version }}-${{ inputs.elixir-version }}-${{ env.MIX_ENV }}-${{ hashFiles('**/mix.lock') }} + restore-keys: | + build-${{ inputs.cache-key }}-${{ runner.os }}-${{ inputs.otp-version }}-${{ inputs.elixir-version }}-${{ env.MIX_ENV }}- + + - name: Get Hex cache + uses: actions/cache@v2 + id: hex-cache + with: + path: ~/.hex + key: build-${{ runner.os }}-${{ inputs.otp-version }}-${{ inputs.elixir-version }}-${{ hashFiles('**/mix.lock') }} + restore-keys: | + build-${{ runner.os }}-${{ inputs.otp-version }}-${{ inputs.elixir-version }}- + + # In my experience, I have issues with incremental builds maybe 1 in 100 + # times that are fixed by doing a full recompile. + # In order to not waste dev time on such trivial issues (while also reaping + # the time savings of incremental builds for *most* day-to-day development), + # I force a full recompile only on builds that we retry. + - name: Clean to rule out incremental build as a source of flakiness + if: github.run_attempt != '1' + run: | + mix deps.clean --all + mix clean + shell: sh + + - name: Install Rebar + run: mix local.rebar --force + shell: sh + if: inputs.install-rebar == 'true' + + - name: Install Hex + run: mix local.hex --force + shell: sh + if: inputs.install-hex == 'true' + + - name: Install Dependencies + run: mix deps.get + shell: sh + + # Normally we'd use `mix deps.compile` here, however that incurs a large + # performance penalty when the dependencies are already fully compiled: + # https://elixirforum.com/t/github-action-cache-elixir-always-recompiles-dependencies-elixir-1-13-3/45994/12 + # + # Accoring to Jose Valim at the above link `mix loadpaths` will check and + # compile missing dependencies + - name: Compile Dependencies + run: mix loadpaths + shell: sh + if: inputs.build-deps == 'true' + + - name: Compile Application + run: mix compile ${{ inputs.build-flags }} + shell: sh + if: inputs.build-app == 'true' diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..2aa49ea --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,8 @@ +version: 2 +updates: +- package-ecosystem: mix + directory: "/" + schedule: + interval: weekly + time: "12:00" + open-pull-requests-limit: 3 diff --git a/.github/workflows/elixir-build-and-test.yml b/.github/workflows/elixir-build-and-test.yml new file mode 100644 index 0000000..1717aa3 --- /dev/null +++ b/.github/workflows/elixir-build-and-test.yml @@ -0,0 +1,35 @@ +name: Build and Test + +on: + push: + branches: + - main + pull_request: + branches: + - '*' + +jobs: + build: + name: Build and test + runs-on: ubuntu-latest + env: + MIX_ENV: test + strategy: + matrix: + elixir: ["1.16.2"] + otp: ["25.3.2"] + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + - name: Setup Elixir Project + uses: ./.github/actions/elixir-setup + with: + elixir-version: ${{ matrix.elixir }} + otp-version: ${{ matrix.otp }} + build-flags: --all-warnings --warnings-as-errors + + - name: Run Tests + run: mix coveralls.json --warnings-as-errors + if: always() diff --git a/.github/workflows/elixir-dialyzer.yml b/.github/workflows/elixir-dialyzer.yml new file mode 100644 index 0000000..d7a4674 --- /dev/null +++ b/.github/workflows/elixir-dialyzer.yml @@ -0,0 +1,56 @@ +name: Elixir Type Linting + +on: + push: + branches: + - main + pull_request: + branches: + - '*' + +jobs: + build: + name: Run Dialyzer + runs-on: ubuntu-latest + env: + MIX_ENV: dev + strategy: + matrix: + elixir: ["1.16.2"] + otp: ["25.3.2"] + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + - name: Setup Elixir Project + uses: ./.github/actions/elixir-setup + id: beam + with: + elixir-version: ${{ matrix.elixir }} + otp-version: ${{ matrix.otp }} + build-app: false + + # Don't cache PLTs based on mix.lock hash, as Dialyzer can incrementally update even old ones + # Cache key based on Elixir & Erlang version (also useful when running in matrix) + - name: Restore PLT cache + uses: actions/cache@v3 + id: plt_cache + with: + key: plt-${{ runner.os }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }}-${{ hashFiles('**/mix.lock') }}-${{ hashFiles('**/*.ex') }} + restore-keys: | + plt-${{ runner.os }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }}-${{ hashFiles('**/mix.lock') }}-${{ hashFiles('**/*.ex') }} + plt-${{ runner.os }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }}-${{ hashFiles('**/mix.lock') }}- + plt-${{ runner.os }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }}- + plt-${{ runner.os }}-${{ steps.beam.outputs.otp-version }}- + path: priv/plts + + # Create PLTs if no cache was found. + # Always rebuild PLT when a job is retried + # (If they were cached at all, they'll be updated when we run mix dialyzer with no flags.) + - name: Create PLTs + if: steps.plt_cache.outputs.cache-hit != 'true' || github.run_attempt != '1' + run: mix dialyzer --plt + + - name: Run Dialyzer + run: mix dialyzer --format github diff --git a/.github/workflows/elixir-quality-checks.yml b/.github/workflows/elixir-quality-checks.yml new file mode 100644 index 0000000..f446052 --- /dev/null +++ b/.github/workflows/elixir-quality-checks.yml @@ -0,0 +1,47 @@ +name: Elixir Quality Checks + +on: + push: + branches: + - main + pull_request: + branches: + - '*' + +jobs: + quality_checks: + name: Formatting, and Unused Deps + runs-on: ubuntu-latest + strategy: + matrix: + elixir: ["1.16.2"] + otp: ["25.3.2"] + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + - name: Setup Elixir Project + uses: ./.github/actions/elixir-setup + with: + elixir-version: ${{ matrix.elixir }} + otp-version: ${{ matrix.otp }} + build-app: false + + - name: Check for unused deps + run: mix deps.unlock --check-unused + - name: Check code formatting + run: mix format --check-formatted + # Check formatting even if there were unused deps so that + # we give devs as much feedback as possible & save some time. + if: always() + - name: Run Credo + run: mix credo suggest --min-priority=normal + # Run Credo even if formatting or the unused deps check failed + if: always() + # - name: Check for compile-time dependencies + # run: mix xref graph --label compile-connected --fail-above 0 + # if: always() + # - name: Check for security vulnerabilities in Phoenix project + # run: mix sobelow + # if: always() diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2c49436 --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where third-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. +/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Ignore package tarball (built via "mix hex.build"). +nimrag-*.tar + +# Temporary files, for example, from tests. +/tmp/ + +priv/plts diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8d15403 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,9 @@ +# Changelog + + + + + +## 0.1.0 (2024-03-29) + +Init release diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..a8d1343 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,22 @@ +# MIT License + +Copyright (c) 2024 Michal Forys + +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/README.md b/README.md new file mode 100644 index 0000000..20ed31e --- /dev/null +++ b/README.md @@ -0,0 +1,139 @@ +# Nimrag + +[![Actions Status](https://github.com/arathunku/nimrag/actions/workflows/elixir-build-and-test.yml/badge.svg)](https://github.com/arathunku/nimrag/actions/workflows/elixir-build-and-test.yml) +[![Hex.pm](https://img.shields.io/hexpm/v/nimrag.svg?style=flat)](https://hex.pm/packages/nimrag) +[![Documentation](https://img.shields.io/badge/hex-docs-lightgreen.svg?style=flat)](https://hexdocs.pm/nimrag) +[![License](https://img.shields.io/hexpm/l/nimrag.svg?style=flat)](https://github.com/arathunku/nimrag/blob/main/LICENSE.md) + + + +Use Garmin API from Elixir. Fetch activities, steps, and more! + +## Installation + +The package can be installed by adding Nimrag to your list of dependencies in `mix.exs`: + +```elixir +def deps do + [ + {:nimrag, "~> 0.1.0"} + ] +end +``` + +If you'd like to use it from Livebook, take a look at [this example](./examples/basic.livemd) + +## Usage + +### Initial auth + +Garmin doesn't have any official public API for individuals, only businesses. +It means we're required to use username, password and (optionally) MFA code to obtain +OAuth tokens. OAuth1 token is valid for up to a year and it's used to generate +OAuth2 token that expires quickly, OAuth2 token is used for making the API calls. +After OAuth1 token expires, we need to repeat the authentication process. + +Please see `Nimrag.Auth` docs for more details about authentication, +and see `Nimrag.Credentials` on how to avoid providing plaintext credentials directly in code. + +```elixir +# If you're using it for the first time, we need to get OAuth Tokens first. +credentials = Nimrag.Credentials.new("username", "password") +# you may get prompted for MFA token on stdin +{:ok, client} = Nimrag.Auth.login_sso() + +# OPTIONAL: If you'd like to store OAuth tokens in ~/.config/nimrag and not log in every time +:ok = Nimrag.Credentials.write_fs_oauth1_token(client) +:ok = Nimrag.Credentials.write_fs_oauth2_token(client) +``` + +### General + +Use functions from `Nimrag` to fetch data from Garmin's API. + +``` +# Restore previously cached in ~/.nimrag OAuth tokens +client = Nimrag.Client.new() |> Nimrag.Client.with_auth(Nimrag.Credentials.read_oauth_tokens!()) + +# Fetch your profile +{:ok, %Nimrag.Api.Profile{} = profile, client} = Nimrag.profile(client) + +# Fetch your latest activity +{:ok, %Nimrag.Api.Activity{} = activity, client} = Nimrag.last_activity(client) + +# Call at the end of the session to cache new OAuth2 token +:ok = Nimrag.Credentials.write_fs_oauth2_token(client) +``` + +### Fallback to raw responses + +`Nimrag` module has also functions with `_req` suffix. They return `{:ok, Req.Response{}, client}` and +do not process nor validate returned body. Other functions may return, if applicable, +structs with known fields. + +This is very important split between response and transformation. Garmin's API may change +at any time but it should still be possible to fallback to raw response if needed, so that +any user of the library didn't have to wait for a fix. + +API calls return `{:ok, data, client}` or `{:error, error}`. On success, client is there +so that it could be chained with always up to date OAuth2 token that will get +automatically updated when it's near expiration. + +There's at this moment no extensive coverage of API endpoints, feel free to submit +PR with new structs and endpoints, see [Contributing](#contributing). + +### Rate limit + +By default, Nimrag uses [Hammer](https://github.com/ExHammer/hammer) for rate limiting requests. +If you are already using `Hammer`, you need to ensure `:nimrag` is added as backend. + +> #### API note {: .warning} +> Nimrag is not using public Garmin's API so please be good citizens and don't hammer their servers. + +See `Nimrag.Client.new/1` for more details about changing the api limits. + +## Contributing + +Please do! Garmin has a lot of endpoints, some are useful, some are less useful and +responses contain a lot of fields! + +You can discover new endpoints by setting up [mitmproxy](https://mitmproxy.org/) and capturing +traffic from mobile app or website. You can also take a look at +[python-garminconnect](https://github.com/cyberjunky/python-garminconnect/blob/master/garminconnect/__init__.py). + +For local setup, the project has minimal dependencies and is easy to install + +```sh +# fork and clone the repo +$ mix deps.get +# ensure everything works! +$ mix check +# do your changes +$ mix check +# submit PR! +# THANK YOU! +``` + +### How to add new API endpoints, fields + +1. Add new function in `Nimrag` module, one with `_req` suffix and one without. + Functions with `_req` should returns direct `Nimrag.Api` result. +1. Call `_req` function in `test` env and save its response as fixture. + + Example for `Nimrag.profile/1`: + + ```elixir + $ MIX_ENV=test iex -S mix + client = Nimrag.Client.new() |> Nimrag.Client.with_auth(Nimrag.Credentials.read_oauth_tokens!()) + client |> Nimrag.profile_req() |> Nimrag.ApiHelper.store_response_as_test_fixture() + ``` + +1. Add tests for new function in [`test/nimrag_test.exs`] +1. Define new [`Schematic`](https://github.com/mhanberg/schematic) schema in `Nimrag.Api`, + and ensure all tests pass. + +## License + +Copyright © 2024 Michal Forys + +This project is licensed under the MIT license. diff --git a/examples/basic.livemd b/examples/basic.livemd new file mode 100644 index 0000000..22ea074 --- /dev/null +++ b/examples/basic.livemd @@ -0,0 +1,184 @@ + + +# Basic + +```elixir +Mix.install([ + {:nimrag, path: "/data"}, + {:kino, "~> 0.12"}, + {:kino_vega_lite, "~> 0.1.10"}, + {:explorer, "~> 0.8.0"}, + # humanized format for durations from activities! + {:timex, "~> 3.7.11"}, + # parsing FIT files + {:ext_fit, "~> 0.1"} +]) +``` + +## Nimrag + +This notebook will show you: + +1. How to do inital auth with Garmin's API, obtain OAuth keys +2. Fetch your profile information +3. Fetch latest activity and display some information about it +4. Graph steps from recent days/weeks + +## Login + +Given that Garmin doesn't have official API for individuals, nor any public auth keys you can generate, Nimrag will use your username, password and may ask for MFA code. + + + +`login_sso` will do the Auth flow and may ask you for MFA code. + + + +```elixir +form = + Kino.Control.form( + [ + username: Kino.Input.text("Garmin email"), + password: Kino.Input.password("Garmin password") + ], + submit: "Log in", + reset_on_submit: true + ) + +mfa_code = Kino.Control.form([mfa: Kino.Input.text("MFA")], submit: "Submit") + +frame = Kino.Frame.new() +Kino.render(frame) +Kino.Frame.append(frame, form) + +Kino.listen(form, fn event -> + credentials = + Nimrag.Credentials.new( + if(event.data.username != "", + do: event.data.username, + else: System.get_env("LB_NIMRAG_USERNAME") + ), + if(event.data.password != "", + do: event.data.password, + else: System.get_env("LB_NIMRAG_PASSWORD") + ), + fn -> + Kino.Frame.append(frame, mfa_code) + Kino.Control.subscribe(mfa_code, :mfa) + + receive do + {:mfa, %{data: %{mfa: code}}} -> + {:ok, String.trim(code)} + after + 30_000 -> + IO.puts(:stderr, "No message in 30 seconds") + {:error, :missing_mfa} + end + end + ) + + {:ok, client} = Nimrag.Auth.login_sso(credentials) + :ok = Nimrag.Credentials.write_fs_oauth1_token(client) + :ok = Nimrag.Credentials.write_fs_oauth2_token(client) + IO.puts("New OAuth tokens saved!") +end) +``` + +```elixir +client = + Nimrag.Client.new() + |> Nimrag.Client.with_auth(Nimrag.Credentials.read_oauth_tokens!()) +``` + +## Use API + +Fetch your profile + +```elixir +{:ok, %Nimrag.Api.Profile{} = profile, client} = Nimrag.profile(client) + +Kino.Markdown.new(""" + ## Profile for: #{profile.display_name} + + ![profile pic](#{profile.profile_image_url_medium}) + #{profile.bio} + + Favorite activity types: + + #{profile.favorite_activity_types |> Enum.map(&"- #{&1}") |> Enum.join("\n")} +""") +``` + +Fetch latest activity + +```elixir +{:ok, %Nimrag.Api.Activity{} = activity, client} = Nimrag.last_activity(client) + +# IO.inspect(activity) + +duration_humanized = + activity.duration + |> trunc() + |> Timex.Duration.from_seconds() + |> Elixir.Timex.Format.Duration.Formatters.Humanized.format() + +Kino.Markdown.new(""" + ## #{activity.activity_name} at #{activity.start_local_at} + + * Distance: #{Float.round(activity.distance / 1000, 2)} km + * Duration: #{duration_humanized} + * ID: #{activity.id} +""") +``` + +Or even download and analyse raw FIT file + +```elixir +{:ok, zip, client} = Nimrag.download_activity(client, activity.id, :raw) +{:ok, [file_path]} = :zip.unzip(zip, cwd: "/tmp") +{:ok, records} = file_path |> File.read!() |> ExtFit.Decode.decode() + +hd(records) +``` + +Show a graph of steps from last week + +```elixir +today = Date.utc_today() + +read_date = fn input -> + input + |> Kino.render() + |> Kino.Input.read() +end + +Kino.Markdown.new("## Select date range") |> Kino.render() +from_value = Kino.Input.date("From day", default: today) |> read_date.() +to_value = Kino.Input.date("To day", default: Date.add(today, -21)) |> read_date.() + +if !from_value || !to_value do + Kino.interrupt!(:error, "Input required") +end + +{:ok, steps_daily, client} = Nimrag.steps_daily(client, from_value, to_value) + +steps = + Explorer.DataFrame.new( + date: Enum.map(steps_daily, & &1.calendar_date), + steps: Enum.map(steps_daily, & &1.total_steps) + ) + +Kino.nothing() +``` + + + +```elixir +VegaLite.new(width: 800, title: "Daily number of steps") +|> VegaLite.data_from_values(steps, only: ["date", "steps"]) +|> VegaLite.mark(:bar) +|> VegaLite.encode_field(:x, "date", type: :temporal) +|> VegaLite.encode_field(:y, "steps", type: :quantitative) +``` + + diff --git a/lib/nimrag.ex b/lib/nimrag.ex new file mode 100644 index 0000000..1e477df --- /dev/null +++ b/lib/nimrag.ex @@ -0,0 +1,152 @@ +defmodule Nimrag do + alias Nimrag.Api + alias Nimrag.Client + import Nimrag.Api, only: [get: 2, response_as_data: 2] + + @type error() :: {:error, any} + + @moduledoc "README.md" + |> File.read!() + |> String.split("") + |> Enum.fetch!(1) + @external_resource "README.md" + + @doc """ + Gets full profile + """ + @spec profile(Client.t()) :: {:ok, Api.Profile.t(), Client.t()} | error() + def profile(client), do: client |> profile_req() |> response_as_data(Api.Profile) + def profile_req(client), do: get(client, url: "/userprofile-service/socialProfile") + + @doc """ + Gets number of completed and goal steps for each day. + + Start date must be equal or before end date. + + Avoid requesting too big ranges as it may fail. + """ + @spec steps_daily(Client.t()) :: {:ok, list(Api.StepsDaily.t()), Client.t()} | error() + @spec steps_daily(Client.t(), start_day :: Date.t()) :: + {:ok, list(Api.StepsDaily.t()), Client.t()} | error() + @spec steps_daily(Client.t(), start_day :: Date.t(), end_day :: Date.t()) :: + {:ok, list(Api.StepsDaily.t()), Client.t()} | error() + def steps_daily(client, start_date \\ Date.utc_today(), end_date \\ Date.utc_today()) do + if Date.before?(end_date, start_date) do + {:error, + {:invalid_date_range, "Start date must be eq or earlier than end date.", start_date, + end_date}} + else + client |> steps_daily_req(start_date, end_date) |> response_as_data(Api.StepsDaily) + end + end + + def steps_daily_req(client, start_date \\ Date.utc_today(), end_date \\ Date.utc_today()) do + get(client, + url: "/usersummary-service/stats/steps/daily/:start_date/:end_date", + path_params: [start_date: Date.to_iso8601(start_date), end_date: Date.to_iso8601(end_date)] + ) + end + + @doc """ + Gets a full summary of a given day. + """ + @spec user_summary(Client.t()) :: {:ok, list(Api.UserSummaryDaily.t()), Client.t()} | error() + @spec user_summary(Client.t(), start_day :: Date.t()) :: + {:ok, Api.UserSummaryDaily.t(), Client.t()} | error() + def user_summary(client, date \\ Date.utc_today()), + do: client |> user_summary_req(date) |> response_as_data(Api.UserSummaryDaily) + + def user_summary_req(client, date) do + get(client, + url: "/usersummary-service/usersummary/daily", + params: [calendarDate: Date.to_iso8601(date)] + ) + end + + @doc """ + Gets latestes activity + """ + @spec last_activity(Client.t()) :: {:ok, Api.Activity.t(), Client.t()} | any() + def last_activity(client) do + case activities(client, 0, 1) do + {:ok, [], _client} -> {:error, :not_found} + {:ok, [activity | _], client} -> {:ok, activity, client} + result -> result + end + end + + @doc """ + Gets activities + """ + @spec activities(Client.t()) :: {:ok, list(Api.Activity.t()), Client.t()} | error() + @spec activities(Client.t(), offset :: integer()) :: + {:ok, list(Api.Activity.t()), Client.t()} | error() + @spec activities(Client.t(), offset :: integer(), limit :: integer()) :: + {:ok, list(Api.Activity.t()), Client.t()} | error() + def activities(client, offset \\ 0, limit \\ 10) do + client |> activities_req(offset, limit) |> response_as_data(Api.Activity) + end + + def activities_req(client, offset, limit) do + get(client, + url: "/activitylist-service/activities/search/activities", + params: [limit: limit, start: offset] + ) + end + + @doc """ + Downloads activity. + + Activity download artifact - if original format is used, it's a zip and you + still need to decode it. + + CSV download is contains a summary of splits. + + ## Working with original zip file + + ```elixir + {:ok, zip, client} = Nimrag.download_activity(client, 123, :raw) + {:ok, [file_path]} = :zip.unzip(zip, cwd: "/tmp") + # Use https://github.com/arathunku/ext_fit to decode FIT file + {:ok, records} = file_path |> File.read!() |> ExtFit.Decode.decode() + ``` + """ + + @spec download_activity(Client.t(), activity_id :: integer(), :raw) :: + {:ok, binary(), Client.t()} | error() + @spec download_activity(Client.t(), activity_id :: integer(), :tcx) :: + {:ok, binary(), Client.t()} | error() + @spec download_activity(Client.t(), activity_id :: integer(), :gpx) :: + {:ok, binary(), Client.t()} | error() + @spec download_activity(Client.t(), activity_id :: integer(), :kml) :: + {:ok, binary(), Client.t()} | error() + @spec download_activity(Client.t(), activity_id :: integer(), :csv) :: + {:ok, binary(), Client.t()} | error() + def download_activity(client, activity_id, :raw) do + with {:ok, %{body: body, status: 200}, client} <- + download_activity_req(client, + prefix_url: "download-service/files/activity", + activity_id: activity_id + ) do + {:ok, body, client} + end + end + + def download_activity(client, activity_id, format) when format in ~w(tcx gpx kml csv)a do + with {:ok, %{body: body, status: 200}, client} <- + download_activity_req(client, + prefix_url: "download-service/export/#{format}/activity", + activity_id: activity_id + ) do + {:ok, body, client} + end + end + + @doc false + def download_activity_req(client, path_params) do + get(client, + url: ":prefix_url/:activity_id", + path_params: path_params + ) + end +end diff --git a/lib/nimrag/api.ex b/lib/nimrag/api.ex new file mode 100644 index 0000000..a98c615 --- /dev/null +++ b/lib/nimrag/api.ex @@ -0,0 +1,124 @@ +defmodule Nimrag.Api do + alias Nimrag.Client + alias Nimrag.OAuth1Token + alias Nimrag.Auth + + require Logger + + @moduledoc """ + Module to interact with Garmin's API **after** authentication. + + It handles common patterns of making the requests, pagination, list, etc. + + By default first argument is always the client, second options to Req, and + all requests are executed against "connectapi" subdomain unless specified otherwise. + + OAuth2 token may get refreshed automatically if expired. This is why all responses + return `{:ok, %Req.Response{}, client}` or `{:error, %Req.Response{}}`. + """ + + @spec get(Client.t(), Keyword.t()) :: + {:ok, Req.Response.t(), Client.t()} | {:error, Req.Response.t()} + def get(%Client{} = client, opts) do + client + |> req(opts) + |> Req.get() + |> case do + {:ok, %{status: 200} = resp} -> {:ok, resp, Req.Response.get_private(resp, :client)} + {:error, error} -> {:error, error} + end + end + + @spec response_as_data({:ok, Req.Response.t(), Client.t()}, data_module :: atom()) :: + {:ok, any(), Client.t()} | {:error, Req.Response.t()} + @spec response_as_data({:error, any()}, data_module :: atom()) :: {:error, any()} + def response_as_data({:ok, %Req.Response{status: 200, body: body}, client}, data_module) do + with {:ok, data} <- do_response_as_data(body, data_module) do + {:ok, data, client} + end + end + + def response_as_data({:error, error}, _data_module), do: {:error, error} + + def response_as_data(body, data_module) when is_map(body) do + data_module.from_api_response(body) + end + + defp do_response_as_data(body, data_module) when is_list(body) do + data = + Enum.map(body, fn element -> + with {:ok, data} <- data_module.from_api_response(element) do + data + end + end) + + first_error = + Enum.find(data, fn + {:error, _} -> true + _ -> false + end) + + first_error || {:ok, data} + end + + defp req(%Client{} = client, opts) do + if client.oauth2_token == nil do + Logger.warning( + "Setup OAuth2 Token first with Nimrag.Auth.login_sso/2 or NimRag.Client.attach_auth/2" + ) + end + + client.connectapi + |> Req.merge(opts) + |> Req.Request.put_private(:client, client) + |> Req.Request.append_request_steps( + req_nimrag_rate_limit: &rate_limit(&1), + req_nimrag_oauth: &connectapi_auth("connectapi." <> client.domain, &1) + ) + end + + defp connectapi_auth(host, %{url: %URI{scheme: "https", host: host, port: 443}} = req) do + client = Req.Request.get_private(req, :client) + + case Auth.maybe_refresh_oauth2_token(client) do + {:ok, client} -> + req + |> Req.Request.put_header("Authorization", "Bearer #{client.oauth2_token.access_token}") + |> Req.Request.append_response_steps( + req_nimrag_attach_request_path: fn {req, resp} -> + %{path: path} = URI.parse(req.url) + {req, Req.Response.put_private(resp, :request_path, path)} + end, + req_nimrag_attach_client: fn {req, resp} -> + {req, Req.Response.put_private(resp, :client, client)} + end + ) + + {:error, reason} -> + {Req.Request.halt(req), {:oauth2_token_refresh_error, reason}} + end + end + + defp connectapi_auth(_, req) do + {Req.Request.halt(req), :invalid_request_host} + end + + defp rate_limit(req) do + %Client{oauth1_token: %OAuth1Token{oauth_token: oauth_token}, rate_limit: rate_limit} = + Req.Request.get_private(req, :client) + + case rate_limit do + [scale_ms: scale_ms, limit: limit] -> + case Hammer.check_rate(:nimrag, "garmin.com:#{oauth_token}", scale_ms, limit) do + {:allow, _count} -> + req + + {:deny, limit} -> + {Req.Request.halt(req), {:rate_limit, limit}} + end + + false -> + req + end + end +end diff --git a/lib/nimrag/api/activity.ex b/lib/nimrag/api/activity.ex new file mode 100644 index 0000000..cf880ec --- /dev/null +++ b/lib/nimrag/api/activity.ex @@ -0,0 +1,25 @@ +defmodule Nimrag.Api.Activity do + use Nimrag.Api.Data + + @type t() :: %__MODULE__{ + id: integer(), + distance: float(), + duration: float(), + activity_name: String.t(), + begin_at: DateTime.t(), + start_local_at: NaiveDateTime.t() + } + + defstruct ~w(id distance duration begin_at start_local_at activity_name)a + + def schematic() do + schema(__MODULE__, %{ + {"beginTimestamp", :begin_at} => timestamp_datetime(), + {"startTimeLocal", :start_local_at} => naive_datetime(), + {"activityId", :id} => int(), + field(:activity_name) => str(), + distance: float(), + duration: float() + }) + end +end diff --git a/lib/nimrag/api/data.ex b/lib/nimrag/api/data.ex new file mode 100644 index 0000000..f65bb04 --- /dev/null +++ b/lib/nimrag/api/data.ex @@ -0,0 +1,83 @@ +defmodule Nimrag.Api.Data do + import Schematic + + @moduledoc false + + # Helper module for transforming API responses into proper structs + # Uses https://github.com/mhanberg/schematic and adds + + defmacro __using__(_) do + quote do + import Nimrag.Api.Data + import Schematic + + def from_api_response(resp) do + Nimrag.Api.Data.from_api_response(resp, __MODULE__) + end + end + end + + def from_api_response(resp, module) do + case unify(module.schematic(), resp) do + {:error, err} -> {:error, {:invalid_response, err, resp}} + {:ok, data} -> {:ok, data} + end + end + + def timestamp_datetime() do + raw( + fn + i, :to -> is_number(i) and match?({:ok, _}, DateTime.from_unix(i, :millisecond)) + i, :from -> match?(%DateTime{}, i) + end, + transform: fn + i, :to -> + {:ok, dt} = DateTime.from_unix(i, :millisecond) + dt + + i, :from -> + DateTime.to_unix(i, :millisecond) + end + ) + end + + def naive_datetime() do + raw( + fn + i, :to -> is_binary(i) and match?({:ok, _}, NaiveDateTime.from_iso8601(i)) + i, :from -> match?(%NaiveDateTime{}, i) + end, + transform: fn + i, :to -> + {:ok, dt} = NaiveDateTime.from_iso8601(i) + dt + + i, :from -> + NaiveDateTime.to_iso8601(i) + end + ) + end + + def date() do + raw( + fn + i, :to -> is_binary(i) and match?({:ok, _}, Date.from_iso8601(i)) + i, :from -> match?(%Date{}, i) + end, + transform: fn + i, :to -> + {:ok, dt} = Date.from_iso8601(i) + dt + + i, :from -> + Date.to_iso8601(i) + end + ) + end + + def field(field) when is_atom(field), + do: { + field |> to_string() |> Recase.to_camel(), + field + } +end diff --git a/lib/nimrag/api/profile.ex b/lib/nimrag/api/profile.ex new file mode 100644 index 0000000..115a8ca --- /dev/null +++ b/lib/nimrag/api/profile.ex @@ -0,0 +1,202 @@ +defmodule Nimrag.Api.Profile do + use Nimrag.Api.Data + + @type t() :: %__MODULE__{ + id: integer(), + profile_id: integer(), + garmin_guid: String.t(), + display_name: String.t(), + full_name: String.t(), + user_name: String.t(), + profile_image_url_large: nil | String.t(), + profile_image_url_medium: nil | String.t(), + profile_image_url_small: nil | String.t(), + location: nil | String.t(), + facebook_url: nil | String.t(), + twitter_url: nil | String.t(), + personal_website: nil | String.t(), + motivation: nil | integer(), + bio: nil | String.t(), + primary_activity: nil | String.t(), + favorite_activity_types: list(String.t()), + running_training_speed: float(), + cycling_training_speed: float(), + favorite_cycling_activity_types: list(String.t()), + cycling_classification: String.t(), + cycling_max_avg_power: float(), + swimming_training_speed: float(), + profile_visibility: String.t(), + activity_start_visibility: String.t(), + activity_map_visibility: String.t(), + course_visibility: String.t(), + activity_heart_rate_visibility: String.t(), + activity_power_visibility: String.t(), + badge_visibility: String.t(), + show_age: boolean(), + show_weight: boolean(), + show_height: boolean(), + show_weight_class: boolean(), + show_age_range: boolean(), + show_gender: boolean(), + show_activity_class: boolean(), + show_vo_2_max: boolean(), + show_personal_records: boolean(), + show_last_12_months: boolean(), + show_lifetime_totals: boolean(), + show_upcoming_events: boolean(), + show_recent_favorites: boolean(), + show_recent_device: boolean(), + show_recent_gear: boolean(), + show_badges: boolean(), + other_activity: nil | String.t(), + other_primary_activity: String.t(), + other_motivation: String.t(), + user_roles: list(String.t()), + name_approved: boolean(), + user_profile_full_name: String.t(), + make_golf_scorecards_private: boolean(), + allow_golf_live_scoring: boolean(), + allow_golf_scoring_by_connections: boolean(), + user_level: integer(), + user_point: integer(), + level_update_date: String.t(), + level_is_viewed: boolean(), + level_point_threshold: integer(), + user_point_offset: integer(), + user_pro: boolean() + } + + @fields [ + :activity_heart_rate_visibility, + :activity_map_visibility, + :activity_power_visibility, + :activity_start_visibility, + :allow_golf_live_scoring, + :allow_golf_scoring_by_connections, + :badge_visibility, + :bio, + :course_visibility, + :cycling_classification, + :cycling_max_avg_power, + :cycling_training_speed, + :display_name, + :facebook_url, + :favorite_activity_types, + :favorite_cycling_activity_types, + :full_name, + :garmin_guid, + :id, + :level_is_viewed, + :level_point_threshold, + :level_update_date, + :location, + :make_golf_scorecards_private, + :motivation, + :name_approved, + :other_activity, + :other_motivation, + :other_primary_activity, + :personal_website, + :primary_activity, + :profile_id, + :profile_image_url_large, + :profile_image_url_medium, + :profile_image_url_small, + :profile_visibility, + :running_training_speed, + :show_activity_class, + :show_age, + :show_age_range, + :show_badges, + :show_gender, + :show_height, + :show_last_12_months, + :show_lifetime_totals, + :show_personal_records, + :show_recent_device, + :show_recent_favorites, + :show_recent_gear, + :show_upcoming_events, + :show_vo_2_max, + :show_weight, + :show_weight_class, + :swimming_training_speed, + :twitter_url, + :user_level, + :user_name, + :user_point, + :user_point_offset, + :user_pro, + :user_profile_full_name, + :user_roles + ] + + defstruct @fields + + def schematic() do + schema(__MODULE__, %{ + field(:activity_heart_rate_visibility) => str(), + field(:activity_map_visibility) => str(), + field(:activity_power_visibility) => str(), + field(:activity_start_visibility) => str(), + field(:allow_golf_live_scoring) => bool(), + field(:allow_golf_scoring_by_connections) => bool(), + field(:badge_visibility) => str(), + field(:bio) => nullable(str()), + field(:course_visibility) => str(), + field(:cycling_classification) => nullable(str()), + field(:cycling_max_avg_power) => float(), + field(:cycling_training_speed) => float(), + field(:display_name) => str(), + field(:facebook_url) => nullable(str()), + field(:favorite_activity_types) => list(str()), + field(:favorite_cycling_activity_types) => list(str()), + field(:full_name) => str(), + field(:garmin_guid) => nullable(str()), + field(:id) => int(), + field(:level_is_viewed) => bool(), + field(:level_point_threshold) => int(), + field(:level_update_date) => str(), + field(:location) => nullable(str()), + field(:make_golf_scorecards_private) => bool(), + field(:motivation) => nullable(int()), + field(:name_approved) => bool(), + field(:other_activity) => nullable(str()), + field(:other_motivation) => nullable(str()), + field(:other_primary_activity) => nullable(str()), + field(:personal_website) => nullable(str()), + field(:primary_activity) => nullable(str()), + field(:profile_id) => int(), + field(:profile_image_url_large) => nullable(str()), + field(:profile_image_url_medium) => nullable(str()), + field(:profile_image_url_small) => nullable(str()), + field(:profile_visibility) => str(), + field(:running_training_speed) => float(), + field(:show_activity_class) => bool(), + field(:show_age) => bool(), + field(:show_age_range) => bool(), + field(:show_badges) => bool(), + field(:show_gender) => bool(), + field(:show_height) => bool(), + field(:show_last_12_months) => bool(), + field(:show_lifetime_totals) => bool(), + field(:show_personal_records) => bool(), + field(:show_recent_device) => bool(), + field(:show_recent_favorites) => bool(), + field(:show_recent_gear) => bool(), + field(:show_upcoming_events) => bool(), + {"showVO2Max", :show_vo_2_max} => bool(), + field(:show_weight) => bool(), + field(:show_weight_class) => bool(), + field(:swimming_training_speed) => float(), + field(:twitter_url) => nullable(str()), + field(:user_level) => int(), + field(:user_name) => str(), + field(:user_point) => int(), + field(:user_point_offset) => int(), + field(:user_pro) => bool(), + field(:user_profile_full_name) => str(), + field(:user_roles) => list(str()) + }) + end +end diff --git a/lib/nimrag/api/steps_daily.ex b/lib/nimrag/api/steps_daily.ex new file mode 100644 index 0000000..6e3409e --- /dev/null +++ b/lib/nimrag/api/steps_daily.ex @@ -0,0 +1,21 @@ +defmodule Nimrag.Api.StepsDaily do + use Nimrag.Api.Data + + @type t() :: %__MODULE__{ + calendar_date: String.t(), + step_goal: integer(), + total_distance: integer(), + total_steps: integer() + } + + defstruct calendar_date: nil, step_goal: 0, total_distance: 0, total_steps: 0 + + def schematic() do + schema(__MODULE__, %{ + field(:calendar_date) => date(), + field(:step_goal) => int(), + field(:total_distance) => int(), + field(:total_steps) => int() + }) + end +end diff --git a/lib/nimrag/api/user_summary_daily.ex b/lib/nimrag/api/user_summary_daily.ex new file mode 100644 index 0000000..16bb012 --- /dev/null +++ b/lib/nimrag/api/user_summary_daily.ex @@ -0,0 +1,113 @@ +defmodule Nimrag.Api.UserSummaryDaily do + use Nimrag.Api.Data + + @type t() :: %__MODULE__{ + total_steps: integer() + } + + defstruct ~w(total_steps)a + + def schematic() do + schema(__MODULE__, %{ + field(:total_steps) => int() + }) + end +end + +# %{ +# "uncategorizedStressDuration" => 120, +# "measurableAwakeDuration" => 600, +# "averageMonitoringEnvironmentAltitude" => 159.0, +# "stressDuration" => 4200, +# "bodyBatteryDuringSleep" => 46, +# "activityStressPercentage" => 1.48, +# "wellnessDistanceMeters" => 161, +# "bodyBatteryVersion" => 3.0, +# "floorsDescended" => 0.0, +# "latestSpo2" => nil, +# "wellnessStartTimeGmt" => "2024-04-05T22:00:00.0", +# "wellnessEndTimeGmt" => "2024-04-06T04:51:00.0", +# "wellnessEndTimeLocal" => "2024-04-06T06:51:00.0", +# "measurableAsleepDuration" => 23580, +# "wellnessStartTimeLocal" => "2024-04-06T00:00:00.0", +# "minAvgHeartRate" => 53, +# "wellnessActiveKilocalories" => 4.0, +# "activityStressDuration" => 360, +# "userDailySummaryId" => 3532005, +# "totalSteps" => 203, +# "averageStressLevel" => 22, +# "floorsAscendedInMeters" => 0.0, +# "userFloorsAscendedGoal" => 0, +# "netRemainingKilocalories" => 1804.0, +# "sedentarySeconds" => 616, +# "includesActivityData" => false, +# "netCalorieGoal" => 1800, +# "includesWellnessData" => true, +# "averageSpo2" => nil, +# "bmrKilocalories" => 594.0, +# "consumedKilocalories" => nil, +# "lowestRespirationValue" => 13.0, +# "lastSyncTimestampGMT" => "2024-04-06T04:51:55.607", +# "vigorousIntensityMinutes" => 0, +# "source" => "GARMIN", +# "uncategorizedStressPercentage" => 0.49, +# "bodyBatteryMostRecentValue" => 69, +# "highStressDuration" => nil, +# "rule" => %{"typeId" => 3, "typeKey" => "subscribers"}, +# "userProfileId" => 3532005, +# "totalDistanceMeters" => 161, +# "wellnessDescription" => nil, +# "bodyBatteryDynamicFeedbackEvent" => %{ +# "bodyBatteryLevel" => "MODERATE", +# "eventTimestampGmt" => "2024-04-06T02:32:33", +# "feedbackLongType" => "EARLY_MORNING_NO_DATA", +# "feedbackShortType" => nil +# }, +# "avgWakingRespirationValue" => 14.0, +# "maxStressLevel" => 62, +# "stressQualifier" => "UNKNOWN", +# "highestRespirationValue" => 19.0, +# "latestRespirationTimeGMT" => "2024-04-06T04:51:00.0", +# "lowStressPercentage" => 17.04, +# "wellnessKilocalories" => 598.0, +# "lastSevenDaysAvgRestingHeartRate" => 52, +# "uuid" => "d4b3a74c4ab94ebba80c556a9a5eff21", +# "intensityMinutesGoal" => 180, +# "bodyBatteryHighestValue" => 69, +# "restingCaloriesFromActivity" => nil, +# "dailyStepGoal" => 7777, +# "remainingKilocalories" => 598.0, +# "bodyBatteryChargedValue" => 40, +# "includesCalorieConsumedData" => false, +# "lowestSpo2" => nil, +# "lowStressDuration" => 4140, +# "activeSeconds" => 37, +# "activeKilocalories" => 4.0, +# "latestSpo2ReadingTimeLocal" => nil, +# "minHeartRate" => 52, +# "restingHeartRate" => 55, +# "abnormalHeartRateAlertsCount" => nil, +# "mediumStressDuration" => 60, +# "bodyBatteryDrainedValue" => 0, +# "durationInMilliseconds" => 24660000, +# "latestRespirationValue" => 14.0, +# "maxAvgHeartRate" => 88, +# "calendarDate" => "2024-04-06", +# "highlyActiveSeconds" => 44, +# "privacyProtected" => false, +# "mediumStressPercentage" => 0.25, +# "totalStressDuration" => 24300, +# "floorsDescendedInMeters" => 0.0, +# "highStressPercentage" => 0.0, +# "moderateIntensityMinutes" => 0, +# "maxHeartRate" => 88, +# "sleepingSeconds" => 23963, +# "latestSpo2ReadingTimeGmt" => nil, +# "burnedKilocalories" => nil, +# "floorsAscended" => 0.0, +# "stressPercentage" => 17.28, +# "restStressPercentage" => 80.74, +# "totalKilocalories" => 598.0, +# "bodyBatteryLowestValue" => 29, +# "restStressDuration" => 19620 +# } diff --git a/lib/nimrag/application.ex b/lib/nimrag/application.ex new file mode 100644 index 0000000..c710f94 --- /dev/null +++ b/lib/nimrag/application.ex @@ -0,0 +1,36 @@ +defmodule Nimrag.Application do + @moduledoc false + + use Application + + @impl true + def start(_type, _args) do + hammer_opts = + Application.get_env(:nimrag, :hammer, + nimrag: + {Hammer.Backend.ETS, + [ + expiry_ms: 60_000 * 60 * 2, + cleanup_interval_ms: 60_000 * 2 + ]} + ) + + hammer = + if Application.get_env(:hammer, :backend) in [[], nil] do + Application.put_env(:hammer, :backend, hammer_opts) + + [ + %{ + id: Hammer.Supervisor, + start: {Hammer.Supervisor, :start_link, [hammer_opts, [name: Hammer.Supervisor]]} + } + ] + else + [] + end + + children = [] ++ hammer + + Supervisor.start_link(children, strategy: :one_for_one) + end +end diff --git a/lib/nimrag/auth.ex b/lib/nimrag/auth.ex new file mode 100644 index 0000000..8e06c5e --- /dev/null +++ b/lib/nimrag/auth.ex @@ -0,0 +1,383 @@ +defmodule Nimrag.Auth do + alias Nimrag.Client + alias Nimrag.Credentials + alias Nimrag.{OAuth1Token, OAuth2Token} + + require Logger + + @moduledoc """ + Unofficial authentication to Garmin's API. + + It requires username, password, and may ask for MFA code if needed. + See `Nimrag.Credentials` for more details on how to provide credentials. + + It's using the same method for obtaining OAuth1 and OAuth2 tokens as the mobile app, + and popular Python library [garth](https://github.com/matin/garth). + + **This may break at any time as it's not using public API.** + + Garmin API is available only to business partners, so please don't abuse it in any way. + """ + + # hardcoded values from fetched s3 url in https://github.com/matin/garth + # base64 encoded values, based on GitHub issues, that's what the app is using + @oauth_consumer_key Base.url_decode64!("ZmMzZTk5ZDItMTE4Yy00NGI4LThhZTMtMDMzNzBkZGUyNGMw") + @oauth_consumer_secret Base.url_decode64!("RTA4V0FSODk3V0V5MmtubjdhRkJydmVnVkFmMEFGZFdCQkY=") + @mobile_user_agent "com.garmin.android.apps.connectmobile" + # API always refreshes expired tokens BEFORE making request, NOT on 401 errors + @short_expires_by_n_seconds 10 + + # simulate web-like login flow without using secret key/secret extracted from mobile app + # def login_web(client) do + # end + + @spec login_sso(Credentials.t()) :: {:ok, Client.t()} | {:error, String.t()} + @spec login_sso(Client.t()) :: {:ok, Client.t()} | {:error, String.t()} + @spec login_sso(Client.t(), Credentials.t()) :: {:ok, Client.t()} | {:error, String.t()} + + def login_sso, do: login_sso(Client.new(), Credentials.new()) + def login_sso(%Credentials{} = credentials), do: login_sso(Client.new(), credentials) + def login_sso(%Client{} = client), do: login_sso(client, Credentials.new()) + + def login_sso(%Client{} = client, %Credentials{} = credentials) do + with {:ok, sso} <- build_sso(client), + {:ok, embed_response} <- embed_req(sso), + {:ok, signin_response} <- signin_req(sso, embed_response), + {:ok, signin_post_response} <- + submit_signin_req(sso, signin_response, credentials), + cookie = get_cookie(signin_response), + {:ok, signin_post_response} <- + maybe_handle_mfa(sso, signin_post_response, cookie, credentials), + {:ok, ticket} <- get_ticket(signin_post_response), + {:ok, oauth1_token} <- get_oauth1_token(client, ticket), + {:ok, oauth2_token} <- get_oauth2_token(client, oauth1_token) do + {:ok, + client + |> Client.put_oauth_token(oauth1_token) + |> Client.put_oauth_token(oauth2_token)} + else + error -> + Logger.debug(fn -> + "Details why login failed: #{inspect(error)}. It may contain sensitive data, depending on the error." + end) + + {:error, "Couldn't fully authenticate. Error data is only printed on debug log level."} + end + end + + def get_oauth1_token(%Client{} = client, ticket) do + url = "/oauth-service/oauth/preauthorized" + + params = [ + {"ticket", ticket}, + {"login-url", sso_url(client) <> "/embed"}, + {"accepts-mfa-tokens", "true"} + ] + + {{"Authorization", oauth}, req_params} = + OAuther.sign("get", client.connectapi.options.base_url <> url, params, oauth_creds()) + |> OAuther.header() + + now = DateTime.utc_now() + + {:ok, response} = + client.connectapi + |> Req.Request.put_header("Authorization", oauth) + |> Req.get( + url: url, + params: req_params, + user_agent: @mobile_user_agent + ) + + %{"oauth_token" => token, "oauth_token_secret" => secret} = + query = URI.decode_query(response.body) + + {:ok, + %OAuth1Token{ + oauth_token: token, + oauth_token_secret: secret, + domain: client.domain, + mfa_token: query["mfa_token"] || "", + # TODO: OAuth1Token, Is that 365 days true with MFA active? We'll wait and see! + expires_at: DateTime.add(now, 365, :day) + }} + end + + def maybe_refresh_oauth2_token(%Client{} = client, opts \\ []) do + force = Keyword.get(opts, :force, false) + + if client.oauth2_token == nil || OAuth2Token.expired?(client.oauth2_token) || force do + Logger.info(fn -> "Refreshing OAuth2Token #{inspect(client.oauth2_token)}" end) + + with {:ok, oauth2_token} <- get_oauth2_token(client, client.oauth1_token) do + {:ok, client |> Client.put_oauth_token(oauth2_token)} + end + else + {:ok, client} + end + end + + def get_oauth2_token(%Client{} = client) do + get_oauth2_token(client, client.oauth1_token) + end + + def get_oauth2_token(%Client{} = client, %OAuth1Token{} = oauth1_token) do + url = "/oauth-service/oauth/exchange/user/2.0" + + params = + if oauth1_token.mfa_token && oauth1_token.mfa_token != "" do + [{"mfa_token", oauth1_token.mfa_token}] + else + [] + end + + {{"Authorization" = auth, oauth}, req_params} = + OAuther.sign( + "post", + client.connectapi.options.base_url <> url, + params, + oauth_creds(oauth1_token) + ) + |> OAuther.header() + + now = DateTime.utc_now() + + result = + client.connectapi + |> Req.Request.put_header(auth, oauth) + |> Req.post( + url: url, + form: req_params, + user_agent: @mobile_user_agent + ) + + with {:ok, response} <- result do + %{ + "access_token" => access_token, + "expires_in" => expires_in, + "jti" => jti, + "refresh_token" => refresh_token, + "refresh_token_expires_in" => refresh_token_expires_in, + "scope" => scope, + "token_type" => token_type + } = response.body + + expires_at = DateTime.add(now, expires_in - @short_expires_by_n_seconds, :second) + + refresh_token_expires_at = + DateTime.add(now, refresh_token_expires_in - @short_expires_by_n_seconds, :second) + + {:ok, + %OAuth2Token{ + access_token: access_token, + jti: jti, + expires_at: expires_at, + refresh_token: refresh_token, + refresh_token_expires_at: refresh_token_expires_at, + scope: scope, + token_type: token_type + }} + end + end + + defp maybe_handle_mfa(sso, %Req.Response{} = prev_resp, cookie, credentials) do + if String.contains?(get_location(prev_resp), "verifyMFA") do + submit_mfa(sso, cookie, credentials) + else + {:ok, prev_resp} + end + end + + defp submit_mfa(sso, cookie, credentials) do + with {:ok, response} <- get_mfa(sso, cookie), + {:ok, csrf_token} <- get_csrf_token(response), + {:ok, mfa_code} = Credentials.get_mfa(credentials), + {:ok, %{status: 302} = response} <- submit_mfa_req(sso, csrf_token, cookie, mfa_code) do + uri = response |> get_location() |> URI.parse() + + sso.client + |> Req.Request.put_header("cookie", Enum.uniq(cookie ++ get_cookie(response))) + |> Req.Request.put_header( + "referer", + "#{sso.url}/verifyMFA/loginEnterMfaCode" + ) + |> Req.get( + url: "/login", + params: URI.decode_query(uri.query) + ) + |> check_response(:submit_mfa) + end + end + + defp oauth_creds do + OAuther.credentials( + consumer_key: @oauth_consumer_key, + consumer_secret: @oauth_consumer_secret + ) + end + + defp oauth_creds(%OAuth1Token{oauth_token: token, oauth_token_secret: secret}) do + OAuther.credentials( + consumer_key: @oauth_consumer_key, + consumer_secret: @oauth_consumer_secret, + token: token, + token_secret: secret + ) + end + + defp get_cookie(%Req.Response{} = response), + do: Req.Response.get_header(response, "set-cookie") + + defp get_location(%Req.Response{} = response), + do: List.first(Req.Response.get_header(response, "location")) || "" + + defp get_csrf_token(%Req.Response{body: body, status: 200}) do + case Regex.scan(~r/name="_csrf"\s+value="(.+?)"/, body) do + [[_, csrf_token]] -> {:ok, csrf_token} + _ -> {:error, :missing_csrf} + end + end + + defp get_csrf_token(%Req.Response{}), do: {:error, :missing_csrf} + + defp get_ticket(%Req.Response{body: body, status: 200}) do + case Regex.scan(~r/embed\?ticket=([^"]+)"/, body) do + [[_, ticket]] -> {:ok, ticket} + _ -> {:error, :missing_ticket} + end + end + + defp submit_mfa_req(sso, csrf_token, cookie, mfa_code) do + sso.client + |> Req.Request.put_header("cookie", cookie) + |> Req.Request.put_header("referer", "#{sso.url}/verifyMFA") + |> Req.post( + url: "/verifyMFA/loginEnterMfaCode", + params: sso.signin_params, + form: %{ + "mfa-code" => mfa_code, + fromPage: "setupEnterMfaCode", + embed: "true", + _csrf: csrf_token + } + ) + |> check_response(:submit_mfa_req) + end + + @get_mfa_retry_count 3 + defp get_mfa(sso, cookie), do: get_mfa(sso, cookie, @get_mfa_retry_count) + defp get_mfa(_sso, _cookie, 0), do: {:error, :mfa_unavailable} + + defp get_mfa(sso, cookie, retry) do + sso.client + |> Req.Request.put_header("cookie", cookie) + |> Req.Request.put_header("referer", "#{sso.url}/signin") + |> Req.get( + url: "/verifyMFA/loginEnterMfaCode", + params: sso.signin_params + ) + |> check_response(:get_mfa) + |> case do + {:ok, %{status: 302}} -> + Logger.debug(fn -> "Getting MFA submit page failed, retrying..." end) + Process.sleep(1000) + get_mfa(sso, cookie, retry - 1) + + result -> + result + end + end + + defp embed_req(sso) do + Req.get(sso.client, url: "/embed", params: sso.embed_params) + |> check_response(:embed_req) + end + + defp signin_req(sso, %Req.Response{} = prev_resp) do + sso.client + |> Req.Request.put_header("cookie", get_cookie(prev_resp)) + |> Req.Request.put_header("referer", "#{sso.url}/embed") + |> Req.get( + url: "/signin", + params: sso.signin_params + ) + |> check_response(:signin_req) + end + + defp submit_signin_req(sso, %Req.Response{} = prev_resp, credentials) do + with {:ok, csrf_token} <- get_csrf_token(prev_resp) do + sso.client + |> Req.Request.put_header("cookie", get_cookie(prev_resp)) + |> Req.Request.put_header("referer", "#{sso.url}/signin") + |> Req.post( + url: "/signin", + params: sso.signin_params, + form: %{ + username: credentials.username, + password: credentials.password, + embed: "true", + _csrf: csrf_token + } + ) + |> check_response(:submit_signin_req) + end + end + + def build_sso(%Client{} = client) do + sso_url = sso_url(client) + sso_embed = "#{sso_url}/embed" + + embed_params = %{ + id: "gauth-widget", + embedWidget: "true", + gauthHost: sso_url + } + + signin_params = + Map.merge(embed_params, %{ + gauthHost: sso_embed, + service: sso_embed, + source: sso_embed, + redirectAfterAccountLoginUrl: sso_embed, + redirectAfterAccountCreationUrl: sso_embed + }) + + {:ok, + %{ + client: sso_client(client), + url: sso_url, + embed: sso_embed, + embed_params: embed_params, + signin_params: signin_params + }} + end + + defp sso_client(%Client{} = client) do + client.req_options + |> Keyword.put(:base_url, sso_url(client)) + |> Keyword.put(:user_agent, @mobile_user_agent) + |> Keyword.put(:retry, false) + |> Keyword.put(:redirect, false) + |> Keyword.put( + :connect_options, + Keyword.merge( + Keyword.get(client.req_options, :connect_options) || [], + # It's very important that http2 is here, otherwise MFA flow fails. + protocols: [:http1, :http2] + ) + ) + |> Req.new() + |> Req.Request.put_new_header("host", "sso.#{client.domain}") + |> Req.Request.put_header("host", client.domain) + end + + defp sso_url(%{domain: domain}) do + "https://sso.#{domain}/sso" + end + + defp check_response({:ok, %{status: status} = response}, _tag) when status in [200, 302], + do: {:ok, response} + + defp check_response({:ok, response}, tag), do: {:error, {tag, response}} + defp check_response({:error, err}, tag), do: {:error, {tag, err}} +end diff --git a/lib/nimrag/client.ex b/lib/nimrag/client.ex new file mode 100644 index 0000000..dd9e281 --- /dev/null +++ b/lib/nimrag/client.ex @@ -0,0 +1,143 @@ +defmodule Nimrag.Client do + @type t() :: %__MODULE__{ + connectapi: Req.Request.t(), + domain: String.t(), + req_options: Keyword.t(), + oauth1_token: Nimrag.OAuth1Token.t() | nil, + oauth2_token: Nimrag.OAuth2Token.t() | nil, + rate_limit: [scale_ms: integer(), limit: integer()] + } + + alias Nimrag.OAuth1Token + alias Nimrag.OAuth2Token + + defstruct connectapi: nil, + domain: "garmin.com", + req_options: [], + oauth1_token: nil, + oauth2_token: nil, + rate_limit: nil + + # Options passed to Hammer, there are no official API limits so let's be + # good citizens! Page load on Garmin dashboard performs over 200 requests + @default_rate_limit [scale_ms: 30_000, limit: 60] + @connectapi_user_agent "Mozilla/5.0 (Android 14; Mobile; rv:125.0) Gecko/125.0 Firefox/125.0" + + @moduledoc """ + Struct containing all the required data to interact with the library and to make + requests to Garmin. + + See `Nimrag.Client.new/1` for more details about the configuration. + """ + + @doc """ + + Builds initial struct with the required configuration to interact with Garmin's API. + + Supported options: + + * `:domain` - Garmin's domain, by default it's "garmin.com". + * `:req_options` - Custom Req options to be passed to all requests. + + You can capture and proxy all requests with [mitmmproxy](https://mitmproxy.org/), + + ```elixir + req_options: [ + connect_options: [ + protocols: [:http2], + transport_opts: [cacertfile: Path.expand("~/.mitmproxy/mitmproxy-ca-cert.pem")], + proxy: {:http, "localhost", 8080, []} + ] + ] + ``` + + + * `:rate_limit` - Rate limit for all requests, see "Rate limit" in the `Nimrag` module, + by default it's set to 60 requests every 30 seconds. + + ```elixir + rate_limit: [scale_ms: 30_000, limit: 10] + ``` + + """ + + @spec new() :: t() + @spec new(Keyword.t()) :: t() | no_return + def new(config \\ []) when is_list(config) do + {domain, config} = Keyword.pop(config, :domain, "garmin.com") + {custom_req_options, config} = Keyword.pop(config, :req_options, []) + {rate_limit, config} = Keyword.pop(config, :rate_limit, @default_rate_limit) + + if config != [] do + raise "Unknown config key(s): #{inspect(config)}" + end + + req_opts = [user_agent: @connectapi_user_agent] |> Keyword.merge(custom_req_options) + + # use: Req.merge + %__MODULE__{ + req_options: req_opts, + connectapi: + [base_url: "https://connectapi.#{domain}"] |> Keyword.merge(req_opts) |> Req.new(), + domain: domain, + oauth1_token: nil, + oauth2_token: nil, + rate_limit: rate_limit + } + end + + @doc """ + Used to attach OAuth tokens to the client + + ## Example + + ```elixir + Nimrag.Client.new() |> Nimrag.Client.with_auth(Nimrag.Credentials.read_oauth_tokens!()) + ``` + + """ + + @spec with_auth(t(), {OAuth1Token.t(), OAuth2Token.t()}) :: t() + def with_auth(%__MODULE__{} = client, {%OAuth1Token{} = oauth1, %OAuth2Token{} = oauth2}) do + client + |> put_oauth_token(oauth1) + |> put_oauth_token(oauth2) + end + + @doc """ + Adds OAuth1 or OAuth2 token to the client + """ + @spec put_oauth_token(t(), OAuth1Token.t()) :: t() + @spec put_oauth_token(t(), OAuth2Token.t()) :: t() + def put_oauth_token(%__MODULE__{} = client, %OAuth1Token{} = token) do + client + |> Map.put(:oauth1_token, token) + end + + def put_oauth_token(%__MODULE__{} = client, %OAuth2Token{} = token) do + client + |> Map.put(:oauth2_token, token) + end +end + +defimpl Inspect, for: Nimrag.Client do + alias Nimrag.Client + import Inspect.Algebra + + def inspect( + %Client{} = client, + opts + ) do + details = + Inspect.List.inspect( + [ + domain: client.domain, + oauth1_token: client.oauth1_token && "#Nimrag.OAuth1Token<...>", + oauth2_token: client.oauth2_token && "#Nimrag.OAuth2Token<...>" + ], + opts + ) + + concat(["#Nimrag.Client<", details, ">"]) + end +end diff --git a/lib/nimrag/credentials.ex b/lib/nimrag/credentials.ex new file mode 100644 index 0000000..686852e --- /dev/null +++ b/lib/nimrag/credentials.ex @@ -0,0 +1,331 @@ +defmodule Nimrag.Credentials do + require Logger + + alias Nimrag.Client + alias Nimrag.OAuth1Token + alias Nimrag.OAuth2Token + + @type get_mfa() :: nil | mfa() | (-> {:ok, String.t()} | {:error, atom()}) + + @type t() :: %__MODULE__{ + username: nil | String.t(), + password: nil | String.t(), + get_mfa: get_mfa() + } + defstruct username: nil, password: nil, get_mfa: nil + + @moduledoc """ + Holds credentials for authentication. Required only to setup initial OAuth tokens. + + Username and password are needed for `Nimrag.Auth.login_sso/2`. + + > ### Multi factor authentication (MFA) {: .warning} + > Nimrag supports MFA flow by asking to input code when needed, + > **it's highly recommended that you set up MFA on you Garmin account**. + + Nimrag tries to provied nice out of box defaults and credentials are obtained in a number of ways: + + * username + + 1. Passed as an argument to `new/2` + 1. Environment variable `NIMRAG_USERNAME` + 1. Read from file `{{config_path}}/nimrag/credentials.json` + + * password: + + 1. Passerd as an argument to `new/2` + 1. Environment variable `NIMRAG_PASSWORD` + 1. Environment variable `NIMRAG_PASSWORD_FILE` with a path to a file containing the password + 1. Environment variable `NIMRAG_PASSWORD_COMMAND` with a command that will output the password + 1. Read from file `{{config_path}}/credentials.json` (`XDG_CONFIG_HOME`) + + * MFA code - by default it's stdin, but you can provide your own function to read it + + You should use `{{config_path}}/credentials.json` as last resort and in case you do, + ensure that the file has limited permissions(`600`), otherwise you'll get a warning. + + What's `{{config_path}}`? + + By default, it's going to be `~/.config/nimrag`. You can also supply custom + value via `config :nimrag, config_fs_path: "/path/to/config"` or `NIMRAG_CONFIG_PATH`. + This is the location for OAuth tokens, and optionally credentials. + + Created OAuth tokens are stored in `{{config_path}}/oauth1_token.json` and `{{config_path}}/oauth2_token.json`. + OAuth2 token is valid for around an hour and is automatically refreshed when needed. + OAuth1Token is valid for up to 1 year and when it expires, you'll need re-authenticate with + username and password. + """ + + @spec new() :: t() + @spec new(username :: nil | String.t()) :: t() + @spec new(username :: nil | String.t(), password :: nil | String.t()) :: t() + @spec new(username :: nil | String.t(), password :: nil | String.t(), get_mfa :: get_mfa()) :: + t() + def new(username \\ nil, password \\ nil, get_mfa \\ nil) do + %__MODULE__{ + username: + username || get_username() || read_fs_credentials().username || + raise("Missing username for authentication"), + password: + password || get_password() || read_fs_credentials().password || + raise("Missing password for authentication"), + get_mfa: get_mfa || {__MODULE__, :read_user_input_mfa, []} + } + end + + @doc """ + Reads previously stored OAuth tokens + """ + @spec read_oauth_tokens! :: {OAuth1Token.t(), OAuth2Token.t()} | no_return + def read_oauth_tokens! do + {read_oauth1_token!(), read_oauth2_token!()} + end + + @doc """ + See `read_oauth1_token/0` for details. + """ + @spec read_oauth1_token! :: OAuth1Token.t() | no_return + def read_oauth1_token! do + case read_oauth1_token() do + {:ok, oauth1_token} -> oauth1_token + {:error, error} -> raise error + end + end + + @spec read_oauth2_token! :: OAuth2Token.t() | no_return + def read_oauth2_token! do + case read_oauth2_token() do + {:ok, oauth2_token} -> oauth2_token + {:error, error} -> raise error + end + end + + @doc """ + Reads OAuth1 token from `{{config_path}}/oauth1_token.json` + + See `Nimrag.Auth` for more details on how to obtain auth tokens. + """ + @spec read_oauth1_token :: {:ok, OAuth1Token.t()} | {:error, String.t()} + def read_oauth1_token do + read_oauth_token(:oauth1_token, fn data -> + {:ok, expires_at, 0} = DateTime.from_iso8601(data["expires_at"]) + + %OAuth1Token{ + domain: data["domain"], + expires_at: expires_at, + mfa_token: data["mfa_token"], + oauth_token: data["oauth_token"], + oauth_token_secret: data["oauth_token_secret"] + } + end) + end + + @doc """ + Reads OAuth2 token from `{{config_path}}/oauth2_token.json` + + See `Nimrag.Auth` for more details on how to obtain auth tokens. + """ + @spec read_oauth2_token :: {:ok, OAuth2Token.t()} | {:error, String.t()} + def read_oauth2_token do + read_oauth_token(:oauth2_token, fn data -> + {:ok, expires_at, 0} = DateTime.from_iso8601(data["expires_at"]) + {:ok, refresh_token_expires_at, 0} = DateTime.from_iso8601(data["refresh_token_expires_at"]) + + %OAuth2Token{ + scope: data["scope"], + jti: data["jit"], + token_type: data["token_type"], + access_token: data["access_token"], + refresh_token: data["refresh_token"], + expires_at: expires_at, + refresh_token_expires_at: refresh_token_expires_at + } + end) + end + + defp read_oauth_token(key, to_struct_mapper) do + case read_fs_oauth_token(key, to_struct_mapper) do + nil -> + {:error, "No #{key}.json found."} + + oauth_token -> + {:ok, oauth_token} + end + end + + defp read_fs_oauth_token(key, to_struct_mapper) do + token_fs_path = Path.join(config_fs_path(), "#{key}.json") + + with {:ok, data} <- File.read(token_fs_path), + {:ok, token} <- decode_json(data, "Invalid JSON in #{key}.json") do + to_struct_mapper.(token) + else + _ -> + nil + end + end + + @doc """ + Writes currently used OAuth1 token to `{{config_path}}/oauth1_token.json` + + You only need to call this after initial login with `Nimrag.Auth`. + """ + def write_fs_oauth1_token(%Client{oauth1_token: token}), do: write_fs_oauth1_token(token) + + @doc false + def write_fs_oauth1_token(%OAuth1Token{} = token), + do: write_fs_oauth_token(:oauth1_token, token) + + @doc """ + Writes currently used OAuth2 token to `{{config_path}}/oauth2_token.json` + + You should call it after initial login with `Nimrag.Auth`, and each session + otherwise this token will have to be refreshed very often. + """ + def write_fs_oauth2_token(%Client{oauth2_token: token}), do: write_fs_oauth2_token(token) + + @doc false + def write_fs_oauth2_token(%OAuth2Token{} = token), + do: write_fs_oauth_token(:oauth2_token, token) + + defp write_fs_oauth_token(key, token) do + path = Path.join(config_fs_path(), "#{key}.json") + + with {:ok, data} = Jason.encode(token, pretty: true), + _ <- Logger.debug(fn -> ["writing ", path] end), + :ok <- File.mkdir_p!(Path.dirname(path)), + :ok <- File.touch!(path), + :ok <- File.chmod!(path, 0o600), + :ok <- File.write!(path, data) do + :ok + end + end + + defp get_username, do: System.get_env("NIMRAG_USERNAME") + + defp get_password do + cond do + password = System.get_env("NIMRAG_PASSWORD") -> + password + + password_file = System.get_env("NIMRAG_PASSWORD_FILE") -> + password_file + |> Path.expand() + |> File.read!() + + password_cmd = System.get_env("NIMRAG_PASSWORD_COMMAND") -> + [cmd | args] = String.split(password_cmd, " ", trim: true) + + case System.cmd(cmd, args) do + {output, 0} -> + output + + _ -> + raise "Failed to execute password command: cmd=#{cmd} args=#{inspect(args)}" + end + end + |> String.trim() + end + + @doc false + def get_mfa(%__MODULE__{get_mfa: get_mfa}) when is_function(get_mfa), do: get_mfa.() + + def get_mfa(%__MODULE__{get_mfa: {module, fun, args}}) when is_atom(module) and is_atom(fun), + do: apply(module, fun, args) + + @doc false + # Reads MFA code from stdin. This is used as a default. + + @spec read_user_input_mfa :: {:ok, String.t()} | {:error, atom()} + def read_user_input_mfa do + IO.gets("Enter MFA code: ") + |> String.trim() + |> case do + "" -> {:error, :invalid_mfa} + code -> {:ok, code} + end + end + + defp read_fs_credentials do + credentials_fs_path = Path.join(config_fs_path(), "credentials.json") + + credentials = + with {:ok, data} <- read_credentials(credentials_fs_path), + {:ok, credentials} <- decode_json(data, "Invalid JSON in credentials.json") do + %__MODULE__{ + username: credentials["username"], + password: credentials["password"] + } + else + _ -> + %__MODULE__{ + username: nil, + password: nil + } + end + + credentials + end + + defp validate_permissions(path) do + case File.stat(path) do + {:ok, %File.Stat{mode: mode}} -> + if mode != 0o100600 do + raise """ + Invalid permissions for #{path}. Expected 600, got #{Integer.to_string(mode, 8)} + """ + end + + _ -> + raise "Could not read permissions for #{path}" + end + end + + defp decode_json(data, error_msg) do + case Jason.decode(data) do + {:ok, data} -> + {:ok, data} + + {:error, _} -> + Logger.warning(error_msg) + + nil + end + end + + defp read_credentials(path) do + if File.exists?(path) do + validate_permissions(path) + File.read(path) + end + end + + defp config_fs_path do + Application.get_env(:nimrag, :config_fs_path) || + System.get_env("NIMRAG_CONFIG_PATH") || + :filename.basedir(:user_config, "nimrag") + end +end + +defimpl Inspect, for: Nimrag.Credentials do + alias Nimrag.Credentials + import Inspect.Algebra + + def inspect( + %Credentials{username: username}, + opts + ) do + details = + Inspect.List.inspect( + [ + username: + (username |> String.split("@", trim: true) |> List.first() |> String.slice(0, 5)) <> + "...", + password: "*****" + ], + opts + ) + + concat(["#Nimrag.Credentials<", details, ">"]) + end +end diff --git a/lib/nimrag/oauth1_token.ex b/lib/nimrag/oauth1_token.ex new file mode 100644 index 0000000..177a7af --- /dev/null +++ b/lib/nimrag/oauth1_token.ex @@ -0,0 +1,43 @@ +defmodule Nimrag.OAuth1Token do + @moduledoc """ + See `Nimrag.Credentials` for more details on how to obtain auth tokens. + """ + @type t() :: %__MODULE__{ + oauth_token: nil | String.t(), + oauth_token_secret: nil | String.t(), + mfa_token: nil | String.t(), + domain: nil | String.t(), + expires_at: nil | DateTime.t() + } + @derive Jason.Encoder + defstruct ~w(oauth_token oauth_token_secret mfa_token domain expires_at)a + + @spec expired?(t()) :: boolean() + def expired?(%__MODULE__{expires_at: nil}), do: true + + def expired?(%__MODULE__{expires_at: expires_at}), + do: DateTime.before?(expires_at, DateTime.utc_now()) +end + +defimpl Inspect, for: Nimrag.OAuth1Token do + alias Nimrag.OAuth1Token + import Inspect.Algebra + + def inspect( + %OAuth1Token{oauth_token: oauth_token, mfa_token: mfa_token} = token, + opts + ) do + details = + Inspect.List.inspect( + [ + oauth_token: String.slice(oauth_token || "", 0, 5) <> "...", + mfa_token: String.slice(mfa_token || "", 0, 5) <> "...", + expired?: OAuth1Token.expired?(token), + expires_at: token.expires_at + ], + opts + ) + + concat(["#Nimrag.OAuth1Token<", details, ">"]) + end +end diff --git a/lib/nimrag/oauth2_token.ex b/lib/nimrag/oauth2_token.ex new file mode 100644 index 0000000..de32956 --- /dev/null +++ b/lib/nimrag/oauth2_token.ex @@ -0,0 +1,56 @@ +defmodule Nimrag.OAuth2Token do + @moduledoc """ + See `Nimrag.Credentials` for more details on how to obtain auth tokens. + """ + @type t() :: %__MODULE__{ + scope: nil | String.t(), + jti: nil | String.t(), + token_type: nil | String.t(), + refresh_token: nil | String.t(), + access_token: nil | String.t(), + expires_at: nil | DateTime.t(), + refresh_token_expires_at: nil | DateTime.t() + } + @derive Jason.Encoder + defstruct ~w( + scope jti token_type refresh_token access_token expires_at + refresh_token_expires_at + )a + + @spec expired?(t()) :: boolean() + def expired?(%__MODULE__{expires_at: nil}), do: true + + def expired?(%__MODULE__{expires_at: expires_at}), + do: DateTime.before?(expires_at, DateTime.utc_now()) + + @spec refresh_token_expired?(t()) :: boolean() + def refresh_token_expired?(%__MODULE__{refresh_token_expires_at: nil}), do: true + + def refresh_token_expired?(%__MODULE__{refresh_token_expires_at: expires_at}), + do: DateTime.before?(expires_at, DateTime.utc_now()) +end + +defimpl Inspect, for: Nimrag.OAuth2Token do + alias Nimrag.OAuth2Token + import Inspect.Algebra + + def inspect( + %OAuth2Token{access_token: access_token, refresh_token: refresh_token} = token, + opts + ) do + details = + Inspect.List.inspect( + [ + access_token: String.slice(access_token || "", 0, 5) <> "...", + refresh_token: String.slice(refresh_token || "", 0, 5) <> "...", + expires_at: token.expires_at, + expired?: OAuth2Token.expired?(token), + refresh_token_expires_at: token.refresh_token_expires_at, + refresh_token_expired?: OAuth2Token.refresh_token_expired?(token) + ], + opts + ) + + concat(["#Nimrag.OAuth2Token<", details, ">"]) + end +end diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..c768f0e --- /dev/null +++ b/mix.exs @@ -0,0 +1,121 @@ +defmodule Nimrag.MixProject do + use Mix.Project + + @version "0.1.0" + + def project do + [ + app: :nimrag, + version: @version, + elixir: "~> 1.15", + elixirc_paths: elixirc_paths(Mix.env()), + elixirc_options: [ + warnings_as_errors: !!System.get_env("CI") + ], + consolidate_protocols: Mix.env() != :test, + deps: deps(), + package: package(), + name: "Nimrag", + source_url: "https://github.com/arathunku/nimrag", + homepage_url: "https://github.com/arathunku/nimrag", + docs: &docs/0, + description: """ + Use Garmin API from Elixir! Fetch activities, steps, and more from Garmin Connect. + """, + aliases: aliases(), + test_coverage: [tool: ExCoveralls], + preferred_cli_env: [ + check: :test, + coveralls: :test, + "coveralls.detail": :test, + "coveralls.html": :test + ], + dialyzer: [ + ignore_warnings: ".dialyzer_ignore.exs", + plt_file: {:no_warn, "priv/plts/dialyzer.plt"}, + plt_add_apps: [:hammer], + flags: [:error_handling, :unknown], + # Error out when an ignore rule is no longer useful so we can remove it + list_unused_filters: true + ] + ] + end + + def application do + [ + mod: {Nimrag.Application, []}, + extra_applications: [:logger] + ] + end + + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + + defp deps do + [ + {:req, "~> 0.4.14"}, + {:oauther, "~> 1.1"}, + {:jason, "~> 1.4"}, + {:recase, "~> 0.7"}, + {:schematic, "~> 0.3"}, + {:hammer, "~> 6.2", runtime: false}, + {:plug, "~> 1.0", only: [:test]}, + {:excoveralls, "~> 0.18.0", only: [:dev, :test], runtime: false}, + {:ex_doc, "~> 0.31", only: :dev, runtime: false}, + {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}, + {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, + {:styler, "~> 0.11", only: [:dev, :test], runtime: false} + ] + end + + defp aliases do + [ + check: [ + "clean", + "deps.unlock --check-unused", + "compile --warnings-as-errors", + "format --check-formatted", + "deps.unlock --check-unused", + "test --warnings-as-errors", + "dialyzer --format short", + "credo" + ] + ] + end + + defp docs do + [ + source_ref: "v#{@version}", + source_url: "https://github.com/arathunku/nimrag", + extras: extras(), + api_reference: false, + groups_for_extras: [ + {"Livebook examples", Path.wildcard("examples/*")} + ], + formatters: ["html"], + main: "readme", + skip_undefined_reference_warnings_on: ["CHANGELOG.md"] + ] + end + + def extras do + [ + "README.md": [title: "Overview"], + "CHANGELOG.md": [title: "Changelog"], + # "CONTRIBUTING.md": [title: "Contributing"], + "LICENSE.md": [title: "License"] + ] ++ Path.wildcard("examples/*.livemd") + end + + defp package do + [ + maintainers: ["@arathunku"], + licenses: ["MIT"], + links: %{ + Changelog: "https://hexdocs.pm/nimrag/changelog.html", + GitHub: "https://github.com/arathunku/nimrag" + }, + files: ~w(lib CHANGELOG.md LICENSE.md mix.exs README.md .formatter.exs) + ] + end +end diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..103d4e1 --- /dev/null +++ b/mix.lock @@ -0,0 +1,33 @@ +%{ + "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, + "castore": {:hex, :castore, "1.0.6", "ffc42f110ebfdafab0ea159cd43d31365fa0af0ce4a02ecebf1707ae619ee727", [:mix], [], "hexpm", "374c6e7ca752296be3d6780a6d5b922854ffcc74123da90f2f328996b962d33a"}, + "credo": {:hex, :credo, "1.7.5", "643213503b1c766ec0496d828c90c424471ea54da77c8a168c725686377b9545", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "f799e9b5cd1891577d8c773d245668aa74a2fcd15eb277f51a0131690ebfb3fd"}, + "dialyxir": {:hex, :dialyxir, "1.4.3", "edd0124f358f0b9e95bfe53a9fcf806d615d8f838e2202a9f430d59566b6b53b", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "bf2cfb75cd5c5006bec30141b131663299c661a864ec7fbbc72dfa557487a986"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.39", "424642f8335b05bb9eb611aa1564c148a8ee35c9c8a8bba6e129d51a3e3c6769", [:mix], [], "hexpm", "06553a88d1f1846da9ef066b87b57c6f605552cfbe40d20bd8d59cc6bde41944"}, + "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, + "ex_doc": {:hex, :ex_doc, "0.31.2", "8b06d0a5ac69e1a54df35519c951f1f44a7b7ca9a5bb7a260cd8a174d6322ece", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.1", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "317346c14febaba9ca40fd97b5b5919f7751fb85d399cc8e7e8872049f37e0af"}, + "excoveralls": {:hex, :excoveralls, "0.18.0", "b92497e69465dc51bc37a6422226ee690ab437e4c06877e836f1c18daeb35da9", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "1109bb911f3cb583401760be49c02cbbd16aed66ea9509fc5479335d284da60b"}, + "file_system": {:hex, :file_system, "1.0.0", "b689cc7dcee665f774de94b5a832e578bd7963c8e637ef940cd44327db7de2cd", [:mix], [], "hexpm", "6752092d66aec5a10e662aefeed8ddb9531d79db0bc145bb8c40325ca1d8536d"}, + "finch": {:hex, :finch, "0.18.0", "944ac7d34d0bd2ac8998f79f7a811b21d87d911e77a786bc5810adb75632ada4", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.6 or ~> 1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "69f5045b042e531e53edc2574f15e25e735b522c37e2ddb766e15b979e03aa65"}, + "hammer": {:hex, :hammer, "6.2.1", "5ae9c33e3dceaeb42de0db46bf505bd9c35f259c8defb03390cd7556fea67ee2", [:mix], [{:poolboy, "~> 1.5", [hex: :poolboy, repo: "hexpm", optional: false]}], "hexpm", "b9476d0c13883d2dc0cc72e786bac6ac28911fba7cc2e04b70ce6a6d9c4b2bdc"}, + "hpax": {:hex, :hpax, "0.1.2", "09a75600d9d8bbd064cdd741f21fc06fc1f4cf3d0fcc335e5aa19be1a7235c84", [:mix], [], "hexpm", "2c87843d5a23f5f16748ebe77969880e29809580efdaccd615cd3bed628a8c13"}, + "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, + "makeup": {:hex, :makeup, "1.1.1", "fa0bc768698053b2b3869fa8a62616501ff9d11a562f3ce39580d60860c3a55e", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "5dc62fbdd0de44de194898b6710692490be74baa02d9d108bc29f007783b0b48"}, + "makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"}, + "makeup_erlang": {:hex, :makeup_erlang, "0.1.5", "e0ff5a7c708dda34311f7522a8758e23bfcd7d8d8068dc312b5eb41c6fd76eba", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "94d2e986428585a21516d7d7149781480013c56e30c6a233534bedf38867a59a"}, + "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, + "mint": {:hex, :mint, "1.5.2", "4805e059f96028948870d23d7783613b7e6b0e2fb4e98d720383852a760067fd", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "d77d9e9ce4eb35941907f1d3df38d8f750c357865353e21d335bdcdf6d892a02"}, + "nimble_options": {:hex, :nimble_options, "1.1.0", "3b31a57ede9cb1502071fade751ab0c7b8dbe75a9a4c2b5bbb0943a690b63172", [:mix], [], "hexpm", "8bbbb3941af3ca9acc7835f5655ea062111c9c27bcac53e004460dfd19008a99"}, + "nimble_ownership": {:hex, :nimble_ownership, "0.3.1", "99d5244672fafdfac89bfad3d3ab8f0d367603ce1dc4855f86a1c75008bce56f", [:mix], [], "hexpm", "4bf510adedff0449a1d6e200e43e57a814794c8b5b6439071274d248d272a549"}, + "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, + "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "oauther": {:hex, :oauther, "1.3.0", "82b399607f0ca9d01c640438b34d74ebd9e4acd716508f868e864537ecdb1f76", [:mix], [], "hexpm", "78eb888ea875c72ca27b0864a6f550bc6ee84f2eeca37b093d3d833fbcaec04e"}, + "plug": {:hex, :plug, "1.15.3", "712976f504418f6dff0a3e554c40d705a9bcf89a7ccef92fc6a5ef8f16a30a97", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "cc4365a3c010a56af402e0809208873d113e9c38c401cabd88027ef4f5c01fd2"}, + "plug_crypto": {:hex, :plug_crypto, "2.0.0", "77515cc10af06645abbfb5e6ad7a3e9714f805ae118fa1a70205f80d2d70fe73", [:mix], [], "hexpm", "53695bae57cc4e54566d993eb01074e4d894b65a3766f1c43e2c61a1b0f45ea9"}, + "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"}, + "recase": {:hex, :recase, "0.7.0", "3f2f719f0886c7a3b7fe469058ec539cb7bbe0023604ae3bce920e186305e5ae", [:mix], [], "hexpm", "36f5756a9f552f4a94b54a695870e32f4e72d5fad9c25e61bc4a3151c08a4e0c"}, + "req": {:hex, :req, "0.4.14", "103de133a076a31044e5458e0f850d5681eef23dfabf3ea34af63212e3b902e2", [:mix], [{:aws_signature, "~> 0.3.2", [hex: :aws_signature, repo: "hexpm", optional: true]}, {:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:nimble_ownership, "~> 0.2.0 or ~> 0.3.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "2ddd3d33f9ab714ced8d3c15fd03db40c14dbf129003c4a3eb80fac2cc0b1b08"}, + "schematic": {:hex, :schematic, "0.3.1", "be633c1472959dc0ace22dd0e1f1445b099991fec39f6d6e5273d35ebd217ac4", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "52c419b5c405286e2d0369b9ca472b00b850c59a8b0bdf0dd69172ad4418d5ea"}, + "styler": {:hex, :styler, "0.11.9", "2595393b94e660cd6e8b582876337cc50ff047d184ccbed42fdad2bfd5d78af5", [:mix], [], "hexpm", "8b7806ba1fdc94d0a75127c56875f91db89b75117fcc67572661010c13e1f259"}, + "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, +} diff --git a/test/fixtures/api/__activitylist-service__activities__search__activities.json b/test/fixtures/api/__activitylist-service__activities__search__activities.json new file mode 100644 index 0000000..433f9b1 --- /dev/null +++ b/test/fixtures/api/__activitylist-service__activities__search__activities.json @@ -0,0 +1,316 @@ +[ + { + "bmrCalories": 97.0, + "strokes": null, + "leftBalance": null, + "curatedCourseId": null, + "maxBottomTime": null, + "sportTypeId": 1, + "ownerProfileImageUrlMedium": "https://s3.amazonaws.com/garmin-connect-prod/profile_images/cb9ea33662d3-prfr.png", + "comments": null, + "excludeFromPowerCurveReports": null, + "diveNumber": null, + "aerobicTrainingEffectMessage": "IMPROVING_AEROBIC_BASE_8", + "hasVideo": false, + "lactateThresholdSpeed": null, + "maxAvgPower_600": null, + "autoCalcCalories": false, + "floorsDescended": null, + "flow": null, + "avgStrokes": null, + "avgGroundContactBalance": null, + "eventType": { + "sortOrder": 10, + "typeId": 9, + "typeKey": "uncategorized" + }, + "unitOfPoolLength": null, + "maxAvgPower_120": null, + "averageHR": 135.0, + "maxAvgPower_18000": null, + "differenceBodyBattery": -13, + "parent": false, + "courseId": null, + "avgGradeAdjustedSpeed": 3.0269999, + "maxStrokeCadence": null, + "trainingStressScore": null, + "hasSplits": true, + "maxAirSpeed": null, + "maxVerticalSpeed": 1.0, + "hasSeedFirstbeatProfile": null, + "maxAvgPower_60": null, + "ownerDisplayName": "nimrag", + "startLongitude": 9.30, + "averageBikingCadenceInRevPerMinute": null, + "summarizedDiveInfo": { + "current": null, + "summarizedDiveGases": [], + "surfaceCondition": null, + "totalSurfaceTime": null, + "visibility": null, + "visibilityUnit": null, + "waterDensity": null, + "waterType": null, + "weight": null, + "weightUnit": null + }, + "activityLikeFullNames": null, + "calendarEventUuid": null, + "ownerProfileImageUrlSmall": "https://s3.amazonaws.com/garmin-connect-prod/profile_images/a0923d-cb9ea33662d3-prth.png", + "avgStress": null, + "locationName": "Germany", + "duration": 4006.337890625, + "privacy": { + "typeId": 3, + "typeKey": "subscribers" + }, + "endCns": null, + "maxLapAvgRunCadence": null, + "maxBikingCadenceInRevPerMinute": null, + "maxAvgPower_30": null, + "avgStrokeCadence": null, + "maxPower": 458.0, + "maxAvgPower_3600": null, + "endN2": null, + "activityTrainingLoad": 94.26568603515625, + "elevationLoss": 51.0, + "avgWindYawAngle": null, + "anaerobicTrainingEffectMessage": "NO_ANAEROBIC_BENEFIT_0", + "activeSets": null, + "averageSpeed": 3.0369999408721924, + "maxDepth": null, + "manualActivity": false, + "numberOfActivityComments": null, + "matchedCuratedCourseId": null, + "vigorousIntensityMinutes": 63, + "steps": 11472, + "max20MinPower": null, + "movingDuration": 4000.3209838867188, + "avgRespirationRate": null, + "avgVerticalOscillation": 8.759999847412109, + "hasImages": false, + "userPro": false, + "maxAvgPower_10": null, + "avgPower": 337.0, + "minAirSpeed": null, + "vO2MaxValue": 54.0, + "jumpCount": null, + "avgWheelchairCadence": null, + "totalSets": null, + "anaerobicTrainingEffect": 0.0, + "maxAvgPower_1": null, + "maxAvgPower_1800": null, + "ownerProfileImageUrlLarge": "https://s3.amazonaws.com/garmin-connect-prod/profile_images/a00bba80-22f1-4342-923d-cb9ea33662d3-prof.png", + "minActivityLapDuration": 52.87799835205078, + "maxDoubleCadence": 212.0, + "maxAvgPower_7200": null, + "calories": 850.0, + "minTemperature": 27.0, + "maxFtp": null, + "activityLikeAuthors": null, + "startLatitude": 50.26, + "groupRideUUID": null, + "startTimeLocal": "2024-04-05 16:33:33", + "hasPolyline": true, + "averageSwimCadenceInStrokesPerMinute": null, + "lactateThresholdBpm": null, + "maxWheelchairCadence": null, + "beginTimestamp": 1712327613000, + "avgFractionalCadence": null, + "avgGroundContactTime": 257.29998779296875, + "maxAvgPower_1200": null, + "startN2": null, + "purposeful": false, + "maxHR": 149.0, + "maxFractionalCadence": null, + "activityLikeProfileImageUrls": null, + "surfaceInterval": null, + "avgDepth": null, + "maxRunningCadenceInStepsPerMinute": 212.0, + "timeZoneId": 124, + "startTimeGMT": "2024-04-05 14:33:33", + "videoUrl": null, + "conversationUuid": null, + "avgVerticalSpeed": null, + "avgStrokeDistance": null, + "ownerFullName": "nimrag", + "avgCda": null, + "maxSwimCadenceInStrokesPerMinute": null, + "gameName": null, + "favorite": false, + "maxCda": null, + "maxJumpRopeCadence": null, + "gameType": null, + "averageSwolf": null, + "deviceId": 3442024736, + "avgLeftBalance": null, + "userRoles": [ + "SCOPE_GOLF_API_READ", + "SCOPE_ATP_READ", + "SCOPE_DIVE_API_WRITE", + "SCOPE_DI_OAUTH_2_CLIENT_REVOCATION_ADMIN", + "SCOPE_CONNECT_WEB_TEMPLATE_RENDER", + "SCOPE_COMMUNITY_COURSE_ADMIN_READ", + "SCOPE_DIVE_API_READ", + "SCOPE_CONNECT_NON_SOCIAL_SHARED_READ", + "SCOPE_DI_OAUTH_2_CLIENT_READ", + "SCOPE_CONNECT_READ", + "SCOPE_CONNECT_WRITE", + "SCOPE_DI_OAUTH_2_TOKEN_ADMIN", + "ROLE_CONNECTUSER", + "ROLE_FITNESS_USER", + "ROLE_WELLNESS_USER", + "ROLE_OUTDOOR_USER", + "ROLE_CONNECT_2_USER" + ], + "waterConsumed": null, + "distance": 12167.2099609375, + "activityName": "Running", + "endLatitude": 50.264, + "workoutId": null, + "averageRunningCadenceInStepsPerMinute": 170.0, + "maxSpeed": 3.4619998931884766, + "splitSummaries": [ + { + "averageElevationGain": 43.0, + "averageSpeed": 3.0369999408721924, + "avgStress": null, + "avgStressDraw": null, + "avgStressLoss": null, + "avgStressWin": null, + "distance": 12167.1904296875, + "duration": 4006.337890625, + "elevationLoss": 51.0, + "gamesAverageWon": null, + "gamesNotWon": null, + "gamesWon": null, + "maxDistance": 12167, + "maxElevationGain": 43.0, + "maxGradeValue": null, + "maxSpeed": 3.4619998931884766, + "mode": null, + "noOfSplits": 1, + "numClimbSends": 0, + "numFalls": 0, + "splitType": "INTERVAL_ACTIVE", + "totalAscent": 43.0 + }, + { + "averageElevationGain": 0.0, + "averageSpeed": 3.1429998874664307, + "avgStress": null, + "avgStressDraw": null, + "avgStressLoss": null, + "avgStressWin": null, + "distance": 12.569999694824219, + "duration": 4.0, + "elevationLoss": 0.0, + "gamesAverageWon": null, + "gamesNotWon": null, + "gamesWon": null, + "maxDistance": 12, + "maxElevationGain": 0.0, + "maxGradeValue": null, + "maxSpeed": 1.156999945640564, + "mode": null, + "noOfSplits": 1, + "numClimbSends": 0, + "numFalls": 0, + "splitType": "RWD_STAND", + "totalAscent": 0.0 + }, + { + "averageElevationGain": 43.0, + "averageSpeed": 3.0339999198913574, + "avgStress": null, + "avgStressDraw": null, + "avgStressLoss": null, + "avgStressWin": null, + "distance": 12154.6201171875, + "duration": 4006.318115234375, + "elevationLoss": 51.0, + "gamesAverageWon": null, + "gamesNotWon": null, + "gamesWon": null, + "maxDistance": 12154, + "maxElevationGain": 43.0, + "maxGradeValue": null, + "maxSpeed": 3.4619998931884766, + "mode": null, + "noOfSplits": 1, + "numClimbSends": 0, + "numFalls": 0, + "splitType": "RWD_RUN", + "totalAscent": 43.0 + } + ], + "maxRespirationRate": null, + "avgGrit": null, + "manufacturer": "GARMIN", + "minCda": null, + "normPower": 340.0, + "avgVerticalRatio": 8.300000190734863, + "elevationGain": 43.0, + "caloriesConsumed": null, + "avgFlow": null, + "rightBalance": null, + "decoDive": false, + "avgStrideLength": 105.50999755859375, + "elevationCorrected": false, + "endStress": null, + "endLongitude": 9.307776996865869, + "maxElevation": 158.60000610351562, + "minStrokes": null, + "parentId": null, + "description": null, + "maxStress": null, + "differenceStress": null, + "activeLengths": null, + "startCns": null, + "activityType": { + "isHidden": false, + "parentTypeId": 17, + "restricted": false, + "trimmable": true, + "typeId": 1, + "typeKey": "running" + }, + "maxAvgPower_2": null, + "numberOfActivityLikes": null, + "intensityFactor": null, + "requestorRelationship": null, + "trainingEffectLabel": "AEROBIC_BASE", + "moderateIntensityMinutes": 0, + "commentedByUser": null, + "likedByUser": null, + "avgWattsPerCda": null, + "elapsedDuration": 4010.8759765625, + "maxAvgPower_300": null, + "summarizedExerciseSets": null, + "ownerId": 35, + "maxAvgPower_20": null, + "caloriesEstimated": null, + "avgDoubleCadence": null, + "pr": false, + "minElevation": 137.60000610351562, + "maxTemperature": 29.0, + "calendarEventId": null, + "avgAirSpeed": null, + "waterEstimated": 1112.0, + "poolLength": null, + "minRespirationRate": null, + "totalReps": null, + "floorsClimbed": null, + "bottomTime": null, + "activityLikeDisplayNames": null, + "maxAvgPower_5": null, + "startStress": null, + "grit": null, + "activityId": 147, + "lapCount": 13, + "avgJumpRopeCadence": null, + "conversationPk": null, + "atpActivity": false, + "aerobicTrainingEffect": 3.0999999046325684 + } +] diff --git a/test/fixtures/api/__userprofile-service__socialProfile.json b/test/fixtures/api/__userprofile-service__socialProfile.json new file mode 100644 index 0000000..8afbd58 --- /dev/null +++ b/test/fixtures/api/__userprofile-service__socialProfile.json @@ -0,0 +1,89 @@ +{ + "swimmingTrainingSpeed": 0.0, + "id": 1337, + "showWeight": false, + "levelUpdateDate": "2023-03-08T16:52:38.0", + "nameApproved": true, + "motivation": 5, + "showHeight": false, + "showWeightClass": false, + "profileImageUrlSmall": "https://s3.amazonaws.com/garmin-connect-prod/profile_images/-cb9ea33662d3-prth.png", + "facebookUrl": "", + "cyclingClassification": null, + "courseVisibility": "public", + "allowGolfScoringByConnections": true, + "otherMotivation": null, + "personalWebsite": "https://github.com/arathunku/nimrag", + "userPro": false, + "showUpcomingEvents": true, + "showVO2Max": false, + "location": "Germany", + "profileImageUrlLarge": "https://s3.amazonaws.com/garmin-connect-prod/profile_images/a00bb-4342-923d-cb9ea33662d3-prof.png", + "runningTrainingSpeed": 2.9411764, + "showGender": false, + "profileId": 100, + "userProfileFullName": "Michal Example", + "userLevel": 4, + "levelIsViewed": true, + "showRecentGear": false, + "favoriteActivityTypes": ["running", "hiking"], + "cyclingTrainingSpeed": 0.0, + "showPersonalRecords": true, + "showAge": false, + "otherActivity": "", + "otherPrimaryActivity": null, + "badgeVisibility": "groups", + "cyclingMaxAvgPower": 0.0, + "userPoint": 252, + "showLast12Months": true, + "userRoles": [ + "SCOPE_ATP_READ", + "SCOPE_ATP_WRITE", + "SCOPE_COMMUNITY_COURSE_READ", + "SCOPE_COMMUNITY_COURSE_WRITE", + "SCOPE_CONNECT_READ", + "SCOPE_CONNECT_WRITE", + "SCOPE_DT_CLIENT_ANALYTICS_WRITE", + "SCOPE_GARMINPAY_READ", + "SCOPE_GARMINPAY_WRITE", + "SCOPE_GCOFFER_READ", + "SCOPE_GCOFFER_WRITE", + "SCOPE_GHS_SAMD", + "SCOPE_GHS_UPLOAD", + "SCOPE_GOLF_API_READ", + "SCOPE_GOLF_API_WRITE", + "SCOPE_INSIGHTS_READ", + "SCOPE_INSIGHTS_WRITE", + "SCOPE_OMT_SUBSCRIPTION_READ", + "SCOPE_PRODUCT_SEARCH_READ", + "ROLE_CONNECTUSER", + "ROLE_FITNESS_USER", + "ROLE_WELLNESS_USER", + "ROLE_OUTDOOR_USER", + "ROLE_CONNECT_2_USER" + ], + "allowGolfLiveScoring": false, + "activityPowerVisibility": "public", + "showActivityClass": false, + "displayName": "Michal", + "bio": "photos, comments -> https://www.strava.com/athletes/555 ", + "favoriteCyclingActivityTypes": [], + "userPointOffset": 0, + "levelPointThreshold": 300, + "showRecentDevice": true, + "showRecentFavorites": false, + "twitterUrl": "", + "userName": "nimrag", + "activityStartVisibility": "private", + "activityMapVisibility": "public", + "profileVisibility": "public", + "fullName": "Michal some name", + "showAgeRange": false, + "garminGUID": "a5305542-5811-46cc-b", + "makeGolfScorecardsPrivate": true, + "activityHeartRateVisibility": "public", + "primaryActivity": "running", + "profileImageUrlMedium": "https://s3.amazonaws.com/garmin-connect-prod/profile_images/prfr.png", + "showLifetimeTotals": true, + "showBadges": true +} diff --git a/test/fixtures/api/__usersummary-service__stats__steps__daily__2024-04-06__2024-04-06.json b/test/fixtures/api/__usersummary-service__stats__steps__daily__2024-04-06__2024-04-06.json new file mode 100644 index 0000000..d8ed551 --- /dev/null +++ b/test/fixtures/api/__usersummary-service__stats__steps__daily__2024-04-06__2024-04-06.json @@ -0,0 +1,8 @@ +[ + { + "calendarDate": "2024-04-06", + "stepGoal": 7777, + "totalDistance": 6191, + "totalSteps": 7820 + } +] \ No newline at end of file diff --git a/test/nimrag_test.exs b/test/nimrag_test.exs new file mode 100644 index 0000000..720d1e2 --- /dev/null +++ b/test/nimrag_test.exs @@ -0,0 +1,32 @@ +defmodule NimragTest do + use ExUnit.Case + alias Nimrag + import Nimrag.ApiHelper + + doctest Nimrag + + test "#profile" do + Req.Test.stub(Nimrag.Api, fn conn -> + Req.Test.json(conn, read_response_fixture(conn)) + end) + + assert {:ok, _profile, _client} = Nimrag.profile(client()) + end + + test "#steps_daily" do + Req.Test.stub(Nimrag.Api, fn conn -> + Req.Test.json(conn, read_response_fixture(conn)) + end) + + assert {:ok, _steps_daily, _client} = + Nimrag.steps_daily(client(), ~D|2024-04-06|, ~D|2024-04-06|) + end + + test "#activities" do + Req.Test.stub(Nimrag.Api, fn conn -> + Req.Test.json(conn, read_response_fixture(conn)) + end) + + assert {:ok, _activities, _client} = Nimrag.activities(client(), 0, 1) + end +end diff --git a/test/support/api_helper.ex b/test/support/api_helper.ex new file mode 100644 index 0000000..f1dd341 --- /dev/null +++ b/test/support/api_helper.ex @@ -0,0 +1,63 @@ +defmodule Nimrag.ApiHelper do + require Logger + + def store_response_as_test_fixture({:ok, %Req.Response{} = resp, _}) do + request_path = Req.Response.get_private(resp, :request_path) + path = rel_fixture_path(request_path) + + File.write!(path, Jason.encode!(resp.body, pretty: true)) + Logger.debug(fn -> "Stored as test fixture: #{Path.relative_to(path, root())}" end) + end + + def read_response_fixture(conn) do + path = rel_fixture_path(conn.request_path) + + case File.read(path) do + {:ok, data} -> + Jason.decode!(data) + + {:error, reason} -> + raise """ + Failed to read fixture: #{inspect(reason)} + + Fix it: + + $ touch #{Path.relative_to(path, root())} + $ nvim #{Path.relative_to(path, root())} + + Then add Garmin's JSON response. + + https://mitmproxy.org/ is an easy way to capture lots raw responses. + """ + end + end + + defp rel_fixture_path(request_path) do + filename = String.replace(request_path, "/", "__") <> ".json" + Path.join([root(), "test", "fixtures", "api", filename]) + end + + defp root() do + Path.join([__DIR__, "..", ".."]) + end + + @spec client :: Nimrag.Client.t() | no_return + def client do + Nimrag.Client.new( + req_options: [plug: {Req.Test, Nimrag.Api}], + rate_limit: false + ) + |> Nimrag.Client.with_auth({ + %Nimrag.OAuth1Token{}, + %Nimrag.OAuth2Token{ + scope: "WRITE", + jti: "uuid-1234-5678-9012-3456", + token_type: "Bearer", + refresh_token: "test-refresh-token", + access_token: "test-access-token", + expires_at: DateTime.utc_now() |> DateTime.add(1, :hour), + refresh_token_expires_at: DateTime.utc_now() |> DateTime.add(1, :hour) + } + }) + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 0000000..869559e --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start()