From 04afd237cf7fb1efa7baac1cd0d050663b82b8e3 Mon Sep 17 00:00:00 2001 From: Erik Berlin Date: Tue, 3 Nov 2026 05:00:40 -0800 Subject: [PATCH] Add Duel performance regression guards Duel (https://github.com/sferik/duel-ruby) runs the benchmarks in duel/ on the branch a pull request targets and on the pull request, side by side, and fails the Perf workflow when the pull request is slower or allocates more. The 39 benchmarks send requests through an API's Rack interface in memory: routing among 200 routes, nested namespaces, requirements and wildcards, 404 and 405, params coercion and validation (query string, form, JSON body, nested arrays, a custom validator, a 400), declared, JSON formatting, present, content negotiation, error! and rescue_from, callbacks and helpers, path, header and param versioning, mounting, and compiling an API. Comparing master with itself passed three times in a row. An extra dup in Router#process_route failed 17 benchmarks on allocations, and reading the attribute three times in CoerceValidator#validate_param! failed 11 on instructions and CPU time. --- .duel.rb | 35 ++++++++ .github/workflows/perf-comment.yml | 92 +++++++++++++++++++ .github/workflows/perf.yml | 122 +++++++++++++++++++++++++ .rubocop.yml | 10 +++ Gemfile | 3 + Rakefile | 6 ++ duel/callbacks_duel.rb | 67 ++++++++++++++ duel/compile_duel.rb | 75 ++++++++++++++++ duel/declared_duel.rb | 60 +++++++++++++ duel/duel_helper.rb | 65 ++++++++++++++ duel/errors_duel.rb | 64 +++++++++++++ duel/format_duel.rb | 74 +++++++++++++++ duel/params_duel.rb | 140 +++++++++++++++++++++++++++++ duel/routing_duel.rb | 102 +++++++++++++++++++++ duel/versioning_duel.rb | 54 +++++++++++ 15 files changed, 969 insertions(+) create mode 100644 .duel.rb create mode 100644 .github/workflows/perf-comment.yml create mode 100644 .github/workflows/perf.yml create mode 100644 duel/callbacks_duel.rb create mode 100644 duel/compile_duel.rb create mode 100644 duel/declared_duel.rb create mode 100644 duel/duel_helper.rb create mode 100644 duel/errors_duel.rb create mode 100644 duel/format_duel.rb create mode 100644 duel/params_duel.rb create mode 100644 duel/routing_duel.rb create mode 100644 duel/versioning_duel.rb diff --git a/.duel.rb b/.duel.rb new file mode 100644 index 000000000..1ddd6240b --- /dev/null +++ b/.duel.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +# Duel's configuration: https://github.com/sferik/duel-ruby/blob/main/docs/configuration.md +# +# Duel follows conventions, so set only what differs: benchmarks are in +# duel/**/*_duel.rb, each version's lib/ is first on the load path, a +# change is compared when it touches lib/ or ext/ (or the benchmarks, or +# this file), and releases are tagged v*. +Duel.configure do |c| + # The library each version's workers require (from the gemspec, unless + # this says otherwise). + # c.library = 'grape' + + # When the workflow comments on a pull request with the report: :always, + # :results (passed or failed), :failures (failed, or couldn't compare), + # or :never. + # c.comment = :failures + + # Which runs a regression fails: :every_run, or :pull_requests, for a + # project where people push to master directly (on GitHub Actions, another + # run warns and passes, and a pushed commit gets the comment instead, from + # a workflow job written only where this is set: see docs/ci.md). + # c.fail_on = :every_run + + # A label that accepts a regression in a pull request, for a change + # that's slower on purpose (nil for none). + c.accept_label = 'duel: accept' + + # What the workflow compares a push with: :before (the commit before it), + # or :last_passed (the last commit Duel passed, which a branch, + # duel/passed/master, marks), so small slowdowns add up until they fail. + # (A workflow job moves the branch, written only where this is set: see + # docs/ci.md.) + # c.push_base = :before +end diff --git a/.github/workflows/perf-comment.yml b/.github/workflows/perf-comment.yml new file mode 100644 index 000000000..dfc0e9027 --- /dev/null +++ b/.github/workflows/perf-comment.yml @@ -0,0 +1,92 @@ +name: Perf comment + +# Duel's report, as a new comment on the pull request, when Duel says to: +# always (post), or only when its last comment's status differs from the +# run's (change), so the latest comment is never stale. It never edits or +# deletes a comment, but hides Duel's earlier ones as outdated, so the +# newest is the one in view. It runs once the Perf workflow has, with +# this repository's own workflow and a token that can write, so a pull +# request from a fork gets a comment too. It checks nothing out and runs +# none of the pull request's code, only `duel comment` (see Duel's +# docs/ci.md), which trusts only what the Duel job could have said in +# what it kept: the pull request's number, if it names the pull request +# the run compared, and the comment and the status, if they're ones Duel +# says. Its report, a fork's too, is the comment, as Markdown. +# +# And when the label that accepts a regression (c.accept_label in +# .duel.rb) is added to a pull request, or taken off, it runs the Duel +# job again, which reads the labels as it runs: a run that failed, once +# the label's added, or one that passed, once it's taken off. +# +# (The workflow must be on the default branch before it runs at all.) +on: + workflow_run: + workflows: ["Perf"] + types: [completed] + # (This repository's own workflow, as for the comment, with a token + # that can run a workflow again: it checks nothing out, and runs none + # of the pull request's code.) + pull_request_target: + types: [labeled, unlabeled] + +permissions: {} + +jobs: + comment: + name: Comment + if: ${{ github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion != 'cancelled' }} + runs-on: ubuntu-latest + permissions: + actions: read # to get the report from the Perf run + pull-requests: write # to comment on the pull request with the report + steps: + - name: Get the report + id: report + continue-on-error: true # there's none when the Duel job failed before comparing + uses: actions/download-artifact@v8 + with: + name: duel-report + path: ${{ runner.temp }}/report + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + - uses: ruby/setup-ruby@v1 + if: steps.report.outcome == 'success' + with: + ruby-version: ruby + # Duel's own code, from RubyGems (none of the pull request's), so a + # fix to how it comments comes with a release of Duel. + - name: Comment on the pull request + if: steps.report.outcome == 'success' + env: + GH_TOKEN: ${{ github.token }} + HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + run: | + gem install duel --version '~> 0.1.8' --no-document + duel comment --report "$RUNNER_TEMP/report" --head "$HEAD_SHA" --marker 'duel' + + again: + name: Run Duel again + if: >- + github.event_name == 'pull_request_target' && github.event.label.name == 'duel: accept' + runs-on: ubuntu-latest + permissions: + actions: write # to run the Perf workflow's jobs again + steps: + - name: Run Duel again + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + ACTION: ${{ github.event.action }} + run: | + # The newest Perf run of the pull request's head commit, once it's + # done (one that's running may have read the labels already). + id=$(gh run list --workflow "Perf" --event pull_request --commit "$HEAD_SHA" --limit 1 \ + --json databaseId --jq '.[0].databaseId // empty') + [ -n "$id" ] || exit 0 + gh run watch "$id" --interval 30 > /dev/null || true + conclusion=$(gh run view "$id" --json conclusion --jq .conclusion) + case "$ACTION:$conclusion" in + labeled:failure) gh run rerun "$id" --failed ;; + unlabeled:success) gh run rerun "$id" ;; + esac diff --git a/.github/workflows/perf.yml b/.github/workflows/perf.yml new file mode 100644 index 000000000..3b4e0c293 --- /dev/null +++ b/.github/workflows/perf.yml @@ -0,0 +1,122 @@ +name: Perf + +on: + pull_request: + merge_group: # (a merge queue's) + push: + branches: [master] # (and any other branch to guard, a stable one, say) + schedule: + - cron: "0 4 * * *" + workflow_dispatch: + +# A pull request's newer push cancels its run, if that's still going, as +# only its newest commit's result counts. Every other run (a push's, say) +# runs to the end, so that each push is compared. +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +# The Duel job runs the branch's code (its benchmarks too), so it only +# reads. Commenting on a commit is the Comment on the commit job's, which +# runs none of it, and commenting on a pull request is the Perf comment +# workflow's. +permissions: + contents: read + pull-requests: read # for the pull request's labels (see c.accept_label) + +jobs: + duel: + name: Duel + runs-on: ubuntu-latest + # Whether to comment (post, change or none), the run's status (passed, + # failed or neutral), and whether to mark a push passed (true, or + # nothing), from Duel. + outputs: + comment: ${{ steps.compare.outputs.comment }} + status: ${{ steps.compare.outputs.status }} + passed: ${{ steps.compare.outputs.passed }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 # the baseline comes from history, tags included + persist-credentials: false + # The labels of the pull request (or for a push, of the one that + # merged it), for c.accept_label in .duel.rb, as a JSON array. They're + # read as the job runs, and adding the label (or taking it off) runs + # the job again: see the Perf comment workflow. + # On a schedule, the last commit since the last release that merged a + # pull request with the label (ACCEPT_LABEL, as .duel.rb names it): + # what that slowed down was accepted, so it's compared with instead. + - name: Find the pull request's labels + id: labels + if: github.event_name == 'pull_request' || github.event_name == 'merge_group' || github.event_name == 'push' || github.event_name == 'schedule' + continue-on-error: true # (without them, nothing's accepted) + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.pull_request.number }} + QUEUED: ${{ github.event.merge_group.head_ref }} + ACCEPT_LABEL: 'duel: accept' + RELEASE_TAGS: "v*" + run: | + if [ "$GITHUB_EVENT_NAME" = schedule ]; then + tag=$(git describe --tags --abbrev=0 --match "$RELEASE_TAGS" HEAD^ 2>/dev/null) || exit 0 + since=$(TZ=UTC git log -1 --date=format-local:%Y-%m-%dT%H:%M:%SZ --format=%cd "$tag") + merged=$(gh pr list --repo "$GITHUB_REPOSITORY" --state merged --label "$ACCEPT_LABEL" --search "merged:>=$since" --limit 1000 \ + --json mergeCommit --jq '.[].mergeCommit.oid') + accepted=$(git rev-list "$tag..HEAD" | grep -Fx -m 1 -f <(echo "$merged")) || accepted= + echo "accepted=$accepted" >> "$GITHUB_OUTPUT" + exit 0 + fi + # (In a merge queue, the last pull request queued, which the queue's + # branch names: gh-readonly-queue/master/pr-123-.) + if [ -n "$QUEUED" ]; then + PR=${QUEUED##*/pr-} + PR=${PR%%-*} + fi + if [ -n "$PR" ]; then + labels=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR/labels" --jq '[.[].name]') + else + labels=$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls" --jq '[.[0].labels[]?.name]') + fi + echo "labels=$labels" >> "$GITHUB_OUTPUT" + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "4.0" # the newest Ruby, which those who care most about speed run + bundler-cache: true + # Against the branch it targets for a pull request, the queue's base + # for a merge queue, what was there before for a push (or as + # c.push_base in .duel.rb says, the last commit Duel passed), and the + # last release on a schedule (slow creep). Duel adds its report to + # the job's summary, writes it to duel.md for the comment, and sets + # the step's outputs to say whether to comment, as c.comment in + # .duel.rb says, whatever the outcome. + - name: Compare + id: compare + env: + DUEL_BASE: ${{ github.event_name == 'schedule' && 'last-release' || github.event_name == 'push' && github.event.before || github.event_name == 'merge_group' && github.event.merge_group.base_sha || '' }} + # (Run by hand, it compares anyway: with nothing changed, the code + # with itself, which checks the setup.) + DUEL_FORCE: ${{ github.event_name == 'workflow_dispatch' && '1' || '' }} + DUEL_LABELS: ${{ steps.labels.outputs.labels }} + DUEL_ACCEPTED: ${{ steps.labels.outputs.accepted }} + run: bundle exec duel --markdown "$RUNNER_TEMP/duel/duel.md" + # For a pull request, what Duel said, for the Perf comment workflow, + # which can't see this job's outputs. + - name: Say whether to comment + if: ${{ !cancelled() && github.event_name == 'pull_request' }} + env: + PR: ${{ github.event.pull_request.number }} + COMMENT: ${{ steps.compare.outputs.comment }} + STATUS: ${{ steps.compare.outputs.status }} + run: | + mkdir -p "$RUNNER_TEMP/duel" + printf 'pr=%s\ncomment=%s\nstatus=%s\n' "$PR" "$COMMENT" "$STATUS" > "$RUNNER_TEMP/duel/comment.env" + # The report, for the comments. + - name: Keep the report + if: ${{ !cancelled() && (github.event_name == 'pull_request' || github.event_name == 'push') }} + uses: actions/upload-artifact@v7 + with: + name: duel-report + path: ${{ runner.temp }}/duel/ + if-no-files-found: ignore # none when Duel didn't get to compare + retention-days: 1 diff --git a/.rubocop.yml b/.rubocop.yml index 9967793ef..53a942b33 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -2,12 +2,18 @@ AllCops: NewCops: enable TargetRubyVersion: 3.3 SuggestExtensions: false + Include: + - .duel.rb Exclude: - vendor/**/* - bin/**/* - benchmark/**/* - tmp/**/* +inherit_mode: + merge: + - Include + plugins: - rubocop-exception_messages - rubocop-performance @@ -32,6 +38,10 @@ Style/MultilineIfModifier: Style/MutableConstant: Recursive: true +Style/OneClassPerFile: + Exclude: + - duel/**/*.rb + Style/RaiseArgs: Enabled: false diff --git a/Gemfile b/Gemfile index fb2e756c0..c09f45f13 100644 --- a/Gemfile +++ b/Gemfile @@ -17,7 +17,10 @@ end group :development do gem 'benchmark-ips' gem 'benchmark-memory' + # Duel requires Ruby 3.4 or later + gem 'duel', '~> 0.1.8' if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new('3.4') gem 'irb' + gem 'stackprof', platforms: :mri end group :test do diff --git a/Rakefile b/Rakefile index 2d79797f7..9fd1a6754 100644 --- a/Rakefile +++ b/Rakefile @@ -25,3 +25,9 @@ require('rubocop/rake_task') RuboCop::RakeTask.new task(default: %i[rubocop spec]) + +# Duel requires Ruby 3.4 or later +if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new('3.4') + require('duel/rake_task') + Duel::RakeTask.new +end diff --git a/duel/callbacks_duel.rb b/duel/callbacks_duel.rb new file mode 100644 index 000000000..e5e89d2a6 --- /dev/null +++ b/duel/callbacks_duel.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require_relative 'duel_helper' + +# Callbacks and helpers: an endpoint runs the before, validation, after +# and finally callbacks of every namespace it's in, and its block calls +# helpers, reads headers, and sets the status, headers and cookies. + +module AuthHelpers + def current_user + @current_user ||= { id: 1, name: 'Ada', token: headers['Authorization'].to_s.delete_prefix('Bearer ') } + end + + def authenticate! + error!('Unauthorized', 401) unless current_user[:token] == 's3cret' + end +end + +class CallbacksAPI < Grape::API + format :json + helpers AuthHelpers + + helpers do + def request_id + headers['X-Request-Id'] || 'none' + end + + params :pagination do + optional :page, type: Integer, default: 1 + optional :per_page, type: Integer, default: 25 + end + end + + before { header 'X-Request-Id', request_id } + before { authenticate! } + after { header 'X-Runtime', '0.001' } + finally { @finished = true } + + namespace :admin do + before { header 'X-Admin', 'true' } + before_validation { @validating = true } + after_validation { @validated = true } + after { header 'Cache-Control', 'no-store' } + + params do + use :pagination + end + get :reports do + status 200 + header 'X-Total-Count', '3' + cookies[:last_seen] = { value: 'reports', path: '/admin', httponly: true } + { user: current_user[:name], page: params[:page], request_id: } + end + end +end + +AUTHORIZED = { 'Authorization' => 'Bearer s3cret', 'X-Request-Id' => '0f8fad5b-d9cb-469f-a165-70867728950e' }.freeze + +duel 'callbacks: before, validation, after and finally callbacks, with helpers' do |n| + once { prepare(CallbacksAPI, rack_env('/admin/reports?page=2', headers: AUTHORIZED), 200, 'Ada') } + serve(@app, @env, n) +end + +duel 'callbacks: a before callback that halts with error!' do |n| + once { prepare(CallbacksAPI, rack_env('/admin/reports', headers: { 'Authorization' => 'Bearer wrong' }), 401, 'Unauthorized') } + serve(@app, @env, n) +end diff --git a/duel/compile_duel.rb b/duel/compile_duel.rb new file mode 100644 index 000000000..00448c714 --- /dev/null +++ b/duel/compile_duel.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require_relative 'duel_helper' + +# Compiling an API, which its first request does (or a boot that compiles +# it ahead): building each endpoint's middleware stack and the router's +# unions, buckets and table of static paths. change! discards the +# compiled instance, so that the next compile! builds it again. + +# 4 resources, each with the five REST routes, and params. +class CompiledAPI < Grape::API + format :json + prefix :api + version 'v1', using: :path + + 4.times do |i| + resource "resource#{i}" do + params do + optional :page, type: Integer, default: 1 + end + get { [] } + params do + requires :name, type: String + end + post { {} } + route_param :id, type: Integer do + get { {} } + put { {} } + delete { {} } + end + end + end +end + +class PostsAPI < Grape::API + helpers { def page = params[:page] } + before { header 'X-Posts', 'true' } + + get { [] } + route_param :id do + get { { id: params[:id] } } + namespace(:comments) { get { [] } } + end +end + +class CommentsAPI < Grape::API + get { [] } + get(':id') { { id: params[:id] } } +end + +class MountedAPI < Grape::API + format :json + rescue_from :all + + mount PostsAPI => '/posts' + mount CommentsAPI => '/comments' + namespace :admin do + mount PostsAPI + end +end + +duel 'compile: an API of 20 routes' do + CompiledAPI.change! + CompiledAPI.compile! +end + +duel 'compile: an API that mounts others' do + MountedAPI.change! + MountedAPI.compile! +end + +duel 'mount: GET a route of a mounted API' do |n| + once { prepare(MountedAPI, rack_env('/admin/42/comments'), 200) } + serve(@app, @env, n) +end diff --git a/duel/declared_duel.rb b/duel/declared_duel.rb new file mode 100644 index 000000000..2b2cf965c --- /dev/null +++ b/duel/declared_duel.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require_relative 'duel_helper' + +# declared(params), which endpoints call to take only the params they +# declared, walks the declarations against what the request sent. + +class DeclaredAPI < Grape::API + format :json + + params do + requires :name, type: String + optional :email, type: String + optional :bio, type: String + optional :role, type: String, values: %w[admin member guest], default: 'member' + optional :settings, type: Hash do + optional :theme, type: String + optional :locale, type: String + optional :notifications, type: Hash do + optional :email, type: Grape::API::Boolean + optional :push, type: Grape::API::Boolean + end + end + optional :links, type: Array do + requires :rel, type: String + requires :href, type: String + optional :title, type: String, as: :label + end + end + route_param :account_id, type: Integer do + post('profiles') { declared(params) } + put('profiles') { declared(params, include_missing: false) } + patch('profiles') { declared(params, stringify: true) } + end +end + +def profile_json + { + name: 'Katherine Johnson', + email: 'katherine@example.com', + unknown: 'dropped', + settings: { theme: 'dark', notifications: { email: true } }, + links: Array.new(10) { |i| { rel: "rel#{i}", href: "https://example.com/#{i}", title: "Link #{i}", extra: i } } + } +end + +duel 'declared: all declared params, with missing ones as nil' do |n| + once { prepare(DeclaredAPI, rack_env('/7/profiles', method: Rack::POST, json: profile_json), 201, '"bio":null') } + serve(@app, @env, n) +end + +duel 'declared: only the params sent' do |n| + once { prepare(DeclaredAPI, rack_env('/7/profiles', method: Rack::PUT, json: profile_json), 200, 'Katherine') } + serve(@app, @env, n) +end + +duel 'declared: with string keys' do |n| + once { prepare(DeclaredAPI, rack_env('/7/profiles', method: Rack::PATCH, json: profile_json), 200, '"label":"Link 9"') } + serve(@app, @env, n) +end diff --git a/duel/duel_helper.rb b/duel/duel_helper.rb new file mode 100644 index 000000000..fdf8204f3 --- /dev/null +++ b/duel/duel_helper.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require 'json' +require 'stringio' + +# Requests go to an API through its Rack interface, in memory, as a +# server would hand them over: no server, no sockets. + +# The env of a request to +path+, as Rack builds it. +headers+ are HTTP +# headers by name, and +json+ is sent as a JSON body. +def rack_env(path, method: Rack::GET, headers: {}, json: nil) + options = { method: } + if json + options[:input] = JSON.generate(json) + options['CONTENT_TYPE'] = 'application/json' + end + env = Rack::MockRequest.env_for(path, options) + headers.each { |name, value| env["HTTP_#{name.upcase.tr('-', '_')}"] = value } + env.freeze +end + +# A copy of +env+ with an input of its own. Grape and Rack write what +# they parse into the env, and read the body from rack.input, so each +# request needs a fresh one, as a server gives it. +def fresh_env(env) + input = env[Rack::RACK_INPUT] + return env.dup unless input + + env.merge(Rack::RACK_INPUT => StringIO.new(input.string)) +end + +# Calls +app+ with +env+ and reads the whole body, as a server does. +# Returns the status and the body. +def respond(app, env) + status, _headers, body = app.call(env) + text = +'' + body.each { |chunk| text << chunk } + body.close if body.respond_to?(:close) + [status, text] +end + +# Answers +count+ requests for +env+, each with an env of its own, made +# outside the measured time. +def serve(app, env, count) + envs = untimed { Array.new(count) { fresh_env(env) } } + envs.each { |request_env| respond(app, request_env) } +end + +# Fails the benchmark unless +app+ answers +env+ with +status+ (and a +# body that includes +text+), so that each benchmark measures the path +# that it's named for, not a 404 or an error. +def check(app, env, status, text = nil) + got, body = respond(app, fresh_env(env)) + return if got == status && (text.nil? || body.include?(text)) + + raise "#{env[Rack::REQUEST_METHOD]} #{env[Rack::PATH_INFO]}: expected #{status}, got #{got}: #{body[0, 300]}" +end + +# Sets up a benchmark of requests for +env+ to +app+, checked to answer +# +status+, in a `once` block of the benchmark. +def prepare(app, env, status, text = nil) + check(app, env, status, text) + @app = app + @env = env +end diff --git a/duel/errors_duel.rb b/duel/errors_duel.rb new file mode 100644 index 000000000..e66d86350 --- /dev/null +++ b/duel/errors_duel.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require_relative 'duel_helper' + +# Errors: error! throws to the error middleware, which renders the +# message in the request's format, and a raised exception is rescued by +# the rescue_from handler registered for its class. + +class RecordNotFound < StandardError; end +class RecordInvalid < StandardError; end + +class ErrorsAPI < Grape::API + format :json + + rescue_from RecordNotFound do |e| + error!({ error: 'not_found', message: e.message }, 404) + end + rescue_from RecordInvalid, ArgumentError do |e| + error!({ error: 'invalid', message: e.message }, 422, 'X-Error' => 'invalid') + end + rescue_from :all do |_e| + error!({ error: 'internal' }, 500) + end + + get('forbidden') { error!('Forbidden', 403) } + get('conflict') { error!({ error: 'conflict', id: 42, detail: 'The record changed since it was read.' }, 409, 'Retry-After' => '5') } + get('missing') { raise RecordNotFound, 'no post with id 42' } + get('invalid') { raise ArgumentError, 'invalid date' } + get('broken') { raise TypeError, "nil can't be coerced into Integer" } +end + +# Without a handler of its own, rescue_from :all answers with the +# default 500. +class DefaultErrorsAPI < Grape::API + format :json + rescue_from :all + + get('broken') { raise 'boom' } +end + +duel 'errors: error! with a message' do |n| + once { prepare(ErrorsAPI, rack_env('/forbidden'), 403, 'Forbidden') } + serve(@app, @env, n) +end + +duel 'errors: error! with a hash and headers' do |n| + once { prepare(ErrorsAPI, rack_env('/conflict'), 409, 'conflict') } + serve(@app, @env, n) +end + +duel 'errors: rescue_from a class with a handler' do |n| + once { prepare(ErrorsAPI, rack_env('/missing'), 404, 'not_found') } + serve(@app, @env, n) +end + +duel 'errors: rescue_from :all with a handler' do |n| + once { prepare(ErrorsAPI, rack_env('/broken'), 500, 'internal') } + serve(@app, @env, n) +end + +duel 'errors: rescue_from :all with the default handler' do |n| + once { prepare(DefaultErrorsAPI, rack_env('/broken'), 500, 'boom') } + serve(@app, @env, n) +end diff --git a/duel/format_duel.rb b/duel/format_duel.rb new file mode 100644 index 000000000..dea49e011 --- /dev/null +++ b/duel/format_duel.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require_relative 'duel_helper' + +# Formatting responses: negotiating the format from the Accept header or +# the path's extension, and rendering the body, plain or presented. + +Book = Struct.new(:id, :title, :author, :year, :isbn, :tags) + +# Stands in for a grape-entity Entity, which `present` calls the same way +# (grape-entity isn't in the development bundle). +class BookRepresenter + def self.represent(object, options = {}) + return object.map { |book| represent(book, options) } if object.respond_to?(:to_ary) + + { id: object.id, title: object.title, author: object.author, year: object.year, isbn: object.isbn, tags: object.tags } + end +end + +BOOKS = Array.new(50) do |i| + Book.new(i, "Book #{i}", "Author #{i % 7}", 1900 + i, "978-0-#{i.to_s.rjust(6, '0')}-#{i % 10}", %w[fiction classic]) +end.freeze + +class FormatAPI < Grape::API + format :json + represent Book, with: BookRepresenter + + get('ping') { { status: 'ok' } } + get('books') { BOOKS.map(&:to_h) } + get('books/presented') { present BOOKS, root: :books } + get('books/:id') { present BOOKS.fetch(params[:id].to_i), with: BookRepresenter } +end + +class NegotiatingAPI < Grape::API + content_type :json, 'application/json' + content_type :xml, 'application/xml' + content_type :txt, 'text/plain' + default_format :json + + get('status') { { status: 'ok', version: '1.2.3' } } +end + +duel 'format: render a small JSON object' do |n| + once { prepare(FormatAPI, rack_env('/ping'), 200, 'ok') } + serve(@app, @env, n) +end + +duel 'format: render a JSON array of 50 hashes' do |n| + once { prepare(FormatAPI, rack_env('/books'), 200, 'Book 49') } + serve(@app, @env, n) +end + +duel 'format: present 50 objects with a representer' do |n| + once { prepare(FormatAPI, rack_env('/books/presented'), 200, 'Book 49') } + serve(@app, @env, n) +end + +duel 'format: present one object with a representer' do |n| + once { prepare(FormatAPI, rack_env('/books/7'), 200, 'Book 7') } + serve(@app, @env, n) +end + +duel 'format: negotiate the format from a browser Accept header' do |n| + once do + accept = 'text/html,application/xhtml+xml,application/json;q=0.9,application/xml;q=0.8,*/*;q=0.5' + prepare(NegotiatingAPI, rack_env('/status', headers: { 'Accept' => accept }), 200, '"version"') + end + serve(@app, @env, n) +end + +duel 'format: take the format from the extension' do |n| + once { prepare(NegotiatingAPI, rack_env('/status.txt'), 200, 'ok') } + serve(@app, @env, n) +end diff --git a/duel/params_duel.rb b/duel/params_duel.rb new file mode 100644 index 000000000..6bfcf9f6d --- /dev/null +++ b/duel/params_duel.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +require_relative 'duel_helper' + +# Declared params: each request's are coerced to their types and checked +# by the validators that `requires` and `optional` declared, from the +# query string, a form, or a JSON body. + +# A custom validator, as the README shows them: `slug: true`. +class Slug < Grape::Validations::Validators::Base + def validate_param!(attr_name, params) + return if params[attr_name].to_s.match?(/\A[a-z0-9]+(?:-[a-z0-9]+)*\z/) + + validation_error!(attr_name, 'must be a slug') + end +end + +class ParamsAPI < Grape::API + format :json + + params do + requires :id, type: Integer + end + get('items/:id') { { id: params[:id] } } + + params do + requires :q, type: String, allow_blank: false + optional :page, type: Integer, default: 1, values: 1..1000 + optional :per_page, type: Integer, default: 25, values: [10, 25, 50, 100] + optional :sort, type: Symbol, values: %i[name created_at updated_at], default: :created_at + optional :order, type: String, values: %w[asc desc], default: 'desc' + optional :since, type: Date + optional :min_price, type: Float + optional :in_stock, type: Grape::API::Boolean + optional :tags, type: [String] + optional :ids, type: Array[Integer], coerce_with: ->(value) { value.split(',').map(&:to_i) } + end + get('search') { { q: params[:q], page: params[:page], ids: params[:ids] } } + + params do + requires :name, type: String, length: { min: 2, max: 64 } + requires :email, type: String, regexp: /\A[^@\s]+@[^@\s]+\z/ + requires :slug, type: String, slug: true + optional :age, type: Integer, values: 13..130 + optional :phone, type: String + optional :website, type: String + mutually_exclusive :phone, :website + end + post('users') { { name: params[:name] } } + + params do + requires :order, type: Hash do + requires :customer, type: Hash do + requires :name, type: String + requires :email, type: String + optional :address, type: Hash do + requires :street, type: String + requires :city, type: String + requires :postal_code, type: String + optional :country, type: String, default: 'US' + end + end + requires :items, type: Array, allow_blank: false do + requires :sku, type: String + requires :quantity, type: Integer, values: 1..100 + requires :price, type: BigDecimal + optional :options, type: Array[String] + optional :gift, type: Grape::API::Boolean, default: false + end + optional :coupon, type: String + optional :notes, type: String + at_least_one_of :coupon, :notes + end + end + post('orders') { { items: params[:order][:items].size } } +end + +# An order of 20 items, from a store's checkout, sent as JSON. +def order_json + { + order: { + customer: { + name: 'Ada Lovelace', + email: 'ada@example.com', + address: { street: '12 St James Square', city: 'London', postal_code: 'SW1Y 4JH' } + }, + items: Array.new(20) do |i| + { sku: "SKU-#{1000 + i}", quantity: (i % 5) + 1, price: format('%.2f', 9.99 + i), options: %w[blue large], gift: i.even? } + end, + notes: 'Please leave it at the door.' + } + } +end + +SEARCH = '/search?q=ruby+books&page=3&per_page=50&sort=name&order=asc&since=2026-01-15' \ + '&min_price=12.5&in_stock=true&tags[]=fiction&tags[]=classic&ids=1,2,3,5,8,13' + +USER = { name: 'Grace Hopper', email: 'grace@example.com', slug: 'grace-hopper', age: 85, website: 'https://example.com' }.freeze + +duel 'params: coerce a path param to an Integer' do |n| + once { prepare(ParamsAPI, rack_env('/items/42'), 200, '42') } + serve(@app, @env, n) +end + +duel 'params: coerce and validate a query string of 10 params' do |n| + once { prepare(ParamsAPI, rack_env(SEARCH), 200, '13') } + serve(@app, @env, n) +end + +duel 'params: fill in defaults for missing optional params' do |n| + once { prepare(ParamsAPI, rack_env('/search?q=ruby'), 200, '"page":1') } + serve(@app, @env, n) +end + +duel 'params: validate a JSON body with a custom validator' do |n| + once { prepare(ParamsAPI, rack_env('/users', method: Rack::POST, json: USER), 201, 'Grace') } + serve(@app, @env, n) +end + +duel 'params: validate a form body' do |n| + once do + form = URI.encode_www_form(USER) + env = Rack::MockRequest.env_for('/users', method: Rack::POST, input: form, 'CONTENT_TYPE' => 'application/x-www-form-urlencoded') + prepare(ParamsAPI, env.freeze, 201, 'Grace') + end + serve(@app, @env, n) +end + +duel 'params: validate a nested JSON body of 20 items' do |n| + once { prepare(ParamsAPI, rack_env('/orders', method: Rack::POST, json: order_json), 201, '20') } + serve(@app, @env, n) +end + +duel 'params: reject invalid params with 400' do |n| + once do + invalid = USER.merge(email: 'not an email', slug: 'Not A Slug', age: 7, phone: '555-0100') + prepare(ParamsAPI, rack_env('/users', method: Rack::POST, json: invalid), 400, 'must be a slug') + end + serve(@app, @env, n) +end diff --git a/duel/routing_duel.rb b/duel/routing_duel.rb new file mode 100644 index 000000000..279f3bc06 --- /dev/null +++ b/duel/routing_duel.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +require_relative 'duel_helper' + +# Finding the route for a request: the router answers a path that a route +# spells out in full from a table, narrows the routes with a parameter by +# a path segment, and otherwise matches the union of their patterns. + +class HelloAPI < Grape::API + prefix :api + version 'v1', using: :path + format :json + + get('/hello') { { hello: 'world' } } + get('/hello/:id') { { hello: params[:id] } } +end + +# An app's worth of resources: 40 of them, each with the five REST routes, +# some nested under the resource before them, so 200 routes. +class ResourcesAPI < Grape::API + prefix :api + version 'v1', using: :path + format :json + + 40.times do |i| + resource "resource#{i}" do + get { [] } + post { {} } + route_param :id do + get { { id: params[:id] } } + put { { id: params[:id] } } + delete { { id: params[:id] } } + end + end + end +end + +# Nested namespaces and route parameters, with requirements. +class NestedAPI < Grape::API + prefix :api + format :json + + namespace :orgs do + route_param :org_id, requirements: /[a-z0-9-]+/ do + namespace :projects do + get { [] } + route_param :project_id, type: Integer, requirements: /\d+/ do + get { { project: params[:project_id] } } + namespace :tasks do + get { [] } + get ':id', requirements: { id: /\d+/ } do + { org: params[:org_id], project: params[:project_id], task: params[:id] } + end + end + end + end + end + end + get 'files/*path' do + { path: params[:path] } + end +end + +duel 'routing: GET a static route' do |n| + once { prepare(HelloAPI, rack_env('/api/v1/hello'), 200, 'world') } + serve(@app, @env, n) +end + +duel 'routing: GET a route with a path param' do |n| + once { prepare(HelloAPI, rack_env('/api/v1/hello/42'), 200, '42') } + serve(@app, @env, n) +end + +duel 'routing: GET the last static route of 200' do |n| + once { prepare(ResourcesAPI, rack_env('/api/v1/resource39'), 200) } + serve(@app, @env, n) +end + +duel 'routing: GET the last param route of 200' do |n| + once { prepare(ResourcesAPI, rack_env('/api/v1/resource39/42'), 200, '42') } + serve(@app, @env, n) +end + +duel 'routing: 404 among 200 routes' do |n| + once { prepare(ResourcesAPI, rack_env('/api/v1/nothing/here'), 404) } + serve(@app, @env, n) +end + +duel 'routing: 405 for a method a path does not answer' do |n| + once { prepare(ResourcesAPI, rack_env('/api/v1/resource39', method: Rack::PATCH), 405) } + serve(@app, @env, n) +end + +duel 'routing: GET three levels of nested namespaces with requirements' do |n| + once { prepare(NestedAPI, rack_env('/api/orgs/acme-co/projects/12/tasks/345'), 200, '345') } + serve(@app, @env, n) +end + +duel 'routing: GET a wildcard route' do |n| + once { prepare(NestedAPI, rack_env('/api/files/docs/2026/report.pdf'), 200, 'report') } + serve(@app, @env, n) +end diff --git a/duel/versioning_duel.rb b/duel/versioning_duel.rb new file mode 100644 index 000000000..43b8a2303 --- /dev/null +++ b/duel/versioning_duel.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require_relative 'duel_helper' + +# Versioning: the versioner middleware takes the version from the path, +# the Accept header, a header of its own or a param, and the router picks +# the version's routes, cascading from one version to the next. + +def versioned_api(using, **options) + Class.new(Grape::API) do + format :json + + version('v2', using:, **options) do + get('users/:id') { { version: 'v2', id: params[:id] } } + get('users') { [] } + end + version('v1', using:, **options) do + get('users/:id') { { version: 'v1', id: params[:id] } } + get('users') { [] } + end + end +end + +PathVersionedAPI = versioned_api(:path) +HeaderVersionedAPI = versioned_api(:header, vendor: 'acme', strict: true) +AcceptVersionHeaderAPI = versioned_api(:accept_version_header) +ParamVersionedAPI = versioned_api(:param, parameter: 'api_version') + +duel 'versioning: a version in the path' do |n| + once { prepare(PathVersionedAPI, rack_env('/v1/users/42'), 200, 'v1') } + serve(@app, @env, n) +end + +duel 'versioning: a vendor media type in the Accept header' do |n| + once { prepare(HeaderVersionedAPI, rack_env('/users/42', headers: { 'Accept' => 'application/vnd.acme-v2+json' }), 200, 'v2') } + serve(@app, @env, n) +end + +# The v2 route matches the path first, and cascades when the versioner +# finds the request is for v1. +duel 'versioning: an Accept header cascading past a newer version' do |n| + once { prepare(HeaderVersionedAPI, rack_env('/users/42', headers: { 'Accept' => 'application/vnd.acme-v1+json' }), 200, 'v1') } + serve(@app, @env, n) +end + +duel 'versioning: the Accept-Version header' do |n| + once { prepare(AcceptVersionHeaderAPI, rack_env('/users/42', headers: { 'Accept-Version' => 'v1' }), 200, 'v1') } + serve(@app, @env, n) +end + +duel 'versioning: a version param' do |n| + once { prepare(ParamVersionedAPI, rack_env('/users/42?api_version=v1'), 200, 'v1') } + serve(@app, @env, n) +end