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