diff --git a/config/dev.exs b/config/dev.exs index a4caa099..a723ba63 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -76,6 +76,8 @@ config :joken, config :unplug, :init_mode, :runtime +config :open_api_spex, :cache_adapter, OpenApiSpex.Plug.NoneCache + # Override with local dev.local.exs file if File.exists?("#{__DIR__}/dev.local.exs") do import_config "dev.local.exs" diff --git a/lib/wanda_web/controllers/error_json.ex b/lib/wanda_web/controllers/error_json.ex index b8ee6583..fe332ae5 100644 --- a/lib/wanda_web/controllers/error_json.ex +++ b/lib/wanda_web/controllers/error_json.ex @@ -10,6 +10,17 @@ defmodule WandaWeb.ErrorJSON do } end + def render("404.json", %{reason: reason}) when not is_exception(reason, Ecto.NoResultsError) do + %{ + errors: [ + %{ + title: "Not Found", + detail: reason + } + ] + } + end + def render("404.json", _) do %{ errors: [ @@ -21,6 +32,17 @@ defmodule WandaWeb.ErrorJSON do } end + def render("400.json", %{reason: reason}) do + %{ + errors: [ + %{ + title: "Bad Request", + detail: reason + } + ] + } + end + def render("403.json", %{reason: reason}) do %{ errors: [ diff --git a/lib/wanda_web/controllers/fallback_controller.ex b/lib/wanda_web/controllers/fallback_controller.ex index 98bc88ea..25ee4462 100644 --- a/lib/wanda_web/controllers/fallback_controller.ex +++ b/lib/wanda_web/controllers/fallback_controller.ex @@ -24,6 +24,30 @@ defmodule WandaWeb.FallbackController do |> render(:"403") end + def call(conn, {:error, :check_not_found}) do + conn + |> put_status(:not_found) + |> put_view(json: ErrorJSON) + |> render(:"404", reason: "Referenced check was not found.") + end + + def call(conn, {:error, :check_not_customizable}) do + conn + |> put_status(:forbidden) + |> put_view(json: ErrorJSON) + |> render(:"403", reason: "Referenced check is not customizable.") + end + + def call(conn, {:error, :invalid_custom_values}) do + conn + |> put_status(:bad_request) + |> put_view(json: ErrorJSON) + |> render(:"400", + reason: + "Some of the custom values do not exist in the check, they're not customizable or a type mismatch occurred." + ) + end + def call(conn, _) do conn |> put_status(:internal_server_error) diff --git a/lib/wanda_web/controllers/v1/checks_customizations_controller.ex b/lib/wanda_web/controllers/v1/checks_customizations_controller.ex new file mode 100644 index 00000000..5fae2b64 --- /dev/null +++ b/lib/wanda_web/controllers/v1/checks_customizations_controller.ex @@ -0,0 +1,51 @@ +defmodule WandaWeb.V1.ChecksCustomizationsController do + use WandaWeb, :controller + use OpenApiSpex.ControllerSpecs + + alias OpenApiSpex.Schema + + alias WandaWeb.Schemas.{BadRequest, Forbidden} + alias WandaWeb.Schemas.V1.ChecksCustomizations.{CustomizationRequest, CustomizationResponse} + + alias Wanda.ChecksCustomizations + + plug OpenApiSpex.Plug.CastAndValidate, json_render_error_v2: true + action_fallback WandaWeb.FallbackController + + operation :apply_custom_values, + summary: "Apply custom values for a specific check", + parameters: [ + check_id: [ + in: :path, + description: "Identifier of the specific check that is being customized", + type: %Schema{ + type: :string + }, + example: "ABC123" + ], + group_id: [ + in: :path, + description: "Identifier of the group for which a custom value should be applied", + type: %Schema{ + type: :string, + format: :uuid + }, + example: "00000000-0000-0000-0000-000000000001" + ] + ], + request_body: {"Custom Values", "application/json", CustomizationRequest}, + responses: [ + ok: {"Check Customizations", "application/json", CustomizationResponse}, + forbidden: Forbidden.response(), + bad_request: BadRequest.response(), + unprocessable_entity: OpenApiSpex.JsonErrorResponse.response() + ] + + def apply_custom_values(conn, %{check_id: check_id, group_id: group_id}) do + %{values: custom_values} = OpenApiSpex.body_params(conn) + + with {:ok, customization} <- ChecksCustomizations.customize(check_id, group_id, custom_values) do + render(conn, :check_customization, %{customization: customization}) + end + end +end diff --git a/lib/wanda_web/controllers/v1/checks_customizations_json.ex b/lib/wanda_web/controllers/v1/checks_customizations_json.ex new file mode 100644 index 00000000..a0ba140a --- /dev/null +++ b/lib/wanda_web/controllers/v1/checks_customizations_json.ex @@ -0,0 +1,13 @@ +defmodule WandaWeb.V1.ChecksCustomizationsJSON do + alias Wanda.Catalog.CheckCustomization + + def check_customization(%{ + customization: %CheckCustomization{ + custom_values: values + } + }) do + %{ + values: values + } + end +end diff --git a/lib/wanda_web/router.ex b/lib/wanda_web/router.ex index 101a7f36..01a74291 100644 --- a/lib/wanda_web/router.ex +++ b/lib/wanda_web/router.ex @@ -57,6 +57,10 @@ defmodule WandaWeb.Router do get "/groups/:id/executions/last", ExecutionController, :last post "/executions/start", ExecutionController, :start get "/catalog", CatalogController, :catalog + + post "/:check_id/customize/:group_id", + ChecksCustomizationsController, + :apply_custom_values end if Application.compile_env!(:wanda, :operations_enabled) do diff --git a/lib/wanda_web/schemas/forbidden.ex b/lib/wanda_web/schemas/forbidden.ex new file mode 100644 index 00000000..76de324a --- /dev/null +++ b/lib/wanda_web/schemas/forbidden.ex @@ -0,0 +1,42 @@ +defmodule WandaWeb.Schemas.Forbidden do + @moduledoc """ + 403 - Forbidden + """ + + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + + require OpenApiSpex + + OpenApiSpex.schema( + %{ + title: "Forbidden", + type: :object, + additionalProperties: false, + properties: %{ + errors: %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + detail: %Schema{ + type: :string, + example: "The requested operation could not be performed." + }, + title: %Schema{type: :string, example: "Forbidden"} + } + } + } + } + }, + struct?: false + ) + + def response do + Operation.response( + "Forbidden", + "application/json", + __MODULE__ + ) + end +end diff --git a/lib/wanda_web/schemas/v1/checks_customizations/custom_value.ex b/lib/wanda_web/schemas/v1/checks_customizations/custom_value.ex new file mode 100644 index 00000000..d79e0173 --- /dev/null +++ b/lib/wanda_web/schemas/v1/checks_customizations/custom_value.ex @@ -0,0 +1,31 @@ +defmodule WandaWeb.Schemas.V1.ChecksCustomizations.CustomValue do + @moduledoc """ + Custom value to be applied or already applied to a check + """ + + alias OpenApiSpex.Schema + + require OpenApiSpex + + OpenApiSpex.schema( + %{ + title: "CustomValue", + description: "A single custom value to be applied or already applied to a check", + type: :object, + additionalProperties: false, + properties: %{ + name: %Schema{type: :string, description: "Name of the specific value to be customized"}, + value: %Schema{ + description: "Overriding value", + oneOf: [ + %Schema{type: :string}, + %Schema{type: :number}, + %Schema{type: :boolean} + ] + } + }, + required: [:name, :value] + }, + struct?: false + ) +end diff --git a/lib/wanda_web/schemas/v1/checks_customizations/customization_request.ex b/lib/wanda_web/schemas/v1/checks_customizations/customization_request.ex new file mode 100644 index 00000000..b1623d07 --- /dev/null +++ b/lib/wanda_web/schemas/v1/checks_customizations/customization_request.ex @@ -0,0 +1,30 @@ +defmodule WandaWeb.Schemas.V1.ChecksCustomizations.CustomizationRequest do + @moduledoc """ + Request to customize a check + """ + + alias OpenApiSpex.Schema + alias WandaWeb.Schemas.V1.ChecksCustomizations.CustomValue + + require OpenApiSpex + + OpenApiSpex.schema( + %{ + title: "CustomizationRequest", + description: "Request to customize a check", + type: :object, + additionalProperties: false, + minProperties: 1, + properties: %{ + values: %Schema{ + type: :array, + description: "List of values to customize", + items: CustomValue, + minItems: 1 + } + }, + required: [:values] + }, + struct?: false + ) +end diff --git a/lib/wanda_web/schemas/v1/checks_customizations/customization_response.ex b/lib/wanda_web/schemas/v1/checks_customizations/customization_response.ex new file mode 100644 index 00000000..e89aeae5 --- /dev/null +++ b/lib/wanda_web/schemas/v1/checks_customizations/customization_response.ex @@ -0,0 +1,27 @@ +defmodule WandaWeb.Schemas.V1.ChecksCustomizations.CustomizationResponse do + @moduledoc """ + Response for a customization operation + """ + alias WandaWeb.Schemas.V1.ChecksCustomizations.CustomValue + + alias OpenApiSpex.Schema + + require OpenApiSpex + + OpenApiSpex.schema( + %{ + title: "CustomizationResponse", + description: "Response for a customization", + type: :object, + additionalProperties: false, + properties: %{ + values: %Schema{ + type: :array, + description: "List of the custom values applied", + items: CustomValue + } + } + }, + struct?: false + ) +end diff --git a/test/wanda_web/controllers/error_json_test.exs b/test/wanda_web/controllers/error_json_test.exs index 233cb646..504c100f 100644 --- a/test/wanda_web/controllers/error_json_test.exs +++ b/test/wanda_web/controllers/error_json_test.exs @@ -8,7 +8,7 @@ defmodule WandaWeb.ErrorJSONTest do } end - test "renders 404.json" do + test "renders a default 404.json" do assert ErrorJSON.render("404.json", %{}) == %{ errors: [ %{detail: "The requested resource was not found.", title: "Not Found"} @@ -16,6 +16,22 @@ defmodule WandaWeb.ErrorJSONTest do } end + test "renders 404.json with custom reason" do + assert ErrorJSON.render("404.json", %{reason: "Custom reason."}) == %{ + errors: [ + %{detail: "Custom reason.", title: "Not Found"} + ] + } + end + + test "renders 400.json" do + assert ErrorJSON.render("400.json", %{reason: "Bad request reason."}) == %{ + errors: [ + %{detail: "Bad request reason.", title: "Bad Request"} + ] + } + end + test "renders a default 403.json" do assert ErrorJSON.render("403.json", %{}) == %{ errors: [ diff --git a/test/wanda_web/controllers/fallback_controller_test.exs b/test/wanda_web/controllers/fallback_controller_test.exs index b3124903..abb52401 100644 --- a/test/wanda_web/controllers/fallback_controller_test.exs +++ b/test/wanda_web/controllers/fallback_controller_test.exs @@ -17,15 +17,61 @@ defmodule WandaWeb.FallbackControllerTest do end test "should return a 403 on forbidden requests", %{conn: conn} do - conn = - conn - |> Phoenix.Controller.accepts(["json"]) - |> FallbackController.call({:error, :forbidden}) + errors_raising_forbidden = [ + {:forbidden, "Unauthorized to perform operation."}, + {:check_not_customizable, "Referenced check is not customizable."} + ] - assert %{ - "errors" => [ - %{"detail" => "Unauthorized to perform operation.", "title" => "Forbidden"} - ] - } == json_response(conn, 403) + for {error, message} <- errors_raising_forbidden do + conn = + conn + |> Phoenix.Controller.accepts(["json"]) + |> FallbackController.call({:error, error}) + + assert %{ + "errors" => [ + %{"detail" => message, "title" => "Forbidden"} + ] + } == json_response(conn, 403) + end + end + + test "should return a 404 on relevant errors", %{conn: conn} do + errors_raising_not_found = [ + {:check_not_found, "Referenced check was not found."} + ] + + for {error, message} <- errors_raising_not_found do + conn = + conn + |> Phoenix.Controller.accepts(["json"]) + |> FallbackController.call({:error, error}) + + assert %{ + "errors" => [ + %{"detail" => message, "title" => "Not Found"} + ] + } == json_response(conn, 404) + end + end + + test "should return a 400 on relevant errors", %{conn: conn} do + errors_raising_bad_request = [ + {:invalid_custom_values, + "Some of the custom values do not exist in the check, they're not customizable or a type mismatch occurred."} + ] + + for {error, message} <- errors_raising_bad_request do + conn = + conn + |> Phoenix.Controller.accepts(["json"]) + |> FallbackController.call({:error, error}) + + assert %{ + "errors" => [ + %{"detail" => message, "title" => "Bad Request"} + ] + } == json_response(conn, 400) + end end end diff --git a/test/wanda_web/controllers/v1/checks_customization_controller_test.exs b/test/wanda_web/controllers/v1/checks_customization_controller_test.exs new file mode 100644 index 00000000..1efc347a --- /dev/null +++ b/test/wanda_web/controllers/v1/checks_customization_controller_test.exs @@ -0,0 +1,298 @@ +defmodule WandaWeb.V1.ChecksCustomizationsControllerTest do + use WandaWeb.ConnCase, async: true + + import OpenApiSpex.TestAssertions + + alias WandaWeb.Schemas.V1.ApiSpec + + setup do + %{api_spec: ApiSpec.spec()} + end + + describe "checks customization" do + test "should not accept invalid body", %{conn: conn, api_spec: api_spec} do + check_id = "ABC123" + group_id = Faker.UUID.v4() + + missing_values_filed_errors = [ + %{ + title: "Invalid value", + source: %{ + pointer: "/values" + }, + detail: "Missing field: values" + } + ] + + invalid_body_scenarios = [ + %{ + body: nil, + expected_errors: missing_values_filed_errors + }, + %{ + body: %{}, + expected_errors: missing_values_filed_errors + }, + %{ + body: "", + expected_errors: missing_values_filed_errors + }, + %{ + body: "{}", + expected_errors: missing_values_filed_errors + }, + %{ + body: %{ + values: [] + }, + expected_errors: [ + %{ + title: "Invalid value", + source: %{ + pointer: "/values" + }, + detail: "Array length 0 is smaller than minItems: 1" + } + ] + }, + %{ + body: %{ + values: nil + }, + expected_errors: [ + %{ + title: "Invalid value", + source: %{ + pointer: "/values" + }, + detail: "null value where array expected" + } + ] + }, + %{ + body: "\"foo\"", + expected_errors: [ + %{ + title: "Invalid value", + source: %{ + pointer: "/" + }, + detail: "Invalid object. Got: string" + } + ] + }, + %{ + body: %{values: [%{value: 50}]}, + expected_errors: [ + %{ + title: "Invalid value", + source: %{pointer: "/values/0/name"}, + detail: "Missing field: name" + } + ] + }, + %{ + body: %{values: [%{name: "foo_bar"}]}, + expected_errors: [ + %{ + title: "Invalid value", + source: %{pointer: "/values/0/value"}, + detail: "Missing field: value" + } + ] + } + ] + + for %{body: invalid_body, expected_errors: expected_errors} <- invalid_body_scenarios do + response = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/checks/#{check_id}/customize/#{group_id}", invalid_body) + |> json_response(:unprocessable_entity) + |> assert_schema("JsonErrorResponse", api_spec) + + assert %{errors: ^expected_errors} = response + end + end + + test "should not allow customizing a non existent check", %{conn: conn, api_spec: api_spec} do + check_id = "ABC123" + group_id = Faker.UUID.v4() + + request_body = %{values: [%{name: "foo", value: "bar"}]} + + response = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/checks/#{check_id}/customize/#{group_id}", request_body) + |> json_response(:not_found) + |> assert_schema("NotFound", api_spec) + + assert %{ + errors: [ + %{ + title: "Not Found", + detail: "Referenced check was not found." + } + ] + } == response + end + + test "should not allow customizing a non customizable check", %{ + conn: conn, + api_spec: api_spec + } do + check_id = "non_customizable_check" + group_id = Faker.UUID.v4() + + request_body = %{values: [%{name: "foo", value: "bar"}]} + + response = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/checks/#{check_id}/customize/#{group_id}", request_body) + |> json_response(:forbidden) + |> assert_schema("Forbidden", api_spec) + + assert %{ + errors: [ + %{ + title: "Forbidden", + detail: "Referenced check is not customizable." + } + ] + } == response + end + + test "should not allow customizing values because a mismatching name, value non customizability or type mismatch", + %{ + conn: conn, + api_spec: api_spec + } do + check_id = "mixed_values_customizability" + group_id = Faker.UUID.v4() + + scenarios = [ + %{ + name: "value name mismatch", + values: [ + %{ + name: "numeric_value", + value: 42 + }, + %{ + name: "mismatching_value_name", + value: "foo_bar" + } + ] + }, + %{ + name: "non customizable value - explicitly set", + values: [ + %{ + name: "numeric_value", + value: 42 + }, + %{ + name: "non_customizable_string_value", + value: "foo_bar" + } + ] + }, + %{ + name: "non customizable value - inferred by non scalar type", + values: [ + %{ + name: "numeric_value", + value: 42 + }, + %{ + name: "list_value", + value: "foo_bar" + } + ] + }, + %{ + name: "non matching value type - numeric", + values: [ + %{ + name: "numeric_value", + value: "foo_bar" + } + ] + }, + %{ + name: "non matching value type - string", + values: [ + %{ + name: "customizable_string_value", + value: 42 + } + ] + }, + %{ + name: "non matching value type - boolean", + values: [ + %{ + name: "bool_value", + value: "foo_bar" + } + ] + } + ] + + for %{values: values} <- scenarios do + response = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/checks/#{check_id}/customize/#{group_id}", %{ + values: values + }) + |> json_response(:bad_request) + |> assert_schema("BadRequest", api_spec) + + assert %{ + errors: [ + %{ + title: "Bad Request", + detail: + "Some of the custom values do not exist in the check, they're not customizable or a type mismatch occurred." + } + ] + } == response + end + end + + test "should allow customizing check values", %{ + conn: conn, + api_spec: api_spec + } do + check_id = "mixed_values_customizability" + group_id = Faker.UUID.v4() + + custom_values = [ + %{ + name: "numeric_value", + value: 42 + }, + %{ + name: "customizable_string_value", + value: "new_value" + } + ] + + response = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/checks/#{check_id}/customize/#{group_id}", %{ + values: custom_values + }) + |> json_response(:ok) + |> assert_schema("CustomizationResponse", api_spec) + + assert %{values: customized_values} = response + + assert length(customized_values) == 2 + end + end +end