-
Notifications
You must be signed in to change notification settings - Fork 5
feat: Screen Config Postgres Client APIs and Admin Logic #3231
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,15 +4,17 @@ defmodule Screens.ScreenConfigs do | |
| """ | ||
|
|
||
| import Ecto.Query | ||
| import Screens.Inject | ||
|
|
||
| alias Screens.Config.Fetch, as: ConfigFetch | ||
| alias Screens.Config.ScreenConfig | ||
| alias Screens.Repo | ||
|
|
||
| @config_fetcher injected(Screens.Config.Fetch) | ||
|
|
||
| @spec import_from_file() :: {:ok, %{upserted: integer(), deleted: integer()}} | {:error, any()} | ||
| def import_from_file do | ||
| # This should be a part of post_config_migration_cleanup | ||
| with {:ok, config, _version} <- ConfigFetch.fetch_config(), | ||
| with {:ok, config, _version} <- @config_fetcher.fetch_config(), | ||
| config = Jason.decode!(config), | ||
| screens when is_map(screens) <- Map.get(config, "screens", %{}) do | ||
| screen_ids = Map.keys(screens) | ||
|
|
@@ -28,8 +30,41 @@ defmodule Screens.ScreenConfigs do | |
| end | ||
| end | ||
|
|
||
| def list do | ||
| Repo.all(ScreenConfig) | ||
| @spec list_all() :: String.t() | :error | ||
| def list_all do | ||
| # Returns all Configs as a JSON to be used by Screens Admin | ||
|
robbie-sundstrom marked this conversation as resolved.
Outdated
|
||
| if config_migration_enabled?() do | ||
| screens = | ||
| ScreenConfig | ||
| |> Repo.all() | ||
| |> Map.new(fn %ScreenConfig{id: id, config: config} -> {id, config} end) | ||
|
|
||
| Jason.encode!(%{screens: screens}) | ||
| else | ||
| with {:ok, config, _version} <- @config_fetcher.fetch_config() do | ||
| config | ||
| end | ||
| end | ||
| end | ||
|
|
||
| @spec list_screen_configs() :: [ScreenConfig.t()] | ||
| def list_screen_configs do | ||
| # Returns all configs as a list of ScreenConfig structs to be used by Screens Admin | ||
| # The API controller handles the JSON encoding and formatting for the response. | ||
| # As part of post_config_migration_cleanup, this and above function can be cleaned up | ||
|
robbie-sundstrom marked this conversation as resolved.
Outdated
|
||
| if config_migration_enabled?() do | ||
| Repo.all(ScreenConfig) | ||
| else | ||
| with {:ok, config_json, _version} <- @config_fetcher.fetch_config(), | ||
| {:ok, decoded_config} <- Jason.decode(config_json), | ||
| screens when is_map(screens) <- Map.get(decoded_config, "screens", %{}) do | ||
| Enum.map(screens, fn {id, config} -> | ||
| %ScreenConfig{id: id, config: config} | ||
| end) | ||
| else | ||
| _ -> [] | ||
| end | ||
| end | ||
| end | ||
|
|
||
| @doc """ | ||
|
|
@@ -46,4 +81,91 @@ defmodule Screens.ScreenConfigs do | |
| conflict_target: :id | ||
| ) | ||
| end | ||
|
|
||
| @spec upsert_list([%{:id => String.t(), :config => map()}]) :: :ok | {:error, any()} | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Are we able to update the That said, it looks like we assume this will succeed here. Is there any logging we should do in the event of a Postgres failure that we might not get from a stacktrace? I'm not familiar with the types of errors that Ecto will return, apologies if that's vague!
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This comment inspired me to do some big improvements in this file. I added a
|
||
| defp upsert_list(updates) do | ||
| Enum.reduce_while(updates, :ok, fn update, _acc -> | ||
| case upsert_screen_config(update) do | ||
| {:ok, _} -> {:cont, :ok} | ||
| {:error, reason} -> {:halt, {:error, reason}} | ||
| end | ||
| end) | ||
| end | ||
|
|
||
| @doc """ | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For my own understanding, we have a mix of
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I do need to be consistent with this 😅 I kind of like the @doc comments because of how they integrate with tools like ElixirLS in VSCode. But I do generally think the
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The Elixir convention as far as I've seen is |
||
| Updates and deletes multiple screen configs. | ||
| Accepts a list of maps with :id and :config keys for updates, and a list of screen IDs for deletions. | ||
| """ | ||
| @spec commit_updates([%{:id => String.t(), :config => map()}], [String.t()]) :: | ||
| :ok | {:error, any()} | ||
| def commit_updates(updates, deletes \\ []) do | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not entirely sure we should combine updates + deletes in a single API call. Maybe I'm being too much of a purist/this reminds me a little bit of SOAP calls though. Is there an advantage of combining these other than one less round trip from the API to the server?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is just recreating existing behavior, which calls a single API point for both updates and deletes at the same time. I agree in principle though, and I could update this so that the frontend make two separate API calls |
||
| if config_migration_enabled?() do | ||
| update_to_postgres(updates, deletes) | ||
| else | ||
| # This branch will be removed as part of post_config_migration_cleanup. | ||
| # When the feature flag is disabled, continue to update the JSON config. | ||
| update_to_legacy_json(updates, deletes) | ||
| end | ||
| end | ||
|
|
||
| @spec update_to_postgres([%{:id => String.t(), :config => map()}], [String.t()]) :: | ||
| :ok | {:error, any()} | ||
| defp update_to_postgres(updates, deletes) do | ||
| with :ok <- upsert_list(updates) do | ||
| perform_deletes(deletes) | ||
| end | ||
| end | ||
|
|
||
| @spec perform_deletes([String.t()]) :: :ok | {:error, any()} | ||
| defp perform_deletes(deletes) do | ||
| Enum.reduce_while(deletes, :ok, fn id, _acc -> | ||
| Repo.delete_all(from s in ScreenConfig, where: s.id == ^id) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Does this fn ever return This may be tied to the first question, but for my own understanding, why do we need to use
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| {:cont, :ok} | ||
| end) | ||
| end | ||
|
|
||
| defp update_to_legacy_json(updates, deletes) do | ||
| # This will be a part of post_config_migration_cleanup | ||
| # Merges updates into the existing config and removes deleted screens, then writes back to the legacy source. | ||
| # We need to fetch the existing config before writing updates to prevent overwriting any existing configs. | ||
| with {:ok, config_json, _version} <- @config_fetcher.fetch_config(), | ||
| {:ok, decoded_config} <- Jason.decode(config_json), | ||
| screens when is_map(screens) <- Map.get(decoded_config, "screens", %{}) do | ||
| updated_screens = | ||
| Enum.reduce(updates, screens, fn update, acc -> | ||
| id = extract_id(update) | ||
| config = extract_config(update) | ||
|
|
||
| existing_config = Map.get(acc, id, %{}) | ||
| merged_config = Map.merge(existing_config, config) | ||
|
robbie-sundstrom marked this conversation as resolved.
Outdated
|
||
| Map.put(acc, id, merged_config) | ||
| end) | ||
|
|
||
| final_screens = Map.drop(updated_screens, deletes) | ||
| updated_config = Map.put(decoded_config, "screens", final_screens) | ||
|
|
||
| case Jason.encode(updated_config) do | ||
| {:ok, encoded_config} -> @config_fetcher.put_config(encoded_config) | ||
| {:error, reason} -> {:error, reason} | ||
| end | ||
| else | ||
| _ -> :error | ||
| end | ||
| end | ||
|
|
||
| @spec config_migration_enabled?() :: boolean() | ||
| def config_migration_enabled? do | ||
| # This will be a part of post_config_migration_cleanup | ||
| Application.get_env(:screens, :config_migration, false) | ||
| end | ||
|
|
||
| # This will be a part of post_config_migration_cleanup | ||
| defp extract_id(%{"id" => id}), do: id | ||
| defp extract_id(%{id: id}), do: id | ||
| defp extract_id(_), do: nil | ||
|
|
||
| # This will be a part of post_config_migration_cleanup | ||
| defp extract_config(%{"config" => config}), do: config | ||
| defp extract_config(%{config: config}), do: config | ||
| defp extract_config(_), do: %{} | ||
| end | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| defmodule ScreensWeb.ScreenConfigsApiController do | ||
| use ScreensWeb, :controller | ||
|
|
||
| alias Screens.ScreenConfigs | ||
|
|
||
| def index(conn, _params) do | ||
| screen_configs = | ||
| ScreenConfigs.list_screen_configs() | ||
| |> Enum.map(fn screen_config -> | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we need this
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It actually has more info than is needed, since it includes the timestamp fields ( |
||
| %{ | ||
| id: screen_config.id, | ||
| config: screen_config.config | ||
| } | ||
| end) | ||
|
|
||
| json(conn, %{screen_configs: screen_configs}) | ||
| end | ||
|
|
||
| def update(conn, %{"screen_configs" => screen_configs} = params) when is_list(screen_configs) do | ||
| deleted_screen_ids = Map.get(params, "deleted_screen_ids", []) | ||
|
|
||
| case ScreenConfigs.commit_updates(screen_configs, deleted_screen_ids) do | ||
| :ok -> | ||
| json(conn, %{success: true}) | ||
|
|
||
| {:error, reason} -> | ||
| conn | ||
| |> put_status(500) | ||
| |> json(%{success: false, error: "Failed to update screen configs: #{inspect(reason)}"}) | ||
| end | ||
| end | ||
|
|
||
| def update(conn, _params) do | ||
| conn | ||
| |> put_status(400) | ||
| |> json(%{success: false, error: "screen_configs parameter is required"}) | ||
| end | ||
| end | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| defmodule ScreensWeb.Plug.EnsureBearerToken do | ||
| @moduledoc """ | ||
| Ensures requests include a valid bearer token in the Authorization header. | ||
|
|
||
| The expected token is loaded from an environment variable. | ||
| """ | ||
|
|
||
| import Plug.Conn | ||
|
|
||
| def init(opts) do | ||
| Keyword.fetch!(opts, :env_var) | ||
| end | ||
|
|
||
| def call(conn, env_var) do | ||
| configured_token = System.get_env(env_var) | ||
| bearer_token = bearer_token_from_header(conn) | ||
|
|
||
| if valid_token?(configured_token, bearer_token) do | ||
| conn | ||
| else | ||
| unauthorized(conn) | ||
| end | ||
| end | ||
|
|
||
| defp bearer_token_from_header(conn) do | ||
| case get_req_header(conn, "authorization") do | ||
| ["Bearer " <> token] -> token | ||
| _ -> nil | ||
| end | ||
| end | ||
|
|
||
| defp valid_token?(configured_token, bearer_token) | ||
| when is_binary(configured_token) and is_binary(bearer_token) do | ||
| byte_size(configured_token) == byte_size(bearer_token) and | ||
| Plug.Crypto.secure_compare(configured_token, bearer_token) | ||
| end | ||
|
|
||
| defp valid_token?(_configured_token, _bearer_token), do: false | ||
|
|
||
| defp unauthorized(conn) do | ||
| conn | ||
| |> put_resp_content_type("application/json") | ||
| |> send_resp(401, "{\"error\":\"unauthorized\"}") | ||
| |> halt() | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -37,6 +37,10 @@ defmodule ScreensWeb.Router do | |
| plug(ScreensWeb.Plug.EnsureScreensGroup) | ||
| end | ||
|
|
||
| pipeline :screens_api_client_auth do | ||
| plug(ScreensWeb.Plug.EnsureBearerToken, env_var: "SCREENS_API_CLIENT_KEY") | ||
| end | ||
|
|
||
| scope "/", ScreensWeb do | ||
| get "/_health", HealthController, :index | ||
| end | ||
|
|
@@ -65,6 +69,7 @@ defmodule ScreensWeb.Router do | |
| pipe_through [:redirect_prod_http, :api, :auth, :ensure_auth, :ensure_screens_group] | ||
|
|
||
| get "/", AdminApiController, :index | ||
| post "/screen_configs", AdminApiController, :update_screen_configs | ||
| post "/screens/validate", AdminApiController, :validate | ||
| post "/screens/validate/:id", AdminApiController, :validate | ||
| post "/screens/confirm", AdminApiController, :confirm | ||
|
|
@@ -132,4 +137,11 @@ defmodule ScreensWeb.Router do | |
|
|
||
| get "/screens_by_alert", ScreensByAlertController, :index | ||
| end | ||
|
|
||
| scope "/api", ScreensWeb do | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Non-blocking nit - From the perspective of someone who is trying to hit these endpoints for the first time, they'd probably have to start digging through the code to figure out why they'd getting 401's back. It might be worth putting in the README that you need to set an authorization header in requests to these endpoints |
||
| pipe_through [:redirect_prod_http, :api, :screens_api_client_auth] | ||
|
|
||
| get "/screen_configs", ScreenConfigsApiController, :index | ||
| post "/screen_configs", ScreenConfigsApiController, :update | ||
| end | ||
| end | ||
Uh oh!
There was an error while loading. Please reload this page.