Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 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
1d9169c
Merge branch 'main' into main
Jul 6, 2025
52cd62a
feat: implement Mappable protocol for struct-to-map conversion
Jul 6, 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
6 changes: 4 additions & 2 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"}]
end
Expand All @@ -22,7 +24,7 @@ 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"
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
32 changes: 32 additions & 0 deletions lib/workos/mappable.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
defmodule WorkOS.Mappable do
@moduledoc """
Defines the Mappable protocol for WorkOS SDK, used for converting Elixir structs to maps.

This protocol provides a standard way to convert WorkOS structs to plain maps,
which is useful for JSON serialization, API responses, or any other case where
you need a map representation of a struct.
"""

@doc """
Converts a struct to a map representation.
"""
@callback to_map(struct()) :: map()

@doc """
Converts a struct to a map representation.
"""
@spec to_map(struct()) :: map()
def to_map(struct) do
struct.__struct__.to_map(struct)
end

@doc """
Converts a list of structs to a list of maps.
"""
@spec to_map_list([struct()]) :: [map()]
def to_map_list(structs) when is_list(structs) do
Enum.map(structs, &to_map/1)
end

def to_map_list(nil), do: nil
end
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
10 changes: 8 additions & 2 deletions lib/workos/user_management/reset_password.ex
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ defmodule WorkOS.UserManagement.ResetPassword do
alias WorkOS.UserManagement.User

@behaviour WorkOS.Castable
@behaviour WorkOS.Mappable

@type t() :: %__MODULE__{
user: User.t()
Expand All @@ -19,9 +20,14 @@ defmodule WorkOS.UserManagement.ResetPassword do
]

@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

Copy link
Author

Choose a reason for hiding this comment

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

Added Mappable protocol for struct→map conversion + fixed missing Castable behaviours. 8 tests passing.

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

@impl true
def to_map(%__MODULE__{} = reset_password) do
Map.from_struct(reset_password)
end
end
Loading