- LIKE wildcard injection: sanitize %, _ in search queries (devices, sites, gaiia) - Jason.decode! → Jason.decode with error handling for untrusted input - inspect() leak: replace with generic error messages, log details server-side - SSRF protection: URL validator blocks private IPs, localhost, non-HTTP schemes - SSRF validation added to HTTP monitoring executor and integration credentials - GraphQL complexity limits: always applied, not just in prod - GraphQL introspection: also check GET query params, not just body - Stripe webhook: explicit nil/empty checks for signature and body - Cookie security: secure flag for session (prod), http_only+secure for remember_me - Honeybadger API key: read from env var with fallback - String.to_integer → Integer.parse with fallback for URL params - String.to_atom → whitelist map for HTTP methods - Gaiia webhook: remove secret_len and expected signature from log - Admin API: add rate limiting pipeline - to_atom_keys: per-key fallback instead of all-or-nothing rescue
1725 lines
39 KiB
Markdown
1725 lines
39 KiB
Markdown
# Gleam Language Reference & Best Practices
|
|
|
|
> Practical reference for building production Gleam applications on the BEAM.
|
|
> Covers language fundamentals, OTP, web development, and real-world patterns.
|
|
|
|
---
|
|
|
|
## Table of Contents
|
|
|
|
1. [Language Fundamentals](#1-language-fundamentals)
|
|
2. [OTP from Gleam](#2-otp-from-gleam)
|
|
3. [Web Development](#3-web-development)
|
|
4. [Package Ecosystem](#4-package-ecosystem)
|
|
5. [Interop (Erlang, Elixir, JavaScript)](#5-interop)
|
|
6. [Testing](#6-testing)
|
|
7. [Project Structure](#7-project-structure)
|
|
8. [Real-World Patterns](#8-real-world-patterns)
|
|
|
|
---
|
|
|
|
## 1. Language Fundamentals
|
|
|
|
### The Type System
|
|
|
|
Gleam is statically typed with full type inference. No `null`, no exceptions, no implicit conversions.
|
|
|
|
#### Result Type
|
|
|
|
The core error handling primitive. Every fallible operation returns `Result(value, error)`.
|
|
|
|
```gleam
|
|
import gleam/result
|
|
import gleam/int
|
|
|
|
pub fn parse_age(input: String) -> Result(Int, String) {
|
|
case int.parse(input) {
|
|
Ok(age) if age >= 0 && age <= 150 -> Ok(age)
|
|
Ok(_) -> Error("Age out of range")
|
|
Error(_) -> Error("Not a number")
|
|
}
|
|
}
|
|
|
|
pub fn main() {
|
|
// Chain results with result.try (like flatMap/bind)
|
|
let output =
|
|
"25"
|
|
|> parse_age
|
|
|> result.map(fn(age) { age + 1 })
|
|
|
|
// Pattern match on results
|
|
case output {
|
|
Ok(age) -> echo age
|
|
Error(msg) -> echo msg
|
|
}
|
|
}
|
|
```
|
|
|
|
#### Option (gleam/option)
|
|
|
|
Gleam has no `null`. Optional values use `Option(a)` which is `Some(a) | None`.
|
|
|
|
```gleam
|
|
import gleam/option.{type Option, None, Some}
|
|
|
|
pub type User {
|
|
User(name: String, email: Option(String))
|
|
}
|
|
|
|
pub fn display_email(user: User) -> String {
|
|
case user.email {
|
|
Some(email) -> email
|
|
None -> "No email provided"
|
|
}
|
|
}
|
|
|
|
// option module has helpers
|
|
pub fn get_email_or_default(user: User) -> String {
|
|
user.email
|
|
|> option.unwrap("no-reply@example.com")
|
|
}
|
|
```
|
|
|
|
#### Custom Types (Algebraic Data Types)
|
|
|
|
Custom types are the backbone of Gleam data modeling. They're tagged unions (sum types) with named fields (product types).
|
|
|
|
```gleam
|
|
// Simple enum
|
|
pub type Color {
|
|
Red
|
|
Green
|
|
Blue
|
|
}
|
|
|
|
// Record with fields
|
|
pub type User {
|
|
User(id: Int, name: String, role: Role)
|
|
}
|
|
|
|
// Sum type (tagged union)
|
|
pub type Role {
|
|
Admin
|
|
Moderator(permissions: List(String))
|
|
Member
|
|
}
|
|
|
|
// Generic types
|
|
pub type Validated(a) {
|
|
Valid(value: a)
|
|
Invalid(errors: List(String))
|
|
}
|
|
|
|
// Opaque types - hide internals from other modules
|
|
pub opaque type Email {
|
|
Email(String)
|
|
}
|
|
|
|
pub fn new_email(value: String) -> Result(Email, String) {
|
|
case value {
|
|
_ if value == "" -> Error("Email cannot be empty")
|
|
_ -> Ok(Email(value))
|
|
}
|
|
}
|
|
|
|
pub fn to_string(email: Email) -> String {
|
|
let Email(value) = email
|
|
value
|
|
}
|
|
```
|
|
|
|
#### Record Updates
|
|
|
|
```gleam
|
|
pub type Config {
|
|
Config(host: String, port: Int, debug: Bool)
|
|
}
|
|
|
|
pub fn enable_debug(config: Config) -> Config {
|
|
Config(..config, debug: True)
|
|
}
|
|
```
|
|
|
|
### Pattern Matching
|
|
|
|
Pattern matching is pervasive in Gleam — it's the primary flow control mechanism.
|
|
|
|
```gleam
|
|
import gleam/int
|
|
import gleam/list
|
|
|
|
// Match on custom types
|
|
pub fn describe_role(role: Role) -> String {
|
|
case role {
|
|
Admin -> "Administrator"
|
|
Moderator(perms) -> "Mod with " <> int.to_string(list.length(perms)) <> " permissions"
|
|
Member -> "Regular member"
|
|
}
|
|
}
|
|
|
|
// Multiple subjects
|
|
pub fn classify(x: Int, y: Int) -> String {
|
|
case x, y {
|
|
0, 0 -> "Origin"
|
|
0, _ -> "Y axis"
|
|
_, 0 -> "X axis"
|
|
_, _ -> "Somewhere else"
|
|
}
|
|
}
|
|
|
|
// Guards
|
|
pub fn categorize_age(age: Int) -> String {
|
|
case age {
|
|
a if a < 0 -> "Invalid"
|
|
a if a < 13 -> "Child"
|
|
a if a < 18 -> "Teenager"
|
|
a if a < 65 -> "Adult"
|
|
_ -> "Senior"
|
|
}
|
|
}
|
|
|
|
// String prefix matching
|
|
pub fn parse_command(input: String) -> Result(String, String) {
|
|
case input {
|
|
"GET " <> path -> Ok(path)
|
|
"POST " <> path -> Ok(path)
|
|
_ -> Error("Unknown command")
|
|
}
|
|
}
|
|
|
|
// List patterns
|
|
pub fn first_two(items: List(a)) -> String {
|
|
case items {
|
|
[] -> "empty"
|
|
[_] -> "one item"
|
|
[_, _, ..] -> "two or more"
|
|
}
|
|
}
|
|
|
|
// Nested patterns
|
|
pub fn get_admin_name(user: User) -> Result(String, Nil) {
|
|
case user {
|
|
User(name:, role: Admin, ..) -> Ok(name)
|
|
_ -> Error(Nil)
|
|
}
|
|
}
|
|
|
|
// As patterns (bind a name to the whole matched value)
|
|
pub fn check(user: User) -> User {
|
|
case user {
|
|
User(role: Admin, ..) as admin -> {
|
|
echo "Admin found"
|
|
admin
|
|
}
|
|
other -> other
|
|
}
|
|
}
|
|
```
|
|
|
|
### Use Expressions
|
|
|
|
`use` is syntactic sugar for callback functions. It converts the rest of the function body into an anonymous function passed as the last argument.
|
|
|
|
```gleam
|
|
import gleam/result
|
|
import gleam/list
|
|
|
|
// WITHOUT use - nested callbacks
|
|
pub fn without_use(input: String) -> Result(String, String) {
|
|
result.try(parse_name(input), fn(name) {
|
|
result.try(validate_name(name), fn(valid_name) {
|
|
result.map(format_greeting(valid_name), fn(greeting) {
|
|
greeting
|
|
})
|
|
})
|
|
})
|
|
}
|
|
|
|
// WITH use - flat and readable
|
|
pub fn with_use(input: String) -> Result(String, String) {
|
|
use name <- result.try(parse_name(input))
|
|
use valid_name <- result.try(validate_name(name))
|
|
use greeting <- result.map(format_greeting(valid_name))
|
|
greeting
|
|
}
|
|
|
|
// use with list.map
|
|
pub fn double_all(numbers: List(Int)) -> List(Int) {
|
|
use n <- list.map(numbers)
|
|
n * 2
|
|
}
|
|
|
|
// use with list.filter
|
|
pub fn only_positive(numbers: List(Int)) -> List(Int) {
|
|
use n <- list.filter(numbers)
|
|
n > 0
|
|
}
|
|
|
|
// use for middleware (very common in web handlers)
|
|
pub fn handle(req: Request) -> Response {
|
|
use <- wisp.log_request(req)
|
|
use <- wisp.serve_static(req, under: "/static", from: "/public")
|
|
use req <- middleware(req)
|
|
route(req)
|
|
}
|
|
```
|
|
|
|
**How `use` works mechanically:**
|
|
|
|
```gleam
|
|
// This:
|
|
use x <- some_function(arg1, arg2)
|
|
do_something(x)
|
|
|
|
// Desugars to:
|
|
some_function(arg1, arg2, fn(x) {
|
|
do_something(x)
|
|
})
|
|
```
|
|
|
|
### Pipelines
|
|
|
|
The `|>` operator passes the left-hand value as the first argument to the right-hand function.
|
|
|
|
```gleam
|
|
import gleam/string
|
|
import gleam/list
|
|
import gleam/int
|
|
|
|
pub fn format_names(names: List(String)) -> String {
|
|
names
|
|
|> list.map(string.trim)
|
|
|> list.filter(fn(s) { s != "" })
|
|
|> list.sort(string.compare)
|
|
|> list.map(string.capitalise)
|
|
|> string.join(", ")
|
|
}
|
|
|
|
// Pipe to a different argument position with function capture
|
|
pub fn example() {
|
|
"world"
|
|
|> string.append("hello ", _) // Becomes string.append("hello ", "world")
|
|
}
|
|
|
|
// Debug in the middle of a pipeline
|
|
pub fn debug_pipeline(x: Int) -> Int {
|
|
x
|
|
|> int.multiply(2)
|
|
|> echo // prints the intermediate value, passes it through
|
|
|> int.add(1)
|
|
}
|
|
```
|
|
|
|
### Labelled Arguments
|
|
|
|
```gleam
|
|
pub fn create_user(
|
|
name name: String,
|
|
email email: String,
|
|
role role: Role,
|
|
) -> User {
|
|
User(id: 0, name:, role:)
|
|
}
|
|
|
|
// Call with labels (order doesn't matter for labelled args)
|
|
pub fn example() {
|
|
create_user(role: Admin, name: "Alice", email: "alice@example.com")
|
|
}
|
|
|
|
// Shorthand: when variable name matches label
|
|
pub fn example2() {
|
|
let name = "Bob"
|
|
let email = "bob@example.com"
|
|
let role = Member
|
|
create_user(name:, email:, role:)
|
|
}
|
|
```
|
|
|
|
### Generics
|
|
|
|
```gleam
|
|
// Generic function
|
|
pub fn first(pair: #(a, b)) -> a {
|
|
pair.0
|
|
}
|
|
|
|
// Generic custom type
|
|
pub type Stack(a) {
|
|
Stack(items: List(a))
|
|
}
|
|
|
|
pub fn push(stack: Stack(a), item: a) -> Stack(a) {
|
|
Stack(items: [item, ..stack.items])
|
|
}
|
|
|
|
pub fn pop(stack: Stack(a)) -> Result(#(a, Stack(a)), Nil) {
|
|
case stack.items {
|
|
[] -> Error(Nil)
|
|
[top, ..rest] -> Ok(#(top, Stack(items: rest)))
|
|
}
|
|
}
|
|
|
|
// Constrained generics don't exist in Gleam —
|
|
// use higher-order functions instead:
|
|
pub fn map_pair(pair: #(a, a), f: fn(a) -> b) -> #(b, b) {
|
|
#(f(pair.0), f(pair.1))
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 2. OTP from Gleam
|
|
|
|
Gleam runs on the BEAM and has first-class OTP support via `gleam_erlang` and `gleam_otp`.
|
|
|
|
```
|
|
gleam add gleam_otp gleam_erlang
|
|
```
|
|
|
|
### Processes and Subjects
|
|
|
|
A `Subject(msg)` is a type-safe process mailbox reference. It's Gleam's answer to untyped Erlang PIDs.
|
|
|
|
```gleam
|
|
import gleam/erlang/process.{type Subject}
|
|
|
|
pub fn spawn_logger() -> Subject(String) {
|
|
// process.new_subject() creates a subject for receiving messages
|
|
let subject = process.new_subject()
|
|
|
|
// Spawn a process
|
|
process.start(linked: True, running: fn() {
|
|
logger_loop(subject)
|
|
})
|
|
|
|
subject
|
|
}
|
|
|
|
fn logger_loop(subject: Subject(String)) -> Nil {
|
|
// Receive a message (blocks until one arrives)
|
|
let msg = process.receive(subject, within: 5000)
|
|
case msg {
|
|
Ok(text) -> {
|
|
echo text
|
|
logger_loop(subject)
|
|
}
|
|
Error(Nil) -> {
|
|
echo "Logger timed out, stopping"
|
|
Nil
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn main() {
|
|
let logger = spawn_logger()
|
|
process.send(logger, "Hello from main!")
|
|
process.send(logger, "Another message")
|
|
process.sleep(100)
|
|
}
|
|
```
|
|
|
|
### Actors (gleam_otp)
|
|
|
|
Actors are the recommended abstraction — they handle OTP system messages automatically.
|
|
|
|
```gleam
|
|
import gleam/erlang/process.{type Subject}
|
|
import gleam/otp/actor
|
|
|
|
// Define your message type
|
|
pub type CounterMsg {
|
|
Increment(by: Int)
|
|
Decrement(by: Int)
|
|
GetCount(reply_to: Subject(Int))
|
|
Reset
|
|
}
|
|
|
|
// State is just a plain type
|
|
pub type CounterState {
|
|
CounterState(count: Int, name: String)
|
|
}
|
|
|
|
// Start the actor
|
|
pub fn start(name: String) -> Result(Subject(CounterMsg), actor.StartError) {
|
|
let init_state = CounterState(count: 0, name:)
|
|
|
|
actor.new(init_state)
|
|
|> actor.on_message(handle_message)
|
|
|> actor.start
|
|
}
|
|
|
|
// Handle messages — return actor.Next to continue or actor.Stop to shut down
|
|
fn handle_message(
|
|
state: CounterState,
|
|
message: CounterMsg,
|
|
) -> actor.Next(CounterState, CounterMsg) {
|
|
case message {
|
|
Increment(by:) -> {
|
|
let new_state = CounterState(..state, count: state.count + by)
|
|
actor.continue(new_state)
|
|
}
|
|
|
|
Decrement(by:) -> {
|
|
let new_state = CounterState(..state, count: state.count - by)
|
|
actor.continue(new_state)
|
|
}
|
|
|
|
GetCount(reply_to:) -> {
|
|
actor.send(reply_to, state.count)
|
|
actor.continue(state)
|
|
}
|
|
|
|
Reset -> {
|
|
actor.continue(CounterState(..state, count: 0))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Client API — hide the message protocol behind functions
|
|
pub fn increment(counter: Subject(CounterMsg), by amount: Int) -> Nil {
|
|
actor.send(counter, Increment(by: amount))
|
|
}
|
|
|
|
pub fn get_count(counter: Subject(CounterMsg)) -> Int {
|
|
// actor.call sends a message and waits for a reply
|
|
actor.call(counter, GetCount, within: 1000)
|
|
}
|
|
|
|
pub fn main() {
|
|
let assert Ok(counter) = start("my_counter")
|
|
|
|
increment(counter, by: 5)
|
|
increment(counter, by: 3)
|
|
actor.send(counter, Decrement(by: 2))
|
|
|
|
let count = get_count(counter)
|
|
echo count // 6
|
|
}
|
|
```
|
|
|
|
### Supervisors
|
|
|
|
```gleam
|
|
import gleam/otp/static_supervisor
|
|
|
|
pub fn start_app() {
|
|
static_supervisor.new(static_supervisor.OneForOne)
|
|
|> static_supervisor.add(static_supervisor.worker_child(
|
|
id: "counter_1",
|
|
run: fn(_) { start("counter_1") },
|
|
))
|
|
|> static_supervisor.add(static_supervisor.worker_child(
|
|
id: "counter_2",
|
|
run: fn(_) { start("counter_2") },
|
|
))
|
|
|> static_supervisor.start
|
|
}
|
|
```
|
|
|
|
### Selectors (Listening to Multiple Subjects)
|
|
|
|
```gleam
|
|
import gleam/erlang/process.{type Selector, type Subject}
|
|
|
|
pub type Event {
|
|
UserMessage(String)
|
|
SystemAlert(String)
|
|
Tick
|
|
}
|
|
|
|
pub fn listen(
|
|
user_sub: Subject(String),
|
|
system_sub: Subject(String),
|
|
tick_sub: Subject(Nil),
|
|
) -> Event {
|
|
// Build a selector that maps different subjects to a unified Event type
|
|
let selector =
|
|
process.new_selector()
|
|
|> process.selecting(user_sub, UserMessage)
|
|
|> process.selecting(system_sub, SystemAlert)
|
|
|> process.selecting(tick_sub, fn(_) { Tick })
|
|
|
|
// Wait for any of them
|
|
let assert Ok(event) = process.select(selector, within: 5000)
|
|
event
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 3. Web Development
|
|
|
|
### Wisp Framework
|
|
|
|
Wisp is the standard web framework for Gleam. It wraps `gleam_http` types and provides middleware.
|
|
|
|
```
|
|
gleam add wisp mist gleam_http gleam_erlang
|
|
```
|
|
|
|
#### Basic App Structure
|
|
|
|
```gleam
|
|
// src/app.gleam — entry point
|
|
import gleam/erlang/process
|
|
import mist
|
|
import wisp
|
|
import wisp/wisp_mist
|
|
import app/router
|
|
import app/web.{type Context, Context}
|
|
|
|
pub fn main() {
|
|
wisp.configure_logger()
|
|
|
|
let secret_key_base = wisp.random_string(64)
|
|
let ctx = Context(
|
|
secret_key_base:,
|
|
db: Nil, // your DB connection goes here
|
|
)
|
|
|
|
let assert Ok(_) =
|
|
wisp_mist.handler(router.handle_request(_, ctx), secret_key_base)
|
|
|> mist.new
|
|
|> mist.port(8000)
|
|
|> mist.start_http
|
|
|
|
process.sleep_forever()
|
|
}
|
|
```
|
|
|
|
#### Context Type
|
|
|
|
```gleam
|
|
// src/app/web.gleam
|
|
pub type Context {
|
|
Context(secret_key_base: String, db: connection)
|
|
}
|
|
```
|
|
|
|
#### Routing
|
|
|
|
```gleam
|
|
// src/app/router.gleam
|
|
import wisp.{type Request, type Response}
|
|
import app/web.{type Context}
|
|
import app/handlers/user_handler
|
|
import app/handlers/health_handler
|
|
|
|
pub fn handle_request(req: Request, ctx: Context) -> Response {
|
|
// Apply global middleware
|
|
use req <- middleware(req, ctx)
|
|
|
|
// Route based on method + path segments
|
|
case wisp.path_segments(req) {
|
|
// GET /
|
|
[] -> wisp.ok() |> wisp.string_body("Welcome!")
|
|
|
|
// GET /health
|
|
["health"] -> health_handler.index(req)
|
|
|
|
// /users/...
|
|
["users", ..rest] -> user_routes(req, ctx, rest)
|
|
|
|
// 404
|
|
_ -> wisp.not_found()
|
|
}
|
|
}
|
|
|
|
fn user_routes(req: Request, ctx: Context, path: List(String)) -> Response {
|
|
case req.method, path {
|
|
// GET /users
|
|
http.Get, [] -> user_handler.list(req, ctx)
|
|
|
|
// POST /users
|
|
http.Post, [] -> user_handler.create(req, ctx)
|
|
|
|
// GET /users/:id
|
|
http.Get, [id] -> user_handler.show(req, ctx, id)
|
|
|
|
// PUT /users/:id
|
|
http.Put, [id] -> user_handler.update(req, ctx, id)
|
|
|
|
// DELETE /users/:id
|
|
http.Delete, [id] -> user_handler.delete(req, ctx, id)
|
|
|
|
// Method not allowed
|
|
_, _ -> wisp.method_not_allowed([http.Get, http.Post, http.Put, http.Delete])
|
|
}
|
|
}
|
|
|
|
fn middleware(req: Request, ctx: Context, handler: fn(Request) -> Response) -> Response {
|
|
let req = wisp.method_override(req)
|
|
use <- wisp.log_request(req)
|
|
use <- wisp.rescue_crashes
|
|
use req <- wisp.handle_head(req)
|
|
use <- wisp.serve_static(req, under: "/static", from: static_directory())
|
|
|
|
handler(req)
|
|
}
|
|
|
|
fn static_directory() -> String {
|
|
let assert Ok(priv) = wisp.priv_directory("app")
|
|
priv <> "/static"
|
|
}
|
|
```
|
|
|
|
#### Request Handling
|
|
|
|
```gleam
|
|
// src/app/handlers/user_handler.gleam
|
|
import gleam/http
|
|
import gleam/json
|
|
import gleam/dynamic/decode
|
|
import wisp.{type Request, type Response}
|
|
import app/web.{type Context}
|
|
|
|
pub fn create(req: Request, ctx: Context) -> Response {
|
|
// Read JSON body
|
|
use json_body <- wisp.require_json(req)
|
|
|
|
// Decode the JSON
|
|
let decoder =
|
|
decode.into({
|
|
use name <- decode.parameter
|
|
use email <- decode.parameter
|
|
#(name, email)
|
|
})
|
|
|> decode.field("name", decode.string)
|
|
|> decode.field("email", decode.string)
|
|
|
|
case decode.run(json_body, decoder) {
|
|
Ok(#(name, email)) -> {
|
|
// Do something with the data...
|
|
let response_json =
|
|
json.object([
|
|
#("name", json.string(name)),
|
|
#("email", json.string(email)),
|
|
#("id", json.int(1)),
|
|
])
|
|
|> json.to_string_tree
|
|
|
|
wisp.json_response(response_json, 201)
|
|
}
|
|
Error(_) -> wisp.unprocessable_entity()
|
|
}
|
|
}
|
|
|
|
pub fn list(_req: Request, _ctx: Context) -> Response {
|
|
let body =
|
|
json.array([
|
|
json.object([#("id", json.int(1)), #("name", json.string("Alice"))]),
|
|
json.object([#("id", json.int(2)), #("name", json.string("Bob"))]),
|
|
], of: fn(x) { x })
|
|
|> json.to_string_tree
|
|
|
|
wisp.json_response(body, 200)
|
|
}
|
|
```
|
|
|
|
### Lustre (Frontend Framework)
|
|
|
|
Lustre is Gleam's Elm-inspired frontend framework. Model-Update-View architecture.
|
|
|
|
```
|
|
gleam add lustre
|
|
gleam add --dev lustre_dev_tools
|
|
```
|
|
|
|
#### Counter App
|
|
|
|
```gleam
|
|
import gleam/int
|
|
import lustre
|
|
import lustre/attribute
|
|
import lustre/element.{text}
|
|
import lustre/element/html.{button, div, h1, p}
|
|
import lustre/event
|
|
|
|
// MODEL
|
|
pub type Model {
|
|
Model(count: Int, name: String)
|
|
}
|
|
|
|
fn init(_flags) -> Model {
|
|
Model(count: 0, name: "Counter")
|
|
}
|
|
|
|
// UPDATE
|
|
pub type Msg {
|
|
Increment
|
|
Decrement
|
|
Reset
|
|
SetName(String)
|
|
}
|
|
|
|
fn update(model: Model, msg: Msg) -> Model {
|
|
case msg {
|
|
Increment -> Model(..model, count: model.count + 1)
|
|
Decrement -> Model(..model, count: model.count - 1)
|
|
Reset -> Model(..model, count: 0)
|
|
SetName(name) -> Model(..model, name:)
|
|
}
|
|
}
|
|
|
|
// VIEW
|
|
fn view(model: Model) -> element.Element(Msg) {
|
|
div([], [
|
|
h1([], [text(model.name)]),
|
|
p([], [text("Count: " <> int.to_string(model.count))]),
|
|
button([event.on_click(Increment)], [text("+")]),
|
|
button([event.on_click(Decrement)], [text("-")]),
|
|
button([event.on_click(Reset)], [text("Reset")]),
|
|
html.input([
|
|
attribute.value(model.name),
|
|
event.on_input(SetName),
|
|
attribute.placeholder("Counter name"),
|
|
]),
|
|
])
|
|
}
|
|
|
|
// MAIN
|
|
pub fn main() {
|
|
let app = lustre.simple(init, update, view)
|
|
let assert Ok(_) = lustre.start(app, "#app", Nil)
|
|
Nil
|
|
}
|
|
```
|
|
|
|
#### Lustre with Effects
|
|
|
|
For apps that need side effects (HTTP requests, timers, etc.), use `lustre.application` instead of `lustre.simple`.
|
|
|
|
```gleam
|
|
import lustre
|
|
import lustre/effect.{type Effect}
|
|
|
|
pub fn main() {
|
|
let app = lustre.application(init, update, view)
|
|
let assert Ok(_) = lustre.start(app, "#app", Nil)
|
|
Nil
|
|
}
|
|
|
|
fn init(_flags) -> #(Model, Effect(Msg)) {
|
|
#(Model(loading: True, data: None), fetch_data())
|
|
}
|
|
|
|
fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
|
|
case msg {
|
|
DataLoaded(data) -> #(Model(loading: False, data: Some(data)), effect.none())
|
|
FetchFailed -> #(Model(loading: False, data: None), effect.none())
|
|
Refresh -> #(Model(..model, loading: True), fetch_data())
|
|
}
|
|
}
|
|
|
|
fn fetch_data() -> Effect(Msg) {
|
|
// Use lustre_http or similar packages for HTTP effects
|
|
effect.none() // placeholder
|
|
}
|
|
```
|
|
|
|
#### Lustre Server Components
|
|
|
|
Lustre components can run on the server and push updates to connected clients, similar to Phoenix LiveView.
|
|
|
|
```gleam
|
|
import lustre
|
|
import lustre/server_component
|
|
|
|
// Create a server component
|
|
pub fn app() {
|
|
lustre.component(init, update, view, on_attribute_change())
|
|
}
|
|
|
|
fn on_attribute_change() -> Dict(String, Decoder(Msg)) {
|
|
dict.new()
|
|
}
|
|
```
|
|
|
|
### Mist HTTP Server
|
|
|
|
Mist is the HTTP server that Wisp runs on top of. You can use it directly for lower-level control.
|
|
|
|
```gleam
|
|
import gleam/bytes_tree
|
|
import gleam/http/response
|
|
import gleam/erlang/process
|
|
import mist
|
|
|
|
pub fn main() {
|
|
let assert Ok(_) =
|
|
fn(_req) {
|
|
response.new(200)
|
|
|> response.set_body(mist.Bytes(
|
|
bytes_tree.from_string("Hello from Mist!"),
|
|
))
|
|
}
|
|
|> mist.new
|
|
|> mist.port(3000)
|
|
|> mist.start_http
|
|
|
|
process.sleep_forever()
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 4. Package Ecosystem
|
|
|
|
### Key Packages
|
|
|
|
| Package | Purpose | Install |
|
|
|---------|---------|---------|
|
|
| `gleam_stdlib` | Standard library (lists, strings, result, option, etc.) | Included by default |
|
|
| `gleam_json` | JSON encoding/decoding | `gleam add gleam_json` |
|
|
| `gleam_http` | HTTP types (Request, Response, Method) | `gleam add gleam_http` |
|
|
| `gleam_crypto` | Hashing, HMAC, secure random | `gleam add gleam_crypto` |
|
|
| `gleam_erlang` | Erlang interop, process, atoms | `gleam add gleam_erlang` |
|
|
| `gleam_otp` | OTP actors, supervisors | `gleam add gleam_otp` |
|
|
| `wisp` | Web framework | `gleam add wisp` |
|
|
| `mist` | HTTP server | `gleam add mist` |
|
|
| `lustre` | Frontend framework | `gleam add lustre` |
|
|
| `sqlight` | SQLite bindings | `gleam add sqlight` |
|
|
| `gleam_pgo` | PostgreSQL client (PGO) | `gleam add gleam_pgo` |
|
|
| `cake` | SQL query builder | `gleam add cake` |
|
|
| `envoy` | Environment variables | `gleam add envoy` |
|
|
| `argv` | CLI argument parsing | `gleam add argv` |
|
|
| `tom` | TOML parser | `gleam add tom` |
|
|
| `simplifile` | File system operations | `gleam add simplifile` |
|
|
| `gleeunit` | Test runner | `gleam add --dev gleeunit` |
|
|
|
|
### gleam_json
|
|
|
|
```gleam
|
|
import gleam/json
|
|
import gleam/dynamic/decode
|
|
|
|
// Encoding
|
|
pub fn encode_user(user: User) -> String {
|
|
json.object([
|
|
#("id", json.int(user.id)),
|
|
#("name", json.string(user.name)),
|
|
#("role", encode_role(user.role)),
|
|
])
|
|
|> json.to_string
|
|
}
|
|
|
|
fn encode_role(role: Role) -> json.Json {
|
|
case role {
|
|
Admin -> json.string("admin")
|
|
Moderator(perms) -> json.object([
|
|
#("type", json.string("moderator")),
|
|
#("permissions", json.array(perms, json.string)),
|
|
])
|
|
Member -> json.string("member")
|
|
}
|
|
}
|
|
|
|
// Decoding
|
|
pub fn decode_user(data: String) -> Result(User, json.DecodeError) {
|
|
let user_decoder =
|
|
decode.into({
|
|
use id <- decode.parameter
|
|
use name <- decode.parameter
|
|
use role <- decode.parameter
|
|
User(id:, name:, role:)
|
|
})
|
|
|> decode.field("id", decode.int)
|
|
|> decode.field("name", decode.string)
|
|
|> decode.field("role", role_decoder())
|
|
|
|
json.parse(data, user_decoder)
|
|
}
|
|
|
|
fn role_decoder() -> decode.Decoder(Role) {
|
|
decode.one_of(decode.string |> decode.then(fn(s) {
|
|
case s {
|
|
"admin" -> decode.into(Admin)
|
|
"member" -> decode.into(Member)
|
|
_ -> decode.fail("Role")
|
|
}
|
|
}), [
|
|
// Or decode the object form
|
|
decode.into({
|
|
use perms <- decode.parameter
|
|
Moderator(permissions: perms)
|
|
})
|
|
|> decode.field("permissions", decode.list(decode.string)),
|
|
])
|
|
}
|
|
```
|
|
|
|
### sqlight (SQLite)
|
|
|
|
```gleam
|
|
import sqlight
|
|
|
|
pub fn main() {
|
|
use conn <- sqlight.with_connection("mydb.sqlite")
|
|
|
|
// Create table
|
|
let assert Ok(_) =
|
|
sqlight.exec(
|
|
"CREATE TABLE IF NOT EXISTS users (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL,
|
|
email TEXT NOT NULL UNIQUE
|
|
)",
|
|
conn,
|
|
)
|
|
|
|
// Insert
|
|
let assert Ok(_) =
|
|
sqlight.query(
|
|
"INSERT INTO users (name, email) VALUES (?, ?)",
|
|
conn,
|
|
[sqlight.text("Alice"), sqlight.text("alice@example.com")],
|
|
dynamic.dynamic,
|
|
)
|
|
|
|
// Query
|
|
let assert Ok(rows) =
|
|
sqlight.query(
|
|
"SELECT id, name, email FROM users WHERE name = ?",
|
|
conn,
|
|
[sqlight.text("Alice")],
|
|
decode.into({
|
|
use id <- decode.parameter
|
|
use name <- decode.parameter
|
|
use email <- decode.parameter
|
|
#(id, name, email)
|
|
})
|
|
|> decode.field(0, decode.int)
|
|
|> decode.field(1, decode.string)
|
|
|> decode.field(2, decode.string),
|
|
)
|
|
|
|
echo rows
|
|
}
|
|
```
|
|
|
|
### Publishing to Hex
|
|
|
|
```toml
|
|
# gleam.toml
|
|
name = "my_package"
|
|
version = "1.0.0"
|
|
description = "A useful Gleam package"
|
|
licences = ["Apache-2.0"]
|
|
|
|
[repository]
|
|
type = "github"
|
|
user = "myuser"
|
|
repo = "my_package"
|
|
```
|
|
|
|
```bash
|
|
# Build and publish
|
|
gleam publish
|
|
|
|
# Retire a version (if you published something broken)
|
|
gleam hex retire my_package 1.0.0 security "Use 1.0.1 instead"
|
|
```
|
|
|
|
---
|
|
|
|
## 5. Interop
|
|
|
|
### Calling Erlang from Gleam (FFI)
|
|
|
|
Create an Erlang file alongside your Gleam module:
|
|
|
|
```gleam
|
|
// src/app/crypto_utils.gleam
|
|
|
|
// Declare external Erlang functions
|
|
@external(erlang, "crypto", "hash")
|
|
fn erlang_hash(algorithm: atom, data: BitArray) -> BitArray
|
|
|
|
// Wrap it in a Gleam-friendly API
|
|
import gleam/erlang/atom
|
|
|
|
pub fn sha256(data: BitArray) -> BitArray {
|
|
let assert Ok(algo) = atom.from_string("sha256")
|
|
erlang_hash(algo, data)
|
|
}
|
|
```
|
|
|
|
For custom Erlang code, create a `.erl` file in `src/`:
|
|
|
|
```erlang
|
|
%% src/my_ffi.erl
|
|
-module(my_ffi).
|
|
-export([system_time/0, format_timestamp/1]).
|
|
|
|
system_time() ->
|
|
erlang:system_time(millisecond).
|
|
|
|
format_timestamp(Ms) ->
|
|
calendar:system_time_to_rfc3339(Ms div 1000, [{unit, second}]).
|
|
```
|
|
|
|
```gleam
|
|
// src/app/time.gleam
|
|
|
|
@external(erlang, "my_ffi", "system_time")
|
|
pub fn system_time_ms() -> Int
|
|
|
|
@external(erlang, "my_ffi", "format_timestamp")
|
|
pub fn format_timestamp(ms: Int) -> String
|
|
```
|
|
|
|
### Calling Elixir from Gleam
|
|
|
|
Elixir modules are just Erlang modules with an `Elixir.` prefix:
|
|
|
|
```gleam
|
|
// Call Elixir's Jason library
|
|
@external(erlang, "Elixir.Jason", "encode!")
|
|
fn jason_encode(term: dynamic.Dynamic) -> String
|
|
|
|
// Call Elixir's Enum module
|
|
@external(erlang, "Elixir.Enum", "shuffle")
|
|
pub fn shuffle(list: List(a)) -> List(a)
|
|
```
|
|
|
|
**Important:** To use Elixir dependencies, add them to your `gleam.toml`:
|
|
|
|
```toml
|
|
[dependencies]
|
|
jason = ">= 1.4.0 and < 2.0.0" # Elixir/Erlang hex packages work
|
|
```
|
|
|
|
### JavaScript Target FFI
|
|
|
|
For the JavaScript target, create `.mjs` files:
|
|
|
|
```javascript
|
|
// src/app/dom_ffi.mjs
|
|
export function get_element(id) {
|
|
const el = document.getElementById(id);
|
|
if (el) return new Ok(el);
|
|
return new Error(undefined);
|
|
}
|
|
|
|
export function set_inner_html(element, html) {
|
|
element.innerHTML = html;
|
|
return undefined;
|
|
}
|
|
```
|
|
|
|
```gleam
|
|
// src/app/dom.gleam
|
|
|
|
@external(javascript, "./dom_ffi.mjs", "get_element")
|
|
pub fn get_element(id: String) -> Result(Dynamic, Nil)
|
|
|
|
@external(javascript, "./dom_ffi.mjs", "set_inner_html")
|
|
pub fn set_inner_html(element: Dynamic, html: String) -> Nil
|
|
```
|
|
|
|
### Target-Conditional Code
|
|
|
|
```gleam
|
|
// Provide different implementations per target
|
|
@external(erlang, "my_ffi", "monotonic_time")
|
|
@external(javascript, "./time_ffi.mjs", "monotonic_time")
|
|
pub fn monotonic_time() -> Int
|
|
```
|
|
|
|
### Erlang Target vs JavaScript Target
|
|
|
|
| Feature | Erlang (BEAM) | JavaScript |
|
|
|---------|---------------|------------|
|
|
| Concurrency | Full OTP (actors, supervisors) | Single-threaded (async) |
|
|
| Int size | Arbitrary precision | 64-bit float |
|
|
| FFI target | `.erl` files | `.mjs` files |
|
|
| Use cases | Servers, distributed systems | Browser apps, serverless |
|
|
| OTP support | ✅ Full | ❌ Not available |
|
|
| Package compat | Erlang + Elixir hex packages | npm packages via FFI |
|
|
| Build output | BEAM bytecode | JavaScript modules |
|
|
|
|
---
|
|
|
|
## 6. Testing
|
|
|
|
### gleeunit
|
|
|
|
Gleam's standard test runner. Any public function in `test/` ending in `_test` is run as a test.
|
|
|
|
```gleam
|
|
// test/app_test.gleam
|
|
import gleeunit
|
|
import gleeunit/should
|
|
import app/user
|
|
|
|
pub fn main() {
|
|
gleeunit.main()
|
|
}
|
|
|
|
pub fn parse_age_valid_test() {
|
|
user.parse_age("25")
|
|
|> should.be_ok
|
|
|> should.equal(25)
|
|
}
|
|
|
|
pub fn parse_age_negative_test() {
|
|
user.parse_age("-1")
|
|
|> should.be_error
|
|
|> should.equal("Age out of range")
|
|
}
|
|
|
|
pub fn parse_age_not_number_test() {
|
|
user.parse_age("abc")
|
|
|> should.be_error
|
|
|> should.equal("Not a number")
|
|
}
|
|
```
|
|
|
|
```bash
|
|
gleam test
|
|
```
|
|
|
|
### Custom Assertions
|
|
|
|
```gleam
|
|
// test/test_helpers.gleam
|
|
import gleam/string
|
|
|
|
pub fn should_contain(haystack: String, needle: String) -> String {
|
|
case string.contains(haystack, needle) {
|
|
True -> haystack
|
|
False ->
|
|
panic as {
|
|
"Expected \"" <> haystack <> "\" to contain \"" <> needle <> "\""
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn should_be_between(value: Int, low: Int, high: Int) -> Int {
|
|
case value >= low && value <= high {
|
|
True -> value
|
|
False ->
|
|
panic as "Expected value to be between bounds"
|
|
}
|
|
}
|
|
```
|
|
|
|
```gleam
|
|
// test/string_test.gleam
|
|
import test_helpers.{should_contain}
|
|
|
|
pub fn greeting_test() {
|
|
build_greeting("Alice")
|
|
|> should_contain("Alice")
|
|
|> should_contain("Hello")
|
|
}
|
|
```
|
|
|
|
### Testing Actors
|
|
|
|
```gleam
|
|
import gleam/erlang/process
|
|
|
|
pub fn counter_increment_test() {
|
|
let assert Ok(counter) = counter.start("test")
|
|
|
|
counter.increment(counter, by: 5)
|
|
counter.increment(counter, by: 3)
|
|
|
|
counter.get_count(counter)
|
|
|> should.equal(8)
|
|
}
|
|
```
|
|
|
|
### Testing Wisp Handlers
|
|
|
|
```gleam
|
|
import wisp/testing
|
|
|
|
pub fn health_check_test() {
|
|
let req = testing.get("/health", [])
|
|
let resp = router.handle_request(req, test_context())
|
|
|
|
resp.status
|
|
|> should.equal(200)
|
|
}
|
|
|
|
pub fn create_user_test() {
|
|
let body = "{\"name\": \"Alice\", \"email\": \"alice@test.com\"}"
|
|
let req = testing.post_json("/users", [], body)
|
|
let resp = router.handle_request(req, test_context())
|
|
|
|
resp.status
|
|
|> should.equal(201)
|
|
}
|
|
|
|
fn test_context() -> Context {
|
|
Context(secret_key_base: "test-secret", db: Nil)
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 7. Project Structure
|
|
|
|
### gleam.toml Configuration
|
|
|
|
```toml
|
|
name = "my_app"
|
|
version = "1.0.0"
|
|
target = "erlang" # or "javascript"
|
|
description = "My Gleam application"
|
|
licences = ["Apache-2.0"]
|
|
|
|
# OTP application config (for BEAM deployment)
|
|
[erlang]
|
|
application_start_module = "my_app/application"
|
|
extra_applications = ["crypto", "ssl"] # Erlang apps to start
|
|
|
|
[repository]
|
|
type = "github"
|
|
user = "myuser"
|
|
repo = "my_app"
|
|
|
|
[dependencies]
|
|
gleam_stdlib = ">= 0.34.0 and < 2.0.0"
|
|
gleam_erlang = ">= 0.25.0 and < 2.0.0"
|
|
gleam_otp = ">= 0.10.0 and < 2.0.0"
|
|
gleam_http = ">= 3.6.0 and < 4.0.0"
|
|
gleam_json = ">= 1.0.0 and < 3.0.0"
|
|
wisp = ">= 1.0.0 and < 3.0.0"
|
|
mist = ">= 1.0.0 and < 4.0.0"
|
|
|
|
[dev-dependencies]
|
|
gleeunit = ">= 1.0.0 and < 2.0.0"
|
|
```
|
|
|
|
### Module Organization
|
|
|
|
```
|
|
my_app/
|
|
├── gleam.toml
|
|
├── manifest.toml # Lock file (commit this)
|
|
├── src/
|
|
│ ├── my_app.gleam # Entry point (pub fn main)
|
|
│ ├── my_app/
|
|
│ │ ├── application.gleam # OTP application (supervisor tree)
|
|
│ │ ├── router.gleam # HTTP routing
|
|
│ │ ├── web.gleam # Context type, shared web helpers
|
|
│ │ ├── internal.gleam # Internal helpers (not public API)
|
|
│ │ ├── handlers/
|
|
│ │ │ ├── user_handler.gleam
|
|
│ │ │ └── health_handler.gleam
|
|
│ │ ├── models/
|
|
│ │ │ ├── user.gleam # User type + encoding/decoding
|
|
│ │ │ └── session.gleam
|
|
│ │ ├── services/
|
|
│ │ │ ├── auth.gleam # Business logic
|
|
│ │ │ └── email.gleam
|
|
│ │ └── db/
|
|
│ │ ├── repo.gleam # Database connection + queries
|
|
│ │ └── migrations.gleam
|
|
│ └── my_ffi.erl # Erlang FFI (if needed)
|
|
├── test/
|
|
│ ├── my_app_test.gleam
|
|
│ └── my_app/
|
|
│ ├── handlers/
|
|
│ │ └── user_handler_test.gleam
|
|
│ └── models/
|
|
│ └── user_test.gleam
|
|
└── priv/
|
|
└── static/ # Static files served by wisp
|
|
├── css/
|
|
└── js/
|
|
```
|
|
|
|
### Gleam vs Elixir: When to Use Each
|
|
|
|
| Dimension | Gleam | Elixir |
|
|
|-----------|-------|--------|
|
|
| **Type safety** | ✅ Full static types, no runtime type errors | ❌ Dynamic typing, runtime crashes possible |
|
|
| **Ecosystem maturity** | 🟡 Growing rapidly, some gaps | ✅ Mature, battle-tested (Phoenix, Ecto, LiveView) |
|
|
| **OTP support** | 🟡 Subset (type-safe actors, supervisors) | ✅ Full OTP (GenServer, GenStage, DynamicSupervisor) |
|
|
| **Metaprogramming** | ❌ No macros (by design) | ✅ Powerful macros |
|
|
| **Learning curve** | ✅ Small language, few concepts | 🟡 More features to learn |
|
|
| **Refactoring** | ✅ Compiler catches everything | 🟡 Tests + dialyzer help, but gaps exist |
|
|
| **Web (backend)** | 🟡 Wisp is solid but young | ✅ Phoenix is world-class |
|
|
| **Web (frontend)** | ✅ Lustre (Elm-like, universal components) | 🟡 LiveView (server-dependent) |
|
|
| **Database** | 🟡 sqlight, gleam_pgo, cake (basic) | ✅ Ecto (migrations, schemas, changesets) |
|
|
| **Interop** | ✅ Calls Erlang & Elixir directly | ✅ Calls Erlang directly |
|
|
| **JS target** | ✅ Compiles to JavaScript | ❌ BEAM only |
|
|
|
|
**Use Gleam when:**
|
|
- Type safety is a priority
|
|
- You want compile-time guarantees
|
|
- Building API services where Wisp's simplicity is sufficient
|
|
- Frontend + backend in one language (JavaScript target)
|
|
- Starting a new service that doesn't need Ecto/Phoenix ecosystem
|
|
|
|
**Use Elixir when:**
|
|
- You need the full Phoenix/LiveView stack
|
|
- Ecto's database abstractions are important
|
|
- You need macros or DSLs
|
|
- The library you need only exists in Elixir
|
|
- You're extending an existing Elixir codebase
|
|
|
|
**Use both:**
|
|
- Gleam and Elixir interop seamlessly on the BEAM
|
|
- Write type-safe core logic in Gleam, use Elixir for Phoenix/Ecto
|
|
- Gradually introduce Gleam into an Elixir project
|
|
|
|
---
|
|
|
|
## 8. Real-World Patterns
|
|
|
|
### Error Handling with Result Chains
|
|
|
|
```gleam
|
|
import gleam/result
|
|
|
|
pub type AppError {
|
|
NotFound(resource: String)
|
|
Unauthorized
|
|
ValidationError(field: String, message: String)
|
|
DatabaseError(detail: String)
|
|
}
|
|
|
|
// Chain operations that can fail
|
|
pub fn update_user_email(
|
|
db: Connection,
|
|
user_id: Int,
|
|
new_email: String,
|
|
) -> Result(User, AppError) {
|
|
use user <- result.try(find_user(db, user_id))
|
|
use validated_email <- result.try(validate_email(new_email))
|
|
use updated <- result.try(save_user(db, User(..user, email: validated_email)))
|
|
Ok(updated)
|
|
}
|
|
|
|
// Map errors between types
|
|
pub fn find_user(db: Connection, id: Int) -> Result(User, AppError) {
|
|
db_query(db, id)
|
|
|> result.map_error(fn(_) { NotFound("user") })
|
|
}
|
|
|
|
// Recover from errors
|
|
pub fn get_user_or_guest(db: Connection, id: Int) -> User {
|
|
find_user(db, id)
|
|
|> result.unwrap(guest_user())
|
|
}
|
|
|
|
// Convert AppError to HTTP responses
|
|
pub fn error_to_response(error: AppError) -> Response {
|
|
case error {
|
|
NotFound(resource) ->
|
|
wisp.not_found()
|
|
|> wisp.string_body(resource <> " not found")
|
|
Unauthorized ->
|
|
wisp.response(401)
|
|
|> wisp.string_body("Unauthorized")
|
|
ValidationError(field, message) ->
|
|
wisp.unprocessable_entity()
|
|
|> wisp.string_body(field <> ": " <> message)
|
|
DatabaseError(_) ->
|
|
wisp.internal_server_error()
|
|
}
|
|
}
|
|
```
|
|
|
|
### Configuration
|
|
|
|
```gleam
|
|
import envoy
|
|
import gleam/int
|
|
import gleam/result
|
|
|
|
pub type Config {
|
|
Config(
|
|
port: Int,
|
|
host: String,
|
|
database_url: String,
|
|
secret_key: String,
|
|
log_level: LogLevel,
|
|
)
|
|
}
|
|
|
|
pub type LogLevel {
|
|
Debug
|
|
Info
|
|
Warn
|
|
Error
|
|
}
|
|
|
|
pub fn load() -> Result(Config, String) {
|
|
use port <- result.try(
|
|
envoy.get("PORT")
|
|
|> result.then(int.parse)
|
|
|> result.replace_error("Invalid PORT"),
|
|
)
|
|
use host <- result.try(
|
|
envoy.get("HOST")
|
|
|> result.replace_error("Missing HOST"),
|
|
)
|
|
use database_url <- result.try(
|
|
envoy.get("DATABASE_URL")
|
|
|> result.replace_error("Missing DATABASE_URL"),
|
|
)
|
|
use secret_key <- result.try(
|
|
envoy.get("SECRET_KEY")
|
|
|> result.replace_error("Missing SECRET_KEY"),
|
|
)
|
|
let log_level =
|
|
envoy.get("LOG_LEVEL")
|
|
|> result.unwrap("info")
|
|
|> parse_log_level
|
|
|
|
Ok(Config(port:, host:, database_url:, secret_key:, log_level:))
|
|
}
|
|
|
|
fn parse_log_level(s: String) -> LogLevel {
|
|
case s {
|
|
"debug" -> Debug
|
|
"warn" -> Warn
|
|
"error" -> Error
|
|
_ -> Info
|
|
}
|
|
}
|
|
```
|
|
|
|
### Logging
|
|
|
|
```gleam
|
|
import gleam/io
|
|
import gleam/erlang
|
|
|
|
// Use wisp's built-in logging (wraps Erlang's logger)
|
|
import wisp
|
|
|
|
pub fn main() {
|
|
wisp.configure_logger()
|
|
wisp.log_info("Application starting")
|
|
wisp.log_warning("Something might be wrong")
|
|
wisp.log_error("Something went wrong!")
|
|
}
|
|
|
|
// Or use Erlang's logger directly via FFI
|
|
@external(erlang, "logger", "info")
|
|
pub fn log_info(message: String) -> Nil
|
|
```
|
|
|
|
### JSON API Design Pattern
|
|
|
|
```gleam
|
|
import gleam/json
|
|
import gleam/dynamic/decode
|
|
import gleam/http
|
|
import wisp.{type Request, type Response}
|
|
|
|
// Consistent API response wrapper
|
|
pub fn json_success(data: json.Json, status: Int) -> Response {
|
|
json.object([
|
|
#("ok", json.bool(True)),
|
|
#("data", data),
|
|
])
|
|
|> json.to_string_tree
|
|
|> wisp.json_response(status)
|
|
}
|
|
|
|
pub fn json_error(message: String, status: Int) -> Response {
|
|
json.object([
|
|
#("ok", json.bool(False)),
|
|
#("error", json.string(message)),
|
|
])
|
|
|> json.to_string_tree
|
|
|> wisp.json_response(status)
|
|
}
|
|
|
|
// Full CRUD handler example
|
|
pub fn handle(req: Request, ctx: Context, id: String) -> Response {
|
|
case req.method {
|
|
http.Get -> show(ctx, id)
|
|
http.Put -> {
|
|
use body <- wisp.require_json(req)
|
|
update(ctx, id, body)
|
|
}
|
|
http.Delete -> delete(ctx, id)
|
|
_ -> wisp.method_not_allowed([http.Get, http.Put, http.Delete])
|
|
}
|
|
}
|
|
|
|
fn show(ctx: Context, id: String) -> Response {
|
|
case find_item(ctx.db, id) {
|
|
Ok(item) -> json_success(encode_item(item), 200)
|
|
Error(NotFound(_)) -> json_error("Not found", 404)
|
|
Error(_) -> json_error("Internal error", 500)
|
|
}
|
|
}
|
|
|
|
fn update(ctx: Context, id: String, body: Dynamic) -> Response {
|
|
let decoder =
|
|
decode.into({
|
|
use name <- decode.parameter
|
|
use value <- decode.parameter
|
|
#(name, value)
|
|
})
|
|
|> decode.field("name", decode.string)
|
|
|> decode.field("value", decode.int)
|
|
|
|
case decode.run(body, decoder) {
|
|
Ok(#(name, value)) -> {
|
|
case save_item(ctx.db, id, name, value) {
|
|
Ok(item) -> json_success(encode_item(item), 200)
|
|
Error(e) -> error_to_response(e)
|
|
}
|
|
}
|
|
Error(_) -> json_error("Invalid request body", 422)
|
|
}
|
|
}
|
|
```
|
|
|
|
### Database Access Pattern
|
|
|
|
```gleam
|
|
import gleam/pgo
|
|
import gleam/dynamic/decode
|
|
|
|
pub type Repo {
|
|
Repo(db: pgo.Connection)
|
|
}
|
|
|
|
pub fn connect(database_url: String) -> Repo {
|
|
let config =
|
|
pgo.url_config(database_url)
|
|
|> pgo.pool_size(10)
|
|
|
|
let db = pgo.connect(config)
|
|
Repo(db:)
|
|
}
|
|
|
|
pub fn find_user_by_email(
|
|
repo: Repo,
|
|
email: String,
|
|
) -> Result(User, AppError) {
|
|
let query = "SELECT id, name, email, role FROM users WHERE email = $1"
|
|
|
|
let user_decoder =
|
|
decode.into({
|
|
use id <- decode.parameter
|
|
use name <- decode.parameter
|
|
use email <- decode.parameter
|
|
use role <- decode.parameter
|
|
User(id:, name:, email:, role: parse_role(role))
|
|
})
|
|
|> decode.field(0, decode.int)
|
|
|> decode.field(1, decode.string)
|
|
|> decode.field(2, decode.string)
|
|
|> decode.field(3, decode.string)
|
|
|
|
case pgo.execute(query, repo.db, [pgo.text(email)], user_decoder) {
|
|
Ok(pgo.Returned(_, [user])) -> Ok(user)
|
|
Ok(pgo.Returned(_, [])) -> Error(NotFound("user"))
|
|
Error(e) -> Error(DatabaseError(string.inspect(e)))
|
|
}
|
|
}
|
|
|
|
pub fn create_user(
|
|
repo: Repo,
|
|
name: String,
|
|
email: String,
|
|
role: String,
|
|
) -> Result(User, AppError) {
|
|
let query =
|
|
"INSERT INTO users (name, email, role) VALUES ($1, $2, $3)
|
|
RETURNING id, name, email, role"
|
|
|
|
let user_decoder =
|
|
decode.into({
|
|
use id <- decode.parameter
|
|
use name <- decode.parameter
|
|
use email <- decode.parameter
|
|
use role <- decode.parameter
|
|
User(id:, name:, email:, role: parse_role(role))
|
|
})
|
|
|> decode.field(0, decode.int)
|
|
|> decode.field(1, decode.string)
|
|
|> decode.field(2, decode.string)
|
|
|> decode.field(3, decode.string)
|
|
|
|
case pgo.execute(query, repo.db, [pgo.text(name), pgo.text(email), pgo.text(role)], user_decoder) {
|
|
Ok(pgo.Returned(_, [user])) -> Ok(user)
|
|
Error(e) -> Error(DatabaseError(string.inspect(e)))
|
|
}
|
|
}
|
|
|
|
fn parse_role(s: String) -> Role {
|
|
case s {
|
|
"admin" -> Admin
|
|
"moderator" -> Moderator(permissions: [])
|
|
_ -> Member
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Quick Reference
|
|
|
|
### Common Imports
|
|
|
|
```gleam
|
|
import gleam/io // println, debug
|
|
import gleam/int // Int operations
|
|
import gleam/float // Float operations
|
|
import gleam/string // String operations
|
|
import gleam/list // List operations
|
|
import gleam/dict.{type Dict} // Dict (hash map)
|
|
import gleam/option.{type Option, Some, None}
|
|
import gleam/result // Result helpers
|
|
import gleam/json // JSON encode/decode
|
|
import gleam/dynamic/decode // Dynamic value decoding
|
|
import gleam/http.{Get, Post, Put, Delete}
|
|
import gleam/erlang/process.{type Subject}
|
|
import gleam/otp/actor // OTP actors
|
|
```
|
|
|
|
### Common Operations Cheat Sheet
|
|
|
|
```gleam
|
|
// String interpolation (there isn't any — use <>)
|
|
"Hello " <> name <> "!"
|
|
|
|
// Convert to string
|
|
int.to_string(42)
|
|
float.to_string(3.14)
|
|
|
|
// Parse
|
|
int.parse("42") // Ok(42)
|
|
float.parse("3.14") // Ok(3.14)
|
|
|
|
// List operations
|
|
list.map(items, fn(x) { x + 1 })
|
|
list.filter(items, fn(x) { x > 0 })
|
|
list.fold(items, 0, fn(acc, x) { acc + x })
|
|
list.find(items, fn(x) { x.id == target_id })
|
|
|
|
// Dict operations
|
|
dict.new()
|
|
dict.insert(d, "key", "value")
|
|
dict.get(d, "key") // Result(value, Nil)
|
|
dict.from_list([#("a", 1), #("b", 2)])
|
|
|
|
// Tuples
|
|
let pair = #("hello", 42)
|
|
pair.0 // "hello"
|
|
pair.1 // 42
|
|
|
|
// Debug print any value
|
|
echo some_value
|
|
|
|
// Assert (crash if pattern doesn't match — use sparingly)
|
|
let assert Ok(value) = might_fail()
|
|
```
|
|
|
|
---
|
|
|
|
*Last updated: 2026-03-13. Based on Gleam ~1.x, gleam_otp 1.2.0, wisp 2.2.1, lustre 5.6.0.*
|