# Spider-Gazelle > A fast, type-safe Crystal web framework with built-in OpenAPI and MCP support Spider-Gazelle is a fast, type-safe web framework for Crystal (built on the action-controller shard). Annotated controller methods are the routes, the parameter validation, the OpenAPI operations and the MCP tools, all generated from one source of truth. # Getting started # Spider-Gazelle A fast, type-safe web framework for [Crystal](https://crystal-lang.org). You write ordinary, documented methods; Spider-Gazelle turns them into HTTP routes, validated parameters, an [OpenAPI](https://spider-gazelle.net/openapi/index.md) description and [MCP](https://spider-gazelle.net/mcp/index.md) tools for AI agents, all from that one source. ## One method, four jobs ``` class Rooms < AC::Base base "/rooms" # Books a meeting room @[AC::Route::POST("/:id/bookings", body: :booking, status_code: HTTP::Status::CREATED)] def book( @[AC::Param::Info(description: "the room to book", example: "lvl3-boardroom")] id : String, booking : Booking, ) : Booking Room.find!(id).book(booking) end end ``` From that method you get: | | What you get | Driven by | | -------------- | ------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- | | **HTTP route** | `POST /rooms/:id/bookings`, returning `201 Created` | the route annotation | | **Validation** | `id` and the JSON body are parsed and type checked before your code runs. Bad input gets a 4xx with a helpful error | argument types | | **OpenAPI** | an operation with a summary, a described path parameter, and request and response schemas | the doc comment, `Param::Info` and types | | **MCP tool** | `rooms_book`, which an AI agent can call with the same validation and the same filters | all of the above | Why this matters Hand-written API docs and agent tool definitions drift from the code, and drifted docs are worse than none. Spider-Gazelle derives them from the code itself. Rename a parameter, change a type or update a comment, and the OpenAPI document and MCP tools update with it. There's one source of truth to review and test, and no second copy to forget. ## Highlights - **Type-safe routing.** Parameters arrive as method arguments, already converted to `Int32`, `UUID`, `Time`, enums or your own types. There's no `params["id"]` boilerplate. - **Self documenting.** [OpenAPI 3](https://spider-gazelle.net/openapi/index.md) is generated from your comments and types. Generate API clients in any language from it. - **AI ready.** A built-in [MCP server](https://spider-gazelle.net/mcp/index.md) exposes your API to Claude, VS Code, Cursor and other agents. It includes progressive tool discovery, prompts, and OAuth sign-in. - **Content negotiation.** Request bodies are parsed by `Content-Type` and responses rendered by `Accept`. JSON is the default; add YAML, XML or your own formats. - **Fast.** It's compiled Crystal, with [LuckyRouter](https://github.com/luckyframework/lucky_router) route matching and optional multi-core execution contexts. - **Simple to test.** The spec helper drives your app in-process, with no server to start. - **You're in control.** There's no hidden magic in your project: you own the entry point, the CLI, the configuration and the server lifecycle. ## Choose your path - **New to Spider-Gazelle?** Start with [Your first app](https://spider-gazelle.net/getting_started/index.md), then work through the [Guides](https://spider-gazelle.net/guides/index.md) in order. - **Experienced Crystal developer?** Jump to [Routing](https://spider-gazelle.net/guides/routing/index.md), [Parameters](https://spider-gazelle.net/guides/parameters/index.md), [OpenAPI](https://spider-gazelle.net/openapi/index.md) and [MCP](https://spider-gazelle.net/mcp/index.md). The [API reference](https://spider-gazelle.net/Config/environment/index.md) has every macro and type. - **Building with an AI agent?** Give your agent the [agent reference](https://spider-gazelle.net/ai/index.md), or point it at [`/llms-full.txt`](https://spider-gazelle.net/llms-full.txt) for the whole site in one file. ## Built by [Place Technology](https://place.technology/), a team in Sydney and Brisbane, Australia. Spider-Gazelle powers the [PlaceOS](https://github.com/PlaceOS) smart building platform. The framework is developed on [GitHub](https://github.com/spider-gazelle). ## Example apps - [Spider-Gazelle template](https://github.com/spider-gazelle/spider-gazelle): the starting point for new apps, with OpenAPI, MCP and Docker set up. - [PlaceOS REST API](https://github.com/PlaceOS/rest-api): a large production API, with its [OpenAPI document](https://editor.swagger.io/?url=https://raw.githubusercontent.com/PlaceOS/rest-api/master/OPENAPI_DOC.yml) and an MCP server with OAuth sign-in. - [PlaceOS Staff API](https://github.com/PlaceOS/staff-api), with its [OpenAPI document](https://editor.swagger.io/?url=https://raw.githubusercontent.com/PlaceOS/staff-api/master/OPENAPI_DOC.yml). - [PlaceOS Core](https://github.com/PlaceOS/core). - [Apple/Google Wallet abstraction](https://github.com/PlaceOS/wallet). # Your first app This page takes you from an empty folder to a running Spider-Gazelle API. Start here if you're new to Spider-Gazelle, or new to Crystal. Spider-Gazelle doesn't depend on anything beyond [Crystal](https://crystal-lang.org) and its package manager, [Shards](https://crystal-lang.org/reference/the_shards_command/index.html). There are two ways to start: - **From the application template** (recommended). You get a project with logging, sessions, error handling, specs, a Dockerfile, CI, OpenAPI docs and an MCP server already wired up. - **From scratch.** You add the `action-controller` shard to a new Crystal project. This is useful for learning, or for adding an HTTP API to an existing project. ## Install Crystal Follow the [Crystal installation guide](https://crystal-lang.org/install/) for your operating system, then check it worked: ``` crystal --version shards --version ``` ## Start from the template The [spider-gazelle template](https://github.com/spider-gazelle/spider-gazelle) is a complete, working application. Clone it into a folder named after your project, and give it a fresh git history: ``` git clone https://github.com/spider-gazelle/spider-gazelle.git my_app cd my_app rm -rf .git && git init shards install crystal run src/app.cr ``` The app compiles and starts listening: ``` Launching Spider-Gazelle v2.0.0 Listening on http://127.0.0.1:3000 ``` Open and you'll see `"You're being trampled by Spider-Gazelle!"`. Try too, which returns `{"result":42}`. Note Crystal is a compiled language, so `crystal run` compiles your app before it starts. The first build takes a little longer, later builds are cached. ### Project layout | Path | Purpose | | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | `src/app.cr` | Entry point. Parses the [command line options](https://spider-gazelle.net/getting_started/configuration/#command-line-options) and starts the server. | | `src/config.cr` | Requires your code and configures logging, middleware, sessions and the MCP server. | | `src/constants.cr` | App name, version and settings read from [environment variables](https://spider-gazelle.net/getting_started/configuration/#environment-variables). | | `src/controllers/application.cr` | `App::Base`, the abstract controller every other controller inherits from. Holds shared filters, responders and error handlers. | | `src/controllers/welcome.cr` | An example controller. | | `src/models/` | Your models. | | `spec/` | Your [specs](https://spider-gazelle.net/guides/testing/index.md). | | `www/` | Static files, served when no route matches. | | `Dockerfile` | Builds a small production image, see [Deployment](https://spider-gazelle.net/deployment/index.md). | | `.github/workflows/ci.yml` | Checks formatting and runs the specs on every push. | Change the app name in `src/constants.cr` (`NAME = "Spider-Gazelle"`) and the `name` in `shard.yml`. Before you deploy, set your own session secret, see [Configuration](https://spider-gazelle.net/getting_started/configuration/#sessions). ## Start from scratch Create a new Crystal app and add `action-controller` to its `shard.yml`: ``` crystal init app my_app cd my_app ``` ``` dependencies: action-controller: github: spider-gazelle/action-controller version: ~> 8.3 ``` Run `shards install`, then replace `src/my_app.cr` with: ``` require "action-controller" # Says hello class Hello < AC::Base base "/" # Returns a friendly greeting @[AC::Route::GET("/")] def index : String "Hello, World!" end end # The server must be required after your controllers require "action-controller/server" server = AC::Server.new(3000, "127.0.0.1") server.run { puts "Listening on #{server.print_addresses}" } ``` Start it with `crystal run src/my_app.cr` and request the route: ``` $ curl http://localhost:3000/ "Hello, World!" ``` The response is `"Hello, World!"` with quotes because responses are JSON by default. The client's `Accept` header selects the format, see [Responses](https://spider-gazelle.net/guides/responses/index.md). `AC` is an alias for `ActionController`, so `AC::Base` and `ActionController::Base` are the same class. ## Your first route A controller is a class that inherits from `AC::Base`, and a route is a method with a route annotation. Add a second route to the `Hello` controller: ``` # Greets someone by name, e.g. GET /greet/Steve?shout=true @[AC::Route::GET("/greet/:name")] def greet(name : String, shout : Bool = false) : String greeting = "Hello, #{name}!" shout ? greeting.upcase : greeting end ``` ``` $ curl "http://localhost:3000/greet/Steve?shout=true" "HELLO, STEVE!" ``` There's no `params["name"]` boilerplate here: - `name` matches the `:name` segment in the route path. - `shout` isn't in the path, so it's read from the query string. It has a default, so it's optional. A `Bool` is `true` when the value is `true` (in any case) and `false` otherwise. - Values are converted to the argument type. If a value can't be converted, for example `abc` for an `Int32` argument, Spider-Gazelle responds with an error instead of calling your method. That one annotated method is also your documentation. The doc comment, argument types and return type become the [OpenAPI](https://spider-gazelle.net/openapi/index.md) operation and the [MCP](https://spider-gazelle.net/mcp/index.md) tool description, so your API docs and LLM tools can't drift from the code. From here, read the guides: - [Routing](https://spider-gazelle.net/guides/routing/index.md): controllers, base paths, HTTP verbs and the route DSL. - [Parameters](https://spider-gazelle.net/guides/parameters/index.md): type conversion, `@[AC::Param::Info]` and request bodies. - [Responses](https://spider-gazelle.net/guides/responses/index.md): response formats, status codes and headers. - [Filters](https://spider-gazelle.net/guides/filters/index.md): run code before, around or after your routes. - [Errors](https://spider-gazelle.net/guides/errors/index.md): turn exceptions into consistent error responses. ## Run the specs The template ships with specs that use Crystal's built-in [spec library](https://crystal-lang.org/reference/guides/testing.html): ``` crystal spec ``` See [Testing](https://spider-gazelle.net/guides/testing/index.md) to write your own. ## Reload while you develop Crystal doesn't reload code at runtime. To rebuild and restart whenever a file changes, use a file watcher such as [watchexec](https://github.com/watchexec/watchexec) or [nodemon](https://nodemon.io/): ``` watchexec -r -e cr -- crystal run src/app.cr # or nodemon --watch src -e cr --exec crystal run src/app.cr ``` ## Build a binary For production, compile an optimised binary. With the template, `shards build` uses the `app` target in `shard.yml` and writes `bin/app`: ``` shards build --production --release ./bin/app --help ``` The binary has options for the port, host, worker count, routes listing, health checks and generating the OpenAPI and MCP description files. See [Configuration](https://spider-gazelle.net/getting_started/configuration/#command-line-options). For containers, see [Deployment](https://spider-gazelle.net/deployment/index.md). ## See also - [Configuration](https://spider-gazelle.net/getting_started/configuration/index.md): environment variables, `config.cr` and command line options - [Routing](https://spider-gazelle.net/guides/routing/index.md) - [Testing](https://spider-gazelle.net/guides/testing/index.md) - [Deployment](https://spider-gazelle.net/deployment/index.md) - [OpenAPI](https://spider-gazelle.net/openapi/index.md) and [MCP](https://spider-gazelle.net/mcp/index.md) # Configuration This page covers how a Spider-Gazelle app is configured: environment variables, `src/config.cr` and the command line options of the compiled binary. It follows the layout of the [application template](https://github.com/spider-gazelle/spider-gazelle), which most apps start from. ## Quick example Every setting has a default, so the app runs without any configuration. In production, set the environment and a session secret, and bind to all interfaces: ``` export SG_ENV=production export COOKIE_SESSION_SECRET="$(openssl rand -hex 16)" ./bin/app -b 0.0.0.0 -p 8080 -w 4 ``` ## How the template is organised The template splits configuration over three files. Each has one job: | File | Job | | ------------------ | ------------------------------------------------------------------------------------------------------- | | `src/constants.cr` | Reads environment variables into constants such as `App::DEFAULT_PORT`. Has no side effects. | | `src/app.cr` | Parses the command line, then requires `config.cr` and starts the server. | | `src/config.cr` | Requires your controllers and models, then configures logging, middleware, the MCP server and sessions. | `app.cr` parses the command line **before** it requires `config.cr`. This means options such as `--routes` and `--help` exit before any database connections or other start-up work in `config.cr` happens. Specs require `config.cr` directly, so your tests get the same configuration without starting a server. ## Environment variables These are read in `src/constants.cr`. Command line options take precedence over the port, host and worker count. | Variable | Default | Purpose | | ----------------------- | --------------------- | ------------------------------------------------------------------------------------------------- | | `SG_ENV` | `development` | Set to `production` for production behaviour, see below. | | `SG_SERVER_HOST` | `127.0.0.1` | Address to bind. Use `0.0.0.0` in containers. | | `SG_SERVER_PORT` | `3000` | Port to bind. | | `SG_WORKER_COUNT` | `1` | Number of threads handling requests, see [Workers and threads](#workers-and-threads). | | `PUBLIC_WWW_PATH` | `./www` | Folder of static files. Ignored if the folder doesn't exist. | | `COOKIE_SESSION_KEY` | `_spider_gazelle_` | Name of the session cookie. | | `COOKIE_SESSION_SECRET` | a fixed example value | Secret that encrypts and signs the session cookie. Must be at least 32 bytes. | | `SG_MCP_PATH` | `/mcp` | Path of the [MCP](https://spider-gazelle.net/mcp/index.md) endpoint. An empty string disables it. | | `SG_MCP_DESCRIPTION` | `mcp.yml` | Location of the generated MCP tool descriptions. | Warning The default `COOKIE_SESSION_SECRET` is in the public template, so anyone can forge your session cookies with it. Always set your own secret in production. ### Production mode When `SG_ENV=production`, `App.running_in_production?` returns `true` and the template: - logs at `info` for your app and action-controller, and `warn` for everything else (instead of `debug` and `info`), see [Logging](https://spider-gazelle.net/guides/logging/index.md) - uses the production error handler, which doesn't send exception details or backtraces to the client - marks the session cookie `Secure`, so browsers only send it over HTTPS Use `App.running_in_production?` in your own code for anything else that differs between environments. ## config.cr This is the template's `src/config.cr`, section by section. ### Requires ``` require "action-controller" require "./constants" require "./controllers/application" require "./controllers/*" require "./models/*" # Server required after application controllers require "action-controller/server" # Exposes the application routes to LLM clients require "action-controller/mcp" ``` `action-controller/server` must be required **after** your controllers. Routes are collected at compile time, and the server only sees controllers defined before it is required. ### Logging ``` if running_in_production? log_level = ::Log::Severity::Info ::Log.setup "*", :warn, LOG_BACKEND else log_level = ::Log::Severity::Debug ::Log.setup "*", :info, LOG_BACKEND end ::Log.builder.bind "action-controller.*", log_level, LOG_BACKEND ::Log.builder.bind "#{NAME}.*", log_level, LOG_BACKEND ``` See [Logging](https://spider-gazelle.net/guides/logging/index.md) for log sources, request IDs, JSON output and changing the level at runtime. ### Middleware ``` filter_params = ["password", "bearer_token"] keeps_headers = ["X-Request-ID"] ActionController::Server.before( ActionController::ErrorHandler.new(running_in_production?, keeps_headers), ActionController::LogHandler.new(filter_params), HTTP::CompressHandler.new ) ``` `ActionController::Server.before` adds standard Crystal [HTTP handlers](https://crystal-lang.org/api/latest/HTTP/Handler.html) that run, in order, before your routes. `Server.after` adds handlers that only run when no route matches. Handlers must be added before the server is created. - `ErrorHandler.new(production, persist_headers)` turns unhandled exceptions into `500` responses. In development it renders a detailed exception page. Headers named in `persist_headers` are kept on the error response, so clients still get their `X-Request-ID`. - `LogHandler.new(filter)` logs every response. Query string params named in `filter` are logged as `[FILTERED]`. - `HTTP::CompressHandler` gzips or deflates responses when the client supports it. ### Static files ``` if File.directory?(STATIC_FILE_PATH) ::MIME.register(".yaml", "text/yaml") ActionController::Server.before( ::HTTP::StaticFileHandler.new(STATIC_FILE_PATH, directory_listing: false) ) end ``` Files in `./www` (or `PUBLIC_WWW_PATH`) are served when a request doesn't match a route. ### MCP server ``` ActionController::MCPServer.tap do |mcp| mcp.server_name = NAME mcp.server_version = VERSION mcp.description_path = ENV["SG_MCP_DESCRIPTION"]? || "mcp.yml" end ``` `app.cr` mounts the MCP server at `SG_MCP_PATH`. The options, including authentication, are covered in the [MCP guide](https://spider-gazelle.net/mcp/index.md). ### Sessions ``` ActionController::Session.configure do |settings| settings.key = COOKIE_SESSION_KEY settings.secret = COOKIE_SESSION_SECRET # HTTPS only: settings.secure = running_in_production? end ``` `key` and `secret` have no defaults in action-controller, so they must be set before a session is used. See [Sessions and cookies](https://spider-gazelle.net/guides/sessions/index.md) for every option. ## Command line options These are defined in the template's `src/app.cr`. Run `./bin/app --help` to list them. | Option | Description | | ----------------------------- | --------------------------------------------------------------------------------------------------------- | | `-b HOST`, `--bind=HOST` | Address to bind. Default `SG_SERVER_HOST`, else `127.0.0.1`. | | `-p PORT`, `--port=PORT` | Port to bind. Default `SG_SERVER_PORT`, else `3000`. | | `-w COUNT`, `--workers=COUNT` | Number of threads handling requests. Default `SG_WORKER_COUNT`, else `1`. `0` or less uses the CPU count. | | `-r`, `--routes` | Prints the routes, then exits. | | `-v`, `--version` | Prints the app name and version, then exits. | | `-c URL`, `--curl=URL` | Requests `URL` as a health check, then exits. See below. | | `-d`, `--docs` | Prints the [OpenAPI](https://spider-gazelle.net/openapi/index.md) document as YAML, then exits. | | `-f FILE`, `--file=FILE` | With `--docs`, writes the document to `FILE` instead. Must come **after** `--docs`. | | `--mcp=FILE` | Writes the [MCP](https://spider-gazelle.net/mcp/index.md) tool descriptions to `FILE`, then exits. | | `-h`, `--help` | Prints the options, then exits. | ### Listing routes ``` $ ./bin/app --routes Controller#Action Verb URI Pattern App::Welcome#index get / App::Welcome#api get /api/:example App::Welcome#api get /api/other/route App::Welcome#api post /api/:example App::Welcome#openapi get /openapi ``` ### Generating OpenAPI and MCP descriptions ``` ./bin/app --docs --file=openapi.yml ./bin/app --mcp=mcp.yml ``` Both read the doc comments in your source code by running `crystal docs`. So they must be run from the project root, on a machine with `crystal` installed. That's why the template's [Dockerfile](https://spider-gazelle.net/deployment/index.md) generates both files during the build stage and copies them into the final image. Note `--file` is only registered once `--docs` has been parsed. `--docs --file=x.yml` works, `--file=x.yml --docs` doesn't. It's also why `--file` isn't listed by `--help`. ### Health checks ``` ./bin/app -c http://127.0.0.1:3000/ ``` `-c` requests the URL using Crystal's built-in HTTP client, so you don't need `curl` in your container image. It exits with: | Exit code | Meaning | | --------- | ----------------------------------------------------------- | | `0` | The response status was between 200 and 499. | | `1` | Any other status, for example a 5xx. | | `2` | The request failed, for example the connection was refused. | A 4xx counts as healthy because the server answered. Point it at a route that doesn't need authentication. See [Deployment](https://spider-gazelle.net/deployment/#health-checks). ### Workers and threads `-w` (or `SG_WORKER_COUNT`) scales your app across CPU cores with threads. A single process runs your requests on several threads: ``` server.threads(thread_count) ``` `threads` resizes Crystal's default execution context to `count` threads. `0` or less uses the CPU count, and `1` (the default) keeps the app single threaded. Threads share memory: a cache, or the in-memory sessions of the MCP server, is shared by every request. Any state you change from requests, such as class variables or mutable constants, must be protected with a `Mutex` (or held in a database or Redis). Processes instead of threads The template also includes a commented-out `server.cluster(thread_count, "-w", "--workers")`, which starts separate processes sharing the port instead. Forking is deprecated in Crystal, so prefer threads. With processes nothing is shared, so caches and MCP sessions exist once per process. `threads` resizes Crystal's default [execution context](https://crystal-lang.org/reference/latest/guides/concurrency.html). Check the Crystal documentation for the compiler flags your Crystal version needs for multi-threading. ### Signals - `SIGTERM`, `SIGINT` (Ctrl+C) and `SIGHUP` close the server gracefully. - `SIGUSR1` toggles `trace` logging for your app's logs, see [Logging](https://spider-gazelle.net/guides/logging/#changing-the-log-level-at-runtime). ## Server options If you don't use the template, create and run the server yourself: ``` require "action-controller" # ... require your controllers ... require "action-controller/server" server = ActionController::Server.new(port: 3000, host: "0.0.0.0") server.run { puts "Listening on #{server.print_addresses}" } ``` To serve HTTPS directly, pass an `OpenSSL::SSL::Context::Server` as the first argument: `ActionController::Server.new(ssl_context, 3443, "0.0.0.0")`. Most deployments terminate TLS at a load balancer or reverse proxy instead. ## See also - [Your first app](https://spider-gazelle.net/getting_started/index.md) - [Logging](https://spider-gazelle.net/guides/logging/index.md) - [Sessions and cookies](https://spider-gazelle.net/guides/sessions/index.md) - [Deployment](https://spider-gazelle.net/deployment/index.md) - [OpenAPI](https://spider-gazelle.net/openapi/index.md) and [MCP](https://spider-gazelle.net/mcp/index.md) # Guides # Guides The guides explain each part of a Spider-Gazelle app. If you're new, read them in order. Each builds on the last. If you're experienced, jump to the topic you need. | Guide | You'll learn | | --------------------------------------------------------------------------- | ------------------------------------------------------------------- | | [Controllers & routing](https://spider-gazelle.net/guides/routing/index.md) | defining routes with annotations, paths, verbs and redirect helpers | | [Parameters](https://spider-gazelle.net/guides/parameters/index.md) | typed parameters, converters, headers and request bodies | | [Responses](https://spider-gazelle.net/guides/responses/index.md) | responders, status codes, rendering, files and headers | | [Filters](https://spider-gazelle.net/guides/filters/index.md) | running code before, around and after actions | | [Error handling](https://spider-gazelle.net/guides/errors/index.md) | turning exceptions into helpful responses | | [Sessions & cookies](https://spider-gazelle.net/guides/sessions/index.md) | storing small amounts of state between requests | | [WebSockets](https://spider-gazelle.net/guides/websockets/index.md) | realtime connections | | [Logging](https://spider-gazelle.net/guides/logging/index.md) | structured logs, request IDs and changing log levels at runtime | | [Testing](https://spider-gazelle.net/guides/testing/index.md) | testing routes, controllers and your MCP server | Everything you write here feeds OpenAPI and MCP The doc comments, `@[AC::Param::Info]` annotations, argument types and return types you'll meet in these guides also generate your [OpenAPI](https://spider-gazelle.net/openapi/index.md) document and [MCP tools](https://spider-gazelle.net/mcp/index.md). Writing a good route is writing good documentation. # 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](https://spider-gazelle.net/guides/parameters/index.md) for how arguments are parsed. That one annotated method is the route, its parameter parsing and validation, its [OpenAPI](https://spider-gazelle.net/openapi/index.md) operation and its [MCP](https://spider-gazelle.net/mcp/index.md) 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](https://spider-gazelle.net/guides/websockets/index.md) | 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](https://github.com/luckyframework/lucky_router). 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](https://spider-gazelle.net/mcp/index.md), a method is a single tool however many routes it has. The tool uses the method's first `GET` route, the same one the [path helper](#building-paths-to-routes) 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](https://spider-gazelle.net/guides/parameters/#request-bodies). | | `status_code:` | `status_code: HTTP::Status::CREATED` | The response status, `200 OK` by default. See [responses](https://spider-gazelle.net/guides/responses/#status-codes). | | `status:` | `status: {Error => HTTP::Status::NOT_FOUND}` | Map return types to status codes. See [responses](https://spider-gazelle.net/guides/responses/#status-codes). | | `content_type:` | `content_type: "text/plain"` | Always respond with this content type, skipping `Accept` negotiation. See [responses](https://spider-gazelle.net/guides/responses/#fixed-content-types). | | `config:` | `config: {id: {base: 16}}` | Options for a parameter's converter. See [parameters](https://spider-gazelle.net/guides/parameters/#converter-options). | | `converters:` | `converters: {tags: ConvertTags}` | Use a custom converter for a parameter. See [parameters](https://spider-gazelle.net/guides/parameters/#custom-converters). | | `map:` | `map: {per_page: :perPage}` | Read an argument from a differently named parameter. See [parameters](https://spider-gazelle.net/guides/parameters/#renaming-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](https://github.com/spider-gazelle/action-controller/blob/master/CONTEXTS.md). | ## 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](https://spider-gazelle.net/getting_started/index.md) wires this to a flag: ``` ./bin/app --routes ``` ## See also - [Parameters](https://spider-gazelle.net/guides/parameters/index.md): types, converters, headers and request bodies - [Responses](https://spider-gazelle.net/guides/responses/index.md): status codes, responders and content types - [Filters](https://spider-gazelle.net/guides/filters/index.md): code that runs before, around and after routes - [Errors](https://spider-gazelle.net/guides/errors/index.md): turning exceptions into responses - [WebSockets](https://spider-gazelle.net/guides/websockets/index.md) - [OpenAPI](https://spider-gazelle.net/openapi/index.md) and [MCP](https://spider-gazelle.net/mcp/index.md) # Parameters Route methods declare the values they need as typed arguments. Spider-Gazelle finds each value in the path, query string, form data or headers, converts it to the argument's type and rejects the request if it can't. This page covers where values come from, which types are supported, how to customise parsing and how to read request bodies. ``` require "action-controller" class Search < AC::Base base "/search" # Searches the catalogue @[AC::Route::GET("/:category")] def index( category : String, @[AC::Param::Info(description: "the text to search for", example: "crystal")] q : String, @[AC::Param::Info(description: "results per page")] limit : Int32 = 20, in_stock : Bool? = nil, ) : String "#{category}: #{q}, limit #{limit}, in stock #{in_stock.inspect}" end end require "action-controller/server" AC::Server.new.run # GET /search/books?q=crystal # => "books: crystal, limit 20, in stock nil" # GET /search/books?q=crystal&limit=5&in_stock=true # # => "books: crystal, limit 5, in stock true" # GET /search/books # => AC::Route::Param::MissingError (q is required) # GET /search/books?q=crystal&limit=many # => AC::Route::Param::ValueError (limit isn't an Int32) ``` There's no `params["limit"].to_i` boilerplate. The method signature describes the request, and the same signature, with the `@[AC::Param::Info]` descriptions and examples, becomes the parameters in the [OpenAPI](https://spider-gazelle.net/openapi/descriptions/#parameters) document and the input schema of the [MCP](https://spider-gazelle.net/mcp/index.md) tool. ## Where values come from Each argument is looked up by its name: 1. **Path:** a `:name`, `?:name` or `*:name` segment in the route. 1. **Query string:** `?name=value`. 1. **Form data:** fields of an `application/x-www-form-urlencoded` or `multipart/form-data` request body. Path parameters take precedence over query parameters, which take precedence over form fields. If a name appears more than once, the first value is used. Two kinds of argument are read from elsewhere: - an argument named in the route's `body:` option is parsed from the [request body](#request-bodies); - an argument annotated with `@[AC::Param::Info(header: "...")]` is read from a [request header](#header-parameters). ## Required and optional Whether a parameter is required comes from its type and default, never from an annotation: | Argument | Required | When missing | | ---------------------- | -------- | --------------------------------------- | | `limit : Int32` | yes | raises `AC::Route::Param::MissingError` | | `limit : Int32 = 20` | no | `20` | | `limit : Int32? = nil` | no | `nil` | | `limit : Int32?` | no | `nil` | A value that's present but can't be converted raises `AC::Route::Param::ValueError`. For a nilable argument without strict parsing, an unparsable value becomes `nil` instead. Both errors carry `parameter` (the name) and `restriction` (the expected type). Spider-Gazelle doesn't turn them into responses for you: without an [exception handler](https://spider-gazelle.net/guides/errors/#handling-parameter-errors) they become a `500 Internal Server Error`. The [application template](https://spider-gazelle.net/getting_started/index.md) includes handlers that respond with `422` and `400`. Defaults must match the type exactly Crystal doesn't widen integer literals in arguments, so an `Int64` argument needs an `Int64` default: `id : Int64 = 0_i64`, not `id : Int64 = 0`. The same applies to `UInt32` (`0_u32`), `Float32` (`0.0_f32`) and so on. ## Supported types | Type | Accepts | Notes | | ---------------------------------------- | ------------------------------------- | -------------------------------------------------------------------------------------- | | `String` | anything | | | `Char` | the first character | | | `Bool` | `true`, case-insensitive | Any other value is `false`, never an error. | | `Int8` to `Int128`, `UInt8` to `UInt128` | integers | | | `Float32`, `Float64` | decimals | | | `BigInt`, `BigDecimal`, `BigFloat` | big numbers | | | `Time` | ISO 8601, e.g. `2024-01-02T03:04:05Z` | Use `config:` for other formats. | | `UUID` | UUID strings | | | any `Enum` | a member name or its integer value | Names ignore case and underscores: `dark_green`, `DarkGreen` and `DARKGREEN` all work. | | a union, e.g. \`Int64 | String\` | any member type | | no type | anything | Treated as `String?`. | Any other type needs a [custom converter](#custom-converters). That includes arrays: `Array(String)` has no built-in converter. Union members are tried in Crystal's internal union order, which isn't necessarily the order you wrote. `String | Int64` tries `Int64` first, so `"12"` becomes an `Int64`, and `Float64 | Int64` always produces a `Float64`. Avoid unions whose members can parse the same value. ``` class Lookup < AC::Base base "/lookup" enum Colour Red Green Blue end # GET /lookup/42 # => "Int64" # GET /lookup/alice # => "String" @[AC::Route::GET("/:id")] def show(id : Int64 | String) : String id.class.to_s end # GET /lookup/paint/green # => "Green" # GET /lookup/paint/2 # => "Blue" @[AC::Route::GET("/paint/:colour")] def paint(colour : Colour) : String colour.to_s end end ``` ## Converter options The `config:` route option passes options to a parameter's converter, keyed by argument name: ``` class Options < AC::Base base "/options" # GET /options/FF?since=2024-01-02%20%2B10:00 # => [255, "2024-01-02 00:00:00 +10:00"] @[AC::Route::GET("/:id", config: { id: {base: 16}, since: {format: "%F %:z"}, })] def show(id : Int32, since : Time? = nil) : Tuple(Int32, String) {id, since.to_s} end end ``` | Type | Option | Default | Effect | | -------------------- | ----------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------- | | integers | `base` | `10` | Number base, e.g. `16` for hex. | | | `underscore` | `false` | Allow `1_000`. | | | `prefix` | `false` | Allow `0x`, `0b` and `0o` prefixes. | | | `whitespace` | `true` | Allow surrounding whitespace. | | | `strict` | `true` | Reject trailing characters. | | | `leading_zero_is_octal` | `false` | Treat `017` as octal. | | `Float32`, `Float64` | `whitespace` | `true` | Allow surrounding whitespace. | | | `strict` | `true` | `strict: false` reads `4.5abc` as `4.5`. | | `BigInt` | `base` | `10` | Number base. | | `Bool` | `true_string` | `"true"` | The value that means `true`. | | `Time` | `format` | ISO 8601 | A [`Time::Format`](https://crystal-lang.org/api/latest/Time/Format.html) pattern, parsed as UTC unless it includes a zone. | | `UUID` | `variant`, `version` | any | Require a `UUID::Variant` or `UUID::Version`. | | enums | `from_value` | `false` | Only accept the integer value. | | | `strict` | `false` | Raise `ValueError` for an unparsable value instead of resolving to `nil`. | When `config:` is given, only the first non-nil type of a union is used. The `strict: true` enum option matters for nilable enums. Without it, an unknown value is silently ignored: ``` class Paint < AC::Base base "/paint" enum Colour Red Green end # GET /paint?colour=red # => "Red" # GET /paint # => "" # GET /paint?colour=cyan # => AC::Route::Param::ValueError @[AC::Route::GET("/", config: {colour: {strict: true}})] def index(colour : Colour? = nil) : String colour.to_s end end ``` Options can also be set on the argument with `@[AC::Param::Info(config: {...})]`. If both are present, the route's `config:` wins, so a method with several routes can parse the same argument differently on each: ``` class Hex < AC::Base base "/hex" # GET /hex/255 # => 255 # GET /hex/x/FF # => 255 @[AC::Route::GET("/:id")] @[AC::Route::GET("/x/:id", config: {id: {base: 16}})] def show(id : UInt64) : UInt64 id end end ``` ## Describing parameters with `@[AC::Param::Info]` `@[AC::Param::Info]` annotates a single argument. All of its keys are optional: | Key | Purpose | | -------------- | ------------------------------------------------------------------- | | `description:` | Describes the parameter in OpenAPI and MCP. | | `example:` | An example value, as a string, for OpenAPI and MCP. | | `name:` | The request parameter name, when it differs from the argument name. | | `header:` | Read the value from this request header instead. | | `class:` | A custom converter for this argument. | | `config:` | Options for the converter. | There's no `required:` key. Make an argument optional by giving it a default or a nilable type. `@[AC::Param::Converter]` is the same annotation under another name. Use whichever reads better. ``` class Articles < AC::Base base "/articles" # Lists articles, newest first @[AC::Route::GET("/")] def index( @[AC::Param::Info(description: "filter by tag", example: "crystal")] tag : String? = nil, @[AC::Param::Info(description: "filter by author", example: "jake")] author : String? = nil, @[AC::Param::Info(description: "maximum number of articles", example: "20")] limit : UInt32 = 20_u32, @[AC::Param::Info(description: "number of articles to skip", example: "0")] offset : UInt32 = 0_u32, ) : Array(String) [] of String end end ``` ## Renaming parameters Request parameters don't always make good Crystal names. Rename one with `name:` on the argument, or with the route's `map:` option (argument name to parameter name): ``` class Pages < AC::Base base "/pages" # GET /pages?perPage=50&q=docs @[AC::Route::GET("/", map: {per_page: :perPage})] def index( per_page : Int32 = 10, @[AC::Param::Info(name: "q")] query : String? = nil, ) : String "#{per_page} results for #{query}" end end ``` The original argument name is no longer read: `?per_page=50` is ignored above. ## Header parameters Set `header:` to read an argument from a request header. Required-ness, defaults and conversion work as they do for other parameters, and the header appears in the OpenAPI document: ``` require "uuid" class Jobs < AC::Base base "/jobs" @[AC::Route::POST("/")] def create( @[AC::Param::Info(header: "X-Request-UUID", description: "an idempotency key", example: "ba714f86-cac6-42c7-8956-bcf5105e1b81")] request_id : UUID, @[AC::Param::Info(header: "X-Priority")] priority : Int32 = 5, ) : String "job #{request_id} at priority #{priority}" end end ``` A missing required header raises `MissingError`, and a value that can't be converted raises `ValueError`, with the header name as the `parameter`. ## Custom converters A converter is any class or struct with a `convert(raw : String)` method. Its `initialize` arguments are the options that `config:` can pass. Return `nil` when the value can't be converted, and Spider-Gazelle raises the usual parameter errors. Attach a converter to an argument with `class:`, or to a route with `converters:`: ``` # Parses comma separated lists, e.g. "red, green,blue" struct ConvertList def initialize(@separator : String = ",") end def convert(raw : String) : Array(String) raw.split(@separator).map(&.strip).reject(&.empty?) end end class Tags < AC::Base base "/tags" # GET /tags?tags=red,green # => ["red","green"] @[AC::Route::GET("/")] def index( @[AC::Param::Info(class: ConvertList)] tags : Array(String) = [] of String, ) : Array(String) tags end # GET /tags/pipes?tags=red|green # => ["red","green"] @[AC::Route::GET("/pipes", converters: {tags: ConvertList}, config: {tags: {separator: "|"}})] def pipes(tags : Array(String)) : Array(String) tags end end ``` To use a converter for every argument of a type without naming it each time, define it as `AC::Route::Param::Convert` followed by the type name. Spider-Gazelle looks this up for any type it doesn't recognise: ``` record Commit, branch : String, sha : String # used automatically for any `Commit` argument struct AC::Route::Param::ConvertCommit # e.g. "main#742887" def convert(raw : String) : Commit? parts = raw.split('#', 2) Commit.new(parts[0], parts[1]) if parts.size == 2 end end class Builds < AC::Base base "/builds" # GET /builds/main%23742887 # => "742887 on main" @[AC::Route::GET("/:commit")] def show(commit : Commit) : String "#{commit.sha} on #{commit.branch}" end end ``` Explicit converters take precedence over the built-in ones, so they can also replace the parsing of a standard type such as `Bool`. ## Request bodies Name an argument in the route's `body:` option to parse the request body into it. The argument's type must be deserialisable by the parser for the request's `Content-Type`. JSON is supported out of the box, using [`JSON::Serializable`](https://crystal-lang.org/api/latest/JSON/Serializable.html): ``` require "action-controller" struct NewUser include JSON::Serializable getter name : String getter email : String end class Users < AC::Base base "/users" # Registers a user @[AC::Route::POST("/", body: :user, status_code: HTTP::Status::CREATED)] def create(user : NewUser) : String "welcome #{user.name}" end end # curl -X POST http://localhost:3000/users \ # -H "Content-Type: application/json" \ # -d '{"name": "Jim", "email": "jim@example.com"}' # => "welcome Jim" ``` How the body is parsed: - The parser is chosen by the request's `Content-Type`. Requests without one use the default parser (`application/json`). - An unsupported `Content-Type` raises `AC::Route::UnsupportedMediaType`, whose `accepts` lists the supported types. - A `String` body argument also accepts `text/plain`, and receives the raw body. - A body that doesn't match the type raises the parser's error, e.g. `JSON::SerializableError` (a `JSON::ParseException`). Handle it to return a `400`, see [errors](https://spider-gazelle.net/guides/errors/#recommended-handlers). - A request without a body uses the argument's default, if it has one. - The body type becomes the OpenAPI request body schema, and the MCP tool's `body` input. The body argument can sit alongside path, query and header arguments: ``` @[AC::Route::PATCH("/:id", body: :changes)] def update(id : Int64, changes : NewUser, notify : Bool = false) : String "updated #{id}" end ``` ### Adding parsers `add_parser` registers a parser for another content type. The block receives the argument's type, the body `IO` and the request. `default_parser` sets the parser used when the request has no `Content-Type`: ``` require "yaml" abstract class Application < AC::Base add_parser("application/yaml") do |klass, body_io, request| klass.from_yaml(body_io) end end ``` Any route's body can arrive in any registered format, so every body type in the application must then support YAML too, e.g. by including `YAML::Serializable`, or the application won't compile. Parsers and responders are application-wide `add_parser`, `default_parser`, `add_responder` and `default_responder` register globally, whichever controller they appear in. Declare them once, in your base class. ## Form data and file uploads Fields of `application/x-www-form-urlencoded` and `multipart/form-data` bodies are parameters, just like query parameters: ``` class Signups < AC::Base base "/signups" # curl -X POST http://localhost:3000/signups -d "name=Jim&age=42" @[AC::Route::POST("/")] def create(name : String, age : Int32? = nil) : String "#{name} (#{age})" end end ``` Files in a multipart body are available from `files`, a `Hash(String, Array(FileUpload))?` keyed by field name: ``` class Uploads < AC::Base base "/uploads" @[AC::Route::POST("/")] def create : Array(String) uploads = files.try(&.["attachment"]?) || return [] of String uploads.map do |upload| "#{upload.filename}: #{File.size(upload.file.path)} bytes" end end end ``` Each `AC::BodyParser::FileUpload` has `name`, `filename`, `headers`, `size` (if the client sent it) and `file`, the temporary file holding the upload. `file` is already closed, so open it by path to read it: `File.open(upload.file.path)`. The temporary files are deleted once the request completes, so copy anything you want to keep. ## Reading the request directly Typed arguments cover most needs, but the raw request is always available in a controller: | Method | Returns | | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------- | | `request` | The [`HTTP::Request`](https://crystal-lang.org/api/latest/HTTP/Request.html), including `request.headers` and `request.body`. | | `params` | Path, query and form parameters combined, as `URI::Params`. | | `route_params` | Path parameters only. | | `query_params` | Query parameters only. | | `form_data` | Form fields only, or `nil`. | | `files` | Uploaded files, or `nil`. | | `request_content_type` | The request's media type, e.g. `"application/json"`. | | `accepts_formats` | The media types in the `Accept` header. | | `client_ip` | The client's IP, using `X-Forwarded-For`, `X-Real-IP` or `Forwarded` when present. | | `request_protocol` | `:https` or `:http`, based on `X-Forwarded-Proto` or `Forwarded`. | | `cookies`, `session` | See [sessions](https://spider-gazelle.net/guides/sessions/index.md). | | `action_name` | The name of the route method being run, as a `Symbol`. | `request.body` can only be read once, so don't read it in a route that also uses `body:` or form parameters. ## See also - [Routing](https://spider-gazelle.net/guides/routing/index.md): paths and route options - [Responses](https://spider-gazelle.net/guides/responses/index.md): what happens to the return value - [Errors](https://spider-gazelle.net/guides/errors/index.md): responding to parameter errors - [Filters](https://spider-gazelle.net/guides/filters/#typed-parameters): typed parameters in filters - [OpenAPI: describing routes](https://spider-gazelle.net/openapi/descriptions/index.md) - [MCP](https://spider-gazelle.net/mcp/index.md) # Responses A route method's return value is the response body. Spider-Gazelle picks a format from the request's `Accept` header, serialises the value and sets the status code from the route annotation. This page covers status codes, content negotiation, responders, `render` and friends, headers, files, CORS and forcing TLS. ``` require "action-controller" struct Comment include JSON::Serializable getter id : Int64 getter body : String def initialize(@id, @body) end end class Comments < AC::Base base "/comments" # Returns a comment @[AC::Route::GET("/:id")] def show(id : Int64) : Comment Comment.new(id, "great article") end # Creates a comment @[AC::Route::POST("/", status_code: HTTP::Status::CREATED)] def create(body : String) : Comment Comment.new(1_i64, body) end end require "action-controller/server" AC::Server.new.run # GET /comments/7 # => 200 {"id":7,"body":"great article"} # POST /comments?body=hi # => 201 {"id":1,"body":"hi"} ``` The return type is optional, but declare it. It documents the route, it's checked by the compiler, and it's the response schema in the [OpenAPI](https://spider-gazelle.net/openapi/index.md) document and the [MCP](https://spider-gazelle.net/mcp/index.md) tool's result. ## Return values | Returned value | Response | | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- | | an object | Serialised by the selected [responder](#responders). JSON by default, using `to_json`. | | a `String` | Also serialised, so the default JSON responder sends `"text"` with quotes. Use a [`text/plain` responder](#fixed-content-types) for plain text. | | `nil` | No body. The status code and headers are still sent. | | anything, for a `HEAD` request | No body. | If the method has already sent a response with [`render`, `head` or `redirect_to`](#render-head-and-redirect_to), the return value is ignored. ## Status codes Responses are `200 OK` unless you say otherwise. Set a different status for a route with `status_code:`: ``` @[AC::Route::POST("/", status_code: HTTP::Status::CREATED)] def create(body : String) : Comment Comment.new(1_i64, body) end ``` When a route can return different types, map each type to a status with `status:`. Declare the return type as a union so the compiler, and the OpenAPI document, know every possibility: ``` struct Missing include JSON::Serializable getter error : String def initialize(@error) end end class Lookups < AC::Base base "/lookups" # Finds a comment @[AC::Route::GET("/:id", status: {Missing => HTTP::Status::NOT_FOUND})] def show(id : Int64) : Comment | Missing if id == 1 Comment.new(id, "found it") else Missing.new("no comment #{id}") end end end ``` The returned value's type selects the status, and types not in the map use `status_code:` (default `200`). Both statuses appear as responses in the OpenAPI operation. Status values can be an `HTTP::Status` or an integer. For errors raised from deeper in your code, [exception handlers](https://spider-gazelle.net/guides/errors/index.md) are usually a better fit than return types. ## Content negotiation The route picks a responder before your method runs: 1. With no `Accept` header, or `Accept: */*`, it uses the default responder (`application/json`). 1. Otherwise it uses the first type listed in `Accept` that has a responder. Quality values (`;q=0.8`) are ignored, so list order decides. 1. If none match and there's no `*/*`, it raises `AC::Route::NotAcceptable`, and your method isn't called. Its `accepts` lists the available types. Handle it to return a `406`, see [errors](https://spider-gazelle.net/guides/errors/#recommended-handlers). The response `Content-Type` is set to the selected type, unless your method has already set one. ## Responders `add_responder` registers a responder for a content type. The block writes `result` to `io`, the response: ``` require "yaml" abstract class Application < AC::Base add_responder("application/yaml") { |io, result| result.to_yaml(io) } add_responder("text/plain") { |io, result| result.to_s(io) } # used when the client doesn't ask for a format default_responder "application/json" end ``` - The block can also take the controller name and the action name as symbols: `|io, result, controller, action|`. - It runs in the context of the controller instance, so `request`, `response` and your helper methods are available. - `default_responder` sets the type used when the client accepts anything. It must already have a responder. - Responders are registered application-wide, whichever controller declares them. Declare them once, in your base class. - Any route can be asked for any registered format, so every route's return type must support every responder. For YAML, include `YAML::Serializable` in your response types, or the application won't compile. ## Fixed content types `content_type:` fixes a route's response type. The `Accept` header is ignored and the response always has this `Content-Type`: ``` abstract class Application < AC::Base add_responder("text/plain") { |io, result| result.to_s(io) } end class Health < Application base "/health" # GET /health # => ok @[AC::Route::GET("/", content_type: "text/plain")] def index : String "ok" end end ``` Register a responder for the content type `content_type:` sets the header, but the body is still written by the responder registered for that type. If there isn't one, the default (JSON) responder writes it, so the route above would send `"ok"` in quotes with a `text/plain` header. Exception handlers accept `content_type:` too. ## `render`, `head` and `redirect_to` These macros send a response immediately and `return` from the method. They work in annotated routes, [filters](https://spider-gazelle.net/guides/filters/index.md) and exception handlers. ``` class Accounts < AC::Base base "/accounts" @[AC::Route::GET("/:id")] def show(id : Int64) : String? head :not_found if id == 0 redirect_to "/accounts/1", status: :moved_permanently if id == 99 render :accepted, text: "account #{id}" if id == 2 "account #{id}" end @[AC::Route::DELETE("/:id")] def destroy(id : Int64) head :no_content end end ``` Return types `head` and `redirect_to` return `nil`, and `render` returns the value it rendered. A route that uses them needs a return type that allows those values, such as `String?`, or no return type at all. ### `render` `render` takes an optional status and one body option: | Option | Content type | Body | | ----------------------- | -------------------------- | --------------------------------------------------------------------- | | `json:` | `application/json` | `to_json`, or the string as-is | | `yaml:` | `text/yaml` | `to_yaml`, or the string as-is | | `xml:` | `application/xml` | `to_s` | | `html:` | `text/html` | `to_s` | | `text:` | `text/plain` | `to_s` | | `binary:` | `application/octet-stream` | `to_s` | | `template:`, `partial:` | `text/html` | a [Kilt](https://github.com/jeromegn/kilt) template from `src/views/` | ``` render json: {status: "ok"} render :created, json: comment render HTTP::Status::ACCEPTED, text: "queued" render :forbidden # status only, no body ``` The status can be a symbol, an `HTTP::Status` or an integer. Symbols are snake case status names: `:ok`, `:created`, `:no_content`, `:bad_request`, `:unauthorized`, `:forbidden`, `:not_found`, `:unprocessable_entity` and so on. A `Content-Type` already set on the response is kept. ### `head` `head status` sends a status with no body, e.g. `head :no_content`. ### `redirect_to` `redirect_to path, status: :found` sets the `Location` header. The status defaults to `302 Found`. Symbols must be redirect statuses: `:moved_permanently`, `:found`, `:see_other`, `:not_modified`, `:temporary_redirect` or `:permanent_redirect`. Build paths with the [route helpers](https://spider-gazelle.net/guides/routing/#building-paths-to-routes). ### `respond_with` `respond_with` chooses between several formats in the action itself. It's intended for [macro DSL](https://spider-gazelle.net/guides/routing/#macro-dsl) routes, because annotated routes have already negotiated the format before the method runs: ``` get "/:id", :show do comment = Comment.new(params["id"].to_i64, "great article") respond_with do json comment text comment.body end end ``` It uses the first option that the `Accept` header allows, or the first option listed if there's no `Accept` header. If nothing matches, it responds `406 Not Acceptable`. ## Headers `response` is the [`HTTP::Server::Response`](https://crystal-lang.org/api/latest/HTTP/Server/Response.html). Set headers on it before the body is written: ``` @[AC::Route::GET("/report")] def report : Array(String) response.headers["Cache-Control"] = "no-store" response.headers["X-Report-Version"] = "2" ["row 1", "row 2"] end ``` Set `response.content_type = "..."` to change the content type. The body is still written by the negotiated responder. To set headers on every response, use a [before filter](https://spider-gazelle.net/guides/filters/index.md). ## Conditional requests and caching `stale?` sets the `ETag` and `Last-Modified` headers and checks them against the request's `If-None-Match` and `If-Modified-Since`. When the client's copy is current, it responds `304 Not Modified` and returns `false`: ``` @[AC::Route::GET("/:id")] def show(id : Int64) : Comment? comment = Comment.new(id, "great article") updated_at = Time.utc(2024, 1, 1) if stale?(last_modified: updated_at, etag: %("comment-#{id}-v1")) comment end end ``` `public: true` also marks the response `Cache-Control: public`. Pass either validator on its own, or both. When a client sends both `If-None-Match` and `If-Modified-Since`, both must match for a `304`: ``` @[AC::Route::GET("/:id")] def show(id : Int64) : Comment? comment = Comment.find!(id) comment if stale?(etag: %("comment-#{id}-#{comment.version}")) end ``` ## Files and streams To send a file or other large data, set the headers, mark the response as rendered and write to `response` directly. The data is streamed rather than held in memory: ``` @[AC::Route::GET("/export.csv")] def export : Nil response.content_type = "text/csv" response.headers["Content-Disposition"] = %(attachment; filename="export.csv") # tells Spider-Gazelle the response has been handled @__render_called__ = true File.open("/data/export.csv") do |file| IO.copy(file, response) end end ``` Set `@__render_called__ = true` before writing. Otherwise Spider-Gazelle tries to set the status and content type after your data has been sent. For data already in memory as a `String`, `render binary: data` is simpler. ## CORS Browsers only let pages call an API on another origin if the response carries [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) headers. Add them in a before filter, and answer the browser's `OPTIONS` preflight requests: ``` abstract class Application < AC::Base @[AC::Route::Filter(:before_action)] def set_cors_headers response.headers["Access-Control-Allow-Origin"] = "https://app.example.com" response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, PATCH, DELETE, OPTIONS" response.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization" end end # Answers CORS preflight requests for every path @[AC::MCP(hide: true)] class Preflight < Application base "/" @[AC::Route::OPTIONS("/*:path")] def preflight head :no_content end end ``` The preflight route is hidden from [MCP](https://spider-gazelle.net/mcp/index.md), as it isn't useful to an agent. Browsers don't send credentials with a preflight request, so if your base class has an authentication filter, skip it here with [`skip_action`](https://spider-gazelle.net/guides/filters/#skipping-inherited-filters). Use `"*"` as the allowed origin only for public APIs that don't use cookies. ## Forcing TLS `force_tls` makes routes reject plain HTTP. A plain HTTP request is redirected (`302`) to the same URL over `https://`, and a WebSocket request gets `412 Precondition Failed`. ``` class Payments < AC::Base base "/payments" # only these routes force_tls only: [:create, :destroy] end class Admin < AC::Base base "/admin" # every route except the health check force_tls except: [:health] end ``` Without `only:` or `except:`, every route in the controller requires TLS: ``` abstract class Application < AC::Base force_tls end ``` How a request's protocol is determined: - **Behind a proxy:** the `X-Forwarded-Proto` or `Forwarded` header describes the client's connection, so it takes precedence. Make sure your TLS-terminating proxy or load balancer sets one of them. - **Serving TLS yourself:** a connection to a port that `ActionController::Server` bound with TLS (`Server.new(ssl_context, ...)`) is HTTPS. `force_ssl` is an alias. Note The no-options form and the TLS port detection need action-controller 8.3.3 or later. ## See also - [Routing](https://spider-gazelle.net/guides/routing/index.md): route options - [Errors](https://spider-gazelle.net/guides/errors/index.md): responses for exceptions - [Filters](https://spider-gazelle.net/guides/filters/index.md): setting headers for every route - [Sessions](https://spider-gazelle.net/guides/sessions/index.md): cookies and sessions - [OpenAPI: describing routes](https://spider-gazelle.net/openapi/descriptions/#responses) # Filters Filters are controller methods that run before, around or after your routes. Use them for work shared by many routes: authentication, loading records, setting headers, wrapping actions in database transactions, and auditing. ``` require "action-controller" abstract class Application < AC::Base getter! current_user : String # Runs before every route in every controller that inherits from Application @[AC::Route::Filter(:before_action)] def authenticate( @[AC::Param::Info(header: "Authorization", description: "a bearer token")] auth : String? = nil, ) render :unauthorized, text: "sign in required" unless auth == "Bearer secret" @current_user = "alice" end end class Profile < Application base "/profile" @[AC::Route::GET("/")] def show : String "signed in as #{current_user}" end end require "action-controller/server" AC::Server.new.run # GET /profile # => 401 sign in required # GET /profile (Authorization: Bearer secret) # => "signed in as alice" ``` ## Filter types | Filter | Runs | Typical uses | | ---------------- | --------------------------------------------------------- | ------------------------------------------------------- | | `:before_action` | before the action | authentication, authorisation, loading records, headers | | `:around_action` | wraps the before filters and the action, and must `yield` | transactions, timing, context that needs cleaning up | | `:after_action` | after the action has rendered | logging, auditing, metrics | A request runs in this order: 1. around filters, outermost first, each `yield`ing to the next; 1. before filters; 1. the action; 1. after filters. Within each type, filters inherited from parent classes run first, then the controller's own, in the order they're defined. ## Declaring filters Annotate a method with `@[AC::Route::Filter(type)]`: ``` abstract class Application < AC::Base @[AC::Route::Filter(:before_action)] def set_request_headers response.headers["X-Frame-Options"] = "DENY" end end ``` Or register an existing method with the `before_action`, `around_action` and `after_action` macros: ``` abstract class Application < AC::Base before_action :set_request_headers private def set_request_headers response.headers["X-Frame-Options"] = "DENY" end end ``` The two styles behave the same. The annotation form is preferred because filter methods can then take [typed parameters](#typed-parameters). Methods registered with the macros can be private and can't take parameters. ## Choosing routes with `only` and `except` By default a filter applies to every route in the controller and its subclasses. Limit it by route method name, with a symbol or an array of symbols: ``` class Comments < Application base "/comments" @[AC::Route::Filter(:before_action, only: [:update, :destroy])] def check_owner # ... end @[AC::Route::Filter(:after_action, except: :index)] def audit Log.info { "#{action_name} by #{current_user}" } end @[AC::Route::GET("/")] def index : Array(String) [] of String 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 ``` The macros take the same options: `before_action :check_owner, only: [:update, :destroy]`. ## Stopping a request A before filter stops the request by sending a response with [`render`, `head` or `redirect_to`](https://spider-gazelle.net/guides/responses/#render-head-and-redirect_to), or by raising an exception. The remaining before filters and the action don't run: ``` @[AC::Route::Filter(:before_action)] def require_admin head :forbidden unless current_user == "admin" end ``` Raising works well with [exception handlers](https://spider-gazelle.net/guides/errors/index.md), because the same error then gets the same response wherever it's raised: ``` @[AC::Route::Filter(:before_action)] def require_admin raise AC::Error::Forbidden.new("admins only") unless current_user == "admin" end ``` After filters still run when a before filter has rendered, so they can log rejected requests. Check `render_called?` if you need to tell the difference. ## Typed parameters Annotation filters can take arguments, parsed exactly like [route parameters](https://spider-gazelle.net/guides/parameters/index.md): from the path, query string, form data or headers, with type conversion, defaults and `@[AC::Param::Info]`. This makes filters a good place to load the record a route works on: ``` struct Article include JSON::Serializable getter id : Int64 getter title : String def initialize(@id, @title) end end class Articles < AC::Base base "/articles" getter! article : Article @[AC::Route::Filter(:before_action, only: [:show, :update])] def find_article(id : Int64) @article = Article.new(id, "Article #{id}") end @[AC::Route::GET("/:id")] def show : Article article end @[AC::Route::PATCH("/:id")] def update(title : String) : Article Article.new(article.id, title) end end ``` - A missing or invalid filter parameter raises the usual [parameter errors](https://spider-gazelle.net/guides/errors/#handling-parameter-errors). - Make a parameter nilable, e.g. `id : Int64?`, when the filter applies to routes that don't all have it. - Filter parameters are added to the OpenAPI parameters of every route the filter applies to. See [parameters from filters](https://spider-gazelle.net/openapi/descriptions/#parameters-from-filters). `getter!` creates an `article` method that raises if `@article` is `nil`, and an `article?` method that returns `nil` instead. ## Around filters An around filter must accept a block and `yield` to run the rest of the request: ``` abstract class Application < AC::Base @[AC::Route::Filter(:around_action, only: [:create, :update, :destroy])] def wrap_in_transaction(&) Database.transaction { yield } end end ``` Code after `yield` runs once the before filters and the action have finished. Use `ensure` for cleanup that must happen even if the action raises: ``` @[AC::Route::Filter(:around_action)] def instrument(&) started = Time.instant yield ensure Log.info { "#{action_name} took #{Time.instant - started}" } if started end ``` Keep around filters thin Crystal inlines a method that `yield`s into every route it wraps, so a large around filter is duplicated once per route in your binary. Keep the body small and move the work into ordinary methods. ## After filters After filters run once the action has produced its response. They can read `response.status_code` and `action_name`, but the body has already been written, so don't use them to change the response. After filters don't run if the action raises an exception. Put cleanup that must always happen in an around filter's `ensure`. ## Skipping inherited filters `skip_action` turns off an inherited filter, for all of a controller's routes or for some of them: ``` class Sessions < Application base "/sessions" # the sign in page can't require a signed in user skip_action :authenticate, only: [:sign_in] @[AC::Route::GET("/new")] def sign_in : String "sign in here" end end ``` It takes the filter's method name, and works for annotation and macro filters alike. Without `only:` or `except:` the filter is skipped for every route in the controller. ## See also - [Routing](https://spider-gazelle.net/guides/routing/index.md) - [Parameters](https://spider-gazelle.net/guides/parameters/index.md) - [Responses](https://spider-gazelle.net/guides/responses/index.md): `render`, `head` and `redirect_to` - [Errors](https://spider-gazelle.net/guides/errors/index.md): handling exceptions raised by filters - [Sessions](https://spider-gazelle.net/guides/sessions/index.md): reading the signed in user from a session - [Logging](https://spider-gazelle.net/guides/logging/index.md): request IDs and log context # Errors Exception handlers turn exceptions into responses. Raise an error anywhere in a route, filter or the code they call, and a handler declared on the controller, or inherited from a parent, decides the status code and body. This page covers declaring handlers, the errors Spider-Gazelle raises, and what happens to exceptions nobody handles. ``` require "action-controller" class Divide < AC::Base base "/divide" # Divides one number by another @[AC::Route::GET("/:num1/:num2")] def divide(num1 : Int32, num2 : Int32) : Int32 num1 // num2 end # Responds 400 when dividing by zero @[AC::Route::Exception(DivisionByZeroError, status_code: HTTP::Status::BAD_REQUEST)] def division_by_zero(error) : NamedTuple(error: String?) {error: error.message} end end require "action-controller/server" AC::Server.new.run # GET /divide/10/2 # => 200 5 # GET /divide/10/0 # => 400 {"error":"Division by 0"} ``` The handler's status code and return type are added to the responses of every route it covers in the [OpenAPI](https://spider-gazelle.net/openapi/index.md) document. ## Exception handlers Annotate a method with `@[AC::Route::Exception(ErrorClass)]`. It handles that class and its subclasses, raised from any route or filter in the controller and its subclasses. - The first argument is the exception. It's typed as the class in the annotation. - The return value is rendered like a route's: serialised by the negotiated [responder](https://spider-gazelle.net/guides/responses/#responders). - If the client's `Accept` header can't be satisfied, the default responder is used, so errors always get a response. | Option | Purpose | | --------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | `status_code:` | The response status, `200 OK` if omitted, so always set it. | | `content_type:` | Always respond with this content type, see [fixed content types](https://spider-gazelle.net/guides/responses/#fixed-content-types). | One method can handle several exception classes, each with its own status: ``` abstract class Application < AC::Base @[AC::Route::Exception(AC::Error::NotFound, status_code: HTTP::Status::NOT_FOUND)] @[AC::Route::Exception(AC::Error::Conflict, status_code: HTTP::Status::CONFLICT)] def resource_error(error) : AC::Error::CommonResponse AC::Error::CommonResponse.new(error, backtrace: false) end end ``` Handlers can take extra arguments, parsed like [route parameters](https://spider-gazelle.net/guides/parameters/index.md). Make them nilable, as the failing request may not include them: ``` @[AC::Route::Exception(AC::Error::NotFound, status_code: HTTP::Status::NOT_FOUND)] def not_found(error, id : Int64?) : NamedTuple(error: String?, id: Int64?) {error: error.message, id: id} end ``` Handlers can also call [`render`, `head` or `redirect_to`](https://spider-gazelle.net/guides/responses/#render-head-and-redirect_to) to take full control of the response. ### Generic exceptions Exceptions can be generic, for example an error class parameterised by its status code. Handle one instance, or name the generic itself to handle every instance: ``` class ApiError(Code) < Exception def code : Int32 Code end end abstract class Application < AC::Base # handles ApiError(404) only @[AC::Route::Exception(ApiError(404), status_code: HTTP::Status::NOT_FOUND)] def api_not_found(error) : AC::Error::CommonResponse AC::Error::CommonResponse.new(error, backtrace: false) end # handles every other ApiError, such as ApiError(400) and ApiError(409) @[AC::Route::Exception(ApiError, status_code: HTTP::Status::BAD_REQUEST)] def api_error(error) : AC::Error::CommonResponse AC::Error::CommonResponse.new(error, backtrace: false) end end ``` The status code in the annotation is fixed. To respond with each error's own code, call `render status: error.code, json: ...` in the handler. ## `rescue_from` The `rescue_from` macro is an alternative that doesn't use annotations. Pass a method name, or a block: ``` class Books < AC::Base base "/books" rescue_from KeyError, :missing_key rescue_from IndexError do |error| render :not_found, json: {error: error.message} end def missing_key(error) render :not_found, json: {error: error.message} end end ``` A `rescue_from` handler must send its response with `render` or `head`: its return value isn't rendered, and it doesn't add a response to the OpenAPI document. Prefer the annotation. ## Built-in errors Spider-Gazelle raises these while handling a request: | Error | Raised when | Suggested status | | --------------------------------- | --------------------------------------------------- | ---------------- | | `AC::Route::Param::MissingError` | a required parameter or header is missing | `422` | | `AC::Route::Param::ValueError` | a parameter can't be converted to its type | `400` | | `AC::Route::NotAcceptable` | no responder matches the `Accept` header | `406` | | `AC::Route::UnsupportedMediaType` | no parser matches the request body's `Content-Type` | `415` | Both parameter errors inherit from `AC::Route::Param::Error`, which has `parameter` (the name) and `restriction` (the expected type). Both media type errors inherit from `AC::Route::Error`, which has `accepts`, the list of supported types. These are provided for your own code, with no built-in handlers: | Error | Typical status | | ------------------------- | -------------- | | `AC::Error::Unauthorized` | `401` | | `AC::Error::Forbidden` | `403` | | `AC::Error::NotFound` | `404` | | `AC::Error::Conflict` | `409` | ``` raise AC::Error::NotFound.new("no article with id #{id}") ``` `AC::Error` also provides response structs to return from handlers. All of them serialise to JSON and YAML: | Struct | Fields | | ------------------------------ | --------------------------------------------------------------- | | `AC::Error::CommonResponse` | `error`, and `backtrace` unless created with `backtrace: false` | | `AC::Error::ParameterResponse` | `error`, `parameter`, `restriction` | | `AC::Error::ContentResponse` | `error`, `accepts` | ## Handling parameter errors Spider-Gazelle doesn't turn the built-in errors into responses for you. Without a handler, a missing parameter is a `500 Internal Server Error`. Handle them in your base class: ``` abstract class Application < AC::Base # a parameter is missing or can't be parsed @[AC::Route::Exception(AC::Route::Param::MissingError, status_code: HTTP::Status::UNPROCESSABLE_ENTITY)] @[AC::Route::Exception(AC::Route::Param::ValueError, status_code: HTTP::Status::BAD_REQUEST)] def invalid_param(error) : AC::Error::ParameterResponse AC::Error::ParameterResponse.new( error: error.message.as(String), parameter: error.parameter, restriction: error.restriction ) end end # GET /search/books?q=crystal&limit=many # => 400 {"error":"invalid parameter value for 'limit'","parameter":"limit","restriction":"Int32"} ``` ## Recommended handlers The [application template](https://spider-gazelle.net/getting_started/index.md) starts with handlers for the built-in errors. A complete base class, adding malformed JSON bodies and the `AC::Error` classes, looks like this: ``` require "action-controller" abstract class Application < AC::Base # no acceptable response format, or an unsupported request body format @[AC::Route::Exception(AC::Route::NotAcceptable, status_code: HTTP::Status::NOT_ACCEPTABLE)] @[AC::Route::Exception(AC::Route::UnsupportedMediaType, status_code: HTTP::Status::UNSUPPORTED_MEDIA_TYPE)] def bad_media_type(error) : AC::Error::ContentResponse AC::Error::ContentResponse.new(error: error.message.as(String), accepts: error.accepts) end # a parameter is missing or can't be parsed @[AC::Route::Exception(AC::Route::Param::MissingError, status_code: HTTP::Status::UNPROCESSABLE_ENTITY)] @[AC::Route::Exception(AC::Route::Param::ValueError, status_code: HTTP::Status::BAD_REQUEST)] def invalid_param(error) : AC::Error::ParameterResponse AC::Error::ParameterResponse.new(error: error.message.as(String), parameter: error.parameter, restriction: error.restriction) end # the request body isn't valid JSON, or doesn't match the expected type @[AC::Route::Exception(JSON::ParseException, status_code: HTTP::Status::BAD_REQUEST)] def invalid_json(error) : AC::Error::CommonResponse AC::Error::CommonResponse.new(error, backtrace: false) end @[AC::Route::Exception(AC::Error::Unauthorized, status_code: HTTP::Status::UNAUTHORIZED)] @[AC::Route::Exception(AC::Error::Forbidden, status_code: HTTP::Status::FORBIDDEN)] @[AC::Route::Exception(AC::Error::NotFound, status_code: HTTP::Status::NOT_FOUND)] @[AC::Route::Exception(AC::Error::Conflict, status_code: HTTP::Status::CONFLICT)] def app_error(error) : AC::Error::CommonResponse AC::Error::CommonResponse.new(error, backtrace: false) end end ``` ## Inheritance and order Handlers are inherited, so declare common ones once in your base class. Two rules follow from how handlers are combined: - **A parent's handler wins.** If a parent and a subclass both handle the same exception class, the parent's handler is used. - **The first matching handler wins.** Handlers are checked in order, parent classes first. A parent handler for a broad class such as `Exception` catches everything, including errors a subclass has a more specific handler for. So keep broad handlers out of your base class, or put them in the leaf controllers that need them. For a catch-all `500` response, rely on the [error handler](#unhandled-exceptions) instead. If a response has already been sent when an exception is raised, for example from an after filter, the handler can't send another one, and the exception continues as if it was unhandled. ## Unhandled exceptions An exception without a handler propagates out of the controller. Add `AC::ErrorHandler` to the server's handlers to turn it into a response. The [application template](https://spider-gazelle.net/getting_started/index.md) does this in `src/config.cr`: ``` # App.running_in_production? is defined in the template's src/constants.cr ActionController::Server.before( ActionController::ErrorHandler.new(App.running_in_production?, ["X-Request-ID"]), ActionController::LogHandler.new(["password", "bearer_token"]), ) ``` | Mode | Response | | -------------------------------------- | -------------------------------------------------------------------------- | | development, `ErrorHandler.new(false)` | `500` with an HTML page showing the exception and backtrace | | production, `ErrorHandler.new(true)` | `500` with a `{}` body if the client accepts JSON, otherwise an empty body | The second argument lists response headers to keep when the response is reset, such as a request ID set by a filter. Never run the development mode in production, as it exposes your source and backtraces. ## See also - [Responses](https://spider-gazelle.net/guides/responses/index.md): status codes and responders - [Parameters](https://spider-gazelle.net/guides/parameters/index.md): the source of parameter errors - [Filters](https://spider-gazelle.net/guides/filters/index.md): raising errors to stop a request - [Logging](https://spider-gazelle.net/guides/logging/index.md): how exceptions are logged - [OpenAPI: error responses](https://spider-gazelle.net/openapi/descriptions/#error-responses) # Sessions and cookies A session stores a small amount of data for each user between requests, such as the ID of the logged-in user. Spider-Gazelle keeps the session in an encrypted, signed cookie, so there's no server-side session store to run. This page also covers reading and writing ordinary cookies. ## Example ``` require "action-controller" ActionController::Session.configure do |settings| settings.key = "_my_app_session_" settings.secret = "4f74c0b358d5bab4000dd3c75465dc2c" # use your own, at least 32 bytes end class Sessions < AC::Base base "/session" # Logs the user in @[AC::Route::POST("/")] def create(username : String, password : String) : Nil if username == "steve" && password == "secret" session["user_id"] = 42_i64 session["username"] = username else head :unauthorized end end # Returns the logged-in user @[AC::Route::GET("/")] def show : NamedTuple(user_id: Int64?, username: String?) { user_id: session["user_id"]?.as(Int64?), username: session["username"]?.as(String?), } end # Logs the user out @[AC::Route::DELETE("/")] def destroy : Nil session.clear end end ``` After `POST /session?username=steve&password=secret`, the response sets the `_my_app_session_` cookie. The browser sends it back on later requests, and `GET /session` returns `{"user_id":42,"username":"steve"}`. ## Configuration Configure sessions once at start-up. In the [application template](https://spider-gazelle.net/getting_started/configuration/#sessions) this is in `src/config.cr`, and the key and secret come from the `COOKIE_SESSION_KEY` and `COOKIE_SESSION_SECRET` environment variables. ``` ActionController::Session.configure do |settings| settings.key = "_my_app_session_" settings.secret = ENV["COOKIE_SESSION_SECRET"] settings.secure = true end ``` | Setting | Type | Default | Description | | ----------- | --------- | -------------- | ------------------------------------------------------------------------------------------------------------ | | `key` | `String` | none, required | The name of the session cookie. | | `secret` | `String` | none, required | The secret used to encrypt and sign the cookie. | | `max_age` | `Int32` | about 20 years | Cookie lifetime in seconds, from the time the session was last written. | | `secure` | `Bool` | `false` | Adds the `Secure` attribute, so browsers only send the cookie over HTTPS. | | `encrypted` | `Bool` | `true` | Encrypts and signs the data. `false` only signs it, so the client can read the contents but not change them. | | `path` | `String` | `"/"` | The cookie `Path`. | | `domain` | `String?` | `nil` | The cookie `Domain`. `nil` limits the cookie to the host that set it. | Rules: - `key` and `secret` must be set before a session is used. They have no defaults. - When `encrypted` is `true`, the secret must be **at least 32 bytes**. It's used as an AES-256 key, and a shorter secret raises an `ArgumentError` when a session is written. `openssl rand -hex 16` prints a suitable 32 character secret. - Changing the secret invalidates every existing session. Cookies that fail to decrypt or verify are ignored, so those users get an empty session. - Set `secure` to `true` in production. The template does this when `SG_ENV` is `production`. The session cookie is always `HttpOnly` (JavaScript can't read it) and `SameSite=Lax`. ## Using the session Inside a controller, `session` works like a hash with `String` keys: ``` # user.id is an Int64 session["user_id"] = user.id # write session["user_id"]? # read, nil if missing session["user_id"] # read, raises KeyError if missing session.has_key?("user_id") # check session.delete("user_id") # remove one key session["user_id"] = nil # also removes the key session.clear # remove everything ``` Values must be `String`, `Int64`, `Float64` or `Bool`. Other types don't compile, so convert them first. For example, use `42_i64` or `id.to_i64` rather than an `Int32`, and `uuid.to_s` for a `UUID`. Reading a value returns the union type `String | Int64 | Float64 | Bool`. Use `.as(T)` or a `case` to get the type you stored: ``` if user_id = session["user_id"]?.as(Int64?) @current_user = User.find(user_id) end ``` ### Loading the current user in a filter A common pattern is to load the user in a [filter](https://spider-gazelle.net/guides/filters/index.md) on your base controller: ``` abstract class Application < AC::Base getter! current_user : User @[AC::Route::Filter(:before_action)] def authenticate user_id = session["user_id"]?.as(Int64?) return head :unauthorized unless user_id @current_user = User.find(user_id) end end ``` ## How sessions are stored - **Lazy loading.** The cookie is only decrypted the first time a route calls `session`. Routes that never touch the session pay nothing, so there's no need to turn sessions off. - **Written when modified.** The cookie is only sent back if the session changed. `session.touch` marks it as changed without changing any data, which refreshes the cookie's expiry. - **Written with the response.** The cookie is set when the response is rendered. Changes made after that, for example in an `after_action` filter, aren't saved. - **Clearing.** `session.clear` on an existing session sends an empty cookie that expires immediately, which removes it from the browser. - **Size limit.** An encoded session larger than 4096 bytes raises `ActionController::CookieSizeExceeded`. Encryption adds overhead, so the usable space is closer to 3 KB. Store IDs and look the data up, rather than storing the data itself. - **WebSockets.** [WebSocket routes](https://spider-gazelle.net/guides/websockets/index.md) can read the session, but don't write it, because there's no HTTP response to carry the cookie. Warning The session is a cookie, so the user can delete it or replay an old copy of it. Don't store anything there that must be revoked server side, such as a permission that can be taken away. Store an ID and check it on each request. ### Per-request cookie domain `session.domain` overrides the configured `domain` for the current request. This is useful for apps that serve several domains: ``` @[AC::Route::Filter(:before_action)] def set_session_domain session.domain = request.hostname end ``` ## Cookies For data that isn't part of the session, use cookies directly. - `cookies` returns the cookies the client **sent** with the request. - `response.cookies` holds the cookies to **send** back. Setting a value on `cookies` doesn't send it to the client. Always use `response.cookies` to set or delete a cookie. ``` class Preferences < AC::Base base "/preferences" # Returns the user's theme @[AC::Route::GET("/theme")] def theme : String cookies["theme"]?.try(&.value) || "light" end # Remembers the user's theme for a year @[AC::Route::POST("/theme/:theme")] def set_theme(theme : String) : String response.cookies << HTTP::Cookie.new( "theme", theme, path: "/", max_age: 365.days, http_only: true, samesite: :lax, ) theme end # Forgets the theme @[AC::Route::DELETE("/theme")] def reset_theme : Nil cookie = HTTP::Cookie.new("theme", "", path: "/") cookie.expire response.cookies << cookie end end ``` `cookies["name"]?` returns an [`HTTP::Cookie`](https://crystal-lang.org/api/latest/HTTP/Cookie.html), so call `.value` to get the string. `cookie.expire` clears the value and sets an expiry in the past, which tells the browser to delete it. The `path` (and `domain`) must match the cookie you're deleting. Plain cookies aren't encrypted or signed. Treat their values as untrusted input. ## Testing sessions The [spec client](https://spider-gazelle.net/guides/testing/index.md) doesn't keep cookies between requests. Copy them from the response yourself: ``` client = AC::SpecHelper.client response = client.post("/session/?username=steve&password=secret") headers = HTTP::Headers.new response.cookies.add_request_headers(headers) client.get("/session/", headers: headers).body # => {"user_id":42,"username":"steve"} ``` ## See also - [Filters](https://spider-gazelle.net/guides/filters/index.md), for authentication checks - [Configuration](https://spider-gazelle.net/getting_started/configuration/index.md), for the session environment variables - [WebSockets](https://spider-gazelle.net/guides/websockets/index.md) - [Testing](https://spider-gazelle.net/guides/testing/index.md) # WebSockets A WebSocket keeps a connection open so the server and client can send each other messages at any time. Use one for chat, live dashboards and notifications. In Spider-Gazelle a WebSocket route is a controller method, so it gets route params, filters and authentication like any other route. ## Example A chat server with rooms. Every message sent to a room is broadcast to everyone connected to it. ``` require "action-controller" class ChatRoom < AC::Base base "/chat" ROOMS = Hash(String, Array(HTTP::WebSocket)).new { |hash, key| hash[key] = [] of HTTP::WebSocket } LOCK = Mutex.new # Joins a chat room @[AC::Route::WebSocket("/:room")] def join(socket, room : String) : Nil LOCK.synchronize { ROOMS[room] << socket } socket.on_message do |message| peers = LOCK.synchronize { ROOMS[room].dup } peers.each &.send("#{room}: #{message}") end socket.on_close do LOCK.synchronize do sockets = ROOMS[room] sockets.delete(socket) ROOMS.delete(room) if sockets.empty? end end end end require "action-controller/server" AC::Server.new.run ``` Connect from a browser's developer console: ``` const socket = new WebSocket("ws://localhost:3000/chat/lobby"); socket.onmessage = (event) => console.log(event.data); socket.onopen = () => socket.send("hello"); // logs "lobby: hello" ``` ## How WebSocket routes work Declare the route with `@[AC::Route::WebSocket("/path")]`. The rules: - The **first argument** of the method is the [`HTTP::WebSocket`](https://crystal-lang.org/api/latest/HTTP/WebSocket.html). It has no type restriction. - Any other arguments are parsed exactly like a normal route: path params, query params, type conversion, defaults and `@[AC::Param::Info]`. See [Parameters](https://spider-gazelle.net/guides/parameters/index.md). - The method should register its callbacks and **return**. After it returns, Spider-Gazelle runs the socket's read loop, which calls your callbacks until the connection closes. Don't block in the method, or no messages are read. - A request that isn't a WebSocket upgrade gets `426 Upgrade Required`. The `HTTP::WebSocket` methods you'll use most: | Method | Description | | ---------------------------------- | ---------------------------------------------------------------- | | `send(message)` | Sends a text message (a `String`) or a binary message (`Bytes`). | | \`on_message { | text | | \`on_binary { | bytes | | \`on_close { | code, reason | | \`on_ping { | message | | `close(code = nil, message = nil)` | Closes the connection. | | `closed?` | Whether the connection is closed. | ## Filters and authentication `before_action` and `around_action` [filters](https://spider-gazelle.net/guides/filters/index.md) run before the connection is upgraded. If a filter renders a response, such as `head :unauthorized`, the upgrade doesn't happen and the client receives that response instead. ``` class Notifications < AC::Base base "/notifications" @[AC::Route::Filter(:before_action)] def authenticate(token : String? = nil) head :unauthorized unless token == "letmein" end # Streams notifications to the client @[AC::Route::WebSocket("/")] def stream(socket) : Nil socket.send({event: "connected"}.to_json) socket.on_message { |message| socket.send(message.upcase) } end end ``` Browsers can't add custom headers, such as `Authorization`, to a WebSocket request. Authenticate with one of: - the [session](https://spider-gazelle.net/guides/sessions/index.md) or another cookie, which the browser sends automatically to the same site - a short-lived token in the query string, as above Note WebSocket routes can read the session, but changes to it aren't saved. The session is stored in a cookie, and there's no HTTP response to carry it once the connection is upgraded. If the controller uses `force_tls` (see [Responses](https://spider-gazelle.net/guides/responses/index.md)), an unencrypted `ws://` request is refused with `412 Precondition Failed` and the message `WebSocket Secure (wss://) connection required`, rather than being redirected. ## Concurrency Each connection runs in its own [fiber](https://crystal-lang.org/reference/guides/concurrency.html). Callbacks from different connections can run at the same time when your app is multi-threaded, so protect shared state, like `ROOMS` above, with a `Mutex`. Connections only exist in the process that accepted them. Threads within a process share them (see [workers and threads](https://spider-gazelle.net/getting_started/configuration/#workers-and-threads)), but if you run several containers or servers, a broadcast from one doesn't reach sockets held by another. Use a shared message bus, such as Redis pub/sub, to fan messages out between instances. ## The route DSL The `ws` macro defines the same kind of route without an annotation. The block arguments become the method's arguments: ``` class Echo < AC::Base base "/echo" ws "/", :echo do |socket| socket.on_message { |message| socket.send(message) } end end ``` Prefer the annotation, which supports typed params. ## OpenAPI and MCP WebSocket routes appear in the [OpenAPI](https://spider-gazelle.net/openapi/index.md) document as `GET` operations, using the method's doc comment. They aren't exposed as [MCP](https://spider-gazelle.net/mcp/index.md) tools, because a tool call is a single request and response. ## Testing The spec client can open a WebSocket to your routes in-process with `establish_ws`. See [Testing WebSockets](https://spider-gazelle.net/guides/testing/#testing-websockets). ## See also - [Filters](https://spider-gazelle.net/guides/filters/index.md) - [Sessions and cookies](https://spider-gazelle.net/guides/sessions/index.md) - [Testing](https://spider-gazelle.net/guides/testing/index.md) - [Routing](https://spider-gazelle.net/guides/routing/index.md) # Logging Spider-Gazelle logs through Crystal's standard [`Log`](https://crystal-lang.org/api/latest/Log.html) module. This page covers logging from your controllers, request logging, request IDs, output formats, redacting sensitive params and changing the log level while the app runs. ## Example Give your base controller a `Log`, and tag every log entry made during a request with a request ID: ``` require "action-controller" require "uuid" module App NAME = "my_app" Log = ::Log.for(NAME) end abstract class App::Base < AC::Base # Logs from controllers use the source "my_app.controller" Log = ::App::Log.for("controller") @[AC::Route::Filter(:before_action)] def set_request_id request_id = request.headers["X-Request-ID"]? || UUID.random.to_s Log.context.set(client_ip: client_ip, request_id: request_id) response.headers["X-Request-ID"] = request_id end end class App::Orders < App::Base base "/orders" # Creates an order @[AC::Route::POST("/:product_id")] def create(product_id : Int64, quantity : Int32 = 1) : NamedTuple(product_id: Int64, quantity: Int32) Log.info { "creating order" } Log.debug { "quantity #{quantity}" } {product_id: product_id, quantity: quantity} end end # Configure where logs go and at what level backend = ActionController.default_backend ::Log.setup "*", :info, backend ::Log.builder.bind "#{App::NAME}.*", :debug, backend ``` With the template's `LogHandler` in place (see [Request logging](#request-logging)), `POST /orders/42?quantity=3` logs: ``` level=[I] time=2026-10-02T06:23:18Z program=app source=my_app.controller message="creating order" client_ip=127.0.0.1 request_id=c6595fb2-7808-41bc-98e7-4be557b552c5 level=[D] time=2026-10-02T06:23:18Z program=app source=my_app.controller message="quantity 3" client_ip=127.0.0.1 request_id=c6595fb2-7808-41bc-98e7-4be557b552c5 level=[I] time=2026-10-02T06:23:18Z program=app source=action-controller client_ip=127.0.0.1 request_id=c6595fb2-7808-41bc-98e7-4be557b552c5 event=response method=POST path=/orders/42?quantity=3 status=200 duration=50.4µs ``` The [application template](https://github.com/spider-gazelle/spider-gazelle) sets all of this up for you, in `src/constants.cr`, `src/config.cr` and `src/controllers/application.cr`. ## Log sources Every log entry has a **source**, a dotted name that says where it came from. You configure levels per source. | Source | What logs there | | --------------------------- | --------------------------------------------------------- | | `action-controller` | Request logging from `LogHandler`, and framework warnings | | `action-controller.session` | Session cookies that can't be decoded | | `action-controller.mcp` | The [MCP server](https://spider-gazelle.net/mcp/index.md) | | `.*` | Your code, if you create loggers with `App::Log.for(...)` | Create a logger for your own code with `Log.for`. Chaining from an app-wide `Log` puts everything under one prefix, so you can set its level with a single `"my_app.*"` binding: ``` module App Log = ::Log.for("my_app") end class App::Billing Log = ::App::Log.for("billing") # source "my_app.billing" end ``` Warning Define `Log` in your base controller. If you don't, `Log` inside a controller resolves to the top-level `::Log`, whose source is empty, so you can't filter those entries by source. Write entries with the usual `Log` methods. Pass a block, so the message is only built when that level is enabled: ``` Log.trace { "very detailed" } Log.debug { "useful while developing" } Log.info { "something happened" } Log.warn { "something looks wrong" } Log.error(exception: error) { "something failed" } ``` ## Configuring output `Log.setup` sets the default level and backend for every source. `Log.builder.bind` then sets the level for specific sources. The template's `src/config.cr` does this: ``` if running_in_production? log_level = ::Log::Severity::Info ::Log.setup "*", :warn, LOG_BACKEND else log_level = ::Log::Severity::Debug ::Log.setup "*", :info, LOG_BACKEND end ::Log.builder.bind "action-controller.*", log_level, LOG_BACKEND ::Log.builder.bind "#{NAME}.*", log_level, LOG_BACKEND ``` | Environment | Your app and action-controller | Everything else (shards) | | -------------------------------- | ------------------------------ | ------------------------ | | development | `debug` | `info` | | production (`SG_ENV=production`) | `info` | `warn` | A pattern such as `"my_app.*"` matches `my_app` and every source below it. ### Formats `ActionController.default_backend(io = STDOUT, formatter = default_formatter)` returns a `Log::IOBackend`. Two formatters are provided: - `ActionController.default_formatter`: `key=value` text, shown above. Easy to read and to grep. - `ActionController.json_formatter`: one JSON object per line, for log tools such as Logstash, Loki or CloudWatch. ``` LOG_BACKEND = ActionController.default_backend(formatter: ActionController.json_formatter) ``` ``` {"level":"INFO","program":"app","time":"2026-10-02T16:23:18+10:00","source":"my_app.controller","message":"creating order","client_ip":"127.0.0.1","request_id":"c6595fb2-7808-41bc-98e7-4be557b552c5"} ``` Both include the context (such as `request_id`), any data passed to the entry, and the exception backtrace if there is one. `program` is `Log.progname`, which defaults to the name of the executable. ## Request logging `ActionController::LogHandler` is an HTTP handler that logs each request. Add it with `ActionController::Server.before`, before the server is created: ``` ActionController::Server.before( ActionController::ErrorHandler.new(App.running_in_production?, ["X-Request-ID"]), ActionController::LogHandler.new(["password", "bearer_token"]), HTTP::CompressHandler.new ) ``` It logs one `info` entry per response, with the method, path, status and duration. If a route raises an unhandled exception, it logs an `error` entry instead, with `status=500` and the backtrace. `LogHandler.new` takes: | Argument | Default | Description | | ------------- | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | | `filter` | `[] of String` | Query string params whose values are logged as `[FILTERED]`. | | `log` | `Event::Response` | Which events to log. \`Event::Request | | `ms` | `false` | Log durations as a plain number of milliseconds, e.g. `0.0504`, instead of with a unit, e.g. `50.4µs`. Simpler for monitoring tools to parse. | | `generate_id` | `true` | When request events are logged, add a random `request_id` to the log context. | The events are `ActionController::LogHandler::Event` values. The handler wraps each request in its own log context. Anything your controller adds with `Log.context.set` appears on the handler's response entry too, as in the example at the top of this page. ### Filtering sensitive params ``` ActionController::LogHandler.new(["password", "bearer_token"]) ``` `GET /login?user=steve&password=hunter2` is logged as `path=/login?user=steve&password=[FILTERED]`. The filter only applies to the query string in the logged path. `LogHandler` never logs request bodies or headers, but your own log calls might, so take care not to log passwords or tokens yourself. ## Request IDs A request ID ties together every log entry made while handling one request, and lets clients quote it when reporting a problem. The template's base controller sets one in a `before_action` filter: ``` @[AC::Route::Filter(:before_action)] def set_request_id request_id = UUID.random.to_s Log.context.set( client_ip: client_ip, request_id: request_id ) response.headers["X-Request-ID"] = request_id end ``` - `Log.context.set` tags every later entry in this request, from any logger. - The `X-Request-ID` response header lets the client see the ID. - The template passes `["X-Request-ID"]` to `ErrorHandler`, so the header is kept on `500` responses, which is when it's needed most. - `client_ip` reads the `X-Forwarded-For`, `X-Real-IP` or `Forwarded` headers set by proxies, falling back to the connection's address. In a microservice, reuse the ID sent by the caller so one ID follows the request across services, and send it on to services you call: ``` request_id = request.headers["X-Request-ID"]? || UUID.random.to_s ``` Note If you log request events with `Event::Request`, the handler adds its own `request_id` before your filter runs, and the filter then replaces it. The request entry and the response entry end up with different IDs. Use one source of IDs: pass `generate_id: false`, or read the existing ID with `Log.context.metadata[:request_id]?` in your filter. ## Changing the log level at runtime The template lets you turn on `trace` logging in a running process without a restart, which is useful when debugging production. Send the process the `USR1` signal: ``` kill -USR1 ``` The process prints `> Log level changed to Trace`. Send `USR1` again to go back to `info` in production, or `debug` in development. This is implemented by `App.register_severity_switch_signals` in the template's `src/constants.cr`, and called from `src/app.cr`. It only changes the level of your app's sources (`"#{NAME}.*"`), not action-controller's or other shards'. It's not available on Windows. With several [workers](https://spider-gazelle.net/getting_started/configuration/#workers-and-threads), each worker is a separate process, so signal each one you want to change. ## See also - [Configuration](https://spider-gazelle.net/getting_started/configuration/index.md) - [Filters](https://spider-gazelle.net/guides/filters/index.md) - [Errors](https://spider-gazelle.net/guides/errors/index.md) - [Deployment](https://spider-gazelle.net/deployment/index.md) - [Crystal's `Log` documentation](https://crystal-lang.org/api/latest/Log.html) # Testing Spider-Gazelle apps are tested with Crystal's built-in [spec library](https://crystal-lang.org/reference/guides/testing.html). Action-controller adds a spec client that sends requests to your routes in-process, without starting a server or opening a port. This page covers request specs, unit specs, WebSockets and testing the MCP server. ## Example ``` # spec/welcome_spec.cr require "./spec_helper" describe App::Welcome do client = AC::SpecHelper.client it "welcomes you" do response = client.get("/") response.status_code.should eq 200 response.body.should eq %("You're being trampled by Spider-Gazelle!") end it "extracts params for you" do response = client.post("/api/400") JSON.parse(response.body).should eq({"result" => 400}) end end ``` Run the specs from the project root: ``` crystal spec ``` ## Setup The spec helper uses the [hot_topic](https://github.com/jgaskins/hot_topic) shard to make HTTP requests without a network. Add it as a development dependency (the template already does): ``` development_dependencies: hot_topic: github: jgaskins/hot_topic ``` Then create `spec/spec_helper.cr`: ``` require "spec" # Helper methods for testing controllers require "action-controller/spec_helper" # Your application config require "../src/config" ``` Require `src/config.cr`, not `src/app.cr`. `config.cr` loads your controllers and configuration, and `app.cr` would parse the command line and start the server. This split is why the template keeps the two files apart. See [Configuration](https://spider-gazelle.net/getting_started/configuration/#how-the-template-is-organised). Each spec file then starts with `require "./spec_helper"`. ## Request specs `AC::SpecHelper.client` returns an [`HTTP::Client`](https://crystal-lang.org/api/latest/HTTP/Client.html) connected directly to your routes. Use the normal client methods, `get`, `post`, `put`, `patch`, `delete` and `head`, with `headers:` and `body:`: ``` describe Widgets do client = AC::SpecHelper.client it "creates a widget" do response = client.post("/widgets/", headers: HTTP::Headers{"Content-Type" => "application/json"}, body: {name: "sprocket", colour: "red"}.to_json, ) response.status_code.should eq 201 Widget.from_json(response.body).name.should eq "sprocket" end # needs a YAML responder, like the one in the template's base controller it "responds with YAML when asked" do response = client.get("/widgets/1", headers: HTTP::Headers{"Accept" => "application/yaml"}) response.headers["Content-Type"].should start_with "application/yaml" end end ``` A request spec runs the whole route: param parsing, filters, the action, exception handlers and the responder. It's the closest test to a real request. Things to know: - **No middleware.** Handlers added with `ActionController::Server.before`, such as `LogHandler`, `ErrorHandler`, compression and static files, aren't run. Requests go straight to the router. - **Unhandled exceptions are raised in your spec.** Without `ErrorHandler`, an exception that no [exception handler](https://spider-gazelle.net/guides/errors/index.md) catches isn't turned into a `500`. It's raised by `client.get(...)`, so use `expect_raises` to test it. The template's base controller handles param errors, so a bad param still returns `400` or `422`. - **No cookie jar.** Cookies aren't kept between requests. Copy them yourself: ``` response = client.post("/session/?username=steve&password=secret") headers = HTTP::Headers.new response.cookies.add_request_headers(headers) client.get("/session/", headers: headers) ``` ## Unit specs To test a controller method directly, create a controller instance with `spec_instance`: ``` describe App::Welcome do it "generates a date header" do welcome = App::Welcome.spec_instance(HTTP::Request.new("GET", "/")) welcome.set_date_header.should contain("GMT") end end ``` `spec_instance(request = HTTP::Request.new("GET", "/"))` builds the controller with a context for that request. If the request matches a route, `route_params` is set from the path. Calling a method on the instance is a plain method call. Filters don't run, and you pass the arguments yourself, so nothing is parsed from the request. Use unit specs for helper methods and filters, and request specs for routes. ## Testing WebSockets The spec client adds `establish_ws(path, headers = HTTP::Headers.new)`, which opens an in-process connection to a [WebSocket route](https://spider-gazelle.net/guides/websockets/index.md) and returns an `HTTP::WebSocket`: ``` describe Notifications do client = AC::SpecHelper.client it "streams notifications" do socket = client.establish_ws("/notifications/?token=letmein") messages = [] of String socket.on_message do |message| messages << message socket.close if messages.size == 2 end socket.send "hi" socket.run # reads messages until the socket is closed messages.should eq [%({"event":"connected"}), "HI"] end it "rejects unauthenticated clients" do client.get("/notifications/").status_code.should eq 401 end end ``` `socket.run` blocks until the socket closes, so close it from a callback once you've received what you expect. If a filter rejects the connection, for example with `head :unauthorized`, `establish_ws` raises a `Socket::Error` with the status code. Test rejections with `expect_raises`, or with a plain `client.get` as above: ``` it "requires a token" do expect_raises(Socket::Error, /Status code was 401/) do client.establish_ws("/notifications/") end end ``` ## Testing your MCP server The [MCP server](https://spider-gazelle.net/mcp/index.md) is an endpoint like any other, so you can test it with JSON-RPC requests. Mount it on a `SpecHelper` router, then use that router's client. This is based on the template's `spec/mcp_spec.cr`: ``` require "./spec_helper" describe "MCP server" do router = AC::SpecHelper.new ActionController::MCPServer.mount(router, "/mcp") client = router.hot_topic headers = HTTP::Headers{ "Content-Type" => "application/json", "Accept" => "application/json", } # sends a JSON-RPC request and returns the result rpc = ->(method : String, params : JSON::Any) do body = {jsonrpc: "2.0", id: 1, method: method, params: params}.to_json response = client.post("/mcp", headers: headers, body: body) response.status_code.should eq 200 JSON.parse(response.body)["result"] end # every request after `initialize` must send the session id before_all do body = {jsonrpc: "2.0", id: 1, method: "initialize", params: {protocolVersion: "2025-11-25"}}.to_json response = client.post("/mcp", headers: headers, body: body) headers["Mcp-Session-Id"] = response.headers["Mcp-Session-Id"] end it "exposes the routes as tools" do rpc.call("tools/call", JSON.parse(%({"name": "open_toolbox", "arguments": {"name": "welcome"}}))) tools = rpc.call("tools/list", JSON.parse("{}"))["tools"].as_a.map(&.["name"].as_s) tools.should contain "welcome_api" result = rpc.call("tools/call", JSON.parse(%({"name": "welcome_api", "arguments": {"example": 42}}))) result["structuredContent"].should eq({"result" => 42}) end end ``` - `AC::SpecHelper.new` is the router behind `AC::SpecHelper.client`, and `hot_topic` returns a client for it. You need the router itself to mount the MCP server on. - `ActionController::MCPServer` comes from `require "action-controller/mcp"`, which the template's `config.cr` already includes. - Tool calls go through your routes, so filters and exception handlers apply, as they do in production. - Tool descriptions come from `mcp.yml`. If it hasn't been generated, the tools still work but have no descriptions, and a warning is logged. Generate it with `crystal run src/app.cr -- --mcp=mcp.yml` if your specs check descriptions. The template's spec also checks [prompts](https://spider-gazelle.net/mcp/index.md) with `prompts/list` and `prompts/get`, and that routes hidden with `@[AC::MCP(hide: true)]` aren't listed. ## Running specs ``` crystal spec # everything crystal spec spec/welcome_spec.cr # one file crystal spec spec/welcome_spec.cr:12 # the example on line 12 crystal spec -v --error-trace # list each example and show full backtraces ``` Add `focus: true` to an `it` or `describe` to run only that example while you work on it, and remove it before committing: ``` it "creates a widget", focus: true do # ... end ``` ### Continuous integration The template's `.github/workflows/ci.yml` checks formatting and runs the specs on every push and pull request, and once a week, against both Crystal `latest` and `nightly`. Simplified to a single Crystal version, it looks like this: ``` jobs: build: runs-on: ubuntu-latest container: crystallang/crystal:latest-alpine steps: - uses: actions/checkout@v4 - name: Install dependencies run: shards install --ignore-crystal-version - name: Format run: crystal tool format --check - name: Run tests run: crystal spec -v --error-trace ``` See [Deployment](https://spider-gazelle.net/deployment/#github-actions) for building and publishing a Docker image from CI. ## See also - [Routing](https://spider-gazelle.net/guides/routing/index.md) - [Errors](https://spider-gazelle.net/guides/errors/index.md) - [WebSockets](https://spider-gazelle.net/guides/websockets/index.md) - [Sessions and cookies](https://spider-gazelle.net/guides/sessions/index.md) - [MCP](https://spider-gazelle.net/mcp/index.md) # OpenAPI # OpenAPI Spider-Gazelle generates an [OpenAPI 3](https://spec.openapis.org/oas/v3.0.3) description of your API from the code you already write: route annotations, method signatures and doc comments. There's no separate spec file to maintain. ``` # Manages the comments on articles class Comments < AC::Base base "/articles/:article_id/comments" # Lists the comments on an article # # Comments are returned newest first. @[AC::Route::GET("/")] def index( article_id : Int64, @[AC::Param::Info(description: "only return comments by this author", example: "steve")] author : String? = nil, ) : Array(Comment) Comment.for(article_id, author) end end ``` That produces a `GET /articles/{article_id}/comments` operation with: - a summary ("Lists the comments on an article") and a description; - a required `article_id` path parameter (`integer`) and an optional, described `author` query parameter; - a `200` response containing an array of `Comment`, with the `Comment` JSON schema generated from the class; - a "Comments" tag, so the operation is grouped with the controller's other routes. ## Where each part comes from | OpenAPI | Comes from | | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | Path summary/description | doc comment on the controller class | | Operation summary | first line of the method's doc comment | | Operation description | the whole doc comment, when it's more than one line | | `operationId`, tags | `Controller_method`, and the controller's name | | Path parameters | `:name` (required) and `?:name` (optional) segments in the route | | Query parameters | other method arguments. Required unless nilable or defaulted | | Header parameters | `@[AC::Param::Info(header: "X-Name")]` | | Parameter description/example | `@[AC::Param::Info(description:, example:)]` | | Parameter schemas | argument types (`Int32`, `UUID`, enums, `Time`, ...) | | Request body | the `body:` argument's type, for each registered parser content type | | Responses | the return type, `status_code:` and `status:` maps, and your exception handlers | | Schemas | `JSON::Serializable` types via [json-schema](https://github.com/spider-gazelle/json-schema), with `@[JSON::Field]` hints | | Filter parameters | arguments of the filters that apply to the route | ## Why it's worth it One source of truth The OpenAPI document is generated from the same code that handles requests, so it can't describe a parameter that doesn't exist or miss one that does. Change a type and the schema changes; rename an argument and the parameter is renamed. Reviewers read one diff and the docs are always current. The document is generally useful too: - Browse and try your API in [Swagger UI](https://editor.swagger.io/), Redoc or Scalar. - Generate typed API clients for TypeScript, Python, Swift, Kotlin and more with [OpenAPI Generator](https://openapi-generator.tech/). - Contract test, mock, and import into API gateways. The same metadata also powers the [MCP server](https://spider-gazelle.net/mcp/index.md). Improving your OpenAPI descriptions improves the tools your AI agents see. ## In this section - [Describing routes](https://spider-gazelle.net/openapi/descriptions/index.md): doc comments, parameters, headers, request bodies and responses. - [Schemas](https://spider-gazelle.net/openapi/schemas/index.md): how types become JSON schema, and how to refine it. - [Generating and serving](https://spider-gazelle.net/openapi/generating/index.md): the CLI, Docker builds, serving the document, client generation and troubleshooting. # Describing routes This page shows how to make each operation in your OpenAPI document as useful as possible. The same comments and annotations describe your [MCP tools](https://spider-gazelle.net/mcp/index.md), so this work pays off twice. ## Summaries and descriptions Doc comments directly above a route become its summary and description: - the **first line** is the summary; - if there's more than one line, the **whole comment** is also the description. ``` # Comments left on articles class Comments < AC::Base base "/comments" # Lists every comment @[AC::Route::GET("/")] def index : Array(Comment) Comment.all end # Creates a comment # # The author is taken from the signed in user. Markdown is supported in the body. @[AC::Route::POST("/", body: :comment, status_code: HTTP::Status::CREATED)] def create(comment : Comment) : Comment comment.save! end end ``` The comment above the **controller class** describes the controller's paths. It's also used as the [MCP toolbox](https://spider-gazelle.net/mcp/#how-agents-see-your-api) description. Keep developer notes out of doc comments Everything in the doc comment is published. Put implementation notes in a separate comment block, separated from the doc comment by a blank line: ``` # NOTE: cached for 5 minutes, see CacheConfig # Lists every comment @[AC::Route::GET("/")] def index : Array(Comment) ``` Comments are inherited: if a method is defined on a parent controller, its doc comment is used for the routes of every subclass. ## Parameters Every method argument is a parameter. Its location comes from the route: | Argument | Location | Required | | ------------------------------------------ | -------------------- | --------------------------- | | Matches a `:name` segment | path | yes | | Matches a `?:name` segment | path | no | | Has `@[AC::Param::Info(header: "X-Name")]` | header | unless nilable or defaulted | | The `body:` argument | request body | yes | | Anything else | query (or form data) | unless nilable or defaulted | Describe parameters with `@[AC::Param::Info]`: ``` @[AC::Route::GET("/")] def index( @[AC::Param::Info(description: "only return comments by this author", example: "steve")] author : String? = nil, @[AC::Param::Info(description: "maximum number of results", example: "20")] limit : Int32 = 20, @[AC::Param::Info(name: "q", description: "full text search", example: "crystal")] query : String? = nil, @[AC::Param::Info(header: "X-Tenant", description: "the tenant to query", example: "acme")] tenant : String? = nil, ) : Array(Comment) ``` | `Param::Info` option | Effect | | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | `description:` | parameter description | | `example:` | example value, as a string | | `name:` | the public parameter name, when it differs from the argument (`?q=` → `query`) | | `header:` | read from this request header rather than the query string | | `class:`, `config:` | a [custom converter](https://spider-gazelle.net/guides/parameters/index.md) and its options. The schema still comes from the argument type | Note Whether a parameter is required comes from the type: nilable or defaulted arguments are optional, everything else is required. There's no `required:` option to keep in sync. ### Parameters from filters Filters can take typed parameters too. When a filter applies to a route, its parameters are added to that route's operation: ``` abstract class Application < AC::Base @[AC::Route::Filter(:before_action)] def set_tenant( @[AC::Param::Info(header: "X-Tenant", description: "the tenant to use")] tenant : String, ) @tenant = Tenant.find!(tenant) end end ``` Every route in every controller inheriting from `Application` documents the `X-Tenant` header. ## Request bodies Name the argument that holds the body with `body:`: ``` @[AC::Route::POST("/", body: :comment)] def create(comment : Comment) : Comment ``` The request body schema is the argument's type. It's listed once for each content type your app can parse (JSON by default; see [parsers](https://spider-gazelle.net/guides/parameters/index.md)). ## Responses The return type is the response schema. The status code defaults to `200`: ``` # 201 Created, with a Comment body @[AC::Route::POST("/", body: :comment, status_code: HTTP::Status::CREATED)] def create(comment : Comment) : Comment # 202 Accepted, with no body @[AC::Route::DELETE("/:id", status_code: HTTP::Status::ACCEPTED)] def destroy(id : Int64) : Nil ``` Map different return types to different status codes with `status:`. Each mapping is documented as its own response: ``` @[AC::Route::GET("/:id", status: { Comment => HTTP::Status::OK, CommentRedirect => HTTP::Status::SEE_OTHER, })] def show(id : Int64) : Comment | CommentRedirect ``` Responses are listed for each content type your app can render (see [responders](https://spider-gazelle.net/guides/responses/index.md)). JSON and YAML responses reference the schema; `text/*` responses are strings. ### Error responses [Exception handlers](https://spider-gazelle.net/guides/errors/index.md) that apply to a route add their responses to its operation. Handlers in a base class document the errors for every route that inherits them: ``` abstract class Application < AC::Base # 404 Not Found, with a CommonResponse body, on every route @[AC::Route::Exception(AC::Error::NotFound, status_code: HTTP::Status::NOT_FOUND)] def not_found(error) : AC::Error::CommonResponse AC::Error::CommonResponse.new(error, backtrace: false) end end ``` Declare return types Without a return type the operation has no response schema. Declaring one also has the compiler check your method returns what you documented. ## What isn't included - Routes defined with the macro DSL (`get "/" do ... end`) aren't documented. Use annotations for anything you want described. - `@[AC::Route::OPTIONS]` routes aren't documented. - Handlers registered with [`rescue_from`](https://spider-gazelle.net/guides/errors/#rescue_from) don't add responses. Only annotated exception handlers do. - MCP prompts (`@[AC::MCP(prompt: true)]`) aren't HTTP routes, so they aren't in the document. ## See also - [Schemas](https://spider-gazelle.net/openapi/schemas/index.md) - [Parameters guide](https://spider-gazelle.net/guides/parameters/index.md) - [Generating and serving](https://spider-gazelle.net/openapi/generating/index.md) # Schemas Every type that appears in a route, whether a parameter, request body or response, is described with [JSON Schema](https://json-schema.org/). The schemas are generated at compile time by the [json-schema](https://github.com/spider-gazelle/json-schema) shard, so they always match your actual types. ## Types you get for free | Crystal type | Schema | | ---------------------------------- | ------------------------------------------------------------- | | `String` | `string` | | `Int32`, `Int64`, `UInt8`, ... | `integer` with a `format` such as `Int64` | | `Float32`, `Float64` | `number` | | `Bool` | `boolean` | | `Time` | `string`, `format: date-time` | | `UUID` | `string`, `format: uuid` | | `Enum` | `string` with an `enum` list of the member names (snake case) | | `Array(T)`, `Set(T)`, `Tuple` | `array` | | `Hash(String, T)` | `object` with `additionalProperties` | | `NamedTuple`, `JSON::Serializable` | `object` with `properties` and `required` | | \`A | B\` | ## Your models Include `JSON::Serializable` and the schema follows your class. Required properties are those that aren't nilable and have no default, matching how `from_json` behaves. ``` # A comment left on an article class Comment include JSON::Serializable getter id : Int64 getter author : String @[JSON::Field(description: "markdown formatted")] getter body : String @[JSON::Field(key: "created_at")] getter created : Time @[JSON::Field(ignore: true)] getter cache_key : String = "" end ``` - The **class doc comment** becomes the schema's description. - `key:` renames properties, and `ignore: true` leaves them out, exactly as the JSON serialiser does. - `description:` documents an individual property. ## Refining a property `@[JSON::Field]` accepts JSON Schema keywords that tighten validation hints in the document: ``` class Signup include JSON::Serializable @[JSON::Field(format: "email")] getter email : String @[JSON::Field(min_length: 8, max_length: 64)] getter password : String @[JSON::Field(pattern: "^[a-z0-9_]+$")] getter username : String @[JSON::Field(minimum: 13, maximum: 130)] getter age : Int32 # stored as a Time, but the JSON value is a unix timestamp @[JSON::Field(converter: Time::EpochConverter, type: "integer", format: "Int64")] getter joined : Time end ``` The supported keys are `type`, `format`, `pattern`, `min_length`, `max_length`, `multiple_of`, `minimum`, `exclusive_minimum`, `maximum`, `exclusive_maximum` and `description`. Note These are documentation hints. They don't add runtime validation. Validate in your model or with a library such as [active-model](https://github.com/spider-gazelle/active-model). When you use a `converter:`, override `type` and `format` so the schema describes the JSON value rather than the Crystal type. ## Custom types If a type serialises itself some other way, describe it by implementing `self.json_schema`. Return a `NamedTuple` (or anything with `to_json`) in JSON Schema form: ``` struct Money def self.json_schema(openapi : Bool? = nil) {type: "string", pattern: "^\\d+\\.\\d{2}$", description: "an amount, e.g. 12.50"} end def to_json(json : JSON::Builder) json.string(to_s) end end ``` ## How schemas appear in the document Request and response types are listed once under `components/schemas` and referenced with `$ref`, so shared models are described in one place. Parameter schemas are inlined. In [MCP tools](https://spider-gazelle.net/mcp/index.md), each tool's input schema is self-contained: the referenced schemas are included as `$defs`, and nullable types are expressed in standard JSON Schema. ## See also - [Describing routes](https://spider-gazelle.net/openapi/descriptions/index.md) - [json-schema shard](https://github.com/spider-gazelle/json-schema) # Generating and serving This page covers producing the OpenAPI document from your app, at build time or from the command line, and then serving it, committing it and generating clients from it. ## How generation works Most of the document is built at compile time from your annotations and types. Doc comments aren't available to compiled code, so they're extracted by running `crystal docs` over your source when the document is generated. Generate where the source code is Generation needs the `crystal` compiler, your `src/` directory and `shard.yml` (for `crystal docs`). Generate the document at build time, or during development. A deployed binary without its source can't generate it. ## From the command line The [application template](https://github.com/spider-gazelle/spider-gazelle) adds a `--docs` flag: ``` crystal build src/app.cr -o app ./app --docs # print the YAML document ./app --docs --file=openapi.yml # save it to a file ``` Behind the flag is a single call that you can use in any app: ``` require "action-controller" require "action-controller/server" docs = ActionController::OpenAPI.generate_open_api_docs( title: "My API", version: "1.0.0", description: "Everything you need to manage widgets", ) File.write("openapi.yml", docs.to_yaml) ``` `title` and `version` are required. Any other [info object](https://spec.openapis.org/oas/v3.0.3#info-object) fields, such as `description`, `termsOfService` or `contact`, can be passed as named arguments. The result is a `NamedTuple`, so `.to_json` works too. ## In Docker builds The template's Dockerfile generates the document in the build stage, where the source is available, and copies it into the final image: ``` # Generate OpenAPI docs and MCP tool descriptions while we still have source code access RUN ./bin/app --docs --file=openapi.yml && \ ./bin/app --mcp=mcp.yml # ... COPY --from=build /app/openapi.yml /openapi.yml ``` ## Serving the document Load the generated file at startup and return it from a route: ``` class Docs < AC::Base base "/" # generated during the docker build OPENAPI = YAML.parse(File.exists?("openapi.yml") ? File.read("openapi.yml") : "{}") # returns the OpenAPI representation of this service @[AC::MCP(hide: true)] @[AC::Route::GET("/openapi")] def openapi : YAML::Any OPENAPI end end ``` `@[AC::MCP(hide: true)]` keeps the document out of your [MCP tools](https://spider-gazelle.net/mcp/index.md): it's for people and code generators, not models. Point [Swagger UI](https://editor.swagger.io/), [Redoc](https://redocly.github.io/redoc/) or [Scalar](https://scalar.com/) at `/openapi` to browse it. ## Committing the document Many projects also commit the generated file (e.g. `OPENAPI_DOC.yml`). Reviewers can then see API changes in pull requests, and clients can be generated from the repository. Regenerate it whenever routes change: ``` crystal build src/app.cr -o app && ./app --docs > OPENAPI_DOC.yml && rm app ``` ## Generating clients Any OpenAPI tool can consume the document. For example, a TypeScript client with [OpenAPI Generator](https://openapi-generator.tech/): ``` npx @openapitools/openapi-generator-cli generate \ -i openapi.yml -g typescript-fetch -o ./client ``` Because the document is generated from the server code, regenerating the client after an API change gives you compile errors exactly where your frontend needs updating. ## Troubleshooting | Symptom | Cause | | -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | `failed to obtain route descriptions via 'crystal docs'` | generation ran without the source code, `crystal`, or `shard.yml` in the working directory | | A route is missing | it was defined with the macro DSL (`get "/" do`), so use an annotation, or it's an `OPTIONS` route, which isn't documented | | No response schema | the method has no return type | | A summary contains notes meant for developers | separate those notes from the doc comment with a blank line | ## See also - [Describing routes](https://spider-gazelle.net/openapi/descriptions/index.md) - [MCP setup](https://spider-gazelle.net/mcp/setup/index.md), which uses the same build step for `mcp.yml` - [Deployment](https://spider-gazelle.net/deployment/index.md) # MCP # MCP server Spider-Gazelle can expose your API to AI agents as a [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server. Claude, VS Code, Cursor and other MCP clients can then discover and call your routes as **tools**, and offer your **prompts** to users. There are no tool definitions to write: they're generated from the same annotations, types and doc comments that drive routing and [OpenAPI](https://spider-gazelle.net/openapi/index.md). ``` claude mcp add --transport http my-app http://localhost:3000/mcp ``` ## Why it's built in Your API is already the tool definition Hand-written tool definitions duplicate your API, and they drift: a renamed parameter or a changed type quietly breaks the agent. In Spider-Gazelle the tool *is* the route: - the **description** is the method's doc comment; - the **input schema** is generated from the argument types, `@[AC::Param::Info]` and the request body type; - a **call** runs the real route in-process, with the same parameter parsing, filters (authentication!), error handlers and responders as an HTTP request. Improve a doc comment and your OpenAPI docs and agent tools both get better. Fix a validation bug and it's fixed for agents too. ## How agents see your API Large APIs have hundreds of routes, and listing them all would fill the model's context before it does any work. Spider-Gazelle uses **progressive disclosure** instead: - **Toolboxes:** each controller is a toolbox, described by the controller's doc comment. - **Starting tools:** a session starts with just three tools: | Tool | Purpose | | --------------------- | ------------------------------------------------------------------- | | `list_toolboxes` | lists the toolboxes, their descriptions, and tool and prompt counts | | `open_toolbox(name)` | adds a toolbox's tools and prompts to the session | | `close_toolbox(name)` | removes them again | - **Loading on demand:** the model opens only the toolboxes it needs. The client is notified (`tools/list_changed`) and refreshes its tool list. - **Root items:** anything marked `root: true` is always available without opening a toolbox. Use it for the few tools and prompts most sessions need. ``` # Manage meeting rooms and their bookings class Rooms < AC::Base base "/rooms" # Lists the rooms in a building @[AC::Route::GET("/")] def index(building_id : String) : Array(Room) Room.where(building_id: building_id).to_a end end ``` Here the toolbox is `rooms` ("Manage meeting rooms and their bookings"), and opening it adds the `rooms_index` tool ("Lists the rooms in a building"). ## Naming - **Toolbox names:** the snake case controller name. The module namespace shared by every controller is left out, so `MyApp::Api::Rooms` and `MyApp::Api::Rooms::Bookings` become `rooms` and `rooms_bookings`. - **Tool and prompt names:** `_`, such as `rooms_index`. - **Multiple routes:** a method with several route annotations is one tool. It uses the method's first `GET` route, otherwise its first route in verb order (`POST`, `PUT`, `PATCH`, `DELETE`). ## What's exposed | | Exposed? | | ------------------------------------------- | ------------------------------------------------------------------ | | Annotated routes (`@[AC::Route::GET]` etc.) | yes, as tools | | Methods marked `@[AC::MCP(prompt: true)]` | yes, as [prompts](https://spider-gazelle.net/mcp/prompts/index.md) | | Routes marked `@[AC::MCP(hide: true)]` | no | | WebSocket and `OPTIONS` routes | no | | Macro DSL routes (`get "/" do`) | no | ## Calls run as the user Tool calls forward the client's `Authorization`, `Cookie` and `X-API-Key` headers to the route, so your existing authentication applies to every call. For OAuth sign-in from MCP clients, see [Authentication](https://spider-gazelle.net/mcp/authentication/index.md). ## In this section - [Setup](https://spider-gazelle.net/mcp/setup/index.md): mounting the server, generating `mcp.yml`, connecting clients and testing. - [Prompts and visibility](https://spider-gazelle.net/mcp/prompts/index.md): prompts, `root`, and `hide`. - [Authentication](https://spider-gazelle.net/mcp/authentication/index.md): API keys, and OAuth sign-in with multi_auth and authly. - [Configuration](https://spider-gazelle.net/mcp/configuration/index.md): every option, and transport details. # Setting up MCP The [application template](https://github.com/spider-gazelle/spider-gazelle) has MCP set up already: run the app and connect a client to `http://localhost:3000/mcp`. This page explains each piece, so you can add MCP to an existing app or customise the template. ## 1. Require and configure MCP is an optional part of action-controller. Require it after your controllers, and configure it in `src/config.cr`: ``` require "action-controller" require "./controllers/*" require "action-controller/server" require "action-controller/mcp" ActionController::MCPServer.tap do |mcp| mcp.server_name = "my-app" mcp.server_version = "1.0.0" mcp.description_path = ENV["SG_MCP_DESCRIPTION"]? || "mcp.yml" end ``` ## 2. Mount the endpoint Mount the server on your `ActionController::Server` when it's created (in the template, `src/app.cr`): ``` server = ActionController::Server.new(port, host) ActionController::MCPServer.mount(server, "/mcp") server.run ``` `mount` registers `POST`, `GET` and `DELETE` handlers at the path for the [Streamable HTTP](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http) transport. In the template the path comes from `SG_MCP_PATH`; set it to an empty string to disable MCP. ## 3. Generate the tool descriptions Tool and toolbox descriptions come from your doc comments. Like the [OpenAPI document](https://spider-gazelle.net/openapi/generating/index.md), these are extracted with `crystal docs`, so they're generated where the source is available and shipped with the binary as `mcp.yml`: ``` ./app --mcp=mcp.yml ``` The template's flag calls: ``` ActionController::MCPServer.write_description("mcp.yml") ``` The Dockerfile generates it during the build and copies it next to the binary: ``` RUN ./bin/app --docs --file=openapi.yml && \ ./bin/app --mcp=mcp.yml COPY --from=build /app/mcp.yml /mcp.yml ``` `mcp.yml` is loaded the first time a client connects. If it's missing, the server builds the tools from the compiled routes alone and logs a warning; everything works, but the model sees no descriptions. Write descriptions for the model The model chooses tools by their descriptions. A one-line summary of what a route does, plus `@[AC::Param::Info(description:)]` on non-obvious parameters, makes a big difference. These are the same comments that document your OpenAPI. ## 4. Connect a client ``` claude mcp add --transport http my-app http://localhost:3000/mcp # with an API key claude mcp add --transport http my-app http://localhost:3000/mcp \ --header "X-API-Key: " ``` ``` { "mcpServers": { "my-app": { "type": "http", "url": "https://my-app.example.com/mcp" } } } ``` `.vscode/mcp.json`: ``` { "servers": { "my-app": { "type": "http", "url": "http://localhost:3000/mcp" } } } ``` The [MCP Inspector](https://github.com/modelcontextprotocol/inspector) is the best way to see exactly what your server exposes: ``` npx @modelcontextprotocol/inspector ``` Choose **Streamable HTTP** and enter `http://localhost:3000/mcp`. ## 5. Test it The spec helper drives the MCP endpoint in-process like any other route: ``` require "./spec_helper" describe "MCP" do router = AC::SpecHelper.new ActionController::MCPServer.mount(router, "/mcp") client = router.hot_topic headers = HTTP::Headers{"Content-Type" => "application/json", "Accept" => "application/json"} it "lists the toolboxes" do init = client.post("/mcp", headers: headers, body: { jsonrpc: "2.0", id: 1, method: "initialize", params: {protocolVersion: "2025-11-25"}, }.to_json) headers["Mcp-Session-Id"] = init.headers["Mcp-Session-Id"] response = client.post("/mcp", headers: headers, body: { jsonrpc: "2.0", id: 2, method: "tools/call", params: {name: "list_toolboxes", arguments: {} of String => String}, }.to_json) toolboxes = JSON.parse(response.body)["result"]["structuredContent"]["toolboxes"] toolboxes.as_a.map(&.["name"]).should contain "welcome" end end ``` See the template's `spec/mcp_spec.cr` for tool calls and prompts. ## See also - [Prompts and visibility](https://spider-gazelle.net/mcp/prompts/index.md) - [Authentication](https://spider-gazelle.net/mcp/authentication/index.md) - [Configuration](https://spider-gazelle.net/mcp/configuration/index.md) # Prompts and visibility `@[AC::MCP]` controls how a controller or method appears to MCP clients: | Option | Applies to | Effect | | -------------- | ---------------------- | ----------------------------------------------- | | `prompt: true` | methods | the method is an MCP prompt rather than a route | | `root: true` | controllers or methods | always available, without opening the toolbox | | `hide: true` | controllers or methods | not exposed over MCP | On a controller, `root` and `hide` apply to every route and prompt in it. They aren't inherited by subclasses, so annotate each controller. A method level annotation takes precedence, so you can hide a controller but expose one route: ``` @[AC::MCP(hide: true)] class Internal < AC::Base # exposed even though the rest of the controller is hidden @[AC::MCP(hide: false)] @[AC::Route::GET("/status")] def status : String "ok" end end ``` ## Prompts [Prompts](https://modelcontextprotocol.io/specification/2025-11-25/server/prompts) are reusable message templates that users pick in their MCP client, often shown as slash commands. They're a good way to package your domain knowledge: what to ask, which tools to use, and in what order. ``` class Welcome < AC::Base base "/" # Learn a surprising fact about a number @[AC::MCP(prompt: true, root: true)] def number_fact( @[AC::Param::Info(description: "the number to share a fact about", example: "42")] number : Int32, @[AC::Param::Info(description: "who the fact is for", example: "a curious five year old")] audience : String = "a general audience", ) : String <<-PROMPT Share one surprising fact about the number #{number}, explained for #{audience}. Use the welcome toolbox to confirm the number with this service first. PROMPT end end ``` - **Description:** the doc comment is what users see in their client. - **Arguments:** the method's arguments, described with `@[AC::Param::Info]`. They're required unless nilable or defaulted, exactly like route parameters. - **Return type:** must be declared, and must be `String` (one user message) or `Array(AC::PromptMessage)` (a conversation). ### Conversations Return messages to prime a multi-turn exchange: ``` # Review a pull request with the team's checklist @[AC::MCP(prompt: true)] def review(id : Int64) : Array(AC::PromptMessage) pr = PullRequest.find!(id) [ AC::PromptMessage.user("Review pull request ##{id}: #{pr.title}\n\n#{pr.diff}"), AC::PromptMessage.assistant("I'll check it against the checklist. Which areas worry you most?"), ] end ``` ### Prompts run like routes A prompt isn't an HTTP route, so it can't be reached over HTTP and isn't in your OpenAPI document. Internally, though, it runs through the same pipeline as a route: - **Parameters** are parsed and validated by the same converters, including `config:` and custom converters. - **Filters** run: `before_action` authentication, loading records, and so on. - **Exception handlers** apply. So a prompt can safely load and embed data, such as the pull request above, with the user's permissions enforced. Warning A prompt that is also a route is a compile error, and so is a prompt without a `String` or `Array(AC::PromptMessage)` return type. ## Root tools and prompts Toolboxes keep the model's context small, but a few tools are needed in almost every session. Mark them `root: true`: ``` class Rooms < AC::Base # always listed, no need to open the rooms toolbox @[AC::MCP(root: true)] @[AC::Route::GET("/search")] def search(query : String) : Array(Room) Room.search(query) end end # every route and prompt in the controller is a root item @[AC::MCP(root: true)] class Me < AC::Base end ``` Root items keep their `_` names. A toolbox that only contains root items isn't listed by `list_toolboxes`, as there's nothing to open. ## Hiding routes Hide routes that aren't useful or safe for a model, such as health checks, webhooks, binary downloads or large documents: ``` # the OpenAPI document is for people and code generators, not models @[AC::MCP(hide: true)] @[AC::Route::GET("/openapi")] def openapi : YAML::Any OPENAPI end ``` Hiding isn't access control `hide: true` only removes a tool from the MCP listing; the HTTP route still works. Protect routes with [filters](https://spider-gazelle.net/guides/filters/index.md) as usual. Tool calls run your filters too. ## See also - [MCP overview](https://spider-gazelle.net/mcp/index.md) - [Authentication](https://spider-gazelle.net/mcp/authentication/index.md) - [Filters](https://spider-gazelle.net/guides/filters/index.md) # Authentication By default your MCP endpoint is open, and each tool call is authenticated by your routes' own filters, using the headers the client sends. That's enough for many apps. This page covers the options, from API keys up to a complete OAuth sign-in for MCP clients, built with [multi_auth](https://github.com/msa7/multi_auth) and [authly](https://github.com/azutoolkit/authly). ## Choosing an approach | Approach | Use it when | Configuration | | ------------------- | ----------------------------------------------------------------------------------- | --------------------------------------------------------------------- | | **Route auth only** | the API is public, or every client can send a fixed header | nothing | | **API keys** | agents and scripts with long lived credentials | `auth_probe`, and the client sends a header | | **OAuth sign-in** | people connect from Claude, VS Code, Cursor, ... and sign in with their own account | `auth_probe` + `resource_metadata`, and an OAuth authorization server | Whichever you choose, tool calls run your real routes with the client's `Authorization`, `Cookie` and `X-API-Key` headers. Permissions are enforced exactly as they are for HTTP requests. Your MCP server can't do anything the user couldn't do with the API directly. ## Why MCP needs more than route auth MCP clients only sign in, or refresh an expired token, when the **MCP endpoint itself** responds with HTTP `401` and a `WWW-Authenticate` challenge. With route auth alone, connecting always succeeds, and a `401` from a route reaches the model as a failed tool call it can't fix. Enabling authentication moves the check to the endpoint: - **Every request is checked:** `initialize`, tool calls, and the notification stream. Failures get a real `401`. - **Token expiry is caught:** if a route rejects a tool call with `401` (an expired token, say), the endpoint answers `401` so the client refreshes and retries. - **Checks are cached:** successful checks are cached for each credential for `auth_cache_ttl` (1 minute), so your auth route isn't called on every message. ## API keys Point `auth_probe` at any route that requires authentication. It's called in-process with the client's headers, and a 2xx response means the request is authenticated: ``` ActionController::MCPServer.auth_probe = "/api/users/current" ``` Clients send their key on every request: ``` claude mcp add --transport http my-app https://my-app.example.com/mcp \ --header "X-API-Key: " ``` Need logic that isn't a route? Use `authenticator` instead: ``` ActionController::MCPServer.authenticator = ->(request : HTTP::Request) do ApiKey.valid?(request.headers["X-API-Key"]?) end ``` ## OAuth sign-in For people, OAuth is seamless: they add your server's URL to their client, a browser opens, they sign in and approve the client, and they're connected. Tokens refresh automatically. ### How it works MCP builds on standard OAuth: protected resource metadata ([RFC 9728](https://www.rfc-editor.org/rfc/rfc9728)), authorization server metadata ([RFC 8414](https://www.rfc-editor.org/rfc/rfc8414)), dynamic client registration ([RFC 7591](https://www.rfc-editor.org/rfc/rfc7591)) and the authorization code flow with [PKCE](https://www.rfc-editor.org/rfc/rfc7636): ``` sequenceDiagram participant C as MCP client participant S as Your app (/mcp) participant A as Authorization server participant P as GitHub (via multi_auth) C->>S: initialize S-->>C: 401 + resource_metadata URL C->>S: GET /.well-known/oauth-protected-resource/mcp C->>A: GET /.well-known/oauth-authorization-server C->>A: POST /oauth/register (dynamic registration) C->>A: browser: /oauth/authorize (PKCE, resource) A->>P: not signed in: sign in with GitHub P-->>A: callback, user identified A-->>C: consent approved: redirect with code C->>A: POST /oauth/token (code + verifier) A-->>C: access + refresh tokens C->>S: initialize (Authorization: Bearer ...) ``` Spider-Gazelle provides the first half: the challenge and the protected resource metadata are served by `MCPServer` once you configure `resource_metadata`: ``` ActionController::MCPServer.tap do |mcp| mcp.auth_probe = "/api/users/current" mcp.resource_metadata = ->(request : HTTP::Request) do ActionController::MCPServer::ResourceMetadata.new( authorization_servers: ["https://#{request.hostname}"], scopes_supported: ["api"], ) end end ``` The authorization server is either an existing identity platform that supports dynamic client registration, or part of your app. The rest of this page builds one into your app: **multi_auth** answers "who is this person?" (GitHub, Google, and other providers) and **authly** answers "give this MCP client a token". ## Worked example: multi_auth + authly A small notes API that MCP clients sign in to with GitHub. It's one app that is both the **resource server** (the API and `/mcp`) and the **authorization server**. Tested This example was built and tested end to end with action-controller 8.3.2, authly `master` (`3e96031`) and multi_auth `master` (`f1f60bf`). The test covered registration, sign-in, consent, PKCE token exchange, MCP calls with the token, refresh, and rejection of replayed codes, forged `state` and wrong verifiers. | File | Role | | ---------------------------------- | -------------------------------------------------------------------- | | `src/config.cr` | configures the session, authly, multi_auth and the MCP server | | `src/controllers/api.cr` | the protected API, verifying access tokens in a `before_action` | | `src/controllers/sessions.cr` | signs users in with multi_auth, checking `state` | | `src/controllers/authorization.cr` | metadata, registration, authorize (with consent) and token endpoints | | `src/oauth/*.cr` | client registry, token minting and the authly fixes | ### 1. Dependencies shard.yml ``` name: mcp-auth-example version: 0.1.0 targets: app: main: src/app.cr dependencies: action-controller: github: spider-gazelle/action-controller version: ~> 8.3 # OAuth 2 authorization server library authly: github: azutoolkit/authly branch: master # sign in with GitHub, Google, etc. master adds `state` to `authorize_uri` multi_auth: github: msa7/multi_auth branch: master ``` Use `master` for both shards multi_auth's tagged release can't pass a `state` to the provider, which you need for CSRF protection. The authly fixes below target `master`. ### 2. Configuration The app is its own issuer, and the MCP server advertises it in the protected resource metadata: src/config.cr ``` require "action-controller" require "action-controller/mcp" require "authly" require "multi_auth" require "multi_auth/providers/github" module App # the public URL of this app, it's both the MCP resource and the OAuth issuer URL = ENV["APP_URL"]? || "http://localhost:3000" # the scope MCP clients request SCOPE = "api" # DEVELOPMENT ONLY: adds a "dev" sign in that skips the identity provider DEV_LOGIN = ENV["DEV_LOGIN"]? == "1" # is this a resource indicator (RFC 8707) for this app? def self.resource?(resource : String) : Bool resource == URL || resource.starts_with?("#{URL}/") end end require "./models/*" require "./oauth/*" require "./controllers/application" require "./controllers/*" # the server is required after the controllers require "action-controller/server" ActionController::Server.before( ActionController::ErrorHandler.new(ENV["SG_ENV"]? == "production", ["X-Request-ID"]), ActionController::LogHandler.new(["code", "code_verifier", "refresh_token", "state"], ms: true) ) ActionController::Session.configure do |settings| settings.key = "_mcp_example_session" settings.secret = ENV["SESSION_SECRET"]? || Random::Secure.hex(64) settings.secure = App::URL.starts_with?("https://") end # the OAuth authorization server Authly.configure do |config| # HS256 signs and verifies with the same key jwt_secret = ENV["JWT_SECRET"]? || Random::Secure.hex(32) config.secret_key = jwt_secret config.public_key = jwt_secret config.issuer = App::URL config.code_ttl = 5.minutes config.access_ttl = 1.hour config.refresh_ttl = 30.days config.clients = AuthServer::CLIENTS end # users sign in with GitHub MultiAuth.config("github", ENV["GITHUB_CLIENT_ID"]? || "", ENV["GITHUB_CLIENT_SECRET"]? || "") # the MCP server challenges clients that don't present a valid access token ActionController::MCPServer.tap do |mcp| mcp.server_name = "notes" mcp.auth_probe = "/api/users/current" mcp.resource_metadata = ->(_request : HTTP::Request) do ActionController::MCPServer::ResourceMetadata.new( authorization_servers: [App::URL], scopes_supported: [App::SCOPE], ) end end ``` src/app.cr ``` require "./config" port = ENV["PORT"]?.try(&.to_i) || 3000 server = ActionController::Server.new(port, "127.0.0.1") ActionController::MCPServer.mount(server, "/mcp") Signal::INT.trap { server.close } server.run { puts "Listening on #{server.print_addresses}" } ``` ### 3. Signing in with multi_auth multi_auth handles the provider round trip. It doesn't validate the OAuth `state` parameter, so store a random value in the session and check it in the callback. That stops an attacker from logging a victim in to the attacker's account. src/controllers/sessions.cr ``` # signs users in with an identity provider, using multi_auth @[AC::MCP(hide: true)] class Sessions < Application base "/auth" PROVIDERS = ["github"] # the sign in page @[AC::Route::GET("/")] def index providers = App::DEV_LOGIN ? PROVIDERS + ["dev"] : PROVIDERS links = providers.map { |name| %(
  • Sign in with #{name}
  • ) } render html: page("Sign in", "
      #{links.join}
    ") end # redirects to the identity provider @[AC::Route::GET("/:provider")] def sign_in(provider : String) # DEVELOPMENT ONLY: sign in without an identity provider if provider == "dev" && App::DEV_LOGIN return signed_in User.save(User.new("dev:developer", "Developer")) end # multi_auth doesn't validate `state`, we check it in the callback state = Random::Secure.urlsafe_base64(32) session["oauth_state"] = state redirect_to engine(provider).authorize_uri(state: state), :see_other end # the identity provider redirects back here @[AC::Route::GET("/:provider/callback")] def callback(provider : String, state : String = "") expected = session.delete("oauth_state").to_s if expected.empty? || !Crypto::Subtle.constant_time_compare(expected, state) render :bad_request, text: "invalid sign in state, please try again" end engine = engine(provider) auth = begin engine.user(request.query_params) rescue error Log.warn(exception: error) { "#{provider} sign in failed" } render :unauthorized, text: "sign in failed" end signed_in User.from(auth) end private def engine(provider : String) : MultiAuth::Engine raise AuthServer::Error.new("invalid_request", "unknown provider", :not_found) unless provider.in?(PROVIDERS) MultiAuth.make(provider, "#{App::URL}/auth/#{provider}/callback") end # continues the OAuth authorization request that required sign in private def signed_in(user : User) session["user_id"] = user.id redirect_to session.delete("return_to").try(&.to_s) || "/auth", :see_other end end ``` src/models/user.cr ``` # a signed in person. Stored in memory for the example, use your database struct User include JSON::Serializable getter id : String getter name : String getter email : String? def initialize(@id, @name, @email = nil) end STORE = {} of String => User def self.find?(id : String) : User? STORE[id]? end def self.save(user : User) : User STORE[user.id] = user end # finds or creates the user for an identity provider account def self.from(auth : MultiAuth::User) : User save new("#{auth.provider}:#{auth.uid}", auth.name || auth.nickname || auth.uid, auth.email) end end ``` GitHub OAuth app Create an OAuth app in GitHub (**Settings → Developer settings → OAuth Apps**) and set its callback URL to `/auth/github/callback`. multi_auth's GitHub provider relies on the registered callback. Then set `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET`. ### 4. The authorization server These endpoints are what MCP clients talk to: - **Metadata:** `/.well-known/oauth-authorization-server`, where clients discover the endpoints. - **Registration:** `/oauth/register`. Every client is public (no secret), so PKCE proves possession. - **Authorize:** `/oauth/authorize`. It sends the user to sign in if needed, shows a consent page protected by a CSRF token, then issues a code. S256 PKCE is required. - **Token:** `/oauth/token`. Codes and refresh tokens are single use, and refresh tokens rotate. src/controllers/authorization.cr ``` # the OAuth 2 authorization server that MCP clients sign in with @[AC::MCP(hide: true)] class Authorization < Application base "/" alias Tokens = AuthServer::Tokens alias Error = AuthServer::Error CLIENTS = AuthServer::CLIENTS # authorization server metadata (RFC 8414), where MCP clients discover the endpoints @[AC::Route::GET("/.well-known/oauth-authorization-server")] def metadata { issuer: App::URL, authorization_endpoint: "#{App::URL}/oauth/authorize", token_endpoint: "#{App::URL}/oauth/token", registration_endpoint: "#{App::URL}/oauth/register", scopes_supported: [App::SCOPE], response_types_supported: ["code"], grant_types_supported: ["authorization_code", "refresh_token"], token_endpoint_auth_methods_supported: ["none"], code_challenge_methods_supported: ["S256"], } end # dynamic client registration (RFC 7591) @[AC::Route::POST("/oauth/register", body: :client_metadata, status_code: HTTP::Status::CREATED)] def register(client_metadata : Hash(String, JSON::Any)) : Authly::DynamicClient CLIENTS.register(client_metadata) end # the user signs in and approves the client, which receives an authorization code @[AC::Route::GET("/oauth/authorize")] @[AC::Route::POST("/oauth/authorize")] def authorize( response_type : String, client_id : String, redirect_uri : String, code_challenge : String = "", code_challenge_method : String = "", scope : String = "", state : String = "", resource : String = "", decision : String = "", csrf_token : String = "", ) # errors are never redirected to an unverified redirect_uri client = CLIENTS.find(client_id) raise Error.new("invalid_request", "unknown client or redirect_uri") unless client && client.redirect_uris.includes?(redirect_uri) raise Error.new("unsupported_response_type") unless response_type == "code" raise Error.new("invalid_request", "PKCE with S256 is required") if code_challenge.empty? || code_challenge_method != "S256" raise Error.new("invalid_target") unless resource.empty? || App.resource?(resource) scope = scope.presence || App::SCOPE raise Error.new("invalid_scope") unless CLIENTS.allowed_scopes?(client_id, scope) # sign in first, then return here unless user = session_user session["return_to"] = request.resource redirect_to "/auth", :see_other end # ask the user to approve the client csrf = (session["csrf_token"] ||= Random::Secure.urlsafe_base64(32)).to_s unless request.method == "POST" && decision.in?("allow", "deny") && Crypto::Subtle.constant_time_compare(csrf, csrf_token) fields = {client_id: client_id, redirect_uri: redirect_uri, response_type: response_type, code_challenge: code_challenge, code_challenge_method: code_challenge_method, scope: scope, state: state, resource: resource, csrf_token: csrf} response.headers["Content-Security-Policy"] = "frame-ancestors 'none'" render html: consent_page(client, user, fields) end redirect_to with_params(redirect_uri, error: "access_denied", state: state) if decision == "deny" code = Authly.code("code", client_id, redirect_uri, scope, code_challenge, code_challenge_method, user.id).as(Authly::Code) redirect_to with_params(redirect_uri, code: code.to_s, state: state) end # exchanges an authorization code or refresh token for tokens @[AC::Route::POST("/oauth/token")] def token( grant_type : String, client_id : String, code : String = "", redirect_uri : String = "", code_verifier : String = "", refresh_token : String = "", resource : String = "", ) : Tokens::Response response.headers["Cache-Control"] = "no-store" raise Error.new("invalid_target") unless resource.empty? || App.resource?(resource) case grant_type when "authorization_code" # authly validates the code, client, redirect_uri and PKCE verifier Authly::AuthorizationCode.new(client_id, "", redirect_uri, code, code_verifier).authorized? claims = Authly.jwt_decode(code).first raise Error.new("invalid_grant") unless claims["client_id"]? == client_id Tokens.spend!(claims["jti"].as_s) Tokens.issue(claims["user_id"].as_s, client_id, claims["scope"].as_s) when "refresh_token" # authly validates the token signature, expiry and the client Authly::RefreshToken.new(client_id, "", refresh_token).authorized? claims = Authly.jwt_decode(refresh_token).first raise Error.new("invalid_grant") unless claims["typ"]? == "refresh" && claims["cid"]? == client_id Tokens.spend!(claims["jti"].as_s) # refresh tokens rotate Tokens.issue(claims["sub"].as_s, client_id, claims["scope"].as_s) else raise Error.new("unsupported_grant_type") end rescue error : Authly::Error raise Error.new(error.type.to_s, error.message, HTTP::Status.new(error.code)) end private def consent_page(client : Authly::DynamicClient, user : User, fields) : String hidden = fields.map { |key, value| %() }.join page("Allow access?", <<-HTML)

    Signed in as #{HTML.escape(user.name)}.

    #{HTML.escape(client.client_name || client.client_id)} wants to use your account (scope: #{HTML.escape(fields[:scope])}) and will return to #{HTML.escape(fields[:redirect_uri])}

    #{hidden}
    HTML end private def with_params(uri : String, **params) : String uri = URI.parse(uri) query = uri.query_params params.each { |key, value| query[key.to_s] = value unless value.empty? } uri.query_params = query uri.to_s end end ``` src/oauth/clients.cr ``` module AuthServer # OAuth clients, registered by MCP clients using dynamic client registration (RFC 7591). # Authly calls these methods when validating authorization and token requests. class Clients include Authly::AuthorizableClient # authly's device flow handler, which we don't use, needs `any?` to compile include Enumerable(Authly::Client) def each(& : Authly::Client ->) : Nil end GRANT_TYPES = {"authorization_code", "refresh_token"} # every client is public: they can't keep a secret, so PKCE proves the # code is being redeemed by the client that requested it def register(metadata : Hash(String, JSON::Any)) : Authly::DynamicClient metadata["token_endpoint_auth_method"] = JSON::Any.new("none") client = begin Authly::DynamicClient.from_registration_request(metadata) rescue error raise Error.new("invalid_client_metadata", error.message) end raise Error.new("invalid_client_metadata") unless client.valid? && supported?(client) Authly.config.client_store.store(client) client end def find(client_id : String) : Authly::DynamicClient? Authly.config.client_store.fetch(client_id) end def valid_redirect?(client_id : String, redirect_uri : String) : Bool !!find(client_id).try(&.redirect_uris.includes?(redirect_uri)) end # public clients have no secret def authorized?(client_id : String, client_secret : String) !find(client_id).nil? end def allowed_scopes?(client_id : String, scopes : String) : Bool scopes.split.all?(App::SCOPE) end def allowed_grant_type?(client_id : String, grant_type : String) : Bool !!find(client_id).try(&.grant_types.includes?(grant_type)) end private def supported?(client) : Bool client.grant_types.all?(&.in?(GRANT_TYPES)) && client.response_types == ["code"] && client.redirect_uris.all? { |uri| safe_redirect?(URI.parse(uri)) } end # https, or http on the loopback interface for native apps (RFC 8252) private def safe_redirect?(uri : URI) : Bool return false if uri.fragment uri.scheme == "https" || (uri.scheme == "http" && uri.host.in?("localhost", "127.0.0.1", "[::1]")) end end CLIENTS = Clients.new end ``` src/oauth/tokens.cr ``` module AuthServer # access and refresh tokens are JWTs signed by authly. # We mint them here as authly's `AccessToken` doesn't record the user (`sub`) module Tokens extend self # the token endpoint response (RFC 6749 section 5.1) struct Response include JSON::Serializable getter access_token : String getter token_type : String = "Bearer" getter expires_in : Int64 getter refresh_token : String getter scope : String def initialize(@access_token, @expires_in, @refresh_token, @scope) end end def issue(user_id : String, client_id : String, scope : String) : Response config = Authly.config Response.new( access_token: encode("access", user_id, client_id, scope, config.access_ttl), expires_in: config.access_ttl.total_seconds.to_i64, refresh_token: encode("refresh", user_id, client_id, scope, config.refresh_ttl), scope: scope, ) end # returns the claims of a valid access token issued for this app def verify(token : String) : JSON::Any? claims = Authly.jwt_decode(token).first claims if claims["typ"]? == "access" && claims["aud"]? == App::URL rescue JWT::Error nil end # authorization codes and refresh tokens can only be used once def spend!(jti : String) : Nil store = Authly.config.token_store raise Error.new("invalid_grant", "already used") if store.revoked?(jti) store.revoke(jti) end private def encode(type : String, user_id, client_id, scope, ttl : Time::Span) : String now = Time.utc Authly.jwt_encode({ "typ" => type, "iss" => Authly.config.issuer, "aud" => App::URL, "sub" => user_id, "cid" => client_id, "scope" => scope, "jti" => Random::Secure.hex(16), "iat" => now.to_unix, "exp" => (now + ttl).to_unix, }) end end end ``` src/oauth/error.cr ``` module AuthServer # an OAuth error response (RFC 6749 section 5.2) class Error < Exception getter error : String getter status : HTTP::Status def initialize(@error, description : String? = nil, @status = HTTP::Status::BAD_REQUEST) super(description || error) end end end ``` ### 5. The protected API A `before_action` verifies the access token, so every route is protected, and so is every MCP tool call, because tool calls run the same filters. `auth_probe` points at `/api/users/current`, so the MCP endpoint uses exactly the same check. src/controllers/api.cr ``` # the protected API, every request requires an access token from our authorization server abstract class Api < Application getter! user : User @[AC::Route::Filter(:before_action)] def authenticate header = request.headers["Authorization"]? || "" claims = AuthServer::Tokens.verify(header.lchop("Bearer ")) if header.starts_with?("Bearer ") @user = claims.try { |token| User.find?(token["sub"].as_s) } render :unauthorized, json: {error: "invalid_token"} unless @user end end # the signed in user class Users < Api base "/api/users" # returns the user the access token was issued to @[AC::MCP(root: true)] @[AC::Route::GET("/current")] def current : User user end end # your notes class Notes < Api base "/api/notes" NOTES = Hash(String, Array(String)).new { |hash, key| hash[key] = [] of String } # lists your notes @[AC::Route::GET("/")] def index : Array(String) NOTES[user.id] end # saves a note @[AC::Route::POST("/", status_code: HTTP::Status::CREATED)] def create( @[AC::Param::Info(description: "the text of the note")] text : String, ) : Array(String) NOTES[user.id] << text end end ``` src/controllers/application.cr ``` abstract class Application < ActionController::Base # the user signed in to this browser session def session_user : User? session["user_id"]?.try { |id| User.find?(id.to_s) } end @[AC::Route::Exception(AuthServer::Error)] def oauth_error(error) render status: error.status, json: {error: error.error, error_description: error.message} end protected def page(title : String, body : String) : String <<-HTML #{title}

    #{title}

    #{body} HTML end end ``` ### 6. authly fixes At the time of writing, authly `master` needs three fixes to be secure for this flow. Include this file, and remove each fix once it's fixed upstream: src/oauth/authly_patches.cr ``` require "base64" require "digest/sha256" # Fixes for authly (azutoolkit/authly master), remove them once fixed upstream. module Authly struct Code # upstream reads the issuer and TTL from constants captured when authly is # required, before `Authly.configure` runs, so codes failed `jwt_decode`. # We also bind the code to its client. def jwt Authly.jwt_encode({ "jti" => Random::Secure.hex(32), "code" => code, "client_id" => client_id, "challenge" => challenge, "method" => method, "scope" => scope, "user_id" => user_id, "redirect_uri" => redirect_uri, "iat" => Time.utc.to_unix, "iss" => Authly.config.issuer, "exp" => Authly.config.code_ttl.from_now.to_unix, }) end end struct CodeChallengeBuilder::S256 # RFC 7636: BASE64URL(SHA256(verifier)) without padding. # Upstream compares against standard, padded base64 def valid?(code_verifier) code == Base64.urlsafe_encode(Digest::SHA256.digest(code_verifier), padding: false) end end class AuthorizationCode # upstream skips the PKCE check when the client omits `code_verifier`. # We require S256 PKCE for every code private def verify_challenge! valid = method == "S256" && !verifier.empty? && code_challenge.valid?(verifier) raise Error.invalid_grant unless valid end end end ``` | Problem | Effect | | ----------------------------------------------------------- | -------------------------------------------------- | | issuer and code TTL captured before `Authly.configure` runs | every code fails to decode | | S256 challenge compared as padded, standard base64 | no standards-compliant client can redeem a code | | PKCE skipped when `code_verifier` is omitted | a stolen code can be redeemed without the verifier | Other things to know: - **Don't use `Authly::Handler`:** its authorize handler takes the user from the query string. - **Mint your own tokens:** authly's own access tokens don't identify the user, so `AuthServer::Tokens` mints JWTs with `sub`, `aud` and a `typ` claim. - **Use one HS256 key:** the HS256 `secret_key` and `public_key` must be the same value. - **Convert authly errors:** authly raises the generic `Authly::Error(Code)`. The token action rescues it and re-raises it as `AuthServer::Error` with the error's own status code. An `@[AC::Route::Exception(Authly::Error)]` handler works too (action-controller 8.3.3+), but its status code is fixed. - **Annotate each controller:** controller-level `@[AC::MCP(hide: true)]` isn't inherited, so annotate each controller you want hidden. ### 7. Try it ``` shards install DEV_LOGIN=1 crystal run src/app.cr ``` Then connect a client: ``` claude mcp add --transport http notes http://localhost:3000/mcp ``` A browser opens to sign in. `DEV_LOGIN=1` adds a "Sign in with dev" option that skips GitHub during development. **Never set it in production.** After approving the client, the model starts with `users_current` (a root tool), and can open the `notes` toolbox to list and create notes as you. ## Production checklist The example keeps everything in memory, to stay readable. Before production: - **Persist** users, clients, spent codes and refresh tokens in a database or Redis (with TTLs), so restarts and multiple instances work. MCP sessions also need sticky routing when you run more than one instance. - **Load secrets** (`JWT_SECRET`, `SESSION_SECRET`) from your secret store, and prefer RS256/ES256 keys with rotation and a JWKS endpoint if other services verify tokens. - **Accept any loopback port** for `http://127.0.0.1` and `localhost` redirect URIs ([RFC 8252 §7.3](https://www.rfc-editor.org/rfc/rfc8252#section-7.3)). Native MCP clients choose their callback port at runtime. - **Support [client ID metadata documents](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization)** (MCP's preferred registration method), and rate limit dynamic registration and clean up unused clients. - **Remember consent** per user and client, with a page to review and revoke access. - **Add revocation**, CORS on the metadata, registration and token endpoints (for browser-based clients such as the MCP Inspector), and rate limits on authorize and token. - **Generate `mcp.yml`** at build time so the model sees your tool descriptions. - **Serve over HTTPS**, and remove the `DEV_LOGIN` shortcut. A production reference [PlaceOS auth](https://github.com/PlaceOS) is a production authorization server built on the same pieces (authly + multi_auth). It adds client ID metadata documents, dynamic registration with rate limits, loopback port matching, consent, PKCE enforcement and RFC 8707 resource checks. [PlaceOS REST API](https://github.com/PlaceOS/rest-api) shows the resource server side, with `auth_probe` and `resource_metadata`. ## See also - [Configuration reference](https://spider-gazelle.net/mcp/configuration/index.md) - [Filters](https://spider-gazelle.net/guides/filters/index.md) - [MCP authorization specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization) # MCP configuration reference All options are class properties on `ActionController::MCPServer`, usually set in `src/config.cr`: ``` ActionController::MCPServer.tap do |mcp| mcp.server_name = "my-app" mcp.server_version = "1.0.0" end ``` ## Server | Option | Default | Purpose | | ------------------ | ------------------------------------------ | -------------------------------------------------------------------- | | `server_name` | `"action-controller"` | reported to clients on `initialize` | | `server_version` | `"1.0.0"` | reported to clients on `initialize` | | `instructions` | toolbox usage guidance | text given to the model on `initialize`. Describe your domain here | | `description_path` | `"mcp.yml"` | the generated tool descriptions, relative to the working directory | | `session_timeout` | `30.minutes` | idle sessions are discarded | | `allowed_origins` | `[]` | browser origins allowed in addition to same-origin. `"*"` allows any | | `forward_headers` | `["Authorization", "Cookie", "X-API-Key"]` | request headers copied onto tool calls and prompts | ## Authentication Authentication is optional and off by default. It's enabled when `auth_probe`, `authenticator` or `resource_metadata` is set. See [Authentication](https://spider-gazelle.net/mcp/authentication/index.md). | Option | Default | Purpose | | ------------------- | ---------- | ------------------------------------------------------------------------------------------- | | `auth_probe` | `nil` | a route requested in-process with the forwarded headers. A 2xx response means authenticated | | `authenticator` | `nil` | `Proc(HTTP::Request, Bool)`, a custom check used instead of the probe | | `auth_cache_ttl` | `1.minute` | how long a successful check is cached for each credential | | `resource_metadata` | `nil` | `Proc(HTTP::Request, ResourceMetadata)`, which advertises your OAuth authorization server | ## Methods | Method | Purpose | | ------------------------------ | ------------------------------------------------------------------- | | `mount(router, path = "/mcp")` | registers the endpoint (and the protected resource metadata) | | `write_description(path)` | generates `mcp.yml` from the code. Needs the source code | | `generate_description` | the same, returning the `Description` | | `description=` | replaces the loaded description (`nil` reloads it), useful in specs | ## Transport details - **Transport:** Streamable HTTP, for protocol versions `2025-11-25`, `2025-06-18` and `2025-03-26`. - **`POST`:** returns `application/json`. When a request produces notifications (e.g. opening a toolbox) and the client accepts `text/event-stream`, the notifications are streamed ahead of the result. - **`GET`:** with `Accept: text/event-stream`, opens a stream for server notifications. - **`DELETE`:** ends the session. - **Sessions:** identified by the `Mcp-Session-Id` header and stored in memory for each process. Deployments with multiple instances need sticky sessions. - **`Origin` headers:** must be same-origin or listed in `allowed_origins`, which protects against DNS rebinding. - **Tool results:** contain the response body as text. A JSON object response is also returned as `structuredContent`. Responses with a status of 400 or above set `isError` (except a `401` when authentication is enabled, which challenges the client to sign in again). - **Unhandled exceptions:** are logged and returned as a generic `500` tool error. `Server.before` handlers (such as `ErrorHandler`) don't run for in-process tool calls. ## See also - [MCP overview](https://spider-gazelle.net/mcp/index.md) - [Setup](https://spider-gazelle.net/mcp/setup/index.md) - [action-controller README](https://github.com/spider-gazelle/action-controller#mcp-server) # Deployment # Deployment A Spider-Gazelle app compiles to a single binary, so deploying it is mostly a matter of building that binary and running it. This page describes the Docker setup in the [application template](https://github.com/spider-gazelle/spider-gazelle), which builds a small image containing only your binary and the files it needs. Docker isn't required, the same steps apply to any server. ## Quick start From your project root: ``` docker build -t my-app . docker run --rm -p 3000:3000 -e SG_ENV=production -e COOKIE_SESSION_SECRET="$(openssl rand -hex 16)" my-app ``` Then open . ## The Dockerfile The template's `Dockerfile` is a multi-stage build. **Build stage** (`84codes/crystal:latest-alpine`): 1. Creates an unprivileged `appuser` (UID `10001`) to run the app. 1. Installs the build dependencies, then copies `shard.yml`, `shard.override.yml` and `shard.lock` and runs `shards install --production`. Dependencies are copied before your source, so Docker caches them until they change. 1. Copies `src/` and builds an optimised binary with `shards build --production --release`. 1. Collects the shared libraries the binary links against. 1. Generates the API descriptions while the source code is still available: ``` RUN ./bin/app --docs --file=openapi.yml && \ ./bin/app --mcp=mcp.yml ``` **Final stage** (`FROM scratch`, an empty image) contains only: - the binary at `/app`, and its shared libraries - `openapi.yml` and `mcp.yml` - CA certificates, so the app can call HTTPS services - timezone data, and the user and group files for `appuser` The final image has no shell, package manager or compiler, which keeps it small and reduces what an attacker could use. ### Why openapi.yml and mcp.yml are generated at build time The [OpenAPI document](https://spider-gazelle.net/openapi/index.md) and the [MCP tool descriptions](https://spider-gazelle.net/mcp/index.md) are both built from the doc comments in your source code. They're extracted with `crystal docs`, which needs the compiler and the source, and neither is in the final image. So the build stage generates the files and the final stage ships them next to the binary. At runtime: - The template serves `openapi.yml` at `GET /openapi`. - The MCP server loads `mcp.yml` the first time a client connects. Its location is set by `SG_MCP_DESCRIPTION`, relative to the working directory (`/` in the image). If the file is missing, the tools still work, but without descriptions. Tip Because routes, OpenAPI operations and MCP tools all come from the same annotated methods, every image you build ships documentation that matches its code. ## Running the container ``` docker run -d \ --name my-app \ --restart unless-stopped \ -p 8080:3000 \ -e SG_ENV=production \ -e COOKIE_SESSION_SECRET=your-32-character-or-longer-secret \ my-app ``` - `-d` runs the container in the background. - `--restart unless-stopped` restarts it after a crash or a reboot. - `-p 8080:3000` maps port 8080 on the host to port 3000 in the container. - `-e` sets [environment variables](https://spider-gazelle.net/getting_started/configuration/#environment-variables). The image's default command binds to `0.0.0.0` on port 3000: ``` ENTRYPOINT ["/app"] CMD ["-b", "0.0.0.0", "-p", "3000"] ``` Arguments after the image name replace `CMD` and are passed to `/app`. Include `-b 0.0.0.0` when you do this. Without it the app binds to `127.0.0.1` inside the container and can't be reached: ``` docker run --rm -p 3000:3000 my-app -b 0.0.0.0 -p 3000 -w 4 docker run --rm my-app --routes ``` Manage the container with `docker logs -f my-app`, `docker restart my-app`, `docker stop my-app` and `docker rm my-app`. ### Docker Compose The template includes a `docker-compose.yml` along these lines: ``` services: sg: build: . ports: - "3000:3000" environment: SG_ENV: "production" ``` Run `docker compose up -d --build` to build and start it. ## Environment variables Configure the container with environment variables rather than rebuilding it. The ones you'll usually set in production: | Variable | Why | | ----------------------- | -------------------------------------------------------------------------------------- | | `SG_ENV=production` | Production logging, no exception details in error responses, `Secure` session cookies. | | `COOKIE_SESSION_SECRET` | The template's default is public. Set your own, at least 32 bytes. | | `COOKIE_SESSION_KEY` | Optional, the session cookie name. | | `SG_MCP_PATH` | Optional. Set to an empty string to turn off the MCP endpoint. | See [Configuration](https://spider-gazelle.net/getting_started/configuration/#environment-variables) for the full list. Pass secrets through your platform's secret store, not on the command line in shared environments. ## Health checks The binary can health check itself. `-c URL` requests the URL with Crystal's built-in HTTP client, so a `scratch` image doesn't need `curl`: ``` HEALTHCHECK CMD ["/app", "-c", "http://127.0.0.1:3000/"] ``` It exits `0` for a response status from 200 to 499, `1` for any other status and `2` if the request fails. Docker marks the container `healthy` when the command exits `0` and `unhealthy` after repeated failures, and `docker ps` shows the status. - Point it at a cheap route that doesn't need authentication. - If you change the port, change the health check URL too. - Orchestrators that run commands, such as Kubernetes exec probes or ECS health checks, can use the same command: ``` livenessProbe: exec: command: ["/app", "-c", "http://127.0.0.1:3000/"] ``` ## Workers and scaling A Crystal process handles many requests concurrently. To use more CPU cores, run more threads with `-w` (or `SG_WORKER_COUNT`): ``` docker run -p 3000:3000 my-app -b 0.0.0.0 -p 3000 -w 4 docker run -p 3000:3000 -e SG_WORKER_COUNT=4 my-app ``` `-w 0` uses one thread per CPU core. See [Workers and threads](https://spider-gazelle.net/getting_started/configuration/#workers-and-threads). When you run several containers behind a load balancer: - Session cookies work on any instance, as long as they share the same `COOKIE_SESSION_SECRET`. - [WebSocket](https://spider-gazelle.net/guides/websockets/index.md) connections and MCP sessions live in the process that accepted them. MCP clients need sticky sessions, see [MCP](https://spider-gazelle.net/mcp/index.md). ## Pushing to a registry Tag the image with your registry's address, log in and push. For the GitHub Container Registry: ``` docker build -t ghcr.io/my-org/my-app:1.0.0 . echo "$GITHUB_TOKEN" | docker login ghcr.io -u my-username --password-stdin docker push ghcr.io/my-org/my-app:1.0.0 ``` On the server, `docker pull ghcr.io/my-org/my-app:1.0.0`, then run it as above. Docker Hub and cloud registries work the same way with their own address. ## GitHub Actions The template's `.github/workflows/ci.yml` runs the formatter and specs on every push, see [Testing](https://spider-gazelle.net/guides/testing/#continuous-integration). To build and publish an image as well, add a workflow such as this one. It isn't part of the template. It pushes to the GitHub Container Registry on every push to `main` and every `v*` tag: ``` # .github/workflows/docker.yml name: Docker on: push: branches: [main] tags: ["v*"] jobs: publish: runs-on: ubuntu-latest permissions: contents: read packages: write steps: - uses: actions/checkout@v4 - uses: docker/setup-buildx-action@v3 - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - id: meta uses: docker/metadata-action@v5 with: images: ghcr.io/${{ github.repository }} - uses: docker/build-push-action@v6 with: context: . push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max ``` The image build runs `shards build`, but not your specs. Run the CI workflow first, or add a `needs:` dependency on its job, so failing code isn't published. ## Without Docker Build on a machine with the same OS and architecture as your server. The binary links against shared libraries such as OpenSSL and PCRE2, so the server needs them installed too. `ldd bin/app` lists them. ``` shards build --production --release ./bin/app --docs --file=openapi.yml ./bin/app --mcp=mcp.yml ``` Copy `bin/app`, `openapi.yml` and `mcp.yml` to the server, and run the binary from the folder containing the two YAML files: ``` SG_ENV=production ./app -b 0.0.0.0 -p 3000 ``` Run it under a process supervisor such as systemd, so it restarts on failure. The app shuts down gracefully on `SIGTERM`. ## See also - [Configuration](https://spider-gazelle.net/getting_started/configuration/index.md) - [Logging](https://spider-gazelle.net/guides/logging/index.md) - [Testing](https://spider-gazelle.net/guides/testing/index.md) - [OpenAPI](https://spider-gazelle.net/openapi/index.md) - [MCP](https://spider-gazelle.net/mcp/index.md) # AI agents # Agent reference A dense, self-contained summary of Spider-Gazelle (action-controller ~> 8.3) for AI coding agents and experienced developers. Paste it into an agent's context, or point the agent at [`/llms.txt`](https://spider-gazelle.net/llms.txt) (an index of the site) or [`/llms-full.txt`](https://spider-gazelle.net/llms-full.txt) (the whole site as Markdown). ## Project conventions (template) ``` src/app.cr entry point: CLI flags (-b -p -w -r -c --docs --file --mcp), server start src/config.cr requires controllers, server, MCP; logging, handlers, sessions, MCP config src/constants.cr NAME, VERSION, env vars (SG_ENV, SG_SERVER_PORT, SG_MCP_PATH ...) src/controllers/ application.cr (abstract base) + one class per resource spec/ spec_helper.cr requires src/config.cr; AC::SpecHelper.client ``` - Controllers inherit from an abstract `Application < AC::Base` (the template calls it `App::Base`). Shared filters, responders and error handlers go there. - `base "/path"` sets the controller's path. It defaults to the snake case class name. - Build with `shards build`, test with `crystal spec`, and format with `crystal tool format`. ## Routes ``` # Doc comment: first line is the OpenAPI summary; the whole comment is the MCP tool description @[AC::Route::GET("/:id")] # GET POST PUT PATCH DELETE OPTIONS WebSocket def show(id : Int64) : Model # args are params; the return type is the response schema @[AC::Route::POST("/", body: :model, status_code: HTTP::Status::CREATED)] def create(model : Model) : Model # body: names the argument parsed from the request body @[AC::Route::GET("/", status: {A => HTTP::Status::OK, B => HTTP::Status::ACCEPTED})] def index : A | B # status codes mapped by return type @[AC::Route::GET("/raw", content_type: "text/plain")] # force the response type @[AC::Route::GET("/:a", map: {value: :a})] # argument `value` reads param `a` @[AC::Route::GET("/:id", config: {id: {base: 16}})] # converter options @[AC::Route::GET("/:id", converters: {id: MyConverter})] ``` - Path segments: `:name` (required), `?:name` (optional) and `*:name` (glob). - Params are taken from the path, then the query, then form data. Bodies use parsers chosen by `Content-Type`; responses use responders chosen by `Accept`. - A parameter is required unless it's nilable or has a default. - Built-in conversions: numbers, `String`, `Char`, `Bool`, `Time`, `Enum`, `UUID`, and unions. `Bool` is `true` only for `true` (any case); any other value is `false`. Arrays need a custom converter. - `@[AC::Param::Info(description:, example:, name:, header:, class:, config:)]` sits on an argument. There's **no `required:`**. - Multiple route annotations on one method are allowed. MCP exposes them as one tool using the first GET route. ## Filters and errors ``` @[AC::Route::Filter(:before_action, except: [:index])] # :before_action :around_action :after_action def authenticate(@[AC::Param::Info(header: "Authorization")] auth : String) ... @[AC::Route::Filter(:around_action)] def transaction(&) Database.transaction { yield } end # around filters must yield skip_action :authenticate, only: :show @[AC::Route::Exception(NotFound, status_code: HTTP::Status::NOT_FOUND)] def not_found(error) : ErrorBody # handlers are inherited ``` - Missing or invalid params raise `AC::Route::Param::MissingError` / `ValueError`. Without an exception handler they become a `500`; the template maps them to `422` and `400`. - `rescue_from Klass, :method` also works, but its handler must `render` itself and it adds nothing to OpenAPI. Prefer the annotation. - `render` and `head` short-circuit an action or filter, e.g. `head :unauthorized`. - Filters can take typed params, which also appear in OpenAPI and MCP. - `force_tls` (alias `force_ssl`) redirects plain HTTP to HTTPS. Without `only:`/`except:` it covers every route. Proxy headers decide the protocol, otherwise a connection to a port `Server` bound with TLS is HTTPS. ## Sessions and cookies - `session["key"] = value` stores `String`, `Int64`, `Float64` or `Bool` in an encrypted cookie; reads return that union, so use `.as(Int64?)` etc. - `cookies` holds the cookies the client **sent**. Set or delete cookies on `response.cookies`. ## Responders and parsers ``` abstract class Application < AC::Base add_responder("application/yaml") { |io, result| result.to_yaml(io) } default_responder "application/json" add_parser("application/yaml") { |klass, body_io| klass.from_yaml(body_io.gets_to_end) } end ``` ## OpenAPI - `ActionController::OpenAPI.generate_open_api_docs(title:, version:, **info)` returns a NamedTuple; call `.to_yaml` on it. - It needs the source code and `crystal docs` (comments are extracted at generation time), so generate at build time: `./app --docs --file=openapi.yml`. - Only annotated routes are documented, not the DSL `get "/" do`. `OPTIONS` routes aren't documented either. ## MCP ``` require "action-controller/mcp" ActionController::MCPServer.mount(server, "/mcp") # in app.cr ActionController::MCPServer.write_description("mcp.yml") # at build time (needs source) @[AC::MCP(hide: true)] # exclude a route/controller @[AC::MCP(root: true)] # available without opening the toolbox @[AC::MCP(prompt: true)] # a prompt; must return String or Array(AC::PromptMessage) ``` - Controllers are toolboxes and each route method is one tool named `_`. The module namespace shared by all controllers is left out of the names. - WebSocket, `OPTIONS` and DSL routes aren't exposed as tools. - Tool calls run the real route in-process, with the same filters and the same auth. `Authorization`, `Cookie` and `X-API-Key` are forwarded. - Auth is optional: `auth_probe = "/users/current"`, plus `resource_metadata` for OAuth. ## Testing ``` client = AC::SpecHelper.client # in-process HTTP client client.get("/users/1", headers: HTTP::Headers{"Accept" => "application/json"}) controller = Users.spec_instance(HTTP::Request.new("GET", "/")) # unit test an instance ``` ## Common mistakes | Mistake | Fix | | --------------------------------------------------- | ----------------------------------------------------------------------------------- | | Developer notes in the doc comment above a route | they're published to OpenAPI and MCP; separate them with a blank line | | No return type on a route | there's no response schema, and the compiler can't check what's returned | | `@[AC::Param::Info(required: true)]` | doesn't exist; make the type non-nilable without a default | | Generating docs from a deployed binary | generate at build time where the source and `crystal` are available | | An exposed route is unsafe for agents | `@[AC::MCP(hide: true)]` and protect it with filters | | Expecting `hide: true` to block access | it only hides the MCP tool; the HTTP route still works | | No handler for `Param::MissingError` / `ValueError` | bad input becomes a `500`; add `@[AC::Route::Exception]` handlers in the base class | | Setting a cookie with `cookies["x"] = ...` | `cookies` is the request's; use `response.cookies << HTTP::Cookie.new(...)` | | `session["id"] = 42` (an `Int32`) | doesn't compile; store `42_i64` | ## See also - [Routing](https://spider-gazelle.net/guides/routing/index.md), [Parameters](https://spider-gazelle.net/guides/parameters/index.md), [Responses](https://spider-gazelle.net/guides/responses/index.md), [Filters](https://spider-gazelle.net/guides/filters/index.md) and [Errors](https://spider-gazelle.net/guides/errors/index.md) for the full rules behind this summary - [OpenAPI](https://spider-gazelle.net/openapi/index.md) and [MCP](https://spider-gazelle.net/mcp/index.md) - [Configuration](https://spider-gazelle.net/getting_started/configuration/index.md) for the template's CLI flags and environment variables