Controllers
Controllers in Apiwork are thin. They connect the router to the domain and back.
A standard Rails controller with Apiwork::Controller included gets three things:
contract— validated request parametersexpose— serialized responsecontext— data passed to representations
class InvoicesController < ApplicationController
include Apiwork::Controller
def index
expose Invoice.all
end
def show
expose Invoice.find(params[:id])
end
def create
invoice = Invoice.create(contract.body[:invoice])
expose invoice
end
endThe controller does not handle serialization, validation, or error formatting. It calls expose and the adapter handles the rest.
Setup
include Apiwork::Controller adds everything a controller needs. Include it in a base controller for the API:
class V1Controller < ApplicationController
include Apiwork::Controller
endThis sets up:
before_action :validate_contract— validates requests against the contract before the action runswrap_parameters false— disables Rails parameter wrappingrescue_from ConstraintError— catches contract violations and renders structured errors
API controllers inherit from the base:
class V1::InvoicesController < V1Controller
def show
expose Invoice.find(params[:id])
end
endThe base controller is also where shared error handling and context belong.
Contract Access
contract provides parsed, validated, type-coerced request data:
def create
invoice = Invoice.create(contract.body[:invoice])
expose invoice
end
def index
scope = Invoice.where(status: contract.query[:status]) if contract.query[:status]
expose scope || Invoice.all
endcontract.body contains the request body. contract.query contains query parameters. Both return only params declared in the contract.
Undeclared params are rejected before the action runs.
Route Parameters
Route parameters like :id come from the Rails router, not the contract. Use params directly:
def show
invoice = Invoice.find(params[:id])
expose invoice
end
def update
invoice = Invoice.find(params[:id])
invoice.update(contract.body[:invoice])
expose invoice
endparams[:id] from the route, contract.body from the request body. Route parameters are handled by Rails routing, not the contract.
Error Handling
expose_error renders transport-level errors:
def show
invoice = Invoice.find_by(id: params[:id])
return expose_error :not_found unless invoice
expose invoice
endexpose_error :forbidden
expose_error :conflict, detail: "Order already shipped"
expose_error :unauthorized, meta: { reason: "token_expired" }See HTTP Errors for the full list of error codes and custom registration.
rescue_from
Apiwork rescues ConstraintError (contract violations) automatically. Other exceptions need rescue_from:
class V1Controller < ApplicationController
include Apiwork::Controller
rescue_from ActiveRecord::RecordNotFound do
expose_error :not_found
end
rescue_from Pundit::NotAuthorizedError do
expose_error :forbidden
end
endControllers inherit from the base:
class V1::InvoicesController < V1Controller
def show
invoice = Invoice.find(params[:id])
expose invoice
end
endInvoice.find raises RecordNotFound if the record does not exist. The base controller catches it and returns a structured 404. No find_by + nil check needed.
Skip Validation
skip_contract_validation! disables contract validation for specific actions:
skip_contract_validation! only: [:ping, :health]
skip_contract_validation! except: [:create, :update]WARNING
Use sparingly. Actions without contract validation lose request validation, typed parameters, and export coverage. The endpoint becomes invisible to introspection and exports.
If an endpoint has no contract, it probably does not belong inside the API boundary. Place it as a regular Rails route instead:
# config/routes.rb
Rails.application.routes.draw do
get '/health', to: 'status#ping'
post '/webhooks/stripe', to: 'webhooks#stripe'
mount Apiwork => '/'
endThese routes use standard Rails controllers without Apiwork::Controller. No contract, no adapter, no exports — because they are not part of the API.
skip_contract_validation! exists for the rare case where an action must live under the same API path but does not need a contract.
Automatic Behaviors
Three things happen automatically when Apiwork::Controller is included:
Contract validation — Every action is validated against its contract before execution. Invalid requests receive a 400 response with structured contract errors.
Parameter wrapping disabled — Rails wraps JSON request bodies by default. Apiwork disables this because contracts define the expected shape explicitly.
Constraint error rescue — ConstraintError exceptions (raised by contract validation) are caught and rendered as structured error responses.
Next Steps
See also
- Controller reference — all controller methods and options
- Contracts — defining request and response shapes
- HTTP Errors — error codes and custom registration
- Serialization — how the adapter serializes responses