OneRuby.devAN ENGINEERING NOTEBOOK

ruby · 6 min read

Sinatra, Rails and Roda: Compare One HTTP Contract

Compare Sinatra, Roda and Rails API controller routing against one JSON request contract, with 33 passing Rack tests and locked Ruby dependencies.

A framework comparison becomes slippery when each example implements a different API. One accepts malformed JSON as a server error, another validates fields, and a third returns an HTML error page. Counting their lines or timing their happy paths does not answer which behavior you want to maintain.

This experiment puts the same small contract behind Sinatra, Roda and Rails' API controller and router. It sends requests through real Rack applications and compares status codes, JSON content types and response bodies. There is no throughput leaderboard: these are behavior tests, not load tests.

Agree on the boundary first

The endpoint is a deliberately stateless note preview. POST /notes accepts a JSON object containing a nonempty string title, trims it and returns that field with status 201. Extra fields are ignored. GET /notes returns an empty array because the example has no persistence.

Malformed JSON returns 400. A valid JSON value with the wrong shape or an empty title returns 422. An unsupported content type returns 415. A GET request to an unknown path, including the root, returns a JSON 404. PUT, PATCH and DELETE on /notes return the same JSON 404; choosing 405 instead would be another valid application contract. These choices form the contract for this exercise; HTTP alone does not select your validation schema or error wording.

The shared function contains the input rules:

Ruby
def self.create(body, content_type)
return [415, {error: 'application/json required'}] unless content_type.to_s.split(';').first == 'application/json'
data = JSON.parse(body)
return [422, {error: 'title must be a nonempty string'}] unless data.is_a?(Hash) && data['title'].is_a?(String) && !data['title'].strip.empty?
[201, {title: data['title'].strip}]
rescue JSON::ParserError
[400, {error: 'invalid JSON'}]
end

A shared function prevents validation differences from being mistaken for framework differences. Returning only the selected title also makes the response shape explicit. The test submits admin: true and verifies that it does not appear in the output.

This is not a user-management API. There are no passwords, tokens, access controls or durable IDs. A real write endpoint needs those decisions, database constraints, resource limits and a consistent policy for unexpected errors. Keeping them outside this experiment makes its result easier to interpret.

Sinatra makes the route the visible unit

The Sinatra application subclasses Sinatra::Base, reads the request body, calls the contract, then sets status and content type before serializing JSON. The route and its response are adjacent. A not_found block gives missing paths the same response format.

Sinatra's documentation covers modular applications, status handling and error blocks. That style suits a small HTTP adapter whose team wants to see the mechanics in each route. As endpoints multiply, repeated parsing and error handling need a home: helpers, middleware or domain objects. Sinatra lets you choose that structure; it does not make the choice disappear.

The fixture uses a test environment. It should not be copied as a deployment configuration. Host authorization, exception pages, logging and a server process belong to the actual application setup.

Roda makes the route tree explicit

Roda handles the same request through a branch for the path and branches for verbs:

Ruby
r.is 'notes' do
r.get { [] }
r.post do
code, payload = Contract.create(r.body.read, r.env['CONTENT_TYPE'])
response.status = code
payload
end
response.status = 404
{error: 'not found'}
end

The fallback belongs inside the matched notes branch as well as outside it. Once r.is matches, an unsupported verb does not resume the outer route tree. Without the inner fallback, PUT can return an empty HTML response instead of the promised JSON error. The json plugin serializes arrays and hashes. Notice that the code sets response.status and returns the payload; a two-element array is not being used as shorthand for status plus JSON. With this plugin an array is itself JSON data.

The Roda documentation describes the small core and optional plugins. A tree can make shared path context natural, particularly when several operations live under one resource. It also requires care about where matching stops. Test neighboring paths and unsupported verbs as the API grows, rather than assuming an attractive route shape guarantees correct responses.

Rails provides a controller boundary

The Rails version subclasses ActionController::API and renders the same payload and status. An ActionDispatch::Routing::RouteSet sends requests to its actions. Its wildcard fallback does not match an empty path, so the route set also declares an explicit root fallback. Both reach the same JSON error action. This is real Rails controller and routing code, but it is deliberately not a generated Rails application: it omits Railties boot, the full middleware stack, Active Record and application configuration.

That limit matters. The fixture can compare routing and controller responses; it cannot support claims about an entire Rails application's memory usage, startup time or authentication behavior.

The Rails API guide explains what an API-only application includes and what differs from a browser-oriented Rails app. Choose that larger application structure when its conventions and integrated components fit the project. If the only job is translating one webhook into another request, compare the work those components save with the configuration they introduce.

Run the same requests through all three

Download the locked example files, install the bundle, and run bundle exec ruby test_apps.rb. The tests use Rack::MockRequest; they cross each application's Rack boundary without opening a network listener.

Eleven request cases run against each implementation: successful create, list, malformed JSON, unsupported content type, blank title, an array instead of an object, an unknown route, the root, and PUT, PATCH and DELETE on the resource. The run passed 33 tests and 132 assertions.

The contract uses the canonical path /notes. Trailing-slash normalization is deliberately outside it: a GET to /notes/ matches Rails here but returns 404 in Sinatra and Roda. That difference matters if clients use both forms. Decide whether to normalize, redirect or reject before claiming those URLs are interchangeable. HEAD response bodies and broader middleware behavior are also outside these request cases. The local run records the exact Ruby and gem versions in the attachment. These cases test behavior only; no request-per-second, resident-memory or concurrent-user claim follows from them.

For a real selection exercise, replace the shared function with one representative operation. Include its database transaction, authentication path, validation failures and serialization policy in all three implementations. Measure with the same server, workers, connection settings, dataset and workload if performance is a deciding factor.

The useful comparison is where the code and responsibilities end up. Sinatra keeps individual routes direct. Roda organizes matching into a tree. Rails gives controllers a place in a larger application structure. A passing contract lets you evaluate those differences without silently changing what clients receive.

Found a mistake or tried a different approach?

Send Alex a note ↗