From 52b9a1007865b884575a4f3cc8f4931458d4123f Mon Sep 17 00:00:00 2001 From: Samuel Gordalina Date: Mon, 7 Nov 2022 11:25:13 -1000 Subject: [PATCH] Initial import --- .check.exs | 19 +++ .credo.exs | 210 ++++++++++++++++++++++++++++++ .formatter.exs | 4 + .github/FUNDING.yml | 1 + .github/workflows/ci.yml | 122 +++++++++++++++++ .gitignore | 28 ++++ CHANGELOG.md | 3 + LICENSE | 13 ++ README.md | 53 ++++++++ bin/release.exs | 85 ++++++++++++ config/config.exs | 5 + config/test.exs | 5 + coveralls.json | 3 + lib/api/limits.ex | 47 +++++++ lib/api/phase.ex | 18 +++ lib/api/pull.ex | 48 +++++++ lib/api/push.ex | 18 +++ lib/api/report.ex | 27 ++++ lib/api/schedule.ex | 19 +++ lib/api/subscribe.ex | 33 +++++ lib/api/whois.ex | 18 +++ lib/client/client.ex | 19 +++ lib/client/response_middleware.ex | 38 ++++++ lib/ex_tier.ex | 18 +++ lib/models/current_phase.ex | 21 +++ lib/models/feature.ex | 24 ++++ lib/models/feature_tier.ex | 19 +++ lib/models/limits.ex | 19 +++ lib/models/model.ex | 19 +++ lib/models/phase.ex | 23 ++++ lib/models/plan.ex | 23 ++++ lib/models/push.ex | 23 ++++ lib/models/push_result.ex | 19 +++ lib/models/usage.ex | 19 +++ lib/models/whois.ex | 17 +++ lib/utils.ex | 20 +++ mix.exs | 79 +++++++++++ mix.lock | 28 ++++ test/api/limits_test.exs | 57 ++++++++ test/api/phase_test.exs | 31 +++++ test/api/pull_test.exs | 57 ++++++++ test/api/push_test.exs | 76 +++++++++++ test/api/report_test.exs | 20 +++ test/api/schedule_test.exs | 17 +++ test/api/subscribe_test.exs | 22 ++++ test/api/whois_test.exs | 27 ++++ test/client/client_test.exs | 39 ++++++ test/test_helper.exs | 1 + 48 files changed, 1554 insertions(+) create mode 100644 .check.exs create mode 100644 .credo.exs create mode 100644 .formatter.exs create mode 100644 .github/FUNDING.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 README.md create mode 100755 bin/release.exs create mode 100644 config/config.exs create mode 100644 config/test.exs create mode 100644 coveralls.json create mode 100644 lib/api/limits.ex create mode 100644 lib/api/phase.ex create mode 100644 lib/api/pull.ex create mode 100644 lib/api/push.ex create mode 100644 lib/api/report.ex create mode 100644 lib/api/schedule.ex create mode 100644 lib/api/subscribe.ex create mode 100644 lib/api/whois.ex create mode 100644 lib/client/client.ex create mode 100644 lib/client/response_middleware.ex create mode 100644 lib/ex_tier.ex create mode 100644 lib/models/current_phase.ex create mode 100644 lib/models/feature.ex create mode 100644 lib/models/feature_tier.ex create mode 100644 lib/models/limits.ex create mode 100644 lib/models/model.ex create mode 100644 lib/models/phase.ex create mode 100644 lib/models/plan.ex create mode 100644 lib/models/push.ex create mode 100644 lib/models/push_result.ex create mode 100644 lib/models/usage.ex create mode 100644 lib/models/whois.ex create mode 100644 lib/utils.ex create mode 100644 mix.exs create mode 100644 mix.lock create mode 100644 test/api/limits_test.exs create mode 100644 test/api/phase_test.exs create mode 100644 test/api/pull_test.exs create mode 100644 test/api/push_test.exs create mode 100644 test/api/report_test.exs create mode 100644 test/api/schedule_test.exs create mode 100644 test/api/subscribe_test.exs create mode 100644 test/api/whois_test.exs create mode 100644 test/client/client_test.exs create mode 100644 test/test_helper.exs diff --git a/.check.exs b/.check.exs new file mode 100644 index 0000000..b3cad22 --- /dev/null +++ b/.check.exs @@ -0,0 +1,19 @@ +[ + ## all available options with default values (see `mix check` docs for description) + # parallel: true, + # skipped: true, + ## list of tools (see `mix check` docs for a list of default curated tools) + tools: [ + ## curated tools may be disabled (e.g. the check for compilation warnings) + # {:compiler, false}, + ## ...or have command & args adjusted (e.g. enable skip comments for sobelow) + {:sobelow, "mix sobelow --exit --skip"}, + ## ...or reordered (e.g. to see output from dialyzer before others) + # {:dialyzer, order: -1}, + ## ...or reconfigured (e.g. disable parallel execution of ex_unit in umbrella) + {:ex_unit, "mix test --trace"} + ## custom new tools may be added (Mix tasks or arbitrary commands) + # {:my_task, "mix my_task", env: %{"MIX_ENV" => "prod"}}, + # {:my_tool, ["my_tool", "arg with spaces"]} + ] +] diff --git a/.credo.exs b/.credo.exs new file mode 100644 index 0000000..979f5bb --- /dev/null +++ b/.credo.exs @@ -0,0 +1,210 @@ +# This file contains the configuration for Credo and you are probably reading +# this after creating it with `mix credo.gen.config`. +# +# If you find anything wrong or unclear in this file, please report an +# issue on GitHub: https://github.com/rrrene/credo/issues +# +%{ + # + # You can have as many configs as you like in the `configs:` field. + configs: [ + %{ + # + # Run any config using `mix credo -C `. If no config name is given + # "default" is used. + # + name: "default", + # + # These are the files included in the analysis: + files: %{ + # + # You can give explicit globs or simply directories. + # In the latter case `**/*.{ex,exs}` will be used. + # + included: [ + "lib/", + "src/", + "test/", + "web/", + "apps/*/lib/", + "apps/*/src/", + "apps/*/test/", + "apps/*/web/" + ], + excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"] + }, + # + # Load and configure plugins here: + # + plugins: [], + # + # If you create your own checks, you must specify the source files for + # them here, so they can be loaded by Credo before running the analysis. + # + requires: [], + # + # If you want to enforce a style guide and need a more traditional linting + # experience, you can change `strict` to `true` below: + # + strict: false, + # + # To modify the timeout for parsing files, change this value: + # + parse_timeout: 5000, + # + # If you want to use uncolored output by default, you can change `color` + # to `false` below: + # + color: true, + # + # You can customize the parameters of any check by adding a second element + # to the tuple. + # + # To disable a check put `false` as second element: + # + # {Credo.Check.Design.DuplicatedCode, false} + # + checks: %{ + enabled: [ + # + ## Consistency Checks + # + {Credo.Check.Consistency.ExceptionNames, []}, + {Credo.Check.Consistency.LineEndings, []}, + {Credo.Check.Consistency.ParameterPatternMatching, []}, + {Credo.Check.Consistency.SpaceAroundOperators, []}, + {Credo.Check.Consistency.SpaceInParentheses, []}, + {Credo.Check.Consistency.TabsOrSpaces, []}, + + # + ## Design Checks + # + # You can customize the priority of any check + # Priority values are: `low, normal, high, higher` + # + {Credo.Check.Design.AliasUsage, + [priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]}, + # You can also customize the exit_status of each check. + # If you don't want TODO comments to cause `mix credo` to fail, just + # set this value to 0 (zero). + # + {Credo.Check.Design.TagTODO, [exit_status: 2]}, + {Credo.Check.Design.TagFIXME, []}, + + # + ## Readability Checks + # + {Credo.Check.Readability.AliasOrder, []}, + {Credo.Check.Readability.FunctionNames, []}, + {Credo.Check.Readability.LargeNumbers, []}, + {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]}, + {Credo.Check.Readability.ModuleAttributeNames, []}, + # {Credo.Check.Readability.ModuleDoc, []}, + {Credo.Check.Readability.ModuleNames, []}, + {Credo.Check.Readability.ParenthesesInCondition, []}, + {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, + {Credo.Check.Readability.PipeIntoAnonymousFunctions, []}, + {Credo.Check.Readability.PredicateFunctionNames, []}, + {Credo.Check.Readability.PreferImplicitTry, []}, + {Credo.Check.Readability.RedundantBlankLines, []}, + {Credo.Check.Readability.Semicolons, []}, + {Credo.Check.Readability.SpaceAfterCommas, []}, + {Credo.Check.Readability.StringSigils, []}, + {Credo.Check.Readability.TrailingBlankLine, []}, + {Credo.Check.Readability.TrailingWhiteSpace, []}, + {Credo.Check.Readability.UnnecessaryAliasExpansion, []}, + {Credo.Check.Readability.VariableNames, []}, + {Credo.Check.Readability.WithSingleClause, []}, + + # + ## Refactoring Opportunities + # + {Credo.Check.Refactor.Apply, []}, + {Credo.Check.Refactor.CondStatements, []}, + {Credo.Check.Refactor.CyclomaticComplexity, []}, + {Credo.Check.Refactor.FunctionArity, []}, + {Credo.Check.Refactor.LongQuoteBlocks, []}, + {Credo.Check.Refactor.MatchInCondition, []}, + {Credo.Check.Refactor.MapJoin, []}, + {Credo.Check.Refactor.NegatedConditionsInUnless, []}, + {Credo.Check.Refactor.NegatedConditionsWithElse, []}, + {Credo.Check.Refactor.Nesting, []}, + {Credo.Check.Refactor.UnlessWithElse, []}, + {Credo.Check.Refactor.WithClauses, []}, + {Credo.Check.Refactor.FilterFilter, []}, + {Credo.Check.Refactor.RejectReject, []}, + {Credo.Check.Refactor.RedundantWithClauseResult, []}, + + # + ## Warnings + # + {Credo.Check.Warning.ApplicationConfigInModuleAttribute, []}, + {Credo.Check.Warning.BoolOperationOnSameValues, []}, + {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, + {Credo.Check.Warning.IExPry, []}, + {Credo.Check.Warning.IoInspect, []}, + {Credo.Check.Warning.OperationOnSameValues, []}, + {Credo.Check.Warning.OperationWithConstantResult, []}, + {Credo.Check.Warning.RaiseInsideRescue, []}, + {Credo.Check.Warning.SpecWithStruct, []}, + {Credo.Check.Warning.WrongTestFileExtension, []}, + {Credo.Check.Warning.UnusedEnumOperation, []}, + {Credo.Check.Warning.UnusedFileOperation, []}, + {Credo.Check.Warning.UnusedKeywordOperation, []}, + {Credo.Check.Warning.UnusedListOperation, []}, + {Credo.Check.Warning.UnusedPathOperation, []}, + {Credo.Check.Warning.UnusedRegexOperation, []}, + {Credo.Check.Warning.UnusedStringOperation, []}, + {Credo.Check.Warning.UnusedTupleOperation, []}, + {Credo.Check.Warning.UnsafeExec, []} + ], + disabled: [ + # + # Checks scheduled for next check update (opt-in for now, just replace `false` with `[]`) + + # + # Controversial and experimental checks (opt-in, just move the check to `:enabled` + # and be sure to use `mix credo --strict` to see low priority checks) + # + {Credo.Check.Consistency.MultiAliasImportRequireUse, []}, + {Credo.Check.Consistency.UnusedVariableNames, []}, + {Credo.Check.Design.DuplicatedCode, []}, + {Credo.Check.Design.SkipTestWithoutComment, []}, + {Credo.Check.Readability.AliasAs, []}, + {Credo.Check.Readability.BlockPipe, []}, + {Credo.Check.Readability.ImplTrue, []}, + {Credo.Check.Readability.MultiAlias, []}, + {Credo.Check.Readability.NestedFunctionCalls, []}, + {Credo.Check.Readability.SeparateAliasRequire, []}, + {Credo.Check.Readability.SingleFunctionToBlockPipe, []}, + {Credo.Check.Readability.SinglePipe, []}, + {Credo.Check.Readability.Specs, []}, + {Credo.Check.Readability.StrictModuleLayout, []}, + {Credo.Check.Readability.WithCustomTaggedTuple, []}, + {Credo.Check.Refactor.ABCSize, []}, + {Credo.Check.Refactor.AppendSingleItem, []}, + {Credo.Check.Refactor.DoubleBooleanNegation, []}, + {Credo.Check.Refactor.FilterReject, []}, + {Credo.Check.Refactor.IoPuts, []}, + {Credo.Check.Refactor.MapMap, []}, + {Credo.Check.Refactor.ModuleDependencies, []}, + {Credo.Check.Refactor.NegatedIsNil, []}, + {Credo.Check.Refactor.PipeChainStart, []}, + {Credo.Check.Refactor.RejectFilter, []}, + {Credo.Check.Refactor.VariableRebinding, []}, + {Credo.Check.Warning.LazyLogging, []}, + {Credo.Check.Warning.LeakyEnvironment, []}, + {Credo.Check.Warning.MapGetUnsafePass, []}, + {Credo.Check.Warning.MixEnv, []}, + {Credo.Check.Warning.UnsafeToAtom, []} + + # {Credo.Check.Refactor.MapInto, []}, + + # + # Custom checks can be created using `mix credo.gen.check`. + # + ] + } + } + ] +} diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..d2cda26 --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,4 @@ +# Used by "mix format" +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..90dd2f1 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: gordalina diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..250ff53 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,122 @@ +name: ci +env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + +on: + push: + branches: ["*"] + tags: ["v*"] + pull_request: + branches: ["*"] + +jobs: + test: + name: test + strategy: + fail-fast: false + matrix: + include: + - otp: 23.3 + elixir: 1.14.1 + - otp: 24.3 + elixir: 1.14.1 + - otp: 25.1 + elixir: 1.14.1 + runs-on: ubuntu-20.04 + env: + MIX_ENV: test + steps: + - uses: actions/checkout@v2 + - uses: erlef/setup-beam@v1 + with: + otp-version: "${{matrix.otp}}" + elixir-version: "${{matrix.elixir}}" + - name: Install Dependencies + run: | + mix local.rebar --force + mix local.hex --force + mix deps.get + - name: Compile + run: mix compile --warnings-as-errors + - name: Check Formatting + run: mix format + - name: Run Tests + run: mix coveralls.json + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 + with: + files: cover/excoveralls.json + + checks: + name: static checks + runs-on: ubuntu-20.04 + strategy: + matrix: + include: + - otp: 25.1 + elixir: 1.14.1 + steps: + - uses: actions/checkout@v2 + - uses: erlef/setup-beam@v1 + with: + otp-version: "${{matrix.otp}}" + elixir-version: "${{matrix.elixir}}" + - name: Cache multiple paths + uses: actions/cache@v2 + with: + path: priv/plts + key: ${{ hashFiles('mix.lock') }}-${{matrix.otp}}-${{matrix.elixir}} + - name: Install dependencies + run: mix deps.get + - name: Run checks + run: mix check + + docs: + name: docs + runs-on: ubuntu-20.04 + strategy: + matrix: + include: + - otp: 25.1 + elixir: 1.14.1 + steps: + - uses: actions/checkout@v2 + - uses: erlef/setup-beam@v1 + with: + otp-version: "${{matrix.otp}}" + elixir-version: "${{matrix.elixir}}" + - name: Install dependencies + run: mix deps.get + - name: Generate docs + run: | + mix docs + test -f doc/index.html && echo "doc/index.html exists." + test -f doc/ex_tier.epub && echo "doc/ex_tier.epub exists." + + release: + if: "startsWith(github.ref, 'refs/tags/v')" + name: release + strategy: + matrix: + include: + - otp: 25.1 + elixir: 1.14.1 + runs-on: ubuntu-20.04 + needs: [test, checks, docs] + env: + HEX_API_KEY: ${{ secrets.HEX_API_KEY }} + steps: + - uses: actions/checkout@v2 + - uses: erlef/setup-beam@v1 + with: + otp-version: "${{matrix.otp}}" + elixir-version: "${{matrix.elixir}}" + - name: Install Dependencies + run: | + mix local.rebar --force + mix local.hex --force + mix deps.get + - name: Release + run: | + mix hex.publish --yes + mix hex.publish docs --yes diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..922eb39 --- /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"). +ex_tier-*.tar + +# Temporary files, for example, from tests. +/tmp/ + +priv/plts diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a684b35 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +## Next + +- Initial version diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..aaa7151 --- /dev/null +++ b/LICENSE @@ -0,0 +1,13 @@ +Copyright 2022 Samuel Gordalina + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..755ed74 --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# ExTier + +[![Build Status](https://img.shields.io/github/workflow/status/gordalina/ex_tier/ci?style=flat-square)](https://github.com/gordalina/ex_tier/actions?query=workflow%3A%22ci%22) +[![Coverage Status](https://img.shields.io/codecov/c/github/gordalina/ex_tier?style=flat-square)](https://app.codecov.io/gh/gordalina/ex_tier) +[![hex.pm version](https://img.shields.io/hexpm/v/ex_tier?style=flat-square)](https://hex.pm/packages/ex_tier) + +ExTier is an elixir client for [Tier.run](https://tier.run), documentation can be found at [https://hexdocs.pm/ex_tier](https://hexdocs.pm/ex_tier). + +## Installation + +The package can be installed by adding `ex_tier` to your list of dependencies in `mix.exs`: + +```elixir +def deps do + [ + {:ex_tier, "~> 0.1.0"} + ] +end +``` + +## Configuration + +### URL + +You need to specify where does the Tier server is reachable: + +```elixir +config :ex_tier, url: "http://localhost:8080" +``` + +### Tesla + +ExTier depends on [Tesla](https://github.com/elixir-tesla/tesla) to perform HTTP requests. You are required to specify which Tesla adapter you want to use. + +```elixir +config :ex_tier, adapter: Tesla.Adapter.Httpc, +``` + +You can also configure an adapter's options via: + +```elixir +config :ex_tier, adapter: {Tesla.Adapter.Finch, name: ExTier} +``` + +## Compatibility + +| ExTier | Tier | Erlang/OTP | Elixir | +| - | - | - | - | +| `>= 0.0.0` | `>= 0.0.0` | `>= 23.0.0` | `>= 1.14.0` | + +## License + +ExTier is released under the Apache License 2.0 - see the [LICENSE](LICENSE) file. diff --git a/bin/release.exs b/bin/release.exs new file mode 100755 index 0000000..10e71d5 --- /dev/null +++ b/bin/release.exs @@ -0,0 +1,85 @@ +#!/usr/bin/env elixir + +defmodule ExTier.Release do + def run([version]) do + parsed = Version.parse!(version) + is_release? = Version.match?(parsed, ">= 0.0.0", allow_pre: false) + + ensure_git_clean() + + if is_release? do + replace_infile("CHANGELOG.md", ~r/## Next/, "## v#{version}") + replace_infile("mix.exs", ~r/@version \".*\"/, "@version \"#{version}\"") + + replace_infile( + "README.md", + ~r/{:ex_tier, \"~> .*\"}/, + "{:ex_tier, \"~> #{parsed.major}.#{parsed.minor}\"}" + ) + + show_git_diff() + end + + ensure_user_wants_release() + + if is_release? do + git(["add", "CHANGELOG.md", "README.md", "mix.exs"]) + git(["commit", "-m", "v#{version}"]) + end + + git(["tag", "v#{version}", "-m", "v#{version}"]) + git(["push"]) + git(["push", "--tags"]) + end + + def run([]) do + tags = + git(["tag", "--list", "--sort=-v:refname"]) + |> then(fn {contents, 0} -> contents end) + |> String.trim() + |> String.split("\n") + |> Enum.slice(0..5) + + """ + Error: Missing version + Usage: bin/release.exs 0.0.0 + + Last 5 tags: + - #{tags |> Enum.join("\n- ")} + """ + |> IO.write() + end + + defp replace_infile(file, pattern, subst) do + file + |> File.read!() + |> String.replace(pattern, subst) + |> case do + contents -> File.write!(file, contents) + end + end + + defp ensure_git_clean() do + if git(["status", "--porcelain"]) != {"", 0} do + raise RuntimeError, message: "Git repository is not clean" + end + end + + defp ensure_user_wants_release() do + if IO.gets("Do you want to release this version [y/N]? ") != "y\n" do + git(["checkout", "HEAD", "CHANGELOG.md", "README.md", "mix.exs"]) + raise RuntimeError, message: "Release aborted" + end + end + + defp show_git_diff() do + git(["diff", "--color"]) + |> Tuple.to_list() + |> hd() + |> IO.puts() + end + + defp git(args), do: System.cmd("git", args) +end + +System.argv() |> ExTier.Release.run() diff --git a/config/config.exs b/config/config.exs new file mode 100644 index 0000000..ee2b945 --- /dev/null +++ b/config/config.exs @@ -0,0 +1,5 @@ +import Config + +if File.exists?("#{__DIR__}/#{config_env()}.exs") do + import_config "#{config_env()}.exs" +end diff --git a/config/test.exs b/config/test.exs new file mode 100644 index 0000000..c4e5ff8 --- /dev/null +++ b/config/test.exs @@ -0,0 +1,5 @@ +import Config + +config :ex_tier, + adapter: Tesla.Mock, + url: "http://localhost:8080" diff --git a/coveralls.json b/coveralls.json new file mode 100644 index 0000000..497a971 --- /dev/null +++ b/coveralls.json @@ -0,0 +1,3 @@ +{ + "skip_files": ["test"] +} diff --git a/lib/api/limits.ex b/lib/api/limits.ex new file mode 100644 index 0000000..64c2a2a --- /dev/null +++ b/lib/api/limits.ex @@ -0,0 +1,47 @@ +defmodule ExTier.Api.Limits do + alias ExTier.{Client, Limits, Usage, Utils} + + @type limits_params :: %{ + :org => String.t() + } + + @type limit_params :: %{ + :org => String.t(), + :feature => String.t() + } + + @doc """ + List the limits & usage of a given organization + + {:ok, %ExTier.Usage{}} = ExTier.limit(%{org: "org:org_id"}) + + """ + @spec limits(limits_params) :: {:ok, Limits.t()} | {:error, String.t()} + def limits(params) do + Client.get("/limits", query: params) |> Utils.cast(Limits) + end + + @doc """ + List the limits & usage of a given organization's feature + + {:ok, %ExTier.Usage{}} = ExTier.limit(%{org: "org:org_id", feature: "feature:feature_name"}) + + """ + @spec limit(limit_params) :: {:ok, Usage.t()} | {:error, String.t()} + def limit(params) do + with {:ok, regex} <- Regex.compile("^#{params.feature}(@plan:.+)?$"), + {:ok, limits} <- params |> Map.drop([:feature]) |> limits() do + limits.usage + |> Enum.find(&String.match?(&1.feature, regex)) + |> case do + nil -> + {:ok, %Usage{feature: params.feature, used: 0, limit: 0}} + + usage -> + {:ok, usage} + end + else + error -> error + end + end +end diff --git a/lib/api/phase.ex b/lib/api/phase.ex new file mode 100644 index 0000000..08d3ce6 --- /dev/null +++ b/lib/api/phase.ex @@ -0,0 +1,18 @@ +defmodule ExTier.Api.Phase do + alias ExTier.{Client, CurrentPhase, Utils} + + @type phase_params :: %{ + org: String.t() + } + + @doc """ + Get the current phase an organization is on + + {:ok, %ExTier.CurrentPhase{}} = ExTier.phase(%{org: "org:org_id"}) + + """ + @spec phase(phase_params) :: {:ok, CurrentPhase.t()} | {:error, String.t()} + def phase(params) do + Client.get("/phase", query: params) |> Utils.cast(CurrentPhase) + end +end diff --git a/lib/api/pull.ex b/lib/api/pull.ex new file mode 100644 index 0000000..1cfe4d7 --- /dev/null +++ b/lib/api/pull.ex @@ -0,0 +1,48 @@ +defmodule ExTier.Api.Pull do + @moduledoc "" + alias ExTier.{Client, Model, Utils} + + @doc """ + Get all pricing plans + + {:ok, %ExTier.Model{}} = ExTier.pull() + + """ + @spec pull() :: {:ok, Model.t()} | {:error, String.t()} + def pull() do + Client.get("/pull") |> Utils.cast(Model) + end + + @doc """ + Get the latest plan versions + + {:ok, %ExTier.Model{}} = ExTier.pull_latest() + + """ + @spec pull_latest() :: {:ok, Model.t()} | {:error, String.t()} + def pull_latest() do + with {:ok, %Model{plans: plans}} <- pull(), + latest <- Enum.reduce(plans, %{}, &latest_plan_version/2), + plans <- Map.new(latest, fn {name, {_version, plan}} -> {name, plan} end) do + {:ok, %Model{plans: plans}} + else + error -> error + end + end + + defp latest_plan_version({plan_name, plan}, acc) do + [name, version] = plan_name |> String.split("@") + version = String.to_integer(version) + + case Map.get(acc, name, nil) do + nil -> + Map.put(acc, name, {version, plan}) + + {latest, _plan} when version > latest -> + Map.put(acc, name, {version, plan}) + + _ -> + acc + end + end +end diff --git a/lib/api/push.ex b/lib/api/push.ex new file mode 100644 index 0000000..c5b0bce --- /dev/null +++ b/lib/api/push.ex @@ -0,0 +1,18 @@ +defmodule ExTier.Api.Push do + alias ExTier.{Client, Model, Push, Utils} + + @type push_params :: %{ + :model => Model.t() + } + + @doc """ + Create or update pricing plans + + {:ok, %ExTier.Api.Push{}} = File.read!("pricing.json") |> ExTier.push() + + """ + @spec push(push_params) :: {:ok, Push.t()} | {:error, String.t()} + def push(params) do + Client.post("/push", params) |> Utils.cast(Push) + end +end diff --git a/lib/api/report.ex b/lib/api/report.ex new file mode 100644 index 0000000..b885d12 --- /dev/null +++ b/lib/api/report.ex @@ -0,0 +1,27 @@ +defmodule ExTier.Api.Report do + alias ExTier.Client + + @type report_params :: %{ + :org => String.t(), + :feature => String.t(), + optional(:n) => float(), + optional(:at) => DateTime.t(), + optional(:clobber) => boolean() + } + + @doc """ + Report usage of a given feature in an organization + + :ok = ExTier.report(%{org: "org:org_id", feature: "feature:feature"}) + + """ + @spec report(report_params) :: :ok | {:error, String.t()} + def report(params) do + params = + params + |> Map.replace_lazy(:at, &DateTime.to_iso8601/1) + |> Map.put_new(:n, 1) + + Client.post("/report", params) + end +end diff --git a/lib/api/schedule.ex b/lib/api/schedule.ex new file mode 100644 index 0000000..5463309 --- /dev/null +++ b/lib/api/schedule.ex @@ -0,0 +1,19 @@ +defmodule ExTier.Api.Schedule do + alias ExTier.{Client, Phase} + + @type schedule_params :: %{ + :org => String.t(), + :phases => [Phase.t()] + } + + @doc """ + Schedule a phase in an organization + + :ok = ExTier.schedule(%{org: "org:org_id", phases: [%{features: ["feature:feature"]}]}) + + """ + @spec schedule(schedule_params) :: :ok | {:error, String.t()} + def schedule(%{phases: _} = params) do + Client.post("/subscribe", params) + end +end diff --git a/lib/api/subscribe.ex b/lib/api/subscribe.ex new file mode 100644 index 0000000..0bc9c63 --- /dev/null +++ b/lib/api/subscribe.ex @@ -0,0 +1,33 @@ +defmodule ExTier.Api.Subscribe do + alias ExTier.{Client, Phase} + + @type subscribe_params :: %{ + :org => String.t(), + :features => Phase.features() | [Phase.features()], + :effective => DateTime.t() + } + + @doc """ + Subscribe an organization to a plan or a set of features + + :ok = ExTier.schedule(%{org: "org:org_id", features: ["plan:my_plan@0"]}) + :ok = ExTier.schedule(%{org: "org:org_id", features: ["feature:feature@plan:my_plan@0"]}) + + """ + @spec subscribe(subscribe_params) :: :ok | {:error, String.t()} + def subscribe(%{features: features} = params) when not is_list(features) do + params + |> Map.replace_lazy(:features, &List.wrap/1) + |> subscribe() + end + + def subscribe(%{features: _} = params) do + phases = Map.take(params, [:features, :effective]) + + params = + %{org: params.org} + |> Map.put(:phases, [phases]) + + Client.post("/subscribe", params) + end +end diff --git a/lib/api/whois.ex b/lib/api/whois.ex new file mode 100644 index 0000000..bc6c466 --- /dev/null +++ b/lib/api/whois.ex @@ -0,0 +1,18 @@ +defmodule ExTier.Api.Whois do + alias ExTier.{Client, Utils, Whois} + + @type whois_params :: %{ + :org => String.t() + } + + @doc """ + Get Stripe's customer id from an organization + + {:ok, %ExTier.Whois{}} = ExTier.schedule(%{org: "org:org_id"}) + + """ + @spec whois(whois_params) :: {:ok, Whois.t()} | {:error, String.t()} + def whois(params) do + Client.get("/whois", query: params) |> Utils.cast(Whois) + end +end diff --git a/lib/client/client.ex b/lib/client/client.ex new file mode 100644 index 0000000..2ce36cd --- /dev/null +++ b/lib/client/client.ex @@ -0,0 +1,19 @@ +defmodule ExTier.Client do + @moduledoc false + + use Tesla + + adapter(fn env -> + {adapter, opts} = + case Application.fetch_env!(:ex_tier, :adapter) do + {adapter, opts} -> {adapter, opts} + adapter -> {adapter, []} + end + + apply(adapter, :call, [env, opts]) + end) + + plug(ExTier.Client.ResponseMiddleware) + plug(Tesla.Middleware.BaseUrl, "#{Application.fetch_env!(:ex_tier, :url)}/v1") + plug(Tesla.Middleware.JSON) +end diff --git a/lib/client/response_middleware.ex b/lib/client/response_middleware.ex new file mode 100644 index 0000000..d92ef19 --- /dev/null +++ b/lib/client/response_middleware.ex @@ -0,0 +1,38 @@ +defmodule ExTier.Client.ResponseMiddleware do + @moduledoc false + + @behaviour Tesla.Middleware + + require Logger + + @impl Tesla.Middleware + def call(env, next, _options) do + env + |> Tesla.run(next) + |> handle_response() + end + + defp handle_response({:error, error}), do: {:error, error} + defp handle_response({:ok, %{status: 200, body: "{}"}}), do: :ok + defp handle_response({:ok, %{status: 200, body: body}}), do: {:ok, body} + + defp handle_response({:ok, %{status: status, body: body} = env}) do + method = env.method |> Atom.to_string() |> String.upcase() + Logger.error("ExTier: #{method} #{env.url} -> #{status}", error_metadata(env)) + {:error, body["code"]} + end + + defp error_metadata(%Tesla.Env{} = env) do + [ + request: [ + method: env.method, + query: env.query + ], + response: [ + headers: env.headers, + status: env.status, + body: env.body + ] + ] + end +end diff --git a/lib/ex_tier.ex b/lib/ex_tier.ex new file mode 100644 index 0000000..88b0641 --- /dev/null +++ b/lib/ex_tier.ex @@ -0,0 +1,18 @@ +defmodule ExTier do + @moduledoc """ + ExTier is an elixir client for tier.run + """ + + alias ExTier.Api + + defdelegate limit(params), to: Api.Limits + defdelegate limits(params), to: Api.Limits + defdelegate phase(params), to: Api.Phase + defdelegate pull(), to: Api.Pull + defdelegate pull_latest(), to: Api.Pull + defdelegate push(params), to: Api.Push + defdelegate report(params), to: Api.Report + defdelegate schedule(params), to: Api.Schedule + defdelegate subscribe(params), to: Api.Subscribe + defdelegate whois(params), to: Api.Whois +end diff --git a/lib/models/current_phase.ex b/lib/models/current_phase.ex new file mode 100644 index 0000000..a36482b --- /dev/null +++ b/lib/models/current_phase.ex @@ -0,0 +1,21 @@ +defmodule ExTier.CurrentPhase do + @moduledoc false + defstruct [:effective, :features, :plans] + + alias ExTier.Utils + + @type t :: %__MODULE__{ + effective: DateTime.t(), + features: [String.t()], + plans: [String.t()] + } + + @spec new(map()) :: t + def new(params) do + %__MODULE__{ + effective: Utils.to_datetime!(params["effective"]), + features: params["features"], + plans: params["plans"] + } + end +end diff --git a/lib/models/feature.ex b/lib/models/feature.ex new file mode 100644 index 0000000..1fe84a0 --- /dev/null +++ b/lib/models/feature.ex @@ -0,0 +1,24 @@ +defmodule ExTier.Feature do + @moduledoc false + defstruct [:title, :base, :tiers, :mode] + + alias ExTier.FeatureTier + + @type mode :: :graduated | :volume + @type t :: %__MODULE__{ + title: String.t(), + base: non_neg_integer(), + tiers: [FeatureTier.t()], + mode: mode() + } + + @spec new(map()) :: t + def new(params) do + %__MODULE__{ + title: params["title"], + base: params["base"], + tiers: Enum.map(params["tiers"], &FeatureTier.new/1), + mode: params["mode"] + } + end +end diff --git a/lib/models/feature_tier.ex b/lib/models/feature_tier.ex new file mode 100644 index 0000000..826869b --- /dev/null +++ b/lib/models/feature_tier.ex @@ -0,0 +1,19 @@ +defmodule ExTier.FeatureTier do + @moduledoc false + defstruct [:upto, :price, :base] + + @type t :: %__MODULE__{ + upto: non_neg_integer(), + price: non_neg_integer(), + base: non_neg_integer() + } + + @spec new(map()) :: t + def new(params) do + %__MODULE__{ + upto: params["upto"], + price: params["price"], + base: params["base"] + } + end +end diff --git a/lib/models/limits.ex b/lib/models/limits.ex new file mode 100644 index 0000000..ecd2c38 --- /dev/null +++ b/lib/models/limits.ex @@ -0,0 +1,19 @@ +defmodule ExTier.Limits do + @moduledoc false + defstruct [:org, :usage] + + alias ExTier.Usage + + @type t :: %__MODULE__{ + org: String.t(), + usage: [Usage.t()] + } + + @spec new(map()) :: t + def new(params) do + %__MODULE__{ + org: params["org"], + usage: Enum.map(params["usage"], &Usage.new/1) + } + end +end diff --git a/lib/models/model.ex b/lib/models/model.ex new file mode 100644 index 0000000..8397cda --- /dev/null +++ b/lib/models/model.ex @@ -0,0 +1,19 @@ +defmodule ExTier.Model do + @moduledoc false + defstruct [:plans] + + alias ExTier.Plan + + @type t :: %__MODULE__{ + plans: %{ + any() => [Plan.t()] + } + } + + @spec new(map()) :: t + def new(params) do + %__MODULE__{ + plans: Map.new(params["plans"], fn {name, plan} -> {name, Plan.new(plan)} end) + } + end +end diff --git a/lib/models/phase.ex b/lib/models/phase.ex new file mode 100644 index 0000000..aa79ec7 --- /dev/null +++ b/lib/models/phase.ex @@ -0,0 +1,23 @@ +defmodule ExTier.Phase do + @moduledoc false + defstruct [:effective, :features] + + alias ExTier.Utils + + @type plan_name :: String.t() + @type versioned_feature_name :: String.t() + @type features :: plan_name() | versioned_feature_name() + + @type t :: %__MODULE__{ + effective: DateTime.t(), + features: [features()] + } + + @spec new(map()) :: t + def new(params) do + %__MODULE__{ + effective: Utils.to_datetime!(params["effective"]), + features: params["features"] + } + end +end diff --git a/lib/models/plan.ex b/lib/models/plan.ex new file mode 100644 index 0000000..2be7267 --- /dev/null +++ b/lib/models/plan.ex @@ -0,0 +1,23 @@ +defmodule ExTier.Plan do + @moduledoc false + defstruct [:title, :features, :currency, :interval] + + alias ExTier.Feature + + @type t :: %__MODULE__{ + title: String.t(), + features: %{any() => [Feature.t()]}, + currency: String.t(), + interval: String.t() + } + + @spec new(map()) :: t + def new(params) do + %__MODULE__{ + title: params["title"], + features: Map.new(params["features"], fn {name, feat} -> {name, Feature.new(feat)} end), + currency: params["currency"], + interval: params["interval"] + } + end +end diff --git a/lib/models/push.ex b/lib/models/push.ex new file mode 100644 index 0000000..175911b --- /dev/null +++ b/lib/models/push.ex @@ -0,0 +1,23 @@ +defmodule ExTier.Push do + @moduledoc false + defstruct [:results] + + alias ExTier.PushResult + + @type push_result :: %{ + feature: String.t(), + status: String.t(), + reason: String.t() + } + + @type t :: %__MODULE__{ + :results => [PushResult.t()] + } + + @spec new(map()) :: t + def new(params) do + %__MODULE__{ + results: Enum.map(params["results"], fn result -> PushResult.new(result) end) + } + end +end diff --git a/lib/models/push_result.ex b/lib/models/push_result.ex new file mode 100644 index 0000000..87b3baa --- /dev/null +++ b/lib/models/push_result.ex @@ -0,0 +1,19 @@ +defmodule ExTier.PushResult do + @moduledoc false + defstruct [:feature, :reason, :status] + + @type t :: %__MODULE__{ + :feature => String.t(), + :reason => String.t(), + :status => String.t() + } + + @spec new(map()) :: t + def new(params) do + %__MODULE__{ + feature: params["feature"], + reason: params["reason"], + status: params["status"] + } + end +end diff --git a/lib/models/usage.ex b/lib/models/usage.ex new file mode 100644 index 0000000..5584833 --- /dev/null +++ b/lib/models/usage.ex @@ -0,0 +1,19 @@ +defmodule ExTier.Usage do + @moduledoc false + defstruct [:feature, :used, :limit] + + @type t :: %__MODULE__{ + feature: String.t(), + used: float(), + limit: float() + } + + @spec new(map()) :: t + def new(params) do + %__MODULE__{ + feature: params["feature"], + used: params["used"], + limit: params["limit"] + } + end +end diff --git a/lib/models/whois.ex b/lib/models/whois.ex new file mode 100644 index 0000000..fc709bd --- /dev/null +++ b/lib/models/whois.ex @@ -0,0 +1,17 @@ +defmodule ExTier.Whois do + @moduledoc false + defstruct [:org, :stripe_id] + + @type t :: %__MODULE__{ + :org => String.t(), + :stripe_id => String.t() + } + + @spec new(map()) :: t + def new(params) do + %__MODULE__{ + org: params["org"], + stripe_id: params["stripe_id"] + } + end +end diff --git a/lib/utils.ex b/lib/utils.ex new file mode 100644 index 0000000..efb5840 --- /dev/null +++ b/lib/utils.ex @@ -0,0 +1,20 @@ +defmodule ExTier.Utils do + @moduledoc false + + @spec to_datetime!(String.t()) :: DateTime.t() + def to_datetime!(date_iso8601) do + date_iso8601 |> NaiveDateTime.from_iso8601!() |> DateTime.from_naive!("Etc/UTC") + end + + @spec cast({:ok, map()} | {:error, String.t()}, atom()) :: {:ok, struct()} + def cast({:ok, json}, module) do + json + |> module.new() + |> then(&{:ok, &1}) + rescue + error -> + {:error, Exception.message(error)} + end + + def cast(other, _), do: other +end diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..6d5f290 --- /dev/null +++ b/mix.exs @@ -0,0 +1,79 @@ +defmodule ExTier.MixProject do + use Mix.Project + + @version "0.0.1" + @source_url "https://github.com/gordalina/ex_tier" + + def project do + [ + app: :ex_tier, + version: @version, + elixir: "~> 1.14", + deps: deps(), + docs: docs(), + description: description(), + package: package(), + start_permanent: Mix.env() == :prod, + source_url: @source_url, + elixirc_paths: elixirc_paths(Mix.env()), + test_coverage: [tool: ExCoveralls], + preferred_cli_env: [ + coveralls: :test, + "coveralls.detail": :test, + "coveralls.github": :test, + "coveralls.html": :test, + "coveralls.json": :test + ], + dialyzer: [ + plt_file: {:no_warn, "priv/plts/dialyzer.plt"} + ] + ] + end + + def application do + [ + extra_applications: [:logger] + ] + end + + defp deps do + [ + {:jason, "~> 1.0"}, + {:tesla, "~> 1.4"}, + {:mox, "~> 1.0", only: :test}, + {:credo, "~> 1.6", only: :dev, runtime: false}, + {:dialyxir, "~> 1.2", only: :dev, runtime: false}, + {:ex_check, "~> 0.13", only: :dev, runtime: false}, + {:ex_doc, "~> 0.28", only: :dev, runtime: false}, + {:excoveralls, "~> 0.14", only: :test, runtime: false}, + {:sobelow, "~> 0.11", only: :dev, runtime: false} + ] + end + + defp elixirc_paths(:test), do: ["test/support"] ++ elixirc_paths(nil) + defp elixirc_paths(_), do: ["lib"] + + defp description() do + "A tier client for Elixir." + end + + defp package() do + [ + name: "ex_tier", + licenses: ["Apache-2.0"], + links: %{"GitHub" => @source_url} + ] + end + + defp docs do + [ + main: "readme", + extras: [ + "README.md", + "CHANGELOG.md" + ], + source_ref: "v#{@version}", + source_url: @source_url + ] + end +end diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..f85ccd4 --- /dev/null +++ b/mix.lock @@ -0,0 +1,28 @@ +%{ + "bunt": {:hex, :bunt, "0.2.1", "e2d4792f7bc0ced7583ab54922808919518d0e57ee162901a16a1b6664ef3b14", [:mix], [], "hexpm", "a330bfb4245239787b15005e66ae6845c9cd524a288f0d141c148b02603777a5"}, + "certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"}, + "credo": {:hex, :credo, "1.6.7", "323f5734350fd23a456f2688b9430e7d517afb313fbd38671b8a4449798a7854", [:mix], [{:bunt, "~> 0.2.1", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "41e110bfb007f7eda7f897c10bf019ceab9a0b269ce79f015d54b0dcf4fc7dd3"}, + "dialyxir": {:hex, :dialyxir, "1.2.0", "58344b3e87c2e7095304c81a9ae65cb68b613e28340690dfe1a5597fd08dec37", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "61072136427a851674cab81762be4dbeae7679f85b1272b6d25c3a839aff8463"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.29", "149d50dcb3a93d9f3d6f3ecf18c918fb5a2d3c001b5d3305c926cddfbd33355b", [:mix], [], "hexpm", "4902af1b3eb139016aed210888748db8070b8125c2342ce3dcae4f38dcc63503"}, + "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, + "ex_check": {:hex, :ex_check, "0.14.0", "d6fbe0bcc51cf38fea276f5bc2af0c9ae0a2bb059f602f8de88709421dae4f0e", [:mix], [], "hexpm", "8a602e98c66e6a4be3a639321f1f545292042f290f91fa942a285888c6868af0"}, + "ex_doc": {:hex, :ex_doc, "0.29.0", "4a1cb903ce746aceef9c1f9ae8a6c12b742a5461e6959b9d3b24d813ffbea146", [:mix], [{:earmark_parser, "~> 1.4.19", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "f096adb8bbca677d35d278223361c7792d496b3fc0d0224c9d4bc2f651af5db1"}, + "excoveralls": {:hex, :excoveralls, "0.15.0", "ac941bf85f9f201a9626cc42b2232b251ad8738da993cf406a4290cacf562ea4", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "9631912006b27eca30a2f3c93562bc7ae15980afb014ceb8147dc5cdd8f376f1"}, + "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, + "hackney": {:hex, :hackney, "1.18.1", "f48bf88f521f2a229fc7bae88cf4f85adc9cd9bcf23b5dc8eb6a1788c662c4f6", [:rebar3], [{:certifi, "~>2.9.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a4ecdaff44297e9b5894ae499e9a070ea1888c84afdd1fd9b7b2bc384950128e"}, + "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, + "jason": {:hex, :jason, "1.4.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"}, + "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"}, + "makeup_elixir": {:hex, :makeup_elixir, "0.16.0", "f8c570a0d33f8039513fbccaf7108c5d750f47d8defd44088371191b76492b0b", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "28b2cbdc13960a46ae9a8858c4bebdec3c9a6d7b4b9e7f4ed1502f8159f338e7"}, + "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, + "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, + "mime": {:hex, :mime, "2.0.3", "3676436d3d1f7b81b5a2d2bd8405f412c677558c81b1c92be58c00562bb59095", [:mix], [], "hexpm", "27a30bf0db44d25eecba73755acf4068cbfe26a4372f9eb3e4ea3a45956bff6b"}, + "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, + "mox": {:hex, :mox, "1.0.2", "dc2057289ac478b35760ba74165b4b3f402f68803dd5aecd3bfd19c183815d64", [:mix], [], "hexpm", "f9864921b3aaf763c8741b5b8e6f908f44566f1e427b2630e89e9a73b981fef2"}, + "nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"}, + "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, + "sobelow": {:hex, :sobelow, "0.11.1", "23438964486f8112b41e743bbfd402da3e5b296fdc9eacab29914b79c48916dd", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "9897363a7eff96f4809304a90aad819e2ad5e5d24db547af502885146746a53c"}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, + "tesla": {:hex, :tesla, "1.4.4", "bb89aa0c9745190930366f6a2ac612cdf2d0e4d7fff449861baa7875afd797b2", [:mix], [{:castore, "~> 0.1", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.3", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, "~> 1.3", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.0", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "d5503a49f9dec1b287567ea8712d085947e247cb11b06bc54adb05bfde466457"}, + "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, +} diff --git a/test/api/limits_test.exs b/test/api/limits_test.exs new file mode 100644 index 0000000..0dd82fc --- /dev/null +++ b/test/api/limits_test.exs @@ -0,0 +1,57 @@ +defmodule ExTier.Api.LimitsTest do + use ExUnit.Case + + alias ExTier.{Limits, Usage} + + setup do + Tesla.Mock.mock(fn + %{method: :get} -> + body = %{ + "org" => "org:o", + "usage" => [ + %{ + "feature" => "feature:f1", + "limit" => 9_223_372_036_854_775_807, + "used" => 0 + }, + %{ + "feature" => "feature:f2", + "limit" => 9_223_372_036_854_775_807, + "used" => 0 + }, + %{ + "feature" => "feature:f3", + "limit" => 9_223_372_036_854_775_807, + "used" => 0 + } + ] + } + + %Tesla.Env{status: 200, body: body} + end) + + :ok + end + + test "limits/1" do + assert {:ok, %Limits{} = limits} = ExTier.limits(%{org: "org:o"}) + assert "org:o" == limits.org + assert 3 == length(limits.usage) + end + + test "limit/1" do + assert {:ok, %Usage{} = usage} = ExTier.limit(%{org: "org:o", feature: "feature:f3"}) + + assert "feature:f3" == usage.feature + assert 9_223_372_036_854_775_807 == usage.limit + assert 0 == usage.used + end + + test "limit/1 with unknown feature" do + assert {:ok, %Usage{} = usage} = ExTier.limit(%{org: "org:o", feature: "feature:unk"}) + + assert "feature:unk" == usage.feature + assert 0 == usage.limit + assert 0 == usage.used + end +end diff --git a/test/api/phase_test.exs b/test/api/phase_test.exs new file mode 100644 index 0000000..0bea5df --- /dev/null +++ b/test/api/phase_test.exs @@ -0,0 +1,31 @@ +defmodule ExTier.Api.PhaseTest do + use ExUnit.Case + + alias ExTier.CurrentPhase + + setup do + Tesla.Mock.mock(fn + %{method: :get} -> + body = %{ + "effective" => "2022-11-05T02:54:40Z", + "features" => [ + "feature:IncomingMessage@plan:basic@0", + "feature:OutgoingMessage@plan:basic@0", + "feature:PhoneNumber@plan:basic@0" + ], + "plans" => ["plan:basic@0"] + } + + %Tesla.Env{status: 200, body: body} + end) + + :ok + end + + test "phase/1" do + assert {:ok, %CurrentPhase{} = phase} = ExTier.phase(%{org: "org:o"}) + assert %DateTime{} = phase.effective + assert 3 == length(phase.features) + assert 1 == length(phase.plans) + end +end diff --git a/test/api/pull_test.exs b/test/api/pull_test.exs new file mode 100644 index 0000000..f59fff6 --- /dev/null +++ b/test/api/pull_test.exs @@ -0,0 +1,57 @@ +defmodule ExTier.Api.PullTest do + use ExUnit.Case + + alias ExTier.{Model, Plan, Feature, FeatureTier} + + setup do + Tesla.Mock.mock(fn + %{method: :get} -> + body = %{ + "plans" => %{ + "plan:basic@0" => %{ + "features" => %{ + "feature:IncomingMessage" => %{"tiers" => [%{"price" => 8}]}, + "feature:OutgoingMessage" => %{"tiers" => [%{"price" => 8}]}, + "feature:PhoneNumber" => %{"tiers" => [%{"price" => 300}]} + }, + "title" => "Basic" + }, + "plan:basic@1" => %{ + "features" => %{ + "feature:IncomingMessage" => %{"tiers" => [%{"price" => 81}]}, + "feature:OutgoingMessage" => %{"tiers" => [%{"price" => 82}]}, + "feature:PhoneNumber" => %{"tiers" => [%{"price" => 3000}]} + }, + "title" => "Basic" + }, + "plan:basic@2" => %{ + "features" => %{ + "feature:IncomingMessage" => %{"tiers" => [%{"price" => 7}]}, + "feature:OutgoingMessage" => %{"tiers" => [%{"price" => 7}]}, + "feature:PhoneNumber" => %{"tiers" => [%{"price" => 30_000}]} + }, + "title" => "Basic" + } + } + } + + %Tesla.Env{status: 200, body: body} + end) + + :ok + end + + test "pull/1" do + assert {:ok, %Model{} = model} = ExTier.pull() + assert 3 == model.plans |> Map.keys() |> length() + assert %Plan{features: features, title: "Basic"} = model.plans["plan:basic@0"] + assert %Feature{tiers: [%FeatureTier{price: 8}]} = features["feature:IncomingMessage"] + end + + test "pull_latest/1" do + assert {:ok, %Model{} = model} = ExTier.pull_latest() + assert 1 == model.plans |> Map.keys() |> length() + assert %Plan{features: features, title: "Basic"} = model.plans["plan:basic"] + assert %Feature{tiers: [%FeatureTier{price: 7}]} = features["feature:IncomingMessage"] + end +end diff --git a/test/api/push_test.exs b/test/api/push_test.exs new file mode 100644 index 0000000..4157c67 --- /dev/null +++ b/test/api/push_test.exs @@ -0,0 +1,76 @@ +defmodule ExTier.Api.PushTest do + use ExUnit.Case + + alias ExTier.{Push, PushResult} + + @json """ + { + "plans": { + "plan:basic@0": { + "features": { + "feature:IncomingMessage": {"tiers": [{"price": 8}]}, + "feature:OutgoingMessage": {"tiers": [{"price": 8}]}, + "feature:PhoneNumber": {"tiers": [{"price": 300}]} + }, + "title": "Basic" + }, + "plan:basic@1": { + "features": { + "feature:IncomingMessage": {"tiers": [{"price": 81}]}, + "feature:OutgoingMessage": {"tiers": [{"price": 82}]}, + "feature:PhoneNumber": {"tiers": [{"price": 3000}]} + }, + "title": "Basic" + }, + "plan:basic@2": { + "features": { + "feature:IncomingMessage": {"tiers": [{"price": 7}]}, + "feature:OutgoingMessage": {"tiers": [{"price": 7}]}, + "feature:PhoneNumber": {"tiers": [{"price": 300}]} + }, + "title": "Basic" + } + } + } + """ + + setup do + Tesla.Mock.mock(fn + %{method: :post} -> + body = %{ + "results" => [ + %{ + "feature" => "feature:PhoneNumber@plan:basic@2", + "reason" => "feature already exists", + "status" => "ok" + }, + %{ + "feature" => "feature:IncomingMessage@plan:basic@2", + "reason" => "feature already exists", + "status" => "ok" + }, + %{ + "feature" => "feature:OutgoingMessage@plan:basic@2", + "reason" => "feature already exists", + "status" => "ok" + } + ] + } + + %Tesla.Env{status: 200, body: body} + end) + + :ok + end + + test "pull/1" do + assert {:ok, %Push{} = push} = ExTier.push(@json) + assert 3 == push.results |> length() + + [%PushResult{} = result | _] = push.results + + assert "feature:PhoneNumber@plan:basic@2" == result.feature + assert "feature already exists" == result.reason + assert "ok" == result.status + end +end diff --git a/test/api/report_test.exs b/test/api/report_test.exs new file mode 100644 index 0000000..df4762f --- /dev/null +++ b/test/api/report_test.exs @@ -0,0 +1,20 @@ +defmodule ExTier.Api.ReportTest do + use ExUnit.Case + + setup do + Tesla.Mock.mock(fn + %{method: :post} -> + %Tesla.Env{status: 200, body: "{}"} + end) + + :ok + end + + test "report/1 with org" do + assert :ok = ExTier.report(%{org: "org:o"}) + end + + test "report/1 with org and feature" do + assert :ok = ExTier.report(%{org: "org:o", feature: "feature:f"}) + end +end diff --git a/test/api/schedule_test.exs b/test/api/schedule_test.exs new file mode 100644 index 0000000..4024eef --- /dev/null +++ b/test/api/schedule_test.exs @@ -0,0 +1,17 @@ +defmodule ExTier.Api.ScheduleTest do + use ExUnit.Case + + setup do + Tesla.Mock.mock(fn + %{method: :post} -> + %Tesla.Env{status: 200, body: "{}"} + end) + + :ok + end + + test "schedule/1" do + params = %{org: "org:o", phases: [%{features: "feature:foo@plan:basic@2"}]} + assert :ok = ExTier.schedule(params) + end +end diff --git a/test/api/subscribe_test.exs b/test/api/subscribe_test.exs new file mode 100644 index 0000000..ed9cffd --- /dev/null +++ b/test/api/subscribe_test.exs @@ -0,0 +1,22 @@ +defmodule ExTier.Api.SubscribeTest do + use ExUnit.Case + + setup do + Tesla.Mock.mock(fn + %{method: :post} -> + %Tesla.Env{status: 200, body: "{}"} + end) + + :ok + end + + test "subscribe/1 with feature" do + assert :ok = ExTier.subscribe(%{org: "org:o", features: "plan:basic@2"}) + end + + test "subscribe/1 with features" do + dt = DateTime.utc_now() + features = ["feature:IncomingMessage", "feature:IncomingMessage"] + assert :ok = ExTier.subscribe(%{org: "org:o", features: features, effective: dt}) + end +end diff --git a/test/api/whois_test.exs b/test/api/whois_test.exs new file mode 100644 index 0000000..e791f0e --- /dev/null +++ b/test/api/whois_test.exs @@ -0,0 +1,27 @@ +defmodule ExTier.Api.WhoisTest do + use ExUnit.Case + + alias ExTier.Whois + + setup do + Tesla.Mock.mock(fn + %{method: :get} -> + %Tesla.Env{ + status: 200, + body: %{ + "org" => "org:o", + "stripe_id" => "cus_dq5fqwbdgE2GD" + } + } + end) + + :ok + end + + test "whois/1" do + assert {:ok, %Whois{org: org, stripe_id: sid}} = ExTier.whois(%{org: "org:o"}) + + assert "org:o" == org + assert "cus_dq5fqwbdgE2GD" = sid + end +end diff --git a/test/client/client_test.exs b/test/client/client_test.exs new file mode 100644 index 0000000..735613a --- /dev/null +++ b/test/client/client_test.exs @@ -0,0 +1,39 @@ +defmodule ExTier.Client.ResponseMiddlewareTest do + use ExUnit.Case + + import ExUnit.CaptureLog + + alias ExTier.Client + + describe "tier error" do + setup do + Tesla.Mock.mock(fn + %{method: :get} = env -> + body = %{"code" => "invalid"} + + %Tesla.Env{env | status: 400, body: body} + end) + + :ok + end + + test "invalid code" do + assert capture_log(fn -> assert {:error, "invalid"} = Client.get("/") end) =~ "ExTier: GET" + end + end + + describe "tesla error" do + setup do + Tesla.Mock.mock(fn + %{method: :get} -> + {:error, "error"} + end) + + :ok + end + + test ":error" do + assert {:error, "error"} = Client.get("/") + end + 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()