Controllers and routing#
A controller is a class that groups related routes, and a route is a method with an annotation saying which HTTP verb and path it handles. This page covers defining controllers, choosing their paths and matching requests to methods.
require "action-controller"
# Manages the comments on articles
class Comments < AC::Base
base "/comments"
# Lists the comments
@[AC::Route::GET("/")]
def index : Array(String)
["first!", "great article"]
end
# Returns a single comment
@[AC::Route::GET("/:id")]
def show(id : Int64) : String
"comment #{id}"
end
end
require "action-controller/server"
AC::Server.new.run
# GET /comments # => ["first!","great article"]
# GET /comments/42 # => "comment 42"
The id in the path is matched to the id argument by name and converted to an
Int64 before the method runs. A request for /comments/abc never reaches your code.
See parameters for how arguments are parsed.
That one annotated method is the route, its parameter parsing and validation, its OpenAPI operation and its MCP tool. The doc comment above it becomes the operation summary and the tool description.
Controllers#
Every controller inherits from AC::Base (an alias for ActionController::Base).
Most applications define an abstract base class for shared filters, responders and
error handlers, and inherit from it:
require "action-controller"
# Abstract classes don't generate routes
abstract class Application < AC::Base
# shared filters and error handlers go here
end
class Users < Application
base "/users"
@[AC::Route::GET("/")]
def index : Array(String)
["alice", "bob"]
end
end
Only concrete (non-abstract) classes generate routes. Filters, exception handlers and routes are all inherited, so a route defined on a parent is available on every concrete subclass at the subclass's base path.
Rules to keep in mind:
- An annotated method name must be unique within a class hierarchy. Redefining an annotated method that a parent already defines is a compile error.
- A route method can't accept a block. Only around filters take a block.
- Controller methods without an annotation are ordinary methods and aren't routable.
Base path#
base sets the path prefix for every route in the controller. If you leave it out,
it's derived from the class name, with :: becoming /:
| Class | Default base |
|---|---|
Users |
/users |
ExampleController |
/example_controller |
Api::V1::Users |
/api/v1/users |
class Welcome < AC::Base
# serve from the root of the site
base "/"
@[AC::Route::GET("/")]
def index : String
"Hello World"
end
end
The base can contain path parameters too. They're available to every route in the controller:
class Features < AC::Base
base "/photos/:photo_id/features"
# GET /photos/:photo_id/features
@[AC::Route::GET("/")]
def index(photo_id : Int64) : Array(String)
["faces", "landmarks"]
end
# GET /photos/:photo_id/features/:id
@[AC::Route::GET("/:id")]
def show(photo_id : Int64, id : Int64) : String
"feature #{id} of photo #{photo_id}"
end
end
HTTP verbs#
| Annotation | Handles |
|---|---|
@[AC::Route::GET(path)] |
GET, and HEAD automatically |
@[AC::Route::POST(path)] |
POST |
@[AC::Route::PUT(path)] |
PUT |
@[AC::Route::PATCH(path)] |
PATCH |
@[AC::Route::DELETE(path)] |
DELETE |
@[AC::Route::OPTIONS(path)] |
OPTIONS |
@[AC::Route::WebSocket(path)] |
WebSocket upgrades (a GET), see WebSockets |
Every GET route also answers HEAD requests. The method runs as normal, and the
status and headers are sent without a body.
class Articles < AC::Base
base "/articles"
@[AC::Route::GET("/")]
def index : Array(String)
["hello"]
end
@[AC::Route::POST("/")]
def create : String
"created"
end
@[AC::Route::PUT("/:id")]
def replace(id : Int64) : String
"replaced #{id}"
end
@[AC::Route::PATCH("/:id")]
def update(id : Int64) : String
"updated #{id}"
end
@[AC::Route::DELETE("/:id")]
def destroy(id : Int64)
head :no_content
end
end
Path patterns#
Paths are matched by LuckyRouter. A path is joined to the controller's base, and each segment can be:
| Segment | Meaning | Example path | Matches |
|---|---|---|---|
name |
literal text | /users |
/users |
:name |
required parameter | /users/:id |
/users/42 |
?:name |
optional parameter | /users/?:id |
/users and /users/42 |
*:name |
glob, captures the rest of the path including / |
/files/*:path |
/files/a/b/c.txt (path is "a/b/c.txt") |
class Files < AC::Base
base "/files"
# GET /files/docs/readme.md # => "docs/readme.md"
@[AC::Route::GET("/*:path")]
def show(path : String) : String
path
end
# GET /files/versions # => "latest"
# GET /files/versions/3 # => "3"
@[AC::Route::GET("/versions/?:version")]
def version(version : Int32? = nil) : String
version ? version.to_s : "latest"
end
end
A method argument receives the path parameter with the same name. An optional parameter's argument must be nilable or have a default. Path parameters take precedence over query parameters with the same name.
Paths without parameters match with or without a trailing /.
Routes must not overlap. Two routes for the same verb whose paths differ only in a
parameter's name, such as /photos/:id/features and /photos/:photo_id/features,
raise LuckyRouter::DuplicateRouteError when the server starts.
Multiple routes per method#
Stack annotations to serve several paths from one method:
class Groups < AC::Base
base "/"
@[AC::Route::GET("/users/:user_id/groups")]
@[AC::Route::GET("/groups")]
def index(user_id : Int64? = nil) : String
user_id ? "groups for user #{user_id}" : "all groups"
end
end
When the paths differ only by an optional segment, ?:name is simpler:
/users/?:user_id/groups matches both /users/groups and /users/5/groups. Stacked
annotations suit paths with different shapes, or routes that need different options
such as config:.
- Each annotation is a separate operation in the OpenAPI document.
- In MCP, a method is a single tool however many routes it has. The
tool uses the method's first
GETroute, the same one the path helper builds.
Route options#
Route annotations accept named options after the path. Each is covered in detail on the linked page.
| Option | Example | Purpose |
|---|---|---|
body: |
body: :comment |
Parse the request body into this argument. See parameters. |
status_code: |
status_code: HTTP::Status::CREATED |
The response status, 200 OK by default. See responses. |
status: |
status: {Error => HTTP::Status::NOT_FOUND} |
Map return types to status codes. See responses. |
content_type: |
content_type: "text/plain" |
Always respond with this content type, skipping Accept negotiation. See responses. |
config: |
config: {id: {base: 16}} |
Options for a parameter's converter. See parameters. |
converters: |
converters: {tags: ConvertTags} |
Use a custom converter for a parameter. See parameters. |
map: |
map: {per_page: :perPage} |
Read an argument from a differently named parameter. See parameters. |
response_type: |
response_type: Array(Comment) |
The response type used in the OpenAPI document, when it differs from the method's return type. |
execution_context: |
execution_context: "reports" |
Run the route on a named execution context. See execution contexts. |
Building paths to routes#
Every concrete controller gets a class method for each route method, named after the method. It builds the path, filling in path parameters from named arguments and adding any others as a query string:
Features.show(photo_id: 1, id: 2) # => "/photos/1/features/2"
Features.show(photo_id: 1, id: 2, full: true) # => "/photos/1/features/2?full=true"
Features.show(photo_id: 1) # raises AC::InvalidRoute (missing :id)
Arguments must be named. Combine the helper with redirect_to:
@[AC::Route::POST("/:photo_id/features")]
def create(photo_id : Int64) : Nil
redirect_to Features.show(photo_id: photo_id, id: 3), status: :see_other
end
If a method has several routes, the helper builds just one of them: the first GET
route. Without a GET, it's the first route in verb order (POST, PUT,
PATCH, DELETE, OPTIONS), not source order. Build paths to the others yourself.
Macro DSL#
Under the annotations is a lower-level macro DSL. It defines a route from a block, and
you read parameters from params and send the response with render yourself:
class Photos < AC::Base
base "/photos"
# GET /photos/:id/features
get "/:id/features", :features do
id = params["id"]
render json: ["faces in photo #{id}"]
end
# POST /photos/:id/feature
post "/:id/feature", :add_feature do
head :created
end
end
The second argument names the generated method, so filters can refer to it with
only: and except:. The macros are get, post, put, patch, delete,
options and ws.
Prefer annotations. DSL routes get no typed parameters or validation, and they don't appear in the OpenAPI document or as MCP tools.
Listing routes#
AC::Server.routes returns every route as {controller, method, verb, path} tuples,
and AC::Server.print_routes prints them as a table. The
application template wires this to a flag:
./bin/app --routes
See also#
- Parameters: types, converters, headers and request bodies
- Responses: status codes, responders and content types
- Filters: code that runs before, around and after routes
- Errors: turning exceptions into responses
- WebSockets
- OpenAPI and MCP