Skip to content

Overhaul: Bump elixir implementation and add comprehensive code coverage. #67

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

Open
wants to merge 18 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
5bb451f
docs(organizations): improve module documentation and add usage examp…
Hydepwns May 14, 2025
fe8353a
test(organizations): add edge and error case tests; feat: improve par…
Hydepwns May 14, 2025
e0e787a
docs: update README, add ARCHITECTURE.md, update workflow and .gitignore
Hydepwns May 14, 2025
7e29925
feat: update core library modules and config for improved error handl…
Hydepwns May 14, 2025
56b8ee3
test: update and add test support/mocks for improved coverage and rel…
Hydepwns May 14, 2025
79b7b97
test: update and add workos module tests for full coverage and regres…
Hydepwns May 14, 2025
0372037
test: update workos_test and add missing support mock tests
Hydepwns May 14, 2025
aa5c3d1
ci: update Codecov upload step to v5, use repo slug and secret token
Hydepwns May 14, 2025
2b49459
ci tweak
Hydepwns May 14, 2025
f31128e
master -> main, remove later
Hydepwns May 14, 2025
dfd6322
fix: Use the ubuntu-22.04 runner (instead of the soon-to-be-retired u…
Hydepwns May 14, 2025
04880d1
test: use module aliases and improve test readability
Hydepwns May 14, 2025
34aaa7e
ci: upload test results to Codecov using test-results-action
Hydepwns May 14, 2025
7aef419
fix: resolve warnings for deprecated MFA, unused variables, and missi…
Hydepwns May 14, 2025
dda10e6
docs: embed Codecov icicle graph below coverage badge in README
Hydepwns May 14, 2025
9119d21
docs: remove Codecov icicle graph, keep only badge in README
Hydepwns May 14, 2025
84cbdf9
fix: adjust actions and readme to point to workos, passed codcov test
Hydepwns May 15, 2025
8c9b37e
Merge branch 'main' into main
Hydepwns May 17, 2025
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
20 changes: 18 additions & 2 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,7 @@ env:
jobs:
test:
name: Test (Elixir ${{ matrix.elixir }}, OTP ${{ matrix.otp }})

runs-on: ubuntu-latest
runs-on: ubuntu-22.04
strategy:
matrix:
# https://hexdocs.pm/elixir/compatibility-and-deprecations.html#compatibility-between-elixir-and-erlang-otp
Expand Down Expand Up @@ -83,6 +82,12 @@ jobs:
- name: Run tests
run: mix test

- name: Upload test results to Codecov
if: ${{ !cancelled() }}
uses: codecov/test-results-action@v1
with:
token: ${{ secrets.CODECOV_TOKEN }}

- name: Retrieve PLT Cache
uses: actions/cache@v3
if: matrix.dialyzer
Expand All @@ -103,3 +108,14 @@ jobs:
- name: Run dialyzer
if: matrix.dialyzer
run: mix dialyzer --no-check --halt-exit-status

- name: Generate coverage report
run: mix coveralls.json

- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: workos/workos-elixir
files: ./cover/excoveralls.json
fail_ci_if_error: true
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,6 @@ workos-*.tar
# Dialyzer
/plts/*.plt
/plts/*.plt.hash

# Codecov token
.secret
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

> **Note:** this an experimental SDK and breaking changes may occur. We don't recommend using this in production since we can't guarantee its stability.

[![codecov](https://codecov.io/gh/workos/workos-elixir/graph/badge.svg?token=D0XH6LBE1K)](https://codecov.io/gh/workos/workos-elixir)

The WorkOS library for Elixir provides convenient access to the WorkOS API from applications written in Elixir.

## Documentation
Expand All @@ -12,7 +14,7 @@ See the [API Reference](https://workos.com/docs/reference/client-libraries) for

Add this package to the list of dependencies in your `mix.exs` file:

```ex
```elixir
def deps do
[{:workos, "~> 1.1.0"}]
end
Expand All @@ -22,17 +24,15 @@ end

### Configure WorkOS API key & client ID on your app config

```ex
```elixir
config :workos, WorkOS.Client,
api_key: "sk_example_123456789",
client_id: "client_123456789"
```

The only required config option is `:api_key` and `:client_id`.

By default, this library uses [Tesla](https://github.com/elixir-tesla/tesla) but it can be replaced via the `:client` option, according to the `WorkOS.Client` module behavior.
The only required config option is `:api_key` and `:client_id`.

###
By default, this library uses [Tesla](https://github.com/elixir-tesla/tesla) but it can be replaced via the `:client` option, according to the `WorkOS.Client` module behavior.

## SDK Versioning

Expand Down
9 changes: 8 additions & 1 deletion lib/workos/castable.ex
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
defmodule WorkOS.Castable do
@moduledoc false
@moduledoc """
Defines the Castable protocol for WorkOS SDK, used for casting API responses to Elixir structs.

This module provides the `cast/2` and `cast_list/2` functions, as well as the `impl` type used throughout the SDK for flexible casting.
"""

@typedoc """
Represents a castable implementation. This can be a module, a tuple of modules, or :raw for raw maps.
"""
@type impl :: module() | {module(), module()} | :raw
@type generic_map :: %{String.t() => any()}

Expand Down
17 changes: 14 additions & 3 deletions lib/workos/client.ex
Original file line number Diff line number Diff line change
Expand Up @@ -105,14 +105,25 @@ defmodule WorkOS.Client do
defp handle_response(response, path, castable_module) do
case response do
{:ok, %{body: "", status: status}} when status in 200..299 ->
{:ok, Castable.cast(castable_module, %{})}
{:error, ""}

{:ok, %{body: body, status: status}} when status in 200..299 and is_map(body) ->
{:ok, WorkOS.Castable.cast(castable_module, body)}

{:ok, %{body: body, status: status}} when status in 200..299 ->
{:ok, Castable.cast(castable_module, body)}
Logger.error("#{inspect(__MODULE__)} error when calling #{path}: #{inspect(body)}")
{:error, body}

{:ok, %{body: reason, status: :error}} when is_atom(reason) ->
Logger.error(
"#{inspect(__MODULE__)} client error when calling #{path}: #{inspect(reason)}"
)

{:error, :client_error}

{:ok, %{body: body}} when is_map(body) ->
Logger.error("#{inspect(__MODULE__)} error when calling #{path}: #{inspect(body)}")
{:error, Castable.cast(WorkOS.Error, body)}
{:error, WorkOS.Castable.cast(WorkOS.Error, body)}

{:ok, %{body: body}} when is_binary(body) ->
Logger.error("#{inspect(__MODULE__)} error when calling #{path}: #{body}")
Expand Down
99 changes: 71 additions & 28 deletions lib/workos/organizations.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,34 @@ defmodule WorkOS.Organizations do
@moduledoc """
Manage Organizations in WorkOS.

@see https://workos.com/docs/reference/organization
Provides functions to list, create, update, retrieve, and delete organizations via the WorkOS API.

See the [WorkOS Organizations API Reference](https://workos.com/docs/reference/organization) for more details.

## Example Usage

```elixir
# List organizations
{:ok, %WorkOS.List{data: organizations}} = WorkOS.Organizations.list_organizations()

# Create an organization
{:ok, organization} = WorkOS.Organizations.create_organization(%{
name: "Test Organization",
domains: ["example.com"]
})

# Get an organization by ID
{:ok, organization} = WorkOS.Organizations.get_organization("org_01EHT88Z8J8795GZNQ4ZP1J81T")

# Update an organization
{:ok, updated_org} = WorkOS.Organizations.update_organization(
"org_01EHT88Z8J8795GZNQ4ZP1J81T",
%{name: "New Name", domains: ["newdomain.com"]}
)

# Delete an organization
{:ok, _} = WorkOS.Organizations.delete_organization("org_01EHT88Z8J8795GZNQ4ZP1J81T")
```
"""

alias WorkOS.Empty
Expand Down Expand Up @@ -82,27 +109,35 @@ defmodule WorkOS.Organizations do

* `:name` - A descriptive name for the Organization. This field does not need to be unique. (required)
* `:domains` - The domains of the Organization.
* `:allow_profiles_outside_organization` - Whether the Connections within this Organization should allow Profiles that do not have a domain that is present in the set of the Organizations User Email Domains.
* `:allow_profiles_outside_organization` - Whether the Connections within this Organization should allow Profiles that do not have a domain that is present in the set of the Organization's User Email Domains.
* `:idempotency_key` - A unique string as the value. Each subsequent request matching this unique string will return the same response.

"""
@spec create_organization(map()) :: WorkOS.Client.response(Organization.t())
@spec create_organization(map()) :: WorkOS.Client.response(Organization.t()) | {:error, atom()}
@spec create_organization(WorkOS.Client.t(), map()) ::
WorkOS.Client.response(Organization.t())
def create_organization(client \\ WorkOS.client(), opts) when is_map_key(opts, :name) do
WorkOS.Client.post(
client,
Organization,
"/organizations",
%{
name: opts[:name],
domains: opts[:domains],
allow_profiles_outside_organization: opts[:allow_profiles_outside_organization]
},
headers: [
{"Idempotency-Key", opts[:idempotency_key]}
]
)
WorkOS.Client.response(Organization.t()) | {:error, atom()}
def create_organization(opts) when is_map(opts) do
create_organization(WorkOS.client(), opts)
end

def create_organization(client, opts) when is_map(opts) do
if Map.has_key?(opts, :name) do
WorkOS.Client.post(
client,
Organization,
"/organizations",
%{
name: opts[:name],
domains: opts[:domains],
allow_profiles_outside_organization: opts[:allow_profiles_outside_organization]
},
headers: [
{"Idempotency-Key", opts[:idempotency_key]}
]
)
else
{:error, :missing_name}
end
end

@doc """
Expand All @@ -113,18 +148,26 @@ defmodule WorkOS.Organizations do
* `:organization` - Unique identifier of the Organization. (required)
* `:name` - A descriptive name for the Organization. This field does not need to be unique. (required)
* `:domains` - The domains of the Organization.
* `:allow_profiles_outside_organization` - Whether the Connections within this Organization should allow Profiles that do not have a domain that is present in the set of the Organizations User Email Domains.
* `:allow_profiles_outside_organization` - Whether the Connections within this Organization should allow Profiles that do not have a domain that is present in the set of the Organization's User Email Domains.

"""
@spec update_organization(String.t(), map()) :: WorkOS.Client.response(Organization.t())
@spec update_organization(String.t(), map()) ::
WorkOS.Client.response(Organization.t()) | {:error, atom()}
@spec update_organization(WorkOS.Client.t(), String.t(), map()) ::
WorkOS.Client.response(Organization.t())
def update_organization(client \\ WorkOS.client(), organization_id, opts)
when is_map_key(opts, :name) do
WorkOS.Client.put(client, Organization, "/organizations/#{organization_id}", %{
name: opts[:name],
domains: opts[:domains],
allow_profiles_outside_organization: !!opts[:allow_profiles_outside_organization]
})
WorkOS.Client.response(Organization.t()) | {:error, atom()}
def update_organization(organization_id, opts) when is_map(opts) do
update_organization(WorkOS.client(), organization_id, opts)
end

def update_organization(client, organization_id, opts) when is_map(opts) do
if Map.has_key?(opts, :name) do
WorkOS.Client.put(client, Organization, "/organizations/#{organization_id}", %{
name: opts[:name],
domains: opts[:domains],
allow_profiles_outside_organization: !!opts[:allow_profiles_outside_organization]
})
else
{:error, :missing_name}
end
end
end
28 changes: 18 additions & 10 deletions lib/workos/sso.ex
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,15 @@ defmodule WorkOS.SSO do
@spec delete_connection(String.t()) :: WorkOS.Client.response(nil)
@spec delete_connection(WorkOS.Client.t(), String.t()) :: WorkOS.Client.response(nil)
def delete_connection(client \\ WorkOS.client(), connection_id) do
WorkOS.Client.delete(client, Empty, "/connections/:id", %{},
opts: [
path_params: [id: connection_id]
]
)
if is_nil(connection_id) or connection_id in ["", nil] do
{:error, :invalid_connection_id}
else
WorkOS.Client.delete(client, Empty, "/connections/:id", %{},
opts: [
path_params: [id: connection_id]
]
)
end
end

@doc """
Expand All @@ -77,11 +81,15 @@ defmodule WorkOS.SSO do
@spec get_connection(String.t()) :: WorkOS.Client.response(Connection.t())
@spec get_connection(WorkOS.Client.t(), String.t()) :: WorkOS.Client.response(Connection.t())
def get_connection(client \\ WorkOS.client(), connection_id) do
WorkOS.Client.get(client, Connection, "/connections/:id",
opts: [
path_params: [id: connection_id]
]
)
if is_nil(connection_id) or connection_id in ["", nil] do
{:error, :invalid_connection_id}
else
WorkOS.Client.get(client, Connection, "/connections/:id",
opts: [
path_params: [id: connection_id]
]
)
end
end

@doc """
Expand Down
4 changes: 2 additions & 2 deletions lib/workos/user_management/multi_factor/sms.ex
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ defmodule WorkOS.UserManagement.MultiFactor.SMS do
]

@impl true
def cast(map) do
def cast(params) do
%__MODULE__{
phone_number: map["phone_number"]
phone_number: params["phone_number"]
}
end
end
12 changes: 6 additions & 6 deletions lib/workos/user_management/multi_factor/totp.ex
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,13 @@ defmodule WorkOS.UserManagement.MultiFactor.TOTP do
]

@impl true
def cast(map) do
def cast(params) do
%__MODULE__{
issuer: map["issuer"],
user: map["user"],
secret: map["secret"],
qr_code: map["qr_code"],
uri: map["uri"]
issuer: params["issuer"],
user: params["user"],
secret: params["secret"],
qr_code: params["qr_code"],
uri: params["uri"]
}
end
end
13 changes: 6 additions & 7 deletions lib/workos/user_management/organization_membership.ex
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,13 @@ defmodule WorkOS.UserManagement.OrganizationMembership do
:created_at
]

@impl true
def cast(map) do
def cast(params) do
%__MODULE__{
id: map["id"],
user_id: map["user_id"],
organization_id: map["organization_id"],
updated_at: map["updated_at"],
created_at: map["created_at"]
id: params["id"],
user_id: params["user_id"],
organization_id: params["organization_id"],
updated_at: params["updated_at"],
created_at: params["created_at"]
}
end
end
7 changes: 2 additions & 5 deletions lib/workos/user_management/reset_password.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@ defmodule WorkOS.UserManagement.ResetPassword do

alias WorkOS.UserManagement.User

@behaviour WorkOS.Castable

@type t() :: %__MODULE__{
user: User.t()
}
Expand All @@ -18,10 +16,9 @@ defmodule WorkOS.UserManagement.ResetPassword do
:user
]

@impl true
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can have a protocol to cast to map

def cast(map) do
def cast(params) do
%__MODULE__{
user: map["user"]
user: params["user"]
}
end
end
Loading