Kemal JSON API Development
This skill provides expert guidance on building robust JSON APIs with Kemal, establishing the canonical patterns for JSON response helpers and strictly following patterns from kemal-by-example/json-api.
Version Notes
env.json and env.status response helpers (symbol or integer statuses): since Kemal 1.10.
- Malformed request bodies (e.g. invalid JSON) are answered with
400 Bad Request since Kemal 1.13.0; earlier releases return 500.
Core Mandates
Dependencies: Always require "json".
Built-in Context Response Helpers (Kemal 1.10+): Use Kemal's built-in env.json and status helpers for API responses:
# Basic JSON response (sets Content-Type to application/json; charset=utf-8 automatically)
get "/api/users" do |env|
env.json({users: %w[alice bob]})
end
# Symbol or integer HTTP status code with JSON:
post "/api/users" do |env|
env.status(:created).json({id: 1, name: "Alice"})
end
# Early halt with JSON:
get "/api/protected" do |env|
halt env.status(:unauthorized).json({error: "Unauthorized"}) unless authenticated?(env)
end
Project JsonResponse Helpers (Custom Pattern): When using standard project helpers for responses (found in JsonApi::JsonResponse):
module JsonApi
module JsonResponse
extend self
def json(env : HTTP::Server::Context, status : Int32, payload : String)
env.response.status_code = status
env.response.content_type = "application/json; charset=utf-8"
payload
end
def error(env : HTTP::Server::Context, status : Int32, message : String)
json(env, status, {"error" => message}.to_json)
end
end
end
Safe JSON Body Parsing: Use env.params.raw_body or read_json_object(env) to safely parse JSON bodies and handle exceptions:
private def read_json_object(env : HTTP::Server::Context) : Hash(String, JSON::Any)?
raw = env.params.raw_body
return nil if raw.strip.empty?
parsed = JSON.parse(raw)
parsed.as_h?
rescue JSON::ParseException
nil
end
Serialization: Models should provide a to_h method. Use model.to_h or model.to_h.to_json for responses.
Patterns from Source Code
API Routes with Full CRUD (json-api/src/routes/api.cr)
require "json"
require "../helpers/json_response"
require "../models/note"
private def read_json_object(env : HTTP::Server::Context) : Hash(String, JSON::Any)?
raw = env.params.raw_body
return nil if raw.strip.empty?
parsed = JSON.parse(raw)
parsed.as_h?
rescue JSON::ParseException
nil
end
private def note_id(env : HTTP::Server::Context) : Int64?
env.params.url["id"]?.try(&.to_i64?)
end
private def string_field(h : Hash(String, JSON::Any), key : String) : String?
h[key]?.try(&.as_s?)
end
get "/api/notes" do |env|
list = Note.all.map(&.to_h)
env.json({"notes" => list})
end
get "/api/notes/:id" do |env|
id = note_id(env)
unless id
halt env.status(:bad_request).json({"error" => "invalid id"})
end
note = Note.find(id)
if note
env.json(note.to_h)
else
env.status(:not_found).json({"error" => "note not found"})
end
end
post "/api/notes" do |env|
obj = read_json_object(env)
unless obj
halt env.status(:bad_request).json({"error" => "expected JSON object body"})
end
title = string_field(obj, "title").try(&.strip) || ""
if title.empty?
halt env.status(:unprocessable_entity).json({"error" => "title is required"})
end
body = string_field(obj, "body").try(&.strip) || ""
new_id = Note.create(title, body)
note = Note.find(new_id)
if note
env.status(:created).json(note.to_h)
else
env.status(:internal_server_error).json({"error" => "failed to load created note"})
end
end
delete "/api/notes/:id" do |env|
id = note_id(env)
unless id
halt env.status(:bad_request).json({"error" => "invalid id"})
end
note = Note.find(id)
unless note
halt env.status(:not_found).json({"error" => "note not found"})
end
note.delete
env.status(:no_content)
end
Best Practices
- Use Context Response Helpers: Leverage
env.json and env.status(...) for idiomatic, chainable response handling.
- Consistent Error Format: Always return a JSON object with an
error key for failure states.
- Safe Field Access: Use
obj[key]?.try(&.as_s?) or similar safe accessors for fields in parsed JSON hashes.
- Input Validation: Rigorously validate required fields and types before processing the request.
- Status Symbols: Use standard status symbols like
:ok, :created, :no_content, :bad_request, :unauthorized, :not_found, :unprocessable_entity.
When to Use
- When developing or refactoring API routes that return JSON.
- When handling JSON body parameters from client requests.
- When implementing standardized JSON error handling.
1---2name: kemal-json3description: Building JSON APIs with Kemal using built-in response helpers and established project patterns.4license: MIT5---67# Kemal JSON API Development89This skill provides expert guidance on building robust JSON APIs with Kemal, establishing the canonical patterns for JSON response helpers and strictly following patterns from [`kemal-by-example/json-api`](https://github.com/sdogruyol/kemal-by-example/tree/master/json-api).1011## Version Notes1213- `env.json` and `env.status` response helpers (symbol or integer statuses): since Kemal 1.10.14- Malformed request bodies (e.g. invalid JSON) are answered with `400 Bad Request` since Kemal 1.13.0; earlier releases return `500`.1516## Core Mandates1718- **Dependencies:** Always `require "json"`.19- **Built-in Context Response Helpers (Kemal 1.10+):** Use Kemal's built-in `env.json` and status helpers for API responses:2021 ```crystal22 # Basic JSON response (sets Content-Type to application/json; charset=utf-8 automatically)23 get "/api/users" do |env|24 env.json({users: %w[alice bob]})25 end2627 # Symbol or integer HTTP status code with JSON:28 post "/api/users" do |env|29 env.status(:created).json({id: 1, name: "Alice"})30 end3132 # Early halt with JSON:33 get "/api/protected" do |env|34 halt env.status(:unauthorized).json({error: "Unauthorized"}) unless authenticated?(env)35 end36 ```3738- **Project JsonResponse Helpers (Custom Pattern):** When using standard project helpers for responses (found in `JsonApi::JsonResponse`):3940 ```crystal41 module JsonApi42 module JsonResponse43 extend self4445 def json(env : HTTP::Server::Context, status : Int32, payload : String)46 env.response.status_code = status47 env.response.content_type = "application/json; charset=utf-8"48 payload49 end5051 def error(env : HTTP::Server::Context, status : Int32, message : String)52 json(env, status, {"error" => message}.to_json)53 end54 end55 end56 ```5758- **Safe JSON Body Parsing:** Use `env.params.raw_body` or `read_json_object(env)` to safely parse JSON bodies and handle exceptions:5960 ```crystal61 private def read_json_object(env : HTTP::Server::Context) : Hash(String, JSON::Any)?62 raw = env.params.raw_body63 return nil if raw.strip.empty?64 parsed = JSON.parse(raw)65 parsed.as_h?66 rescue JSON::ParseException67 nil68 end69 ```7071- **Serialization:** Models should provide a `to_h` method. Use `model.to_h` or `model.to_h.to_json` for responses.7273## Patterns from Source Code7475### API Routes with Full CRUD (json-api/src/routes/api.cr)7677```crystal78require "json"79require "../helpers/json_response"80require "../models/note"8182private def read_json_object(env : HTTP::Server::Context) : Hash(String, JSON::Any)?83 raw = env.params.raw_body84 return nil if raw.strip.empty?85 parsed = JSON.parse(raw)86 parsed.as_h?87rescue JSON::ParseException88 nil89end9091private def note_id(env : HTTP::Server::Context) : Int64?92 env.params.url["id"]?.try(&.to_i64?)93end9495private def string_field(h : Hash(String, JSON::Any), key : String) : String?96 h[key]?.try(&.as_s?)97end9899get "/api/notes" do |env|100 list = Note.all.map(&.to_h)101 env.json({"notes" => list})102end103104get "/api/notes/:id" do |env|105 id = note_id(env)106 unless id107 halt env.status(:bad_request).json({"error" => "invalid id"})108 end109110 note = Note.find(id)111 if note112 env.json(note.to_h)113 else114 env.status(:not_found).json({"error" => "note not found"})115 end116end117118post "/api/notes" do |env|119 obj = read_json_object(env)120 unless obj121 halt env.status(:bad_request).json({"error" => "expected JSON object body"})122 end123124 title = string_field(obj, "title").try(&.strip) || ""125 if title.empty?126 halt env.status(:unprocessable_entity).json({"error" => "title is required"})127 end128129 body = string_field(obj, "body").try(&.strip) || ""130 new_id = Note.create(title, body)131 note = Note.find(new_id)132133 if note134 env.status(:created).json(note.to_h)135 else136 env.status(:internal_server_error).json({"error" => "failed to load created note"})137 end138end139140delete "/api/notes/:id" do |env|141 id = note_id(env)142 unless id143 halt env.status(:bad_request).json({"error" => "invalid id"})144 end145146 note = Note.find(id)147 unless note148 halt env.status(:not_found).json({"error" => "note not found"})149 end150151 note.delete152 env.status(:no_content)153end154```155156## Best Practices157158- **Use Context Response Helpers:** Leverage `env.json` and `env.status(...)` for idiomatic, chainable response handling.159- **Consistent Error Format:** Always return a JSON object with an `error` key for failure states.160- **Safe Field Access:** Use `obj[key]?.try(&.as_s?)` or similar safe accessors for fields in parsed JSON hashes.161- **Input Validation:** Rigorously validate required fields and types before processing the request.162- **Status Symbols:** Use standard status symbols like `:ok`, `:created`, `:no_content`, `:bad_request`, `:unauthorized`, `:not_found`, `:unprocessable_entity`.163164## When to Use165166- When developing or refactoring API routes that return JSON.167- When handling JSON body parameters from client requests.168- When implementing standardized JSON error handling.