Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .formatter.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Used by "mix format"
[
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]
100 changes: 100 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -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 }}
6 changes: 6 additions & 0 deletions coveralls.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"skip_files": [
"lib/cyphi/(?!client)",
"lib/cyphi/client/adapter/req.ex"
]
}
10 changes: 5 additions & 5 deletions lib/cyphi/client.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions lib/cyphi/client/adapter/req.ex
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
defmodule Cyphi.HttpAdapter.Req do
@moduledoc false

@behaviour Cyphi.HttpAdapter

@impl true
Expand Down
7 changes: 6 additions & 1 deletion lib/cyphi/client/adapter/test.ex
Original file line number Diff line number Diff line change
@@ -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
Expand Down
8 changes: 8 additions & 0 deletions lib/cyphi/client/http_adapter.ex
Original file line number Diff line number Diff line change
@@ -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()}
Expand Down
11 changes: 10 additions & 1 deletion lib/cyphi/client/request.ex
Original file line number Diff line number Diff line change
@@ -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()
Expand Down Expand Up @@ -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

Expand Down
22 changes: 17 additions & 5 deletions lib/cyphi/client/response.ex
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions lib/cyphi/course.ex
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ defmodule Cyphi.Course do
weights: String.t() | nil
}

# credo:disable-for-next-line
defstruct [
:access_code,
:allow_reenrollment,
Expand Down
1 change: 1 addition & 0 deletions lib/cyphi/course_template.ex
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ defmodule Cyphi.CourseTemplate do
weights: String.t() | nil
}

# credo:disable-for-next-line
defstruct [
:allow_reenrollment,
:allow_unenrollment,
Expand Down
1 change: 1 addition & 0 deletions lib/cyphi/learner.ex
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ defmodule Cyphi.Learner do
user_id: integer
}

# credo:disable-for-next-line
defstruct [
:completed,
:completed_at,
Expand Down
1 change: 1 addition & 0 deletions lib/cyphi/user.ex
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ defmodule Cyphi.User do
zip: String.t() | nil
}

# credo:disable-for-next-line
defstruct [
:about,
:added_by_id,
Expand Down
21 changes: 18 additions & 3 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
[
Expand All @@ -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
6 changes: 6 additions & 0 deletions mix.lock
Original file line number Diff line number Diff line change
@@ -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"},
Expand Down
4 changes: 2 additions & 2 deletions test/cyphi/client/caster_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 6 additions & 0 deletions test/cyphi/client/request_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 2 additions & 2 deletions test/cyphi/client/response_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}]}]}

Expand Down
9 changes: 9 additions & 0 deletions test/cyphi/client_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions test/support/client_api_helper.ex
Original file line number Diff line number Diff line change
@@ -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)
Expand Down