From 17b2327b29ae7b656c63e9b7dd594b9494f50c71 Mon Sep 17 00:00:00 2001 From: Best Olunusi Date: Sat, 17 May 2025 11:35:20 -0500 Subject: [PATCH 1/2] feat: implement the OFREP provider Signed-off-by: Best Olunusi --- .github/workflows/ci.yaml | 1 + .release-please-manifest.json | 3 +- .../openfeature-provider-ofrep/.formatter.exs | 5 + .../openfeature-provider-ofrep/CHANGELOG.md | 0 .../openfeature-provider-ofrep/README.md | 25 + .../lib/openfeature/provider/ofrep.ex | 358 +++++++++++++ .../lib/openfeature/provider/ofrep/reason.ex | 25 + providers/openfeature-provider-ofrep/mix.exs | 62 +++ providers/openfeature-provider-ofrep/mix.lock | 25 + .../openfeature/provider/ofrep/ofrep_test.exs | 478 ++++++++++++++++++ .../test/test_helper.exs | 6 + release-please-config.json | 7 + 12 files changed, 994 insertions(+), 1 deletion(-) create mode 100644 providers/openfeature-provider-ofrep/.formatter.exs create mode 100644 providers/openfeature-provider-ofrep/CHANGELOG.md create mode 100644 providers/openfeature-provider-ofrep/README.md create mode 100644 providers/openfeature-provider-ofrep/lib/openfeature/provider/ofrep.ex create mode 100644 providers/openfeature-provider-ofrep/lib/openfeature/provider/ofrep/reason.ex create mode 100644 providers/openfeature-provider-ofrep/mix.exs create mode 100644 providers/openfeature-provider-ofrep/mix.lock create mode 100644 providers/openfeature-provider-ofrep/test/openfeature/provider/ofrep/ofrep_test.exs create mode 100644 providers/openfeature-provider-ofrep/test/test_helper.exs diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 80ce9fc..49d1aef 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -22,6 +22,7 @@ jobs: lint: true package: - "providers/openfeature-provider-flagd" + - "providers/openfeature-provider-ofrep" env: MIX_ENV: test defaults: diff --git a/.release-please-manifest.json b/.release-please-manifest.json index b5102cc..2e7166a 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,4 @@ { - "providers/openfeature-provider-flagd": "0.1.0" + "providers/openfeature-provider-flagd": "0.1.0", + "providers/openfeature-provider-ofrep": "0.1.0" } diff --git a/providers/openfeature-provider-ofrep/.formatter.exs b/providers/openfeature-provider-ofrep/.formatter.exs new file mode 100644 index 0000000..0a70dc0 --- /dev/null +++ b/providers/openfeature-provider-ofrep/.formatter.exs @@ -0,0 +1,5 @@ +# Used by "mix format" +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"], + line_length: 120 +] diff --git a/providers/openfeature-provider-ofrep/CHANGELOG.md b/providers/openfeature-provider-ofrep/CHANGELOG.md new file mode 100644 index 0000000..e69de29 diff --git a/providers/openfeature-provider-ofrep/README.md b/providers/openfeature-provider-ofrep/README.md new file mode 100644 index 0000000..9ad8288 --- /dev/null +++ b/providers/openfeature-provider-ofrep/README.md @@ -0,0 +1,25 @@ +# OFREP Provider for OpenFeature + +An OpenFeature provider for `OpenFeature Remote Evaluation Protocol (OFREP)`, enabling feature flag evaluation in Elixir over HTTP. + +## Installation + +Add `open_feature_provider_ofrep` to your list of dependencies in `mix.exs`: + +```elixir +def deps do + [ + {:open_feature_provider_ofrep, "~> 0.1.0"} + ] +end +``` + +## Usage + +```elixir +provider = OpenFeature.Provider.OFREP.new(base_url: "http://ofrep-service:8016") +{:ok, _} = OpenFeature.set_provider(provider) + +client = OpenFeature.get_client() +OpenFeature.Client.get_boolean_value(client, "my-feature", false) +``` diff --git a/providers/openfeature-provider-ofrep/lib/openfeature/provider/ofrep.ex b/providers/openfeature-provider-ofrep/lib/openfeature/provider/ofrep.ex new file mode 100644 index 0000000..3db7b2e --- /dev/null +++ b/providers/openfeature-provider-ofrep/lib/openfeature/provider/ofrep.ex @@ -0,0 +1,358 @@ +defmodule OpenFeature.Provider.OFREP do + @moduledoc """ + OpenFeature provider for the OpenFeature Remote Evaluation Protocol (OFREP). + """ + + @moduledoc since: "0.1.0" + + require Logger + + @behaviour OpenFeature.Provider + + alias OpenFeature.Provider.OFREP.Reason + alias OpenFeature.ResolutionDetails + + defstruct name: "OFREP", + base_url: nil, + domain: nil, + hooks: [], + state: :not_ready, + req: nil, + req_opts: [], + retry_after: nil + + @typedoc "OFREP provider" + @type t() :: %__MODULE__{ + name: String.t(), + base_url: String.t(), + domain: String.t() | nil, + hooks: [OpenFeature.Hook.t()], + state: :not_ready | :ready, + req: Req.Request.t() | nil, + req_opts: keyword(), + retry_after: DateTime.t() | nil + } + + @config_opts [:base_url, :name, :domain, :hooks, :req_opts] + + @doc """ + Creates a new OFREP provider. + + ## Options + + * `:base_url` - (required) The base URL of the OFREP instance e.g. `"http://ofrep-service:8016"` + * `:name` - (optional) Custom name for the provider (default: `"OFREP"`) + * `:domain` - (optional) Domain name to differentiate between providers + * `:hooks` - (optional) A list of OpenFeature hooks (%OpenFeature.Hook{}) + * `:req_opts` - (optional) Keyword list passed to `Req.new/1` + + ## Examples + + ```elixir + provider = OpenFeature.Provider.OFREP.new(base_url: "http://ofrep-service:8016", domain: "my-service") + ``` + """ + @doc since: "0.1.0" + @spec new(opts :: Keyword.t()) :: t() + def new(opts \\ []) do + base_url = Keyword.fetch!(opts, :base_url) + validate_base_url!(base_url) + + opts + |> Keyword.take(@config_opts) + |> then(&struct(__MODULE__, &1)) + end + + @impl true + @spec initialize(provider :: t(), domain :: any(), context :: any()) :: + {:ok, t()} | {:error, :provider_not_ready} + def initialize(%__MODULE__{req: nil, req_opts: req_opts} = provider, domain, _context) do + req = build_req(provider, req_opts) + {:ok, %{provider | req: req, domain: domain, state: :ready}} + rescue + e -> + Logger.error("Failed to initialize OFREP provider: #{inspect(e)}") + {:error, :provider_not_ready} + end + + @impl true + def initialize(%__MODULE__{} = provider, domain, _context) do + {:ok, %{provider | domain: domain, state: :ready}} + end + + @impl true + @spec shutdown(any()) :: :ok + def shutdown(_), do: :ok + + @impl true + @spec resolve_boolean_value( + provider :: t(), + key :: String.t(), + default :: boolean, + context :: any() + ) :: OpenFeature.Provider.result() + def resolve_boolean_value(provider, key, default, context) do + request(provider, key, default, context, :boolean) + end + + @impl true + @spec resolve_string_value( + provider :: t(), + key :: String.t(), + default :: String.t(), + context :: any() + ) :: OpenFeature.Provider.result() + def resolve_string_value(provider, key, default, context) do + request(provider, key, default, context, :string) + end + + @impl true + @spec resolve_number_value( + provider :: t(), + key :: String.t(), + default :: number, + context :: any() + ) :: OpenFeature.Provider.result() + def resolve_number_value(provider, key, default, context) do + request(provider, key, default, context, :number) + end + + @impl true + @spec resolve_map_value( + provider :: t(), + key :: String.t(), + default :: map, + context :: any() + ) :: OpenFeature.Provider.result() + def resolve_map_value(provider, key, default, context) do + request(provider, key, default, context, :map) + end + + @spec validate_base_url!(String.t()) :: :ok | no_return() + defp validate_base_url!(base_url) do + case URI.parse(base_url) do + %{scheme: scheme, host: host, port: port} + when scheme in ["http", "https"] and host != nil and port != nil -> + :ok + + _ -> + raise ArgumentError, "Invalid base URL: #{base_url}" + end + end + + @spec build_req(t(), keyword()) :: Req.Request.t() + defp build_req(%__MODULE__{base_url: base_url}, req_opts) do + Req.new( + Keyword.merge( + [ + base_url: base_url, + method: :post, + headers: [{"Content-Type", "application/json"}], + connect_options: [timeout: 10_000] + ], + req_opts + ) + ) + end + + @spec request( + t(), + String.t(), + any(), + any(), + atom() + ) :: OpenFeature.Provider.result() + defp request( + %__MODULE__{retry_after: retry_after} = provider, + key, + default, + context, + expected_type + ) + when not is_nil(retry_after) do + now = DateTime.utc_now() + + if DateTime.compare(now, retry_after) == :lt do + retry_after_str = DateTime.to_iso8601(retry_after) + Logger.warning("OFREP evaluation paused due to rate limiting until #{retry_after_str}") + + {:ok, + %ResolutionDetails{ + value: default, + error_code: :general, + error_message: "Rate limited. Retry after: #{retry_after_str}", + reason: :error + }} + else + updated_provider = %{provider | retry_after: nil} + OpenFeature.Store.set_provider(provider.domain, updated_provider) + + request(updated_provider, key, default, context, expected_type) + end + end + + defp request(provider, key, default, context, expected_type) do + case Jason.encode(%{"context" => context}) do + {:ok, json_body} -> + do_request(provider, key, default, json_body, expected_type) + + {:error, error} -> + {:error, :unexpected_error, error} + end + end + + @spec do_request( + t(), + String.t(), + any(), + String.t(), + atom() + ) :: OpenFeature.Provider.result() + defp do_request(provider, key, default, json_body, expected_type) do + method_path = "/ofrep/v1/evaluate/flags/#{key}" + + parse_options = %{ + provider: provider, + default: default, + expected_type: expected_type + } + + provider.req + |> Req.merge(url: method_path, body: json_body) + |> Req.Request.run_request() + |> parse_result(parse_options) + rescue + e -> {:error, :unexpected_error, e} + end + + @spec parse_result( + {Req.Request.t(), Req.Response.t()}, + %{provider: t(), default: any(), expected_type: atom()} + ) :: OpenFeature.Provider.result() + defp parse_result( + {_req, %Req.Response{status: 200, body: body}}, + %{default: default, expected_type: expected_type} + ) do + value = body["value"] + + if type_of(value) == expected_type do + {:ok, + %ResolutionDetails{ + value: value, + variant: body["variant"], + reason: Reason.to_reason(body["reason"]), + flag_metadata: body["flagMetadata"] + }} + else + error_message = + "Type mismatch: expected #{inspect(expected_type)}, got #{inspect(type_of(value))}" + + {:ok, + %ResolutionDetails{ + value: default, + error_code: :type_mismatch, + error_message: error_message, + reason: :error + }} + end + end + + defp parse_result({_req, %Req.Response{status: 404}}, %{default: default}) do + {:ok, + %ResolutionDetails{ + value: default, + error_code: :flag_not_found, + error_message: "Flag not found", + reason: :error + }} + end + + defp parse_result({_req, %Req.Response{status: 400, body: body}}, %{default: default}) do + error_message = body["message"] || "Unknown error" + + {:ok, + %ResolutionDetails{ + value: default, + error_code: :general, + error_message: error_message, + reason: :error + }} + end + + defp parse_result({_req, %Req.Response{status: status, body: body}}, %{default: default}) + when status in [401, 403] do + message = body["message"] || "Unknown" + error_message = "Auth error: #{message}" + + {:ok, + %ResolutionDetails{ + value: default, + error_code: :provider_fatal, + error_message: error_message, + reason: :error + }} + end + + defp parse_result( + {_req, %Req.Response{status: 429, headers: headers}}, + %{provider: provider, default: default} + ) do + retry_after_dt = + headers + |> get_retry_after() + |> parse_retry_after() + + error_message = "Rate limited. Retry after: #{retry_after_dt}" + + updated_provider = %{provider | retry_after: retry_after_dt} + OpenFeature.Store.set_provider(provider.domain, updated_provider) + + Logger.warning("Rate limited by OFREP service. Retry after: #{retry_after_dt}") + + {:ok, + %ResolutionDetails{ + value: default, + error_code: :general, + error_message: error_message, + reason: :error + }} + end + + defp parse_result({_req, %Req.Response{status: status, body: body}}, _) do + message = body["message"] || "Unknown error" + unexpected_error = %RuntimeError{message: "[#{status}] #{message}"} + {:error, :unexpected_error, unexpected_error} + end + + @spec type_of(any()) :: atom() + defp type_of(value) when is_boolean(value), do: :boolean + defp type_of(value) when is_binary(value), do: :string + defp type_of(value) when is_number(value), do: :number + defp type_of(value) when is_map(value), do: :map + defp type_of(_), do: :unknown + + @spec get_retry_after(list()) :: String.t() + defp get_retry_after(headers) do + case List.keyfind(headers, "retry-after", 0) do + {_, value} -> value + nil -> "unknown" + end + end + + @spec parse_retry_after(String.t()) :: DateTime.t() + defp parse_retry_after(retry_after) do + case Integer.parse(retry_after) do + {seconds, ""} -> + DateTime.utc_now() |> DateTime.add(seconds, :second) + + _ -> + case DateTime.from_iso8601(retry_after) do + {:ok, datetime, _} -> + datetime + + _ -> + DateTime.utc_now() |> DateTime.add(60, :second) + end + end + end +end diff --git a/providers/openfeature-provider-ofrep/lib/openfeature/provider/ofrep/reason.ex b/providers/openfeature-provider-ofrep/lib/openfeature/provider/ofrep/reason.ex new file mode 100644 index 0000000..de62965 --- /dev/null +++ b/providers/openfeature-provider-ofrep/lib/openfeature/provider/ofrep/reason.ex @@ -0,0 +1,25 @@ +defmodule OpenFeature.Provider.OFREP.Reason do + @moduledoc """ + Converts string reasons from OFREP into OpenFeature reason atoms. + """ + @moduledoc since: "0.1.0" + + @reasons %{ + "static" => :static, + "default" => :default, + "targeting_match" => :targeting_match, + "split" => :split, + "cached" => :cached, + "disabled" => :disabled, + "unknown" => :unknown, + "stale" => :stale, + "error" => :error + } + + @spec to_reason(String.t() | nil) :: OpenFeature.Types.reason() + def to_reason(nil), do: :unknown + + def to_reason(reason) do + Map.get(@reasons, String.downcase(reason), :unknown) + end +end diff --git a/providers/openfeature-provider-ofrep/mix.exs b/providers/openfeature-provider-ofrep/mix.exs new file mode 100644 index 0000000..e7a1a80 --- /dev/null +++ b/providers/openfeature-provider-ofrep/mix.exs @@ -0,0 +1,62 @@ +defmodule OpenFeature.Provider.OFREP.MixProject do + use Mix.Project + + @git_repo "https://github.com/open-feature/elixir-sdk-contrib/providers/openfeature-provider-ofrep" + + @version "0.1.0" + + def project do + [ + app: :open_feature_provider_ofrep, + version: @version, + elixir: "~> 1.14", + start_permanent: Mix.env() == :prod, + deps: deps(), + dialyzer: [plt_add_apps: [:mix]], + + # Docs + name: "OpenFeature OFREP", + source_url: @git_repo, + homepage_url: "https://openfeature.dev", + docs: docs(), + + # Hex + description: "OpenFeature OFREP provider", + package: package() + ] + end + + def application do + [ + extra_applications: [:logger] + ] + end + + defp deps do + [ + {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, + {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}, + {:ex_doc, "~> 0.37", only: :docs, runtime: false}, + {:open_feature, "~> 0.1"}, + {:mimic, "~> 1.11", only: :test, runtime: false}, + {:req, "~> 0.5"} + ] + end + + defp docs do + [ + main: "readme", + extras: ["README.md", "../../LICENSE", "../../CONTRIBUTING.md", "CHANGELOG.md"] + ] + end + + defp package do + [ + licenses: ["Apache-2.0"], + links: %{ + "GitHub" => @git_repo, + "Changelog" => "https://hexdocs.pm/open_feature_provider_ofrep/changelog.html" + } + ] + end +end diff --git a/providers/openfeature-provider-ofrep/mix.lock b/providers/openfeature-provider-ofrep/mix.lock new file mode 100644 index 0000000..298eec4 --- /dev/null +++ b/providers/openfeature-provider-ofrep/mix.lock @@ -0,0 +1,25 @@ +%{ + "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, + "credo": {:hex, :credo, "1.7.12", "9e3c20463de4b5f3f23721527fcaf16722ec815e70ff6c60b86412c695d426c1", [: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", "8493d45c656c5427d9c729235b99d498bd133421f3e0a683e5c1b561471291e5"}, + "dialyxir": {:hex, :dialyxir, "1.4.5", "ca1571ac18e0f88d4ab245f0b60fa31ff1b12cbae2b11bd25d207f865e8ae78a", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b0fb08bb8107c750db5c0b324fa2df5ceaa0f9307690ee3c1f6ba5b9eb5d35c3"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, + "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, + "ex_doc": {:hex, :ex_doc, "0.38.1", "bae0a0bd5b5925b1caef4987e3470902d072d03347114ffe03a55dbe206dd4c2", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "754636236d191b895e1e4de2ebb504c057fe1995fdfdd92e9d75c4b05633008b"}, + "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, + "finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"}, + "ham": {:hex, :ham, "0.3.0", "7cd031b4a55fba219c11553e7b13ba73bd86eab4034518445eff1e038cb9a44d", [:mix], [], "hexpm", "7d6c6b73d7a6a83233876cc1b06a4d9b5de05562b228effda4532f9a49852bf6"}, + "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, + "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, + "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [: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", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, + "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "mimic": {:hex, :mimic, "1.11.2", "6449c3b0faee9eb95a86c50752cac4062aa002226128a6e656d020523df8b9e5", [:mix], [{:ham, "~> 0.2", [hex: :ham, repo: "hexpm", optional: false]}], "hexpm", "4856ce771c96e3d115b56f931499b2178246bbb314bcc1f80369dfda754c4912"}, + "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, + "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, + "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, + "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "open_feature": {:hex, :open_feature, "0.1.1", "7d9fc0026abc63e2ac0a4e7e324ff747a64f8c1bd560ad2dc317395a588a1cf6", [:mix], [], "hexpm", "b9ef7929d83bdb751f53e6a533e47fe836a4ac9a316a88183a980456b7cdfddd"}, + "req": {:hex, :req, "0.5.10", "a3a063eab8b7510785a467f03d30a8d95f66f5c3d9495be3474b61459c54376c", [:mix], [{: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, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "8a604815743f8a2d3b5de0659fa3137fa4b1cffd636ecb69b30b2b9b2c2559be"}, + "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, +} diff --git a/providers/openfeature-provider-ofrep/test/openfeature/provider/ofrep/ofrep_test.exs b/providers/openfeature-provider-ofrep/test/openfeature/provider/ofrep/ofrep_test.exs new file mode 100644 index 0000000..84afdde --- /dev/null +++ b/providers/openfeature-provider-ofrep/test/openfeature/provider/ofrep/ofrep_test.exs @@ -0,0 +1,478 @@ +defmodule OpenFeature.Provider.OFREPTest do + use ExUnit.Case + use Mimic + + import ExUnit.CaptureLog + + setup :set_mimic_global + setup :verify_on_exit! + + alias OpenFeature.Provider.OFREP + + setup do + provider = OFREP.new(base_url: "http://localhost:8016") + {:ok, provider: provider} + end + + test "new/1 filters out non-config options" do + provider = + OFREP.new( + base_url: "http://example.com:9000", + # This should be filtered out + state: :ready, + # This should be filtered out + req: :something + ) + + assert provider.base_url == "http://example.com:9000" + # Default value preserved + assert provider.state == :not_ready + # Default value preserved + assert provider.req == nil + end + + test "new/1 validates base_url" do + assert_raise ArgumentError, ~r/Invalid base URL/, fn -> + OFREP.new(base_url: "invalid") + end + end + + test "successfully initializes with valid options" do + provider = + OFREP.new( + base_url: "https://example.com:9000", + domain: "test-domain" + ) + + expect(Req, :new, fn opts -> + assert Keyword.get(opts, :base_url) == "https://example.com:9000" + assert Keyword.get(opts, :method) == :post + %Req.Request{} + end) + + {:ok, initialized} = OFREP.initialize(provider, "override-domain", %{}) + + assert initialized.domain == "override-domain" + assert initialized.state == :ready + assert %Req.Request{} = initialized.req + end + + test "uses user-provided req_opts and overrides defaults" do + custom_headers = [{"X-Custom", "Value"}] + + provider = + OFREP.new( + base_url: "http://example.com:9000", + req_opts: [headers: custom_headers] + ) + + expect(Req, :new, fn opts -> + headers = Keyword.fetch!(opts, :headers) + # It should NOT include the default "Content-Type" since it was overridden + refute {"Content-Type", "application/json"} in headers + assert {"X-Custom", "Value"} in headers + %Req.Request{} + end) + + {:ok, _} = OFREP.initialize(provider, "test", %{}) + end + + test "handles initialization failure", %{provider: provider} do + expect(Req, :new, fn _opts -> + raise "Failed to create request" + end) + + log = + capture_log(fn -> + assert {:error, :provider_not_ready} = OFREP.initialize(provider, "test", %{}) + end) + + assert log =~ "Failed to initialize OFREP provider" + end + + test "successfully resolves boolean flag", %{provider: provider} do + expect(Req.Request, :run_request, fn _req -> + { + %Req.Request{}, + %Req.Response{ + status: 200, + body: %{ + "value" => true, + "variant" => "on", + "reason" => "STATIC" + } + } + } + end) + + {:ok, provider} = OFREP.initialize(provider, "test-domain", %{}) + assert {:ok, result} = OFREP.resolve_boolean_value(provider, "bool-flag", false, %{}) + + assert result.value == true + assert result.reason == :static + assert result.variant == "on" + end + + test "handles not_found flag error", %{provider: provider} do + expect(Req.Request, :run_request, fn _req -> + { + %Req.Request{}, + %Req.Response{ + status: 404, + body: %{"code" => "not_found", "message" => "flag not found"} + } + } + end) + + {:ok, provider} = OFREP.initialize(provider, "test", %{}) + + {:ok, resolution_details} = + OFREP.resolve_boolean_value(provider, "missing", false, %{}) + + assert %OpenFeature.ResolutionDetails{ + reason: :error, + value: false, + error_code: :flag_not_found, + error_message: "Flag not found", + variant: nil, + flag_metadata: nil + } = resolution_details + end + + test "handles unexpected error with message", %{provider: provider} do + expect(Req.Request, :run_request, fn _req -> + { + %Req.Request{}, + %Req.Response{ + status: 500, + body: %{"code" => "server_error", "message" => "Boom"} + } + } + end) + + {:ok, provider} = OFREP.initialize(provider, "test", %{}) + + assert {:error, :unexpected_error, %RuntimeError{message: "[500] Boom"}} = + OFREP.resolve_boolean_value(provider, "some-flag", false, %{}) + end + + test "successfully resolves string flag", %{provider: provider} do + expect(Req.Request, :run_request, fn _req -> + { + %Req.Request{}, + %Req.Response{ + status: 200, + body: %{ + "value" => "#FF00FF", + "variant" => "magenta", + "reason" => "STATIC" + } + } + } + end) + + {:ok, provider} = OFREP.initialize(provider, "test", %{}) + assert {:ok, result} = OFREP.resolve_string_value(provider, "color", "red", %{}) + + assert result.value == "#FF00FF" + assert result.variant == "magenta" + assert result.reason == :static + end + + test "successfully resolves number flag (float)", %{provider: provider} do + expect(Req.Request, :run_request, fn _req -> + { + %Req.Request{}, + %Req.Response{ + status: 200, + body: %{ + "value" => 3.14, + "variant" => "pi", + "reason" => "STATIC" + } + } + } + end) + + {:ok, provider} = OFREP.initialize(provider, "test", %{}) + assert {:ok, result} = OFREP.resolve_number_value(provider, "pi", 0.0, %{}) + assert result.value == 3.14 + assert result.variant == "pi" + end + + test "successfully resolves number flag (int)", %{provider: provider} do + expect(Req.Request, :run_request, fn _req -> + { + %Req.Request{}, + %Req.Response{ + status: 200, + body: %{ + "value" => 42, + "variant" => "default", + "reason" => "STATIC" + } + } + } + end) + + {:ok, provider} = OFREP.initialize(provider, "test", %{}) + assert {:ok, result} = OFREP.resolve_number_value(provider, "number-flag", 0, %{}) + assert result.value == 42 + assert result.variant == "default" + end + + test "successfully resolves map flag", %{provider: provider} do + expect(Req.Request, :run_request, fn _req -> + { + %Req.Request{}, + %Req.Response{ + status: 200, + body: %{ + "value" => %{"enabled" => true, "limit" => 10}, + "variant" => "default", + "reason" => "STATIC" + } + } + } + end) + + {:ok, provider} = OFREP.initialize(provider, "test", %{}) + assert {:ok, result} = OFREP.resolve_map_value(provider, "config", %{}, %{}) + assert result.value["enabled"] == true + assert result.value["limit"] == 10 + end + + test "handles response with nil reason and metadata", %{provider: provider} do + expect(Req.Request, :run_request, fn _req -> + { + %Req.Request{}, + %Req.Response{ + status: 200, + body: %{ + "value" => "some_value", + "variant" => "default" + } + } + } + end) + + {:ok, provider} = OFREP.initialize(provider, "test", %{}) + assert {:ok, result} = OFREP.resolve_string_value(provider, "some-flag", "fallback", %{}) + assert result.reason == :unknown + assert result.flag_metadata == nil + end + + test "handles invalid JSON body", %{provider: provider} do + expect(Req.Request, :run_request, fn _req -> + raise Jason.DecodeError, data: "", position: 0, token: "<" + end) + + {:ok, provider} = OFREP.initialize(provider, "test", %{}) + + assert {:error, :unexpected_error, %Jason.DecodeError{}} = + OFREP.resolve_string_value(provider, "some-flag", "default", %{}) + end + + test "handles network failure (e.g. timeout or SSL error)", %{provider: provider} do + expect(Req.Request, :run_request, fn _req -> + raise RuntimeError, message: "connection refused" + end) + + {:ok, provider} = OFREP.initialize(provider, "test", %{}) + + assert {:error, :unexpected_error, %RuntimeError{message: "connection refused"}} = + OFREP.resolve_boolean_value(provider, "network-flag", false, %{}) + end + + test "handles type mismatch", %{provider: provider} do + expect(Req.Request, :run_request, fn _req -> + { + %Req.Request{}, + %Req.Response{ + status: 200, + body: %{ + "value" => "not a boolean", + "variant" => "default", + "reason" => "STATIC" + } + } + } + end) + + {:ok, provider} = OFREP.initialize(provider, "test", %{}) + + {:ok, resolution_details} = + OFREP.resolve_boolean_value(provider, "type-mismatch", false, %{}) + + assert %OpenFeature.ResolutionDetails{ + reason: :error, + value: false, + error_code: :type_mismatch, + error_message: "Type mismatch: expected :boolean, got :string", + variant: nil, + flag_metadata: nil + } = resolution_details + end + + test "sends merged context from client and call", %{provider: provider} do + expected_context = %{"env" => "prod", "user" => "alice"} + + expect(Req.Request, :run_request, fn req -> + {:ok, payload} = Jason.decode(req.body) + + # Ensure merged context was sent + assert payload["context"] == expected_context + + { + req, + %Req.Response{ + status: 200, + body: %{ + "value" => true, + "variant" => "v1", + "reason" => "targeting_match" + } + } + } + end) + + {:ok, provider} = OFREP.initialize(provider, "test", %{}) + OpenFeature.set_provider(provider) + + # Simulate global + client context merging + OpenFeature.set_global_context(%{"env" => "prod"}) + client = OpenFeature.get_client() |> OpenFeature.Client.set_context(%{"user" => "alice"}) + + details = OpenFeature.Client.get_boolean_details(client, "merge-flag", false) + + assert %OpenFeature.EvaluationDetails{ + value: true, + key: "merge-flag", + reason: :targeting_match, + variant: "v1", + error_code: nil, + error_message: nil + } = details + end + + test "formats request URL correctly", %{provider: provider} do + flag_key = "test-flag" + + expect(Req.Request, :run_request, fn req -> + assert req.url.path == "/ofrep/v1/evaluate/flags/#{flag_key}" + + { + req, + %Req.Response{ + status: 200, + body: %{ + "value" => true, + "variant" => "default", + "reason" => "STATIC" + } + } + } + end) + + {:ok, provider} = OFREP.initialize(provider, "test", %{}) + assert {:ok, _} = OFREP.resolve_boolean_value(provider, flag_key, false, %{}) + end + + test "initial 429 response updates provider with retry-after time", %{provider: provider} do + now = DateTime.utc_now() + retry_after = DateTime.add(now, 30, :second) + + {:ok, provider} = OFREP.initialize(provider, "test-domain", %{}) + {:ok, _} = OpenFeature.set_provider("test-domain", provider) + + expect(Req.Request, :run_request, fn _req -> + { + %Req.Request{}, + %Req.Response{ + status: 429, + body: %{"message" => "Rate limit exceeded"}, + headers: [{"retry-after", DateTime.to_iso8601(retry_after)}] + } + } + end) + + log = + capture_log(fn -> + {:ok, result} = OFREP.resolve_boolean_value(provider, "rate-limited-flag", false, %{}) + + assert result.value == false + assert result.error_code == :general + assert result.error_message =~ "Rate limited" + assert result.reason == :error + end) + + assert log =~ "Rate limited by OFREP service" + + updated_provider = OpenFeature.get_provider("test-domain") + + assert updated_provider.retry_after != nil + assert DateTime.compare(updated_provider.retry_after, now) == :gt + end + + test "requests during rate-limit cooldown period return error without HTTP request", %{ + provider: provider + } do + now = DateTime.utc_now() + retry_after = DateTime.add(now, 30, :second) + + {:ok, provider} = OFREP.initialize(provider, "test-domain", %{}) + + rate_limited_provider = %{provider | retry_after: retry_after} + {:ok, _} = OpenFeature.set_provider("test-domain", rate_limited_provider) + + log = + capture_log(fn -> + {:ok, result} = + OFREP.resolve_boolean_value(rate_limited_provider, "another-flag", false, %{}) + + assert result.value == false + assert result.error_code == :general + assert result.error_message =~ "Rate limited" + assert result.reason == :error + end) + + assert log =~ "OFREP evaluation paused due to rate limiting until" + end + + test "provider resumes normal operation after rate-limit cooldown expires", %{ + provider: provider + } do + now = DateTime.utc_now() + {:ok, provider} = OFREP.initialize(provider, "test-domain", %{}) + + expired_retry_after = DateTime.add(now, -10, :second) + expired_provider = %{provider | retry_after: expired_retry_after} + {:ok, _} = OpenFeature.set_provider("test-domain", expired_provider) + + expect(Req.Request, :run_request, fn _req -> + { + %Req.Request{}, + %Req.Response{ + status: 200, + body: %{ + "value" => true, + "variant" => "default", + "reason" => "STATIC" + } + } + } + end) + + {:ok, result} = + OFREP.resolve_boolean_value(expired_provider, "post-rate-limit-flag", false, %{}) + + assert result.value == true + assert result.error_code == nil + assert result.error_message == nil + assert result.reason == :static + assert result.variant == "default" + + final_provider = OpenFeature.get_provider("test-domain") + assert final_provider.retry_after == nil + end +end diff --git a/providers/openfeature-provider-ofrep/test/test_helper.exs b/providers/openfeature-provider-ofrep/test/test_helper.exs new file mode 100644 index 0000000..10d6036 --- /dev/null +++ b/providers/openfeature-provider-ofrep/test/test_helper.exs @@ -0,0 +1,6 @@ +Application.ensure_all_started(:mimic) + +Mimic.copy(Req) +Mimic.copy(Req.Request) + +ExUnit.start() diff --git a/release-please-config.json b/release-please-config.json index ba1d044..ff751bf 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -13,6 +13,13 @@ "bump-patch-for-minor-pre-major": true, "versioning": "default", "extra-files": ["README.md"] + }, + "providers/openfeature-provider-ofrep": { + "package-name": "openfeature-provider-ofrep", + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": true, + "versioning": "default", + "extra-files": ["README.md"] } }, "changelog-sections": [ From df75a4964c803d6f943c81f81efc7b8fc4f21807 Mon Sep 17 00:00:00 2001 From: Best Olunusi Date: Sat, 17 May 2025 15:39:05 -0500 Subject: [PATCH 2/2] chore: fix git repo link in mix file Signed-off-by: Best Olunusi --- providers/openfeature-provider-ofrep/mix.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/providers/openfeature-provider-ofrep/mix.exs b/providers/openfeature-provider-ofrep/mix.exs index e7a1a80..8dc3102 100644 --- a/providers/openfeature-provider-ofrep/mix.exs +++ b/providers/openfeature-provider-ofrep/mix.exs @@ -1,7 +1,7 @@ defmodule OpenFeature.Provider.OFREP.MixProject do use Mix.Project - @git_repo "https://github.com/open-feature/elixir-sdk-contrib/providers/openfeature-provider-ofrep" + @git_repo "https://github.com/open-feature/elixir-sdk-contrib" @version "0.1.0"