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/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..6bd0e9a --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,100 @@ +name: Elixir Testing + +# Define workflow that runs when changes are pushed to the +# `main` branch or pushed to a PR branch that targets the `main` +# branch. Change the branch name if your project uses a +# different name for the main branch like "master" or "production". +on: + pull_request: + branches: + - main + +permissions: + contents: read + checks: write + +jobs: + test: + # Sets the ENV `MIX_ENV` to `test` for running tests + env: + MIX_ENV: test + RAILS_ENV: test + + runs-on: ubuntu-22.04 + name: Test on OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Detect system info + uses: kenchan0130/actions-system-info@master + id: system-info + + - name: Setup OTP/Elixir + uses: erlef/setup-beam@v1 + id: setup-beam + with: + version-type: strict + version-file: .tool-versions + + - name: Cache Elixir deps and build + uses: actions/cache@v4 + with: + path: | + deps + _build + key: "mix-cache-\ + ${{ steps.system-info.outputs.name }}-\ + ${{ steps.system-info.outputs.release }}-\ + ${{ steps.setup-beam.outputs.otp-version }}-\ + elixir-${{ steps.setup-beam.outputs.elixir-version }}-\ + mix.lock-${{ hashFiles('**/mix.lock') }}" + + - name: Install Elixir dependencies + run: mix deps.get + + - name: Compiles + run: mix compile + + - name: Audit Deprecated dependencies + run: mix hex.audit + + - name: Audit Elixir dependencies + run: mix deps.audit + + - name: Check Formatting + run: | + mix format --check-formatted + + - name: Analyze code with credo + run: mix credo + + - name: Security check with Sobelow + run: mix sobelow --config + + - name: Run tests + id: test_with_cover + run: | + mix test --cover 2>&1 | tee test.log + result_code=${PIPESTATUS[0]} + echo "coverage=$(cat test.log | grep '\[TOTAL\][[:space:]]\+[^%]\+' | sed 's/[^0-9\.]*//g')" >> "${GITHUB_OUTPUT}" + exit $result_code + + # Create the directory where badges will be saved, if needed + - name: Create destination directory + run: mkdir -p doc + + - name: Create or update coverage ishields json + run: | + result=${{ steps.test_with_cover.outputs.coverage }} + if [ "${result%.*}" -lt 90 ]; then color="orange"; else color="green"; fi + echo '{"schemaVersion": 1, "label": "Test coverage", "message": "'"${{ steps.test_with_cover.outputs.coverage }}"'%", "color": "'"${color}"'"}' > doc/coverage.json + + - name: Archive code coverage results + uses: actions/upload-artifact@v4 + with: + name: pr-${{ env.PR_NUMBER }} + path: doc + env: + PR_NUMBER: ${{ github.event.number }} diff --git a/coveralls.json b/coveralls.json new file mode 100644 index 0000000..174750d --- /dev/null +++ b/coveralls.json @@ -0,0 +1,6 @@ +{ + "skip_files": [ + "lib/cyphi/(?!client)", + "lib/cyphi/client/adapter/req.ex" + ] +} diff --git a/lib/cyphi/client.ex b/lib/cyphi/client.ex index 06c19b9..273857d 100644 --- a/lib/cyphi/client.ex +++ b/lib/cyphi/client.ex @@ -31,11 +31,11 @@ defmodule Cyphi.Client do end defp valid_url?(url) do - case URI.parse(url) do - %URI{host: host, path: path} -> - path == "localhost" || - (is_binary(host) and byte_size(host) > 0) - + with true <- is_binary(url) and byte_size(url) > 0, + %URI{host: host, path: path} <- URI.parse(url) do + path == "localhost" || + (is_binary(host) and byte_size(host) > 0) + else _ -> false end diff --git a/lib/cyphi/client/adapter/req.ex b/lib/cyphi/client/adapter/req.ex index 1cbe419..1907c92 100644 --- a/lib/cyphi/client/adapter/req.ex +++ b/lib/cyphi/client/adapter/req.ex @@ -1,4 +1,6 @@ defmodule Cyphi.HttpAdapter.Req do + @moduledoc false + @behaviour Cyphi.HttpAdapter @impl true diff --git a/lib/cyphi/client/adapter/test.ex b/lib/cyphi/client/adapter/test.ex index d6dcd6d..c3d370a 100644 --- a/lib/cyphi/client/adapter/test.ex +++ b/lib/cyphi/client/adapter/test.ex @@ -1,12 +1,17 @@ defmodule Cyphi.HttpAdapter.Test do + @moduledoc false + @behaviour Cyphi.HttpAdapter @impl true def send_request(_method, _url, _opts) do case Process.get(:http_response) do - nil -> raise "Missing http response process" + nil -> + raise "Missing http response process" + response when is_map(response) -> {:ok, struct(Req.Response, response)} + response -> response end diff --git a/lib/cyphi/client/http_adapter.ex b/lib/cyphi/client/http_adapter.ex index f5a8a1a..2b7a4e7 100644 --- a/lib/cyphi/client/http_adapter.ex +++ b/lib/cyphi/client/http_adapter.ex @@ -1,4 +1,12 @@ defmodule Cyphi.HttpAdapter do + @moduledoc """ + Defines the behaviour for HTTP adapters used by `Cyphi.Client`. + + This contract allows the HTTP backend to be swapped or mocked (e.g., using `Req` + in production and a mock in tests). Implementing modules must handle the execution + of the network request and return a standard `Req.Response` struct or an error tuple. + """ + @doc "Contract for HTTP request adapters" @callback send_request(method :: atom(), url :: String.t(), opts :: keyword()) :: {:ok, Req.Response.t()} | {:error, term()} diff --git a/lib/cyphi/client/request.ex b/lib/cyphi/client/request.ex index ba3cdd0..4b2e04a 100644 --- a/lib/cyphi/client/request.ex +++ b/lib/cyphi/client/request.ex @@ -1,4 +1,13 @@ defmodule Cyphi.Client.Request do + @moduledoc """ + Prepares and encodes HTTP request options for the `Cyphi.Client`. + + This module transforms high-level request maps into adapter-compatible keywords by: + * **Injecting Headers:** Adds default JSON content types, `Accept` headers, and the `x-api-key` for authentication. + * **Serializing Bodies:** Encodes payloads to JSON, ensuring maps and keyword lists are normalized into ordered objects. + * **Mapping Parameters:** Converts query parameters into the expected adapter format. + """ + @spec encode(map()) :: keyword() def encode(request) do Keyword.new() @@ -56,7 +65,7 @@ defmodule Cyphi.Client.Request do Keyword.put(req_opts, :body, encoded) err -> - {:error, "Invalid request body: #{inspect(err)}"} + {:error, "Invalid request body: #{inspect(err)}"} end end diff --git a/lib/cyphi/client/response.ex b/lib/cyphi/client/response.ex index 271bf98..0be8b23 100644 --- a/lib/cyphi/client/response.ex +++ b/lib/cyphi/client/response.ex @@ -1,5 +1,17 @@ defmodule Cyphi.Client.Response do - @moduledoc false + @moduledoc """ + Handles the decoding and casting of HTTP responses into Elixir structs. + + This module serves as the final step in the request pipeline. It matches the + response status code against a provided `response_spec` (generated by the + OpenAPI client) to determine how to process the body. + + ### Decoding Strategy + * **Structs (200/201):** Validates and casts raw JSON maps into typed Elixir structs using `Cyphi.Client.Caster`. + * **Lists:** Automatically handles lists of items by mapping the decoding logic over the collection. + * **Raw Bodies (202/204):** Returns the raw body for Accepted or No Content responses where no schema is strictly defined. + * **Errors:** Returns generic error tuples for unhandled status codes (e.g., 404, 500). + """ alias Cyphi.Client.Caster @@ -41,11 +53,11 @@ defmodule Cyphi.Client.Response do attrs = module.__fields__(type) |> Enum.reduce(%{}, fn {field, field_type}, acc -> - key = Atom.to_string(field) - value = Map.get(map, key) + key = Atom.to_string(field) + value = Map.get(map, key) - Map.put(acc, String.to_existing_atom(key), Caster.cast(value, field_type)) - end) + Map.put(acc, String.to_existing_atom(key), Caster.cast(value, field_type)) + end) struct(module, attrs) end diff --git a/lib/cyphi/course.ex b/lib/cyphi/course.ex index d76377d..92de285 100644 --- a/lib/cyphi/course.ex +++ b/lib/cyphi/course.ex @@ -54,6 +54,7 @@ defmodule Cyphi.Course do weights: String.t() | nil } + # credo:disable-for-next-line defstruct [ :access_code, :allow_reenrollment, diff --git a/lib/cyphi/course_template.ex b/lib/cyphi/course_template.ex index f2aad0c..6be5673 100644 --- a/lib/cyphi/course_template.ex +++ b/lib/cyphi/course_template.ex @@ -45,6 +45,7 @@ defmodule Cyphi.CourseTemplate do weights: String.t() | nil } + # credo:disable-for-next-line defstruct [ :allow_reenrollment, :allow_unenrollment, diff --git a/lib/cyphi/learner.ex b/lib/cyphi/learner.ex index 611bb1f..1ac2000 100644 --- a/lib/cyphi/learner.ex +++ b/lib/cyphi/learner.ex @@ -41,6 +41,7 @@ defmodule Cyphi.Learner do user_id: integer } + # credo:disable-for-next-line defstruct [ :completed, :completed_at, diff --git a/lib/cyphi/user.ex b/lib/cyphi/user.ex index eb92837..2bde3ce 100644 --- a/lib/cyphi/user.ex +++ b/lib/cyphi/user.ex @@ -52,6 +52,7 @@ defmodule Cyphi.User do zip: String.t() | nil } + # credo:disable-for-next-line defstruct [ :about, :added_by_id, diff --git a/mix.exs b/mix.exs index f5662a9..e876234 100644 --- a/mix.exs +++ b/mix.exs @@ -8,10 +8,23 @@ defmodule Cyphi.MixProject do elixir: "~> 1.17", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, + test_coverage: [tool: ExCoveralls], deps: deps() ] end + def cli do + [ + preferred_envs: [ + coveralls: :test, + "coveralls.detail": :test, + "coveralls.post": :test, + "coveralls.html": :test, + "coveralls.cobertura": :test + ] + ] + end + # Run "mix help compile.app" to learn about applications. def application do [ @@ -26,10 +39,12 @@ defmodule Cyphi.MixProject do # Run "mix help deps" to learn about dependencies. defp deps do [ + {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, + {:excoveralls, "~> 0.18", only: :test}, + {:mix_audit, "~> 2.1", only: [:dev, :test], runtime: false}, + {:oapi_generator, "~> 0.4", only: :dev, runtime: false}, {:req, "~> 0.5"}, - {:oapi_generator, "~> 0.4", only: :dev, runtime: false} - # {:dep_from_hexpm, "~> 0.3.0"}, - # {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"} + {:sobelow, "~> 0.13", only: [:dev, :test], runtime: false} ] end end diff --git a/mix.lock b/mix.lock index 9a1a923..9eefe64 100644 --- a/mix.lock +++ b/mix.lock @@ -1,13 +1,19 @@ %{ + "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, + "credo": {:hex, :credo, "1.7.14", "c7e75216cea8d978ba8c60ed9dede4cc79a1c99a266c34b3600dd2c33b96bc92", [: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", "12a97d6bb98c277e4fb1dff45aaf5c137287416009d214fb46e68147bd9e0203"}, + "excoveralls": {:hex, :excoveralls, "0.18.5", "e229d0a65982613332ec30f07940038fe451a2e5b29bce2a5022165f0c9b157e", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "523fe8a15603f86d64852aab2abe8ddbd78e68579c8525ae765facc5eae01562"}, + "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, "finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [: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", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"}, "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"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "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"}, + "mix_audit": {:hex, :mix_audit, "2.1.5", "c0f77cee6b4ef9d97e37772359a187a166c7a1e0e08b50edf5bf6959dfe5a016", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "87f9298e21da32f697af535475860dc1d3617a010e0b418d2ec6142bc8b42d69"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "oapi_generator": {:hex, :oapi_generator, "0.4.0", "6e4bdee4c87549e223a8adb046276aa924e6c76756909978d05ae61305858791", [:mix], [{:yaml_elixir, "~> 2.9", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "381d8fce6d1dd16a9407b620bdc31f370a9536590a066f3dd47becaec9ce9b3e"}, "req": {:hex, :req, "0.5.16", "99ba6a36b014458e52a8b9a0543bfa752cb0344b2a9d756651db1281d4ba4450", [: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", "974a7a27982b9b791df84e8f6687d21483795882a7840e8309abdbe08bb06f09"}, + "sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"}, "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, "yaml_elixir": {:hex, :yaml_elixir, "2.12.0", "30343ff5018637a64b1b7de1ed2a3ca03bc641410c1f311a4dbdc1ffbbf449c7", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "ca6bacae7bac917a7155dca0ab6149088aa7bc800c94d0fe18c5238f53b313c6"}, diff --git a/test/cyphi/client/caster_test.exs b/test/cyphi/client/caster_test.exs index 30cbab8..9f22811 100644 --- a/test/cyphi/client/caster_test.exs +++ b/test/cyphi/client/caster_test.exs @@ -41,8 +41,8 @@ defmodule Cyphi.Client.CasterTest do end test "returns nil for invalid date strings (soft failure)" do - assert Caster.cast("not a date" , {:string, "date-time"}) == nil - assert Caster.cast("00-01-01 10:22:00" , {:string, "date-time"}) == nil + assert Caster.cast("not a date", {:string, "date-time"}) == nil + assert Caster.cast("00-01-01 10:22:00", {:string, "date-time"}) == nil end end diff --git a/test/cyphi/client/request_test.exs b/test/cyphi/client/request_test.exs index 6e5319f..d46daff 100644 --- a/test/cyphi/client/request_test.exs +++ b/test/cyphi/client/request_test.exs @@ -94,5 +94,11 @@ defmodule Cyphi.Client.RequestTest do assert msg =~ "Invalid request body:" assert msg =~ "Jason.Encoder" end + + test "normalize simple values body" do + request = Request.encode(%{body: 10}) + + assert request[:body] == "10" + end end end diff --git a/test/cyphi/client/response_test.exs b/test/cyphi/client/response_test.exs index 1361396..5ec1af3 100644 --- a/test/cyphi/client/response_test.exs +++ b/test/cyphi/client/response_test.exs @@ -9,8 +9,7 @@ defmodule Cyphi.Client.ResponseTest do req_success: %Req.Response{status: 200, body: %{}}, req_created: %Req.Response{status: 201, body: %{}}, req_accepted: %Req.Response{status: 202, body: %{}}, - req_error: %Req.Response{status: 404, body: "Not Found"} - } + req_error: %Req.Response{status: 404, body: "Not Found"}} end describe "decode/2 with Adapter Error" do @@ -56,6 +55,7 @@ defmodule Cyphi.Client.ResponseTest do %{"first_name" => "A", "id" => 1}, %{"first_name" => "B", "id" => 2} ] + req = %Req.Response{status: 200, body: body} opts = %{response: [{200, [{Cyphi.User, :t}]}]} diff --git a/test/cyphi/client_test.exs b/test/cyphi/client_test.exs index 34454ee..7f95f3e 100644 --- a/test/cyphi/client_test.exs +++ b/test/cyphi/client_test.exs @@ -110,5 +110,14 @@ defmodule Cyphi.ClientTest do end) end) end + + test "Invalid nil response" do + assert_raise RuntimeError, "Missing http response process", fn -> + with_response(nil, fn -> + opts = %{method: :get, url: "/", response: []} + assert {:ok, _resp} = Client.request(opts) + end) + end + end end end diff --git a/test/support/client_api_helper.ex b/test/support/client_api_helper.ex index 52684fb..7004a28 100644 --- a/test/support/client_api_helper.ex +++ b/test/support/client_api_helper.ex @@ -1,4 +1,6 @@ defmodule Cyphi.ClientApiHelper do + @moduledoc false + def with_api_url(url, fun) do original_url = Application.get_env(:cyphi, :api_url) Application.put_env(:cyphi, :api_url, url)