Higher-level modules for building web services, APIs, and concurrent applications.
Built-in HTTP server powered by Cowboy. Define routes and handlers in a Winn module.
module MyApp.Router
use Winn.Router
def routes()
[
{:get, "/", :index},
{:get, "/users", :list_users},
{:post, "/users", :create_user},
{:get, "/users/:id", :get_user}
]
end
def index(conn)
Server.json(conn, %{message: "Welcome to MyApp"})
end
def list_users(conn)
match Repo.all(User)
ok users => Server.json(conn, users)
err reason => Server.json(conn, %{error: reason}, 500)
end
end
def create_user(conn)
params = Server.body_params(conn)
match Repo.insert(User, params)
ok user => Server.json(conn, user, 201)
err reason => Server.json(conn, %{error: reason}, 422)
end
end
def get_user(conn)
id = Server.path_param(conn, "id")
match Repo.get(User, id)
ok user => Server.json(conn, user)
err :not_found => Server.json(conn, %{error: "not found"}, 404)
end
end
end
Server.start(MyApp.Router, 4000)
Returns {:ok, pid}. The server listens on the given port.
Server.stop()
Send a JSON response. Maps are automatically encoded. Default status is 200.
Server.json(conn, %{name: "Alice"})
Server.json(conn, %{error: "not found"}, 404)
Send a plain text response.
Server.text(conn, "OK")
Server.text(conn, "Created", 201)
Send a raw response with custom headers.
Read and JSON-decode the request body. Returns a map.
params = Server.body_params(conn)
name = Map.get(:name, params)
Extract a named path parameter. Routes use :name syntax for params.
# Route: {:get, "/users/:id", :get_user}
# Request: GET /users/42
id = Server.path_param(conn, "id")
# => "42"
Extract a query string parameter.
# Request: GET /search?q=hello
q = Server.query_param(conn, "q")
# => "hello"
Read a request header (lowercase name). Returns nil if not present.
auth = Server.header(conn, "authorization")
Set a response header. Applied when the response is sent.
conn = Server.set_header(conn, "x-request-id", UUID.v4())
Define middleware functions that run before every handler. Export middleware/0 from your router:
module Api
use Winn.Router
def middleware()
[:cors, :authenticate, :log_request]
end
def cors(conn, next)
conn = Server.set_header(conn, "access-control-allow-origin", "*")
next(conn)
end
def authenticate(conn, next)
match Server.header(conn, "authorization")
nil => Server.json(conn, %{error: "unauthorized"}, 401)
_token => next(conn)
end
end
def log_request(conn, next)
Logger.info("#{Server.method(conn)} #{Server.path(conn)}")
next(conn)
end
end
Each middleware takes (conn, next). Call next(conn) to continue to the next middleware or handler. Return a response directly to short-circuit.
Middleware executes in list order — first in the list is outermost. Routers without middleware/0 work unchanged.
Routes are matched top-to-bottom by HTTP method and path pattern:
- Literal segments match exactly:
/usersmatches/users - Parameter segments start with
:and capture the value:/users/:idmatches/users/42 - Unmatched requests automatically get a 404 JSON response
Make HTTP requests with automatic JSON encoding/decoding. Powered by hackney and jsone.
match HTTP.get("https://api.example.com/users")
ok resp => IO.inspect(resp.body)
err reason => IO.puts("request failed")
end
Map bodies are automatically JSON-encoded:
match HTTP.post("https://api.example.com/users", %{name: "Alice", email: "alice@example.com"})
ok resp => resp.body
err reason => {:error, reason}
end
Same pattern as get and post.
Low-level request. method is an atom (:get, :post, :put, :patch, :delete). body is a map (JSON-encoded), binary, or nil.
All HTTP functions return {:ok, response} or {:error, reason}.
The response is a map:
%{
status: 200, # HTTP status code (integer)
body: %{...}, # decoded JSON map, or raw binary
headers: %{...} # lowercase header names -> values
}
JSON responses (Content-Type containing "json") are automatically decoded into maps.
ETS-backed configuration system for application settings.
Get a config value. Returns nil if not found.
port = Config.get(:http, :port)
# => 4000 or nil
Get with a default:
port = Config.get(:http, :port, 3000)
Set a config value:
Config.put(:http, :port, 4000)
Bulk-load config from a nested map:
Config.load(%{
database: %{pool_size: 10, timeout: 5000},
http: %{port: 4000}
})
Run concurrent work without writing GenServer code.
Fire and forget — spawns a process, returns its pid.
Task.spawn() do ||
IO.puts("background work")
end
Spawn a task and wait for its result:
handle = Task.async() do ||
expensive_computation()
end
result = Task.await(handle)
Await with an explicit timeout. Returns {:error, :timeout} if the task doesn't complete in time.
result = Task.await(handle, 5000)
Parallel map — runs the function on each element concurrently, returns results in order:
results = Task.async_all([1, 2, 3]) do |id|
fetch_user(id)
end
# => [user1, user2, user3]
Pure Erlang HS256 JSON Web Token implementation. No external dependencies.
Sign a claims map and return a JWT token string:
token = JWT.sign(%{user_id: 42, role: :admin}, "my_secret")
# => "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjo0Mn0...."
Include an exp field (Unix timestamp) to create expiring tokens:
exp = DateTime.now() + 3600
token = JWT.sign(%{user_id: 42, exp: exp}, secret)
Verify a token's signature and check expiry. Returns {:ok, claims} or {:error, reason}.
match JWT.verify(token, secret)
ok claims => claims
err :expired => {:error, :token_expired}
err :invalid_signature => {:error, :unauthorized}
err _ => {:error, :invalid_token}
end
Possible errors: :invalid_signature, :expired, :invalid_token, :malformed_token.
- Signatures use HMAC-SHA256 via the OTP
cryptomodule - Signature comparison is constant-time to prevent timing attacks
- Expiry (
expclaim) is checked automatically during verification
WebSocket client powered by gun. Supports ws:// and wss://.
Open a WebSocket connection:
match WS.connect("wss://api.example.com/ws")
ok conn => conn
err reason => IO.puts("connection failed")
end
Send a message. Maps are automatically JSON-encoded:
WS.send(conn, %{type: :subscribe, channel: "prices"})
WS.send(conn, "plain text message")
Receive the next message. Default timeout is 5 seconds.
match WS.recv(conn)
ok msg => IO.inspect(msg)
err :timeout => IO.puts("no message")
err :closed => IO.puts("disconnected")
end
Close the connection:
WS.close(conn)
Define a WebSocket handler module with use Winn.WebSocket:
module MyApp.WsHandler
use Winn.WebSocket
def on_connect(conn)
{:ok, %{conn: conn}}
end
def on_message(msg, state)
IO.inspect(msg)
{:ok, state}
end
def on_close(state)
:ok
end
end
module UserService
def create_user(params)
# Validate
token = UUID.v4()
Logger.info("creating user", %{token: token})
# Save to DB
match Repo.insert(User, Map.put(:token, token, params))
ok user =>
jwt = JWT.sign(%{user_id: user.id}, System.get_env("JWT_SECRET"))
{:ok, %{user: user, token: jwt}}
err reason =>
Logger.error("user creation failed", %{reason: reason})
{:error, reason}
end
end
def fetch_external_profile(url)
match HTTP.get(url)
ok resp =>
if resp.status == 200
{:ok, resp.body}
else
{:error, resp.status}
end
err reason =>
{:error, reason}
end
end
def notify_all(user_ids)
Task.async_all(user_ids) do |id|
HTTP.post("https://notify.example.com/send", %{user_id: id})
end
end
end