nshkrdotcom/pristine
Manifest-driven hexagonal core for generating Elixir SDKs and services with pluggable ports/adapters for transport, schema, retries, telemetry, streaming, and multipart.
Pristine
Manifest-driven, hexagonal SDK generator for Elixir
Pristine separates domain logic from infrastructure through a clean ports and adapters architecture. Define your API in a declarative manifest, then generate type-safe Elixir SDKs with built-in resilience patterns, streaming support, and comprehensive observability.
Features
- Hexagonal Architecture — Clean separation via ports (interfaces) and adapters (implementations)
- Manifest-Driven — Declarative API definitions in JSON, YAML, or Elixir
- Code Generation — Generate type modules, resource modules, and clients from manifests
- Type Safety — Sinter schema validation for requests and responses
- Optional OAuth2 Control Plane — Authorization URL generation, PKCE, token exchange, refresh, revoke, and introspect helpers without routing normal API traffic through Tesla
- Resilience Built-In — Retry policies, circuit breakers, and rate limiting
- Streaming Support — First-class SSE (Server-Sent Events) handling
- Observable — Telemetry events throughout the request lifecycle
- Extensible — Swap adapters for transport, auth, serialization, and more
Installation
Add Pristine to your dependencies:
def deps do
[
{:pristine, "~> 0.1.0"},
{:oauth2, "~> 2.1"} # Only if you want Pristine.OAuth2 helpers
]
endoauth2 stays optional. Pristine's normal runtime and generated SDK execution path do not depend on Tesla or the oauth2 request client.
Quick Start
1. Define Your API Manifest
{
"name": "myapi",
"version": "1.0.0",
"base_url": "https://api.example.com",
"endpoints": [
{
"id": "get_user",
"method": "GET",
"path": "/users/{id}",
"resource": "users",
"response": "User"
},
{
"id": "create_user",
"method": "POST",
"path": "/users",
"resource": "users",
"request": "CreateUserRequest",
"response": "User"
}
],
"types": {
"User": {
"fields": {
"id": {"type": "string", "required": true},
"name": {"type": "string", "required": true},
"email": {"type": "string"}
}
},
"CreateUserRequest": {
"fields": {
"name": {"type": "string", "required": true},
"email": {"type": "string"}
}
}
}
}2. Generate SDK Code
mix pristine.generate \
--manifest manifest.json \
--output lib/myapi \
--namespace MyAPI3. Use the Generated SDK
# Create a client
client = MyAPI.Client.new(
base_url: "https://api.example.com",
transport: Pristine.Adapters.Transport.Finch,
transport_opts: [finch: MyApp.Finch],
auth: [{Pristine.Adapters.Auth.Bearer, token: "your-token"}]
)
# Make API calls
resource = MyAPI.Client.users(client)
{:ok, user} = MyAPI.Users.get(resource, "user-123")
{:ok, new_user} = MyAPI.Users.create(resource, "John Doe", email: "john@example.com")Architecture
Pristine implements a hexagonal (ports and adapters) architecture:
┌─────────────────────────────────────────────────────────┐
│ Your Application │
├─────────────────────────────────────────────────────────┤
│ Generated SDK Layer │
│ (Client, Resources, Types) │
├─────────────────────────────────────────────────────────┤
│ Pristine Core │
│ Pipeline │ Manifest │ Codegen │ Streaming │
├─────────────────────────────────────────────────────────┤
│ Ports │
│ Transport │ Serializer │ Auth │ Retry │ Telemetry │
├─────────────────────────────────────────────────────────┤
│ Adapters │
│ Finch │ JSON │ Bearer │ Foundation │ Gzip │ SSE │
└─────────────────────────────────────────────────────────┘
Ports define interface contracts. Adapters provide implementations. Swap adapters to change behavior without touching domain logic.
Available Adapters
| Category | Adapters |
|---|---|
| Transport | Finch, FinchStream |
| Serializer | JSON |
| Auth | Bearer, APIKey |
| Retry | Foundation, Noop |
| Circuit Breaker | Foundation, Noop |
| Rate Limit | BackoffWindow, Noop |
| Telemetry | Foundation, Raw, Reporter, Noop |
| Compression | Gzip |
| TokenSource | File, Refreshable, Static |
| Streaming | SSE |
Runtime Execution
Execute endpoints without code generation:
# Load manifest
{:ok, manifest} = Pristine.load_manifest_file("manifest.json")
# Build context with adapters
context = Pristine.context(
base_url: "https://api.example.com",
transport: Pristine.Adapters.Transport.Finch,
transport_opts: [finch: MyApp.Finch],
serializer: Pristine.Adapters.Serializer.JSON,
auth: [{Pristine.Adapters.Auth.Bearer, token: "your-token"}],
retry: Pristine.Adapters.Retry.Foundation,
telemetry: Pristine.Adapters.Telemetry.Foundation
)
# Execute endpoint
{:ok, result} = Pristine.execute(manifest, :get_user, %{}, context,
path_params: %{"id" => "123"}
)Security Metadata And OAuth2
Pristine manifests and OpenAPI-generated request maps now carry native security metadata:
- manifest-level
security_schemes - manifest-level
security - endpoint-level
security
At runtime the pipeline resolves auth in this order:
- request-level
authoverride - endpoint
security - manifest
security - legacy endpoint
auth - legacy context
auth
endpoint.security == [] explicitly disables inherited auth.
OpenAPI-generated operation request maps preserve effective security metadata
through the normal generator path. Pristine.OpenAPI.Security.read/1 remains
available only as an explicit fallback when a caller needs to inject security
metadata manually.
Pristine.OpenAPI.Bridge.run/3 returns a canonical
%Pristine.OpenAPI.Result{}. The legacy top-level files, operations, and
schemas fields remain in place, and the result also exposes ir,
source_contexts, generator_state, and a JSON-ready docs_manifest built by
Pristine.OpenAPI.Docs.
For OAuth2 control-plane work, use Pristine.OAuth2 with a normal Pristine Context:
provider =
Pristine.OAuth2.Provider.new(
name: "example",
site: "https://api.example.com",
authorize_url: "/oauth/authorize",
token_url: "/oauth/token",
client_auth_method: :basic,
token_content_type: "application/json"
)
{:ok, request} =
Pristine.OAuth2.authorization_request(provider,
client_id: "...",
redirect_uri: "https://example.com/callback",
generate_state: true,
pkce: true,
params: [audience: "api"]
)
{:ok, token} =
Pristine.OAuth2.exchange_code(provider, "code-from-callback",
client_id: "...",
client_secret: "...",
redirect_uri: "https://example.com/callback",
context: context
)For interactive terminal onboarding, use the reusable Pristine.OAuth2
helpers instead of rebuilding browser launch, callback capture, and manual
paste-back yourself:
{:ok, token} =
Pristine.OAuth2.Interactive.authorize(provider,
client_id: "...",
client_secret: "...",
redirect_uri: "http://127.0.0.1:40071/callback",
context: context
)Pristine.OAuth2.Browser opens the authorization URL on a best-effort basis.
Pristine.OAuth2.CallbackServer only binds exact literal-loopback http
redirect URIs such as http://127.0.0.1:40071/callback. Manual paste-back of
the full redirect URL or raw code is always available.
Persist tokens generically with the file-backed token source when a caller
wants JSON storage outside of any provider-specific SDK:
token_path = Path.expand("~/.config/example/oauth/token.json")
:ok =
Pristine.Adapters.TokenSource.File.put(token,
path: token_path,
create_dirs?: true
)
{:ok, persisted_token} =
Pristine.Adapters.TokenSource.File.fetch(path: token_path)The stored envelope stays generic and round-trips access_token,
refresh_token, expires_at, token_type, and any provider metadata inside
other_params.
If a provider returns real expiry metadata such as expires_at or
expires_in, wrap the durable source with
Pristine.Adapters.TokenSource.Refreshable to refresh and persist replacements
through the same storage boundary:
oauth_context = Pristine.context(
transport: Pristine.Adapters.Transport.Finch,
transport_opts: [finch: MyApp.Finch],
serializer: Pristine.Adapters.Serializer.JSON
)
context = Pristine.context(
auth: %{
"bearerAuth" => [
Pristine.Adapters.Auth.OAuth2.new(
token_source:
{Pristine.Adapters.TokenSource.Refreshable,
inner_source: {Pristine.Adapters.TokenSource.File, path: token_path},
provider: provider,
context: oauth_context,
client_id: System.fetch_env!("OAUTH_CLIENT_ID"),
client_secret: System.fetch_env!("OAUTH_CLIENT_SECRET"),
refresh_skew_seconds: 60}
)
]
}
)Refreshable only refreshes when the token already carries real expiry data. It
does not invent expiry policy for providers that omit expires_at.
If your manifest already defines an OAuth2 security scheme, build the provider from that metadata instead of duplicating it in code:
provider = Pristine.OAuth2.Provider.from_manifest!(manifest, :notionOauth)Supported scheme extensions include:
x-pristine-flowto select a specific flow when the scheme defines more than onex-pristine-client-auth-methodfor:basic,:request_body, or:nonex-pristine-token-methodfor:postor:getx-pristine-token-content-typefor JSON vs form-encoded token/control requestsx-pristine-revocation-url,x-pristine-introspection-url, andx-pristine-default-scopes
OpenAPI Runtime Contract
Pristine also supports OpenAPI-generated schema refs directly at runtime. Endpoint request and response entries can point at:
- manifest-native string keys such as
"User" - direct type specs
- direct OpenAPI refs such as
{MySDK.User, :t}
Generated OpenAPI schema modules are expected to expose runtime helpers:
__schema__/1for validationdecode/1ordecode/2for materialization
When an SDK opts into typed_responses: true, successful responses are materialized through those helpers. Default runtime behavior stays compatibility-friendly: validated maps when schema refs are present, or raw decoded maps when the SDK chooses not to wire typed refs into the manifest. Broken direct refs now fail fast instead of silently skipping validation.
Streaming Support
Handle SSE streams with first-class support:
context = Pristine.context(
stream_transport: Pristine.Adapters.Transport.FinchStream,
# ... other config
)
{:ok, response} = Pristine.Core.Pipeline.execute_stream(
manifest, :stream_endpoint, payload, context
)
# Consume events lazily
response.stream
|> Stream.each(fn event ->
case Pristine.Streaming.Event.json(event) do
{:ok, data} -> process(data)
{:error, _} -> :skip
end
end)
|> Stream.run()Resilience Patterns
Configure retry policies in your manifest:
{
"retry_policies": {
"default": {
"max_attempts": 3,
"backoff": "exponential",
"base_delay_ms": 1000
}
},
"endpoints": [
{
"id": "important_call",
"retry": "default"
}
]
}Built-in support for:
- Exponential backoff with jitter
- Circuit breakers per endpoint
- Rate limiting with server-driven backoff
- Idempotency keys for safe retries
Documentation
- Getting Started — Installation and quick start
- Architecture — Hexagonal design overview
- Manifests — Complete manifest reference
- Ports & Adapters — Available adapters
- Code Generation — Customize generated code
- Streaming — SSE and streaming responses
- Pipeline — Request execution internals
Development
# Install dependencies
mix deps.get
# Run tests
mix test
# Type checking
mix dialyzer
# Linting
mix credo --strict
# Format code
mix format
# Run all checks
mix test && mix dialyzer && mix credo --strictDependencies
Pristine integrates with several companion libraries:
| Library | Purpose |
|---|---|
| Foundation | Retry, backoff, circuit breaker |
| Sinter | Schema validation |
| Finch | HTTP client |
| Jason | JSON encoding |
Contributing
Contributions are welcome! Please read our contributing guidelines and submit pull requests to the GitHub repository.
License
MIT License. See LICENSE for details.