Skip to content

Lint every response of an API once under lint! - #2960

Open
ericproulx wants to merge 1 commit into
fix/router-head-404-bodyfrom
feat/lint-router
Open

ericproulx wants to merge 1 commit into
fix/router-head-404-bodyfrom
feat/lint-router

Conversation

@ericproulx

@ericproulx ericproulx commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Stacked on #2958. The suite runs with Grape.config.lint = true, so once every response is linted, #2958's anchoring HEAD examples fail without its fix. I'll retarget this to master and rebase once #2958 merges.

Summary

lint! and Grape.config.lint put Rack::Lint in each endpoint's stack. Two kinds of response never meet it:

  • the 404 the router answers for a path nothing matches, which no endpoint builds;
  • a Rack app mounted with mount, which the router calls directly, not through a stack.

That's how #2958's HEAD-with-a-body 404 got past a suite that lints every API. It also means a mounted Rack app can break the Rack SPEC under lint! without anything noticing:

class API < Grape::API
  lint!
  mount ->(_env) { [42, {}, ['bad']] } => '/rack'   # status 42 answered, no LintError
end

This PR removes the per-endpoint Rack::Lint. A linted API wraps its router in a single Rack::Lint, which checks every response the API gives exactly once: endpoints, errors, the router's own 404 and mounted Rack apps. It runs after Rack::Head, so HEAD responses are checked with their body already stripped.

Why one Lint, not one more

Keeping the endpoint Lints and adding one around the router would nest two. Nested Rack::Lints break when a middleware calls to_ary on the body: the inner one closes the body inside to_ary, then the outer one iterates it (Response body is already closed). Rack::ETag does exactly that in any Rails app that mounts the API, and spec/integration/rails/mounting_spec.rb failed that way on my first attempt.

Scope of lint! (behaviour change)

lint! is now read from the top of the API being served, which matches what the README documents ("at the API level"). Declared inside a namespace, or in an API mounted into another one, it no longer does anything. A root-level lint! covers mounted APIs' endpoints, and Grape.config.lint covers everything as before. README and UPGRADING both describe this.

where lint! is declared before after
top of the API served / Grape.config.lint endpoints only every response
a namespace that namespace's endpoints no effect
a mounted API that API's endpoints no effect

Fixtures

With every response linted, the suite's own lint flagged two fixtures that break the SPEC:

  • API.call({}) in api_spec: Lint rejects the empty env. It now passes Rack::MockRequest.env_for('/').
  • A mounted Rack app in instance_spec returning a frozen headers Hash, which Rack 3 forbids. It now returns an unfrozen Hash, which the spec still checks isn't written into.

UPGRADING notes that suites using lint! may start raising in cases like these.

Test plan

  • .lint! examples: an endpoint's bad status, an unmatched request with a bad env, a mounted Rack app, and an endpoint of a mounted Grape API all raise; HEAD to an unmatched path passes. Without the Lint around the router, the four raising examples fail.
  • Full RSpec suite passes locally (2921 examples).
  • spec/integration passes with gemfiles/rails_8_0.gemfile (the Rails mounting spec) and gemfiles/grape_entity.gemfile.
  • RuboCop clean.
  • CI green.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Sep 18, 2026

Copy link
Copy Markdown

Danger Report

No issues found.

View run

@ericproulx
ericproulx force-pushed the feat/lint-router branch 2 times, most recently from 93e1f0f to a24a5fe Compare September 18, 2026 15:09
@ericproulx
ericproulx added this pull request to stack #2962 September 18, 2026 20:19
`lint!` and Grape.config.lint put Rack::Lint in each endpoint's stack, so
two kinds of response never met it: the 404 the router answers for a path
nothing matches, which no endpoint builds, and a Rack app mounted with
`mount`, which the router calls directly instead of through a stack. That
is how a HEAD request for an unknown path answered with a body (#2958)
while the suite, which lints every API, stayed green.

The per-endpoint Rack::Lint is gone. A linted API instead wraps its router
in a single Rack::Lint, which checks every response the API gives --
endpoints, errors, the router's own 404, mounted Rack apps -- exactly once,
and after Rack::Head, so a HEAD response is checked with its body stripped.
Keeping the endpoint Lints and adding one around the router would nest
two, and nested Rack::Lints cannot survive a middleware that calls `to_ary`
on the body -- the inner one closes it, the outer one then iterates it --
which Rack::ETag does in any Rails app mounting the API.

`lint!` is therefore read from the top of the API that is served, as the
README describes it ("at the API level"). Declared inside a namespace, or
in an API mounted into another one, it no longer does anything; README and
UPGRADING say so.

The suite's own lint then flagged two fixtures: `API.call({})`, an env
Rack::Lint rejects, and a mounted Rack app returning a frozen headers Hash,
which Rack 3 forbids. The first now passes a real env; the second an
unfrozen Hash the spec still checks is not written into.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@ericproulx ericproulx changed the title Lint the router's 404 and mounted Rack apps under lint! Lint every response of an API once under lint! Sep 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants