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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
60 changes: 60 additions & 0 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# CLAUDE.md

This file provides guidance to AI coding agents when working with code in this repository.

## Code Generation

This SDK is **auto-generated** by `oagen` (an internal OpenAPI code generator). Generated files begin with `# This file is auto-generated by oagen. Do not edit.` and are tracked in `.oagen-manifest.json`.

**Do not hand-edit generated files.** Fix the Elixir emitter in the `oagen-emitters` repo instead, then regenerate from the `openapi-spec` repo:

```bash
cd ../../openapi-spec
npm run sdk:generate -- --lang elixir --output ../backend/workos-elixir
```

Hand-maintained files (safe to edit): project files (`mix.exs`, `.formatter.exs`, `README.md`, `.gitignore`), `script/`, `.claude/`, `LICENSE`, `CHANGELOG.md`, `mix.lock`, and anything else not listed in `.oagen-manifest.json`. Project files are static one-time scaffolding — the emitter only generates OpenAPI-spec-driven code.

## Development Commands

```bash
mix deps.get # Install dependencies
script/ci # Run everything CI runs (see below)

mix compile --warnings-as-errors
mix format --check-formatted # Formatting check (mix format to fix)
mix credo --all # Lint
mix dialyzer # Type check (first run builds the PLT; slow)
mix test # Run the test suite
mix test test/workos/organizations_test.exs # Run one file
```

## Architecture Overview

### Client

- `WorkOS.Client.new(api_key: "sk_...")` builds an instance-scoped `%WorkOS.Client{}` struct — there is no global configuration. Falls back to the `WORKOS_API_KEY` env var.
- HTTP transport is [Req](https://hexdocs.pm/req). JSON uses Elixir's built-in `JSON` module (Elixir >= 1.18), not Jason.
- Retries: exponential backoff with jitter on 429/5xx and transport errors; default 3 retries, 60s timeout.
- Per-request options on every resource function: `:headers`, `:timeout`, `:idempotency_key`, `:query`.

### Resources and Models

- One module per API service (`WorkOS.Organizations`, `WorkOS.UserManagement`, ...) in `lib/workos/`. Functions take the client as the first argument and return `{:ok, result} | {:error, error}`.
- One module per model with a `defstruct`, `@type t`, `from_map/1` (wire -> struct), and `to_map/1` (struct -> wire).

### Pagination

- List endpoints return `{:ok, %WorkOS.Page{}}` with a `fetch_next` closure wired in.
- `WorkOS.Page.stream/1` lazily auto-paginates across all pages; `after_cursor/1` and `has_more?/1` support manual paging.

### Error Handling

- `WorkOS.Error.error()` is the union of `WorkOS.ApiError` (non-2xx, with a `:kind` atom like `:not_found` classifying the status) and `WorkOS.TransportError` (network failures).
- `WorkOS.ConfigurationError` raises at client construction for missing config.

### Testing

- ExUnit with `async: true` everywhere; HTTP stubbed via `Req.Test` plugs — no real network.
- JSON fixtures in `test/support/fixtures/`, loaded via `WorkOS.TestFixtures.fixture/1`.
- `test/workos/client_runtime_test.exs` covers the runtime contract: instance-scoped config, per-request options on the wire, typed transport errors, multi-page auto-pagination.
3 changes: 1 addition & 2 deletions .formatter.exs
100755 → 100644
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
# Used by "mix format"
[
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
inputs: ["{mix,.formatter}.exs", "{lib,test}/**/*.{ex,exs}"]
]
13 changes: 13 additions & 0 deletions .github/workflows/block-generated-edits.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
name: Block Generated File Edits

on:
pull_request_target:
# No "reopened": a maintainer reopening to override the bot should not be
# immediately re-closed. A follow-up push (synchronize) re-checks.
types: [opened, synchronize]

jobs:
block-generated-edits:
uses: workos/actions/.github/workflows/block-generated-edits.yml@main
secrets:
SDK_BOT_PRIVATE_KEY: ${{ secrets.SDK_BOT_PRIVATE_KEY }}
10 changes: 1 addition & 9 deletions .github/workflows/main.yml → .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,6 @@ jobs:
#
# Elixir | Supported OTP
# -------+---------------
# 1.16 | 24 - 26
# 1.17 | 25 - 27
# 1.18 | 25 - 27
# 1.19 | 26 - 28
# 1.20 | 27 - 29
Expand All @@ -47,16 +45,10 @@ jobs:
- elixir: '1.19'
otp: '28'

# Minimum supported Elixir (matches the `elixir:` requirement in mix.exs).
- elixir: '1.18'
otp: '27'

- elixir: '1.17'
otp: '27'

# Minimum supported Elixir (matches the `elixir:` requirement in mix.exs).
- elixir: '1.16'
otp: '26'

steps:
- name: Check out this repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
Expand Down
54 changes: 54 additions & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
name: Publish API Docs
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124 # v1.24.1
with:
# Docs only need to build on a single pair — use the newest one
# (matches lint.yml).
elixir-version: '1.20'
otp-version: '28'
- run: mix deps.get
# ExDoc writes the HTML site to doc/ by default.
- run: mix docs
- uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
- name: Archive site
run: |
tar \
--dereference --hard-dereference \
--directory doc \
-cvf "$RUNNER_TEMP/artifact.tar" \
--exclude=.git \
--exclude=.github \
.
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: github-pages
path: ${{ runner.temp }}/artifact.tar
retention-days: 1
if-no-files-found: error
deploy:
needs: build
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
id: deployment
169 changes: 154 additions & 15 deletions .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,27 @@ on:
branches:
- main

concurrency:
group: release-please
cancel-in-progress: false

permissions:
contents: write
pull-requests: write

jobs:
# release-please owns tag + GitHub Release creation (no
# skip-github-release). Because it tags every release inside its own run
# — before it computes the next release PR — it always knows the previous
# release boundary, so it never regenerates the changelog from the start
# of history or proposes a spurious major bump. Creating the release with
# the app token also fires the `release: published` event that release.yml
# uses to publish to Hex. This job is kept minimal so nothing downstream
# (changelog enrichment, release notes) can fail it.
release-please:
runs-on: ubuntu-latest
outputs:
pr: ${{ steps.release.outputs.pr }}
release_created: ${{ steps.release.outputs.release_created }}
tag_name: ${{ steps.release.outputs.tag_name }}
steps:
Expand All @@ -30,27 +43,153 @@ jobs:
with:
token: ${{ steps.generate-token.outputs.token }}

publish:
name: Publish to Hex
# While the release PR is open, enrich it before it merges. Runs as its
# own job (gated on the PR existing) so a failure here can never block the
# release or any publish that depends on release-please.
enrich-release-pr:
needs: release-please
if: needs.release-please.outputs.release_created == 'true'
if: needs.release-please.outputs.pr
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
- name: Generate token
id: generate-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: ${{ vars.SDK_BOT_APP_ID }}
private-key: ${{ secrets.SDK_BOT_PRIVATE_KEY }}
permission-contents: write
permission-pull-requests: write

- name: Checkout release PR branch
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ fromJSON(needs.release-please.outputs.pr).headBranchName }}
token: ${{ steps.generate-token.outputs.token }}

# Inline pending changelog fragments under the version heading
# release-please just wrote in CHANGELOG.md. For PRs that have a
# fragment (the autogen flow always writes one), drop the line
# release-please rendered and use the fragment instead. For PRs
# without a fragment (typical for human-authored PRs), keep what
# release-please wrote. Fragments are deleted in the same commit.
# Idempotent: if no fragments exist, skip silently.
- name: Inline rich changelog fragments
env:
PR_JSON: ${{ needs.release-please.outputs.pr }}
run: |
set -euo pipefail
shopt -s nullglob
fragments=(.changelog-pending/*.md)
if [ ${#fragments[@]} -eq 0 ]; then
echo "No .changelog-pending fragments; leaving release-please CHANGELOG.md as-is."
exit 0
fi

VERSION=$(echo "$PR_JSON" | jq -r '.title' | grep -oE '[0-9]+\.[0-9]+\.[0-9]+')
export VERSION

python3 - <<'PY'
import os, re, pathlib, glob

version = os.environ["VERSION"]

# Load fragments + extract the PR number each one covers from
# its top-line "* [#NN](url) ...".
fragments = []
covered = set()
for path in sorted(glob.glob(".changelog-pending/*.md")):
body = pathlib.Path(path).read_text().rstrip()
m = re.search(r'\[#(\d+)\]', body)
if m:
covered.add(m.group(1))
fragments.append(body)

- name: Setup Elixir and Erlang
uses: erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124 # v1.24.1
changelog = pathlib.Path("CHANGELOG.md")
text = changelog.read_text()
section_re = re.compile(
r'(^## \[' + re.escape(version) + r'\][^\n]*\n)(.*?)(?=^## |\Z)',
re.MULTILINE | re.DOTALL,
)
match = section_re.search(text)
if not match:
raise SystemExit(f"Could not find '## [{version}]' heading in CHANGELOG.md")
heading, body = match.group(1), match.group(2)

# Drop any release-please line that references a PR we have a
# fragment for.
kept = []
for line in body.split("\n"):
if any(pr in covered for pr in re.findall(r'\[#(\d+)\]', line)):
continue
kept.append(line)
filtered = "\n".join(kept)

# Collapse "### Heading\n(blank lines)\n" with nothing under
# it. Run repeatedly until stable in case of stacked empties.
empty_section = re.compile(
r'^### [^\n]*\n(?:\s*\n)*(?=^### |\Z)',
re.MULTILINE,
)
while True:
new = empty_section.sub('', filtered)
if new == filtered:
break
filtered = new
filtered = filtered.strip()

parts = []
if filtered:
parts.append(filtered)
parts.extend(fragments)
new_body = "\n\n".join(parts)

new_text = text[:match.start()] + heading + "\n" + new_body + "\n\n" + text[match.end():]
changelog.write_text(new_text)
PY

git config user.name "workos-sdk-automation[bot]"
git config user.email "255426317+workos-sdk-automation[bot]@users.noreply.github.com"
git rm .changelog-pending/*.md
git add CHANGELOG.md
git commit -m "chore: inline release notes from .changelog-pending"
git push

# After release-please tags the release and creates the GitHub Release,
# replace its body with the rich section from CHANGELOG.md (release-please
# writes only its terse default rendering). Cosmetic-only: never fails the
# release if the section can't be found.
update-release-notes:
needs: release-please
if: needs.release-please.outputs.release_created == 'true'
runs-on: ubuntu-latest
steps:
- name: Generate token
id: generate-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
elixir-version: '1.18'
otp-version: '27'
app-id: ${{ vars.SDK_BOT_APP_ID }}
private-key: ${{ secrets.SDK_BOT_PRIVATE_KEY }}
permission-contents: write
permission-pull-requests: write

- name: Install dependencies
run: mix deps.get
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
token: ${{ steps.generate-token.outputs.token }}

- name: Publish to Hex
run: mix hex.publish --yes
- name: Set rich release notes from CHANGELOG.md
env:
HEX_API_KEY: ${{ secrets.HEX_API_KEY }}
GH_TOKEN: ${{ steps.generate-token.outputs.token }}
TAG: ${{ needs.release-please.outputs.tag_name }}
run: |
set -euo pipefail
VERSION="${TAG#v}"
awk -v v="$VERSION" '
$0 ~ ("^## \\[" v "\\]") { found=1; next }
found && /^## \[/ { exit }
found
' CHANGELOG.md > /tmp/release-notes.md
if [ -s /tmp/release-notes.md ]; then
gh release edit "$TAG" --notes-file /tmp/release-notes.md
else
echo "No CHANGELOG.md body for $TAG; keeping release-please default notes."
fi
Loading