diff --git a/.github/workflows/CODEOWNERS b/.github/workflows/CODEOWNERS new file mode 100644 index 0000000000..5eb6f2bec7 --- /dev/null +++ b/.github/workflows/CODEOWNERS @@ -0,0 +1 @@ +* @sinatra/team-sinatra diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000000..b14d417218 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,106 @@ +name: Testing + +on: + push: + branches: + - '**' + pull_request: + +permissions: + contents: read # to fetch code (actions/checkout) + +jobs: + test: + name: ${{ matrix.ruby }} (Rack ${{ matrix.rack }}, Puma ${{ matrix.puma }}, Tilt ${{ matrix.tilt }}) + permissions: + contents: read # to fetch code (actions/checkout) + actions: read # to list jobs for workflow run (8398a7/action-slack) + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + puma: + - stable + rack: + - '~> 2' + tilt: + - stable + # Due to https://github.com/actions/runner/issues/849, we have to use quotes for '3.0' + ruby: [2.6, 2.7, '3.0', 3.1, 3.2, truffleruby] + include: + # Puma + - { ruby: 3.1, rack: '~> 2', puma: '~> 5', tilt: stable } + - { ruby: 3.2, rack: '~> 2', puma: head, tilt: stable, allow-failure: true } + # Tilt + - { ruby: 3.2, rack: '~> 2', puma: stable, tilt: head, allow-failure: true } + # Due to flaky tests, see https://github.com/sinatra/sinatra/pull/1870 + - { ruby: jruby-9.3, rack: '~> 2', puma: stable, tilt: stable, allow-failure: true } + # Due to https://github.com/jruby/jruby/issues/7647 + - { ruby: jruby-9.4, rack: '~> 2', puma: stable, tilt: stable, allow-failure: true } + # Never fail our build due to problems with head + - { ruby: ruby-head, rack: '~> 2', puma: stable, tilt: stable, allow-failure: true } + - { ruby: jruby-head, rack: '~> 2', puma: stable, tilt: stable, allow-failure: true } + - { ruby: truffleruby-head, rack: '~> 2', puma: stable, tilt: stable, allow-failure: true } + env: + rack: ${{ matrix.rack }} + puma: ${{ matrix.puma }} + tilt: ${{ matrix.tilt }} + steps: + - name: Install dependencies + run: | + sudo apt-get install --yes \ + pandoc \ + nodejs \ + pkg-config \ + libxml2-dev \ + libxslt-dev \ + libyaml-dev + + - uses: actions/checkout@v3 + + - uses: ruby/setup-ruby@v1 + continue-on-error: ${{ matrix.allow-failure || false }} + id: setup-ruby + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + + - name: Run sinatra tests + continue-on-error: ${{ matrix.allow-failure || false }} + id: tests + run: bundle exec rake + + - name: Run sinatra-contrib tests + continue-on-error: ${{ matrix.allow-failure || false }} + id: contrib-tests + working-directory: sinatra-contrib + run: | + bundle install --jobs=3 --retry=3 + bundle exec rake + + - name: Run rack-protection tests + continue-on-error: ${{ matrix.allow-failure || false }} + id: protection-tests + working-directory: rack-protection + run: | + bundle install --jobs=3 --retry=3 + bundle exec rake + + - uses: 8398a7/action-slack@v3 + with: + status: ${{ job.status }} + fields: repo,message,commit,author,ref,job,took # selectable (default: repo,message) + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} # required + MATRIX_CONTEXT: ${{ toJson(matrix) }} # required + if: failure() && env.SLACK_WEBHOOK_URL + # because continue-on-error marks the steps as pass even if they fail + - name: "setup-ruby (bundle install) outcome: ${{ steps.setup-ruby.outcome }}" + run: "" + - name: "sinatra tests outcome: ${{ steps.tests.outcome }}" + run: "" + - name: "sinatra-contrib tests outcome: ${{ steps.contrib-tests.outcome }}" + run: "" + - name: "rack-protection tests outcome: ${{ steps.protection-tests.outcome }}" + run: "" diff --git a/.gitignore b/.gitignore index e009b57e80..efc495acca 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,12 @@ -*.log -.DS_Store +# please add general patterns to your global ignore list +# see https://github.com/github/gitignore#readme +.DS_STORE +*.swp +*.rbc /pkg -/book -/doc/api -/doc/*.html -.#* -\#* -.emacs* +/Gemfile.lock +/coverage +.yardoc +/doc +.bundle +vendor diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000000..ae3aef8d26 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,150 @@ +# The behavior of RuboCop can be controlled via the .rubocop.yml +# configuration file. It makes it possible to enable/disable +# certain cops (checks) and to alter their behavior if they accept +# any parameters. The file can be placed either in your home +# directory or in some project directory. +# +# RuboCop will start looking for the configuration file in the directory +# where the inspected file is and continue its way up to the root directory. +# +# See https://github.com/rubocop-hq/rubocop/blob/master/manual/configuration.md +AllCops: + TargetRubyVersion: 2.6 + SuggestExtensions: false + NewCops: enable + Exclude: + - 'test/**/*' + - 'rack-protection/**/*' + - 'sinatra-contrib/**/*' + - vendor/bundle/**/* + +Layout/ExtraSpacing: + AllowForAlignment: true + AllowBeforeTrailingComments: true + +# Temporary disable cops because warnings are fixed +Style/SingleLineMethods: + Enabled: false + +Style/MutableConstant: + Enabled: false + +Lint/AmbiguousBlockAssociation: + Enabled: false + +Style/CaseEquality: + Enabled: false + +Style/PerlBackrefs: + Enabled: false + +Style/Documentation: + Enabled: false + +Lint/IneffectiveAccessModifier: + Enabled: false + +Lint/RescueException: + Enabled: false + +Style/SpecialGlobalVars: + Enabled: false + +Bundler/DuplicatedGem: + Enabled: false + +Layout/HeredocIndentation: + Enabled: false + +Style/FormatStringToken: + Enabled: false + +Lint/UselessAccessModifier: + Enabled: false + +Style/ClassVars: + Enabled: false + +Lint/UselessAssignment: + Enabled: false + +Style/EmptyLiteral: + Enabled: false + +Layout/LineLength: + Enabled: false + +Metrics/MethodLength: + Enabled: false + +Metrics/AbcSize: + Enabled: false + +Metrics/CyclomaticComplexity: + Enabled: false + +Metrics/PerceivedComplexity: + Enabled: false + +Lint/SuppressedException: + Enabled: false + +Metrics/ClassLength: + Enabled: false + +Metrics/BlockLength: + Enabled: false + +Metrics/ModuleLength: + Enabled: false + +Lint/AmbiguousRegexpLiteral: + Enabled: false + +Style/AccessModifierDeclarations: + Enabled: false + +Style/ClassAndModuleChildren: + Enabled: false + +Style/EvalWithLocation: + Enabled: false + +Lint/MissingSuper: + Enabled: false + +Style/MissingRespondToMissing: + Enabled: false + +Style/MixinUsage: + Enabled: false + +Style/MultilineTernaryOperator: + Enabled: false + +Style/StructInheritance: + Enabled: false + +Style/SymbolProc: + Enabled: false + +Style/IfUnlessModifier: + Enabled: false + +Style/OptionalBooleanParameter: + Enabled: false + +Style/DocumentDynamicEvalDefinition: + Enabled: false + +Lint/ToEnumArguments: + Enabled: false + +Naming/MethodParameterName: + Enabled: false + +Naming/AccessorMethodName: + Enabled: false + +Style/SlicingWithRange: + Enabled: false diff --git a/.yardopts b/.yardopts new file mode 100644 index 0000000000..60e00e9a35 --- /dev/null +++ b/.yardopts @@ -0,0 +1,5 @@ +--readme README.md +--title 'Sinatra API Documentation' +--charset utf-8 +--markup markdown +'lib/**/*.rb' - '*.md' diff --git a/AUTHORS b/AUTHORS deleted file mode 100644 index 028ea276d4..0000000000 --- a/AUTHORS +++ /dev/null @@ -1,43 +0,0 @@ -Sinatra was designed and developed by Blake Mizerany (bmizerany) in -California. Continued development would not be possible without the ongoing -financial support provided by [Heroku](http://heroku.com) and the emotional -support provided by Adam Wiggins (adamwiggins) of Heroku, Chris Wanstrath (defunkt), -PJ Hyett (pjhyett), and the rest of the GitHub crew. - -Special thanks to the following extraordinary individuals, who-out which -Sinatra would not be possible: - -* Ryan Tomayko (rtomayko) for constantly fixing whitespace errors 60d5006 -* Ezra Zygmuntowicz (ezmobius) for initial help and letting Blake steal - some of merbs internal code. -* Ari Lerner (http://xnot.org/) for his evangelism, spirit, and gumption - that got Sinatra recognized from Day 1. -* Christopher Schneid (cschneid) for The Book, the blog (gittr.com), - irclogger.com, and a bunch of useful patches. -* Markus Prinz (cypher) for patches over the years, caring about - the README, and hanging in there when times were rough. -* Simon Rozet (sr) for a ton of doc patches, HAML options, and all that - advocacy stuff he's going to do for 1.0. -* Erik Kastner (kastner) for fixing `MIME_TYPES` under Rack 0.5. -* Ben Bleything (bleything) for caring about HTTP status codes and doc fixes. -* Igal Koshevoy (igal) for root path detection under Thin/Passenger. -* Jon Crosby (jcrosby) for coffee breaks, doc fixes, and just because, man. -* Karel Minarik (karmi) for screaming until the website came back up. -* Jeremy Evans (jeremyevans) for unbreaking optional path params (twice!) -* The GitHub guys for stealing Blake's table. -* Nickolas Means (nmeans) for Sass template support. -* Victor Hugo Borja (vic) for splat'n routes specs and doco. -* Avdi Grimm (avdi) for basic RSpec support. -* Jack Danger Canty for a more accurate root directory and for making me - watch [this](http://www.youtube.com/watch?v=ueaHLHgskkw) just now. -* Mathew Walker for making escaped paths work with static files. -* Millions of Us for having the problem that led to Sinatra's conception. -* Songbird for the problems that helped Sinatra's future become realized. -* Rick Olson (technoweenie) for the killer plug at RailsConf '08. -* Steven Garcia for the amazing custom artwork you see on 404's and 500's -* Pat Nakajima (nakajima) for fixing non-nested params in nested params Hash's. - -and last but not least: - -* Frank Sinatra (chairman of the board) for having so much class he - deserves a web-framework named after him. diff --git a/AUTHORS.md b/AUTHORS.md new file mode 100644 index 0000000000..925eb91ae2 --- /dev/null +++ b/AUTHORS.md @@ -0,0 +1,81 @@ +Sinatra was designed and developed by Blake Mizerany in California. + +### Current Team + +* **Konstantin Haase** (maintainer) +* **Zachary Scott** +* **Kashyap Kondamudi** +* **Ashley Williams** +* **Trevor Bramble** +* **Kunpei Sakai** + +### Alumni + +* **Blake Mizerany** (creator) +* **Ryan Tomayko** +* **Simon Rozet** +* **Katrina Owen** + +### Thanks + +Sinatra would not have been possible without strong company backing. +In the past, financial and emotional support have been provided mainly by +[Heroku](http://heroku.com), [GitHub](https://github.com) and +[Engine Yard](http://www.engineyard.com/), and is now taken care of by +[Travis CI](http://travis-ci.com/). + +Special thanks to the following extraordinary individuals, without whom +Sinatra would not be possible: + +* [Ryan Tomayko](http://tomayko.com/) (rtomayko) for constantly fixing + whitespace errors __60d5006__ +* [Ezra Zygmuntowicz](http://brainspl.at/) (ezmobius) for initial help and + letting Blake steal some of merbs internal code. +* [Chris Schneider](http://gittr.com) (cschneid) for The Book, the blog, + [irclogger.com](http://irclogger.com/sinatra/), and a bunch of useful + patches. +* [Markus Prinz](http://nuclearsquid.com/) (cypher) for patches over the + years, caring about the README, and hanging in there when times were rough. +* [Erik Kastner](http://metaatem.net/) (kastner) for fixing `MIME_TYPES` under + Rack 0.5. +* [Ben Bleything](http://blog.bleything.net/) (bleything) for caring about HTTP + status codes and doc fixes. +* [Igal Koshevoy](http://twitter.com/igalko) (igal) for root path detection under + Thin/Passenger. +* [Jon Crosby](http://joncrosby.me/) (jcrosby) for coffee breaks, doc fixes, and + just because, man. +* [Karel Minarik](https://github.com/karmi) (karmi) for screaming until the + website came back up. +* [Jeremy Evans](http://code.jeremyevans.net/) (jeremyevans) for unbreaking + optional path params (twice!) +* [The GitHub guys](https://github.com/) for stealing Blake's table. +* [Nickolas Means](http://nmeans.org/) (nmeans) for Sass template support. +* [Victor Hugo Borja](https://github.com/vic) (vic) for splat'n routes specs and + doco. +* [Avdi Grimm](http://avdi.org/) (avdi) for basic RSpec support. +* [Jack Danger Canty](http://jåck.com/) for a more accurate root directory + and for making me watch [this](http://www.youtube.com/watch?v=ueaHLHgskkw) just + now. +* Mathew Walker for making escaped paths work with static files. +* Millions of Us for having the problem that led to Sinatra's conception. +* [Songbird](http://getsongbird.com/) for the problems that helped Sinatra's + future become realized. +* [Rick Olson](http://techno-weenie.net/) (technoweenie) for the killer plug + at RailsConf '08. +* Steven Garcia for the amazing custom artwork you see on 404's and 500's +* [Pat Nakajima](http://patnakajima.com/) (nakajima) for fixing non-nested + params in nested params Hash's. +* Gabriel Andretta for having people wonder whether our documentation is + actually in English or in Spanish. +* Vasily Polovnyov, Nickolay Schwarz, Luciano Sousa, Wu Jiang, + Mickael Riga, Bernhard Essl, Janos Hardi, Kouhei Yanagita and + "burningTyger" for willingly translating whatever ends up in the README. +* [Wordy](https://wordy.com/) for proofreading our README. **73e137d** +* cactus for digging through code and specs, multiple times. +* Nicolás Sanguinetti (foca) for strong demand of karma and shaping + helpers/register. + +And last but not least: + +* [Frank Sinatra](http://www.sinatra.com/) (chairman of the board) for having so much class he + deserves a web-framework named after him. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000000..7c36066204 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,1719 @@ +## Unreleased + +* _Your new feature here._ + +* New: Add support to keep open streaming connections with Puma [#1858](https://github.com/sinatra/sinatra/pull/1858) by Jordan Owens + +* Fix: Avoid crash in `uri` helper on Integer input [#1890](https://github.com/sinatra/sinatra/pull/1890) by Patrik Ragnarsson + +* Fix: Rescue `RuntimeError` when trying to use `SecureRandom` [#1888](https://github.com/sinatra/sinatra/pull/1888) by Stefan Sundin + +## 3.0.5 / 2022-12-16 + +* Fix: Add Zeitwerk compatibility. [#1831](https://github.com/sinatra/sinatra/pull/1831) by Dawid Janczak + +* Fix: Allow CALLERS_TO_IGNORE to be overridden + +## 3.0.4 / 2022-11-25 + +* Fix: Escape filename in the Content-Disposition header. [#1841](https://github.com/sinatra/sinatra/pull/1841) by Kunpei Sakai + +## 3.0.3 / 2022-11-11 + +* Fix: fixed ReDoS for Rack::Protection::IPSpoofing. [#1823](https://github.com/sinatra/sinatra/pull/1823) by @ooooooo-q + +## 3.0.2 / 2022-10-01 + +* New: Add Haml 6 support. [#1820](https://github.com/sinatra/sinatra/pull/1820) by Jordan Owens + +## 3.0.1 / 2022-09-26 + +* Fix: Revert removal of rack-protection.rb. [#1814](https://github.com/sinatra/sinatra/pull/1814) by Olle Jonsson + +* Fix: Revert change to server start and stop messaging by using Kernel#warn. Renamed internal warn method warn_for_deprecation. [#1818](https://github.com/sinatra/sinatra/pull/1818) by Jordan Owens + +## 3.0.0 / 2022-09-26 + +* New: Add Falcon support. [#1794](https://github.com/sinatra/sinatra/pull/1794) by Samuel Williams and @horaciob + +* New: Add AES GCM encryption support for session cookies. [#1324] (https://github.com/sinatra/sinatra/pull/1324) by Michael Coyne + +* Deprecated: Sinatra Reloader will be removed in the next major release. + +* Fix: Internal Sinatra errors now extend `Sinatra::Error`. This fixes [#1204](https://github.com/sinatra/sinatra/issues/1204) and [#1518](https://github.com/sinatra/sinatra/issues/1518). [bda8c29d](https://github.com/sinatra/sinatra/commit/bda8c29d70619d53f5b1c181140638d340695514) by Jordan Owens + +* Fix: Preserve query param value if named route param nil. [#1676](https://github.com/sinatra/sinatra/pull/1676) by Jordan Owens + +* Require Ruby 2.6 as minimum Ruby version. [#1699](https://github.com/sinatra/sinatra/pull/1699) by Eloy Pérez + +* Breaking change: Remove support for the Stylus template engine. [#1697](https://github.com/sinatra/sinatra/pull/1697) by Eloy Pérez + +* Breaking change: Remove support for the erubis template engine. [#1761](https://github.com/sinatra/sinatra/pull/1761) by Eloy Pérez + +* Breaking change: Remove support for the textile template engine. [#1766](https://github.com/sinatra/sinatra/pull/1766) by Eloy Pérez + +* Breaking change: Remove support for SASS as a template engine. [#1768](https://github.com/sinatra/sinatra/pull/1768) by Eloy Pérez + +* Breaking change: Remove support for Wlang as a template engine. [#1780](https://github.com/sinatra/sinatra/pull/1780) by Eloy Pérez + +* Breaking change: Remove support for CoffeeScript as a template engine. [#1790](https://github.com/sinatra/sinatra/pull/1790) by Eloy Pérez + +* Breaking change: Remove support for Mediawiki as a template engine. [#1791](https://github.com/sinatra/sinatra/pull/1791) by Eloy Pérez + +* Breaking change: Remove support for Creole as a template engine. [#1792](https://github.com/sinatra/sinatra/pull/1792) by Eloy Pérez + +* Breaking change: Remove support for Radius as a template engine. [#1793](https://github.com/sinatra/sinatra/pull/1793) by Eloy Pérez + +* Breaking change: Remove support for the defunct Less templating library. See [#1716](https://github.com/sinatra/sinatra/issues/1716), [#1715](https://github.com/sinatra/sinatra/issues/1715) for more discussion and background. [d1af2f1e](https://github.com/sinatra/sinatra/commit/d1af2f1e6c8710419dfe3102a660f7a32f0e67e3) by Olle Jonsson + +* Breaking change: Remove Reel integration. [54597502](https://github.com/sinatra/sinatra/commit/545975025927a27a1daca790598620038979f1c5) by Olle Jonsson + +* CI: Start testing on Ruby 3.1. [60e221940](https://github.com/sinatra/sinatra/commit/60e2219407e6ae067bf3e53eb060ee4860c60c8d) and [b0fa4bef](https://github.com/sinatra/sinatra/commit/b0fa4beffaa3b10bf02947d0a35e137403296c6b) by Johannes Würbach + +* Use `Kernel#caller_locations`. [#1491](https://github.com/sinatra/sinatra/pull/1491) by Julik Tarkhanov + +* Docs: Japanese documentation: Add notes about the `default_content_type` setting. [#1650](https://github.com/sinatra/sinatra/pull/1650) by Akifumi Tominaga + +* Docs: Polish documentation: Add section about Multithreaded modes and Routes. [#1708](https://github.com/sinatra/sinatra/pull/1708) by Patrick Gramatowski + +* Docs: Japanese documentation: Make Session section reflect changes done to README.md. [#1731](https://github.com/sinatra/sinatra/pull/1731) by @shu-i-chi + +## 2.2.3 / 2022-11-25 + +* Fix: Escape filename in the Content-Disposition header. [#1841](https://github.com/sinatra/sinatra/pull/1841) by Kunpei Sakai + +* Fix: fixed ReDoS for Rack::Protection::IPSpoofing. [#1823](https://github.com/sinatra/sinatra/pull/1823) by @ooooooo-q + +## 2.2.2 / 2022-07-23 + +* Update mustermann dependency to version 2. + +## 2.2.1 / 2022-07-15 + +* Fix JRuby regression by using ruby2_keywords for delegation. #1750 by Patrik Ragnarsson + +* Add JRuby to CI. #1755 by Karol Bucek + +## 2.2.0 / 2022-02-15 + +* Breaking change: Add `#select`, `#reject` and `#compact` methods to `Sinatra::IndifferentHash`. If hash keys need to be converted to symbols, call `#to_h` to get a `Hash` instance first. [#1711](https://github.com/sinatra/sinatra/pull/1711) by Olivier Bellone + +* Handle EOFError raised by Rack and return Bad Request 400 status. [#1743](https://github.com/sinatra/sinatra/pull/1743) by tamazon + +* Minor refactors in `base.rb`. [#1640](https://github.com/sinatra/sinatra/pull/1640) by ceclinux + +* Add escaping to the static 404 page. [#1645](https://github.com/sinatra/sinatra/pull/1645) by Chris Gavin + +* Remove `detect_rack_handler` method. [#1652](https://github.com/sinatra/sinatra/pull/1652) by ceclinux + +* Respect content type set in superclass before filter. Fixes [#1647](https://github.com/sinatra/sinatra/issues/1647) [#1649](https://github.com/sinatra/sinatra/pull/1649) by Jordan Owens + +* *Revert "Use prepend instead of include for helpers.* [#1662](https://github.com/sinatra/sinatra/pull/1662) by namusyaka + +* Fix usage of inherited `Sinatra::Base` classes keyword arguments. Fixes [#1669](https://github.com/sinatra/sinatra/issues/1669) [#1670](https://github.com/sinatra/sinatra/pull/1670) by Cadu Ribeiro + +* Reduce RDoc generation time by not including every README. Fixes [#1578](https://github.com/sinatra/sinatra/issues/1578) [#1671](https://github.com/sinatra/sinatra/pull/1671) by Eloy Pérez + +* Add support for per form csrf tokens. Fixes [#1616](https://github.com/sinatra/sinatra/issues/1616) [#1653](https://github.com/sinatra/sinatra/pull/1653) by Jordan Owens + +* Update MAINTENANCE.md with the `stable` branch status. [#1681](https://github.com/sinatra/sinatra/pull/1681) by Fredrik Rubensson + +* Validate expanded path matches `public_dir` when serving static files. [#1683](https://github.com/sinatra/sinatra/pull/1683) by cji-stripe + +* Fix Delegator to pass keyword arguments for Ruby 3.0. [#1684](https://github.com/sinatra/sinatra/pull/1684) by andrewtblake + +* Fix use with keyword arguments for Ruby 3.0. [#1701](https://github.com/sinatra/sinatra/pull/1701) by Robin Wallin + +* Fix memory leaks for proc template. Fixes [#1704](https://github.com/sinatra/sinatra/issues/1714) [#1719](https://github.com/sinatra/sinatra/pull/1719) by Slevin + +* Remove unnecessary `test_files` from the gemspec. [#1712](https://github.com/sinatra/sinatra/pull/1712) by Masataka Pocke Kuwabara + +* Docs: Spanish documentation: Update README.es.md with removal of Thin. [#1630](https://github.com/sinatra/sinatra/pull/1630) by Espartaco Palma + +* Docs: German documentation: Fixed typos in German README.md. [#1648](https://github.com/sinatra/sinatra/pull/1648) by Juri + +* Docs: Japanese documentation: Update README.ja.md with removal of Thin. [#1629](https://github.com/sinatra/sinatra/pull/1629) by Ryuichi KAWAMATA + +* Docs: English documentation: Various minor fixes to README.md. [#1663](https://github.com/sinatra/sinatra/pull/1663) by Yanis Zafirópulos + +* Docs: English documentation: Document when `dump_errors` is enabled. Fixes [#1664](https://github.com/sinatra/sinatra/issues/1664) [#1665](https://github.com/sinatra/sinatra/pull/1665) by Patrik Ragnarsson + +* Docs: Brazilian Portuguese documentation: Update README.pt-br.md with translation fixes. [#1668](https://github.com/sinatra/sinatra/pull/1668) by Vitor Oliveira + +### CI + +* Use latest JRuby 9.2.16.0 on CI. [#1682](https://github.com/sinatra/sinatra/pull/1682) by Olle Jonsson + +* Switch CI from travis to GitHub Actions. [#1691](https://github.com/sinatra/sinatra/pull/1691) by namusyaka + +* Skip the Slack action if `secrets.SLACK_WEBHOOK` is not set. [#1705](https://github.com/sinatra/sinatra/pull/1705) by Robin Wallin + +* Small CI improvements. [#1703](https://github.com/sinatra/sinatra/pull/1703) by Robin Wallin + +* Drop auto-generated boilerplate comments from CI configuration file. [#1728](https://github.com/sinatra/sinatra/pull/1728) by Olle Jonsson + +### sinatra-contrib + +* Do not raise when key is an enumerable. [#1619](https://github.com/sinatra/sinatra/pull/1619) by Ulysse Buonomo + +### Rack protection + +* Fix broken `origin_whitelist` option. Fixes [#1641](https://github.com/sinatra/sinatra/issues/1641) [#1642](https://github.com/sinatra/sinatra/pull/1642) by Takeshi YASHIRO + +## 2.1.0 / 2020-09-05 + +* Fix additional Ruby 2.7 keyword warnings [#1586](https://github.com/sinatra/sinatra/pull/1586) by Stefan Sundin + +* Drop Ruby 2.2 support [#1455](https://github.com/sinatra/sinatra/pull/1455) by Eloy Pérez + +* Add Rack::Protection::ReferrerPolicy [#1291](https://github.com/sinatra/sinatra/pull/1291) by Stefan Sundin + +* Add `default_content_type` setting. Fixes [#1238](https://github.com/sinatra/sinatra/pull/1238) [#1239](https://github.com/sinatra/sinatra/pull/1239) by Mike Pastore + +* Allow `set :` in sinatra-namespace [#1255](https://github.com/sinatra/sinatra/pull/1255) by Christian Höppner + +* Use prepend instead of include for helpers. Fixes [#1213](https://github.com/sinatra/sinatra/pull/1213) [#1214](https://github.com/sinatra/sinatra/pull/1214) by Mike Pastore + +* Fix issue with passed routes and provides Fixes [#1095](https://github.com/sinatra/sinatra/pull/1095) [#1606](https://github.com/sinatra/sinatra/pull/1606) by Mike Pastore, Jordan Owens + +* Add QuietLogger that excludes pathes from Rack::CommonLogger [1250](https://github.com/sinatra/sinatra/pull/1250) by Christoph Wagner + +* Sinatra::Contrib dependency updates. Fixes [#1207](https://github.com/sinatra/sinatra/pull/1207) [#1411](https://github.com/sinatra/sinatra/pull/1411) by Mike Pastore + +* Allow CSP to fallback to default-src. Fixes [#1484](https://github.com/sinatra/sinatra/pull/1484) [#1490](https://github.com/sinatra/sinatra/pull/1490) by Jordan Owens + +* Replace `origin_whitelist` with `permitted_origins`. Closes [#1620](https://github.com/sinatra/sinatra/issues/1620) [#1625](https://github.com/sinatra/sinatra/pull/1625) by rhymes + +* Use Rainbows instead of thin for async/stream features. Closes [#1624](https://github.com/sinatra/sinatra/issues/1624) [#1627](https://github.com/sinatra/sinatra/pull/1627) by Ryuichi KAWAMATA + +* Enable EscapedParams if passed via settings. Closes [#1615](https://github.com/sinatra/sinatra/issues/1615) [#1632](https://github.com/sinatra/sinatra/issues/1632) by Anders Bälter + +* Support for parameters in mime types. Fixes [#1141](https://github.com/sinatra/sinatra/issues/1141) by John Hope + +* Handle null byte when serving static files [#1574](https://github.com/sinatra/sinatra/issues/1574) by Kush Fanikiso + +* Improve development support and documentation and source code by Olle Jonsson, Pierre-Adrien Buisson, Shota Iguchi + +## 2.0.8.1 / 2020-01-02 + +* Allow multiple hashes to be passed in `merge` and `merge!` for `Sinatra::IndifferentHash` [#1572](https://github.com/sinatra/sinatra/pull/1572) by Shota Iguchi + +## 2.0.8 / 2020-01-01 + +* Lookup Tilt class for template engine without loading files [#1558](https://github.com/sinatra/sinatra/pull/1558). Fixes [#1172](https://github.com/sinatra/sinatra/issues/1172) by Jordan Owens + +* Add request info in NotFound exception [#1566](https://github.com/sinatra/sinatra/pull/1566) by Stefan Sundin + +* Add `.yaml` support in `Sinatra::Contrib::ConfigFile` [#1564](https://github.com/sinatra/sinatra/issues/1564). Fixes [#1563](https://github.com/sinatra/sinatra/issues/1563) by Emerson Manabu Araki + +* Remove only routing parameters from @params hash [#1569](https://github.com/sinatra/sinatra/pull/1569). Fixes [#1567](https://github.com/sinatra/sinatra/issues/1567) by Jordan Owens, Horacio + +* Support `capture` and `content_for` with Hamlit [#1580](https://github.com/sinatra/sinatra/pull/1580) by Takashi Kokubun + +* Eliminate warnings of keyword parameter for Ruby 2.7.0 [#1581](https://github.com/sinatra/sinatra/pull/1581) by Osamtimizer + +## 2.0.7 / 2019-08-22 + +* Fix a regression [#1560](https://github.com/sinatra/sinatra/pull/1560) by Kunpei Sakai + +## 2.0.6 / 2019-08-21 + +* Fix an issue setting environment from command line option [#1547](https://github.com/sinatra/sinatra/pull/1547), [#1554](https://github.com/sinatra/sinatra/pull/1554) by Jordan Owens, Kunpei Sakai + +* Support pandoc as a new markdown renderer [#1533](https://github.com/sinatra/sinatra/pull/1533) by Vasiliy + +* Remove outdated code for tilt 1.x [#1532](https://github.com/sinatra/sinatra/pull/1532) by Vasiliy + +* Remove an extra logic for `force_encoding` [#1527](https://github.com/sinatra/sinatra/pull/1527) by Jordan Owens + +* Avoid multiple errors even if `params` contains special values [#1526](https://github.com/sinatra/sinatra/pull/1527) by Kunpei Sakai + +* Support `bundler/inline` with `require 'sinatra'` integration [#1520](https://github.com/sinatra/sinatra/pull/1520) by Kunpei Sakai + +* Avoid `TypeError` when params contain a key without a value on Ruby < 2.4 [#1516](https://github.com/sinatra/sinatra/pull/1516) by Samuel Giddins + +* Improve development support and documentation and source code by Olle Jonsson, Basavanagowda Kanur, Yuki MINAMIYA + +## 2.0.5 / 2018-12-22 + +* Avoid FrozenError when params contains frozen value [#1506](https://github.com/sinatra/sinatra/pull/1506) by Kunpei Sakai + +* Add support for Erubi [#1494](https://github.com/sinatra/sinatra/pull/1494) by @tkmru + +* `IndifferentHash` monkeypatch warning improvements [#1477](https://github.com/sinatra/sinatra/pull/1477) by Mike Pastore + +* Improve development support and documentation and source code by Anusree Prakash, Jordan Owens, @ceclinux and @krororo. + +### sinatra-contrib + +* Add `flush` option to `content_for` [#1225](https://github.com/sinatra/sinatra/pull/1225) by Shota Iguchi + +* Drop activesupport dependency from sinatra-contrib [#1448](https://github.com/sinatra/sinatra/pull/1448) + +* Update `yield_content` to append default to ERB template buffer [#1500](https://github.com/sinatra/sinatra/pull/1500) by Jordan Owens + +### rack-protection + +* Don't track the Accept-Language header by default [#1504](https://github.com/sinatra/sinatra/pull/1504) by Artem Chistyakov + +## 2.0.4 / 2018-09-15 + +* Don't blow up when passing frozen string to `send_file` disposition [#1137](https://github.com/sinatra/sinatra/pull/1137) by Andrew Selder + +* Fix ubygems LoadError [#1436](https://github.com/sinatra/sinatra/pull/1436) by Pavel Rosický + +* Unescape regex captures [#1446](https://github.com/sinatra/sinatra/pull/1446) by Jordan Owens + +* Slight performance improvements for IndifferentHash [#1427](https://github.com/sinatra/sinatra/pull/1427) by Mike Pastore + +* Improve development support and documentation and source code by Will Yang, Jake Craige, Grey Baker and Guilherme Goettems Schneider + +## 2.0.3 / 2018-06-09 + +* Fix the backports gem regression [#1442](https://github.com/sinatra/sinatra/issues/1442) by Marc-André Lafortune + +## 2.0.2 / 2018-06-05 + +* Escape invalid query parameters [#1432](https://github.com/sinatra/sinatra/issues/1432) by Kunpei Sakai + * The patch fixes [CVE-2018-11627](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2018-11627). + +* Fix undefined method error for `Sinatra::RequiredParams` with hash key [#1431](https://github.com/sinatra/sinatra/issues/1431) by Arpit Chauhan + +* Add xml content-types to valid html_types for Rack::Protection [#1413](https://github.com/sinatra/sinatra/issues/1413) by Reenan Arbitrario + +* Encode route parameters using :default_encoding setting [#1412](https://github.com/sinatra/sinatra/issues/1412) by Brian m. Carlson + +* Fix unpredictable behaviour from Sinatra::ConfigFile [#1244](https://github.com/sinatra/sinatra/issues/1244) by John Hope + +* Add Sinatra::IndifferentHash#slice [#1405](https://github.com/sinatra/sinatra/issues/1405) by Shota Iguchi + +* Remove status code 205 from drop body response [#1398](https://github.com/sinatra/sinatra/issues/1398) by Shota Iguchi + +* Ignore empty captures from params [#1390](https://github.com/sinatra/sinatra/issues/1390) by Shota Iguchi + +* Improve development support and documentation and source code by Zp Yuan, Andreas Finger, Olle Jonsson, Shota Iguchi, Nikita Bulai and Joshua O'Brien + +## 2.0.1 / 2018-02-17 + +* Repair nested namespaces, by avoiding prefix duplication [#1322](https://github.com/sinatra/sinatra/issues/1322). Fixes [#1310](https://github.com/sinatra/sinatra/issues/1310) by Kunpei Sakai + +* Add pattern matches to values for Mustermann::Concat [#1333](https://github.com/sinatra/sinatra/issues/1333). Fixes [#1332](https://github.com/sinatra/sinatra/issues/1332) by Dawa Ometto + +* Ship the VERSION file with the gem, to allow local unpacking [#1338](https://github.com/sinatra/sinatra/issues/1338) by Olle Jonsson + +* Fix issue with custom error handler on bad request [#1351](https://github.com/sinatra/sinatra/issues/1351). Fixes [#1350](https://github.com/sinatra/sinatra/issues/1350) by Jordan Owens + +* Override Rack::ShowExceptions#pretty to set custom template [#1377](https://github.com/sinatra/sinatra/issues/1377). Fixes [#1376](https://github.com/sinatra/sinatra/issues/1376) by Jordan Owens + +* Enhanced path validation in Windows [#1379](https://github.com/sinatra/sinatra/issues/1379) by Orange Tsai from DEVCORE + * The patch fixes [CVE-2018-7212](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2018-7212) + +* Improve development support and documentation by Faheel Ahmad, Shota Iguchi, Olle Jonsson, Manabu Niseki, John Hope, Horacio, Ice-Storm, GraniteRock, Raman Skaskevich, Carlos Azuaje, 284km, Dan Rice and Zachary Scott + +## 2.0.0 / 2017-04-10 + + * Use Mustermann for patterns [#1086](https://github.com/sinatra/sinatra/issues/1086) by Konstantin Haase + + * Server now provides `-q` flag for quiet mode, which disables start/stop messages [#1153](https://github.com/sinatra/sinatra/issues/1153) by Vasiliy. + + * Session middleware can now be specified with `:session_store` setting [#1161](https://github.com/sinatra/sinatra/issues/1161) by Jordan Owens. + + * `APP_ENV` is now preferred and recommended over `RACK_ENV` for setting environment [#984](https://github.com/sinatra/sinatra/issues/984) by Damien Mathieu. + + * Add Reel support [#793](https://github.com/sinatra/sinatra/issues/793) by Patricio Mac Adden. + + * Make route params available during error handling [#895](https://github.com/sinatra/sinatra/issues/895) by Jeremy Evans. + + * Unify `not_found` and `error` 404 behavior [#896](https://github.com/sinatra/sinatra/issues/896) by Jeremy Evans. + + * Enable Ruby 2.3 `frozen_string_literal` feature [#1076](https://github.com/sinatra/sinatra/issues/1076) by Vladimir Kochnev. + + * Add Sinatra::ShowExceptions::TEMPLATE and patched Rack::ShowExceptions to prefer Sinatra template by Zachary Scott. + + * Sinatra::Runner is used internally for integration tests [#840](https://github.com/sinatra/sinatra/issues/840) by Nick Sutterer. + + * Fix case-sensitivity issue in `uri` method [#889](https://github.com/sinatra/sinatra/issues/889) by rennex. + + * Use `Rack::Utils.status_code` to allow `status` helper to use symbol as well as numeric codes [#968](https://github.com/sinatra/sinatra/issues/968) by Tobias H. Michaelsen. + + * Improved error handling for invalid params through Rack [#1070](https://github.com/sinatra/sinatra/issues/1070) by Jordan Owens. + + * Ensure template is cached only once [#1021](https://github.com/sinatra/sinatra/issues/1021) by Patrik Rak. + + * Rack middleware is initialized at server runtime rather than after receiving first request [#1205](https://github.com/sinatra/sinatra/issues/1205) by Itamar Turner-Trauring. + + * Improve Session Secret documentation to encourage better security practices [#1218](https://github.com/sinatra/sinatra/issues/1218) by Glenn Rempe + + * Exposed global and per-route options for Mustermann route parsing [#1233](https://github.com/sinatra/sinatra/issues/1233) by Mike Pastore + + * Use same `session_secret` for classic and modular apps in development [#1245](https://github.com/sinatra/sinatra/issues/1245) by Marcus Stollsteimer + + * Make authenticity token length a fixed value of 32 [#1181](https://github.com/sinatra/sinatra/issues/1181) by Jordan Owens + + * Modernize Rack::Protection::ContentSecurityPolicy with CSP Level 2 and 3 Directives [#1202](https://github.com/sinatra/sinatra/issues/1202) by Glenn Rempe + + * Adds preload option to Rack:Protection:StrictTransport [#1209](https://github.com/sinatra/sinatra/issues/1209) by Ed Robinson + + * Improve BadRequest logic. Raise and handle exceptions if status is 400 [#1212](https://github.com/sinatra/sinatra/issues/1212) by Mike Pastore + + * Make Rack::Test a development dependency [#1232](https://github.com/sinatra/sinatra/issues/1232) by Mike Pastore + + * Capture exception messages of raised NotFound and BadRequest [#1210](https://github.com/sinatra/sinatra/issues/1210) by Mike Pastore + + * Add explicit set method to contrib/cookies to override cookie settings [#1240](https://github.com/sinatra/sinatra/issues/1240) by Andrew Allen + + * Avoid executing filters even if prefix matches with other namespace [#1253](https://github.com/sinatra/sinatra/issues/1253) by namusyaka + + * Make `#has_key?` also indifferent in access, can accept String or Symbol [#1262](https://github.com/sinatra/sinatra/issues/1262) by Stephen Paul Weber + + * Add `allow_if` option to bypass json csrf protection [#1265](https://github.com/sinatra/sinatra/issues/1265) by Jordan Owens + + * rack-protection: Bundle StrictTransport, CookieTossing, and CSP [#1267](https://github.com/sinatra/sinatra/issues/1267) by Mike Pastore + + * Add `:strict_paths` option for managing trailing slashes [#1273](https://github.com/sinatra/sinatra/issues/1273) by namusyaka + + * Add full IndifferentHash implementation to params [#1279](https://github.com/sinatra/sinatra/issues/1279) by Mike Pastore + +## 1.4.8 / 2017-01-30 + + * Fix the deprecation warning from Ruby about Fixnum. [#1235](https://github.com/sinatra/sinatra/issues/1235) by Akira Matsuda + +## 1.4.7 / 2016-01-24 + + * Add Ashley Williams, Trevor Bramble, and Kashyap Kondamudi to team Sinatra. + + * Correctly handle encoded colons in routes. (Jeremy Evans) + + * Rename CHANGES to CHANGELOG.md and update Rakefile. [#1043](https://github.com/sinatra/sinatra/issues/1043) (Eliza Sorensen) + + * Improve documentation. [#941](https://github.com/sinatra/sinatra/issues/941), [#1069](https://github.com/sinatra/sinatra/issues/1069), [#1075](https://github.com/sinatra/sinatra/issues/1075), [#1025](https://github.com/sinatra/sinatra/issues/1025), [#1052](https://github.com/sinatra/sinatra/issues/1052) (Many great folks) + + * Introduce `Sinatra::Ext` to workaround Rack 1.6 bug to fix Ruby 1.8.7 + support. [#1080](https://github.com/sinatra/sinatra/issues/1080) (Zachary Scott) + + * Add CONTRIBUTING guide. [#987](https://github.com/sinatra/sinatra/issues/987) (Katrina Owen) + + +## 1.4.6 / 2015-03-23 + + * Improve tests and documentation. (Darío Hereñú, Seiichi Yonezawa, kyoendo, + John Voloski, Ferenc-, Renaud Martinet, Christian Haase, marocchino, + huoxito, Damir Svrtan, Amaury Medeiros, Jeremy Evans, Kashyap, shenqihui, + Ausmarton Fernandes, kami, Vipul A M, Lei Wu, 7stud, Taylor Shuler, + namusyaka, burningTyger, Cornelius Bock, detomastah, hakeda, John Hope, + Ruben Gonzalez, Andrey Deryabin, attilaolah, Anton Davydov, Nikita Penzin, + Dyego Costa) + + * Remove duplicate require of sinatra/base. (Alexey Muranov) + + * Escape HTML in 404 error page. (Andy Brody) + + * Refactor to method call in `Stream#close` and `#callback`. (Damir Svrtan) + + * Depend on latest version of Slim. (Damir Svrtan) + + * Fix compatibility with Tilt version 2. (Yegor Timoschenko) + + * Fix compatibility issue with Rack `pretty` method from ShowExceptions. + (Kashyap) + + * Show date in local time in exception messages. (tayler1) + + * Fix logo on error pages when using Ruby 1.8. (Jeremy Evans) + + * Upgrade test suite to Minitest version 5 and fix Ruby 2.2 compatibility. + (Vipul A M) + +## 1.4.5 / 2014-04-08 + + * Improve tests and documentation. (Seiichi Yonezawa, Mike Gehard, Andrew + Deitrick, Matthew Nicholas Bradley, GoGo tanaka, Carlos Lazo, Shim Tw, + kyoendo, Roman Kuznietsov, Stanislav Chistenko, Ryunosuke SATO, Ben Lewis, + wuleicanada, Patricio Mac Adden, Thais Camilo) + + * Fix Ruby warnings. (Vipul A M, Piotr Szotkowski) + + * Fix template cache memory leak. (Scott Holden) + + * Work around UTF-8 bug in JRuby. (namusyaka) + + * Don't set charset for JSON mime-type (Sebastian Borrazas) + + * Fix bug in request.accept? that might trigger a NoMethodError. (sbonami) + +## 1.4.4 / 2013-10-21 + + * Allow setting layout to false specifically for a single rendering engine. + (Matt Wildig) + + * Allow using wildcard in argument passed to `request.accept?`. (wilkie) + + * Treat missing Accept header like wild card. (Patricio Mac Adden) + + * Improve tests and documentation. (Darío Javier Cravero, Armen P., michelc, + Patricio Mac Adden, Matt Wildig, Vipul A M, utenmiki, George Timoschenko, + Diogo Scudelletti) + + * Fix Ruby warnings. (Vipul A M, Patricio Mac Adden) + + * Improve self-hosted server started by `run!` method or in classic mode. + (Tobias Bühlmann) + + * Reduce objects allocated per request. (Vipul A M) + + * Drop unused, undocumented options hash from Sinatra.new. (George Timoschenko) + + * Keep Content-Length header when response is a `Rack::File` or when streaming. + (Patricio Mac Adden, George Timoschenko) + + * Use reel if it's the only server available besides webrick. (Tobias Bühlmann) + + * Add `disable :traps` so setting up signal traps for self hosted server can be + skipped. (George Timoschenko) + + * The `status` option passed to `send_file` may now be a string. (George + Timoschenko) + + * Reduce file size of dev mode images for 404 and 500 pages. (Francis Go) + +## 1.4.3 / 2013-06-07 + + * Running a Sinatra file directly or via `run!` it will now ignore an + empty $PORT env variable. (noxqsgit) + + * Improve documentation. (burningTyger, Patricio Mac Adden, + Konstantin Haase, Diogo Scudelletti, Dominic Imhof) + + * Expose matched pattern as env["sinatra.route"]. (Aman Gupta) + + * Fix warning on Ruby 2.0. (Craig Little) + + * Improve running subset of tests in isolation. (Viliam Pucik) + + * Reorder private/public methods. (Patricio Mac Adden) + + * Loosen version dependency for rack, so it runs with Rails 3.2. + (Konstantin Haase) + + * Request#accept? now returns true instead of a truthy value. (Alan Harris) + +## 1.4.2 / 2013-03-21 + + * Fix parsing error for case where both the pattern and the captured part + contain a dot. (Florian Hanke, Konstantin Haase) + + * Missing Accept header is treated like */*. (Greg Denton) + + * Improve documentation. (Patricio Mac Adden, Joe Bottigliero) + +## 1.4.1 / 2013-03-15 + + * Make delegated methods available in config.ru (Konstantin Haase) + +## 1.4.0 / 2013-03-15 + + * Add support for LINK and UNLINK requests. (Konstantin Haase) + + * Add support for Yajl templates. (Jamie Hodge) + + * Add support for Rabl templates. (Jesse Cooke) + + * Add support for Wlang templates. (Bernard Lambeau) + + * Add support for Stylus templates. (Juan David Pastas, Konstantin Haase) + + * You can now pass a block to ERb, Haml, Slim, Liquid and Wlang templates, + which will be used when calling `yield` in the template. (Alexey Muranov) + + * When running in classic mode, no longer include Sinatra::Delegator in Object, + instead extend the main object only. (Konstantin Haase) + + * Improved route parsing: "/:name.?:format?" with "/foo.png" now matches to + {name: "foo", format: "png"} instead of {name: "foo.png"}. (Florian Hanke) + + * Add :status option support to send_file. (Konstantin Haase) + + * The `provides` condition now respects an earlier set content type. + (Konstantin Haase) + + * Exception#code is only used when :use_code is enabled. Moreover, it will + be ignored if the value is not between 400 and 599. You should use + Exception#http_status instead. (Konstantin Haase) + + * Status, headers and body will be set correctly in an after filter when using + halt in a before filter or route. (Konstantin Haase) + + * Sinatra::Base.new now returns a Sinatra::Wrapper instance, exposing + `#settings` and `#helpers`, yet going through the middleware stack on + `#call`. It also implements a nice `#inspect`, so it plays nice with + Rails' `rake routes`. (Konstantin Haase) + + * In addition to WebRick, Thin and Mongrel, Sinatra will now automatically pick + up Puma, Trinidad, ControlTower or Net::HTTP::Server when installed. The + logic for picking the server has been improved and now depends on the Ruby + implementation used. (Mark Rada, Konstantin Haase, Patricio Mac Adden) + + * "Sinatra doesn't know this ditty" pages now show the app class when running + a modular application. This helps detecting where the response came from when + combining multiple modular apps. (Konstantin Haase) + + * When port is not set explicitly, use $PORT env variable if set and only + default to 4567 if not. Plays nice with foreman. (Konstantin Haase) + + * Allow setting layout on a per engine basis. (Zachary Scott, Konstantin Haase) + + * You can now use `register` directly in a classic app. (Konstantin Haase) + + * `redirect` now accepts URI or Addressable::URI instances. (Nicolas + Sanguinetti) + + * Have Content-Disposition header also include file name for `inline`, not + just for `attachment`. (Konstantin Haase) + + * Better compatibility to Rack 1.5. (James Tucker, Konstantin Haase) + + * Make route parsing regex more robust. (Zoltan Dezso, Konstantin Haase) + + * Improve Accept header parsing, expose parameters. (Pieter van de Bruggen, + Konstantin Haase) + + * Add `layout_options` render option. Allows you, amongst other things, to + render a layout from a different folder. (Konstantin Haase) + + * Explicitly setting `layout` to `nil` is treated like setting it to `false`. + (richo) + + * Properly escape attributes in Content-Type header. (Pieter van de Bruggen) + + * Default to only serving localhost in development mode. (Postmodern) + + * Setting status code to 404 in error handler no longer triggers not_found + handler. (Konstantin Haase) + + * The `protection` option now takes a `session` key for force + disabling/enabling session based protections. (Konstantin Haase) + + * Add `x_cascade` option to disable `X-Cascade` header on missing route. + (Konstantin Haase) + + * Improve documentation. (Kashyap, Stanislav Chistenko, Zachary Scott, + Anthony Accomazzo, Peter Suschlik, Rachel Mehl, ymmtmsys, Anurag Priyam, + burningTyger, Tony Miller, akicho8, Vasily Polovnyov, Markus Prinz, + Alexey Muranov, Erik Johnson, Vipul A M, Konstantin Haase) + + * Convert documentation to Markdown. (Kashyap, Robin Dupret, burningTyger, + Vasily Polovnyov, Iain Barnett, Giuseppe Capizzi, Neil West) + + * Don't set not_found content type to HTML in development mode with custom + not_found handler. (Konstantin Haase) + + * Fix mixed indentation for private methods. (Robin Dupret) + + * Recalculate Content-Length even if hard coded if body is reset. Relevant + mostly for error handlers. (Nathan Esquenazi, Konstantin Haase) + + * Plus sign is once again kept as such when used for URL matches. (Konstantin + Haase) + + * Take views option into account for template caching. (Konstantin Haase) + + * Consistent use of `headers` instead of `header` internally. (Patricio Mac Adden) + + * Fix compatibility to RDoc 4. (Bohuslav Kabrda) + + * Make chat example work with latest jQuery. (loveky, Tony Miller) + + * Make tests run without warnings. (Patricio Mac Adden) + + * Make sure value returned by `mime_type` is a String or nil, even when a + different object is passed in, like an AcceptEntry. (Konstantin Haase) + + * Exceptions in `after` filter are now handled like any other exception. + (Nathan Esquenazi) + +## 1.3.6 (backport release) / 2013-03-15 + +Backported from 1.4.0: + + * Take views option into account for template caching. (Konstantin Haase) + + * Improve documentation (Konstantin Haase) + + * No longer override `define_singleton_method`. (Konstantin Haase) + +## 1.3.5 / 2013-02-25 + + * Fix for RubyGems 2.0 (Uchio KONDO) + + * Improve documentation (Konstantin Haase) + + * No longer override `define_singleton_method`. (Konstantin Haase) + +## 1.3.4 / 2013-01-26 + + * Improve documentation. (Kashyap, Stanislav Chistenko, Konstantin Haase, + ymmtmsys, Anurag Priyam) + + * Adjustments to template system to work with Tilt edge. (Konstantin Haase) + + * Fix streaming with latest Rack release. (Konstantin Haase) + + * Fix default content type for Sinatra::Response with latest Rack release. + (Konstantin Haase) + + * Fix regression where + was no longer treated like space. (Ross Boucher) + + * Status, headers and body will be set correctly in an after filter when using + halt in a before filter or route. (Konstantin Haase) + +## 1.3.3 / 2012-08-19 + + * Improved documentation. (burningTyger, Konstantin Haase, Gabriel Andretta, + Anurag Priyam, michelc) + + * No longer modify the load path. (Konstantin Haase) + + * When keeping a stream open, set up callback/errback correctly to deal with + clients closing the connection. (Konstantin Haase) + + * Fix bug where having a query param and a URL param by the same name would + concatenate the two values. (Konstantin Haase) + + * Prevent duplicated log output when application is already wrapped in a + `Rack::CommonLogger`. (Konstantin Haase) + + * Fix issue where `Rack::Link` and Rails were preventing indefinite streaming. + (Konstantin Haase) + + * No longer cause warnings when running Ruby with `-w`. (Konstantin Haase) + + * HEAD requests on static files no longer report a Content-Length of 0, but + instead the proper length. (Konstantin Haase) + + * When protecting against CSRF attacks, drop the session instead of refusing + the request. (Konstantin Haase) + +## 1.3.2 / 2011-12-30 + + * Don't automatically add `Rack::CommonLogger` if `Rack::Server` is adding it, + too. (Konstantin Haase) + + * Setting `logging` to `nil` will avoid setting up `Rack::NullLogger`. + (Konstantin Haase) + + * Route specific params are now available in the block passed to #stream. + (Konstantin Haase) + + * Fix bug where rendering a second template in the same request, after the + first one raised an exception, skipped the default layout. (Nathan Baum) + + * Fix bug where parameter escaping got enabled when disabling a different + protection. (Konstantin Haase) + + * Fix regression: Filters without a pattern may now again manipulate the params + hash. (Konstantin Haase) + + * Added examples directory. (Konstantin Haase) + + * Improved documentation. (Gabriel Andretta, Markus Prinz, Erick Zetta, Just + Lest, Adam Vaughan, Aleksander Dąbrowski) + + * Improved MagLev support. (Tim Felgentreff) + +## 1.3.1 / 2011-10-05 + + * Support adding more than one callback to the stream object. (Konstantin + Haase) + + * Fix for infinite loop when streaming on 1.9.2 with Thin from a modular + application (Konstantin Haase) + +## 1.3.0 / 2011-09-30 + + * Added `stream` helper method for easily creating streaming APIs, Server + Sent Events or even WebSockets. See README for more on that topic. + (Konstantin Haase) + + * If a HTTP 1.1 client is redirected from a different verb than GET, use 303 + instead of 302 by default. You may still pass 302 explicitly. Fixes AJAX + redirects in Internet Explorer 9 (to be fair, everyone else is doing it + wrong and IE is behaving correct). (Konstantin Haase) + + * Added support for HTTP PATCH requests. (Konstantin Haase) + + * Use rack-protection to defend against common opportunistic attacks. + (Josh Lane, Jacob Burkhart, Konstantin Haase) + + * Support for Creole templates, Creole is a standardized wiki markup, + supported by many wiki implementations. (Konstanin Haase) + + * The `erubis` method has been deprecated. If Erubis is available, Sinatra + will automatically use it for rendering ERB templates. `require 'erb'` + explicitly to prevent that behavior. (Magnus Holm, Ryan Tomayko, Konstantin + Haase) + + * Patterns now match against the escaped URLs rather than the unescaped + version. This makes Sinatra confirm with RFC 2396 section 2.2 and RFC 2616 + section 3.2.3 (escaped reserved characters should not be treated like the + unescaped version), meaning that "/:name" will also match `/foo%2Fbar`, but + not `/foo/bar`. To avoid incompatibility, pattern matching has been + adjusted. Moreover, since we do no longer need to keep an unescaped version + of path_info around, we handle all changes to `env['PATH_INFO']` correctly. + (Konstantin Haase) + + * `settings.app_file` now defaults to the file subclassing `Sinatra::Base` in + modular applications. (Konstantin Haase) + + * Set up `Rack::Logger` or `Rack::NullLogger` depending on whether logging + was enabled or not. Also, expose that logger with the `logger` helper + method. (Konstantin Haase) + + * The sessions setting may be an options hash now. (Konstantin Haase) + + * Important: Ruby 1.8.6 support has been dropped. This version also depends + on at least Rack 1.3.0. This means that it is incompatible with Rails prior + to 3.1.0. Please use 1.2.x if you require an earlier version of Ruby or + Rack, which we will continue to supply with bug fixes. (Konstantin Haase) + + * Renamed `:public` to `:public_folder` to avoid overriding Ruby's built-in + `public` method/keyword. `set(:public, ...)` is still possible but shows a + warning. (Konstantin Haase) + + * It is now possible to use a different target class for the top level DSL + (aka classic style) than `Sinatra::Application` by setting + `Delegator.target`. This was mainly introduced to ease testing. (Konstantin + Haase) + + * Error handlers defined for an error class will now also handle subclasses + of that class, unless more specific error handlers exist. (Konstantin + Haase) + + * Error handling respects Exception#code, again. (Konstantin Haase) + + * Changing a setting will merge hashes: `set(:x, :a => 1); set(:x :b => 2)` + will result in `{:a => 1, :b => 2}`. Use `set(:x, {:a => 1}, true)` to + avoid this behavior. (Konstantin Haase) + + * Added `request.accept?` and `request.preferred_type` to ease dealing with + `Accept` headers. (Konstantin Haase) + + * Added `:static_cache_control` setting to automatically set cache control + headers to static files. (Kenichi Nakamura) + + * Added `informal?`, `success?`, `redirect?`, `client_error?`, + `server_error?` and `not_found?` helper methods to ease dealing with status + codes. (Konstantin Haase) + + * Uses SecureRandom to generate default session secret. (Konstantin Haase) + + * The `attachment` helper will set Content-Type (if it hasn't been set yet) + depending on the supplied file name. (Vasiliy Ermolovich) + + * Conditional requests on `etag` helper now work properly for unsafe HTTP + methods. (Matthew Schinckel, Konstantin Haase) + + * The `last_modified` helper does not stop execution and change the status code + if the status code is something different than 200. (Konstantin Haase) + + * Added support for If-Unmodified-Since header. (Konstantin Haase) + + * `Sinatra::Base.run!` now prints to stderr rather than stdout. (Andrew + Armenia) + + * `Sinatra::Base.run!` takes a block allowing access to the Rack handler. + (David Waite) + + * Automatic `app_file` detection now works in directories containing brackets + (Konstantin Haase) + + * Exception objects are now passed to error handlers. (Konstantin Haase) + + * Improved documentation. (Emanuele Vicentini, Peter Higgins, Takanori + Ishikawa, Konstantin Haase) + + * Also specify charset in Content-Type header for JSON. (Konstantin Haase) + + * Rack handler names will not be converted to lower case internally, this + allows you to run Sinatra with custom Rack handlers, like Kirk or Mongrel2. + Example: `ruby app.rb -s Mongrel2` (Konstantin Haase) + + * Ignore `to_ary` on response bodies. Fixes compatibility to Rails 3.1. + (Konstantin Haase) + + * Middleware setup is now distributed across multiple methods, allowing + Sinatra extensions to easily hook into the setup process. (Konstantin + Haase) + + * Internal refactoring and minor performance improvements. (Konstantin Haase) + + * Move Sinatra::VERSION to separate file, so it can be checked without + loading Sinatra. (Konstantin Haase) + + * Command line options now complain if value passed to `-p` is not a valid + integer. (Konstantin Haase) + + * Fix handling of broken query params when displaying exceptions. (Luke + Jahnke) + +## 1.2.9 (backports release) / 2013-03-15 + +IMPORTANT: THIS IS THE LAST 1.2.x RELEASE, PLEASE UPGRADE. + + * Display EOL warning when loading Sinatra. (Konstantin Haase) + + * Improve documentation. (Anurag Priyam, Konstantin Haase) + + * Do not modify the load path. (Konstantin Haase) + + * Display deprecation warning if RUBY_IGNORE_CALLERS is used. (Konstantin Haase) + + * Add backports library so we can still run on Ruby 1.8.6. (Konstantin Haase) + +## 1.2.8 (backports release) / 2011-12-30 + +Backported from 1.3.2: + +* Fix bug where rendering a second template in the same request after the + first one raised an exception skipped the default layout (Nathan Baum) + +## 1.2.7 (backports release) / 2011-09-30 + +Custom changes: + + * Fix Ruby 1.8.6 issue with Accept header parsing. (Konstantin Haase) + +Backported from 1.3.0: + + * Ignore `to_ary` on response bodies. Fixes compatibility to Rails 3.1. + (Konstantin Haase) + + * `Sinatra.run!` now prints to stderr rather than stdout. (Andrew Armenia) + + * Automatic `app_file` detection now works in directories containing brackets + (Konstantin Haase) + + * Improved documentation. (Emanuele Vicentini, Peter Higgins, Takanori + Ishikawa, Konstantin Haase) + + * Also specify charset in Content-Type header for JSON. (Konstantin Haase) + + * Rack handler names will not be converted to lower case internally, this + allows you to run Sinatra with custom Rack handlers, like Kirk or Mongrel2. + Example: `ruby app.rb -s Mongrel2` (Konstantin Haase) + + * Fix uninitialized instance variable warning. (David Kellum) + + * Command line options now complain if value passed to `-p` is not a valid + integer. (Konstantin Haase) + + * Fix handling of broken query params when displaying exceptions. (Luke + Jahnke) + +## 1.2.6 / 2011-05-01 + + * Fix broken delegation, backport delegation tests from Sinatra 1.3. + (Konstantin Haase) + +## 1.2.5 / 2011-04-30 + + * Restore compatibility with Ruby 1.8.6. (Konstantin Haase) + +## 1.2.4 / 2011-04-30 + + * Sinatra::Application (classic style) does not use a session secret in + development mode, so sessions are not invalidated after every request when + using Shotgun. (Konstantin Haase) + + * The request object was shared between multiple Sinatra instances in the + same middleware chain. This caused issues if any non-sinatra routing + happened in-between two of those instances, or running a request twice + against an application (described in the README). The caching was reverted. + See GH[#239](https://github.com/sinatra/sinatra/issues/239) and GH[#256](https://github.com/sinatra/sinatra/issues/256) for more infos. (Konstantin Haase) + + * Fixes issues where the top level DSL was interfering with method_missing + proxies. This issue surfaced when Rails 3 was used with older Sass versions + and Sinatra >= 1.2.0. (Konstantin Haase) + + * Sinatra::Delegator.delegate is now able to delegate any method names, even + those containing special characters. This allows better integration into + other programming languages on Rubinius (probably on the JVM, too), like + Fancy. (Konstantin Haase) + + * Remove HEAD request logic and let Rack::Head handle it instead. (Paolo + "Nusco" Perrotta) + +## 1.2.3 / 2011-04-13 + + * This release is compatible with Tilt 1.3, it will still work with Tilt 1.2.2, + however, if you want to use a newer Tilt version, you have to upgrade to at + least this version of Sinatra. (Konstantin Haase) + + * Helpers dealing with time, like `expires`, handle objects that pretend to be + numbers, like `ActiveSupport::Duration`, better. (Konstantin Haase) + +## 1.2.2 / 2011-04-08 + + * The `:provides => :js` condition now matches both `application/javascript` + and `text/javascript`. The `:provides => :xml` condition now matches both + `application/xml` and `text/xml`. The `Content-Type` header is set + accordingly. If the client accepts both, the `application/*` version is + preferred, since the `text/*` versions are deprecated. (Konstantin Haase) + + * The `provides` condition now handles wildcards in `Accept` headers correctly. + Thus `:provides => :html` matches `text/html`, `text/*` and `*/*`. + (Konstantin Haase) + + * When parsing `Accept` headers, `Content-Type` preferences are honored + according to RFC 2616 section 14.1. (Konstantin Haase) + + * URIs passed to the `url` helper or `redirect` may now use any schema to be + identified as absolute URIs, not only `http` or `https`. (Konstantin Haase) + + * Handles `Content-Type` strings that already contain parameters correctly in + `content_type` (example: `content_type "text/plain; charset=utf-16"`). + (Konstantin Haase) + + * If a route with an empty pattern is defined (`get("") { ... }`) requests with + an empty path info match this route instead of "/". (Konstantin Haase) + + * In development environment, when running under a nested path, the image URIs + on the error pages are set properly. (Konstantin Haase) + +## 1.2.1 / 2011-03-17 + + * Use a generated session secret when using `enable :sessions`. (Konstantin + Haase) + + * Fixed a bug where the wrong content type was used if no content type was set + and a template engine was used with a different engine for the layout with + different default content types, say Less embedded in Slim. (Konstantin + Haase) + + * README translations improved (Gabriel Andretta, burningTyger, Sylvain Desvé, + Gregor Schmidt) + +## 1.2.0 / 2011-03-03 + + * Added `slim` rendering method for rendering Slim templates. (Steve + Hodgkiss) + + * The `markaby` rendering method now allows passing a block, making inline + usage possible. Requires Tilt 1.2 or newer. (Konstantin Haase) + + * All render methods now take a `:layout_engine` option, allowing to use a + layout in a different template language. Even more useful than using this + directly (`erb :index, :layout_engine => :haml`) is setting this globally for + a template engine that otherwise does not support layouts, like Markdown or + Textile (`set :markdown, :layout_engine => :erb`). (Konstantin Haase) + + * Before and after filters now support conditions, both with and without + patterns (`before '/api/*', :agent => /Songbird/`). (Konstantin Haase) + + * Added a `url` helper method which constructs absolute URLs. Copes with + reverse proxies and Rack handlers correctly. Aliased to `to`, so you can + write `redirect to('/foo')`. (Konstantin Haase) + + * If running on 1.9, patterns for routes and filters now support named + captures: `get(%r{/hi/(?[^/?#]+)}) { "Hi #{params['name']}" }`. + (Steve Price) + + * All rendering methods now take a `:scope` option, which renders them in + another context. Note that helpers and instance variables will be + unavailable if you use this feature. (Paul Walker) + + * The behavior of `redirect` can now be configured with `absolute_redirects` + and `prefixed_redirects`. (Konstantin Haase) + + * `send_file` now allows overriding the Last-Modified header, which defaults + to the file's mtime, by passing a `:last_modified` option. (Konstantin Haase) + + * You can use your own template lookup method by defining `find_template`. + This allows, among other things, using more than one views folder. + (Konstantin Haase) + + * Largely improved documentation. (burningTyger, Vasily Polovnyov, Gabriel + Andretta, Konstantin Haase) + + * Improved error handling. (cactus, Konstantin Haase) + + * Skip missing template engines in tests correctly. (cactus) + + * Sinatra now ships with a Gemfile for development dependencies, since it eases + supporting different platforms, like JRuby. (Konstantin Haase) + +## 1.1.4 (backports release) / 2011-04-13 + + * Compatible with Tilt 1.3. (Konstantin Haase) + +## 1.1.3 / 2011-02-20 + + * Fixed issues with `user_agent` condition if the user agent header is missing. + (Konstantin Haase) + + * Fix some routing tests that have been skipped by accident (Ross A. Baker) + + * Fix rendering issues with Builder and Nokogiri (Konstantin Haase) + + * Replace last_modified helper with better implementation. (cactus, + Konstantin Haase) + + * Fix issue with charset not being set when using `provides` condition. + (Konstantin Haase) + + * Fix issue with `render` not picking up all alternative file extensions for + a rendering engine - it was not possible to register ".html.erb" without + tricks. (Konstantin Haase) + +## 1.1.2 / 2010-10-25 + +Like 1.1.1, but with proper CHANGES file. + +## 1.1.1 / 2010-10-25 + + * README has been translated to Russian (Nickolay Schwarz, Vasily Polovnyov) + and Portuguese (Luciano Sousa). + + * Nested templates without a `:layout` option can now be used from the layout + template without causing an infinite loop. (Konstantin Haase) + + * Inline templates are now encoding aware and can therefore be used with + unicode characters on Ruby 1.9. Magic comments at the beginning of the file + will be honored. (Konstantin Haase) + + * Default `app_file` is set correctly when running with bundler. Using + bundler caused Sinatra not to find the `app_file` and therefore not to find + the `views` folder on it's own. (Konstantin Haase) + + * Better handling of Content-Type when using `send_file`: If file extension + is unknown, fall back to `application/octet-stream` and do not override + content type if it has already been set, except if `:type` is passed + explicitly (Konstantin Haase) + + * Path is no longer cached if changed between handlers that do pattern + matching. This means you can change `request.path_info` in a pattern + matching before filter. (Konstantin Haase) + + * Headers set by cache_control now always set max_age as an Integer, making + sure it is compatible with RFC2616. (Konstantin Haase) + + * Further improved handling of string encodings on Ruby 1.9, templates now + honor default_encoding and URLs support unicode characters. (Konstantin + Haase) + +## 1.1.0 / 2010-10-24 + + * Before and after filters now support pattern matching, including the + ability to use captures: "before('/user/:name') { |name| ... }". This + avoids manual path checking. No performance loss if patterns are avoided. + (Konstantin Haase) + + * It is now possible to render SCSS files with the `scss` method, which + behaves exactly like `sass` except for the different file extension and + assuming the SCSS syntax. (Pedro Menezes, Konstantin Haase) + + * Added `liquid`, `markdown`, `nokogiri`, `textile`, `rdoc`, `radius`, + `markaby`, and `coffee` rendering methods for rendering Liquid, Markdown, + Nokogiri, Textile, RDoc, Radius, Markaby and CoffeeScript templates. + (Konstantin Haase) + + * Now supports byte-range requests (the HTTP_RANGE header) for static files. + Multi-range requests are not supported, however. (Jens Alfke) + + * You can now use #settings method from class and top level for convenience. + (Konstantin Haase) + + * Setting multiple values now no longer relies on #to_hash and therefore + accepts any Enumerable as parameter. (Simon Rozet) + + * Nested templates default the `layout` option to `false` rather than `true`. + This eases the use of partials. If you wanted to render one haml template + embedded in another, you had to call `haml :partial, {}, :layout => false`. + As you almost never want the partial to be wrapped in the standard layout + in this situation, you now only have to call `haml :partial`. Passing in + `layout` explicitly is still possible. (Konstantin Haase) + + * If a the return value of one of the render functions is used as a response + body and the content type has not been set explicitly, Sinatra chooses a + content type corresponding to the rendering engine rather than just using + "text/html". (Konstantin Haase) + + * README is now available in Chinese (Wu Jiang), French (Mickael Riga), + German (Bernhard Essl, Konstantin Haase, burningTyger), Hungarian (Janos + Hardi) and Spanish (Gabriel Andretta). The extremely outdated Japanese + README has been updated (Kouhei Yanagita). + + * It is now possible to access Sinatra's template_cache from the outside. + (Nick Sutterer) + + * The `last_modified` method now also accepts DateTime instances and makes + sure the header will always be set to a string. (Konstantin Haase) + + * 599 now is a legal status code. (Steve Shreeve) + + * This release is compatible with Ruby 1.9.2. Sinatra was trying to read + non existent files Ruby added to the call stack. (Shota Fukumori, + Konstantin Haase) + + * Prevents a memory leak on 1.8.6 in production mode. Note, however, that + this is due to a bug in 1.8.6 and request will have the additional overhead + of parsing templates again on that version. It is recommended to use at + least Ruby 1.8.7. (Konstantin Haase) + + * Compares last modified date correctly. `last_modified` was halting only + when the 'If-Modified-Since' header date was equal to the time specified. + Now, it halts when is equal or later than the time specified (Gabriel + Andretta). + + * Sinatra is now usable in combination with Rails 3. When mounting a Sinatra + application under a subpath in Rails 3, the PATH_INFO is not prefixed with + a slash and no routes did match. (José Valim) + + * Better handling of encodings in 1.9, defaults params encoding to UTF-8. + (Konstantin Haase) + + * `show_exceptions` handling is now triggered after custom error handlers, if + it is set to `:after_handlers`, thus not disabling those handler in + development mode. (pangel, Konstantin Haase) + + * Added ability to handle weighted HTTP_ACCEPT headers. (Davide D'Agostino) + + * `send_file` now always respects the `:type` option if set. Previously it + was discarded if no matching mime type was found, which made it impossible + to directly pass a mime type. (Konstantin Haase) + + * `redirect` always redirects to an absolute URI, even if a relative URI was + passed. Ensures compatibility with RFC 2616 section 14.30. (Jean-Philippe + Garcia Ballester, Anthony Williams) + + * Broken examples for using Erubis, Haml and Test::Unit in README have been + fixed. (Nick Sutterer, Doug Ireton, Jason Stewart, Eric Marden) + + * Sinatra now handles SIGTERM correctly. (Patrick Collison) + + * Fixes an issue with inline templates in modular applications that manually + call `run!`. (Konstantin Haase) + + * Spaces after inline template names are now ignored (Konstantin Haase) + + * It's now possible to use Sinatra with different package management + systems defining a custom require. (Konstantin Haase) + + * Lighthouse has been dropped in favor of GitHub issues. + + * Tilt is now a dependency and therefore no longer ships bundled with + Sinatra. (Ryan Tomayko, Konstantin Haase) + + * Sinatra now depends on Rack 1.1 or higher. Rack 1.0 is no longer supported. + (Konstantin Haase) + +## 1.0 / 2010-03-23 + + * It's now possible to register blocks to run after each request using + after filters. After filters run at the end of each request, after + routes and error handlers. (Jimmy Schementi) + + * Sinatra now uses Tilt for rendering + templates. This adds support for template caching, consistent + template backtraces, and support for new template engines, like + mustache and liquid. (Ryan Tomayko) + + * ERB, Erubis, and Haml templates are now compiled the first time + they're rendered instead of being string eval'd on each invocation. + Benchmarks show a 5x-10x improvement in render time. This also + reduces the number of objects created, decreasing pressure on Ruby's + GC. (Ryan Tomayko) + + * New 'settings' method gives access to options in both class and request + scopes. This replaces the 'options' method. (Chris Wanstrath) + + * New boolean 'reload_templates' setting controls whether template files + are reread from disk and recompiled on each request. Template read/compile + is cached by default in all environments except development. (Ryan Tomayko) + + * New 'erubis' helper method for rendering ERB template with Erubis. The + erubis gem is required. (Dylan Egan) + + * New 'cache_control' helper method provides a convenient way of + setting the Cache-Control response header. Takes a variable number + of boolean directives followed by a hash of value directives, like + this: cache_control :public, :must_revalidate, :max_age => 60 + (Ryan Tomayko) + + * New 'expires' helper method is like cache_control but takes an + integer number of seconds or Time object: + expires 300, :public, :must_revalidate + (Ryan Tomayko) + + * New request.secure? method for checking for an SSL connection. + (Adam Wiggins) + + * Sinatra apps can now be run with a `-o ` argument to specify + the address to bind to. (Ryan Tomayko) + + * Rack::Session::Cookie is now added to the middleware pipeline when + running in test environments if the :sessions option is set. + (Simon Rozet) + + * Route handlers, before filters, templates, error mappings, and + middleware are now resolved dynamically up the inheritance hierarchy + when needed instead of duplicating the superclass's version when + a new Sinatra::Base subclass is created. This should fix a variety + of issues with extensions that need to add any of these things + to the base class. (Ryan Tomayko) + + * Exception error handlers always override the raise_errors option now. + Previously, all exceptions would be raised outside of the application + when the raise_errors option was enabled, even if an error handler was + defined for that exception. The raise_errors option now controls + whether unhandled exceptions are raised (enabled) or if a generic 500 + error is returned (disabled). (Ryan Tomayko) + + * The X-Cascade response header is set to 'pass' when no matching route + is found or all routes pass. (Josh Peek) + + * Filters do not run when serving static files anymore. (Ryan Tomayko) + + * pass takes an optional block to be used as the route handler if no + subsequent route matches the request. (Blake Mizerany) + +The following Sinatra features have been obsoleted (removed entirely) in +the 1.0 release: + + * The `sinatra/test` library is obsolete. This includes the `Sinatra::Test` + module, the `Sinatra::TestHarness` class, and the `get_it`, `post_it`, + `put_it`, `delete_it`, and `head_it` helper methods. The + [`Rack::Test` library](http://gitrdoc.com/brynary/rack-test) should + be used instead. + + * Test framework specific libraries (`sinatra/test/spec`, + `sinatra/test/bacon`,`sinatra/test/rspec`, etc.) are obsolete. See + http://www.sinatrarb.com/testing.html for instructions on setting up a + testing environment under each of these frameworks. + + * `Sinatra::Default` is obsolete; use `Sinatra::Base` instead. + `Sinatra::Base` acts more like `Sinatra::Default` in development mode. + For example, static file serving and sexy development error pages are + enabled by default. + + * Auto-requiring template libraries in the `erb`, `builder`, `haml`, + and `sass` methods is obsolete due to thread-safety issues. You must + require the template libraries explicitly in your app. + + * The `:views_directory` option to rendering methods is obsolete; use + `:views` instead. + + * The `:haml` and `:sass` options to rendering methods are obsolete. + Template engine options should be passed in the second Hash argument + instead. + + * The `use_in_file_templates` method is obsolete. Use + `enable :inline_templates` or `set :inline_templates, 'path/to/file'` + + * The 'media_type' helper method is obsolete. Use 'mime_type' instead. + + * The 'mime' main and class method is obsolete. Use 'mime_type' instead. + + * The request-level `send_data` method is no longer supported. + + * The `Sinatra::Event` and `Sinatra::EventContext` classes are no longer + supported. This may effect extensions written for versions prior to 0.9.2. + See [Writing Sinatra Extensions](http://www.sinatrarb.com/extensions.html) + for the officially supported extensions API. + + * The `set_option` and `set_options` methods are obsolete; use `set` + instead. + + * The `:env` setting (`settings.env`) is obsolete; use `:environment` + instead. + + * The request level `stop` method is obsolete; use `halt` instead. + + * The request level `entity_tag` method is obsolete; use `etag` + instead. + + * The request level `headers` method (HTTP response headers) is obsolete; + use `response['Header-Name']` instead. + + * `Sinatra.application` is obsolete; use `Sinatra::Application` instead. + + * Using `Sinatra.application = nil` to reset an application is obsolete. + This should no longer be necessary. + + * Using `Sinatra.default_options` to set base configuration items is + obsolete; use `Sinatra::Base.set(key, value)` instead. + + * The `Sinatra::ServerError` exception is obsolete. All exceptions raised + within a request are now treated as internal server errors and result in + a 500 response status. + + * The `:methodoverride' option to enable/disable the POST _method hack is + obsolete; use `:method_override` instead. + +## 0.9.2 / 2009-05-18 + + * This version is compatible with Rack 1.0. [Rein Henrichs] + + * The development-mode unhandled exception / error page has been + greatly enhanced, functionally and aesthetically. The error + page is used when the :show_exceptions option is enabled and an + exception propagates outside of a route handler or before filter. + [Simon Rozet / Matte Noble / Ryan Tomayko] + + * Backtraces that move through templates now include filenames and + line numbers where possible. [#51 / S. Brent Faulkner] + + * All templates now have an app-level option for setting default + template options (:haml, :sass, :erb, :builder). The app-level + option value must be a Hash if set and is merged with the + template options specified to the render method (Base#haml, + Base#erb, Base#builder). [S. Brent Faulkner, Ryan Tomayko] + + * The method signature for all template rendering methods has + been unified: "def engine(template, options={}, locals={})". + The options Hash now takes the generic :views, :layout, and + :locals options but also any template-specific options. The + generic options are removed before calling the template specific + render method. Locals may be specified using either the + :locals key in the options hash or a second Hash option to the + rendering method. [#191 / Ryan Tomayko] + + * The receiver is now passed to "configure" blocks. This + allows for the following idiom in top-level apps: + configure { |app| set :foo, app.root + '/foo' } + [TJ Holowaychuck / Ryan Tomayko] + + * The "sinatra/test" lib is deprecated and will be removed in + Sinatra 1.0. This includes the Sinatra::Test module and + Sinatra::TestHarness class in addition to all the framework + test helpers that were deprecated in 0.9.1. The Rack::Test + lib should be used instead: http://gitrdoc.com/brynary/rack-test + [#176 / Simon Rozet] + + * Development mode source file reloading has been removed. The + "shotgun" (http://rtomayko.github.com/shotgun/) program can be + used to achieve the same basic functionality in most situations. + Passenger users should use the "tmp/always_restart.txt" + file (http://tinyurl.com/c67o4h). [#166 / Ryan Tomayko] + + * Auto-requiring template libs in the erb, builder, haml, and + sass methods is deprecated due to thread-safety issues. You must + require the template libs explicitly in your app file. [Simon Rozet] + + * A new Sinatra::Base#route_missing method was added. route_missing + is sent when no route matches the request or all route handlers + pass. The default implementation forwards the request to the + downstream app when running as middleware (i.e., "@app" is + non-nil), or raises a NotFound exception when no downstream app + is defined. Subclasses can override this method to perform custom + route miss logic. [Jon Crosby] + + * A new Sinatra::Base#route_eval method was added. The method + yields to the block and throws :halt with the result. Subclasses + can override this method to tap into the route execution logic. + [TJ Holowaychuck] + + * Fix the "-x" (enable request mutex / locking) command line + argument. Passing -x now properly sets the :lock option. + [S. Brent Faulkner, Ryan Tomayko] + + * Fix writer ("foo=") and predicate ("foo?") methods in extension + modules not being added to the registering class. + [#172 / Pat Nakajima] + + * Fix in-file templates when running alongside activesupport and + fatal errors when requiring activesupport before sinatra + [#178 / Brian Candler] + + * Fix various issues running on Google AppEngine. + [Samuel Goebert, Simon Rozet] + + * Fix in-file templates __END__ detection when __END__ exists with + other stuff on a line [Yoji Shidara] + +## 0.9.1.1 / 2009-03-09 + + * Fix directory traversal vulnerability in default static files + route. See [#177] for more info. + +## 0.9.1 / 2009-03-01 + + * Sinatra now runs under Ruby 1.9.1 [#61] + + * Route patterns (splats, :named, or Regexp captures) are now + passed as arguments to the block. [#140] + + * The "helpers" method now takes a variable number of modules + along with the normal block syntax. [#133] + + * New request-level #forward method for middleware components: passes + the env to the downstream app and merges the response status, headers, + and body into the current context. [#126] + + * Requests are now automatically forwarded to the downstream app when + running as middleware and no matching route is found or all routes + pass. + + * New simple API for extensions/plugins to add DSL-level and + request-level methods. Use Sinatra.register(mixin) to extend + the DSL with all public methods defined in the mixin module; + use Sinatra.helpers(mixin) to make all public methods defined + in the mixin module available at the request level. [#138] + See http://www.sinatrarb.com/extensions.html for details. + + * Named parameters in routes now capture the "." character. This makes + routes like "/:path/:filename" match against requests like + "/foo/bar.txt"; in this case, "params[:filename]" is "bar.txt". + Previously, the route would not match at all. + + * Added request-level "redirect back" to redirect to the referring + URL. + + * Added a new "clean_trace" option that causes backtraces dumped + to rack.errors and displayed on the development error page to + omit framework and core library backtrace lines. The option is + enabled by default. [#77] + + * The ERB output buffer is now available to helpers via the @_out_buf + instance variable. + + * It's now much easier to test sessions in unit tests by passing a + ":session" option to any of the mock request methods. e.g., + get '/', {}, :session => { 'foo' => 'bar' } + + * The testing framework specific files ('sinatra/test/spec', + 'sinatra/test/bacon', 'sinatra/test/rspec', etc.) have been deprecated. + See http://sinatrarb.com/testing.html for instructions on setting up + a testing environment with these frameworks. + + * The request-level #send_data method from Sinatra 0.3.3 has been added + for compatibility but is deprecated. + + * Fix :provides causing crash on any request when request has no + Accept header [#139] + + * Fix that ERB templates were evaluated twice per "erb" call. + + * Fix app-level middleware not being run when the Sinatra application is + run as middleware. + + * Fixed some issues with running under Rack's CGI handler caused by + writing informational stuff to stdout. + + * Fixed that reloading was sometimes enabled when starting from a + rackup file [#110] + + * Fixed that "." in route patterns erroneously matched any character + instead of a literal ".". [#124] + +## 0.9.0.4 / 2009-01-25 + + * Using halt with more than 1 args causes ArgumentError [#131] + * using halt in a before filter doesn't modify response [#127] + * Add deprecated Sinatra::EventContext to unbreak plugins [#130] + * Give access to GET/POST params in filters [#129] + * Preserve non-nested params in nested params hash [#117] + * Fix backtrace dump with Rack::Lint [#116] + +## 0.9.0.3 / 2009-01-21 + + * Fall back on mongrel then webrick when thin not found. [#75] + * Use :environment instead of :env in test helpers to + fix deprecation warnings coming from framework. + * Make sinatra/test/rspec work again [#113] + * Fix app_file detection on windows [#118] + * Fix static files with Rack::Lint in pipeline [#121] + +## 0.9.0.2 / 2009-01-18 + + * Halting a before block should stop processing of routes [#85] + * Fix redirect/halt in before filters [#85] + +## 0.9.0 / 2009-01-18 + + * Works with and requires Rack >= 0.9.1 + + * Multiple Sinatra applications can now co-exist peacefully within a + single process. The new "Sinatra::Base" class can be subclassed to + establish a blank-slate Rack application or middleware component. + Documentation on using these features is forth-coming; the following + provides the basic gist: http://gist.github.com/38605 + + * Parameters with subscripts are now parsed into a nested/recursive + Hash structure. e.g., "post[title]=Hello&post[body]=World" yields + params: {'post' => {'title' => 'Hello', 'body' => 'World'}}. + + * Regular expressions may now be used in route pattens; captures are + available at "params[:captures]". + + * New ":provides" route condition takes an array of mime types and + matches only when an Accept request header is present with a + corresponding type. [cypher] + + * New request-level "pass" method; immediately exits the current block + and passes control to the next matching route. + + * The request-level "body" method now takes a block; evaluation is + deferred until an attempt is made to read the body. The block must + return a String or Array. + + * New "route conditions" system for attaching rules for when a route + matches. The :agent and :host route options now use this system. + + * New "dump_errors" option controls whether the backtrace is dumped to + rack.errors when an exception is raised from a route. The option is + enabled by default for top-level apps. + + * Better default "app_file", "root", "public", and "views" location + detection; changes to "root" and "app_file" automatically cascade to + other options that depend on them. + + * Error mappings are now split into two distinct layers: exception + mappings and custom error pages. Exception mappings are registered + with "error(Exception)" and are run only when the app raises an + exception. Custom error pages are registered with "error(status_code)", + where "status_code" is an integer, and are run any time the response + has the status code specified. It's also possible to register an error + page for a range of status codes: "error(500..599)". + + * In-file templates are now automatically imported from the file that + requires 'sinatra'. The use_in_file_templates! method is still available + for loading templates from other files. + + * Sinatra's testing support is no longer dependent on Test::Unit. Requiring + 'sinatra/test' adds the Sinatra::Test module and Sinatra::TestHarness + class, which can be used with any test framework. The 'sinatra/test/unit', + 'sinatra/test/spec', 'sinatra/test/rspec', or 'sinatra/test/bacon' files + can be required to setup a framework-specific testing environment. See the + README for more information. + + * Added support for Bacon (test framework). The 'sinatra/test/bacon' file + can be required to setup Sinatra test helpers on Bacon::Context. + + * Deprecated "set_option" and "set_options"; use "set" instead. + + * Deprecated the "env" option ("options.env"); use "environment" instead. + + * Deprecated the request level "stop" method; use "halt" instead. + + * Deprecated the request level "entity_tag" method; use "etag" instead. + Both "entity_tag" and "etag" were previously supported. + + * Deprecated the request level "headers" method (HTTP response headers); + use "response['Header-Name']" instead. + + * Deprecated "Sinatra.application"; use "Sinatra::Application" instead. + + * Deprecated setting Sinatra.application = nil to reset an application. + This should no longer be necessary. + + * Deprecated "Sinatra.default_options"; use + "Sinatra::Default.set(key, value)" instead. + + * Deprecated the "ServerError" exception. All Exceptions are now + treated as internal server errors and result in a 500 response + status. + + * Deprecated the "get_it", "post_it", "put_it", "delete_it", and "head_it" + test helper methods. Use "get", "post", "put", "delete", and "head", + respectively, instead. + + * Removed Event and EventContext classes. Applications are defined in a + subclass of Sinatra::Base; each request is processed within an + instance. + +## 0.3.3 / 2009-01-06 + + * Pin to Rack 0.4.0 (this is the last release on Rack 0.4) + + * Log unhandled exception backtraces to rack.errors. + + * Use RACK_ENV environment variable to establish Sinatra + environment when given. Thin sets this when started with + the -e argument. + + * BUG: raising Sinatra::NotFound resulted in a 500 response + code instead of 404. + + * BUG: use_in_file_templates! fails with CR/LF [#45] + + * BUG: Sinatra detects the app file and root path when run under + thin/passenger. + +## 0.3.2 + + * BUG: Static and send_file read entire file into String before + sending. Updated to stream with 8K chunks instead. + + * Rake tasks and assets for building basic documentation website. + See http://sinatra.rubyforge.org + + * Various minor doc fixes. + +## 0.3.1 + + * Unbreak optional path parameters [jeremyevans] + +## 0.3.0 + + * Add sinatra.gemspec w/ support for github gem builds. Forks can now + enable the build gem option in github to get free username-sinatra.gem + builds: gem install username-sinatra.gem --source=http://gems.github.com/ + + * Require rack-0.4 gem; removes frozen rack dir. + + * Basic RSpec support; require 'sinatra/test/rspec' instead of + 'sinatra/test/spec' to use. [avdi] + + * before filters can modify request environment vars used for + routing (e.g., PATH_INFO, REQUEST_METHOD, etc.) for URL rewriting + type functionality. + + * In-file templates now uses @@ instead of ## as template separator. + + * Top-level environment test predicates: development?, test?, production? + + * Top-level "set", "enable", and "disable" methods for tweaking + app options. [rtomayko] + + * Top-level "use" method for building Rack middleware pipelines + leading to app. See README for usage. [rtomayko] + + * New "reload" option - set false to disable reloading in development. + + * New "host" option - host/ip to bind to [cschneid] + + * New "app_file" option - override the file to reload in development + mode [cschneid] + + * Development error/not_found page cleanup [sr, adamwiggins] + + * Remove a bunch of core extensions (String#to_param, String#from_param, + Hash#from_params, Hash#to_params, Hash#symbolize_keys, Hash#pass) + + * Various grammar and formatting fixes to README; additions on + community and contributing [cypher] + + * Build RDoc using Hanna template: http://sinatrarb.rubyforge.org/api + + * Specs, documentation and fixes for splat'n routes [vic] + + * Fix whitespace errors across all source files. [rtomayko] + + * Fix streaming issues with Mongrel (body not closed). [bmizerany] + + * Fix various issues with environment not being set properly (configure + blocks not running, error pages not registering, etc.) [cypher] + + * Fix to allow locals to be passed to ERB templates [cschneid] + + * Fix locking issues causing random errors during reload in development. + + * Fix for escaped paths not resolving static files [Matthew Walker] + +## 0.2.1 + + * File upload fix and minor tweaks. + +## 0.2.0 + + * Initial gem release of 0.2 codebase. diff --git a/CHANGES b/CHANGES deleted file mode 100644 index ff999d0862..0000000000 --- a/CHANGES +++ /dev/null @@ -1,467 +0,0 @@ -= 1.0 / unreleased - - * It's now possible to register blocks to run after each request using - after filters. After filters run at the end of each request, after - routes and error handlers. - - * Sinatra now uses Tilt for rendering - templates. This adds support for template caching, consistent - template backtraces, and support for new template engines, like - mustache and liquid. - - * New 'settings' method gives access to options in both class and request - scopes. This replaces the 'options' method. - - * New 'erubis' helper method for rendering Erubis templates. - - * New 'expires' helper method is like cache_control but takes an - integer number of seconds or Time object: - expires 300, :public, :must_revalidate - - * New 'cache_control' helper method provides a convenient way of - setting the Cache-Control response header. Takes a variable number - of boolean directives followed by a hash of value directives, like - this: - cache_control :public, :must_revalidate, :max_age => 60 - - * Sinatra apps can now be run with a `-h ` argument to specify - the address to bind to. - - * Rack::Session::Cookie is now added to the middleware pipeline when - running in test environments if the :sessions option is set. - - * Route handlers, before filters, templates, error mappings, and - middleware are now resolved dynamically up the inheritance hierarchy - when needed instead of duplicating the superclass's version when - a new Sinatra::Base subclass is created. This should fix a variety - of issues with extensions that need to add any of these things - to the base class. - -The following Sinatra features have been obsoleted in the 1.0 release: - - * The `sinatra/test` library is obsolete. This includes the - `Sinatra::Test` module, the `Sinatra::TestHarness` class, - and the `get_it`, `post_it`, `put_it`, `delete_it`, and `head_it` - helper methods. The - [`Rack::Test` library](http://gitrdoc.com/brynary/rack-test) should - be used instead. - - * Test framework specific libraries (`sinatra/test/spec`, - `sinatra/test/bacon`,`sinatra/test/rspec`, etc.) are obsolete. - See http://www.sinatrarb.com/testing.html for instructions on - setting up a testing environment under each of these frameworks. - - * Auto-requiring template libraries in the `erb`, `builder`, `haml`, - and `sass` methods is obsolete due to thread-safety issues. You must - require the template libraries explicitly in your app. - - * The `:views_directory` option to rendering methods is obsolete; use - `:views` instead. - - * The `:haml` and `:sass` options to rendering methods are obsolete. - Template engine options should be passed in the second Hash argument - instead. - - * The 'media_type' helper method is obsolete. Use 'mime_type' instead. - - * The request-level `send_data` method is no longer supported. - - * The `Sinatra::Event` and `Sinatra::EventContext` classes are no longer - supported. This may effect extensions written for versions prior to 0.9.2. - See [Writing Sinatra Extensions](http://www.sinatrarb.com/extensions.html) - for the officially supported extensions API. - - * The `set_option` and `set_options` methods are obsolete; use `set` - instead. - - * The `:env` setting (`settings.env`) is obsolete; use `:environment` - instead. - - * The request level `stop` method is obsolete; use `halt` instead. - - * The request level `entity_tag` method is obsolete; use `etag` - instead. - - * The request level `headers` method (HTTP response headers) is obsolete; - use `response['Header-Name']` instead. - - * `Sinatra.application` is obsolete; use `Sinatra::Application` instead. - - * Using `Sinatra.application = nil` to reset an application is obsolete. - This should no longer be necessary. - - * Using `Sinatra.default_options` to set base configuration items is - obsolete; use `Sinatra::Base.set(key, value)` instead. - - * The `Sinatra::ServerError` exception is obsolete. All exceptions raised - within a request are now treated as internal server errors and result in - a 500 response status. - -= 0.9.2 / 2009-05-18 - - * This version is compatible with Rack 1.0. [Rein Henrichs] - - * The development-mode unhandled exception / error page has been - greatly enhanced, functionally and aesthetically. The error - page is used when the :show_exceptions option is enabled and an - exception propagates outside of a route handler or before filter. - [Simon Rozet / Matte Noble / Ryan Tomayko] - - * Backtraces that move through templates now include filenames and - line numbers where possible. [#51 / S. Brent Faulkner] - - * All templates now have an app-level option for setting default - template options (:haml, :sass, :erb, :builder). The app-level - option value must be a Hash if set and is merged with the - template options specified to the render method (Base#haml, - Base#erb, Base#builder). [S. Brent Faulkner, Ryan Tomayko] - - * The method signature for all template rendering methods has - been unified: "def engine(template, options={}, locals={})". - The options Hash now takes the generic :views, :layout, and - :locals options but also any template-specific options. The - generic options are removed before calling the template specific - render method. Locals may be specified using either the - :locals key in the options hash or a second Hash option to the - rendering method. [#191 / Ryan Tomayko] - - * The receiver is now passed to "configure" blocks. This - allows for the following idiom in top-level apps: - configure { |app| set :foo, app.root + '/foo' } - [TJ Holowaychuck / Ryan Tomayko] - - * The "sinatra/test" lib is deprecated and will be removed in - Sinatra 1.0. This includes the Sinatra::Test module and - Sinatra::TestHarness class in addition to all the framework - test helpers that were deprecated in 0.9.1. The Rack::Test - lib should be used instead: http://gitrdoc.com/brynary/rack-test - [#176 / Simon Rozet] - - * Development mode source file reloading has been removed. The - "shotgun" (http://rtomayko.github.com/shotgun/) program can be - used to achieve the same basic functionality in most situations. - Passenger users should use the "tmp/always_restart.txt" - file (http://tinyurl.com/c67o4h). [#166 / Ryan Tomayko] - - * Auto-requiring template libs in the erb, builder, haml, and - sass methods is deprecated due to thread-safety issues. You must - require the template libs explicitly in your app file. [Simon Rozet] - - * A new Sinatra::Base#route_missing method was added. route_missing - is sent when no route matches the request or all route handlers - pass. The default implementation forwards the request to the - downstream app when running as middleware (i.e., "@app" is - non-nil), or raises a NotFound exception when no downstream app - is defined. Subclasses can override this method to perform custom - route miss logic. [Jon Crosby] - - * A new Sinatra::Base#route_eval method was added. The method - yields to the block and throws :halt with the result. Subclasses - can override this method to tap into the route execution logic. - [TJ Holowaychuck] - - * Fix the "-x" (enable request mutex / locking) command line - argument. Passing -x now properly sets the :lock option. - [S. Brent Faulkner, Ryan Tomayko] - - * Fix writer ("foo=") and predicate ("foo?") methods in extension - modules not being added to the registering class. - [#172 / Pat Nakajima] - - * Fix in-file templates when running alongside activesupport and - fatal errors when requiring activesupport before sinatra - [#178 / Brian Candler] - - * Fix various issues running on Google AppEngine. - [Samuel Goebert, Simon Rozet] - - * Fix in-file templates __END__ detection when __END__ exists with - other stuff on a line [Yoji Shidara] - -= 0.9.1.1 / 2009-03-09 - - * Fix directory traversal vulnerability in default static files - route. See [#177] for more info. - -= 0.9.1 / 2009-03-01 - - * Sinatra now runs under Ruby 1.9.1 [#61] - - * Route patterns (splats, :named, or Regexp captures) are now - passed as arguments to the block. [#140] - - * The "helpers" method now takes a variable number of modules - along with the normal block syntax. [#133] - - * New request-level #forward method for middleware components: passes - the env to the downstream app and merges the response status, headers, - and body into the current context. [#126] - - * Requests are now automatically forwarded to the downstream app when - running as middleware and no matching route is found or all routes - pass. - - * New simple API for extensions/plugins to add DSL-level and - request-level methods. Use Sinatra.register(mixin) to extend - the DSL with all public methods defined in the mixin module; - use Sinatra.helpers(mixin) to make all public methods defined - in the mixin module available at the request level. [#138] - See http://www.sinatrarb.com/extensions.html for details. - - * Named parameters in routes now capture the "." character. This makes - routes like "/:path/:filename" match against requests like - "/foo/bar.txt"; in this case, "params[:filename]" is "bar.txt". - Previously, the route would not match at all. - - * Added request-level "redirect back" to redirect to the referring - URL. - - * Added a new "clean_trace" option that causes backtraces dumped - to rack.errors and displayed on the development error page to - omit framework and core library backtrace lines. The option is - enabled by default. [#77] - - * The ERB output buffer is now available to helpers via the @_out_buf - instance variable. - - * It's now much easier to test sessions in unit tests by passing a - ":session" option to any of the mock request methods. e.g., - get '/', {}, :session => { 'foo' => 'bar' } - - * The testing framework specific files ('sinatra/test/spec', - 'sinatra/test/bacon', 'sinatra/test/rspec', etc.) have been deprecated. - See http://sinatrarb.com/testing.html for instructions on setting up - a testing environment with these frameworks. - - * The request-level #send_data method from Sinatra 0.3.3 has been added - for compatibility but is deprecated. - - * Fix :provides causing crash on any request when request has no - Accept header [#139] - - * Fix that ERB templates were evaluated twice per "erb" call. - - * Fix app-level middleware not being run when the Sinatra application is - run as middleware. - - * Fixed some issues with running under Rack's CGI handler caused by - writing informational stuff to stdout. - - * Fixed that reloading was sometimes enabled when starting from a - rackup file [#110] - - * Fixed that "." in route patterns erroneously matched any character - instead of a literal ".". [#124] - -= 0.9.0.4 / 2009-01-25 - - * Using halt with more than 1 args causes ArgumentError [#131] - * using halt in a before filter doesn't modify response [#127] - * Add deprecated Sinatra::EventContext to unbreak plugins [#130] - * Give access to GET/POST params in filters [#129] - * Preserve non-nested params in nested params hash [#117] - * Fix backtrace dump with Rack::Lint [#116] - -= 0.9.0.3 / 2009-01-21 - - * Fall back on mongrel then webrick when thin not found. [#75] - * Use :environment instead of :env in test helpers to - fix deprecation warnings coming from framework. - * Make sinatra/test/rspec work again [#113] - * Fix app_file detection on windows [#118] - * Fix static files with Rack::Lint in pipeline [#121] - -= 0.9.0.2 / 2009-01-18 - - * Halting a before block should stop processing of routes [#85] - * Fix redirect/halt in before filters [#85] - -= 0.9.0 / 2009-01-18 - - * Works with and requires Rack >= 0.9.1 - - * Multiple Sinatra applications can now co-exist peacefully within a - single process. The new "Sinatra::Base" class can be subclassed to - establish a blank-slate Rack application or middleware component. - Documentation on using these features is forth-coming; the following - provides the basic gist: http://gist.github.com/38605 - - * Parameters with subscripts are now parsed into a nested/recursive - Hash structure. e.g., "post[title]=Hello&post[body]=World" yields - params: {'post' => {'title' => 'Hello', 'body' => 'World'}}. - - * Regular expressions may now be used in route pattens; captures are - available at "params[:captures]". - - * New ":provides" route condition takes an array of mime types and - matches only when an Accept request header is present with a - corresponding type. [cypher] - - * New request-level "pass" method; immediately exits the current block - and passes control to the next matching route. - - * The request-level "body" method now takes a block; evaluation is - deferred until an attempt is made to read the body. The block must - return a String or Array. - - * New "route conditions" system for attaching rules for when a route - matches. The :agent and :host route options now use this system. - - * New "dump_errors" option controls whether the backtrace is dumped to - rack.errors when an exception is raised from a route. The option is - enabled by default for top-level apps. - - * Better default "app_file", "root", "public", and "views" location - detection; changes to "root" and "app_file" automatically cascade to - other options that depend on them. - - * Error mappings are now split into two distinct layers: exception - mappings and custom error pages. Exception mappings are registered - with "error(Exception)" and are run only when the app raises an - exception. Custom error pages are registered with "error(status_code)", - where "status_code" is an integer, and are run any time the response - has the status code specified. It's also possible to register an error - page for a range of status codes: "error(500..599)". - - * In-file templates are now automatically imported from the file that - requires 'sinatra'. The use_in_file_templates! method is still available - for loading templates from other files. - - * Sinatra's testing support is no longer dependent on Test::Unit. Requiring - 'sinatra/test' adds the Sinatra::Test module and Sinatra::TestHarness - class, which can be used with any test framework. The 'sinatra/test/unit', - 'sinatra/test/spec', 'sinatra/test/rspec', or 'sinatra/test/bacon' files - can be required to setup a framework-specific testing environment. See the - README for more information. - - * Added support for Bacon (test framework). The 'sinatra/test/bacon' file - can be required to setup Sinatra test helpers on Bacon::Context. - - * Deprecated "set_option" and "set_options"; use "set" instead. - - * Deprecated the "env" option ("options.env"); use "environment" instead. - - * Deprecated the request level "stop" method; use "halt" instead. - - * Deprecated the request level "entity_tag" method; use "etag" instead. - Both "entity_tag" and "etag" were previously supported. - - * Deprecated the request level "headers" method (HTTP response headers); - use "response['Header-Name']" instead. - - * Deprecated "Sinatra.application"; use "Sinatra::Application" instead. - - * Deprecated setting Sinatra.application = nil to reset an application. - This should no longer be necessary. - - * Deprecated "Sinatra.default_options"; use - "Sinatra::Default.set(key, value)" instead. - - * Deprecated the "ServerError" exception. All Exceptions are now - treated as internal server errors and result in a 500 response - status. - - * Deprecated the "get_it", "post_it", "put_it", "delete_it", and "head_it" - test helper methods. Use "get", "post", "put", "delete", and "head", - respectively, instead. - - * Removed Event and EventContext classes. Applications are defined in a - subclass of Sinatra::Base; each request is processed within an - instance. - -= 0.3.3 / 2009-01-06 - - * Pin to Rack 0.4.0 (this is the last release on Rack 0.4) - - * Log unhandled exception backtraces to rack.errors. - - * Use RACK_ENV environment variable to establish Sinatra - environment when given. Thin sets this when started with - the -e argument. - - * BUG: raising Sinatra::NotFound resulted in a 500 response - code instead of 404. - - * BUG: use_in_file_templates! fails with CR/LF (#45) - - * BUG: Sinatra detects the app file and root path when run under - thin/passenger. - -= 0.3.2 - - * BUG: Static and send_file read entire file into String before - sending. Updated to stream with 8K chunks instead. - - * Rake tasks and assets for building basic documentation website. - See http://sinatra.rubyforge.org - - * Various minor doc fixes. - -= 0.3.1 - - * Unbreak optional path parameters [jeremyevans] - -= 0.3.0 - - * Add sinatra.gemspec w/ support for github gem builds. Forks can now - enable the build gem option in github to get free username-sinatra.gem - builds: gem install username-sinatra.gem --source=http://gems.github.com/ - - * Require rack-0.4 gem; removes frozen rack dir. - - * Basic RSpec support; require 'sinatra/test/rspec' instead of - 'sinatra/test/spec' to use. [avdi] - - * before filters can modify request environment vars used for - routing (e.g., PATH_INFO, REQUEST_METHOD, etc.) for URL rewriting - type functionality. - - * In-file templates now uses @@ instead of ## as template separator. - - * Top-level environment test predicates: development?, test?, production? - - * Top-level "set", "enable", and "disable" methods for tweaking - app options. [rtomayko] - - * Top-level "use" method for building Rack middleware pipelines - leading to app. See README for usage. [rtomayko] - - * New "reload" option - set false to disable reloading in development. - - * New "host" option - host/ip to bind to [cschneid] - - * New "app_file" option - override the file to reload in development - mode [cschneid] - - * Development error/not_found page cleanup [sr, adamwiggins] - - * Remove a bunch of core extensions (String#to_param, String#from_param, - Hash#from_params, Hash#to_params, Hash#symbolize_keys, Hash#pass) - - * Various grammar and formatting fixes to README; additions on - community and contributing [cypher] - - * Build RDoc using Hanna template: http://sinatrarb.rubyforge.org/api - - * Specs, documentation and fixes for splat'n routes [vic] - - * Fix whitespace errors across all source files. [rtomayko] - - * Fix streaming issues with Mongrel (body not closed). [bmizerany] - - * Fix various issues with environment not being set properly (configure - blocks not running, error pages not registering, etc.) [cypher] - - * Fix to allow locals to be passed to ERB templates [cschneid] - - * Fix locking issues causing random errors during reload in development. - - * Fix for escaped paths not resolving static files [Matthew Walker] - -= 0.2.1 - - * File upload fix and minor tweaks. - -= 0.2.0 - - * Initial gem release of 0.2 codebase. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000000..2f36d22726 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +sinatra-conduct@gmail.com. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000..14563a23a6 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,100 @@ +# Contribute + +Want to show Sinatra some love? Help out by contributing! + +## Found a bug? + +Log it in our [issue tracker][ghi] or send a note to the [mailing list][ml]. +Be sure to include all relevant information, like the versions of Sinatra and +Ruby you're using. A [gist](http://gist.github.com/) of the code that caused +the issue as well as any error messages are also very helpful. + +## Need help? + +The [Sinatra mailing list][ml] has over 900 subscribers, many of which are happy +to help out newbies or talk about potential feature additions. You can also +drop by the [#sinatra](irc://chat.freenode.net/#sinatra) channel on +[irc.freenode.net](http://freenode.net). + +## Have a patch? + +Bugs and feature requests that include patches are much more likely to +get attention. Here are some guidelines that will help ensure your patch +can be applied as quickly as possible: + +1. **Use [Git](http://git-scm.com) and [GitHub](http://github.com):** + The easiest way to get setup is to fork the + [sinatra/sinatra repo](http://github.com/sinatra/sinatra/). + Or, the [sinatra.github.com repo](http://github.com/sinatra/sinatra.github.com/), + if the patch is doc related. + +2. **Write unit tests:** If you add or modify functionality, it must + include unit tests. If you don't write tests, we have to, and this + can hold up acceptance of the patch. + +3. **Mind the `README`:** If the patch adds or modifies a major feature, + modify the `README.md` file to reflect that. Again, if you don't + update the `README`, we have to, and this holds up acceptance. + +4. **Push it:** Once you're ready, push your changes to a topic branch + and add a note to the ticket with the URL to your branch. Or, say + something like, "you can find the patch on johndoe/foobranch". We also + gladly accept GitHub [pull requests](http://help.github.com/pull-requests/). + +__NOTE:__ _We will take whatever we can get._ If you prefer to attach diffs in +emails to the mailing list, that's fine; but do know that _someone_ will need +to take the diff through the process described above and this can hold things +up considerably. + +## Want to write docs? + +The process for contributing to Sinatra's website, documentation or the book +is the same as contributing code. We use Git for versions control and GitHub to +track patch requests. + +* [The sinatra.github.com repo](http://github.com/sinatra/sinatra.github.com/) + is where the website sources are managed. There are almost always people in + `#sinatra` that are happy to discuss, apply, and publish website patches. + +* [The Book](http://sinatra-org-book.herokuapp.com/) has its own [Git + repository](http://github.com/sinatra/sinatra-book/) and build process but is + managed the same as the website and project codebase. + +* [Sinatra Recipes](http://recipes.sinatrarb.com/) is a community + project where anyone is free to contribute ideas, recipes and tutorials. Which + also has its own [Git repository](http://github.com/sinatra/sinatra-recipes). + +* [The Introduction](http://www.sinatrarb.com/intro.html) is generated from + Sinatra's [README file](http://github.com/sinatra/sinatra/blob/main/README.md). + +* If you want to help translating the documentation, the README is already + available in + [Japanese](http://github.com/sinatra/sinatra/blob/main/README.ja.md), + [German](http://github.com/sinatra/sinatra/blob/main/README.de.md), + [Chinese](https://github.com/sinatra/sinatra/blob/main/README.zh.md), + [Russian](https://github.com/sinatra/sinatra/blob/main/README.ru.md), + [European](https://github.com/sinatra/sinatra/blob/main/README.pt-pt.md) and + [Brazilian](https://github.com/sinatra/sinatra/blob/main/README.pt-br.md) + Portuguese, + [French](https://github.com/sinatra/sinatra/blob/main/README.fr.md), + [Spanish](https://github.com/sinatra/sinatra/blob/main/README.es.md), + [Korean](https://github.com/sinatra/sinatra/blob/main/README.ko.md), and + [Hungarian](https://github.com/sinatra/sinatra/blob/main/README.hu.md). + The translations tend to fall behind the English version. Translations into + other languages would also be appreciated. + +## Looking for something to do? + +If you'd like to help out but aren't sure how, pick something that looks +interesting from the [issues][ghi] list and hack on. Make sure to leave a +comment on the ticket noting that you're investigating (a simple "Taking…" is +fine). + +[ghi]: http://github.com/sinatra/sinatra/issues +[ml]: http://groups.google.com/group/sinatrarb/topics "Sinatra Mailing List" + +* ["Help Wanted"](https://github.com/sinatra/sinatra/labels/help%20wanted): Anyone willing to pitch in is open to contribute to this ticket as they see fit (will try to add context / summarize or ask for requirements) + +* ["Good First Issue"](https://github.com/sinatra/sinatra/labels/good%20first%20issue): Potential first time contributors should start here + +* ["Wishlist"](https://github.com/sinatra/sinatra/labels/Wishlist): All the things I wish we had but have no time for diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000000..ea8703237f --- /dev/null +++ b/Gemfile @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +# Why use bundler? +# Well, not all development dependencies install on all rubies. Moreover, `gem +# install sinatra --development` doesn't work, as it will also try to install +# development dependencies of our dependencies, and those are not conflict free. +# So, here we are, `bundle install`. +# +# If you have issues with a gem: `bundle install --without-coffee-script`. + +source 'https://rubygems.org' +gemspec + +gem 'rake' + +rack_version = ENV['rack'].to_s +rack_version = nil if rack_version.empty? || (rack_version == 'stable') +rack_version = { github: 'rack/rack' } if rack_version == 'head' +gem 'rack', rack_version + +puma_version = ENV['puma'].to_s +puma_version = nil if puma_version.empty? || (puma_version == 'stable') +puma_version = { github: 'puma/puma' } if puma_version == 'head' +gem 'puma', puma_version + +gem 'minitest', '~> 5.0' +gem 'rack-test', github: 'rack/rack-test' +gem 'rubocop', '~> 1.32.0', require: false +gem 'yard' + +gem 'rack-protection', path: 'rack-protection' +gem 'sinatra-contrib', path: 'sinatra-contrib' + +gem 'activesupport', '~> 6.1' + +gem 'asciidoctor' +gem 'builder' +gem 'commonmarker', '~> 0.23.4', platforms: [:ruby] +gem 'erubi' +gem 'eventmachine' +gem 'falcon', '~> 0.40', platforms: [:ruby] +gem 'haml', '~> 6' +gem 'kramdown' +gem 'liquid' +gem 'markaby' +gem 'nokogiri', '> 1.5.0' +gem 'pandoc-ruby', '~> 2.0.2' +gem 'rabl' +gem 'rainbows', platforms: [:mri] # uses #fork +gem 'rdiscount', platforms: [:ruby] +gem 'rdoc' +gem 'redcarpet', platforms: [:ruby] +gem 'simplecov', require: false +gem 'slim', '~> 4' +gem 'yajl-ruby', platforms: [:ruby] + +gem 'json', platforms: %i[jruby mri] + +gem 'jar-dependencies', '= 0.4.1', platforms: [:jruby] # Gem::LoadError with jar-dependencies 0.4.2 diff --git a/LICENSE b/LICENSE index 145fdff527..c53f830ff5 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,8 @@ +The MIT License (MIT) + Copyright (c) 2007, 2008, 2009 Blake Mizerany +Copyright (c) 2010-2017 Konstantin Haase +Copyright (c) 2015-2017 Zachary Scott Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation @@ -19,4 +23,4 @@ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file +OTHER DEALINGS IN THE SOFTWARE. diff --git a/MAINTENANCE.md b/MAINTENANCE.md new file mode 100644 index 0000000000..550032acfb --- /dev/null +++ b/MAINTENANCE.md @@ -0,0 +1,29 @@ +# Sinatra maintenance + +## Versions + +### Releases + +The next major version of Sinatra will be released from the main branch. Each version will be tagged so it will be possible to branch of should there be a need for bug fixes and other updates. + +## Issues + +### New features + +New features will only be added to the main branch and will not be made available in point releases. + +### Bug fixes + +Only the latest release series will receive bug fixes. When enough bugs are fixed and its deemed worthy to release a new gem, this is the branch it happens from. + +### Security issues + +The current release series will receive patches and new versions in case of a security issue. + +### Severe security issues + +For severe security issues we will provide new versions as above, and also the last major release series will receive patches and new versions. The classification of the security issue is judged by the core team. + +### Unsupported Release Series + +When a release series is no longer supported, it’s your own responsibility to deal with bugs and security issues. We may provide back-ports of the fixes and publish them to git, however there will be no new versions released. If you are not comfortable maintaining your own versions, you should upgrade to a supported version. diff --git a/README.jp.rdoc b/README.jp.rdoc deleted file mode 100644 index 6f82987919..0000000000 --- a/README.jp.rdoc +++ /dev/null @@ -1,552 +0,0 @@ -= Sinatra - -SinatraはRubyで下記のような最小労力で手早くウェブアプリケーションを作成するためのDSLです。 - - # myapp.rb - require 'rubygems' - require 'sinatra' - get '/' do - 'Hello world!' - end - -gemをインストールして動かしてみる。 - - sudo gem install sinatra - ruby myapp.rb - -http://localhost:4567 を見る。 - -== ルート - -Sinatraでは、ルートはHTTPメソッドとURLマッチングパターンがペアになっています。 -ルートはブロックに結び付けられています。 - - get '/' do - .. 何か見せる .. - end - - post '/' do - .. 何か生成する .. - end - - put '/' do - .. 何か更新する .. - end - - delete '/' do - .. 何か削除する .. - end - -ルートは定義された順番にマッチします。 リクエストに最初にマッチしたルートが呼び出されます。 - -ルートのパターンは名前付きパラメータを含むことができ、 -paramsハッシュで取得できます。 - - get '/hello/:name' do - # matches "GET /hello/foo" and "GET /hello/bar" - # params[:name] is 'foo' or 'bar' - "Hello #{params[:name]}!" - end - -また、ブロックパラメータで名前付きパラメータにアクセスすることもできます。 - - get '/hello/:name' do |n| - "Hello #{n}!" - end - -ルートパターンはsplat(またはワイルドカード)を含むこともでき、 -params[:splat] で取得できます。 - - get '/say/*/to/*' do - # matches /say/hello/to/world - params[:splat] # => ["hello", "world"] - end - - get '/download/*.*' do - # matches /download/path/to/file.xml - params[:splat] # => ["path/to/file", "xml"] - end - -正規表現を使ったルート: - - get %r{/hello/([\w]+)} do - "Hello, #{params[:captures].first}!" - end - -ブロックパラーメータを使用した場合: - - get %r{/hello/([\w]+)} do |c| - "Hello, #{c}!" - end - -ルートにはユーザエージェントのようなさまざまな条件を含めることができます。 - - get '/foo', :agent => /Songbird (\d\.\d)[\d\/]*?/ do - "You're using Songbird version #{params[:agent][0]}" - end - - get '/foo' do - # Matches non-songbird browsers - end - -== 静的ファイル - -静的ファイルは./publicディレクトリから配信されます。 -:publicオプションを指定することで別の場所を指定することができます。 - - set :public, File.dirname(__FILE__) + '/static' - -注意: この静的ファイル用のディレクトリ名はURL中に含まれません。 -例えば、./public/css/style.csshttp://example.com/css/style.cssでアクセスできます。 - -== ビュー / テンプレート - -テンプレートは./viewsディレクトリ下に配置されています。 -他のディレクトリを使用する場合の例: - - set :views, File.dirname(__FILE__) + '/templates' - -テンプレートはシンボルを使用して参照させることを覚えておいて下さい。 -サブデレクトリでもこの場合は:'subdir/template'のようにします。 -レンダリングメソッドは文字列が渡されると、そのまま文字列を出力します。 - -=== Haml テンプレート - -hamlを使うにはhamlライブラリが必要です: - - ## hamlを読み込みます - require 'haml' - - get '/' do - haml :index - end - -./views/index.hamlを表示します。 - -{Haml's options}[http://haml.hamptoncatlin.com/docs/rdoc/classes/Haml.html] -はSinatraの設定でグローバルに設定することができます。 -{Options and Configurations}[http://www.sinatrarb.com/configuration.html], -を参照してそれぞれ設定を上書きして下さい。 - - set :haml, {:format => :html5 } # デフォルトのフォーマットは:xhtml - - get '/' do - haml :index, :haml_options => {:format => :html4 } # 上書き - end - - -=== Erb テンプレート - - ## erbを読み込みます - require 'erb' - - get '/' do - erb :index - end - -./views/index.erbを表示します。 - -=== Builder テンプレート - -builderを使うにはbuilderライブラリが必要です: - - ## builderを読み込みます - require 'builder' - - get '/' do - content_type 'application/xml', :charset => 'utf-8' - builder :index - end - -./views/index.builderを表示します。 - -=== Sass テンプレート - -Sassテンプレートを使うにはsassライブラリが必要です: - - ## hamlかsassを読み込みます - require 'sass' - - get '/stylesheet.css' do - content_type 'text/css', :charset => 'utf-8' - sass :stylesheet - end - -./views/stylesheet.sassを表示します。 - -{Sass' options}[http://haml.hamptoncatlin.com/docs/rdoc/classes/Sass.html] -はSinatraの設定でグローバルに設定することができます。 -see {Options and Configurations}[http://www.sinatrarb.com/configuration.html], -を参照してそれぞれ設定を上書きして下さい。 - - set :sass, {:style => :compact } # デフォルトのSass styleは :nested - - get '/stylesheet.css' do - content_type 'text/css', :charset => 'utf-8' - sass :stylesheet, :sass_options => {:style => :expanded } # 上書き - end - - -=== インラインテンプレート - - get '/' do - haml '%div.title Hello World' - end - -文字列をテンプレートとして表示します。 - -=== テンプレート内で変数にアクセスする - -テンプレートはルートハンドラと同じコンテキストの中で評価されます。. ルートハンドラでセットされたインスタンス変数は -テンプレート内で直接使うことができます。 - - get '/:id' do - @foo = Foo.find(params[:id]) - haml '%h1= @foo.name' - end - -ローカル変数を明示的に定義することもできます。 - - get '/:id' do - foo = Foo.find(params[:id]) - haml '%h1= foo.name', :locals => { :foo => foo } - end - -このやり方は他のテンプレート内で部分テンプレートとして表示する時に典型的に使用されます。 - -=== ファイル内テンプレート - -テンプレートはソースファイルの最後で定義することもできます。 - - require 'rubygems' - require 'sinatra' - - get '/' do - haml :index - end - - __END__ - - @@ layout - %html - = yield - - @@ index - %div.title Hello world!!!!! - -注意: sinatraをrequireするファイル内で定義されたファイル内テンプレートは自動的に読み込まれます。 -他のファイルで定義されているテンプレートを使うには use_in_file_templates!メソッドで指定します。 - -=== 名前付きテンプレート - -テンプレートはトップレベルのtemplateメソッドで定義することができます。 - - template :layout do - "%html\n =yield\n" - end - - template :index do - '%div.title Hello World!' - end - - get '/' do - haml :index - end - -「layout」というテンプレートが存在する場合、そのテンプレートファイルは他のテンプレートが -表示される度に使用されます。:layout => false.することでlayoutsを無効にできます。 - - get '/' do - haml :index, :layout => !request.xhr? - end - -== ヘルパー - -トップレベルのhelpersを使用してルートハンドラやテンプレートで使うヘルパメソッドを -定義できます。 - - helpers do - def bar(name) - "#{name}bar" - end - end - - get '/:name' do - bar(params[:name]) - end - -== フィルタ - -beforeフィルタはリクエストされたコンテキストを実行する前に評価され、 -リクエストとレスポンスを変更することができます。フィルタ内でセットされた -インスタンス変数はルーティングとテンプレートで使用できます。 - - before do - @note = 'Hi!' - request.path_info = '/foo/bar/baz' - end - - get '/foo/*' do - @note #=> 'Hi!' - params[:splat] #=> 'bar/baz' - end - -== 強制終了 - -ルートかbeforeフィルタ内で直ちに実行を終了する方法: - - halt - -body部を指定することもできます ... - - halt 'ここにbodyを書く' - -ステータスとbody部を指定する ... - - halt 401, '立ち去れ!' - -== パッシング(Passing) - -ルートはpassを使って次のルートに飛ばすことができます: - - get '/guess/:who' do - pass unless params[:who] == 'Frank' - "見つかっちゃった!" - end - - get '/guess/*' do - "はずれです!" - end - -ルートブロックからすぐに抜け出し、次にマッチするルートを実行します。 -マッチするルートが見当たらない場合は404が返されます。 - -== 設定 - -どの環境でも起動時に1回だけ実行されます。 - - configure do - ... - end - -環境変数:production(RACK_ENV環境変数) がセットされている時だけ実行する方法: - - configure :production do - ... - end - -環境変数:production:testの場合に設定する方法: - - configure :production, :test do - ... - end - -== エラーハンドリング - -エラーハンドラーはルートコンテキストとbeforeフィルタ内で実行します。 -hamlerbhaltなどを使うこともできます。 - -=== Not Found - -Sinatra::NotFoundが起きた時か レスポンスのステータスコードが -404の時にnot_foundハンドラーが発動します。 - - not_found do - 'ファイルが存在しません' - end - -=== エラー - -+error+ ハンドラーはルートブロックかbeforeフィルタ内で例外が発生した時はいつでも発動します。 -block or before filter. 例外オブジェクトはRack変数sinatra.errorから取得されます。 - - error do - 'エラーが発生しました。 - ' + env['sinatra.error'].name - end - -エラーをカスタマイズする場合は、 - - error MyCustomError do - 'エラーメッセージ...' + request.env['sinatra.error'].message - end - -と書いておいて,下記のように呼び出します。 - - get '/' do - raise MyCustomError, '何かがまずかったようです' - end - -そうするとこうなります: - - エラーメッセージ... 何かがまずかったようです - -開発環境として実行している場合、Sinatraは特別なnot_founderrorハンドラーを -インストールしています。 - -== MIMEタイプ - -send_fileか静的ファイルを使う時、Sinatraが理解でいないMIMEタイプがある場合があります。 -その時は +mime_type+ を使ってファイル拡張子毎に登録して下さい。 - - mime_type :foo, 'text/foo' - -== Rackミドルウェア - -SinatraはRack[http://rack.rubyforge.org/]というRubyのWEBフレームワーク用の -最小限の標準インターフェース 上で動作しています。Rack中でもアプリケーションデベロッパー -向けに一番興味深い機能はミドルウェア(サーバとアプリケーション間に介在し、モニタリング、HTTPリクエストとレスポンス -の手動操作ができるなど、一般的な機能のいろいろなことを提供するもの)をサポートすることです。 - -Sinatraではトップレベルの+user+ メソッドを使ってRackにパイプラインを構築します。 - - require 'sinatra' - require 'my_custom_middleware' - - use Rack::Lint - use MyCustomMiddleware - - get '/hello' do - 'Hello World' - end - -use の意味は{Rack::Builder}[http://rack.rubyforge.org/doc/classes/Rack/Builder.html] DSLで定義されていることと全て一致します。 -例えば +use+ メソッドはブロック構文のように複数の引数を受け取ることができます。 - - use Rack::Auth::Basic do |username, password| - username == 'admin' && password == 'secret' - end - -Rackはログ、デバッギング、URLルーティング、認証、セッションなどいろいろな機能を備えた標準的ミドルウェアです。 -Sinatraはその多くのコンポーネントを自動で使うよう基本設定されているため、+use+で明示的に指定する必要はありません。 - -== テスト - -SinatraでのテストはRack-basedのテストライブラリかフレームワークを使って書くことができます。 -{Rack::Test}[http://gitrdoc.com/brynary/rack-test] をおすすめします。やり方: - - require 'my_sinatra_app' - require 'rack/test' - - class MyAppTest < Test::Unit::TestCase - include Rack::Test::Methods - - def app - Sinatra::Application - end - - def test_my_default - get '/' - assert_equal 'Hello World!', last_response.body - end - - def test_with_params - get '/meet', :name => 'Frank' - assert_equal 'Hello Frank!', last_response.body - end - - def test_with_rack_env - get '/', {}, 'HTTP_USER_AGENT' => 'Songbird' - assert_equal "あなたはSongbirdを使ってますね!", last_response.body - end - end - -注意: ビルトインのSinatra::TestモジュールとSinatra::TestHarnessクラスは -0.9.2リリース以降、廃止予定になっています。 - -== Sinatra::Base - ミドルウェア、ライブラリ、 モジュラーアプリ - -トップレベル(グローバル領域)上でいろいろ定義していくのは軽量アプリならうまくいきますが、 -RackミドルウェアやRails metal、サーバのコンポーネントを含んだシンプルな -ライブラリやSinatraの拡張プログラムを考慮するような場合はそうとは限りません。 -トップレベルのDSLがネームスペースを汚染したり、設定を変えてしまうこと(例:./publicや./view)がありえます。 -そこでSinatra::Baseの出番です。 - - require 'sinatra/base' - - class MyApp < Sinatra::Base - set :sessions, true - set :foo, 'bar' - - get '/' do - 'Hello world!' - end - end - -このMyAppは独立したRackコンポーネントで、RackミドルウェアやRackアプリケーション -Rails metalとして使用することができます。config.ruファイル内で +use+ か、または -+run+ でこのクラスを指定するか、ライブラリとしてサーバコンポーネントをコントロールします。 - - MyApp.run! :host => 'localhost', :port => 9090 - -Sinatra::Baseのサブクラスで使えるメソッドはトップレベルのDSLを経由して確実に使うことができます。 -ほとんどのトップレベルで記述されたアプリは、以下の2点を修正することでSinatra::Baseコンポーネントに変えることができます。 - -* +sinatra+の代わりにsinatra/baseを読み込む -(そうしない場合、SinatraのDSLメソッドの全てがメインネームスペースにインポートされます) -* ルート、エラーハンドラー、フィルター、オプションをSinatra::Baseのサブクラスに書く - -Sinatra::Base はまっさらです。ビルトインサーバを含む、ほとんどのオプションがデフォルト -で無効になっています。オプション詳細については{Options and Configuration}[http://sinatra.github.com/configuration.html] -をご覧下さい。 - -補足: SinatraのトップレベルDSLはシンプルな委譲(delgation)システムで実装されています。 -Sinatra::Applicationクラス(Sinatra::Baseの特別なサブクラス)は、トップレベルに送られる -:get、 :put、 :post、:delete、 :before、:error、:not_found、 :configure、:set messagesのこれら -全てを受け取ります。 詳細を閲覧されたい方はこちら(英語): -{Sinatra::Delegator mixin}[http://github.com/sinatra/sinatra/blob/master/lib/sinatra/base.rb#L1064] -{included into the main namespace}[http://github.com/sinatra/sinatra/blob/master/lib/sinatra/main.rb#L25]. - -== コマンドライン - -Sinatraアプリケーションは直接実行できます。 - - ruby myapp.rb [-h] [-x] [-e ENVIRONMENT] [-p PORT] [-s HANDLER] - -オプション: - - -h # ヘルプ - -p # ポート指定(デフォルトは4567) - -e # 環境を指定 (デフォルトはdevelopment) - -s # rackserver/handlerを指定 (デフォルトはthin) - -x # mutex lockを付ける (デフォルトはoff) - -== 最新開発版について - -Sinatraの開発版を使いたい場合は、ローカルに開発版を落として、 -LOAD_PATHsinatra/libディレクトリを指定して実行して下さい。 - - cd myapp - git clone git://github.com/sinatra/sinatra.git - ruby -Isinatra/lib myapp.rb - -sinatra/libディレクトリをto theLOAD_PATHに追加する方法もあります。 - - $LOAD_PATH.unshift File.dirname(__FILE__) + '/sinatra/lib' - require 'rubygems' - require 'sinatra' - - get '/about' do - "今使ってるバージョンは" + Sinatra::VERSION - end - -Sinatraのソースを更新する方法: - - cd myproject/sinatra - git pull - -== その他 - -日本語サイト - -* {Greenbear Laboratory Rack日本語マニュアル}[http://mono.kmc.gr.jp/~yhara/w/?RackReferenceJa] - Rackの日本語マニュアル - -英語サイト - -* {プロジェクトサイト}[http://sinatra.github.com/] - ドキュメント、 - ニュース、他のリソースへのリンクがあります。 -* {プロジェクトに参加(貢献)する}[http://sinatra.github.com/contributing.html] - バグレポート - パッチの送信、サポートなど -* {Lighthouse}[http://sinatra.lighthouseapp.com] - チケット管理とリリース計画 -* {Twitter}[http://twitter.com/sinatra] -* {メーリングリスト}[http://groups.google.com/group/sinatrarb] -* {IRC: #sinatra}[irc://chat.freenode.net/#sinatra] on http://freenode.net diff --git a/README.md b/README.md new file mode 100644 index 0000000000..41d4eb00cd --- /dev/null +++ b/README.md @@ -0,0 +1,2905 @@ +# Sinatra + +[![Gem Version](https://badge.fury.io/rb/sinatra.svg)](https://badge.fury.io/rb/sinatra) +[![Testing](https://github.com/sinatra/sinatra/actions/workflows/test.yml/badge.svg)](https://github.com/sinatra/sinatra/actions/workflows/test.yml) + +Sinatra is a [DSL](https://en.wikipedia.org/wiki/Domain-specific_language) for +quickly creating web applications in Ruby with minimal effort: + +```ruby +# myapp.rb +require 'sinatra' + +get '/' do + 'Hello world!' +end +``` + +Install the gem: + +```shell +gem install sinatra +gem install puma # or any other server +``` + +And run with: + +```shell +ruby myapp.rb +``` + +View at: [http://localhost:4567](http://localhost:4567) + +The code you changed will not take effect until you restart the server. +Please restart the server every time you change or use a code reloader +like [rerun](https://github.com/alexch/rerun) or +[rack-unreloader](https://github.com/jeremyevans/rack-unreloader). + +It is recommended to also run `gem install puma`, which Sinatra will +pick up if available. + +## Table of Contents + +- [Sinatra](#sinatra) + - [Table of Contents](#table-of-contents) + - [Routes](#routes) + - [Conditions](#conditions) + - [Return Values](#return-values) + - [Custom Route Matchers](#custom-route-matchers) + - [Static Files](#static-files) + - [Views / Templates](#views--templates) + - [Literal Templates](#literal-templates) + - [Available Template Languages](#available-template-languages) + - [Haml Templates](#haml-templates) + - [Erb Templates](#erb-templates) + - [Builder Templates](#builder-templates) + - [Nokogiri Templates](#nokogiri-templates) + - [Liquid Templates](#liquid-templates) + - [Markdown Templates](#markdown-templates) + - [RDoc Templates](#rdoc-templates) + - [AsciiDoc Templates](#asciidoc-templates) + - [Markaby Templates](#markaby-templates) + - [RABL Templates](#rabl-templates) + - [Slim Templates](#slim-templates) + - [Yajl Templates](#yajl-templates) + - [Accessing Variables in Templates](#accessing-variables-in-templates) + - [Templates with `yield` and nested layouts](#templates-with-yield-and-nested-layouts) + - [Inline Templates](#inline-templates) + - [Named Templates](#named-templates) + - [Associating File Extensions](#associating-file-extensions) + - [Adding Your Own Template Engine](#adding-your-own-template-engine) + - [Using Custom Logic for Template Lookup](#using-custom-logic-for-template-lookup) + - [Filters](#filters) + - [Helpers](#helpers) + - [Using Sessions](#using-sessions) + - [Session Secret Security](#session-secret-security) + - [Session Config](#session-config) + - [Choosing Your Own Session Middleware](#choosing-your-own-session-middleware) + - [Halting](#halting) + - [Passing](#passing) + - [Triggering Another Route](#triggering-another-route) + - [Setting Body, Status Code, and Headers](#setting-body-status-code-and-headers) + - [Streaming Responses](#streaming-responses) + - [Logging](#logging) + - [Mime Types](#mime-types) + - [Generating URLs](#generating-urls) + - [Browser Redirect](#browser-redirect) + - [Cache Control](#cache-control) + - [Sending Files](#sending-files) + - [Accessing the Request Object](#accessing-the-request-object) + - [Attachments](#attachments) + - [Dealing with Date and Time](#dealing-with-date-and-time) + - [Looking Up Template Files](#looking-up-template-files) + - [Configuration](#configuration) + - [Configuring attack protection](#configuring-attack-protection) + - [Available Settings](#available-settings) + - [Environments](#environments) + - [Error Handling](#error-handling) + - [Not Found](#not-found) + - [Error](#error) + - [Rack Middleware](#rack-middleware) + - [Testing](#testing) + - [Sinatra::Base - Middleware, Libraries, and Modular Apps](#sinatrabase---middleware-libraries-and-modular-apps) + - [Modular vs. Classic Style](#modular-vs-classic-style) + - [Serving a Modular Application](#serving-a-modular-application) + - [Using a Classic Style Application with a config.ru](#using-a-classic-style-application-with-a-configru) + - [When to use a config.ru?](#when-to-use-a-configru) + - [Using Sinatra as Middleware](#using-sinatra-as-middleware) + - [Dynamic Application Creation](#dynamic-application-creation) + - [Scopes and Binding](#scopes-and-binding) + - [Application/Class Scope](#applicationclass-scope) + - [Request/Instance Scope](#requestinstance-scope) + - [Delegation Scope](#delegation-scope) + - [Command Line](#command-line) + - [Multi-threading](#multi-threading) + - [Requirement](#requirement) + - [The Bleeding Edge](#the-bleeding-edge) + - [With Bundler](#with-bundler) + - [Versioning](#versioning) + - [Further Reading](#further-reading) + +## Routes + +In Sinatra, a route is an HTTP method paired with a URL-matching pattern. +Each route is associated with a block: + +```ruby +get '/' do + .. show something .. +end + +post '/' do + .. create something .. +end + +put '/' do + .. replace something .. +end + +patch '/' do + .. modify something .. +end + +delete '/' do + .. annihilate something .. +end + +options '/' do + .. appease something .. +end + +link '/' do + .. affiliate something .. +end + +unlink '/' do + .. separate something .. +end +``` + +Routes are matched in the order they are defined. The first route that +matches the request is invoked. + +Routes with trailing slashes are different from the ones without: + +```ruby +get '/foo' do + # Does not match "GET /foo/" +end +``` + +Route patterns may include named parameters, accessible via the +`params` hash: + +```ruby +get '/hello/:name' do + # matches "GET /hello/foo" and "GET /hello/bar" + # params['name'] is 'foo' or 'bar' + "Hello #{params['name']}!" +end +``` + +You can also access named parameters via block parameters: + +```ruby +get '/hello/:name' do |n| + # matches "GET /hello/foo" and "GET /hello/bar" + # params['name'] is 'foo' or 'bar' + # n stores params['name'] + "Hello #{n}!" +end +``` + +Route patterns may also include splat (or wildcard) parameters, accessible +via the `params['splat']` array: + +```ruby +get '/say/*/to/*' do + # matches /say/hello/to/world + params['splat'] # => ["hello", "world"] +end + +get '/download/*.*' do + # matches /download/path/to/file.xml + params['splat'] # => ["path/to/file", "xml"] +end +``` + +Or with block parameters: + +```ruby +get '/download/*.*' do |path, ext| + [path, ext] # => ["path/to/file", "xml"] +end +``` + +Route matching with Regular Expressions: + +```ruby +get /\/hello\/([\w]+)/ do + "Hello, #{params['captures'].first}!" +end +``` + +Or with a block parameter: + +```ruby +get %r{/hello/([\w]+)} do |c| + # Matches "GET /meta/hello/world", "GET /hello/world/1234" etc. + "Hello, #{c}!" +end +``` + +Route patterns may have optional parameters: + +```ruby +get '/posts/:format?' do + # matches "GET /posts/" and any extension "GET /posts/json", "GET /posts/xml" etc +end +``` + +Routes may also utilize query parameters: + +```ruby +get '/posts' do + # matches "GET /posts?title=foo&author=bar" + title = params['title'] + author = params['author'] + # uses title and author variables; query is optional to the /posts route +end +``` + +By the way, unless you disable the path traversal attack protection (see +[below](#configuring-attack-protection)), the request path might be modified before +matching against your routes. + +You may customize the [Mustermann](https://github.com/sinatra/mustermann#readme) +options used for a given route by passing in a `:mustermann_opts` hash: + +```ruby +get '\A/posts\z', :mustermann_opts => { :type => :regexp, :check_anchors => false } do + # matches /posts exactly, with explicit anchoring + "If you match an anchored pattern clap your hands!" +end +``` + +It looks like a [condition](#conditions), but it isn't one! These options will +be merged into the global `:mustermann_opts` hash described +[below](#available-settings). + +## Conditions + +Routes may include a variety of matching conditions, such as the user agent: + +```ruby +get '/foo', :agent => /Songbird (\d\.\d)[\d\/]*?/ do + "You're using Songbird version #{params['agent'][0]}" +end + +get '/foo' do + # Matches non-songbird browsers +end +``` + +Other available conditions are `host_name` and `provides`: + +```ruby +get '/', :host_name => /^admin\./ do + "Admin Area, Access denied!" +end + +get '/', :provides => 'html' do + haml :index +end + +get '/', :provides => ['rss', 'atom', 'xml'] do + builder :feed +end +``` +`provides` searches the request's Accept header. + +You can easily define your own conditions: + +```ruby +set(:probability) { |value| condition { rand <= value } } + +get '/win_a_car', :probability => 0.1 do + "You won!" +end + +get '/win_a_car' do + "Sorry, you lost." +end +``` + +For a condition that takes multiple values use a splat: + +```ruby +set(:auth) do |*roles| # <- notice the splat here + condition do + unless logged_in? && roles.any? {|role| current_user.in_role? role } + redirect "/login/", 303 + end + end +end + +get "/my/account/", :auth => [:user, :admin] do + "Your Account Details" +end + +get "/only/admin/", :auth => :admin do + "Only admins are allowed here!" +end +``` + +## Return Values + +The return value of a route block determines at least the response body +passed on to the HTTP client or at least the next middleware in the +Rack stack. Most commonly, this is a string, as in the above examples. +But other values are also accepted. + +You can return an object that would either be a valid Rack response, Rack +body object or HTTP status code: + +* An Array with three elements: `[status (Integer), headers (Hash), response + body (responds to #each)]` +* An Array with two elements: `[status (Integer), response body (responds to + #each)]` +* An object that responds to `#each` and passes nothing but strings to + the given block +* A Integer representing the status code + +That way we can, for instance, easily implement a streaming example: + +```ruby +class Stream + def each + 100.times { |i| yield "#{i}\n" } + end +end + +get('/') { Stream.new } +``` + +You can also use the `stream` helper method ([described below](#streaming-responses)) to reduce +boilerplate and embed the streaming logic in the route. + +## Custom Route Matchers + +As shown above, Sinatra ships with built-in support for using String +patterns and regular expressions as route matches. However, it does not +stop there. You can easily define your own matchers: + +```ruby +class AllButPattern + def initialize(except) + @except = except + end + + def to_pattern(options) + return self + end + + def params(route) + return {} unless @except === route + end +end + +def all_but(pattern) + AllButPattern.new(pattern) +end + +get all_but("/index") do + # ... +end +``` + +Note that the above example might be over-engineered, as it can also be +expressed as: + +```ruby +get /.*/ do + pass if request.path_info == "/index" + # ... +end +``` + +## Static Files + +Static files are served from the `./public` directory. You can specify +a different location by setting the `:public_folder` option: + +```ruby +set :public_folder, __dir__ + '/static' +``` + +Note that the public directory name is not included in the URL. A file +`./public/css/style.css` is made available as +`http://example.com/css/style.css`. + +Use the `:static_cache_control` setting (see [below](#cache-control)) to add +`Cache-Control` header info. + +## Views / Templates + +Each template language is exposed via its own rendering method. These +methods simply return a string: + +```ruby +get '/' do + erb :index +end +``` + +This renders `views/index.erb`. + +Instead of a template name, you can also just pass in the template content +directly: + +```ruby +get '/' do + code = "<%= Time.now %>" + erb code +end +``` + +Templates take a second argument, the options hash: + +```ruby +get '/' do + erb :index, :layout => :post +end +``` + +This will render `views/index.erb` embedded in the +`views/post.erb` (default is `views/layout.erb`, if it exists). + +Any options not understood by Sinatra will be passed on to the template +engine: + +```ruby +get '/' do + haml :index, :format => :html5 +end +``` + +You can also set options per template language in general: + +```ruby +set :haml, :format => :html5 + +get '/' do + haml :index +end +``` + +Options passed to the render method override options set via `set`. + +Available Options: + +
+
locals
+
+ List of locals passed to the document. Handy with partials. + Example: erb "<%= foo %>", :locals => {:foo => "bar"} +
+ +
default_encoding
+
+ String encoding to use if uncertain. Defaults to + settings.default_encoding. +
+ +
views
+
+ Views folder to load templates from. Defaults to settings.views. +
+ +
layout
+
+ Whether to use a layout (true or false). If it's a + Symbol, specifies what template to use. Example: + erb :index, :layout => !request.xhr? +
+ +
content_type
+
+ Content-Type the template produces. Default depends on template language. +
+ +
scope
+
+ Scope to render template under. Defaults to the application + instance. If you change this, instance variables and helper methods + will not be available. +
+ +
layout_engine
+
+ Template engine to use for rendering the layout. Useful for + languages that do not support layouts otherwise. Defaults to the + engine used for the template. Example: set :rdoc, :layout_engine + => :erb +
+ +
layout_options
+
+ Special options only used for rendering the layout. Example: + set :rdoc, :layout_options => { :views => 'views/layouts' } +
+
+ +Templates are assumed to be located directly under the `./views` +directory. To use a different views directory: + +```ruby +set :views, settings.root + '/templates' +``` + + +One important thing to remember is that you always have to reference +templates with symbols, even if they're in a subdirectory (in this case, +use: `:'subdir/template'` or `'subdir/template'.to_sym`). You must use a +symbol because otherwise rendering methods will render any strings +passed to them directly. + +### Literal Templates + +```ruby +get '/' do + haml '%div.title Hello World' +end +``` + +Renders the template string. You can optionally specify `:path` and +`:line` for a clearer backtrace if there is a filesystem path or line +associated with that string: + +```ruby +get '/' do + haml '%div.title Hello World', :path => 'examples/file.haml', :line => 3 +end +``` + +### Available Template Languages + +Some languages have multiple implementations. To specify what implementation +to use (and to be thread-safe), you should simply require it first: + +```ruby +require 'rdiscount' +get('/') { markdown :index } +``` + +#### Haml Templates + + + + + + + + + + + + + + +
Dependencyhaml
File Extension.haml
Examplehaml :index, :format => :html5
+ +#### Erb Templates + + + + + + + + + + + + + + +
Dependency + erubi + or erb (included in Ruby) +
File Extensions.erb, .rhtml or .erubi (Erubi only)
Exampleerb :index
+ +#### Builder Templates + + + + + + + + + + + + + + +
Dependency + builder +
File Extension.builder
Examplebuilder { |xml| xml.em "hi" }
+ +It also takes a block for inline templates (see [example](#inline-templates)). + +#### Nokogiri Templates + + + + + + + + + + + + + + +
Dependencynokogiri
File Extension.nokogiri
Examplenokogiri { |xml| xml.em "hi" }
+ +It also takes a block for inline templates (see [example](#inline-templates)). + + +#### Liquid Templates + + + + + + + + + + + + + + +
Dependencyliquid
File Extension.liquid
Exampleliquid :index, :locals => { :key => 'value' }
+ +Since you cannot call Ruby methods (except for `yield`) from a Liquid +template, you almost always want to pass locals to it. + +#### Markdown Templates + + + + + + + + + + + + + + +
Dependency + Anyone of: + RDiscount, + RedCarpet, + kramdown, + commonmarker + pandoc +
File Extensions.markdown, .mkd and .md
Examplemarkdown :index, :layout_engine => :erb
+ +It is not possible to call methods from Markdown, nor to pass locals to it. +You therefore will usually use it in combination with another rendering +engine: + +```ruby +erb :overview, :locals => { :text => markdown(:introduction) } +``` + +Note that you may also call the `markdown` method from within other +templates: + +```ruby +%h1 Hello From Haml! +%p= markdown(:greetings) +``` + +Since you cannot call Ruby from Markdown, you cannot use layouts written in +Markdown. However, it is possible to use another rendering engine for the +template than for the layout by passing the `:layout_engine` option. + +#### RDoc Templates + + + + + + + + + + + + + + +
DependencyRDoc
File Extension.rdoc
Examplerdoc :README, :layout_engine => :erb
+ +It is not possible to call methods from RDoc, nor to pass locals to it. You +therefore will usually use it in combination with another rendering engine: + +```ruby +erb :overview, :locals => { :text => rdoc(:introduction) } +``` + +Note that you may also call the `rdoc` method from within other templates: + +```ruby +%h1 Hello From Haml! +%p= rdoc(:greetings) +``` + +Since you cannot call Ruby from RDoc, you cannot use layouts written in +RDoc. However, it is possible to use another rendering engine for the +template than for the layout by passing the `:layout_engine` option. + +#### AsciiDoc Templates + + + + + + + + + + + + + + +
DependencyAsciidoctor
File Extension.asciidoc, .adoc and .ad
Exampleasciidoc :README, :layout_engine => :erb
+ +Since you cannot call Ruby methods directly from an AsciiDoc template, you +almost always want to pass locals to it. + +#### Markaby Templates + + + + + + + + + + + + + + +
DependencyMarkaby
File Extension.mab
Examplemarkaby { h1 "Welcome!" }
+ +It also takes a block for inline templates (see [example](#inline-templates)). + +#### RABL Templates + + + + + + + + + + + + + + +
DependencyRabl
File Extension.rabl
Examplerabl :index
+ +#### Slim Templates + + + + + + + + + + + + + + +
DependencySlim Lang
File Extension.slim
Exampleslim :index
+ +#### Yajl Templates + + + + + + + + + + + + + + +
Dependencyyajl-ruby
File Extension.yajl
Example + + yajl :index, + :locals => { :key => 'qux' }, + :callback => 'present', + :variable => 'resource' + +
+ + +The template source is evaluated as a Ruby string, and the +resulting json variable is converted using `#to_json`: + +```ruby +json = { :foo => 'bar' } +json[:baz] = key +``` + +The `:callback` and `:variable` options can be used to decorate the rendered +object: + +```javascript +var resource = {"foo":"bar","baz":"qux"}; +present(resource); +``` + +### Accessing Variables in Templates + +Templates are evaluated within the same context as route handlers. Instance +variables set in route handlers are directly accessible by templates: + +```ruby +get '/:id' do + @foo = Foo.find(params['id']) + haml '%h1= @foo.name' +end +``` + +Or, specify an explicit Hash of local variables: + +```ruby +get '/:id' do + foo = Foo.find(params['id']) + haml '%h1= bar.name', :locals => { :bar => foo } +end +``` + +This is typically used when rendering templates as partials from within +other templates. + +### Templates with `yield` and nested layouts + +A layout is usually just a template that calls `yield`. +Such a template can be used either through the `:template` option as +described above, or it can be rendered with a block as follows: + +```ruby +erb :post, :layout => false do + erb :index +end +``` + +This code is mostly equivalent to `erb :index, :layout => :post`. + +Passing blocks to rendering methods is most useful for creating nested +layouts: + +```ruby +erb :main_layout, :layout => false do + erb :admin_layout do + erb :user + end +end +``` + +This can also be done in fewer lines of code with: + +```ruby +erb :admin_layout, :layout => :main_layout do + erb :user +end +``` + +Currently, the following rendering methods accept a block: `erb`, `haml`, +`liquid`, `slim `. Also, the general `render` method accepts a block. + +### Inline Templates + +Templates may be defined at the end of the source file: + +```ruby +require 'sinatra' + +get '/' do + haml :index +end + +__END__ + +@@ layout +%html + != yield + +@@ index +%div.title Hello world. +``` + +NOTE: Inline templates defined in the source file that requires Sinatra are +automatically loaded. Call `enable :inline_templates` explicitly if you +have inline templates in other source files. + +### Named Templates + +Templates may also be defined using the top-level `template` method: + +```ruby +template :layout do + "%html\n =yield\n" +end + +template :index do + '%div.title Hello World!' +end + +get '/' do + haml :index +end +``` + +If a template named "layout" exists, it will be used each time a template +is rendered. You can individually disable layouts by passing +`:layout => false` or disable them by default via +`set :haml, :layout => false`: + +```ruby +get '/' do + haml :index, :layout => !request.xhr? +end +``` + +### Associating File Extensions + +To associate a file extension with a template engine, use +`Tilt.register`. For instance, if you like to use the file extension +`tt` for Haml templates, you can do the following: + +```ruby +Tilt.register Tilt[:haml], :tt +``` + +### Adding Your Own Template Engine + +First, register your engine with Tilt, then create a rendering method: + +```ruby +Tilt.register MyAwesomeTemplateEngine, :myat + +helpers do + def myat(*args) render(:myat, *args) end +end + +get '/' do + myat :index +end +``` + +Renders `./views/index.myat`. Learn more about +[Tilt](https://github.com/rtomayko/tilt#readme). + +### Using Custom Logic for Template Lookup + +To implement your own template lookup mechanism you can write your +own `#find_template` method: + +```ruby +configure do + set :views, [ './views/a', './views/b' ] +end + +def find_template(views, name, engine, &block) + Array(views).each do |v| + super(v, name, engine, &block) + end +end +``` + +## Filters + +Before filters are evaluated before each request within the same context +as the routes will be and can modify the request and response. Instance +variables set in filters are accessible by routes and templates: + +```ruby +before do + @note = 'Hi!' + request.path_info = '/foo/bar/baz' +end + +get '/foo/*' do + @note #=> 'Hi!' + params['splat'] #=> 'bar/baz' +end +``` + +After filters are evaluated after each request within the same context +as the routes will be and can also modify the request and response. +Instance variables set in before filters and routes are accessible by +after filters: + +```ruby +after do + puts response.status +end +``` + +Note: Unless you use the `body` method rather than just returning a +String from the routes, the body will not yet be available in the after +filter, since it is generated later on. + +Filters optionally take a pattern, causing them to be evaluated only if the +request path matches that pattern: + +```ruby +before '/protected/*' do + authenticate! +end + +after '/create/:slug' do |slug| + session[:last_slug] = slug +end +``` + +Like routes, filters also take conditions: + +```ruby +before :agent => /Songbird/ do + # ... +end + +after '/blog/*', :host_name => 'example.com' do + # ... +end +``` + +## Helpers + +Use the top-level `helpers` method to define helper methods for use in +route handlers and templates: + +```ruby +helpers do + def bar(name) + "#{name}bar" + end +end + +get '/:name' do + bar(params['name']) +end +``` + +Alternatively, helper methods can be separately defined in a module: + +```ruby +module FooUtils + def foo(name) "#{name}foo" end +end + +module BarUtils + def bar(name) "#{name}bar" end +end + +helpers FooUtils, BarUtils +``` + +The effect is the same as including the modules in the application class. + +### Using Sessions + +A session is used to keep state during requests. If activated, you have one +session hash per user session: + +```ruby +enable :sessions + +get '/' do + "value = " << session[:value].inspect +end + +get '/:value' do + session['value'] = params['value'] +end +``` + +#### Session Secret Security + +To improve security, the session data in the cookie is signed with a session +secret using `HMAC-SHA1`. This session secret should optimally be a +cryptographically secure random value of an appropriate length which for +`HMAC-SHA1` is greater than or equal to 64 bytes (512 bits, 128 hex +characters). You would be advised not to use a secret that is less than 32 +bytes of randomness (256 bits, 64 hex characters). It is therefore **very +important** that you don't just make the secret up, but instead use a secure +random number generator to create it. Humans are extremely bad at generating +random values. + +By default, a 32 byte secure random session secret is generated for you by +Sinatra, but it will change with every restart of your application. If you +have multiple instances of your application, and you let Sinatra generate the +key, each instance would then have a different session key which is probably +not what you want. + +For better security and usability it's +[recommended](https://12factor.net/config) that you generate a secure random +secret and store it in an environment variable on each host running your +application so that all of your application instances will share the same +secret. You should periodically rotate this session secret to a new value. +Here are some examples of how you might create a 64-byte secret and set it: + +**Session Secret Generation** + +```text +$ ruby -e "require 'securerandom'; puts SecureRandom.hex(64)" +99ae8af...snip...ec0f262ac +``` + +**Session Secret Generation (Bonus Points)** + +Use the [sysrandom gem](https://github.com/cryptosphere/sysrandom#readme) to +use the system RNG facilities to generate random values instead of +userspace `OpenSSL` which MRI Ruby currently defaults to: + +```text +$ gem install sysrandom +Building native extensions. This could take a while... +Successfully installed sysrandom-1.x +1 gem installed + +$ ruby -e "require 'sysrandom/securerandom'; puts SecureRandom.hex(64)" +99ae8af...snip...ec0f262ac +``` + +**Session Secret Environment Variable** + +Set a `SESSION_SECRET` environment variable for Sinatra to the value you +generated. Make this value persistent across reboots of your host. Since the +method for doing this will vary across systems this is for illustrative +purposes only: + +```bash +# echo "export SESSION_SECRET=99ae8af...snip...ec0f262ac" >> ~/.bashrc +``` + +**Session Secret App Config** + +Set up your app config to fail-safe to a secure random secret +if the `SESSION_SECRET` environment variable is not available. + +For bonus points use the [sysrandom +gem](https://github.com/cryptosphere/sysrandom#readme) here as well: + +```ruby +require 'securerandom' +# -or- require 'sysrandom/securerandom' +set :session_secret, ENV.fetch('SESSION_SECRET') { SecureRandom.hex(64) } +``` + +#### Session Config + +If you want to configure it further, you may also store a hash with options +in the `sessions` setting: + +```ruby +set :sessions, :domain => 'foo.com' +``` + +To share your session across other apps on subdomains of foo.com, prefix the +domain with a *.* like this instead: + +```ruby +set :sessions, :domain => '.foo.com' +``` + +#### Choosing Your Own Session Middleware + +Note that `enable :sessions` actually stores all data in a cookie. This +might not always be what you want (storing lots of data will increase your +traffic, for instance). You can use any Rack session middleware in order to +do so, one of the following methods can be used: + +```ruby +enable :sessions +set :session_store, Rack::Session::Pool +``` + +Or to set up sessions with a hash of options: + +```ruby +set :sessions, :expire_after => 2592000 +set :session_store, Rack::Session::Pool +``` + +Another option is to **not** call `enable :sessions`, but instead pull in +your middleware of choice as you would any other middleware. + +It is important to note that when using this method, session based +protection **will not be enabled by default**. + +The Rack middleware to do that will also need to be added: + +```ruby +use Rack::Session::Pool, :expire_after => 2592000 +use Rack::Protection::RemoteToken +use Rack::Protection::SessionHijacking +``` + +See '[Configuring attack protection](#configuring-attack-protection)' for more information. + +### Halting + +To immediately stop a request within a filter or route use: + +```ruby +halt +``` + +You can also specify the status when halting: + +```ruby +halt 410 +``` + +Or the body: + +```ruby +halt 'this will be the body' +``` + +Or both: + +```ruby +halt 401, 'go away!' +``` + +With headers: + +```ruby +halt 402, {'Content-Type' => 'text/plain'}, 'revenge' +``` + +It is of course possible to combine a template with `halt`: + +```ruby +halt erb(:error) +``` + +### Passing + +A route can punt processing to the next matching route using `pass`: + +```ruby +get '/guess/:who' do + pass unless params['who'] == 'Frank' + 'You got me!' +end + +get '/guess/*' do + 'You missed!' +end +``` + +The route block is immediately exited and control continues with the next +matching route. If no matching route is found, a 404 is returned. + +### Triggering Another Route + +Sometimes `pass` is not what you want, instead, you would like to get the +result of calling another route. Simply use `call` to achieve this: + +```ruby +get '/foo' do + status, headers, body = call env.merge("PATH_INFO" => '/bar') + [status, headers, body.map(&:upcase)] +end + +get '/bar' do + "bar" +end +``` + +Note that in the example above, you would ease testing and increase +performance by simply moving `"bar"` into a helper used by both `/foo` and +`/bar`. + +If you want the request to be sent to the same application instance rather +than a duplicate, use `call!` instead of `call`. + +Check out the Rack specification if you want to learn more about `call`. + +### Setting Body, Status Code, and Headers + +It is possible and recommended to set the status code and response body with +the return value of the route block. However, in some scenarios, you might +want to set the body at an arbitrary point in the execution flow. You can do +so with the `body` helper method. If you do so, you can use that method from +thereon to access the body: + +```ruby +get '/foo' do + body "bar" +end + +after do + puts body +end +``` + +It is also possible to pass a block to `body`, which will be executed by the +Rack handler (this can be used to implement streaming, [see "Return Values"](#return-values)). + +Similar to the body, you can also set the status code and headers: + +```ruby +get '/foo' do + status 418 + headers \ + "Allow" => "BREW, POST, GET, PROPFIND, WHEN", + "Refresh" => "Refresh: 20; https://ietf.org/rfc/rfc2324.txt" + body "I'm a teapot!" +end +``` + +Like `body`, `headers` and `status` with no arguments can be used to access +their current values. + +### Streaming Responses + +Sometimes you want to start sending out data while still generating parts of +the response body. In extreme examples, you want to keep sending data until +the client closes the connection. You can use the `stream` helper to avoid +creating your own wrapper: + +```ruby +get '/' do + stream do |out| + out << "It's gonna be legen -\n" + sleep 0.5 + out << " (wait for it) \n" + sleep 1 + out << "- dary!\n" + end +end +``` + +This allows you to implement streaming APIs, +[Server Sent Events](https://w3c.github.io/eventsource/), and can be used as +the basis for [WebSockets](https://en.wikipedia.org/wiki/WebSocket). It can +also be used to increase throughput if some but not all content depends on a +slow resource. + +Note that the streaming behavior, especially the number of concurrent +requests, highly depends on the webserver used to serve the application. +Some servers might not even support streaming at all. If the server does not +support streaming, the body will be sent all at once after the block passed +to `stream` finishes executing. Streaming does not work at all with Shotgun. + +If the optional parameter is set to `keep_open`, it will not call `close` on +the stream object, allowing you to close it at any later point in the +execution flow. This only works on evented servers, like Rainbows. +Other servers will still close the stream: + +```ruby +# config.ru +require 'sinatra/base' + +class App < Sinatra::Base + connections = [] + + get '/subscribe', provides: 'text/event-stream' do + # register a client's interest in server events + stream(:keep_open) do |out| + connections << out + # purge dead connections + connections.reject!(&:closed?) + end + end + + post '/' do + connections.each do |out| + # notify client that a new message has arrived + out << "data: #{params[:msg]}\n\n" + + # indicate client to connect again + out.close + end + + 204 # response without entity body + end +end + +run App +``` + +```ruby +# rainbows.conf +Rainbows! do + use :EventMachine +end +```` + +Run: + +```shell +rainbows -c rainbows.conf +``` + +It's also possible for the client to close the connection when trying to +write to the socket. Because of this, it's recommended to check +`out.closed?` before trying to write. + +### Logging + +In the request scope, the `logger` helper exposes a `Logger` instance: + +```ruby +get '/' do + logger.info "loading data" + # ... +end +``` + +This logger will automatically take your Rack handler's logging settings into +account. If logging is disabled, this method will return a dummy object, so +you do not have to worry about it in your routes and filters. + +Note that logging is only enabled for `Sinatra::Application` by default, so +if you inherit from `Sinatra::Base`, you probably want to enable it yourself: + +```ruby +class MyApp < Sinatra::Base + configure :production, :development do + enable :logging + end +end +``` + +To avoid any logging middleware to be set up, set the `logging` option to +`nil`. However, keep in mind that `logger` will in that case return `nil`. A +common use case is when you want to set your own logger. Sinatra will use +whatever it will find in `env['rack.logger']`. + +### Mime Types + +When using `send_file` or static files you may have mime types Sinatra +doesn't understand. Use `mime_type` to register them by file extension: + +```ruby +configure do + mime_type :foo, 'text/foo' +end +``` + +You can also use it with the `content_type` helper: + +```ruby +get '/' do + content_type :foo + "foo foo foo" +end +``` + +### Generating URLs + +For generating URLs you should use the `url` helper method, for instance, in +Haml: + +```ruby +%a{:href => url('/foo')} foo +``` + +It takes reverse proxies and Rack routers into account - if present. + +This method is also aliased to `to` (see [below](#browser-redirect) for an example). + +### Browser Redirect + +You can trigger a browser redirect with the `redirect` helper method: + +```ruby +get '/foo' do + redirect to('/bar') +end +``` + +Any additional parameters are handled like arguments passed to `halt`: + +```ruby +redirect to('/bar'), 303 +redirect 'http://www.google.com/', 'wrong place, buddy' +``` + +You can also easily redirect back to the page the user came from with +`redirect back`: + +```ruby +get '/foo' do + "do something" +end + +get '/bar' do + do_something + redirect back +end +``` + +To pass arguments with a redirect, either add them to the query: + +```ruby +redirect to('/bar?sum=42') +``` + +Or use a session: + +```ruby +enable :sessions + +get '/foo' do + session[:secret] = 'foo' + redirect to('/bar') +end + +get '/bar' do + session[:secret] +end +``` + +### Cache Control + +Setting your headers correctly is the foundation for proper HTTP caching. + +You can easily set the Cache-Control header like this: + +```ruby +get '/' do + cache_control :public + "cache it!" +end +``` + +Pro tip: Set up caching in a before filter: + +```ruby +before do + cache_control :public, :must_revalidate, :max_age => 60 +end +``` + +If you are using the `expires` helper to set the corresponding header, +`Cache-Control` will be set automatically for you: + +```ruby +before do + expires 500, :public, :must_revalidate +end +``` + +To properly use caches, you should consider using `etag` or `last_modified`. +It is recommended to call those helpers *before* doing any heavy lifting, as +they will immediately flush a response if the client already has the current +version in its cache: + +```ruby +get "/article/:id" do + @article = Article.find params['id'] + last_modified @article.updated_at + etag @article.sha1 + erb :article +end +``` + +It is also possible to use a +[weak ETag](https://en.wikipedia.org/wiki/HTTP_ETag#Strong_and_weak_validation): + +```ruby +etag @article.sha1, :weak +``` + +These helpers will not do any caching for you, but rather feed the necessary +information to your cache. If you are looking for a quick +reverse-proxy caching solution, try +[rack-cache](https://github.com/rtomayko/rack-cache#readme): + +```ruby +require "rack/cache" +require "sinatra" + +use Rack::Cache + +get '/' do + cache_control :public, :max_age => 36000 + sleep 5 + "hello" +end +``` + +Use the `:static_cache_control` setting (see [below](#cache-control)) to add +`Cache-Control` header info to static files. + +According to RFC 2616, your application should behave differently if the +If-Match or If-None-Match header is set to `*`, depending on whether the +resource requested is already in existence. Sinatra assumes resources for +safe (like get) and idempotent (like put) requests are already in existence, +whereas other resources (for instance post requests) are treated as new +resources. You can change this behavior by passing in a `:new_resource` +option: + +```ruby +get '/create' do + etag '', :new_resource => true + Article.create + erb :new_article +end +``` + +If you still want to use a weak ETag, pass in a `:kind` option: + +```ruby +etag '', :new_resource => true, :kind => :weak +``` + +### Sending Files + +To return the contents of a file as the response, you can use the `send_file` +helper method: + +```ruby +get '/' do + send_file 'foo.png' +end +``` + +It also takes options: + +```ruby +send_file 'foo.png', :type => :jpg +``` + +The options are: + +
+
filename
+
File name to be used in the response, + defaults to the real file name.
+
last_modified
+
Value for Last-Modified header, defaults to the file's mtime.
+ +
type
+
Value for Content-Type header, guessed from the file extension if + missing.
+ +
disposition
+
+ Value for Content-Disposition header, possible values: nil + (default), :attachment and :inline +
+ +
length
+
Value for Content-Length header, defaults to file size.
+ +
status
+
+ Status code to be sent. Useful when sending a static file as an error + page. If supported by the Rack handler, other means than streaming + from the Ruby process will be used. If you use this helper method, + Sinatra will automatically handle range requests. +
+
+ +### Accessing the Request Object + +The incoming request object can be accessed from request level (filter, +routes, error handlers) through the `request` method: + +```ruby +# app running on http://example.com/example +get '/foo' do + t = %w[text/css text/html application/javascript] + request.accept # ['text/html', '*/*'] + request.accept? 'text/xml' # true + request.preferred_type(t) # 'text/html' + request.body # request body sent by the client (see below) + request.scheme # "http" + request.script_name # "/example" + request.path_info # "/foo" + request.port # 80 + request.request_method # "GET" + request.query_string # "" + request.content_length # length of request.body + request.media_type # media type of request.body + request.host # "example.com" + request.get? # true (similar methods for other verbs) + request.form_data? # false + request["some_param"] # value of some_param parameter. [] is a shortcut to the params hash. + request.referrer # the referrer of the client or '/' + request.user_agent # user agent (used by :agent condition) + request.cookies # hash of browser cookies + request.xhr? # is this an ajax request? + request.url # "http://example.com/example/foo" + request.path # "/example/foo" + request.ip # client IP address + request.secure? # false (would be true over ssl) + request.forwarded? # true (if running behind a reverse proxy) + request.env # raw env hash handed in by Rack +end +``` + +Some options, like `script_name` or `path_info`, can also be written: + +```ruby +before { request.path_info = "/" } + +get "/" do + "all requests end up here" +end +``` + +The `request.body` is an IO or StringIO object: + +```ruby +post "/api" do + request.body.rewind # in case someone already read it + data = JSON.parse request.body.read + "Hello #{data['name']}!" +end +``` + +### Attachments + +You can use the `attachment` helper to tell the browser the response should +be stored on disk rather than displayed in the browser: + +```ruby +get '/' do + attachment + "store it!" +end +``` + +You can also pass it a file name: + +```ruby +get '/' do + attachment "info.txt" + "store it!" +end +``` + +### Dealing with Date and Time + +Sinatra offers a `time_for` helper method that generates a Time object from +the given value. It is also able to convert `DateTime`, `Date` and similar +classes: + +```ruby +get '/' do + pass if Time.now > time_for('Dec 23, 2016') + "still time" +end +``` + +This method is used internally by `expires`, `last_modified` and akin. You +can therefore easily extend the behavior of those methods by overriding +`time_for` in your application: + +```ruby +helpers do + def time_for(value) + case value + when :yesterday then Time.now - 24*60*60 + when :tomorrow then Time.now + 24*60*60 + else super + end + end +end + +get '/' do + last_modified :yesterday + expires :tomorrow + "hello" +end +``` + +### Looking Up Template Files + +The `find_template` helper is used to find template files for rendering: + +```ruby +find_template settings.views, 'foo', Tilt[:haml] do |file| + puts "could be #{file}" +end +``` + +This is not really useful. But it is useful that you can actually override +this method to hook in your own lookup mechanism. For instance, if you want +to be able to use more than one view directory: + +```ruby +set :views, ['views', 'templates'] + +helpers do + def find_template(views, name, engine, &block) + Array(views).each { |v| super(v, name, engine, &block) } + end +end +``` + +Another example would be using different directories for different engines: + +```ruby +set :views, :haml => 'templates', :default => 'views' + +helpers do + def find_template(views, name, engine, &block) + _, folder = views.detect { |k,v| engine == Tilt[k] } + folder ||= views[:default] + super(folder, name, engine, &block) + end +end +``` + +You can also easily wrap this up in an extension and share it with others! + +Note that `find_template` does not check if the file really exists but +rather calls the given block for all possible paths. This is not a +performance issue, since `render` will use `break` as soon as a file is +found. Also, template locations (and content) will be cached if you are not +running in development mode. You should keep that in mind if you write a +really crazy method. + +## Configuration + +Run once, at startup, in any environment: + +```ruby +configure do + # setting one option + set :option, 'value' + + # setting multiple options + set :a => 1, :b => 2 + + # same as `set :option, true` + enable :option + + # same as `set :option, false` + disable :option + + # you can also have dynamic settings with blocks + set(:css_dir) { File.join(views, 'css') } +end +``` + +Run only when the environment (`APP_ENV` environment variable) is set to +`:production`: + +```ruby +configure :production do + ... +end +``` + +Run when the environment is set to either `:production` or `:test`: + +```ruby +configure :production, :test do + ... +end +``` + +You can access those options via `settings`: + +```ruby +configure do + set :foo, 'bar' +end + +get '/' do + settings.foo? # => true + settings.foo # => 'bar' + ... +end +``` + +### Configuring attack protection + +Sinatra is using +[Rack::Protection](https://github.com/sinatra/sinatra/tree/main/rack-protection#readme) to +defend your application against common, opportunistic attacks. You can +easily disable this behavior (which will open up your application to tons +of common vulnerabilities): + +```ruby +disable :protection +``` + +To skip a single defense layer, set `protection` to an options hash: + +```ruby +set :protection, :except => :path_traversal +``` +You can also hand in an array in order to disable a list of protections: + +```ruby +set :protection, :except => [:path_traversal, :session_hijacking] +``` + +By default, Sinatra will only set up session based protection if `:sessions` +have been enabled. See '[Using Sessions](#using-sessions)'. Sometimes you may want to set up +sessions "outside" of the Sinatra app, such as in the config.ru or with a +separate `Rack::Builder` instance. In that case, you can still set up session +based protection by passing the `:session` option: + +```ruby +set :protection, :session => true +``` + +### Available Settings + +
+
absolute_redirects
+
+ If disabled, Sinatra will allow relative redirects, however, Sinatra + will no longer conform with RFC 2616 (HTTP 1.1), which only allows + absolute redirects. +
+
+ Enable if your app is running behind a reverse proxy that has not been + set up properly. Note that the url helper will still produce + absolute URLs, unless you pass in false as the second + parameter. +
+
Disabled by default.
+ +
add_charset
+
+ Mime types the content_type helper will automatically add the + charset info to. You should add to it rather than overriding this + option: settings.add_charset << "application/foobar" +
+ +
app_file
+
+ Path to the main application file, used to detect project root, views + and public folder and inline templates. +
+ +
bind
+
+ IP address to bind to (default: 0.0.0.0 or + localhost if your `environment` is set to development). Only + used for built-in server. +
+ +
default_content_type
+
+ Content-Type to assume if unknown (defaults to "text/html"). Set + to nil to not set a default Content-Type on every response; when + configured so, you must set the Content-Type manually when emitting content + or the user-agent will have to sniff it (or, if nosniff is enabled + in Rack::Protection::XSSHeader, assume application/octet-stream). +
+ +
default_encoding
+
Encoding to assume if unknown (defaults to "utf-8").
+ +
dump_errors
+
Display errors in the log. Enabled by default unless environment is "test".
+ +
environment
+
+ Current environment. Defaults to ENV['APP_ENV'], or + "development" if not available. +
+ +
logging
+
Use the logger.
+ +
lock
+
+ Places a lock around every request, only running processing on request + per Ruby process concurrently. +
+
Enabled if your app is not thread-safe. Disabled by default.
+ +
method_override
+
+ Use _method magic to allow put/delete forms in browsers that + don't support it. +
+ +
mustermann_opts
+
+ A default hash of options to pass to Mustermann.new when compiling routing + paths. +
+ +
port
+
Port to listen on. Only used for built-in server.
+ +
prefixed_redirects
+
+ Whether or not to insert request.script_name into redirects + if no absolute path is given. That way redirect '/foo' would + behave like redirect to('/foo'). Disabled by default. +
+ +
protection
+
+ Whether or not to enable web attack protections. See protection section + above. +
+ +
public_dir
+
Alias for public_folder. See below.
+ +
public_folder
+
+ Path to the folder public files are served from. Only used if static + file serving is enabled (see static setting below). Inferred + from app_file setting if not set. +
+ +
quiet
+
+ Disables logs generated by Sinatra's start and stop commands. + false by default. +
+ +
reload_templates
+
+ Whether or not to reload templates between requests. Enabled in + development mode. +
+ +
root
+
+ Path to project root folder. Inferred from app_file setting + if not set. +
+ +
raise_errors
+
+ Raise exceptions (will stop application). Enabled by default when + environment is set to "test", disabled otherwise. +
+ +
run
+
+ If enabled, Sinatra will handle starting the web server. Do not + enable if using rackup or other means. +
+ +
running
+
Is the built-in server running now? Do not change this setting!
+ +
server
+
+ Server or list of servers to use for built-in server. Order indicates + priority, default depends on Ruby implementation. +
+ +
server_settings
+
+ If you are using a WEBrick web server, presumably for your development + environment, you can pass a hash of options to server_settings, + such as SSLEnable or SSLVerifyClient. However, web + servers such as Puma do not support this, so you can set + server_settings by defining it as a method when you call + configure. +
+ +
sessions
+
+ Enable cookie-based sessions support using + Rack::Session::Cookie. See 'Using Sessions' section for more + information. +
+ +
session_store
+
+ The Rack session middleware used. Defaults to + Rack::Session::Cookie. See 'Using Sessions' section for more + information. +
+ +
show_exceptions
+
+ Show a stack trace in the browser when an exception happens. Enabled by + default when environment is set to "development", + disabled otherwise. +
+
+ Can also be set to :after_handler to trigger app-specified + error handling before showing a stack trace in the browser. +
+ +
static
+
Whether Sinatra should handle serving static files.
+
Disable when using a server able to do this on its own.
+
Disabling will boost performance.
+
+ Enabled by default in classic style, disabled for modular apps. +
+ +
static_cache_control
+
+ When Sinatra is serving static files, set this to add + Cache-Control headers to the responses. Uses the + cache_control helper. Disabled by default. +
+
+ Use an explicit array when setting multiple values: + set :static_cache_control, [:public, :max_age => 300] +
+ +
threaded
+
+ If set to true, will tell server to use + EventMachine.defer for processing the request. +
+ +
traps
+
Whether Sinatra should handle system signals.
+ +
views
+
+ Path to the views folder. Inferred from app_file setting if + not set. +
+ +
x_cascade
+
+ Whether or not to set the X-Cascade header if no route matches. + Defaults to true. +
+
+ +## Environments + +There are three predefined `environments`: `"development"`, +`"production"` and `"test"`. Environments can be set through the +`APP_ENV` environment variable. The default value is `"development"`. +In the `"development"` environment all templates are reloaded between +requests, and special `not_found` and `error` handlers display stack +traces in your browser. In the `"production"` and `"test"` environments, +templates are cached by default. + +To run different environments, set the `APP_ENV` environment variable: + +```shell +APP_ENV=production ruby my_app.rb +``` + +You can use predefined methods: `development?`, `test?` and `production?` to +check the current environment setting: + +```ruby +get '/' do + if settings.development? + "development!" + else + "not development!" + end +end +``` + +## Error Handling + +Error handlers run within the same context as routes and before filters, +which means you get all the goodies it has to offer, like `haml`, `erb`, +`halt`, etc. + +### Not Found + +When a `Sinatra::NotFound` exception is raised, or the response's status +code is 404, the `not_found` handler is invoked: + +```ruby +not_found do + 'This is nowhere to be found.' +end +``` + +### Error + +The `error` handler is invoked any time an exception is raised from a route +block or a filter. But note in development it will only run if you set the +show exceptions option to `:after_handler`: + +```ruby +set :show_exceptions, :after_handler +``` + +The exception object can be obtained from the `sinatra.error` Rack variable: + +```ruby +error do + 'Sorry there was a nasty error - ' + env['sinatra.error'].message +end +``` + +Custom errors: + +```ruby +error MyCustomError do + 'So what happened was...' + env['sinatra.error'].message +end +``` + +Then, if this happens: + +```ruby +get '/' do + raise MyCustomError, 'something bad' +end +``` + +You get this: + +``` +So what happened was... something bad +``` + +Alternatively, you can install an error handler for a status code: + +```ruby +error 403 do + 'Access forbidden' +end + +get '/secret' do + 403 +end +``` + +Or a range: + +```ruby +error 400..510 do + 'Boom' +end +``` + +Sinatra installs special `not_found` and `error` handlers when +running under the development environment to display nice stack traces +and additional debugging information in your browser. + +## Rack Middleware + +Sinatra rides on [Rack](https://rack.github.io/), a minimal standard +interface for Ruby web frameworks. One of Rack's most interesting +capabilities for application developers is support for "middleware" -- +components that sit between the server and your application monitoring +and/or manipulating the HTTP request/response to provide various types +of common functionality. + +Sinatra makes building Rack middleware pipelines a cinch via a top-level +`use` method: + +```ruby +require 'sinatra' +require 'my_custom_middleware' + +use Rack::Lint +use MyCustomMiddleware + +get '/hello' do + 'Hello World' +end +``` + +The semantics of `use` are identical to those defined for the +[Rack::Builder](https://www.rubydoc.info/github/rack/rack/main/Rack/Builder) DSL +(most frequently used from rackup files). For example, the `use` method +accepts multiple/variable args as well as blocks: + +```ruby +use Rack::Auth::Basic do |username, password| + username == 'admin' && password == 'secret' +end +``` + +Rack is distributed with a variety of standard middleware for logging, +debugging, URL routing, authentication, and session handling. Sinatra uses +many of these components automatically based on configuration so you +typically don't have to `use` them explicitly. + +You can find useful middleware in +[rack](https://github.com/rack/rack/tree/main/lib/rack), +[rack-contrib](https://github.com/rack/rack-contrib#readme), +or in the [Rack wiki](https://github.com/rack/rack/wiki/List-of-Middleware). + +## Testing + +Sinatra tests can be written using any Rack-based testing library or +framework. +[Rack::Test](https://www.rubydoc.info/github/rack/rack-test/main/frames) +is recommended: + +```ruby +require 'my_sinatra_app' +require 'minitest/autorun' +require 'rack/test' + +class MyAppTest < Minitest::Test + include Rack::Test::Methods + + def app + Sinatra::Application + end + + def test_my_default + get '/' + assert_equal 'Hello World!', last_response.body + end + + def test_with_params + get '/meet', :name => 'Frank' + assert_equal 'Hello Frank!', last_response.body + end + + def test_with_user_agent + get '/', {}, 'HTTP_USER_AGENT' => 'Songbird' + assert_equal "You're using Songbird!", last_response.body + end +end +``` + +Note: If you are using Sinatra in the modular style, replace +`Sinatra::Application` above with the class name of your app. + +## Sinatra::Base - Middleware, Libraries, and Modular Apps + +Defining your app at the top-level works well for micro-apps but has +considerable drawbacks when building reusable components such as Rack +middleware, Rails metal, simple libraries with a server component, or even +Sinatra extensions. The top-level assumes a micro-app style configuration +(e.g., a single application file, `./public` and `./views` +directories, logging, exception detail page, etc.). That's where +`Sinatra::Base` comes into play: + +```ruby +require 'sinatra/base' + +class MyApp < Sinatra::Base + set :sessions, true + set :foo, 'bar' + + get '/' do + 'Hello world!' + end +end +``` + +The methods available to `Sinatra::Base` subclasses are exactly the same +as those available via the top-level DSL. Most top-level apps can be +converted to `Sinatra::Base` components with two modifications: + +* Your file should require `sinatra/base` instead of `sinatra`; + otherwise, all of Sinatra's DSL methods are imported into the main + namespace. +* Put your app's routes, error handlers, filters, and options in a subclass + of `Sinatra::Base`. + +`Sinatra::Base` is a blank slate. Most options are disabled by default, +including the built-in server. See [Configuring +Settings](http://www.sinatrarb.com/configuration.html) for details on +available options and their behavior. If you want behavior more similar +to when you define your app at the top level (also known as Classic +style), you can subclass `Sinatra::Application`: + +```ruby +require 'sinatra/base' + +class MyApp < Sinatra::Application + get '/' do + 'Hello world!' + end +end +``` + +### Modular vs. Classic Style + +Contrary to common belief, there is nothing wrong with the classic +style. If it suits your application, you do not have to switch to a +modular application. + +The main disadvantage of using the classic style rather than the modular +style is that you will only have one Sinatra application per Ruby +process. If you plan to use more than one, switch to the modular style. +There is no reason you cannot mix the modular and classic styles. + +If switching from one style to the other, you should be aware of +slightly different default settings: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SettingClassicModularModular
app_filefile loading sinatrafile subclassing Sinatra::Basefile subclassing Sinatra::Application
run$0 == app_filefalsefalse
loggingtruefalsetrue
method_overridetruefalsetrue
inline_templatestruefalsetrue
statictrueFile.exist?(public_folder)true
+ +### Serving a Modular Application + +There are two common options for starting a modular app, actively +starting with `run!`: + +```ruby +# my_app.rb +require 'sinatra/base' + +class MyApp < Sinatra::Base + # ... app code here ... + + # start the server if ruby file executed directly + run! if app_file == $0 +end +``` + +Start with: + +```shell +ruby my_app.rb +``` + +Or with a `config.ru` file, which allows using any Rack handler: + +```ruby +# config.ru (run with rackup) +require './my_app' +run MyApp +``` + +Run: + +```shell +rackup -p 4567 +``` + +### Using a Classic Style Application with a config.ru + +Write your app file: + +```ruby +# app.rb +require 'sinatra' + +get '/' do + 'Hello world!' +end +``` + +And a corresponding `config.ru`: + +```ruby +require './app' +run Sinatra::Application +``` + +### When to use a config.ru? + +A `config.ru` file is recommended if: + +* You want to deploy with a different Rack handler (Passenger, Unicorn, + Heroku, ...). +* You want to use more than one subclass of `Sinatra::Base`. +* You want to use Sinatra only for middleware, and not as an endpoint. + +**There is no need to switch to a `config.ru` simply because you +switched to the modular style, and you don't have to use the modular +style for running with a `config.ru`.** + +### Using Sinatra as Middleware + +Not only is Sinatra able to use other Rack middleware, any Sinatra +application can, in turn, be added in front of any Rack endpoint as +middleware itself. This endpoint could be another Sinatra application, +or any other Rack-based application (Rails/Hanami/Roda/...): + +```ruby +require 'sinatra/base' + +class LoginScreen < Sinatra::Base + enable :sessions + + get('/login') { haml :login } + + post('/login') do + if params['name'] == 'admin' && params['password'] == 'admin' + session['user_name'] = params['name'] + else + redirect '/login' + end + end +end + +class MyApp < Sinatra::Base + # middleware will run before filters + use LoginScreen + + before do + unless session['user_name'] + halt "Access denied, please login." + end + end + + get('/') { "Hello #{session['user_name']}." } +end +``` + +### Dynamic Application Creation + +Sometimes you want to create new applications at runtime without having to +assign them to a constant. You can do this with `Sinatra.new`: + +```ruby +require 'sinatra/base' +my_app = Sinatra.new { get('/') { "hi" } } +my_app.run! +``` + +It takes the application to inherit from as an optional argument: + +```ruby +# config.ru (run with rackup) +require 'sinatra/base' + +controller = Sinatra.new do + enable :logging + helpers MyHelpers +end + +map('/a') do + run Sinatra.new(controller) { get('/') { 'a' } } +end + +map('/b') do + run Sinatra.new(controller) { get('/') { 'b' } } +end +``` + +This is especially useful for testing Sinatra extensions or using Sinatra in +your own library. + +This also makes using Sinatra as middleware extremely easy: + +```ruby +require 'sinatra/base' + +use Sinatra do + get('/') { ... } +end + +run RailsProject::Application +``` + +## Scopes and Binding + +The scope you are currently in determines what methods and variables are +available. + +### Application/Class Scope + +Every Sinatra application corresponds to a subclass of `Sinatra::Base`. +If you are using the top-level DSL (`require 'sinatra'`), then this +class is `Sinatra::Application`, otherwise it is the subclass you +created explicitly. At the class level, you have methods like `get` or +`before`, but you cannot access the `request` or `session` objects, as +there is only a single application class for all requests. + +Options created via `set` are methods at class level: + +```ruby +class MyApp < Sinatra::Base + # Hey, I'm in the application scope! + set :foo, 42 + foo # => 42 + + get '/foo' do + # Hey, I'm no longer in the application scope! + end +end +``` + +You have the application scope binding inside: + +* Your application class body +* Methods defined by extensions +* The block passed to `helpers` +* Procs/blocks used as a value for `set` +* The block passed to `Sinatra.new` + +You can reach the scope object (the class) like this: + +* Via the object passed to configure blocks (`configure { |c| ... }`) +* `settings` from within the request scope + +### Request/Instance Scope + +For every incoming request, a new instance of your application class is +created, and all handler blocks run in that scope. From within this scope you +can access the `request` and `session` objects or call rendering methods like +`erb` or `haml`. You can access the application scope from within the request +scope via the `settings` helper: + +```ruby +class MyApp < Sinatra::Base + # Hey, I'm in the application scope! + get '/define_route/:name' do + # Request scope for '/define_route/:name' + @value = 42 + + settings.get("/#{params['name']}") do + # Request scope for "/#{params['name']}" + @value # => nil (not the same request) + end + + "Route defined!" + end +end +``` + +You have the request scope binding inside: + +* get, head, post, put, delete, options, patch, link and unlink blocks +* before and after filters +* helper methods +* templates/views + +### Delegation Scope + +The delegation scope just forwards methods to the class scope. However, it +does not behave exactly like the class scope, as you do not have the class +binding. Only methods explicitly marked for delegation are available, and you +do not share variables/state with the class scope (read: you have a different +`self`). You can explicitly add method delegations by calling +`Sinatra::Delegator.delegate :method_name`. + +You have the delegate scope binding inside: + +* The top-level binding, if you did `require "sinatra"` +* An object extended with the `Sinatra::Delegator` mixin + +Have a look at the code for yourself: here's the +[Sinatra::Delegator mixin](https://github.com/sinatra/sinatra/blob/ca06364/lib/sinatra/base.rb#L1609-1633) +being [extending the main object](https://github.com/sinatra/sinatra/blob/ca06364/lib/sinatra/main.rb#L28-30). + +## Command Line + +Sinatra applications can be run directly: + +```shell +ruby myapp.rb [-h] [-x] [-q] [-e ENVIRONMENT] [-p PORT] [-o HOST] [-s HANDLER] +``` + +Options are: + +``` +-h # help +-p # set the port (default is 4567) +-o # set the host (default is 0.0.0.0) +-e # set the environment (default is development) +-s # specify rack server/handler (default is puma) +-q # turn on quiet mode for server (default is off) +-x # turn on the mutex lock (default is off) +``` + +### Multi-threading + +_Paraphrasing from +[this StackOverflow answer](https://stackoverflow.com/a/6282999/5245129) +by Konstantin_ + +Sinatra doesn't impose any concurrency model but leaves that to the +underlying Rack handler (server) like Puma or WEBrick. Sinatra +itself is thread-safe, so there won't be any problem if the Rack handler +uses a threaded model of concurrency. This would mean that when starting +the server, you'd have to specify the correct invocation method for the +specific Rack handler. The following example is a demonstration of how +to start a multi-threaded Rainbows server: + +```ruby +# config.ru + +require 'sinatra/base' + +class App < Sinatra::Base + get '/' do + "Hello, World" + end +end + +run App +``` + +```ruby +# rainbows.conf + +# Rainbows configurator is based on Unicorn. +Rainbows! do + use :ThreadSpawn +end +``` + +To start the server, the command would be: + +```shell +rainbows -c rainbows.conf +``` + +## Requirement + +The following Ruby versions are officially supported: +
+
Ruby 2.6
+
+ 2.6 is fully supported and recommended. There are currently no plans to + drop official support for it. +
+ +
Rubinius
+
+ Rubinius is officially supported (Rubinius >= 2.x). It is recommended to + gem install puma. +
+ +
JRuby
+
+ The latest stable release of JRuby is officially supported. It is not + recommended to use C extensions with JRuby. It is recommended to + gem install trinidad. +
+
+ +Versions of Ruby before 2.6 are no longer supported as of Sinatra 3.0.0. + +We also keep an eye on upcoming Ruby versions. Expect upcoming +3.x releases to be fully supported. + +Sinatra should work on any operating system supported by the chosen Ruby +implementation. + +Running Sinatra on a not officially supported Ruby flavor means that if things only break there we assume it's not our issue but theirs. + +## The Bleeding Edge + +If you would like to use Sinatra's latest bleeding-edge code, feel free +to run your application against the main branch, it should be rather +stable. + +We also push out prerelease gems from time to time, so you can do a + +```shell +gem install sinatra --pre +``` + +to get some of the latest features. + +### With Bundler + +If you want to run your application with the latest Sinatra, using +[Bundler](https://bundler.io) is the recommended way. + +First, install bundler, if you haven't: + +```shell +gem install bundler +``` + +Then, in your project directory, create a `Gemfile`: + +```ruby +source 'https://rubygems.org' +gem 'sinatra', :github => 'sinatra/sinatra' + +# other dependencies +gem 'haml' # for instance, if you use haml +``` + +Note that you will have to list all your application's dependencies in +the `Gemfile`. Sinatra's direct dependencies (Rack and Tilt) will, +however, be automatically fetched and added by Bundler. + +Now you can run your app like this: + +```shell +bundle exec ruby myapp.rb +``` + +## Versioning + +Sinatra follows [Semantic Versioning](https://semver.org/), both SemVer and +SemVerTag. + +## Further Reading + +* [Project Website](https://sinatrarb.com/) - Additional documentation, + news, and links to other resources. +* [Contributing](https://sinatrarb.com/contributing) - Find a bug? Need + help? Have a patch? +* [Issue tracker](https://github.com/sinatra/sinatra/issues) +* [Twitter](https://twitter.com/sinatra) +* [Mailing List](https://groups.google.com/forum/#!forum/sinatrarb) +* IRC: [#sinatra](irc://chat.freenode.net/#sinatra) on [Freenode](https://freenode.net) +* [Sinatra & Friends](https://discord.gg/ncjsfsNHh7) on Discord +* [Sinatra Book](https://github.com/sinatra/sinatra-book) - Cookbook Tutorial +* [Sinatra Recipes](http://recipes.sinatrarb.com/) - Community contributed + recipes +* API documentation for the [latest release](https://www.rubydoc.info/gems/sinatra) + or the [current HEAD](https://www.rubydoc.info/github/sinatra/sinatra) on + [RubyDoc](https://www.rubydoc.info/) +* [CI Actions](https://github.com/sinatra/sinatra/actions) diff --git a/README.rdoc b/README.rdoc deleted file mode 100644 index b8543c7965..0000000000 --- a/README.rdoc +++ /dev/null @@ -1,581 +0,0 @@ -= Sinatra - -Sinatra is a DSL for quickly creating web applications in Ruby with minimal -effort: - - # myapp.rb - require 'rubygems' - require 'sinatra' - get '/' do - 'Hello world!' - end - -Install the gem and run with: - - sudo gem install sinatra - ruby myapp.rb - -View at: http://localhost:4567 - -== Routes - -In Sinatra, a route is an HTTP method paired with an URL matching pattern. -Each route is associated with a block: - - get '/' do - .. show something .. - end - - post '/' do - .. create something .. - end - - put '/' do - .. update something .. - end - - delete '/' do - .. annihilate something .. - end - -Routes are matched in the order they are defined. The first route that -matches the request is invoked. - -Route patterns may include named parameters, accessible via the -params hash: - - get '/hello/:name' do - # matches "GET /hello/foo" and "GET /hello/bar" - # params[:name] is 'foo' or 'bar' - "Hello #{params[:name]}!" - end - -You can also access named parameters via block parameters: - - get '/hello/:name' do |n| - "Hello #{n}!" - end - -Route patterns may also include splat (or wildcard) parameters, accessible -via the params[:splat] array. - - get '/say/*/to/*' do - # matches /say/hello/to/world - params[:splat] # => ["hello", "world"] - end - - get '/download/*.*' do - # matches /download/path/to/file.xml - params[:splat] # => ["path/to/file", "xml"] - end - -Route matching with Regular Expressions: - - get %r{/hello/([\w]+)} do - "Hello, #{params[:captures].first}!" - end - -Or with a block parameter: - - get %r{/hello/([\w]+)} do |c| - "Hello, #{c}!" - end - -Routes may include a variety of matching conditions, such as the user agent: - - get '/foo', :agent => /Songbird (\d\.\d)[\d\/]*?/ do - "You're using Songbird version #{params[:agent][0]}" - end - - get '/foo' do - # Matches non-songbird browsers - end - -== Static Files - -Static files are served from the ./public directory. You can specify -a different location by setting the :public option: - - set :public, File.dirname(__FILE__) + '/static' - -Note that the public directory name is not included in the URL. A file -./public/css/style.css is made available as -http://example.com/css/style.css. - -== Views / Templates - -Templates are assumed to be located directly under the ./views -directory. To use a different views directory: - - set :views, File.dirname(__FILE__) + '/templates' - -One important thing to remember is that you always have to reference -templates with symbols, even if they're in a subdirectory (in this -case use :'subdir/template'). Rendering methods will render -any strings passed to them directly. - -=== Haml Templates - -The haml gem/library is required to render HAML templates: - - ## You'll need to require haml in your app - require 'haml' - - get '/' do - haml :index - end - -Renders ./views/index.haml. - -{Haml's options}[http://haml.hamptoncatlin.com/docs/rdoc/classes/Haml.html] -can be set globally through Sinatra's configurations, -see {Options and Configurations}[http://www.sinatrarb.com/configuration.html], -and overridden on an individual basis. - - set :haml, {:format => :html5 } # default Haml format is :xhtml - - get '/' do - haml :index, :haml_options => {:format => :html4 } # overridden - end - - -=== Erb Templates - - ## You'll need to require erb in your app - require 'erb' - - get '/' do - erb :index - end - -Renders ./views/index.erb - -=== Builder Templates - -The builder gem/library is required to render builder templates: - - ## You'll need to require builder in your app - require 'builder' - - get '/' do - content_type 'application/xml', :charset => 'utf-8' - builder :index - end - -Renders ./views/index.builder. - -=== Sass Templates - -The sass gem/library is required to render Sass templates: - - ## You'll need to require haml or sass in your app - require 'sass' - - get '/stylesheet.css' do - content_type 'text/css', :charset => 'utf-8' - sass :stylesheet - end - -Renders ./views/stylesheet.sass. - -{Sass' options}[http://haml.hamptoncatlin.com/docs/rdoc/classes/Sass.html] -can be set globally through Sinatra's configurations, -see {Options and Configurations}[http://www.sinatrarb.com/configuration.html], -and overridden on an individual basis. - - set :sass, {:style => :compact } # default Sass style is :nested - - get '/stylesheet.css' do - content_type 'text/css', :charset => 'utf-8' - sass :stylesheet, :sass_options => {:style => :expanded } # overridden - end - - -=== Inline Templates - - get '/' do - haml '%div.title Hello World' - end - -Renders the inlined template string. - -=== Accessing Variables in Templates - -Templates are evaluated within the same context as route handlers. Instance -variables set in route handlers are direcly accessible by templates: - - get '/:id' do - @foo = Foo.find(params[:id]) - haml '%h1= @foo.name' - end - -Or, specify an explicit Hash of local variables: - - get '/:id' do - foo = Foo.find(params[:id]) - haml '%h1= foo.name', :locals => { :foo => foo } - end - -This is typically used when rendering templates as partials from within -other templates. - -=== In-file Templates - -Templates may be defined at the end of the source file: - - require 'rubygems' - require 'sinatra' - - get '/' do - haml :index - end - - __END__ - - @@ layout - %html - = yield - - @@ index - %div.title Hello world!!!!! - -NOTE: In-file templates defined in the source file that requires sinatra -are automatically loaded. Call the use_in_file_templates! -method explicitly if you have in-file templates in other source files. - -=== Named Templates - -Templates may also be defined using the top-level template method: - - template :layout do - "%html\n =yield\n" - end - - template :index do - '%div.title Hello World!' - end - - get '/' do - haml :index - end - -If a template named "layout" exists, it will be used each time a template -is rendered. You can disable layouts by passing :layout => false. - - get '/' do - haml :index, :layout => !request.xhr? - end - -== Helpers - -Use the top-level helpers method to define helper methods for use in -route handlers and templates: - - helpers do - def bar(name) - "#{name}bar" - end - end - - get '/:name' do - bar(params[:name]) - end - -== Filters - -Before filters are evaluated before each request within the context of the -request and can modify the request and response. Instance variables set in -filters are accessible by routes and templates: - - before do - @note = 'Hi!' - request.path_info = '/foo/bar/baz' - end - - get '/foo/*' do - @note #=> 'Hi!' - params[:splat] #=> 'bar/baz' - end - -After filter are evaluated after each request within the context of the -request and can also modify the request and response. Instance variables -set in before filters and routes are accessible by after filters: - - after do - puts response.status - end - -== Halting - -To immediately stop a request within a filter or route use: - - halt - -You can also specify a body when halting ... - - halt 'this will be the body' - -Or set the status and body ... - - halt 401, 'go away!' - -== Passing - -A route can punt processing to the next matching route using pass: - - get '/guess/:who' do - pass unless params[:who] == 'Frank' - "You got me!" - end - - get '/guess/*' do - "You missed!" - end - -The route block is immediately exited and control continues with the next -matching route. If no matching route is found, a 404 is returned. - -== Configuration - -Run once, at startup, in any environment: - - configure do - ... - end - -Run only when the environment (RACK_ENV environment variable) is set to -:production: - - configure :production do - ... - end - -Run when the environment is set to either :production or -:test: - - configure :production, :test do - ... - end - -== Error handling - -Error handlers run within the same context as routes and before filters, which -means you get all the goodies it has to offer, like haml, erb, -halt, etc. - -=== Not Found - -When a Sinatra::NotFound exception is raised, or the response's status -code is 404, the not_found handler is invoked: - - not_found do - 'This is nowhere to be found' - end - -=== Error - -The +error+ handler is invoked any time an exception is raised from a route -block or before filter. The exception object can be obtained from the -sinatra.error Rack variable: - - error do - 'Sorry there was a nasty error - ' + env['sinatra.error'].name - end - -Custom errors: - - error MyCustomError do - 'So what happened was...' + request.env['sinatra.error'].message - end - -Then, if this happens: - - get '/' do - raise MyCustomError, 'something bad' - end - -You get this: - - So what happened was... something bad - -Sinatra installs special not_found and error handlers when -running under the development environment. - -== Mime types - -When using send_file or static files you may have mime types Sinatra -doesn't understand. Use +mime_type+ to register them by file extension: - - mime_type :foo, 'text/foo' - -== Rack Middleware - -Sinatra rides on Rack[http://rack.rubyforge.org/], a minimal standard -interface for Ruby web frameworks. One of Rack's most interesting capabilities -for application developers is support for "middleware" -- components that sit -between the server and your application monitoring and/or manipulating the -HTTP request/response to provide various types of common functionality. - -Sinatra makes building Rack middleware pipelines a cinch via a top-level -+use+ method: - - require 'sinatra' - require 'my_custom_middleware' - - use Rack::Lint - use MyCustomMiddleware - - get '/hello' do - 'Hello World' - end - -The semantics of +use+ are identical to those defined for the -Rack::Builder[http://rack.rubyforge.org/doc/classes/Rack/Builder.html] DSL -(most frequently used from rackup files). For example, the +use+ method -accepts multiple/variable args as well as blocks: - - use Rack::Auth::Basic do |username, password| - username == 'admin' && password == 'secret' - end - -Rack is distributed with a variety of standard middleware for logging, -debugging, URL routing, authentication, and session handling. Sinatra uses -many of of these components automatically based on configuration so you -typically don't have to +use+ them explicitly. - -== Testing - -Sinatra tests can be written using any Rack-based testing library -or framework. {Rack::Test}[http://gitrdoc.com/brynary/rack-test] is -recommended: - - require 'my_sinatra_app' - require 'rack/test' - - class MyAppTest < Test::Unit::TestCase - include Rack::Test::Methods - - def app - Sinatra::Application - end - - def test_my_default - get '/' - assert_equal 'Hello World!', last_response.body - end - - def test_with_params - get '/meet', :name => 'Frank' - assert_equal 'Hello Frank!', last_response.body - end - - def test_with_rack_env - get '/', {}, 'HTTP_USER_AGENT' => 'Songbird' - assert_equal "You're using Songbird!", last_response.body - end - end - -NOTE: The built-in Sinatra::Test module and Sinatra::TestHarness class -are deprecated as of the 0.9.2 release. - -== Sinatra::Base - Middleware, Libraries, and Modular Apps - -Defining your app at the top-level works well for micro-apps but has -considerable drawbacks when building reuseable components such as Rack -middleware, Rails metal, simple libraries with a server component, or -even Sinatra extensions. The top-level DSL pollutes the Object namespace -and assumes a micro-app style configuration (e.g., a single application -file, ./public and ./views directories, logging, exception detail page, -etc.). That's where Sinatra::Base comes into play: - - require 'sinatra/base' - - class MyApp < Sinatra::Base - set :sessions, true - set :foo, 'bar' - - get '/' do - 'Hello world!' - end - end - -The MyApp class is an independent Rack component that can act as -Rack middleware, a Rack application, or Rails metal. You can +use+ or -+run+ this class from a rackup +config.ru+ file; or, control a server -component shipped as a library: - - MyApp.run! :host => 'localhost', :port => 9090 - -The methods available to Sinatra::Base subclasses are exactly as those -available via the top-level DSL. Most top-level apps can be converted to -Sinatra::Base components with two modifications: - -* Your file should require +sinatra/base+ instead of +sinatra+; - otherwise, all of Sinatra's DSL methods are imported into the main - namespace. -* Put your app's routes, error handlers, filters, and options in a subclass - of Sinatra::Base. - -+Sinatra::Base+ is a blank slate. Most options are disabled by default, -including the built-in server. See {Options and Configuration}[http://sinatra.github.com/configuration.html] -for details on available options and their behavior. - -SIDEBAR: Sinatra's top-level DSL is implemented using a simple delegation -system. The +Sinatra::Application+ class -- a special subclass of -Sinatra::Base -- receives all :get, :put, :post, :delete, :before, -:error, :not_found, :configure, and :set messages sent to the -top-level. Have a look at the code for yourself: here's the -{Sinatra::Delegator mixin}[http://github.com/sinatra/sinatra/blob/master/lib/sinatra/base.rb#L1064] -being {included into the main namespace}[http://github.com/sinatra/sinatra/blob/master/lib/sinatra/main.rb#L25]. - -== Command line - -Sinatra applications can be run directly: - - ruby myapp.rb [-h] [-x] [-e ENVIRONMENT] [-p PORT] [-s HANDLER] - -Options are: - - -h # help - -p # set the port (default is 4567) - -e # set the environment (default is development) - -s # specify rack server/handler (default is thin) - -x # turn on the mutex lock (default is off) - -== The Bleeding Edge - -If you would like to use Sinatra's latest bleeding code, create a local -clone and run your app with the sinatra/lib directory on the -LOAD_PATH: - - cd myapp - git clone git://github.com/sinatra/sinatra.git - ruby -Isinatra/lib myapp.rb - -Alternatively, you can add the sinatra/lib directory to the -LOAD_PATH in your application: - - $LOAD_PATH.unshift File.dirname(__FILE__) + '/sinatra/lib' - require 'rubygems' - require 'sinatra' - - get '/about' do - "I'm running version " + Sinatra::VERSION - end - -To update the Sinatra sources in the future: - - cd myproject/sinatra - git pull - -== More - -* {Project Website}[http://sinatra.github.com/] - Additional documentation, - news, and links to other resources. -* {Contributing}[http://sinatra.github.com/contributing.html] - Find a bug? Need - help? Have a patch? -* {Lighthouse}[http://sinatra.lighthouseapp.com] - Issue tracking and release - planning. -* {Twitter}[http://twitter.com/sinatra] -* {Mailing List}[http://groups.google.com/group/sinatrarb] -* {IRC: #sinatra}[irc://chat.freenode.net/#sinatra] on http://freenode.net diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000000..d020247d31 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,68 @@ +# Releasing Sinatra 🥂 + +This document explains releasing process for all Sinatra gems. + +Since everything is bundled in same repo (except `Mustermann`), we now +have one rake task to cut a release. + +(Please refer to [Mustermann](https://github.com/sinatra/mustermann) if that also needs a release.) + +### Releasing +For releasing new version of [`sinatra`, `sinatra-contrib`, `rack-protection`], this is the procedure: + +1. Update `VERSION` file with target version +2. Run `bundle exec rake release:all` +3. ??? +4. Profit!!! + +Thats it! + +This rake task can be broken down as: + +* Pick up latest version string from `VERSION` file +* Run all tests to ensure gems are not broken +* Update `version.rb` file in all gems with latest `VERSION` +* Create a new commit with new `VERSION` and `version.rb` files +* Tag the commit with same version +* Push the commit and tags to github +* Package all the gems, ie create all `.gem` files +* Ensure that all the gems can be installed locally +* If no issues, push all gems to upstream. + +In addition to above rake task, there are other rake tasks which can help +with development. + +### Packaging +These rake tasks will generate `.gem` and `.tar.gz` files. For each gem, +there is one dedicated rake task. + +```sh +# Build sinatra-contrib package +$ bundle exec rake package:sinatra-contrib + +# Build rack-protection package +$ bundle exec rake package:rack-protection + +# Build sinatra package +$ bundle exec rake package:sinatra + +# Build all packages +$ bundle exec rake package:all +``` + +### Packaging and installing locally +These rake tasks will package all the gems, and install them locally + +```sh +# Build and install sinatra-contrib gem locally +$ bundle exec rake install:sinatra-contrib + +# Build and install rack-protection gem locally +$ bundle exec rake install:rack-protection + +# Build and install sinatra gem locally +$ bundle exec rake install:sinatra + +# Build and install all gems locally +$ bundle exec rake install:all +``` diff --git a/Rakefile b/Rakefile index 5bb0cafae9..732cfc84bd 100644 --- a/Rakefile +++ b/Rakefile @@ -1,129 +1,216 @@ +# frozen_string_literal: true + require 'rake/clean' -require 'rake/testtask' +require 'minitest/test_task' require 'fileutils' +require 'date' -task :default => :test -task :spec => :test +task default: :test -# SPECS =============================================================== +def source_version + @source_version ||= File.read(File.expand_path('VERSION', __dir__)).strip +end -Rake::TestTask.new(:test) do |t| - t.test_files = FileList['test/*_test.rb'] - t.ruby_opts = ['-rubygems'] if defined? Gem +def prev_feature + source_version.gsub(/^(\d\.)(\d+)\..*$/) { $1 + ($2.to_i - 1).to_s } end -# PACKAGING ============================================================ +def prev_version + return "#{prev_feature}.0" if source_version.end_with? '.0' -# Load the gemspec using the same limitations as github -def spec - @spec ||= - begin - require 'rubygems/specification' - data = File.read('sinatra.gemspec') - spec = nil - Thread.new { spec = eval("$SAFE = 3\n#{data}") }.join - spec - end + source_version.gsub(/\d+$/) { |s| s.to_i - 1 } end -def package(ext='') - "pkg/sinatra-#{spec.version}" + ext +# Tests =============================================================== + +Minitest::TestTask.create # Default `test` task +Minitest::TestTask.create(:'test:core') do |t| + t.warning = true + t.test_globs = %w[ + base delegator encoding extensions filter + helpers mapped_error middleware rdoc + readme request response result route_added_hook + routing server settings sinatra static templates + ].map { |n| "test/#{n}_test.rb" } end -desc 'Build packages' -task :package => %w[.gem .tar.gz].map {|e| package(e)} +# Test code coverage ================================================== -desc 'Build and install as local gem' -task :install => package('.gem') do - sh "gem install #{package('.gem')}" +namespace :test do + desc 'Measures test coverage' + task :coverage do + rm_f 'coverage' + ENV['COVERAGE'] = '1' + Rake::Task['test'].invoke + end end +CLEAN.include('coverage') -directory 'pkg/' -CLOBBER.include('pkg') +# Website ============================================================= -file package('.gem') => %w[pkg/ sinatra.gemspec] + spec.files do |f| - sh "gem build sinatra.gemspec" - mv File.basename(f.name), f.name -end +desc 'Generate RDoc under doc/api' +task 'doc' => ['doc:api'] +task('doc:api') { sh 'yardoc -o doc/api' } +CLEAN.include 'doc/api' -file package('.tar.gz') => %w[pkg/] + spec.files do |f| - sh <<-SH - git archive \ - --prefix=sinatra-#{source_version}/ \ - --format=tar \ - HEAD | gzip > #{f.name} - SH +# README =============================================================== + +task :add_template, [:name] do |_t, args| + Dir.glob('README.*') do |file| + code = File.read(file) + if code =~ /^===.*#{args.name.capitalize}/ + puts "Already covered in #{file}" + else + template = code[%r{===[^\n]*Liquid.*index\.liquid[^\n]*}m] + if template + puts "Adding section to #{file}" + template = template.gsub(/Liquid/, args.name.capitalize).gsub(/liquid/, args.name.downcase) + code.gsub! /^(\s*===.*CoffeeScript)/, "\n" << template << "\n\\1" + File.open(file, 'w') { |f| f << code } + else + puts "Liquid not found in #{file}" + end + end + end end -# Rubyforge Release / Publish Tasks ================================== +# Thanks in announcement =============================================== -desc 'Publish gem and tarball to rubyforge' -task 'release' => [package('.gem'), package('.tar.gz')] do |t| - sh <<-end - rubyforge add_release sinatra sinatra #{spec.version} #{package('.gem')} && - rubyforge add_file sinatra sinatra #{spec.version} #{package('.tar.gz')} - end +team = ['Ryan Tomayko', 'Blake Mizerany', 'Simon Rozet', 'Konstantin Haase', 'Zachary Scott'] +desc 'list of contributors' +task :thanks, ['release:all', :backports] do |_t, a| + a.with_defaults release: "#{prev_version}..HEAD", + backports: "#{prev_feature}.0..#{prev_feature}.x" + + included = `git log --format=format:"%aN\t%s" #{a.release}`.lines.map { |l| l.force_encoding('binary') } + excluded = `git log --format=format:"%aN\t%s" #{a.backports}`.lines.map { |l| l.force_encoding('binary') } + commits = (included - excluded).group_by { |c| c[/^[^\t]+/] } + authors = commits.keys.sort_by { |n| - commits[n].size } - team + puts authors[0..-2].join(', ') << ' and ' << authors.last, + "(based on commits included in #{a.release}, but not in #{a.backports})" end -# Website ============================================================ -# Building docs requires HAML and the hanna gem: -# gem install mislav-hanna --source=http://gems.github.com - -task 'doc' => ['doc:api'] - -desc 'Generate Hanna RDoc under doc/api' -task 'doc:api' => ['doc/api/index.html'] - -file 'doc/api/index.html' => FileList['lib/**/*.rb','README.rdoc'] do |f| - rb_files = f.prerequisites - sh((<<-end).gsub(/\s+/, ' ')) - hanna --charset utf8 \ - --fmt html \ - --inline-source \ - --line-numbers \ - --main README.rdoc \ - --op doc/api \ - --title 'Sinatra API Documentation' \ - #{rb_files.join(' ')} +desc 'list of authors' +task :authors, [:commit_range, :format, :sep] do |_t, a| + a.with_defaults format: '%s (%d)', sep: ', ', commit_range: '--all' + authors = Hash.new(0) + blake = 'Blake Mizerany' + overall = 0 + mapping = { + 'blake.mizerany@gmail.com' => blake, 'bmizerany' => blake, + 'a_user@mac.com' => blake, 'ichverstehe' => 'Harry Vangberg', + 'Wu Jiang (nouse)' => 'Wu Jiang' + } + `git shortlog -s #{a.commit_range}`.lines.map do |line| + line = line.force_encoding 'binary' if line.respond_to? :force_encoding + num, name = line.split("\t", 2).map(&:strip) + authors[mapping[name] || name] += num.to_i + overall += num.to_i end + puts "#{overall} commits by #{authors.count} authors:" + puts authors.sort_by { |_n, c| -c }.map { |e| a.format % e }.join(a.sep) end -CLEAN.include 'doc/api' -# Gemspec Helpers ==================================================== +desc 'generates TOC' +task :toc, [:readme] do |_t, a| + a.with_defaults readme: 'README.md' -def source_version - line = File.read('lib/sinatra/base.rb')[/^\s*VERSION = .*/] - line.match(/.*VERSION = '(.*)'/)[1] -end + def self.link(title) + title.downcase.gsub(/(?!-)\W /, '-').gsub(' ', '-').gsub(/(?!-)\W/, '') + end -task 'sinatra.gemspec' => FileList['{lib,test,compat}/**','Rakefile','CHANGES','*.rdoc'] do |f| - # read spec file and split out manifest section - spec = File.read(f.name) - head, manifest, tail = spec.split(" # = MANIFEST =\n") - # replace version and date - head.sub!(/\.version = '.*'/, ".version = '#{source_version}'") - head.sub!(/\.date = '.*'/, ".date = '#{Date.today.to_s}'") - # determine file list from git ls-files - files = `git ls-files`. - split("\n"). - sort. - reject{ |file| file =~ /^\./ }. - reject { |file| file =~ /^doc/ }. - map{ |file| " #{file}" }. - join("\n") - # piece file back together and write... - manifest = " s.files = %w[\n#{files}\n ]\n" - spec = [head,manifest,tail].join(" # = MANIFEST =\n") - File.open(f.name, 'w') { |io| io.write(spec) } - puts "updated #{f.name}" + puts '* [Sinatra](#sinatra)' + title = Regexp.new('(?<=\* )(.*)') # so Ruby 1.8 doesn't complain + File.binread(a.readme).scan(/^##.*/) do |line| + puts line.gsub(/#(?=#)/, ' ').gsub('#', '*').gsub(title) { "[#{$1}](##{link($1)})" } + end end -# Rcov ============================================================== -namespace :test do - desc 'Mesures test coverage' - task :coverage do - rm_f "coverage" - rcov = "rcov --text-summary --test-unit-only -Ilib" - system("#{rcov} --no-html --no-color test/*_test.rb") +# PACKAGING ============================================================ + +if defined?(Gem) + GEMS_AND_ROOT_DIRECTORIES = { + 'sinatra' => '.', + 'sinatra-contrib' => './sinatra-contrib', + 'rack-protection' => './rack-protection' + }.freeze + + def package(gem, ext = '') + "pkg/#{gem}-#{source_version}" + ext + end + + directory 'pkg/' + CLOBBER.include('pkg') + + GEMS_AND_ROOT_DIRECTORIES.each do |gem, directory| + file package(gem, '.gem') => ['pkg/', "#{"#{directory}/#{gem}"}.gemspec"] do |f| + sh "cd #{directory} && gem build #{gem}.gemspec" + mv "#{directory}/#{File.basename(f.name)}", f.name + end + + file package(gem, '.tar.gz') => ['pkg/'] do |f| + sh <<-SH + git archive \ + --prefix=#{gem}-#{source_version}/ \ + --format=tar \ + HEAD -- #{directory} | gzip > #{f.name} + SH + end + end + + namespace :package do + GEMS_AND_ROOT_DIRECTORIES.each do |gem, _directory| + desc "Build #{gem} packages" + task gem => %w[.gem .tar.gz].map { |e| package(gem, e) } + end + + desc 'Build all packages' + task all: GEMS_AND_ROOT_DIRECTORIES.keys + end + + namespace :install do + GEMS_AND_ROOT_DIRECTORIES.each do |gem, _directory| + desc "Build and install #{gem} as local gem" + task gem => package(gem, '.gem') do + sh "gem install #{package(gem, '.gem')}" + end + end + + desc 'Build and install all of the gems as local gems' + task all: GEMS_AND_ROOT_DIRECTORIES.keys + end + + namespace :release do + GEMS_AND_ROOT_DIRECTORIES.each do |gem, _directory| + desc "Release #{gem} as a package" + task gem => "package:#{gem}" do + sh <<-SH + gem install #{package(gem, '.gem')} --local && + gem push #{package(gem, '.gem')} + SH + end + end + + desc 'Commits the version to github repository' + task :commit_version do + %w[ + lib/sinatra + sinatra-contrib/lib/sinatra/contrib + rack-protection/lib/rack/protection + ].each do |path| + path = File.join(path, 'version.rb') + File.write(path, File.read(path).sub(/VERSION = '(.+?)'/, "VERSION = '#{source_version}'")) + end + + sh <<-SH + git commit --allow-empty -a -m '#{source_version} release' && + git tag -s v#{source_version} -m '#{source_version} release' && + git push && (git push origin || true) && + git push --tags && (git push origin --tags || true) + SH + end + + desc 'Release all gems as packages' + task all: %i[test commit_version] + GEMS_AND_ROOT_DIRECTORIES.keys end end diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000000..e5affc29f2 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,35 @@ +# Reporting a security bug + +All security bugs in Sinatra should be reported to the core team through our private mailing list [sinatra-security@googlegroups.com](https://groups.google.com/group/sinatra-security). Your report will be acknowledged within 24 hours, and you’ll receive a more detailed response to your email within 48 hours indicating the next steps in handling your report. + +After the initial reply to your report the security team will endeavor to keep you informed of the progress being made towards a fix and full announcement. These updates will be sent at least every five days, in reality this is more likely to be every 24-48 hours. + +If you have not received a reply to your email within 48 hours, or have not heard from the security team for the past five days there are a few steps you can take: + +* Contact the current security coordinator [Zachary Scott](mailto:zzak@ruby-lang.org) directly + +## Disclosure Policy + +Sinatra has a 5 step disclosure policy, that is upheld to the best of our ability. + +1. Security report received and is assigned a primary handler. This person will coordinate the fix and release process. +2. Problem is confirmed and, a list of all affected versions is determined. Code is audited to find any potential similar problems. +3. Fixes are prepared for all releases which are still supported. These fixes are not committed to the public repository but rather held locally pending the announcement. +4. A suggested embargo date for this vulnerability is chosen and distros@openwall is notified. This notification will include patches for all versions still under support and a contact address for packagers who need advice back-porting patches to older versions. +5. On the embargo date, the [mailing list][mailing-list] and [security list][security-list] are sent a copy of the announcement. The changes are pushed to the public repository and new gems released to rubygems. + +Typically the embargo date will be set 72 hours from the time vendor-sec is first notified, however this may vary depending on the severity of the bug or difficulty in applying a fix. + +This process can take some time, especially when coordination is required with maintainers of other projects. Every effort will be made to handle the bug in as timely a manner as possible, however it’s important that we follow the release process above to ensure that the disclosure is handled in a consistent manner. + +## Security Updates + +Security updates will be posted on the [mailing list][mailing-list] and [security list][security-list]. + +## Comments on this Policy + +If you have any suggestions to improve this policy, please send an email the core team at [sinatrarb@googlegroups.com](https://groups.google.com/group/sinatrarb). + + +[mailing-list]: http://groups.google.com/group/sinatrarb/topics +[security-list]: http://groups.google.com/group/sinatra-security/topics diff --git a/VERSION b/VERSION new file mode 100644 index 0000000000..eca690e737 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +3.0.5 diff --git a/examples/chat.rb b/examples/chat.rb new file mode 100755 index 0000000000..afba7a3e98 --- /dev/null +++ b/examples/chat.rb @@ -0,0 +1,75 @@ +#!/usr/bin/env ruby -I ../lib -I lib +# frozen_string_literal: true + +# This example does *not* work properly with WEBrick or other +# servers that buffer output. To shut down the server, close any +# open browser tabs that are connected to the chat server. + +require 'sinatra' +set :server, :puma +connections = Set.new + +get '/' do + halt erb(:login) unless params[:user] + erb :chat, locals: { user: params[:user].gsub(/\W/, '') } +end + +get '/stream', provides: 'text/event-stream' do + stream :keep_open do |out| + if connections.add?(out) + out.callback { connections.delete(out) } + end + out << "heartbeat:\n" + sleep 1 + rescue + out.close + end +end + +post '/' do + connections.each do |out| + out << "data: #{params[:msg]}\n\n" + rescue + out.close + end + 204 # response without entity body +end + +__END__ + +@@ layout + + + Super Simple Chat with Sinatra + + + + <%= yield %> + + +@@ login +
+ + + +
+ +@@ chat +

+
+ +
+ + + diff --git a/examples/rainbows.conf b/examples/rainbows.conf new file mode 100644 index 0000000000..31742e961b --- /dev/null +++ b/examples/rainbows.conf @@ -0,0 +1,3 @@ +Rainbows! do + use :EventMachine +end diff --git a/examples/rainbows.rb b/examples/rainbows.rb new file mode 100644 index 0000000000..4dab26444e --- /dev/null +++ b/examples/rainbows.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require 'rainbows' + +module Rack + module Handler + class Rainbows + def self.run(app, **options) + rainbows_options = { + listeners: ["#{options[:Host]}:#{options[:Port]}"], + worker_processes: 1, + timeout: 30, + config_file: ::File.expand_path('rainbows.conf', __dir__) + } + + ::Rainbows::HttpServer.new(app, rainbows_options).start.join + end + end + + register :rainbows, ::Rack::Handler::Rainbows + end +end diff --git a/examples/simple.rb b/examples/simple.rb new file mode 100755 index 0000000000..31b3fcf96a --- /dev/null +++ b/examples/simple.rb @@ -0,0 +1,5 @@ +#!/usr/bin/env ruby -I ../lib -I lib +# frozen_string_literal: true + +require 'sinatra' +get('/') { 'this is a simple app' } diff --git a/examples/stream.ru b/examples/stream.ru new file mode 100644 index 0000000000..ee1615d65e --- /dev/null +++ b/examples/stream.ru @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +# this example does *not* work properly with WEBrick +# +# run *one* of these: +# +# rackup -s mongrel stream.ru # gem install mongrel +# unicorn stream.ru # gem install unicorn +# puma stream.ru # gem install puma +# rainbows -c rainbows.conf stream.ru # gem install rainbows eventmachine + +require 'sinatra/base' + +class Stream < Sinatra::Base + get '/' do + content_type :txt + + stream do |out| + out << "It's gonna be legen -\n" + sleep 0.5 + out << " (wait for it) \n" + sleep 1 + out << "- dary!\n" + end + end +end + +run Stream diff --git a/lib/sinatra.rb b/lib/sinatra.rb index ce3ad6a464..fabdc41b57 100644 --- a/lib/sinatra.rb +++ b/lib/sinatra.rb @@ -1,7 +1,5 @@ -libdir = File.dirname(__FILE__) -$LOAD_PATH.unshift(libdir) unless $LOAD_PATH.include?(libdir) +# frozen_string_literal: true -require 'sinatra/base' require 'sinatra/main' -use_in_file_templates! +enable :inline_templates diff --git a/lib/sinatra/base.rb b/lib/sinatra/base.rb index 79d41a7396..c3ebf61924 100644 --- a/lib/sinatra/base.rb +++ b/lib/sinatra/base.rb @@ -1,117 +1,370 @@ -require 'thread' +# frozen_string_literal: true + +# external dependencies +require 'rack' +require 'tilt' +require 'rack/protection' +require 'mustermann' +require 'mustermann/sinatra' +require 'mustermann/regular' + +# stdlib dependencies require 'time' require 'uri' -require 'rack' -require 'rack/builder' -require 'sinatra/showexceptions' - -# require tilt if available; fall back on bundled version. -begin - require 'tilt' -rescue LoadError - require 'sinatra/tilt' -end -module Sinatra - VERSION = '0.10.1' +# other files we need +require 'sinatra/indifferent_hash' +require 'sinatra/show_exceptions' +require 'sinatra/version' +module Sinatra # The request object. See Rack::Request for more info: - # http://rack.rubyforge.org/doc/classes/Rack/Request.html + # https://rubydoc.info/github/rack/rack/main/Rack/Request class Request < Rack::Request - def user_agent - @env['HTTP_USER_AGENT'] - end + HEADER_PARAM = /\s*[\w.]+=(?:[\w.]+|"(?:[^"\\]|\\.)*")?\s*/.freeze + HEADER_VALUE_WITH_PARAMS = %r{(?:(?:\w+|\*)/(?:\w+(?:\.|-|\+)?|\*)*)\s*(?:;#{HEADER_PARAM})*}.freeze # Returns an array of acceptable media types for the response def accept - @env['HTTP_ACCEPT'].to_s.split(',').map { |a| a.strip } + @env['sinatra.accept'] ||= if @env.include?('HTTP_ACCEPT') && (@env['HTTP_ACCEPT'].to_s != '') + @env['HTTP_ACCEPT'] + .to_s + .scan(HEADER_VALUE_WITH_PARAMS) + .map! { |e| AcceptEntry.new(e) } + .sort + else + [AcceptEntry.new('*/*')] + end + end + + def accept?(type) + preferred_type(type).to_s.include?(type) + end + + def preferred_type(*types) + return accept.first if types.empty? + + types.flatten! + return types.first if accept.empty? + + accept.detect do |accept_header| + type = types.detect { |t| MimeTypeEntry.new(t).accepts?(accept_header) } + return type if type + end + end + + alias secure? ssl? + + def forwarded? + @env.include? 'HTTP_X_FORWARDED_HOST' + end + + def safe? + get? || head? || options? || trace? + end + + def idempotent? + safe? || put? || delete? || link? || unlink? + end + + def link? + request_method == 'LINK' + end + + def unlink? + request_method == 'UNLINK' end - # Override Rack 0.9.x's #params implementation (see #72 in lighthouse) def params - self.GET.update(self.POST) - rescue EOFError, Errno::ESPIPE - self.GET + super + rescue Rack::Utils::ParameterTypeError, Rack::Utils::InvalidParameterError => e + raise BadRequest, "Invalid query parameters: #{Rack::Utils.escape_html(e.message)}" + rescue EOFError => e + raise BadRequest, "Invalid multipart/form-data: #{Rack::Utils.escape_html(e.message)}" + end + + class AcceptEntry + attr_accessor :params + attr_reader :entry + + def initialize(entry) + params = entry.scan(HEADER_PARAM).map! do |s| + key, value = s.strip.split('=', 2) + value = value[1..-2].gsub(/\\(.)/, '\1') if value.start_with?('"') + [key, value] + end + + @entry = entry + @type = entry[/[^;]+/].delete(' ') + @params = params.to_h + @q = @params.delete('q') { 1.0 }.to_f + end + + def <=>(other) + other.priority <=> priority + end + + def priority + # We sort in descending order; better matches should be higher. + [@q, -@type.count('*'), @params.size] + end + + def to_str + @type + end + + def to_s(full = false) + full ? entry : to_str + end + + def respond_to?(*args) + super || to_str.respond_to?(*args) + end + + def method_missing(*args, &block) + to_str.send(*args, &block) + end end - def secure? - (@env['HTTP_X_FORWARDED_PROTO'] || @env['rack.url_scheme']) == 'https' + class MimeTypeEntry + attr_reader :params + + def initialize(entry) + params = entry.scan(HEADER_PARAM).map! do |s| + key, value = s.strip.split('=', 2) + value = value[1..-2].gsub(/\\(.)/, '\1') if value.start_with?('"') + [key, value] + end + + @type = entry[/[^;]+/].delete(' ') + @params = params.to_h + end + + def accepts?(entry) + File.fnmatch(entry, self) && matches_params?(entry.params) + end + + def to_str + @type + end + + def matches_params?(params) + return true if @params.empty? + + params.all? { |k, v| !@params.key?(k) || @params[k] == v } + end end end - # The response object. See Rack::Response and Rack::ResponseHelpers for + # The response object. See Rack::Response and Rack::Response::Helpers for # more info: - # http://rack.rubyforge.org/doc/classes/Rack/Response.html - # http://rack.rubyforge.org/doc/classes/Rack/Response/Helpers.html + # https://rubydoc.info/github/rack/rack/main/Rack/Response + # https://rubydoc.info/github/rack/rack/main/Rack/Response/Helpers class Response < Rack::Response + DROP_BODY_RESPONSES = [204, 304].freeze + + def body=(value) + value = value.body while Rack::Response === value + @body = String === value ? [value.to_str] : value + end + + def each + block_given? ? super : enum_for(:each) + end + def finish - @body = block if block_given? - if [204, 304].include?(status.to_i) - header.delete "Content-Type" - [status.to_i, header.to_hash, []] - else - body = @body || [] - body = [body] if body.respond_to? :to_str - if body.respond_to?(:to_ary) - header["Content-Length"] = body.to_ary. - inject(0) { |len, part| len + Rack::Utils.bytesize(part) }.to_s - end - [status.to_i, header.to_hash, body] + result = body + + if drop_content_info? + headers.delete 'Content-Length' + headers.delete 'Content-Type' + end + + if drop_body? + close + result = [] + end + + if calculate_content_length? + # if some other code has already set Content-Length, don't muck with it + # currently, this would be the static file-handler + headers['Content-Length'] = body.map(&:bytesize).reduce(0, :+).to_s + end + + [status, headers, result] + end + + private + + def calculate_content_length? + headers['Content-Type'] && !headers['Content-Length'] && (Array === body) + end + + def drop_content_info? + informational? || drop_body? + end + + def drop_body? + DROP_BODY_RESPONSES.include?(status) + end + end + + # Some Rack handlers (Rainbows!) implement an extended body object protocol, however, + # some middleware (namely Rack::Lint) will break it by not mirroring the methods in question. + # This middleware will detect an extended body object and will make sure it reaches the + # handler directly. We do this here, so our middleware and middleware set up by the app will + # still be able to run. + class ExtendedRack < Struct.new(:app) + def call(env) + result = app.call(env) + callback = env['async.callback'] + return result unless callback && async?(*result) + + after_response { callback.call result } + setup_close(env, *result) + throw :async + end + + private + + def setup_close(env, _status, _headers, body) + return unless body.respond_to?(:close) && env.include?('async.close') + + env['async.close'].callback { body.close } + env['async.close'].errback { body.close } + end + + def after_response(&block) + raise NotImplementedError, 'only supports EventMachine at the moment' unless defined? EventMachine + + EventMachine.next_tick(&block) + end + + def async?(status, _headers, body) + return true if status == -1 + + body.respond_to?(:callback) && body.respond_to?(:errback) + end + end + + # Behaves exactly like Rack::CommonLogger with the notable exception that it does nothing, + # if another CommonLogger is already in the middleware chain. + class CommonLogger < Rack::CommonLogger + def call(env) + env['sinatra.commonlogger'] ? @app.call(env) : super + end + + superclass.class_eval do + alias_method :call_without_check, :call unless method_defined? :call_without_check + def call(env) + env['sinatra.commonlogger'] = true + call_without_check(env) end end end - class NotFound < NameError #:nodoc: - def code ; 404 ; end + class Error < StandardError # :nodoc: + end + + class BadRequest < Error # :nodoc: + def http_status; 400 end + end + + class NotFound < Error # :nodoc: + def http_status; 404 end end # Methods available to routes, before/after filters, and views. module Helpers # Set or retrieve the response status code. - def status(value=nil) - response.status = value if value + def status(value = nil) + response.status = Rack::Utils.status_code(value) if value response.status end # Set or retrieve the response body. When a block is given, # evaluation is deferred until the body is read with #each. - def body(value=nil, &block) + def body(value = nil, &block) if block_given? - def block.each ; yield call ; end + def block.each; yield(call) end response.body = block - else + elsif value + # Rack 2.0 returns a Rack::File::Iterator here instead of + # Rack::File as it was in the previous API. + unless request.head? || value.is_a?(Rack::Files::Iterator) || value.is_a?(Stream) + headers.delete 'Content-Length' + end response.body = value + else + response.body end end # Halt processing and redirect to the URI provided. def redirect(uri, *args) - status 302 - response['Location'] = uri + if (env['HTTP_VERSION'] == 'HTTP/1.1') && (env['REQUEST_METHOD'] != 'GET') + status 303 + else + status 302 + end + + # According to RFC 2616 section 14.30, "the field value consists of a + # single absolute URI" + response['Location'] = uri(uri.to_s, settings.absolute_redirects?, settings.prefixed_redirects?) halt(*args) end + # Generates the absolute URI for a given path in the app. + # Takes Rack routers and reverse proxies into account. + def uri(addr = nil, absolute = true, add_script_name = true) + return addr if addr.to_s =~ /\A[a-z][a-z0-9+.\-]*:/i + + uri = [host = String.new] + if absolute + host << "http#{'s' if request.secure?}://" + host << if request.forwarded? || (request.port != (request.secure? ? 443 : 80)) + request.host_with_port + else + request.host + end + end + uri << request.script_name.to_s if add_script_name + uri << (addr || request.path_info).to_s + File.join uri + end + + alias url uri + alias to uri + # Halt processing and return the error status provided. - def error(code, body=nil) - code, body = 500, code.to_str if code.respond_to? :to_str + def error(code, body = nil) + if code.respond_to? :to_str + body = code.to_str + code = 500 + end response.body = body unless body.nil? halt code end # Halt processing and return a 404 Not Found. - def not_found(body=nil) + def not_found(body = nil) error 404, body end # Set multiple response headers with Hash. - def headers(hash=nil) + def headers(hash = nil) response.headers.merge! hash if hash response.headers end # Access the underlying Rack session. def session - env['rack.session'] ||= {} + request.session + end + + # Access shared logger object. + def logger + request.logger end # Look up a media type by file extension in Rack's mime registry. @@ -121,66 +374,155 @@ def mime_type(type) # Set the Content-Type of the response body given a media type or file # extension. - def content_type(type, params={}) - mime_type = self.mime_type(type) - fail "Unknown media type: %p" % type if mime_type.nil? - if params.any? - params = params.collect { |kv| "%s=%s" % kv }.join(', ') - response['Content-Type'] = [mime_type, params].join(";") - else - response['Content-Type'] = mime_type + def content_type(type = nil, params = {}) + return response['Content-Type'] unless type + + default = params.delete :default + mime_type = mime_type(type) || default + raise format('Unknown media type: %p', type) if mime_type.nil? + + mime_type = mime_type.dup + unless params.include?(:charset) || settings.add_charset.all? { |p| !(p === mime_type) } + params[:charset] = params.delete('charset') || settings.default_encoding end + params.delete :charset if mime_type.include? 'charset' + unless params.empty? + mime_type << (mime_type.include?(';') ? ', ' : ';') + mime_type << params.map do |key, val| + val = val.inspect if val =~ /[";,]/ + "#{key}=#{val}" + end.join(', ') + end + response['Content-Type'] = mime_type end + # https://html.spec.whatwg.org/#multipart-form-data + MULTIPART_FORM_DATA_REPLACEMENT_TABLE = { + '"' => '%22', + "\r" => '%0D', + "\n" => '%0A' + }.freeze + # Set the Content-Disposition to "attachment" with the specified filename, # instructing the user agents to prompt to save. - def attachment(filename=nil) - response['Content-Disposition'] = 'attachment' - if filename - params = '; filename="%s"' % File.basename(filename) - response['Content-Disposition'] << params - end + def attachment(filename = nil, disposition = :attachment) + response['Content-Disposition'] = disposition.to_s.dup + return unless filename + + params = format('; filename="%s"', File.basename(filename).gsub(/["\r\n]/, MULTIPART_FORM_DATA_REPLACEMENT_TABLE)) + response['Content-Disposition'] << params + ext = File.extname(filename) + content_type(ext) unless response['Content-Type'] || ext.empty? end # Use the contents of the file at +path+ as the response body. - def send_file(path, opts={}) - stat = File.stat(path) - last_modified stat.mtime + def send_file(path, opts = {}) + if opts[:type] || !response['Content-Type'] + content_type opts[:type] || File.extname(path), default: 'application/octet-stream' + end - content_type mime_type(opts[:type]) || - mime_type(File.extname(path)) || - response['Content-Type'] || - 'application/octet-stream' + disposition = opts[:disposition] + filename = opts[:filename] + disposition = :attachment if disposition.nil? && filename + filename = path if filename.nil? + attachment(filename, disposition) if disposition - response['Content-Length'] ||= (opts[:length] || stat.size).to_s + last_modified opts[:last_modified] if opts[:last_modified] - if opts[:disposition] == 'attachment' || opts[:filename] - attachment opts[:filename] || path - elsif opts[:disposition] == 'inline' - response['Content-Disposition'] = 'inline' - end + file = Rack::Files.new(File.dirname(settings.app_file)) + result = file.serving(request, path) - halt StaticFile.open(path, 'rb') + result[1].each { |k, v| headers[k] ||= v } + headers['Content-Length'] = result[1]['Content-Length'] + opts[:status] &&= Integer(opts[:status]) + halt (opts[:status] || result[0]), result[2] rescue Errno::ENOENT not_found end - # Rack response body used to deliver static files. The file contents are - # generated iteratively in 8K chunks. - class StaticFile < ::File #:nodoc: - alias_method :to_path, :path - def each - rewind - while buf = read(8192) - yield buf + # Class of the response body in case you use #stream. + # + # Three things really matter: The front and back block (back being the + # block generating content, front the one sending it to the client) and + # the scheduler, integrating with whatever concurrency feature the Rack + # handler is using. + # + # Scheduler has to respond to defer and schedule. + class Stream + def self.schedule(*) yield end + def self.defer(*) yield end + + def initialize(scheduler = self.class, keep_open = false, &back) + @back = back.to_proc + @scheduler = scheduler + @keep_open = keep_open + @callbacks = [] + @closed = false + end + + def close + return if closed? + + @closed = true + @scheduler.schedule { @callbacks.each { |c| c.call } } + end + + def each(&front) + @front = front + @scheduler.defer do + begin + @back.call(self) + rescue Exception => e + @scheduler.schedule { raise e } + ensure + close unless @keep_open + end end end + + def <<(data) + @scheduler.schedule { @front.call(data.to_s) } + self + end + + def callback(&block) + return yield if closed? + + @callbacks << block + end + + alias errback callback + + def closed? + @closed + end + end + + # Allows to start sending data to the client even though later parts of + # the response body have not yet been generated. + # + # The close parameter specifies whether Stream#close should be called + # after the block has been executed. This is only relevant for evented + # servers like Rainbows. + def stream(keep_open = false) + scheduler = env['async.callback'] ? EventMachine : Stream + current = @params.dup + stream = if scheduler == Stream && keep_open + Stream.new(scheduler, false) do |out| + until out.closed? + with_params(current) { yield(out) } + end + end + else + Stream.new(scheduler, keep_open) { |out| with_params(current) { yield(out) } } + end + body stream end # Specify response freshness policy for HTTP caches (Cache-Control header). # Any number of non-value directives (:public, :private, :no_cache, # :no_store, :must_revalidate, :proxy_revalidate) may be passed along with - # a Hash of value directives (:max_age, :min_stale, :s_max_age). + # a Hash of value directives (:max_age, :s_maxage). # # cache_control :public, :must_revalidate, :max_age => 60 # => Cache-Control: public, must-revalidate, max-age=60 @@ -188,16 +530,20 @@ def each # See RFC 2616 / 14.9 for more on standard cache control directives: # http://tools.ietf.org/html/rfc2616#section-14.9.1 def cache_control(*values) - if values.last.kind_of?(Hash) + if values.last.is_a?(Hash) hash = values.pop - hash.reject! { |k,v| v == false } - hash.reject! { |k,v| values << k if v == true } + hash.reject! { |_k, v| v == false } + hash.reject! { |k, v| values << k if v == true } else hash = {} end - values = values.map { |value| value.to_s.tr('_','-') } - hash.each { |k,v| values << [k.to_s.tr('_', '-'), v].join('=') } + values.map! { |value| value.to_s.tr('_', '-') } + hash.each do |key, value| + key = key.to_s.tr('_', '-') + value = value.to_i if %w[max-age s-maxage].include? key + values << "#{key}=#{value}" + end response['Cache-Control'] = values.join(', ') if values.any? end @@ -208,21 +554,21 @@ def cache_control(*values) # "values" arguments are passed to the #cache_control helper: # # expires 500, :public, :must_revalidate - # => Cache-Control: public, must-revalidate, max-age=60 + # => Cache-Control: public, must-revalidate, max-age=500 # => Expires: Mon, 08 Jun 2009 08:50:17 GMT # def expires(amount, *values) - values << {} unless values.last.kind_of?(Hash) + values << {} unless values.last.is_a?(Hash) - if amount.respond_to?(:to_time) - max_age = amount.to_time - Time.now - time = amount.to_time - else + if amount.is_a? Integer + time = Time.now + amount.to_i max_age = amount - time = Time.now + amount + else + time = time_for amount + max_age = time - Time.now end - values.last.merge!(:max_age => max_age) + values.last.merge!(max_age: max_age) { |_key, v1, v2| v1 || v2 } cache_control(*values) response['Expires'] = time.httpdate @@ -232,17 +578,31 @@ def expires(amount, *values) # and halt if conditional GET matches. The +time+ argument is a Time, # DateTime, or other object that responds to +to_time+. # - # When the current request includes an 'If-Modified-Since' header that - # matches the time specified, execution is immediately halted with a - # '304 Not Modified' response. + # When the current request includes an 'If-Modified-Since' header that is + # equal or later than the time specified, execution is immediately halted + # with a '304 Not Modified' response. def last_modified(time) - time = time.to_time if time.respond_to?(:to_time) - time = time.httpdate if time.respond_to?(:httpdate) - response['Last-Modified'] = time - halt 304 if time == request.env['HTTP_IF_MODIFIED_SINCE'] - time + return unless time + + time = time_for time + response['Last-Modified'] = time.httpdate + return if env['HTTP_IF_NONE_MATCH'] + + if (status == 200) && env['HTTP_IF_MODIFIED_SINCE'] + # compare based on seconds since epoch + since = Time.httpdate(env['HTTP_IF_MODIFIED_SINCE']).to_i + halt 304 if since >= time.to_i + end + + if (success? || (status == 412)) && env['HTTP_IF_UNMODIFIED_SINCE'] + # compare based on seconds since epoch + since = Time.httpdate(env['HTTP_IF_UNMODIFIED_SINCE']).to_i + halt 412 if since < time.to_i + end + rescue ArgumentError end + ETAG_KINDS = %i[strong weak].freeze # Set the response entity tag (HTTP 'ETag' header) and halt if conditional # GET matches. The +value+ argument is an identifier that uniquely # identifies the current version of the resource. The +kind+ argument @@ -252,22 +612,107 @@ def last_modified(time) # When the current request includes an 'If-None-Match' header with a # matching etag, execution is immediately halted. If the request method is # GET or HEAD, a '304 Not Modified' response is sent. - def etag(value, kind=:strong) - raise TypeError, ":strong or :weak expected" if ![:strong,:weak].include?(kind) - value = '"%s"' % value - value = 'W/' + value if kind == :weak + def etag(value, options = {}) + # Before touching this code, please double check RFC 2616 14.24 and 14.26. + options = { kind: options } unless Hash === options + kind = options[:kind] || :strong + new_resource = options.fetch(:new_resource) { request.post? } + + unless ETAG_KINDS.include?(kind) + raise ArgumentError, ':strong or :weak expected' + end + + value = format('"%s"', value) + value = "W/#{value}" if kind == :weak response['ETag'] = value - # Conditional GET check - if etags = env['HTTP_IF_NONE_MATCH'] - etags = etags.split(/\s*,\s*/) - halt 304 if etags.include?(value) || etags.include?('*') + return unless success? || status == 304 + + if etag_matches?(env['HTTP_IF_NONE_MATCH'], new_resource) + halt(request.safe? ? 304 : 412) end + + if env['HTTP_IF_MATCH'] + return if etag_matches?(env['HTTP_IF_MATCH'], new_resource) + + halt 412 + end + + nil + end + + # Sugar for redirect (example: redirect back) + def back + request.referer end - ## Sugar for redirect (example: redirect back) - def back ; request.referer ; end + # whether or not the status is set to 1xx + def informational? + status.between? 100, 199 + end + # whether or not the status is set to 2xx + def success? + status.between? 200, 299 + end + + # whether or not the status is set to 3xx + def redirect? + status.between? 300, 399 + end + + # whether or not the status is set to 4xx + def client_error? + status.between? 400, 499 + end + + # whether or not the status is set to 5xx + def server_error? + status.between? 500, 599 + end + + # whether or not the status is set to 404 + def not_found? + status == 404 + end + + # whether or not the status is set to 400 + def bad_request? + status == 400 + end + + # Generates a Time object from the given value. + # Used by #expires and #last_modified. + def time_for(value) + if value.is_a? Numeric + Time.at value + elsif value.respond_to? :to_s + Time.parse value.to_s + else + value.to_time + end + rescue ArgumentError => e + raise e + rescue Exception + raise ArgumentError, "unable to convert #{value.inspect} to a Time object" + end + + private + + # Helper method checking if a ETag value list includes the current ETag. + def etag_matches?(list, new_resource = request.post?) + return !new_resource if list == '*' + + list.to_s.split(/\s*,\s*/).include? response['ETag'] + end + + def with_params(temp_params) + original = @params + @params = temp_params + yield + ensure + @params = original if original + end end # Template rendering methods. Each method takes the name of a template @@ -279,82 +724,194 @@ def back ; request.referer ; end # that will be rendered. # # Possible options are: - # :layout If set to false, no layout is rendered, otherwise - # the specified layout is used (Ignored for `sass`) - # :locals A hash with local variables that should be available - # in the template + # :content_type The content type to use, same arguments as content_type. + # :layout If set to something falsy, no layout is rendered, otherwise + # the specified layout is used + # :layout_engine Engine to use for rendering the layout. + # :locals A hash with local variables that should be available + # in the template + # :scope If set, template is evaluate with the binding of the given + # object rather than the application instance. + # :views Views directory to use. module Templates - def erb(template, options={}, locals={}) - render :erb, template, options, locals + module ContentTyped + attr_accessor :content_type + end + + def initialize + super + @default_layout = :layout + @preferred_extension = nil + end + + def erb(template, options = {}, locals = {}, &block) + render(:erb, template, options, locals, &block) + end + + def haml(template, options = {}, locals = {}, &block) + render(:haml, template, options, locals, &block) + end + + def builder(template = nil, options = {}, locals = {}, &block) + options[:default_content_type] = :xml + render_ruby(:builder, template, options, locals, &block) end - def erubis(template, options={}, locals={}) - render :erubis, template, options, locals + def liquid(template, options = {}, locals = {}, &block) + render(:liquid, template, options, locals, &block) end - def haml(template, options={}, locals={}) - render :haml, template, options, locals + def markdown(template, options = {}, locals = {}) + options[:exclude_outvar] = true + render :markdown, template, options, locals end - def sass(template, options={}, locals={}) - options[:layout] = false - render :sass, template, options, locals + def rdoc(template, options = {}, locals = {}) + render :rdoc, template, options, locals end - def builder(template=nil, options={}, locals={}, &block) - options, template = template, nil if template.is_a?(Hash) - template = Proc.new { block } if template.nil? - render :builder, template, options, locals + def asciidoc(template, options = {}, locals = {}) + render :asciidoc, template, options, locals end - private - def render(engine, data, options={}, locals={}, &block) + def markaby(template = nil, options = {}, locals = {}, &block) + render_ruby(:mab, template, options, locals, &block) + end + + def nokogiri(template = nil, options = {}, locals = {}, &block) + options[:default_content_type] = :xml + render_ruby(:nokogiri, template, options, locals, &block) + end + + def slim(template, options = {}, locals = {}, &block) + render(:slim, template, options, locals, &block) + end + + def yajl(template, options = {}, locals = {}) + options[:default_content_type] = :json + render :yajl, template, options, locals + end + + def rabl(template, options = {}, locals = {}) + Rabl.register! + render :rabl, template, options, locals + end + + # Calls the given block for every possible template file in views, + # named name.ext, where ext is registered on engine. + def find_template(views, name, engine) + yield ::File.join(views, "#{name}.#{@preferred_extension}") + + Tilt.default_mapping.extensions_for(engine).each do |ext| + yield ::File.join(views, "#{name}.#{ext}") unless ext == @preferred_extension + end + end + + private + + # logic shared between builder and nokogiri + def render_ruby(engine, template, options = {}, locals = {}, &block) + if template.is_a?(Hash) + options = template + template = nil + end + template = proc { block } if template.nil? + render engine, template, options, locals + end + + def render(engine, data, options = {}, locals = {}, &block) # merge app-level options - options = settings.send(engine).merge(options) if settings.respond_to?(engine) + engine_options = settings.respond_to?(engine) ? settings.send(engine) : {} + options.merge!(engine_options) { |_key, v1, _v2| v1 } # extract generic options - locals = options.delete(:locals) || locals || {} - views = options.delete(:views) || settings.views || "./views" - layout = options.delete(:layout) - layout = :layout if layout.nil? || layout == true + locals = options.delete(:locals) || locals || {} + views = options.delete(:views) || settings.views || './views' + layout = options[:layout] + layout = false if layout.nil? && options.include?(:layout) + eat_errors = layout.nil? + layout = engine_options[:layout] if layout.nil? || (layout == true && engine_options[:layout] != false) + layout = @default_layout if layout.nil? || (layout == true) + layout_options = options.delete(:layout_options) || {} + content_type = options.delete(:default_content_type) + content_type = options.delete(:content_type) || content_type + layout_engine = options.delete(:layout_engine) || engine + scope = options.delete(:scope) || self + exclude_outvar = options.delete(:exclude_outvar) + options.delete(:layout) + + # set some defaults + options[:outvar] ||= '@_out_buf' unless exclude_outvar + options[:default_encoding] ||= settings.default_encoding # compile and render template - template = compile_template(engine, data, options, views) - output = template.render(self, locals, &block) + begin + layout_was = @default_layout + @default_layout = false + template = compile_template(engine, data, options, views) + output = template.render(scope, locals, &block) + ensure + @default_layout = layout_was + end # render layout if layout - begin - options = options.merge(:views => views, :layout => false) - output = render(engine, layout, options, locals) { output } - rescue Errno::ENOENT - end + extra_options = { views: views, layout: false, eat_errors: eat_errors, scope: scope } + options = options.merge(extra_options).merge!(layout_options) + + catch(:layout_missing) { return render(layout_engine, layout, options, locals) { output } } end + output.extend(ContentTyped).content_type = content_type if content_type output end def compile_template(engine, data, options, views) - @template_cache.fetch engine, data, options do - case - when data.is_a?(Symbol) - body, path, line = self.class.templates[data] + eat_errors = options.delete :eat_errors + template = Tilt[engine] + raise "Template engine not found: #{engine}" if template.nil? + + case data + when Symbol + template_cache.fetch engine, data, options, views do + body, path, line = settings.templates[data] if body body = body.call if body.respond_to?(:call) - Tilt[engine].new(path, line.to_i, options) { body } + template.new(path, line.to_i, options) { body } else - path = ::File.join(views, "#{data}.#{engine}") - Tilt[engine].new(path, 1, options) + found = false + @preferred_extension = engine.to_s + find_template(views, data, template) do |file| + path ||= file # keep the initial path rather than the last one + found = File.exist?(file) + if found + path = file + break + end + end + throw :layout_missing if eat_errors && !found + template.new(path, 1, options) end - when data.is_a?(Proc) || data.is_a?(String) - body = data.is_a?(String) ? Proc.new { data } : data - path, line = self.class.caller_locations.first - Tilt[engine].new(path, line.to_i, options, &body) - else - raise ArgumentError end + when Proc + compile_block_template(template, options, &data) + when String + template_cache.fetch engine, data, options, views do + compile_block_template(template, options) { data } + end + else + raise ArgumentError, "Sorry, don't know how to render #{data.inspect}." end end + + def compile_block_template(template, options, &body) + first_location = caller_locations.first + path = first_location.path + line = first_location.lineno + path = options[:path] || path + line = options[:line] || line + template.new(path, line.to_i, options, &body) + end end # Base class for all Sinatra applications and middleware. @@ -363,11 +920,16 @@ class Base include Helpers include Templates - attr_accessor :app + URI_INSTANCE = URI::Parser.new + + attr_accessor :app, :env, :request, :response, :params + attr_reader :template_cache - def initialize(app=nil) + def initialize(app = nil, **_kwargs) + super() @app = app @template_cache = Tilt::Cache.new + @pinned_response = nil # whether a before! filter pinned the content-type yield self if block_given? end @@ -376,35 +938,37 @@ def call(env) dup.call!(env) end - attr_accessor :env, :request, :response, :params - - def call!(env) + def call!(env) # :nodoc: @env = env + @params = IndifferentHash.new @request = Request.new(env) @response = Response.new - @params = indifferent_params(@request.params) + @pinned_response = nil + template_cache.clear if settings.reload_templates invoke { dispatch! } - invoke { error_block!(response.status) } - - status, header, body = @response.finish + invoke { error_block!(response.status) } unless @env['sinatra.error'] - # Never produce a body on HEAD requests. Do retain the Content-Length - # unless it's "0", in which case we assume it was calculated erroneously - # for a manual HEAD response and remove it entirely. - if @env['REQUEST_METHOD'] == 'HEAD' - body = [] - header.delete('Content-Length') if header['Content-Length'] == '0' + unless @response['Content-Type'] + if Array === body && body[0].respond_to?(:content_type) + content_type body[0].content_type + elsif (default = settings.default_content_type) + content_type default + end end - [status, header, body] + @response.finish + end + + # Access settings defined with Base.set. + def self.settings + self end # Access settings defined with Base.set. def settings - self.class + self.class.settings end - alias_method :options, :settings # Exit the current block, halts any further processing # of the request, and returns the specified response. @@ -422,79 +986,93 @@ def pass(&block) # Forward the request to the downstream app -- middleware only. def forward - fail "downstream app not set" unless @app.respond_to? :call - status, headers, body = @app.call(@request.env) + raise 'downstream app not set' unless @app.respond_to? :call + + status, headers, body = @app.call env @response.status = status @response.body = body @response.headers.merge! headers nil end - private - # Run before filters defined on the class and all superclasses. - def before_filter!(base=self.class) - before_filter!(base.superclass) if base.superclass.respond_to?(:before_filters) - base.before_filters.each { |block| instance_eval(&block) } - end + private - # Run after filters defined on the class and all superclasses. - def after_filter!(base=self.class) - after_filter!(base.superclass) if base.superclass.respond_to?(:after_filters) - base.after_filters.each { |block| instance_eval(&block) } + # Run filters defined on the class and all superclasses. + # Accepts an optional block to call after each filter is applied. + def filter!(type, base = settings, &block) + filter!(type, base.superclass, &block) if base.superclass.respond_to?(:filters) + base.filters[type].each do |args| + result = process_route(*args) + block.call(result) if block_given? + end end # Run routes defined on the class and all superclasses. - def route!(base=self.class, pass_block=nil) - if routes = base.routes[@request.request_method] - original_params = @params - path = unescape(@request.path_info) - - routes.each do |pattern, keys, conditions, block| - if match = pattern.match(path) - values = match.captures.to_a - params = - if keys.any? - keys.zip(values).inject({}) do |hash,(k,v)| - if k == 'splat' - (hash[k] ||= []) << v - else - hash[k] = v - end - hash - end - elsif values.any? - {'captures' => values} - else - {} - end - @params = original_params.merge(params) - @block_params = values + def route!(base = settings, pass_block = nil) + routes = base.routes[@request.request_method] - pass_block = catch(:pass) do - conditions.each { |cond| - throw :pass if instance_eval(&cond) == false } - route_eval(&block) - end - end + routes&.each do |pattern, conditions, block| + response.delete_header('Content-Type') unless @pinned_response + + returned_pass_block = process_route(pattern, conditions) do |*args| + env['sinatra.route'] = "#{@request.request_method} #{pattern}" + route_eval { block[*args] } end - @params = original_params + # don't wipe out pass_block in superclass + pass_block = returned_pass_block if returned_pass_block end # Run routes defined in superclass. if base.superclass.respond_to?(:routes) - route! base.superclass, pass_block - return + return route!(base.superclass, pass_block) end route_eval(&pass_block) if pass_block - route_missing end # Run a route block and throw :halt with the result. - def route_eval(&block) - throw :halt, instance_eval(&block) + def route_eval + throw :halt, yield + end + + # If the current request matches pattern and conditions, fill params + # with keys and call the given block. + # Revert params afterwards. + # + # Returns pass block. + def process_route(pattern, conditions, block = nil, values = []) + route = @request.path_info + route = '/' if route.empty? && !settings.empty_path_info? + route = route[0..-2] if !settings.strict_paths? && route != '/' && route.end_with?('/') + + params = pattern.params(route) + return unless params + + params.delete('ignore') # TODO: better params handling, maybe turn it into "smart" object or detect changes + force_encoding(params) + @params = @params.merge(params) { |_k, v1, v2| v2 || v1 } if params.any? + + regexp_exists = pattern.is_a?(Mustermann::Regular) || (pattern.respond_to?(:patterns) && pattern.patterns.any? { |subpattern| subpattern.is_a?(Mustermann::Regular) }) + if regexp_exists + captures = pattern.match(route).captures.map { |c| URI_INSTANCE.unescape(c) if c } + values += captures + @params[:captures] = force_encoding(captures) unless captures.nil? || captures.empty? + else + values += params.values.flatten + end + + catch(:pass) do + conditions.each { |c| throw :pass if c.bind(self).call == false } + block ? block[self, values] : yield(self, values) + end + rescue StandardError + @env['sinatra.error.params'] = @params + raise + ensure + params ||= {} + params.each { |k, _| @params.delete(k) } unless @env['sinatra.error.params'] end # No matching route was found or all routes passed. The default @@ -503,153 +1081,177 @@ def route_eval(&block) # a NotFound exception. Subclasses can override this method to perform # custom route miss logic. def route_missing - if @app - forward - else - raise NotFound - end + raise NotFound unless @app + + forward end # Attempt to serve static files from public directory. Throws :halt when # a matching file is found, returns nil otherwise. - def static! - return if (public_dir = settings.public).nil? - public_dir = File.expand_path(public_dir) + def static!(options = {}) + return if (public_dir = settings.public_folder).nil? - path = File.expand_path(public_dir + unescape(request.path_info)) - return if path[0, public_dir.length] != public_dir - return unless File.file?(path) + path = "#{public_dir}#{URI_INSTANCE.unescape(request.path_info)}" + return unless valid_path?(path) - send_file path, :disposition => nil - end + path = File.expand_path(path) + return unless path.start_with?("#{File.expand_path(public_dir)}/") - # Enable string or symbol key access to the nested params hash. - def indifferent_params(params) - params = indifferent_hash.merge(params) - params.each do |key, value| - next unless value.is_a?(Hash) - params[key] = indifferent_params(value) - end - end + return unless File.file?(path) - def indifferent_hash - Hash.new {|hash,key| hash[key.to_s] if Symbol === key } + env['sinatra.static_file'] = path + cache_control(*settings.static_cache_control) if settings.static_cache_control? + send_file path, options.merge(disposition: nil) end # Run the block with 'throw :halt' support and apply result to the response. def invoke(&block) - res = catch(:halt) { instance_eval(&block) } - return if res.nil? - - case - when res.respond_to?(:to_str) - @response.body = [res] - when res.respond_to?(:to_ary) - res = res.to_ary - if Fixnum === res.first - if res.length == 3 - @response.status, headers, body = res - @response.body = body if body - headers.each { |k, v| @response.headers[k] = v } if headers - elsif res.length == 2 - @response.status = res.first - @response.body = res.last - else - raise TypeError, "#{res.inspect} not supported" - end - else - @response.body = res - end - when res.respond_to?(:each) - @response.body = res - when (100...599) === res - @response.status = res + res = catch(:halt, &block) + + res = [res] if (Integer === res) || (String === res) + if (Array === res) && (Integer === res.first) + res = res.dup + status(res.shift) + body(res.pop) + headers(*res) + elsif res.respond_to? :each + body res end - - res + nil # avoid double setting the same response tuple twice end # Dispatch a request with error handling. def dispatch! - static! if settings.static? && (request.get? || request.head?) - before_filter! - route! - rescue NotFound => boom - handle_not_found!(boom) - rescue ::Exception => boom - handle_exception!(boom) - ensure - after_filter! - end + # Avoid passing frozen string in force_encoding + @params.merge!(@request.params).each do |key, val| + next unless val.respond_to?(:force_encoding) - def handle_not_found!(boom) - @env['sinatra.error'] = boom - @response.status = 404 - @response.body = ['

Not Found

'] - error_block! boom.class, NotFound + val = val.dup if val.frozen? + @params[key] = force_encoding(val) + end + + invoke do + static! if settings.static? && (request.get? || request.head?) + filter! :before do + @pinned_response = !response['Content-Type'].nil? + end + route! + end + rescue ::Exception => e + invoke { handle_exception!(e) } + ensure + begin + filter! :after unless env['sinatra.static_file'] + rescue ::Exception => e + invoke { handle_exception!(e) } unless @env['sinatra.error'] + end end + # Error handling during requests. def handle_exception!(boom) + error_params = @env['sinatra.error.params'] + + @params = @params.merge(error_params) if error_params + @env['sinatra.error'] = boom - dump_errors!(boom) if settings.dump_errors? - raise boom if settings.raise_errors? || settings.show_exceptions? + http_status = if boom.is_a? Sinatra::Error + if boom.respond_to? :http_status + boom.http_status + elsif settings.use_code? && boom.respond_to?(:code) + boom.code + end + end + + http_status = 500 unless http_status&.between?(400, 599) + status(http_status) + + if server_error? + dump_errors! boom if settings.dump_errors? + raise boom if settings.show_exceptions? && (settings.show_exceptions != :after_handler) + elsif not_found? + headers['X-Cascade'] = 'pass' if settings.x_cascade? + end + + if (res = error_block!(boom.class, boom) || error_block!(status, boom)) + return res + end + + if not_found? || bad_request? + if boom.message && boom.message != boom.class.name + body Rack::Utils.escape_html(boom.message) + else + content_type 'text/html' + body "

#{not_found? ? 'Not Found' : 'Bad Request'}

" + end + end + + return unless server_error? - @response.status = 500 - error_block! boom.class, Exception + raise boom if settings.raise_errors? || settings.show_exceptions? + + error_block! Exception, boom end # Find an custom error block for the key(s) specified. - def error_block!(*keys) - keys.each do |key| - base = self.class - while base.respond_to?(:errors) - if block = base.errors[key] - # found a handler, eval and return result - res = instance_eval(&block) - return res - else - base = base.superclass - end + def error_block!(key, *block_params) + base = settings + while base.respond_to?(:errors) + args_array = base.errors[key] + + next base = base.superclass unless args_array + + args_array.reverse_each do |args| + first = args == args_array.first + args += [block_params] + resp = process_route(*args) + return resp unless resp.nil? && !first end end - nil + return false unless key.respond_to?(:superclass) && (key.superclass < Exception) + + error_block!(key.superclass, *block_params) end def dump_errors!(boom) - backtrace = clean_backtrace(boom.backtrace) - msg = ["#{boom.class} - #{boom.message}:", - *backtrace].join("\n ") + msg = ["#{Time.now.strftime('%Y-%m-%d %H:%M:%S')} - #{boom.class} - #{boom.message}:", *boom.backtrace].join("\n\t") @env['rack.errors'].puts(msg) end - def clean_backtrace(trace) - return trace unless settings.clean_trace? - - trace.reject { |line| - line =~ /lib\/sinatra.*\.rb/ || - (defined?(Gem) && line.include?(Gem.dir)) - }.map! { |line| line.gsub(/^\.\//, '') } - end - class << self - attr_reader :routes, :before_filters, :after_filters, :templates, :errors + CALLERS_TO_IGNORE = [ # :nodoc: + %r{/sinatra(/(base|main|show_exceptions))?\.rb$}, # all sinatra code + %r{lib/tilt.*\.rb$}, # all tilt code + /^\(.*\)$/, # generated code + %r{rubygems/(custom|core_ext/kernel)_require\.rb$}, # rubygems require hacks + /active_support/, # active_support require hacks + %r{bundler(/(?:runtime|inline))?\.rb}, # bundler require hacks + /= 1.9.2 + %r{zeitwerk/kernel\.rb} # Zeitwerk kernel#require decorator + ].freeze + + attr_reader :routes, :filters, :templates, :errors + + def callers_to_ignore + CALLERS_TO_IGNORE + end + # Removes all routes, filters, middleware and extension hooks from the + # current class (not routes/filters/... defined by its superclass). def reset! @conditions = [] @routes = {} - @before_filters = [] - @after_filters = [] + @filters = { before: [], after: [] } @errors = {} @middleware = [] @prototype = nil @extensions = [] - if superclass.respond_to?(:templates) - @templates = Hash.new { |hash,key| superclass.templates[key] } - else - @templates = {} - end + @templates = if superclass.respond_to?(:templates) + Hash.new { |_hash, key| superclass.templates[key] } + else + {} + end end # Extension modules registered on this class and all superclasses. @@ -672,18 +1274,43 @@ def middleware # Sets an option to the given value. If the value is a proc, # the proc will be called every time the option is accessed. - def set(option, value=self) - if value.kind_of?(Proc) - metadef(option, &value) - metadef("#{option}?") { !!__send__(option) } - metadef("#{option}=") { |val| set(option, Proc.new{val}) } - elsif value == self && option.respond_to?(:to_hash) - option.to_hash.each { |k,v| set(k, v) } - elsif respond_to?("#{option}=") - __send__ "#{option}=", value - else - set option, Proc.new{value} + def set(option, value = (not_set = true), ignore_setter = false, &block) + raise ArgumentError if block && !not_set + + if block + value = block + not_set = false + end + + if not_set + raise ArgumentError unless option.respond_to?(:each) + + option.each { |k, v| set(k, v) } + return self end + + if respond_to?("#{option}=") && !ignore_setter + return __send__("#{option}=", value) + end + + setter = proc { |val| set option, val, true } + getter = proc { value } + + case value + when Proc + getter = value + when Symbol, Integer, FalseClass, TrueClass, NilClass + getter = value.inspect + when Hash + setter = proc do |val| + val = value.merge val if Hash === val + set option, val, true + end + end + + define_singleton("#{option}=", setter) + define_singleton(option, getter) + define_singleton("#{option}?", "!!#{option}") unless method_defined? "#{option}?" self end @@ -700,13 +1327,17 @@ def disable(*opts) # Define a custom error handler. Optionally takes either an Exception # class, or an HTTP status code to specify which errors should be # handled. - def error(codes=Exception, &block) - Array(codes).each { |code| @errors[code] = block } + def error(*codes, &block) + args = compile! 'ERROR', /.*/, block + codes = codes.flat_map(&method(:Array)) + codes << Exception if codes.empty? + codes << Sinatra::NotFound if codes.include?(404) + codes.each { |c| (@errors[c] ||= []) << args } end # Sugar for `error(404) { ... }` def not_found(&block) - error 404, &block + error(404, &block) end # Define a named template. The block must return the template source. @@ -716,101 +1347,104 @@ def template(name, &block) end # Define the layout template. The block must return the template source. - def layout(name=:layout, &block) + def layout(name = :layout, &block) template name, &block end - # Load embeded templates from the file; uses the caller's __FILE__ + # Load embedded templates from the file; uses the caller's __FILE__ # when no file is specified. - def use_in_file_templates!(file=nil) - file ||= caller_files.first + def inline_templates=(file = nil) + file = (caller_files.first || File.expand_path($0)) if file.nil? || file == true begin - app, data = - ::IO.read(file).gsub("\r\n", "\n").split(/^__END__$/, 2) + io = ::IO.respond_to?(:binread) ? ::IO.binread(file) : ::IO.read(file) + app, data = io.gsub("\r\n", "\n").split(/^__END__$/, 2) rescue Errno::ENOENT app, data = nil end - if data - lines = app.count("\n") + 1 - template = nil - data.each_line do |line| - lines += 1 - if line =~ /^@@\s*(.*)/ - template = '' - templates[$1.to_sym] = [template, file, lines] - elsif template - template << line - end + return unless data + + encoding = if app && app =~ /([^\n]*\n)?#[^\n]*coding: *(\S+)/m + $2 + else + settings.default_encoding + end + + lines = app.count("\n") + 1 + template = nil + force_encoding data, encoding + data.each_line do |line| + lines += 1 + if line =~ /^@@\s*(.*\S)\s*$/ + template = force_encoding(String.new, encoding) + templates[$1.to_sym] = [template, file, lines] + elsif template + template << line end end end # Lookup or register a mime type in Rack's mime registry. - def mime_type(type, value=nil) - return type if type.nil? || type.to_s.include?('/') - type = ".#{type}" unless type.to_s[0] == ?. + def mime_type(type, value = nil) + return type if type.nil? + return type.to_s if type.to_s.include?('/') + + type = ".#{type}" unless type.to_s[0] == '.' return Rack::Mime.mime_type(type, nil) unless value + Rack::Mime::MIME_TYPES[type] = value end + # provides all mime types matching type, including deprecated types: + # mime_types :html # => ['text/html'] + # mime_types :js # => ['application/javascript', 'text/javascript'] + def mime_types(type) + type = mime_type type + type =~ %r{^application/(xml|javascript)$} ? [type, "text/#{$1}"] : [type] + end + # Define a before filter; runs before all requests within the same # context as route handlers and may access/modify the request and # response. - def before(&block) - @before_filters << block + def before(path = /.*/, **options, &block) + add_filter(:before, path, **options, &block) end # Define an after filter; runs after all requests within the same # context as route handlers and may access/modify the request and # response. - def after(&block) - @after_filters << block + def after(path = /.*/, **options, &block) + add_filter(:after, path, **options, &block) + end + + # add a filter + def add_filter(type, path = /.*/, **options, &block) + filters[type] << compile!(type, path, block, **options) end # Add a route condition. The route is considered non-matching when the # block returns false. - def condition(&block) - @conditions << block + def condition(name = "#{caller.first[/`.*'/]} condition", &block) + @conditions << generate_method(name, &block) end - private - def host_name(pattern) - condition { pattern === request.host } + def public=(value) + warn_for_deprecation ':public is no longer used to avoid overloading Module#public, use :public_folder or :public_dir instead' + set(:public_folder, value) end - def user_agent(pattern) - condition { - if request.user_agent =~ pattern - @params[:agent] = $~[1..-1] - true - else - false - end - } + def public_dir=(value) + self.public_folder = value end - alias_method :agent, :user_agent - - def provides(*types) - types = [types] unless types.kind_of? Array - types.map!{|t| mime_type(t)} - condition { - matching_types = (request.accept & types) - unless matching_types.empty? - response.headers['Content-Type'] = matching_types.first - true - else - false - end - } + def public_dir + public_folder end - public # Defining a `GET` handler also automatically defines # a `HEAD` handler. - def get(path, opts={}, &block) + def get(path, opts = {}, &block) conditions = @conditions.dup route('GET', path, opts, &block) @@ -818,75 +1452,31 @@ def get(path, opts={}, &block) route('HEAD', path, opts, &block) end - def put(path, opts={}, &bk); route 'PUT', path, opts, &bk end - def post(path, opts={}, &bk); route 'POST', path, opts, &bk end - def delete(path, opts={}, &bk); route 'DELETE', path, opts, &bk end - def head(path, opts={}, &bk); route 'HEAD', path, opts, &bk end + def put(path, opts = {}, &block) route 'PUT', path, opts, &block end - private - def route(verb, path, options={}, &block) - # Because of self.options.host - host_name(options.delete(:host)) if options.key?(:host) + def post(path, opts = {}, &block) route 'POST', path, opts, &block end - options.each {|option, args| send(option, *args)} + def delete(path, opts = {}, &block) route 'DELETE', path, opts, &block end - pattern, keys = compile(path) - conditions, @conditions = @conditions, [] + def head(path, opts = {}, &block) route 'HEAD', path, opts, &block end - define_method "#{verb} #{path}", &block - unbound_method = instance_method("#{verb} #{path}") - block = - if block.arity != 0 - lambda { unbound_method.bind(self).call(*@block_params) } - else - lambda { unbound_method.bind(self).call } - end + def options(path, opts = {}, &block) route 'OPTIONS', path, opts, &block end - invoke_hook(:route_added, verb, path, block) + def patch(path, opts = {}, &block) route 'PATCH', path, opts, &block end - (@routes[verb] ||= []). - push([pattern, keys, conditions, block]).last - end + def link(path, opts = {}, &block) route 'LINK', path, opts, &block end - def invoke_hook(name, *args) - extensions.each { |e| e.send(name, *args) if e.respond_to?(name) } - end + def unlink(path, opts = {}, &block) route 'UNLINK', path, opts, &block end - def compile(path) - keys = [] - if path.respond_to? :to_str - special_chars = %w{. + ( )} - pattern = - path.to_str.gsub(/((:\w+)|[\*#{special_chars.join}])/) do |match| - case match - when "*" - keys << 'splat' - "(.*?)" - when *special_chars - Regexp.escape(match) - else - keys << $2[1..-1] - "([^/?&#]+)" - end - end - [/^#{pattern}$/, keys] - elsif path.respond_to?(:keys) && path.respond_to?(:match) - [path, path.keys] - elsif path.respond_to? :match - [path, keys] - else - raise TypeError, path - end - end - - public # Makes the methods defined in the block and in the Modules given # in `extensions` available to the handlers and templates def helpers(*extensions, &block) - class_eval(&block) if block_given? + class_eval(&block) if block_given? include(*extensions) if extensions.any? end + # Register an extension. Alternatively take a block from which an + # extension will be created and registered on the fly. def register(*extensions, &block) extensions << Module.new(&block) if block_given? @extensions += extensions @@ -902,7 +1492,7 @@ def test?; environment == :test end # Set configuration options for Sinatra and/or the app. # Allows scoping of settings for certain environments. - def configure(*envs, &block) + def configure(*envs) yield self if envs.empty? || envs.include?(environment.to_sym) end @@ -911,25 +1501,48 @@ def use(middleware, *args, &block) @prototype = nil @middleware << [middleware, args, block] end + ruby2_keywords(:use) if respond_to?(:ruby2_keywords, true) + + # Stop the self-hosted server if running. + def quit! + return unless running? + + # Use Thin's hard #stop! if available, otherwise just #stop. + running_server.respond_to?(:stop!) ? running_server.stop! : running_server.stop + warn '== Sinatra has ended his set (crowd applauds)' unless suppress_messages? + set :running_server, nil + set :handler_name, nil + end + + alias stop! quit! # Run the Sinatra app as a self-hosted server using - # Thin, Mongrel or WEBrick (in that order) - def run!(options={}) + # Puma, Falcon, Mongrel, or WEBrick (in that order). If given a block, will call + # with the constructed handler once we have taken the stage. + def run!(options = {}, &block) + return if running? + set options - handler = detect_rack_handler - handler_name = handler.name.gsub(/.*::/, '') - puts "== Sinatra/#{Sinatra::VERSION} has taken the stage " + - "on #{port} for #{environment} with backup from #{handler_name}" unless handler_name =~/cgi/i - handler.run self, :Host => host, :Port => port do |server| - trap(:INT) do - ## Use thins' hard #stop! if available, otherwise just #stop - server.respond_to?(:stop!) ? server.stop! : server.stop - puts "\n== Sinatra has ended his set (crowd applauds)" unless handler_name =~/cgi/i - end - set :running, true + handler = Rack::Handler.pick(server) + handler_name = handler.name.gsub(/.*::/, '') + server_settings = settings.respond_to?(:server_settings) ? settings.server_settings : {} + server_settings.merge!(Port: port, Host: bind) + + begin + start_server(handler, server_settings, handler_name, &block) + rescue Errno::EADDRINUSE + warn "== Someone is already performing on port #{port}!" + raise + ensure + quit! end - rescue Errno::EADDRINUSE => e - puts "== Someone is already performing on port #{port}!" + end + + alias start! run! + + # Check whether the self-hosted server is running or not. + def running? + running_server? end # The prototype instance used to process requests. @@ -937,40 +1550,237 @@ def prototype @prototype ||= new end + # Create a new instance without middleware in front of it. + alias new! new unless method_defined? :new! + # Create a new instance of the class fronted by its middleware # pipeline. The object is guaranteed to respond to #call but may not be # an instance of the class new was called on. - def new(*args, &bk) - builder = Rack::Builder.new - builder.use Rack::Session::Cookie if sessions? - builder.use Rack::CommonLogger if logging? - builder.use Rack::MethodOverride if methodoverride? - builder.use ShowExceptions if show_exceptions? - middleware.each { |c,a,b| builder.use(c, *a, &b) } + def new(*args, &block) + instance = new!(*args, &block) + Wrapper.new(build(instance).to_app, instance) + end + ruby2_keywords :new if respond_to?(:ruby2_keywords, true) - builder.run super - builder.to_app + # Creates a Rack::Builder instance with all the middleware set up and + # the given +app+ as end point. + def build(app) + builder = Rack::Builder.new + setup_default_middleware builder + setup_middleware builder + builder.run app + builder end def call(env) synchronize { prototype.call(env) } end - private - def detect_rack_handler - servers = Array(self.server) - servers.each do |server_name| - begin - return Rack::Handler.get(server_name.downcase) - rescue LoadError - rescue NameError + # Like Kernel#caller but excluding certain magic entries and without + # line / method information; the resulting array contains filenames only. + def caller_files + cleaned_caller(1).flatten + end + + private + + # Starts the server by running the Rack Handler. + def start_server(handler, server_settings, handler_name) + # Ensure we initialize middleware before startup, to match standard Rack + # behavior, by ensuring an instance exists: + prototype + # Run the instance we created: + handler.run(self, **server_settings) do |server| + unless suppress_messages? + warn "== Sinatra (v#{Sinatra::VERSION}) has taken the stage on #{port} for #{environment} with backup from #{handler_name}" + end + + setup_traps + set :running_server, server + set :handler_name, handler_name + server.threaded = settings.threaded if server.respond_to? :threaded= + + yield server if block_given? + end + end + + def suppress_messages? + handler_name =~ /cgi/i || quiet + end + + def setup_traps + return unless traps? + + at_exit { quit! } + + %i[INT TERM].each do |signal| + old_handler = trap(signal) do + quit! + old_handler.call if old_handler.respond_to?(:call) + end + end + + set :traps, false + end + + # Dynamically defines a method on settings. + def define_singleton(name, content = Proc.new) + singleton_class.class_eval do + undef_method(name) if method_defined? name + String === content ? class_eval("def #{name}() #{content}; end") : define_method(name, &content) + end + end + + # Condition for matching host name. Parameter might be String or Regexp. + def host_name(pattern) + condition { pattern === request.host } + end + + # Condition for matching user agent. Parameter should be Regexp. + # Will set params[:agent]. + def user_agent(pattern) + condition do + if request.user_agent.to_s =~ pattern + @params[:agent] = $~[1..-1] + true + else + false + end + end + end + alias agent user_agent + + # Condition for matching mimetypes. Accepts file extensions. + def provides(*types) + types.map! { |t| mime_types(t) } + types.flatten! + condition do + response_content_type = response['Content-Type'] + preferred_type = request.preferred_type(types) + + if response_content_type + types.include?(response_content_type) || types.include?(response_content_type[/^[^;]+/]) + elsif preferred_type + params = (preferred_type.respond_to?(:params) ? preferred_type.params : {}) + content_type(preferred_type, params) + true + else + false end end - fail "Server handler (#{servers.join(',')}) not found." + end + + def route(verb, path, options = {}, &block) + enable :empty_path_info if path == '' && empty_path_info.nil? + signature = compile!(verb, path, block, **options) + (@routes[verb] ||= []) << signature + invoke_hook(:route_added, verb, path, block) + signature + end + + def invoke_hook(name, *args) + extensions.each { |e| e.send(name, *args) if e.respond_to?(name) } + end + + def generate_method(method_name, &block) + define_method(method_name, &block) + method = instance_method method_name + remove_method method_name + method + end + + def compile!(verb, path, block, **options) + # Because of self.options.host + host_name(options.delete(:host)) if options.key?(:host) + # Pass Mustermann opts to compile() + route_mustermann_opts = options.key?(:mustermann_opts) ? options.delete(:mustermann_opts) : {}.freeze + + options.each_pair { |option, args| send(option, *args) } + + pattern = compile(path, route_mustermann_opts) + method_name = "#{verb} #{path}" + unbound_method = generate_method(method_name, &block) + conditions = @conditions + @conditions = [] + wrapper = block.arity.zero? ? + proc { |a, _p| unbound_method.bind(a).call } : + proc { |a, p| unbound_method.bind(a).call(*p) } + + [pattern, conditions, wrapper] + end + + def compile(path, route_mustermann_opts = {}) + Mustermann.new(path, **mustermann_opts.merge(route_mustermann_opts)) + end + + def setup_default_middleware(builder) + builder.use ExtendedRack + builder.use ShowExceptions if show_exceptions? + builder.use Rack::MethodOverride if method_override? + builder.use Rack::Head + setup_logging builder + setup_sessions builder + setup_protection builder + end + + def setup_middleware(builder) + middleware.each { |c, a, b| builder.use(c, *a, &b) } + end + + def setup_logging(builder) + if logging? + setup_common_logger(builder) + setup_custom_logger(builder) + elsif logging == false + setup_null_logger(builder) + end + end + + def setup_null_logger(builder) + builder.use Rack::NullLogger + end + + def setup_common_logger(builder) + builder.use Sinatra::CommonLogger + end + + def setup_custom_logger(builder) + if logging.respond_to? :to_int + builder.use Rack::Logger, logging + else + builder.use Rack::Logger + end + end + + def setup_protection(builder) + return unless protection? + + options = Hash === protection ? protection.dup : {} + options = { + img_src: "'self' data:", + font_src: "'self'" + }.merge options + + protect_session = options.fetch(:session) { sessions? } + options[:without_session] = !protect_session + + options[:reaction] ||= :drop_session + + builder.use Rack::Protection, options + end + + def setup_sessions(builder) + return unless sessions? + + options = {} + options[:secret] = session_secret if session_secret? + options.merge! sessions.to_hash if sessions.respond_to? :to_hash + builder.use session_store, options end def inherited(subclass) subclass.reset! + subclass.set :app_file, caller_files.first unless subclass.app_file? super end @@ -983,60 +1793,104 @@ def synchronize(&block) end end - def metadef(message, &block) - (class << self; self; end). - send :define_method, message, &block + # used for deprecation warnings + def warn_for_deprecation(message) + warn message + "\n\tfrom #{cleaned_caller.first.join(':')}" end - public - CALLERS_TO_IGNORE = [ - /\/sinatra(\/(base|main|showexceptions))?\.rb$/, # all sinatra code - /lib\/tilt.*\.rb$/, # all tilt code - /\(.*\)/, # generated code - /custom_require\.rb$/, # rubygems require hacks - /active_support/, # active_support require hacks - ] - - # add rubinius (and hopefully other VM impls) ignore patterns ... - CALLERS_TO_IGNORE.concat(RUBY_IGNORE_CALLERS) if defined?(RUBY_IGNORE_CALLERS) - - # Like Kernel#caller but excluding certain magic entries and without - # line / method information; the resulting array contains filenames only. - def caller_files - caller_locations. - map { |file,line| file } + # Like Kernel#caller but excluding certain magic entries + def cleaned_caller(keep = 3) + caller(1) + .map! { |line| line.split(/:(?=\d|in )/, 3)[0, keep] } + .reject { |file, *_| callers_to_ignore.any? { |pattern| file =~ pattern } } end + end - def caller_locations - caller(1). - map { |line| line.split(/:(?=\d|in )/)[0,2] }. - reject { |file,line| CALLERS_TO_IGNORE.any? { |pattern| file =~ pattern } } + # Force data to specified encoding. It defaults to settings.default_encoding + # which is UTF-8 by default + def self.force_encoding(data, encoding = default_encoding) + return if data == settings || data.is_a?(Tempfile) + + if data.respond_to? :force_encoding + data.force_encoding(encoding).encode! + elsif data.respond_to? :each_value + data.each_value { |v| force_encoding(v, encoding) } + elsif data.respond_to? :each + data.each { |v| force_encoding(v, encoding) } end + data + end + + def force_encoding(*args) + settings.force_encoding(*args) end reset! - set :raise_errors, true - set :dump_errors, false - set :clean_trace, true - set :show_exceptions, false + set :environment, (ENV['APP_ENV'] || ENV['RACK_ENV'] || :development).to_sym + set :raise_errors, proc { test? } + set :dump_errors, proc { !test? } + set :show_exceptions, proc { development? } set :sessions, false + set :session_store, Rack::Protection::EncryptedCookie set :logging, false - set :methodoverride, false - set :static, false - set :environment, (ENV['RACK_ENV'] || :development).to_sym + set :protection, true + set :method_override, false + set :use_code, false + set :default_encoding, 'utf-8' + set :x_cascade, true + set :add_charset, %w[javascript xml xhtml+xml].map { |t| "application/#{t}" } + settings.add_charset << %r{^text/} + set :mustermann_opts, {} + set :default_content_type, 'text/html' + + # explicitly generating a session secret eagerly to play nice with preforking + begin + require 'securerandom' + set :session_secret, SecureRandom.hex(64) + rescue LoadError, NotImplementedError, RuntimeError + # SecureRandom raises a NotImplementedError if no random device is available + # RuntimeError raised due to broken openssl backend: https://bugs.ruby-lang.org/issues/19230 + set :session_secret, format('%064x', Kernel.rand((2**256) - 1)) + end + + class << self + alias methodoverride? method_override? + alias methodoverride= method_override= + end set :run, false # start server via at-exit hook? - set :running, false # is the built-in server running now? - set :server, %w[thin mongrel webrick] - set :host, '0.0.0.0' - set :port, 4567 + set :running_server, nil + set :handler_name, nil + set :traps, true + set :server, %w[HTTP webrick] + set :bind, proc { development? ? 'localhost' : '0.0.0.0' } + set :port, Integer(ENV['PORT'] && !ENV['PORT'].empty? ? ENV['PORT'] : 4567) + set :quiet, false + + ruby_engine = defined?(RUBY_ENGINE) && RUBY_ENGINE + + server.unshift 'puma' + server.unshift 'falcon' if ruby_engine != 'jruby' + server.unshift 'mongrel' if ruby_engine.nil? + server.unshift 'thin' if ruby_engine != 'jruby' + server.unshift 'trinidad' if ruby_engine == 'jruby' + + set :absolute_redirects, true + set :prefixed_redirects, false + set :empty_path_info, nil + set :strict_paths, true set :app_file, nil - set :root, Proc.new { app_file && File.expand_path(File.dirname(app_file)) } - set :views, Proc.new { root && File.join(root, 'views') } - set :public, Proc.new { root && File.join(root, 'public') } + set :root, proc { app_file && File.expand_path(File.dirname(app_file)) } + set :views, proc { root && File.join(root, 'views') } + set :reload_templates, proc { development? } set :lock, false + set :threaded, true + + set :public_folder, proc { root && File.join(root, 'public') } + set :static, proc { public_folder && File.exist?(public_folder) } + set :static_cache_control, false error ::Exception do response.status = 500 @@ -1046,7 +1900,7 @@ def caller_locations configure :development do get '/__sinatra__/:image.png' do - filename = File.dirname(__FILE__) + "/images/#{params[:image]}.png" + filename = __dir__ + "/images/#{params[:image].to_i}.png" content_type :png send_file filename end @@ -1054,44 +1908,64 @@ def caller_locations error NotFound do content_type 'text/html' - (<<-HTML).gsub(/^ {8}/, '') - - - - - - -

Sinatra doesn't know this ditty.

- -
- Try this: -
#{request.request_method.downcase} '#{request.path_info}' do\n  "Hello World"\nend
-
- - + if instance_of?(Sinatra::Application) + code = <<-RUBY.gsub(/^ {12}/, '') + #{request.request_method.downcase} '#{request.path_info}' do + "Hello World" + end + RUBY + else + code = <<-RUBY.gsub(/^ {12}/, '') + class #{self.class} + #{request.request_method.downcase} '#{request.path_info}' do + "Hello World" + end + end + RUBY + + file = settings.app_file.to_s.sub(settings.root.to_s, '').sub(%r{^/}, '') + code = "# in #{file}\n#{code}" unless file.empty? + end + + <<-HTML.gsub(/^ {10}/, '') + + + + + + +

Sinatra doesn’t know this ditty.

+ +
+ Try this: +
#{Rack::Utils.escape_html(code)}
+
+ + HTML end end end - # The top-level Application. All DSL methods executed on main are delegated - # to this class. + # Execution context for classic style (top-level) applications. All + # DSL methods executed on main are delegated to this class. + # + # The Application class should not be subclassed, unless you want to + # inherit all settings, routes, handlers, and error pages from the + # top-level. Subclassing Sinatra::Base is highly recommended for + # modular applications. class Application < Base - set :raise_errors, Proc.new { test? } - set :show_exceptions, Proc.new { development? } - set :dump_errors, true - set :sessions, false - set :logging, Proc.new { ! test? } - set :methodoverride, true - set :static, true - set :run, Proc.new { ! test? } + set :logging, proc { !test? } + set :method_override, true + set :run, proc { !test? } + set :app_file, nil - def self.register(*extensions, &block) #:nodoc: - added_methods = extensions.map {|m| m.public_instance_methods }.flatten + def self.register(*extensions, &block) # :nodoc: + added_methods = extensions.flat_map(&:public_instance_methods) Delegator.delegate(*added_methods) super(*extensions, &block) end @@ -1100,39 +1974,74 @@ def self.register(*extensions, &block) #:nodoc: # Sinatra delegation mixin. Mixing this module into an object causes all # methods to be delegated to the Sinatra::Application class. Used primarily # at the top-level. - module Delegator #:nodoc: + module Delegator # :nodoc: def self.delegate(*methods) methods.each do |method_name| - eval <<-RUBY, binding, '(__DELEGATE__)', 1 - def #{method_name}(*args, &b) - ::Sinatra::Application.send(#{method_name.inspect}, *args, &b) - end - private #{method_name.inspect} - RUBY + define_method(method_name) do |*args, &block| + return super(*args, &block) if respond_to? method_name + + Delegator.target.send(method_name, *args, &block) + end + # ensure keyword argument passing is compatible with ruby >= 2.7 + ruby2_keywords(method_name) if respond_to?(:ruby2_keywords, true) + private method_name end end - delegate :get, :put, :post, :delete, :head, :template, :layout, - :before, :after, :error, :not_found, :configure, :set, :mime_type, - :enable, :disable, :use, :development?, :test?, - :production?, :use_in_file_templates!, :helpers + delegate :get, :patch, :put, :post, :delete, :head, :options, :link, :unlink, + :template, :layout, :before, :after, :error, :not_found, :configure, + :set, :mime_type, :enable, :disable, :use, :development?, :test?, + :production?, :helpers, :settings, :register + + class << self + attr_accessor :target + end + + self.target = Application end - # Create a new Sinatra application. The block is evaluated in the new app's - # class scope. - def self.new(base=Base, options={}, &block) + class Wrapper + def initialize(stack, instance) + @stack = stack + @instance = instance + end + + def settings + @instance.settings + end + + def helpers + @instance + end + + def call(env) + @stack.call(env) + end + + def inspect + "#<#{@instance.class} app_file=#{settings.app_file.inspect}>" + end + end + + # Create a new Sinatra application; the block is evaluated in the class scope. + def self.new(base = Base, &block) base = Class.new(base) - base.send :class_eval, &block if block_given? + base.class_eval(&block) if block_given? base end # Extend the top-level DSL with the modules provided. def self.register(*extensions, &block) - Application.register(*extensions, &block) + Delegator.target.register(*extensions, &block) end # Include the helper modules provided in Sinatra's request context. def self.helpers(*extensions, &block) - Application.helpers(*extensions, &block) + Delegator.target.helpers(*extensions, &block) + end + + # Use the middleware for classic applications. + def self.use(*args, &block) + Delegator.target.use(*args, &block) end end diff --git a/lib/sinatra/images/404.png b/lib/sinatra/images/404.png index 902110e161..f16a914ff2 100644 Binary files a/lib/sinatra/images/404.png and b/lib/sinatra/images/404.png differ diff --git a/lib/sinatra/images/500.png b/lib/sinatra/images/500.png index 57c84c3d96..e08b17d9e6 100644 Binary files a/lib/sinatra/images/500.png and b/lib/sinatra/images/500.png differ diff --git a/lib/sinatra/indifferent_hash.rb b/lib/sinatra/indifferent_hash.rb new file mode 100644 index 0000000000..a3a96d5c4d --- /dev/null +++ b/lib/sinatra/indifferent_hash.rb @@ -0,0 +1,206 @@ +# frozen_string_literal: true + +module Sinatra + # A poor man's ActiveSupport::HashWithIndifferentAccess, with all the Rails-y + # stuff removed. + # + # Implements a hash where keys :foo and "foo" are + # considered to be the same. + # + # rgb = Sinatra::IndifferentHash.new + # + # rgb[:black] = '#000000' # symbol assignment + # rgb[:black] # => '#000000' # symbol retrieval + # rgb['black'] # => '#000000' # string retrieval + # + # rgb['white'] = '#FFFFFF' # string assignment + # rgb[:white] # => '#FFFFFF' # symbol retrieval + # rgb['white'] # => '#FFFFFF' # string retrieval + # + # Internally, symbols are mapped to strings when used as keys in the entire + # writing interface (calling e.g. []=, merge). This mapping + # belongs to the public interface. For example, given: + # + # hash = Sinatra::IndifferentHash.new(:a=>1) + # + # You are guaranteed that the key is returned as a string: + # + # hash.keys # => ["a"] + # + # Technically other types of keys are accepted: + # + # hash = Sinatra::IndifferentHash.new(:a=>1) + # hash[0] = 0 + # hash # => { "a"=>1, 0=>0 } + # + # But this class is intended for use cases where strings or symbols are the + # expected keys and it is convenient to understand both as the same. For + # example the +params+ hash in Sinatra. + class IndifferentHash < Hash + def self.[](*args) + new.merge!(Hash[*args]) + end + + def initialize(*args) + args.map!(&method(:convert_value)) + + super(*args) + end + + def default(*args) + args.map!(&method(:convert_key)) + + super(*args) + end + + def default=(value) + super(convert_value(value)) + end + + def assoc(key) + super(convert_key(key)) + end + + def rassoc(value) + super(convert_value(value)) + end + + def fetch(key, *args) + args.map!(&method(:convert_value)) + + super(convert_key(key), *args) + end + + def [](key) + super(convert_key(key)) + end + + def []=(key, value) + super(convert_key(key), convert_value(value)) + end + + alias store []= + + def key(value) + super(convert_value(value)) + end + + def key?(key) + super(convert_key(key)) + end + + alias has_key? key? + alias include? key? + alias member? key? + + def value?(value) + super(convert_value(value)) + end + + alias has_value? value? + + def delete(key) + super(convert_key(key)) + end + + # Added in Ruby 2.3 + def dig(key, *other_keys) + super(convert_key(key), *other_keys) + end + + def fetch_values(*keys) + keys.map!(&method(:convert_key)) + + super(*keys) + end + + def slice(*keys) + keys.map!(&method(:convert_key)) + + self.class[super(*keys)] + end + + def values_at(*keys) + keys.map!(&method(:convert_key)) + + super(*keys) + end + + def merge!(*other_hashes) + other_hashes.each do |other_hash| + if other_hash.is_a?(self.class) + super(other_hash) + else + other_hash.each_pair do |key, value| + key = convert_key(key) + value = yield(key, self[key], value) if block_given? && key?(key) + self[key] = convert_value(value) + end + end + end + + self + end + + alias update merge! + + def merge(*other_hashes, &block) + dup.merge!(*other_hashes, &block) + end + + def replace(other_hash) + super(other_hash.is_a?(self.class) ? other_hash : self.class[other_hash]) + end + + def transform_values(&block) + dup.transform_values!(&block) + end + + def transform_values! + super + super(&method(:convert_value)) + end + + def transform_keys(&block) + dup.transform_keys!(&block) + end + + def transform_keys! + super + super(&method(:convert_key)) + end + + def select(*args, &block) + return to_enum(:select) unless block_given? + + dup.tap { |hash| hash.select!(*args, &block) } + end + + def reject(*args, &block) + return to_enum(:reject) unless block_given? + + dup.tap { |hash| hash.reject!(*args, &block) } + end + + def compact + dup.tap(&:compact!) + end + + private + + def convert_key(key) + key.is_a?(Symbol) ? key.to_s : key + end + + def convert_value(value) + case value + when Hash + value.is_a?(self.class) ? value : self.class[value] + when Array + value.map(&method(:convert_value)) + else + value + end + end + end +end diff --git a/lib/sinatra/main.rb b/lib/sinatra/main.rb index c2f7a18bd7..bca7fc1a09 100644 --- a/lib/sinatra/main.rb +++ b/lib/sinatra/main.rb @@ -1,30 +1,56 @@ -require 'sinatra/base' +# frozen_string_literal: true module Sinatra - class Application < Base + PARAMS_CONFIG = {} + + if ARGV.any? + require 'optparse' + parser = OptionParser.new do |op| + op.on('-p port', 'set the port (default is 4567)') { |val| PARAMS_CONFIG[:port] = Integer(val) } + op.on('-s server', 'specify rack server/handler') { |val| PARAMS_CONFIG[:server] = val } + op.on('-q', 'turn on quiet mode (default is off)') { PARAMS_CONFIG[:quiet] = true } + op.on('-x', 'turn on the mutex lock (default is off)') { PARAMS_CONFIG[:lock] = true } + op.on('-e env', 'set the environment (default is development)') do |val| + ENV['RACK_ENV'] = val + PARAMS_CONFIG[:environment] = val.to_sym + end + op.on('-o addr', "set the host (default is (env == 'development' ? 'localhost' : '0.0.0.0'))") do |val| + PARAMS_CONFIG[:bind] = val + end + end + begin + parser.parse!(ARGV.dup) + rescue StandardError => e + PARAMS_CONFIG[:optparse_error] = e + end + end + + require 'sinatra/base' + class Application < Base # we assume that the first file that requires 'sinatra' is the # app_file. all other path related options are calculated based # on this path by default. set :app_file, caller_files.first || $0 - set :run, Proc.new { $0 == app_file } + set :run, proc { File.expand_path($0) == File.expand_path(app_file) } if run? && ARGV.any? - require 'optparse' - OptionParser.new { |op| - op.on('-x') { set :lock, true } - op.on('-e env') { |val| set :environment, val.to_sym } - op.on('-s server') { |val| set :server, val } - op.on('-p port') { |val| set :port, val.to_i } - op.on('-h addr') { |val| set :host, val } - }.parse!(ARGV.dup) - end + error = PARAMS_CONFIG.delete(:optparse_error) + raise error if error - at_exit do - run! if $!.nil? && run? + PARAMS_CONFIG.each { |k, v| set k, v } end end + + remove_const(:PARAMS_CONFIG) + at_exit { Application.run! if $!.nil? && Application.run? } end -include Sinatra::Delegator +# include would include the module in Object +# extend only extends the `main` object +extend Sinatra::Delegator + +class Rack::Builder + include Sinatra::Delegator +end diff --git a/lib/sinatra/showexceptions.rb b/lib/sinatra/show_exceptions.rb similarity index 70% rename from lib/sinatra/showexceptions.rb rename to lib/sinatra/show_exceptions.rb index c5aa178849..db847ffab7 100644 --- a/lib/sinatra/showexceptions.rb +++ b/lib/sinatra/show_exceptions.rb @@ -1,24 +1,77 @@ -require 'rack/showexceptions' +# frozen_string_literal: true + +require 'rack/show_exceptions' module Sinatra + # Sinatra::ShowExceptions catches all exceptions raised from the app it + # wraps. It shows a useful backtrace with the sourcefile and clickable + # context, the whole Rack environment and the request data. + # + # Be careful when you use this on public-facing sites as it could reveal + # information helpful to attackers. class ShowExceptions < Rack::ShowExceptions + @@eats_errors = Object.new + def @@eats_errors.flush(*) end + + def @@eats_errors.puts(*) end + def initialize(app) - @app = app - @template = ERB.new(TEMPLATE) + @app = app + end + + def call(env) + @app.call(env) + rescue Exception => e + errors = env['rack.errors'] + env['rack.errors'] = @@eats_errors + + if prefers_plain_text?(env) + content_type = 'text/plain' + body = dump_exception(e) + else + content_type = 'text/html' + body = pretty(env, e) + end + + env['rack.errors'] = errors + + [ + 500, + { + 'Content-Type' => content_type, + 'Content-Length' => body.bytesize.to_s + }, + [body] + ] + end + + def template + TEMPLATE + end + + private + + def bad_request?(exception) + Sinatra::BadRequest === exception + end + + def prefers_plain_text?(env) + Request.new(env).preferred_type('text/plain', 'text/html') != 'text/html' && + [/curl/].index { |item| item =~ env['HTTP_USER_AGENT'] } end def frame_class(frame) - if frame.filename =~ /lib\/sinatra.*\.rb/ - "framework" + if frame.filename =~ %r{lib/sinatra.*\.rb} + 'framework' elsif (defined?(Gem) && frame.filename.include?(Gem.dir)) || - frame.filename =~ /\/bin\/(\w+)$/ - "system" + frame.filename =~ %r{/bin/(\w+)\z} + 'system' else - "app" + 'app' end end -TEMPLATE = < @@ -73,14 +126,14 @@ def frame_class(frame) #explanation {font-size: 12px; color: #666666; margin: 20px 0 0 100px;} /* WRAP */ - #wrap {width: 860px; background: #FFFFFF; margin: 0 auto; + #wrap {width: 1000px; background: #FFFFFF; margin: 0 auto; padding: 30px 50px 20px 50px; border-left: 1px solid #DDDDDD; border-right: 1px solid #DDDDDD;} /* HEADER */ #header {margin: 0 auto 25px auto;} #header img {float: left;} - #header #summary {float: left; margin: 12px 0 0 20px; width:520px; + #header #summary {float: left; margin: 12px 0 0 20px; width:660px; font-family: 'Lucida Grande', 'Lucida Sans Unicode';} h1 {margin: 0; font-size: 36px; color: #981919;} h2 {margin: 0; font-size: 22px; color: #333333;} @@ -94,7 +147,7 @@ def frame_class(frame) #get, #post, #cookies, - #rack {width: 860px; margin: 0 auto 10px auto;} + #rack {width: 980px; margin: 0 auto 10px auto;} p#nav {float: right; font-size: 14px;} /* BACKTRACE */ a#expando {float: left; padding-left: 5px; color: #666666; @@ -107,7 +160,7 @@ def frame_class(frame) font-size: 12px; color: #333333;} #backtrace ul {list-style-position: outside; border: 1px solid #E9E9E9; border-bottom: 0;} - #backtrace ol {width: 808px; margin-left: 50px; + #backtrace ol {width: 920px; margin-left: 50px; font: 10px 'Lucida Console', monospace; color: #666666;} #backtrace ol li {border: 0; border-left: 1px solid #E9E9E9; padding: 2px 0;} @@ -119,10 +172,11 @@ def frame_class(frame) #backtrace.condensed .framework {display:none;} /* REQUEST DATA */ p.no-data {padding-top: 2px; font-size: 12px; color: #666666;} - table.req {width: 760px; text-align: left; font-size: 12px; + table.req {width: 980px; text-align: left; font-size: 12px; color: #666666; padding: 0; border-spacing: 0; border: 1px solid #EEEEEE; border-bottom: 0; - border-left: 0;} + border-left: 0; + clear:both} table.req tr th {padding: 2px 10px; font-weight: bold; background: #F7F7F7; border-bottom: 1px solid #EEEEEE; border-left: 1px solid #EEEEEE;} @@ -132,12 +186,15 @@ def frame_class(frame) /* HIDE PRE/POST CODE AT START */ .pre-context, .post-context {display: none;} + + table td.code {width:750px} + table td.code div {width:750px;overflow:hidden}
-HTML + HTML end end diff --git a/lib/sinatra/tilt.rb b/lib/sinatra/tilt.rb deleted file mode 100644 index a5bd72d5ad..0000000000 --- a/lib/sinatra/tilt.rb +++ /dev/null @@ -1,509 +0,0 @@ -module Tilt - VERSION = '0.4' - - @template_mappings = {} - - # Hash of template path pattern => template implementation - # class mappings. - def self.mappings - @template_mappings - end - - # Register a template implementation by file extension. - def self.register(ext, template_class) - ext = ext.to_s.sub(/^\./, '') - mappings[ext.downcase] = template_class - end - - # Create a new template for the given file using the file's extension - # to determine the the template mapping. - def self.new(file, line=nil, options={}, &block) - if template_class = self[file] - template_class.new(file, line, options, &block) - else - fail "No template engine registered for #{File.basename(file)}" - end - end - - # Lookup a template class given for the given filename or file - # extension. Return nil when no implementation is found. - def self.[](file) - if @template_mappings.key?(pattern = file.to_s.downcase) - @template_mappings[pattern] - elsif @template_mappings.key?(pattern = File.basename(pattern)) - @template_mappings[pattern] - else - while !pattern.empty? - if @template_mappings.key?(pattern) - return @template_mappings[pattern] - else - pattern = pattern.sub(/^[^.]*\.?/, '') - end - end - nil - end - end - - - # Base class for template implementations. Subclasses must implement - # the #compile! method and one of the #evaluate or #template_source - # methods. - class Template - # Template source; loaded from a file or given directly. - attr_reader :data - - # The name of the file where the template data was loaded from. - attr_reader :file - - # The line number in #file where template data was loaded from. - attr_reader :line - - # A Hash of template engine specific options. This is passed directly - # to the underlying engine and is not used by the generic template - # interface. - attr_reader :options - - # Create a new template with the file, line, and options specified. By - # default, template data is read from the file specified. When a block - # is given, it should read template data and return as a String. When - # file is nil, a block is required. - # - # The #initialize_engine method is called if this is the very first - # time this template subclass has been initialized. - def initialize(file=nil, line=1, options={}, &block) - raise ArgumentError, "file or block required" if file.nil? && block.nil? - options, line = line, 1 if line.is_a?(Hash) - @file = file - @line = line || 1 - @options = options || {} - @reader = block || lambda { |t| File.read(file) } - - if !self.class.engine_initialized - initialize_engine - self.class.engine_initialized = true - end - end - - # Called once and only once for each template subclass the first time - # the template class is initialized. This should be used to require the - # underlying template library and perform any initial setup. - def initialize_engine - end - @engine_initialized = false - class << self ; attr_accessor :engine_initialized ; end - - - # Load template source and compile the template. The template is - # loaded and compiled the first time this method is called; subsequent - # calls are no-ops. - def compile - if @data.nil? - @data = @reader.call(self) - compile! - end - end - - # Render the template in the given scope with the locals specified. If a - # block is given, it is typically available within the template via - # +yield+. - def render(scope=Object.new, locals={}, &block) - compile - evaluate scope, locals || {}, &block - end - - # The basename of the template file. - def basename(suffix='') - File.basename(file, suffix) if file - end - - # The template file's basename with all extensions chomped off. - def name - basename.split('.', 2).first if basename - end - - # The filename used in backtraces to describe the template. - def eval_file - file || '(__TEMPLATE__)' - end - - protected - # Do whatever preparation is necessary to "compile" the template. - # Called immediately after template #data is loaded. Instance variables - # set in this method are available when #evaluate is called. - # - # Subclasses must provide an implementation of this method. - def compile! - raise NotImplementedError - end - - # Process the template and return the result. Subclasses should override - # this method unless they implement the #template_source. - def evaluate(scope, locals, &block) - source, offset = local_assignment_code(locals) - source = [source, template_source].join("\n") - scope.instance_eval source, eval_file, line - offset - end - - # Return a string containing the (Ruby) source code for the template. The - # default Template#evaluate implementation requires this method be - # defined. - def template_source - raise NotImplementedError - end - - private - def local_assignment_code(locals) - return ['', 1] if locals.empty? - source = locals.collect { |k,v| "#{k} = locals[:#{k}]" } - [source.join("\n"), source.length] - end - - def require_template_library(name) - if Thread.list.size > 1 - warn "WARN: tilt autoloading '#{name}' in a non thread-safe way; " + - "explicit require '#{name}' suggested." - end - require name - end - end - - # Extremely simple template cache implementation. Calling applications - # create a Tilt::Cache instance and use #fetch with any set of hashable - # arguments (such as those to Tilt.new): - # cache = Tilt::Cache.new - # cache.fetch(path, line, options) { Tilt.new(path, line, options) } - # - # Subsequent invocations return the already compiled template object. - class Cache - def initialize - @cache = {} - end - - def fetch(*key) - @cache[key] ||= yield - end - - def clear - @cache = {} - end - end - - - # Template Implementations ================================================ - - - # The template source is evaluated as a Ruby string. The #{} interpolation - # syntax can be used to generated dynamic output. - class StringTemplate < Template - def compile! - @code = "%Q{#{data}}" - end - - def template_source - @code - end - end - register 'str', StringTemplate - - - # ERB template implementation. See: - # http://www.ruby-doc.org/stdlib/libdoc/erb/rdoc/classes/ERB.html - class ERBTemplate < Template - def initialize_engine - require_template_library 'erb' unless defined? ::ERB - end - - def compile! - @engine = ::ERB.new(data, options[:safe], options[:trim], '@_out_buf') - end - - def template_source - @engine.src - end - - def evaluate(scope, locals, &block) - source, offset = local_assignment_code(locals) - source = [source, template_source].join("\n") - - original_out_buf = - scope.instance_variables.any? { |var| var.to_sym == :@_out_buf } && - scope.instance_variable_get(:@_out_buf) - - scope.instance_eval source, eval_file, line - offset - - output = scope.instance_variable_get(:@_out_buf) - scope.instance_variable_set(:@_out_buf, original_out_buf) - - output - end - - private - - # ERB generates a line to specify the character coding of the generated - # source in 1.9. Account for this in the line offset. - if RUBY_VERSION >= '1.9.0' - def local_assignment_code(locals) - source, offset = super - [source, offset + 1] - end - end - end - %w[erb rhtml].each { |ext| register ext, ERBTemplate } - - - # Erubis template implementation. See: - # http://www.kuwata-lab.com/erubis/ - class ErubisTemplate < ERBTemplate - def initialize_engine - require_template_library 'erubis' unless defined? ::Erubis - end - - def compile! - Erubis::Eruby.class_eval(%Q{def add_preamble(src) src << "@_out_buf = _buf = '';" end}) - @engine = ::Erubis::Eruby.new(data, options) - end - - private - - # Erubis doesn't have ERB's line-off-by-one under 1.9 problem. Override - # and adjust back. - if RUBY_VERSION >= '1.9.0' - def local_assignment_code(locals) - source, offset = super - [source, offset - 1] - end - end - end - register 'erubis', ErubisTemplate - - - # Haml template implementation. See: - # http://haml.hamptoncatlin.com/ - class HamlTemplate < Template - def initialize_engine - require_template_library 'haml' unless defined? ::Haml::Engine - end - - def compile! - @engine = ::Haml::Engine.new(data, haml_options) - end - - def evaluate(scope, locals, &block) - @engine.render(scope, locals, &block) - end - - private - def haml_options - options.merge(:filename => eval_file, :line => line) - end - end - register 'haml', HamlTemplate - - - # Sass template implementation. See: - # http://haml.hamptoncatlin.com/ - # - # Sass templates do not support object scopes, locals, or yield. - class SassTemplate < Template - def initialize_engine - require_template_library 'sass' unless defined? ::Sass::Engine - end - - def compile! - @engine = ::Sass::Engine.new(data, sass_options) - end - - def evaluate(scope, locals, &block) - @engine.render - end - - private - def sass_options - options.merge(:filename => eval_file, :line => line) - end - end - register 'sass', SassTemplate - - - # Builder template implementation. See: - # http://builder.rubyforge.org/ - class BuilderTemplate < Template - def initialize_engine - require_template_library 'builder' unless defined?(::Builder) - end - - def compile! - end - - def evaluate(scope, locals, &block) - xml = ::Builder::XmlMarkup.new(:indent => 2) - if data.respond_to?(:to_str) - locals[:xml] = xml - super(scope, locals, &block) - elsif data.kind_of?(Proc) - data.call(xml) - end - xml.target! - end - - def template_source - data.to_str - end - end - register 'builder', BuilderTemplate - - - # Liquid template implementation. See: - # http://liquid.rubyforge.org/ - # - # Liquid is designed to be a *safe* template system and threfore - # does not provide direct access to execuatable scopes. In order to - # support a +scope+, the +scope+ must be able to represent itself - # as a hash by responding to #to_h. If the +scope+ does not respond - # to #to_h it will be ignored. - # - # LiquidTemplate does not support yield blocks. - # - # It's suggested that your program require 'liquid' at load - # time when using this template engine. - class LiquidTemplate < Template - def initialize_engine - require_template_library 'liquid' unless defined? ::Liquid::Template - end - - def compile! - @engine = ::Liquid::Template.parse(data) - end - - def evaluate(scope, locals, &block) - locals = locals.inject({}){ |h,(k,v)| h[k.to_s] = v ; h } - if scope.respond_to?(:to_h) - scope = scope.to_h.inject({}){ |h,(k,v)| h[k.to_s] = v ; h } - locals = scope.merge(locals) - end - # TODO: Is it possible to lazy yield ? - locals['yield'] = block.nil? ? '' : yield - locals['content'] = block.nil? ? '' : yield - @engine.render(locals) - end - end - register 'liquid', LiquidTemplate - - - # Discount Markdown implementation. See: - # http://github.com/rtomayko/rdiscount - # - # RDiscount is a simple text filter. It does not support +scope+ or - # +locals+. The +:smart+ and +:filter_html+ options may be set true - # to enable those flags on the underlying RDiscount object. - class RDiscountTemplate < Template - def flags - [:smart, :filter_html].select { |flag| options[flag] } - end - - def initialize_engine - require_template_library 'rdiscount' unless defined? ::RDiscount - end - - def compile! - @engine = RDiscount.new(data, *flags) - end - - def evaluate(scope, locals, &block) - @engine.to_html - end - end - register 'markdown', RDiscountTemplate - register 'mkd', RDiscountTemplate - register 'md', RDiscountTemplate - - -# RedCloth implementation. See: -# http://redcloth.org/ -class RedClothTemplate < Template - def initialize_engine - require_template_library 'redcloth' unless defined? ::RedCloth - end - - def compile! - @engine = RedCloth.new(data) - end - - def evaluate(scope, locals, &block) - @engine.to_html - end -end -register 'textile', RedClothTemplate - - - # Mustache is written and maintained by Chris Wanstrath. See: - # http://github.com/defunkt/mustache - # - # When a scope argument is provided to MustacheTemplate#render, the - # instance variables are copied from the scope object to the Mustache - # view. - class MustacheTemplate < Template - attr_reader :engine - - def initialize_engine - require_template_library 'mustache' unless defined? ::Mustache - end - - def compile! - Mustache.view_namespace = options[:namespace] - @engine = options[:view] || Mustache.view_class(name) - options.each do |key, value| - next if %w[view namespace mustaches].include?(key.to_s) - @engine.send("#{key}=", value) if @engine.respond_to? "#{key}=" - end - end - - def evaluate(scope=nil, locals={}, &block) - instance = @engine.new - - # copy instance variables from scope to the view - scope.instance_variables.each do |name| - instance.instance_variable_set(name, scope.instance_variable_get(name)) - end - - # locals get added to the view's context - locals.each do |local, value| - instance[local] = value - end - - # if we're passed a block it's a subview. Sticking it in yield - # lets us use {{yield}} in layout.html to render the actual page. - instance[:yield] = block.call if block - - instance.template = data unless instance.compiled? - - instance.to_html - end - end - register 'mustache', MustacheTemplate - - # RDoc template. See: - # http://rdoc.rubyforge.org/ - # - # It's suggested that your program require 'rdoc/markup' and - # 'rdoc/markup/to_html' at load time when using this template - # engine. - class RDocTemplate < Template - def initialize_engine - unless defined?(::RDoc::Markup) - require_template_library 'rdoc/markup' - require_template_library 'rdoc/markup/to_html' - end - end - - def compile! - markup = RDoc::Markup::ToHtml.new - @engine = markup.convert(data) - end - - def evaluate(scope, locals, &block) - @engine.to_s - end - end - register 'rdoc', RDocTemplate -end diff --git a/lib/sinatra/version.rb b/lib/sinatra/version.rb new file mode 100644 index 0000000000..58501272fd --- /dev/null +++ b/lib/sinatra/version.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module Sinatra + VERSION = '3.0.5' +end diff --git a/rack-protection/.gitignore b/rack-protection/.gitignore new file mode 100644 index 0000000000..3743ebb669 --- /dev/null +++ b/rack-protection/.gitignore @@ -0,0 +1,4 @@ +# please add general patterns to your global ignore list +# see https://github.com/github/gitignore#readme +Gemfile.lock +doc/ diff --git a/rack-protection/.rspec b/rack-protection/.rspec new file mode 100644 index 0000000000..0d786ba0e1 --- /dev/null +++ b/rack-protection/.rspec @@ -0,0 +1,3 @@ +--color +--warnings +--require spec_helper diff --git a/rack-protection/Gemfile b/rack-protection/Gemfile new file mode 100644 index 0000000000..690d4ca07d --- /dev/null +++ b/rack-protection/Gemfile @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +source 'https://rubygems.org' +# encoding: utf-8 + +gem 'rake' + +rack_version = ENV['rack'].to_s +rack_version = nil if rack_version.empty? || (rack_version == 'stable') +rack_version = { github: 'rack/rack' } if rack_version == 'head' +gem 'rack', rack_version + +gem 'sinatra', path: '..' + +gemspec + +gem 'rack-test', github: 'rack/rack-test' diff --git a/rack-protection/License b/rack-protection/License new file mode 100644 index 0000000000..29cdd92f75 --- /dev/null +++ b/rack-protection/License @@ -0,0 +1,23 @@ +The MIT License (MIT) + +Copyright (c) 2011-2017 Konstantin Haase +Copyright (c) 2015-2017 Zachary Scott + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/rack-protection/README.md b/rack-protection/README.md new file mode 100644 index 0000000000..82fbf9842f --- /dev/null +++ b/rack-protection/README.md @@ -0,0 +1,118 @@ +# Rack::Protection + +This gem protects against typical web attacks. +Should work for all Rack apps, including Rails. + +# Usage + +Use all protections you probably want to use: + +``` ruby +# config.ru +require 'rack/protection' +use Rack::Protection +run MyApp +``` + +Skip a single protection middleware: + +``` ruby +# config.ru +require 'rack/protection' +use Rack::Protection, :except => :path_traversal +run MyApp +``` + +Use a single protection middleware: + +``` ruby +# config.ru +require 'rack/protection' +use Rack::Protection::AuthenticityToken +run MyApp +``` + +# Prevented Attacks + +## Cross Site Request Forgery + +Prevented by: + +* [`Rack::Protection::AuthenticityToken`][authenticity-token] (not included by `use Rack::Protection`) +* [`Rack::Protection::FormToken`][form-token] (not included by `use Rack::Protection`) +* [`Rack::Protection::JsonCsrf`][json-csrf] +* [`Rack::Protection::RemoteReferrer`][remote-referrer] (not included by `use Rack::Protection`) +* [`Rack::Protection::RemoteToken`][remote-token] +* [`Rack::Protection::HttpOrigin`][http-origin] + +## Cross Site Scripting + +Prevented by: + +* [`Rack::Protection::EscapedParams`][escaped-params] (not included by `use Rack::Protection`) +* [`Rack::Protection::XSSHeader`][xss-header] (Internet Explorer and Chrome only) +* [`Rack::Protection::ContentSecurityPolicy`][content-security-policy] + +## Clickjacking + +Prevented by: + +* [`Rack::Protection::FrameOptions`][frame-options] + +## Directory Traversal + +Prevented by: + +* [`Rack::Protection::PathTraversal`][path-traversal] + +## Session Hijacking + +Prevented by: + +* [`Rack::Protection::SessionHijacking`][session-hijacking] + +## Cookie Tossing + +Prevented by: +* [`Rack::Protection::CookieTossing`][cookie-tossing] (not included by `use Rack::Protection`) + +## IP Spoofing + +Prevented by: + +* [`Rack::Protection::IPSpoofing`][ip-spoofing] + +## Helps to protect against protocol downgrade attacks and cookie hijacking + +Prevented by: + +* [`Rack::Protection::StrictTransport`][strict-transport] (not included by `use Rack::Protection`) + +# Installation + + gem install rack-protection + +# Instrumentation + +Instrumentation is enabled by passing in an instrumenter as an option. +``` +use Rack::Protection, instrumenter: ActiveSupport::Notifications +``` + +The instrumenter is passed a namespace (String) and environment (Hash). The namespace is 'rack.protection' and the attack type can be obtained from the environment key 'rack.protection.attack'. + +[authenticity-token]: http://www.sinatrarb.com/protection/authenticity_token +[content-security-policy]: http://www.sinatrarb.com/protection/content_security_policy +[cookie-tossing]: http://www.sinatrarb.com/protection/cookie_tossing +[escaped-params]: http://www.sinatrarb.com/protection/escaped_params +[form-token]: http://www.sinatrarb.com/protection/form_token +[frame-options]: http://www.sinatrarb.com/protection/frame_options +[http-origin]: http://www.sinatrarb.com/protection/http_origin +[ip-spoofing]: http://www.sinatrarb.com/protection/ip_spoofing +[json-csrf]: http://www.sinatrarb.com/protection/json_csrf +[path-traversal]: http://www.sinatrarb.com/protection/path_traversal +[remote-referrer]: http://www.sinatrarb.com/protection/remote_referrer +[remote-token]: http://www.sinatrarb.com/protection/remote_token +[session-hijacking]: http://www.sinatrarb.com/protection/session_hijacking +[strict-transport]: http://www.sinatrarb.com/protection/strict_transport +[xss-header]: http://www.sinatrarb.com/protection/xss_header diff --git a/rack-protection/Rakefile b/rack-protection/Rakefile new file mode 100644 index 0000000000..4ccffd5ffa --- /dev/null +++ b/rack-protection/Rakefile @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +$LOAD_PATH.unshift File.expand_path('lib', __dir__) + +begin + require 'bundler' + Bundler::GemHelper.install_tasks +rescue LoadError => e + warn e +end + +desc 'run specs' +task(:spec) { ruby '-S rspec' } + +namespace :doc do + task :readmes do + Dir.glob 'lib/rack/protection/*.rb' do |file| + excluded_files = %w[lib/rack/protection/base.rb lib/rack/protection/version.rb] + next if excluded_files.include?(file) + + doc = File.read(file)[/^ module Protection(\n)+( #[^\n]*\n)*/m].scan(/^ *#(?!#) ?(.*)\n/).join("\n") + file = "doc/#{file[4..-4].tr('/_', '-')}.rdoc" + Dir.mkdir 'doc' unless File.directory? 'doc' + puts "writing #{file}" + File.open(file, 'w') { |f| f << doc } + end + end + + task :index do + doc = File.read('README.md') + file = 'doc/rack-protection-readme.md' + Dir.mkdir 'doc' unless File.directory? 'doc' + puts "writing #{file}" + File.open(file, 'w') { |f| f << doc } + end + + task all: %i[readmes index] +end + +desc 'generate documentation' +task doc: 'doc:all' + +desc 'generate gemspec' +task 'rack-protection.gemspec' do + require 'rack/protection/version' + content = File.binread 'rack-protection.gemspec' + + # fetch data + fields = { + authors: `git shortlog -sn`.force_encoding('utf-8').scan(/[^\d\s].*/), + email: ['mail@zzak.io', 'konstantin.haase@gmail.com'], + files: %w[License README.md Rakefile Gemfile rack-protection.gemspec] + Dir['lib/**/*'] + } + + # insert data + fields.each do |field, values| + updated = " s.#{field} = [" + updated << values.map { |v| "\n %p" % v }.join(',') + updated << "\n ]" + content.sub!(/ s\.#{field} = \[\n( .*\n)* \]/, updated) + end + + # set version + content.sub! /(s\.version.*=\s+).*/, "\\1\"#{Rack::Protection::VERSION}\"" + + # escape unicode + content.gsub!(/./) { |c| c.bytesize > 1 ? "\\u{#{c.codepoints.first.to_s(16)}}" : c } + + File.open('rack-protection.gemspec', 'w') { |f| f << content } +end + +task gemspec: 'rack-protection.gemspec' +task default: :spec +task test: :spec diff --git a/rack-protection/lib/rack-protection.rb b/rack-protection/lib/rack-protection.rb new file mode 100644 index 0000000000..795d37a774 --- /dev/null +++ b/rack-protection/lib/rack-protection.rb @@ -0,0 +1 @@ +require 'rack/protection' diff --git a/rack-protection/lib/rack/protection.rb b/rack-protection/lib/rack/protection.rb new file mode 100644 index 0000000000..21d2ec9268 --- /dev/null +++ b/rack-protection/lib/rack/protection.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require 'rack/protection/version' +require 'rack' + +module Rack + module Protection + autoload :AuthenticityToken, 'rack/protection/authenticity_token' + autoload :Base, 'rack/protection/base' + autoload :CookieTossing, 'rack/protection/cookie_tossing' + autoload :ContentSecurityPolicy, 'rack/protection/content_security_policy' + autoload :Encryptor, 'rack/protection/encryptor' + autoload :EncryptedCookie, 'rack/protection/encrypted_cookie' + autoload :EscapedParams, 'rack/protection/escaped_params' + autoload :FormToken, 'rack/protection/form_token' + autoload :FrameOptions, 'rack/protection/frame_options' + autoload :HttpOrigin, 'rack/protection/http_origin' + autoload :IPSpoofing, 'rack/protection/ip_spoofing' + autoload :JsonCsrf, 'rack/protection/json_csrf' + autoload :PathTraversal, 'rack/protection/path_traversal' + autoload :ReferrerPolicy, 'rack/protection/referrer_policy' + autoload :RemoteReferrer, 'rack/protection/remote_referrer' + autoload :RemoteToken, 'rack/protection/remote_token' + autoload :SessionHijacking, 'rack/protection/session_hijacking' + autoload :StrictTransport, 'rack/protection/strict_transport' + autoload :XSSHeader, 'rack/protection/xss_header' + + def self.new(app, options = {}) + # does not include: RemoteReferrer, AuthenticityToken and FormToken + except = Array options[:except] + use_these = Array options[:use] + + if options.fetch(:without_session, false) + except += %i[session_hijacking remote_token] + end + + Rack::Builder.new do + # Off by default, unless added + use ::Rack::Protection::AuthenticityToken, options if use_these.include? :authenticity_token + use ::Rack::Protection::ContentSecurityPolicy, options if use_these.include? :content_security_policy + use ::Rack::Protection::CookieTossing, options if use_these.include? :cookie_tossing + use ::Rack::Protection::EscapedParams, options if use_these.include? :escaped_params + use ::Rack::Protection::FormToken, options if use_these.include? :form_token + use ::Rack::Protection::ReferrerPolicy, options if use_these.include? :referrer_policy + use ::Rack::Protection::RemoteReferrer, options if use_these.include? :remote_referrer + use ::Rack::Protection::StrictTransport, options if use_these.include? :strict_transport + + # On by default, unless skipped + use ::Rack::Protection::FrameOptions, options unless except.include? :frame_options + use ::Rack::Protection::HttpOrigin, options unless except.include? :http_origin + use ::Rack::Protection::IPSpoofing, options unless except.include? :ip_spoofing + use ::Rack::Protection::JsonCsrf, options unless except.include? :json_csrf + use ::Rack::Protection::PathTraversal, options unless except.include? :path_traversal + use ::Rack::Protection::RemoteToken, options unless except.include? :remote_token + use ::Rack::Protection::SessionHijacking, options unless except.include? :session_hijacking + use ::Rack::Protection::XSSHeader, options unless except.include? :xss_header + run app + end.to_app + end + end +end diff --git a/rack-protection/lib/rack/protection/authenticity_token.rb b/rack-protection/lib/rack/protection/authenticity_token.rb new file mode 100644 index 0000000000..f5c8db62d4 --- /dev/null +++ b/rack-protection/lib/rack/protection/authenticity_token.rb @@ -0,0 +1,255 @@ +# frozen_string_literal: true + +require 'rack/protection' +require 'securerandom' +require 'openssl' +require 'base64' + +module Rack + module Protection + ## + # Prevented attack:: CSRF + # Supported browsers:: all + # More infos:: http://en.wikipedia.org/wiki/Cross-site_request_forgery + # + # This middleware only accepts requests other than GET, + # HEAD, OPTIONS, TRACE if their given access + # token matches the token included in the session. + # + # It checks the X-CSRF-Token header and the POST form + # data. + # + # It is not OOTB-compatible with the {rack-csrf}[https://rubygems.org/gems/rack_csrf] gem. + # For that, the following patch needs to be applied: + # + # Rack::Protection::AuthenticityToken.default_options(key: "csrf.token", authenticity_param: "_csrf") + # + # == Options + # + # [:authenticity_param] the name of the param that should contain + # the token on a request. Default value: + # "authenticity_token" + # + # [:key] the name of the param that should contain + # the token in the session. Default value: + # :csrf + # + # [:allow_if] a proc for custom allow/deny logic. Default value: + # nil + # + # == Example: Forms application + # + # To show what the AuthenticityToken does, this section includes a sample + # program which shows two forms. One with, and one without a CSRF token + # The one without CSRF token field will get a 403 Forbidden response. + # + # Install the gem, then run the program: + # + # gem install 'rack-protection' + # ruby server.rb + # + # Here is server.rb: + # + # require 'rack/protection' + # + # app = Rack::Builder.app do + # use Rack::Session::Cookie, secret: 'secret' + # use Rack::Protection::AuthenticityToken + # + # run -> (env) do + # [200, {}, [ + # <<~EOS + # + # + # + # + # rack-protection minimal example + # + # + #

Without Authenticity Token

+ #

This takes you to Forbidden

+ #
+ # + # + #
+ # + #

With Authenticity Token

+ #

This successfully takes you to back to this form.

+ #
+ # + # + # + #
+ # + # + # EOS + # ]] + # end + # end + # + # Rack::Handler::WEBrick.run app + # + # == Example: Customize which POST parameter holds the token + # + # To customize the authenticity parameter for form data, use the + # :authenticity_param option: + # use Rack::Protection::AuthenticityToken, authenticity_param: 'your_token_param_name' + class AuthenticityToken < Base + TOKEN_LENGTH = 32 + + default_options authenticity_param: 'authenticity_token', + key: :csrf, + allow_if: nil + + def self.token(session, path: nil, method: :post) + new(nil).mask_authenticity_token(session, path: path, method: method) + end + + def self.random_token + SecureRandom.urlsafe_base64(TOKEN_LENGTH, padding: false) + end + + def accepts?(env) + session = session(env) + set_token(session) + + safe?(env) || + valid_token?(env, env['HTTP_X_CSRF_TOKEN']) || + valid_token?(env, Request.new(env).params[options[:authenticity_param]]) || + options[:allow_if]&.call(env) + rescue StandardError + false + end + + def mask_authenticity_token(session, path: nil, method: :post) + set_token(session) + + token = if path && method + per_form_token(session, path, method) + else + global_token(session) + end + + mask_token(token) + end + + GLOBAL_TOKEN_IDENTIFIER = '!real_csrf_token' + private_constant :GLOBAL_TOKEN_IDENTIFIER + + private + + def set_token(session) + session[options[:key]] ||= self.class.random_token + end + + # Checks the client's masked token to see if it matches the + # session token. + def valid_token?(env, token) + return false if token.nil? || !token.is_a?(String) || token.empty? + + session = session(env) + + begin + token = decode_token(token) + rescue ArgumentError # encoded_masked_token is invalid Base64 + return false + end + + # See if it's actually a masked token or not. We should be able + # to handle any unmasked tokens that we've issued without error. + + if unmasked_token?(token) + compare_with_real_token(token, session) + elsif masked_token?(token) + token = unmask_token(token) + + compare_with_global_token(token, session) || + compare_with_real_token(token, session) || + compare_with_per_form_token(token, session, Request.new(env)) + else + false # Token is malformed + end + end + + # Creates a masked version of the authenticity token that varies + # on each request. The masking is used to mitigate SSL attacks + # like BREACH. + def mask_token(token) + one_time_pad = SecureRandom.random_bytes(token.length) + encrypted_token = xor_byte_strings(one_time_pad, token) + masked_token = one_time_pad + encrypted_token + encode_token(masked_token) + end + + # Essentially the inverse of +mask_token+. + def unmask_token(masked_token) + # Split the token into the one-time pad and the encrypted + # value and decrypt it + token_length = masked_token.length / 2 + one_time_pad = masked_token[0...token_length] + encrypted_token = masked_token[token_length..] + xor_byte_strings(one_time_pad, encrypted_token) + end + + def unmasked_token?(token) + token.length == TOKEN_LENGTH + end + + def masked_token?(token) + token.length == TOKEN_LENGTH * 2 + end + + def compare_with_real_token(token, session) + secure_compare(token, real_token(session)) + end + + def compare_with_global_token(token, session) + secure_compare(token, global_token(session)) + end + + def compare_with_per_form_token(token, session, request) + secure_compare(token, + per_form_token(session, request.path.chomp('/'), request.request_method)) + end + + def real_token(session) + decode_token(session[options[:key]]) + end + + def global_token(session) + token_hmac(session, GLOBAL_TOKEN_IDENTIFIER) + end + + def per_form_token(session, path, method) + token_hmac(session, "#{path}##{method.downcase}") + end + + def encode_token(token) + Base64.urlsafe_encode64(token) + end + + def decode_token(token) + Base64.urlsafe_decode64(token) + end + + def token_hmac(session, identifier) + OpenSSL::HMAC.digest( + OpenSSL::Digest.new('SHA256'), + real_token(session), + identifier + ) + end + + def xor_byte_strings(s1, s2) + s2 = s2.dup + size = s1.bytesize + i = 0 + while i < size + s2.setbyte(i, s1.getbyte(i) ^ s2.getbyte(i)) + i += 1 + end + s2 + end + end + end +end diff --git a/rack-protection/lib/rack/protection/base.rb b/rack-protection/lib/rack/protection/base.rb new file mode 100644 index 0000000000..a1314adb82 --- /dev/null +++ b/rack-protection/lib/rack/protection/base.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +require 'rack/protection' +require 'rack/utils' +require 'digest' +require 'logger' +require 'uri' + +module Rack + module Protection + class Base + DEFAULT_OPTIONS = { + reaction: :default_reaction, logging: true, + message: 'Forbidden', encryptor: Digest::SHA1, + session_key: 'rack.session', status: 403, + allow_empty_referrer: true, + report_key: 'protection.failed', + html_types: %w[text/html application/xhtml text/xml application/xml] + } + + attr_reader :app, :options + + def self.default_options(options) + define_method(:default_options) { super().merge(options) } + end + + def self.default_reaction(reaction) + alias_method(:default_reaction, reaction) + end + + def default_options + DEFAULT_OPTIONS + end + + def initialize(app, options = {}) + @app = app + @options = default_options.merge(options) + end + + def safe?(env) + %w[GET HEAD OPTIONS TRACE].include? env['REQUEST_METHOD'] + end + + def accepts?(env) + raise NotImplementedError, "#{self.class} implementation pending" + end + + def call(env) + unless accepts? env + instrument env + result = react env + end + result or app.call(env) + end + + def react(env) + result = send(options[:reaction], env) + result if (Array === result) && (result.size == 3) + end + + def warn(env, message) + return unless options[:logging] + + l = options[:logger] || env['rack.logger'] || ::Logger.new(env['rack.errors']) + l.warn(message) + end + + def instrument(env) + return unless (i = options[:instrumenter]) + + env['rack.protection.attack'] = self.class.name.split('::').last.downcase + i.instrument('rack.protection', env) + end + + def deny(env) + warn env, "attack prevented by #{self.class}" + [options[:status], { 'Content-Type' => 'text/plain' }, [options[:message]]] + end + + def report(env) + warn env, "attack reported by #{self.class}" + env[options[:report_key]] = true + end + + def session?(env) + env.include? options[:session_key] + end + + def session(env) + return env[options[:session_key]] if session? env + + raise "you need to set up a session middleware *before* #{self.class}" + end + + def drop_session(env) + session(env).clear if session? env + end + + def referrer(env) + ref = env['HTTP_REFERER'].to_s + return if !options[:allow_empty_referrer] && ref.empty? + + URI.parse(ref).host || Request.new(env).host + rescue URI::InvalidURIError + end + + def origin(env) + env['HTTP_ORIGIN'] || env['HTTP_X_ORIGIN'] + end + + def random_string(secure = defined? SecureRandom) + secure ? SecureRandom.hex(16) : '%032x' % rand((2**128) - 1) + rescue NotImplementedError + random_string false + end + + def encrypt(value) + options[:encryptor].hexdigest value.to_s + end + + def secure_compare(a, b) + Rack::Utils.secure_compare(a.to_s, b.to_s) + end + + alias default_reaction deny + + def html?(headers) + return false unless (header = headers.detect { |k, _v| k.downcase == 'content-type' }) + + options[:html_types].include? header.last[%r{^\w+/\w+}] + end + end + end +end diff --git a/rack-protection/lib/rack/protection/content_security_policy.rb b/rack-protection/lib/rack/protection/content_security_policy.rb new file mode 100644 index 0000000000..32d8ac70fa --- /dev/null +++ b/rack-protection/lib/rack/protection/content_security_policy.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require 'rack/protection' + +module Rack + module Protection + ## + # Prevented attack:: XSS and others + # Supported browsers:: Firefox 23+, Safari 7+, Chrome 25+, Opera 15+ + # + # Description:: Content Security Policy, a mechanism web applications + # can use to mitigate a broad class of content injection + # vulnerabilities, such as cross-site scripting (XSS). + # Content Security Policy is a declarative policy that lets + # the authors (or server administrators) of a web application + # inform the client about the sources from which the + # application expects to load resources. + # + # More info:: W3C CSP Level 1 : https://www.w3.org/TR/CSP1/ (deprecated) + # W3C CSP Level 2 : https://www.w3.org/TR/CSP2/ (current) + # W3C CSP Level 3 : https://www.w3.org/TR/CSP3/ (draft) + # https://developer.mozilla.org/en-US/docs/Web/Security/CSP + # http://caniuse.com/#search=ContentSecurityPolicy + # http://content-security-policy.com/ + # https://securityheaders.io + # https://scotthelme.co.uk/csp-cheat-sheet/ + # http://www.html5rocks.com/en/tutorials/security/content-security-policy/ + # + # Sets the 'Content-Security-Policy[-Report-Only]' header. + # + # Options: ContentSecurityPolicy configuration is a complex topic with + # several levels of support that has evolved over time. + # See the W3C documentation and the links in the more info + # section for CSP usage examples and best practices. The + # CSP3 directives in the 'NO_ARG_DIRECTIVES' constant need to be + # presented in the options hash with a boolean 'true' in order + # to be used in a policy. + # + class ContentSecurityPolicy < Base + default_options default_src: "'self'", report_only: false + + DIRECTIVES = %i[base_uri child_src connect_src default_src + font_src form_action frame_ancestors frame_src + img_src manifest_src media_src object_src + plugin_types referrer reflected_xss report_to + report_uri require_sri_for sandbox script_src + style_src worker_src webrtc_src navigate_to + prefetch_src].freeze + + NO_ARG_DIRECTIVES = %i[block_all_mixed_content disown_opener + upgrade_insecure_requests].freeze + + def csp_policy + directives = [] + + DIRECTIVES.each do |d| + if options.key?(d) + directives << "#{d.to_s.sub(/_/, '-')} #{options[d]}" + end + end + + # Set these key values to boolean 'true' to include in policy + NO_ARG_DIRECTIVES.each do |d| + if options.key?(d) && options[d].is_a?(TrueClass) + directives << d.to_s.tr('_', '-') + end + end + + directives.compact.sort.join('; ') + end + + def call(env) + status, headers, body = @app.call(env) + header = options[:report_only] ? 'Content-Security-Policy-Report-Only' : 'Content-Security-Policy' + headers[header] ||= csp_policy if html? headers + [status, headers, body] + end + end + end +end diff --git a/rack-protection/lib/rack/protection/cookie_tossing.rb b/rack-protection/lib/rack/protection/cookie_tossing.rb new file mode 100644 index 0000000000..0a7f43ad54 --- /dev/null +++ b/rack-protection/lib/rack/protection/cookie_tossing.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require 'rack/protection' +require 'pathname' + +module Rack + module Protection + ## + # Prevented attack:: Cookie Tossing + # Supported browsers:: all + # More infos:: https://github.com/blog/1466-yummy-cookies-across-domains + # + # Does not accept HTTP requests if the HTTP_COOKIE header contains more than one + # session cookie. This does not protect against a cookie overflow attack. + # + # Options: + # + # session_key:: The name of the session cookie (default: 'rack.session') + class CookieTossing < Base + default_reaction :deny + + def call(env) + status, headers, body = super + response = Rack::Response.new(body, status, headers) + request = Rack::Request.new(env) + remove_bad_cookies(request, response) + response.finish + end + + def accepts?(env) + cookie_header = env['HTTP_COOKIE'] + cookies = Rack::Utils.parse_query(cookie_header, ';,') { |s| s } + cookies.each do |k, v| + if (k == session_key && Array(v).size > 1) || + (k != session_key && Rack::Utils.unescape(k) == session_key) + bad_cookies << k + end + end + bad_cookies.empty? + end + + def remove_bad_cookies(request, response) + return if bad_cookies.empty? + + paths = cookie_paths(request.path) + bad_cookies.each do |name| + paths.each { |path| response.set_cookie name, empty_cookie(request.host, path) } + end + end + + def redirect(env) + request = Request.new(env) + warn env, "attack prevented by #{self.class}" + [302, { 'Content-Type' => 'text/html', 'Location' => request.path }, []] + end + + def bad_cookies + @bad_cookies ||= [] + end + + def cookie_paths(path) + path = '/' if path.to_s.empty? + paths = [] + Pathname.new(path).descend { |p| paths << p.to_s } + paths + end + + def empty_cookie(host, path) + { value: '', domain: host, path: path, expires: Time.at(0) } + end + + def session_key + @session_key ||= options[:session_key] + end + end + end +end diff --git a/rack-protection/lib/rack/protection/encrypted_cookie.rb b/rack-protection/lib/rack/protection/encrypted_cookie.rb new file mode 100644 index 0000000000..bf682312a9 --- /dev/null +++ b/rack-protection/lib/rack/protection/encrypted_cookie.rb @@ -0,0 +1,273 @@ +# frozen_string_literal: true + +require 'openssl' +require 'zlib' +require 'json' +require 'rack/request' +require 'rack/response' +require 'rack/session/abstract/id' + +module Rack + module Protection + # Rack::Protection::EncryptedCookie provides simple cookie based session management. + # By default, the session is a Ruby Hash stored as base64 encoded marshalled + # data set to :key (default: rack.session). The object that encodes the + # session data is configurable and must respond to +encode+ and +decode+. + # Both methods must take a string and return a string. + # + # When the secret key is set, cookie data is checked for data integrity. + # The old_secret key is also accepted and allows graceful secret rotation. + # A legacy_hmac_secret is also accepted and is used to upgrade existing + # sessions to the new encryption scheme. + # + # There is also a legacy_hmac_coder option which can be set if a non-default + # coder was used for legacy session cookies. + # + # Example: + # + # use Rack::Protection::EncryptedCookie, + # :key => 'rack.session', + # :domain => 'foo.com', + # :path => '/', + # :expire_after => 2592000, + # :secret => 'change_me', + # :old_secret => 'old_secret' + # + # All parameters are optional. + # + # Example using legacy HMAC options + # + # Rack::Protection:EncryptedCookie.new(application, { + # # The secret used for legacy HMAC cookies + # legacy_hmac_secret: 'legacy secret', + # # legacy_hmac_coder will default to Rack::Protection::EncryptedCookie::Base64::Marshal + # legacy_hmac_coder: Rack::Protection::EncryptedCookie::Identity.new, + # # legacy_hmac will default to OpenSSL::Digest::SHA1 + # legacy_hmac: OpenSSL::Digest::SHA256 + # }) + # + # Example of a cookie with no encoding: + # + # Rack::Protection::EncryptedCookie.new(application, { + # :coder => Rack::Protection::EncryptedCookie::Identity.new + # }) + # + # Example of a cookie with custom encoding: + # + # Rack::Protection::EncryptedCookie.new(application, { + # :coder => Class.new { + # def encode(str); str.reverse; end + # def decode(str); str.reverse; end + # }.new + # }) + # + class EncryptedCookie < Rack::Session::Abstract::Persisted + # Encode session cookies as Base64 + class Base64 + def encode(str) + [str].pack('m0') + end + + def decode(str) + str.unpack1('m') + end + + # Encode session cookies as Marshaled Base64 data + class Marshal < Base64 + def encode(str) + super(::Marshal.dump(str)) + end + + def decode(str) + return unless str + + begin + ::Marshal.load(super(str)) + rescue StandardError + nil + end + end + end + + # N.B. Unlike other encoding methods, the contained objects must be a + # valid JSON composite type, either a Hash or an Array. + class JSON < Base64 + def encode(obj) + super(::JSON.dump(obj)) + end + + def decode(str) + return unless str + + begin + ::JSON.parse(super(str)) + rescue StandardError + nil + end + end + end + + class ZipJSON < Base64 + def encode(obj) + super(Zlib::Deflate.deflate(::JSON.dump(obj))) + end + + def decode(str) + return unless str + + ::JSON.parse(Zlib::Inflate.inflate(super(str))) + rescue StandardError + nil + end + end + end + + # Use no encoding for session cookies + class Identity + def encode(str); str; end + def decode(str); str; end + end + + class Marshal + def encode(str) + ::Marshal.dump(str) + end + + def decode(str) + ::Marshal.load(str) if str + end + end + + attr_reader :coder + + def initialize(app, options = {}) + # Assume keys are hex strings and convert them to raw byte strings for + # actual key material + @secrets = options.values_at(:secret, :old_secret).compact.map do |secret| + [secret].pack('H*') + end + + warn <<-MSG unless secure?(options) + SECURITY WARNING: No secret option provided to Rack::Protection::EncryptedCookie. + This poses a security threat. It is strongly recommended that you + provide a secret to prevent exploits that may be possible from crafted + cookies. This will not be supported in future versions of Rack, and + future versions will even invalidate your existing user cookies. + + Called from: #{caller[0]}. + MSG + + warn <<-MSG if @secrets.first && @secrets.first.length < 32 + SECURITY WARNING: Your secret is not long enough. It must be at least + 32 bytes long and securely random. To generate such a key for use + you can run the following command: + + ruby -rsecurerandom -e 'p SecureRandom.hex(32)' + + Called from: #{caller[0]}. + MSG + + if options.key?(:legacy_hmac_secret) + @legacy_hmac = options.fetch(:legacy_hmac, OpenSSL::Digest::SHA1) + + # Multiply the :digest_length: by 2 because this value is the length of + # the digest in bytes but session digest strings are encoded as hex + # strings + @legacy_hmac_length = @legacy_hmac.new.digest_length * 2 + @legacy_hmac_secret = options[:legacy_hmac_secret] + @legacy_hmac_coder = (options[:legacy_hmac_coder] ||= Base64::Marshal.new) + else + @legacy_hmac = false + end + + # If encryption is used we can just use a default Marshal encoder + # without Base64 encoding the results. + # + # If no encryption is used, rely on the previous default (Base64::Marshal) + @coder = (options[:coder] ||= (@secrets.any? ? Marshal.new : Base64::Marshal.new)) + + super(app, options.merge!(cookie_only: true)) + end + + private + + def find_session(req, _sid) + data = unpacked_cookie_data(req) + data = persistent_session_id!(data) + [data['session_id'], data] + end + + def extract_session_id(request) + unpacked_cookie_data(request)['session_id'] + end + + def unpacked_cookie_data(request) + request.fetch_header(RACK_SESSION_UNPACKED_COOKIE_DATA) do |k| + session_data = cookie_data = request.cookies[@key] + + # Try to decrypt with the first secret, if that returns nil, try + # with old_secret + unless @secrets.empty? + session_data = Rack::Protection::Encryptor.decrypt_message(cookie_data, @secrets.first) + session_data ||= Rack::Protection::Encryptor.decrypt_message(cookie_data, @secrets[1]) if @secrets.size > 1 + end + + # If session_data is still nil, are there is a legacy HMAC + # configured, try verify and parse the cookie that way + if !session_data && @legacy_hmac + digest = cookie_data.slice!(-@legacy_hmac_length..-1) + cookie_data.slice!(-2..-1) # remove double dash + session_data = cookie_data if digest_match?(cookie_data, digest) + + # Decode using legacy HMAC decoder + request.set_header(k, @legacy_hmac_coder.decode(session_data) || {}) + else + request.set_header(k, coder.decode(session_data) || {}) + end + end + end + + def persistent_session_id!(data, sid = nil) + data ||= {} + data['session_id'] ||= sid || generate_sid + data + end + + def write_session(req, session_id, session, _options) + session = session.merge('session_id' => session_id) + session_data = coder.encode(session) + + unless @secrets.empty? + session_data = Rack::Protection::Encryptor.encrypt_message(session_data, @secrets.first) + end + + if session_data.size > (4096 - @key.size) + req.get_header(RACK_ERRORS).puts('Warning! Rack::Protection::EncryptedCookie data size exceeds 4K.') + nil + else + session_data + end + end + + def delete_session(_req, _session_id, options) + # Nothing to do here, data is in the client + generate_sid unless options[:drop] + end + + def digest_match?(data, digest) + return false unless data && digest + + Rack::Utils.secure_compare(digest, generate_hmac(data)) + end + + def generate_hmac(data) + OpenSSL::HMAC.hexdigest(@legacy_hmac.new, @legacy_hmac_secret, data) + end + + def secure?(options) + @secrets.size >= 1 || + (options[:coder] && options[:let_coder_handle_secure_encoding]) + end + end + end +end diff --git a/rack-protection/lib/rack/protection/encryptor.rb b/rack-protection/lib/rack/protection/encryptor.rb new file mode 100644 index 0000000000..ded10c7e8e --- /dev/null +++ b/rack-protection/lib/rack/protection/encryptor.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require 'openssl' + +module Rack + module Protection + module Encryptor + CIPHER = 'aes-256-gcm' + DELIMITER = '--' + + def self.base64_encode(str) + [str].pack('m0') + end + + def self.base64_decode(str) + str.unpack1('m0') + end + + def self.encrypt_message(data, secret, auth_data = '') + raise ArgumentError, 'data cannot be nil' if data.nil? + + cipher = OpenSSL::Cipher.new(CIPHER) + cipher.encrypt + cipher.key = secret[0, cipher.key_len] + + # Rely on OpenSSL for the initialization vector + iv = cipher.random_iv + + # This must be set to properly use AES GCM for the OpenSSL module + cipher.auth_data = auth_data + + cipher_text = cipher.update(data) + cipher_text << cipher.final + + "#{base64_encode cipher_text}#{DELIMITER}#{base64_encode iv}#{DELIMITER}#{base64_encode cipher.auth_tag}" + end + + def self.decrypt_message(data, secret) + return unless data + + cipher = OpenSSL::Cipher.new(CIPHER) + cipher_text, iv, auth_tag = data.split(DELIMITER, 3).map! { |v| base64_decode(v) } + + # This check is from ActiveSupport::MessageEncryptor + # see: https://github.com/ruby/openssl/issues/63 + return if auth_tag.nil? || auth_tag.bytes.length != 16 + + cipher.decrypt + cipher.key = secret[0, cipher.key_len] + cipher.iv = iv + cipher.auth_tag = auth_tag + cipher.auth_data = '' + + decrypted_data = cipher.update(cipher_text) + decrypted_data << cipher.final + decrypted_data + rescue OpenSSL::Cipher::CipherError, TypeError, ArgumentError + nil + end + end + end +end diff --git a/rack-protection/lib/rack/protection/escaped_params.rb b/rack-protection/lib/rack/protection/escaped_params.rb new file mode 100644 index 0000000000..80f71d1b9c --- /dev/null +++ b/rack-protection/lib/rack/protection/escaped_params.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require 'rack/protection' +require 'rack/utils' +require 'tempfile' + +begin + require 'escape_utils' +rescue LoadError +end + +module Rack + module Protection + ## + # Prevented attack:: XSS + # Supported browsers:: all + # More infos:: http://en.wikipedia.org/wiki/Cross-site_scripting + # + # Automatically escapes Rack::Request#params so they can be embedded in HTML + # or JavaScript without any further issues. + # + # Options: + # escape:: What escaping modes to use, should be Symbol or Array of Symbols. + # Available: :html (default), :javascript, :url + class EscapedParams < Base + extend Rack::Utils + + class << self + alias escape_url escape + public :escape_html + end + + default_options escape: :html, + escaper: defined?(EscapeUtils) ? EscapeUtils : self + + def initialize(*) + super + + modes = Array options[:escape] + @escaper = options[:escaper] + @html = modes.include? :html + @javascript = modes.include? :javascript + @url = modes.include? :url + + return unless @javascript && (!@escaper.respond_to? :escape_javascript) + + raise('Use EscapeUtils for JavaScript escaping.') + end + + def call(env) + request = Request.new(env) + get_was = handle(request.GET) + post_was = begin + handle(request.POST) + rescue StandardError + nil + end + app.call env + ensure + request.GET.replace get_was if get_was + request.POST.replace post_was if post_was + end + + def handle(hash) + was = hash.dup + hash.replace escape(hash) + was + end + + def escape(object) + case object + when Hash then escape_hash(object) + when Array then object.map { |o| escape(o) } + when String then escape_string(object) + when Tempfile then object + end + end + + def escape_hash(hash) + hash = hash.dup + hash.each { |k, v| hash[k] = escape(v) } + hash + end + + def escape_string(str) + str = @escaper.escape_url(str) if @url + str = @escaper.escape_html(str) if @html + str = @escaper.escape_javascript(str) if @javascript + str + end + end + end +end diff --git a/rack-protection/lib/rack/protection/form_token.rb b/rack-protection/lib/rack/protection/form_token.rb new file mode 100644 index 0000000000..7e01976171 --- /dev/null +++ b/rack-protection/lib/rack/protection/form_token.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'rack/protection' + +module Rack + module Protection + ## + # Prevented attack:: CSRF + # Supported browsers:: all + # More infos:: http://en.wikipedia.org/wiki/Cross-site_request_forgery + # + # Only accepts submitted forms if a given access token matches the token + # included in the session. Does not expect such a token from Ajax request. + # + # This middleware is not used when using the Rack::Protection collection, + # since it might be a security issue, depending on your application + # + # Compatible with rack-csrf. + class FormToken < AuthenticityToken + def accepts?(env) + env['HTTP_X_REQUESTED_WITH'] == 'XMLHttpRequest' or super + end + end + end +end diff --git a/rack-protection/lib/rack/protection/frame_options.rb b/rack-protection/lib/rack/protection/frame_options.rb new file mode 100644 index 0000000000..c159d4a985 --- /dev/null +++ b/rack-protection/lib/rack/protection/frame_options.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require 'rack/protection' + +module Rack + module Protection + ## + # Prevented attack:: Clickjacking + # Supported browsers:: Internet Explorer 8, Firefox 3.6.9, Opera 10.50, + # Safari 4.0, Chrome 4.1.249.1042 and later + # More infos:: https://developer.mozilla.org/en/The_X-FRAME-OPTIONS_response_header + # + # Sets X-Frame-Options header to tell the browser avoid embedding the page + # in a frame. + # + # Options: + # + # frame_options:: Defines who should be allowed to embed the page in a + # frame. Use :deny to forbid any embedding, :sameorigin + # to allow embedding from the same origin (default). + class FrameOptions < Base + default_options frame_options: :sameorigin + + def frame_options + @frame_options ||= begin + frame_options = options[:frame_options] + frame_options = options[:frame_options].to_s.upcase unless frame_options.respond_to? :to_str + frame_options.to_str + end + end + + def call(env) + status, headers, body = @app.call(env) + headers['X-Frame-Options'] ||= frame_options if html? headers + [status, headers, body] + end + end + end +end diff --git a/rack-protection/lib/rack/protection/http_origin.rb b/rack-protection/lib/rack/protection/http_origin.rb new file mode 100644 index 0000000000..8eb25bbbd9 --- /dev/null +++ b/rack-protection/lib/rack/protection/http_origin.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require 'rack/protection' + +module Rack + module Protection + ## + # Prevented attack:: CSRF + # Supported browsers:: Google Chrome 2, Safari 4 and later + # More infos:: http://en.wikipedia.org/wiki/Cross-site_request_forgery + # http://tools.ietf.org/html/draft-abarth-origin + # + # Does not accept unsafe HTTP requests when value of Origin HTTP request header + # does not match default or permitted URIs. + # + # If you want to permit a specific domain, you can pass in as the `:permitted_origins` option: + # + # use Rack::Protection, permitted_origins: ["http://localhost:3000", "http://127.0.01:3000"] + # + # The `:allow_if` option can also be set to a proc to use custom allow/deny logic. + class HttpOrigin < Base + DEFAULT_PORTS = { 'http' => 80, 'https' => 443, 'coffee' => 80 } + default_reaction :deny + default_options allow_if: nil + + def base_url(env) + request = Rack::Request.new(env) + port = ":#{request.port}" unless request.port == DEFAULT_PORTS[request.scheme] + "#{request.scheme}://#{request.host}#{port}" + end + + def accepts?(env) + return true if safe? env + return true unless (origin = env['HTTP_ORIGIN']) + return true if base_url(env) == origin + return true if options[:allow_if]&.call(env) + + permitted_origins = options[:permitted_origins] + Array(permitted_origins).include? origin + end + end + end +end diff --git a/rack-protection/lib/rack/protection/ip_spoofing.rb b/rack-protection/lib/rack/protection/ip_spoofing.rb new file mode 100644 index 0000000000..1e6812e597 --- /dev/null +++ b/rack-protection/lib/rack/protection/ip_spoofing.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require 'rack/protection' + +module Rack + module Protection + ## + # Prevented attack:: IP spoofing + # Supported browsers:: all + # More infos:: http://blog.c22.cc/2011/04/22/surveymonkey-ip-spoofing/ + # + # Detect (some) IP spoofing attacks. + class IPSpoofing < Base + default_reaction :deny + + def accepts?(env) + return true unless env.include? 'HTTP_X_FORWARDED_FOR' + + ips = env['HTTP_X_FORWARDED_FOR'].split(',').map(&:strip) + return false if env.include?('HTTP_CLIENT_IP') && (!ips.include? env['HTTP_CLIENT_IP']) + return false if env.include?('HTTP_X_REAL_IP') && (!ips.include? env['HTTP_X_REAL_IP']) + + true + end + end + end +end diff --git a/rack-protection/lib/rack/protection/json_csrf.rb b/rack-protection/lib/rack/protection/json_csrf.rb new file mode 100644 index 0000000000..9f6817ac18 --- /dev/null +++ b/rack-protection/lib/rack/protection/json_csrf.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require 'rack/protection' + +module Rack + module Protection + ## + # Prevented attack:: CSRF + # Supported browsers:: all + # More infos:: http://flask.pocoo.org/docs/0.10/security/#json-security + # http://haacked.com/archive/2008/11/20/anatomy-of-a-subtle-json-vulnerability.aspx + # + # JSON GET APIs are vulnerable to being embedded as JavaScript when the + # Array prototype has been patched to track data. Checks the referrer + # even on GET requests if the content type is JSON. + # + # If request includes Origin HTTP header, defers to HttpOrigin to determine + # if the request is safe. Please refer to the documentation for more info. + # + # The `:allow_if` option can be set to a proc to use custom allow/deny logic. + class JsonCsrf < Base + default_options allow_if: nil + + alias react deny + + def call(env) + request = Request.new(env) + status, headers, body = app.call(env) + + if has_vector?(request, headers) + warn env, "attack prevented by #{self.class}" + + react_and_close(env, body) or [status, headers, body] + else + [status, headers, body] + end + end + + def has_vector?(request, headers) + return false if request.xhr? + return false if options[:allow_if]&.call(request.env) + return false unless headers['Content-Type'].to_s.split(';', 2).first =~ %r{^\s*application/json\s*$} + + origin(request.env).nil? and referrer(request.env) != request.host + end + + def react_and_close(env, body) + reaction = react(env) + + close_body(body) if reaction + + reaction + end + + def close_body(body) + body.close if body.respond_to?(:close) + end + end + end +end diff --git a/rack-protection/lib/rack/protection/path_traversal.rb b/rack-protection/lib/rack/protection/path_traversal.rb new file mode 100644 index 0000000000..34e2e36eb5 --- /dev/null +++ b/rack-protection/lib/rack/protection/path_traversal.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'rack/protection' + +module Rack + module Protection + ## + # Prevented attack:: Directory traversal + # Supported browsers:: all + # More infos:: http://en.wikipedia.org/wiki/Directory_traversal + # + # Unescapes '/' and '.', expands +path_info+. + # Thus GET /foo/%2e%2e%2fbar becomes GET /bar. + class PathTraversal < Base + def call(env) + path_was = env['PATH_INFO'] + env['PATH_INFO'] = cleanup path_was if path_was && !path_was.empty? + app.call env + ensure + env['PATH_INFO'] = path_was + end + + def cleanup(path) + encoding = path.encoding + dot = '.'.encode(encoding) + slash = '/'.encode(encoding) + backslash = '\\'.encode(encoding) + + parts = [] + unescaped = path.gsub(/%2e/i, dot).gsub(/%2f/i, slash).gsub(/%5c/i, backslash) + unescaped = unescaped.gsub(backslash, slash) + + unescaped.split(slash).each do |part| + next if part.empty? || (part == dot) + + part == '..' ? parts.pop : parts << part + end + + cleaned = slash + parts.join(slash) + cleaned << slash if parts.any? && unescaped =~ (%r{/\.{0,2}$}) + cleaned + end + end + end +end diff --git a/rack-protection/lib/rack/protection/referrer_policy.rb b/rack-protection/lib/rack/protection/referrer_policy.rb new file mode 100644 index 0000000000..eaff7020f9 --- /dev/null +++ b/rack-protection/lib/rack/protection/referrer_policy.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require 'rack/protection' + +module Rack + module Protection + ## + # Prevented attack:: Secret leakage, third party tracking + # Supported browsers:: mixed support + # More infos:: https://www.w3.org/TR/referrer-policy/ + # https://caniuse.com/#search=referrer-policy + # + # Sets Referrer-Policy header to tell the browser to limit the Referer header. + # + # Options: + # referrer_policy:: The policy to use (default: 'strict-origin-when-cross-origin') + class ReferrerPolicy < Base + default_options referrer_policy: 'strict-origin-when-cross-origin' + + def call(env) + status, headers, body = @app.call(env) + headers['Referrer-Policy'] ||= options[:referrer_policy] + [status, headers, body] + end + end + end +end diff --git a/rack-protection/lib/rack/protection/remote_referrer.rb b/rack-protection/lib/rack/protection/remote_referrer.rb new file mode 100644 index 0000000000..f882567fa1 --- /dev/null +++ b/rack-protection/lib/rack/protection/remote_referrer.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require 'rack/protection' + +module Rack + module Protection + ## + # Prevented attack:: CSRF + # Supported browsers:: all + # More infos:: http://en.wikipedia.org/wiki/Cross-site_request_forgery + # + # Does not accept unsafe HTTP requests if the Referer [sic] header is set to + # a different host. + class RemoteReferrer < Base + default_reaction :deny + + def accepts?(env) + safe?(env) or referrer(env) == Request.new(env).host + end + end + end +end diff --git a/rack-protection/lib/rack/protection/remote_token.rb b/rack-protection/lib/rack/protection/remote_token.rb new file mode 100644 index 0000000000..ef4f6e5744 --- /dev/null +++ b/rack-protection/lib/rack/protection/remote_token.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require 'rack/protection' + +module Rack + module Protection + ## + # Prevented attack:: CSRF + # Supported browsers:: all + # More infos:: http://en.wikipedia.org/wiki/Cross-site_request_forgery + # + # Only accepts unsafe HTTP requests if a given access token matches the token + # included in the session *or* the request comes from the same origin. + # + # Compatible with rack-csrf. + class RemoteToken < AuthenticityToken + default_reaction :deny + + def accepts?(env) + super or referrer(env) == Request.new(env).host + end + end + end +end diff --git a/rack-protection/lib/rack/protection/session_hijacking.rb b/rack-protection/lib/rack/protection/session_hijacking.rb new file mode 100644 index 0000000000..555121d8b9 --- /dev/null +++ b/rack-protection/lib/rack/protection/session_hijacking.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'rack/protection' + +module Rack + module Protection + ## + # Prevented attack:: Session Hijacking + # Supported browsers:: all + # More infos:: http://en.wikipedia.org/wiki/Session_hijacking + # + # Tracks request properties like the user agent in the session and empties + # the session if those properties change. This essentially prevents attacks + # from Firesheep. Since all headers taken into consideration can be + # spoofed, too, this will not prevent determined hijacking attempts. + class SessionHijacking < Base + default_reaction :drop_session + default_options tracking_key: :tracking, + track: %w[HTTP_USER_AGENT] + + def accepts?(env) + session = session env + key = options[:tracking_key] + if session.include? key + session[key].all? { |k, v| v == encode(env[k]) } + else + session[key] = {} + options[:track].each { |k| session[key][k] = encode(env[k]) } + end + end + + def encode(value) + value.to_s.downcase + end + end + end +end diff --git a/rack-protection/lib/rack/protection/strict_transport.rb b/rack-protection/lib/rack/protection/strict_transport.rb new file mode 100644 index 0000000000..05fe4ae6a8 --- /dev/null +++ b/rack-protection/lib/rack/protection/strict_transport.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require 'rack/protection' + +module Rack + module Protection + ## + # Prevented attack:: Protects against against protocol downgrade attacks and cookie hijacking. + # Supported browsers:: all + # More infos:: https://en.wikipedia.org/wiki/HTTP_Strict_Transport_Security + # + # browser will prevent any communications from being sent over HTTP + # to the specified domain and will instead send all communications over HTTPS. + # It also prevents HTTPS click through prompts on browsers. + # + # Options: + # + # max_age:: How long future requests to the domain should go over HTTPS; specified in seconds + # include_subdomains:: If all present and future subdomains will be HTTPS + # preload:: Allow this domain to be included in browsers HSTS preload list. See https://hstspreload.appspot.com/ + + class StrictTransport < Base + default_options max_age: 31_536_000, include_subdomains: false, preload: false + + def strict_transport + @strict_transport ||= begin + strict_transport = "max-age=#{options[:max_age]}" + strict_transport += '; includeSubDomains' if options[:include_subdomains] + strict_transport += '; preload' if options[:preload] + strict_transport.to_str + end + end + + def call(env) + status, headers, body = @app.call(env) + headers['Strict-Transport-Security'] ||= strict_transport + [status, headers, body] + end + end + end +end diff --git a/rack-protection/lib/rack/protection/version.rb b/rack-protection/lib/rack/protection/version.rb new file mode 100644 index 0000000000..2b2ae4e28d --- /dev/null +++ b/rack-protection/lib/rack/protection/version.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Rack + module Protection + VERSION = '3.0.5' + end +end diff --git a/rack-protection/lib/rack/protection/xss_header.rb b/rack-protection/lib/rack/protection/xss_header.rb new file mode 100644 index 0000000000..14c679d9fb --- /dev/null +++ b/rack-protection/lib/rack/protection/xss_header.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require 'rack/protection' + +module Rack + module Protection + ## + # Prevented attack:: Non-permanent XSS + # Supported browsers:: Internet Explorer 8+ and Chrome + # More infos:: http://blogs.msdn.com/b/ie/archive/2008/07/01/ie8-security-part-iv-the-xss-filter.aspx + # + # Sets X-XSS-Protection header to tell the browser to block attacks. + # + # Options: + # xss_mode:: How the browser should prevent the attack (default: :block) + class XSSHeader < Base + default_options xss_mode: :block, nosniff: true + + def call(env) + status, headers, body = @app.call(env) + headers['X-XSS-Protection'] ||= "1; mode=#{options[:xss_mode]}" if html? headers + headers['X-Content-Type-Options'] ||= 'nosniff' if options[:nosniff] + [status, headers, body] + end + end + end +end diff --git a/rack-protection/lib/rack_protection.rb b/rack-protection/lib/rack_protection.rb new file mode 100644 index 0000000000..fa36f00450 --- /dev/null +++ b/rack-protection/lib/rack_protection.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +require 'rack/protection' diff --git a/rack-protection/rack-protection.gemspec b/rack-protection/rack-protection.gemspec new file mode 100644 index 0000000000..2303f37d8d --- /dev/null +++ b/rack-protection/rack-protection.gemspec @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +version = File.read(File.expand_path('../VERSION', __dir__)).strip + +Gem::Specification.new do |s| + # general infos + s.name = 'rack-protection' + s.version = version + s.description = 'Protect against typical web attacks, works with all Rack apps, including Rails.' + s.homepage = 'https://sinatrarb.com/protection/' + s.summary = s.description + s.license = 'MIT' + s.authors = ['https://github.com/sinatra/sinatra/graphs/contributors'] + s.email = 'sinatrarb@googlegroups.com' + s.files = Dir['lib/**/*.rb'] + [ + 'License', + 'README.md', + 'Rakefile', + 'Gemfile', + 'rack-protection.gemspec' + ] + + unless s.respond_to?(:metadata) + raise <<-WARN +RubyGems 2.0 or newer is required to protect against public gem pushes. You can update your rubygems version by running: + gem install rubygems-update + update_rubygems: + gem update --system + WARN + end + + s.metadata = { + 'source_code_uri' => 'https://github.com/sinatra/sinatra/tree/main/rack-protection', + 'homepage_uri' => 'http://sinatrarb.com/protection/', + 'documentation_uri' => 'https://www.rubydoc.info/gems/rack-protection', + 'rubygems_mfa_required' => 'true' + } + + s.required_ruby_version = '>= 2.6.0' + + # dependencies + s.add_dependency 'rack' + s.add_development_dependency 'rack-test', '~> 2' + s.add_development_dependency 'rspec', '~> 3' +end diff --git a/rack-protection/spec/lib/rack/protection/authenticity_token_spec.rb b/rack-protection/spec/lib/rack/protection/authenticity_token_spec.rb new file mode 100644 index 0000000000..e589726be5 --- /dev/null +++ b/rack-protection/spec/lib/rack/protection/authenticity_token_spec.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +RSpec.describe Rack::Protection::AuthenticityToken do + let(:token) { described_class.random_token } + let(:masked_token) { described_class.token(session) } + let(:bad_token) { Base64.strict_encode64('badtoken') } + let(:session) { { csrf: token } } + + it_behaves_like 'any rack application' + + it 'denies post requests without any token' do + expect(post('/')).not_to be_ok + end + + it 'accepts post requests with correct X-CSRF-Token header' do + post('/', {}, 'rack.session' => session, 'HTTP_X_CSRF_TOKEN' => token) + expect(last_response).to be_ok + end + + it 'accepts post requests with masked X-CSRF-Token header' do + post('/', {}, 'rack.session' => session, 'HTTP_X_CSRF_TOKEN' => masked_token) + expect(last_response).to be_ok + end + + it 'denies post requests with wrong X-CSRF-Token header' do + post('/', {}, 'rack.session' => session, 'HTTP_X_CSRF_TOKEN' => bad_token) + expect(last_response).not_to be_ok + end + + it 'accepts post form requests with correct authenticity_token field' do + post('/', { 'authenticity_token' => token }, 'rack.session' => session) + expect(last_response).to be_ok + end + + it 'accepts post form requests with masked authenticity_token field' do + post('/', { 'authenticity_token' => masked_token }, 'rack.session' => session) + expect(last_response).to be_ok + end + + it 'denies post form requests with wrong authenticity_token field' do + post('/', { 'authenticity_token' => bad_token }, 'rack.session' => session) + expect(last_response).not_to be_ok + end + + it 'accepts post form requests with a valid per form token' do + token = Rack::Protection::AuthenticityToken.token(session, path: '/foo') + post('/foo', { 'authenticity_token' => token }, 'rack.session' => session) + expect(last_response).to be_ok + end + + it 'denies post form requests with an invalid per form token' do + token = Rack::Protection::AuthenticityToken.token(session, path: '/foo') + post('/bar', { 'authenticity_token' => token }, 'rack.session' => session) + expect(last_response).not_to be_ok + end + + it 'prevents ajax requests without a valid token' do + expect(post('/', {}, 'HTTP_X_REQUESTED_WITH' => 'XMLHttpRequest')).not_to be_ok + end + + it 'allows for a custom authenticity token param' do + mock_app do + use Rack::Protection::AuthenticityToken, authenticity_param: 'csrf_param' + run proc { |_e| [200, { 'Content-Type' => 'text/plain' }, ['hi']] } + end + + post('/', { 'csrf_param' => token }, 'rack.session' => { csrf: token }) + expect(last_response).to be_ok + end + + it "sets a new csrf token for the session in env, even after a 'safe' request" do + get('/', {}, {}) + expect(env['rack.session'][:csrf]).not_to be_nil + end + + it 'allows for a custom token session key' do + mock_app do + use Rack::Session::Cookie, key: 'rack.session' + use Rack::Protection::AuthenticityToken, key: :_csrf + run DummyApp + end + + get '/' + expect(env['rack.session'][:_csrf]).not_to be_nil + end + + describe '.token' do + it 'returns a unique masked version of the authenticity token' do + expect(Rack::Protection::AuthenticityToken.token(session)).not_to eq(masked_token) + end + + it 'sets a session authenticity token if one does not exist' do + session = {} + allow(Rack::Protection::AuthenticityToken).to receive(:random_token).and_return(token) + allow_any_instance_of(Rack::Protection::AuthenticityToken).to receive(:mask_token).and_return(masked_token) + Rack::Protection::AuthenticityToken.token(session) + expect(session[:csrf]).to eq(token) + end + end + + describe '.random_token' do + it 'generates a base64 encoded 32 character string' do + expect(Base64.urlsafe_decode64(token).length).to eq(32) + end + end +end diff --git a/rack-protection/spec/lib/rack/protection/base_spec.rb b/rack-protection/spec/lib/rack/protection/base_spec.rb new file mode 100644 index 0000000000..c4b8f8c86e --- /dev/null +++ b/rack-protection/spec/lib/rack/protection/base_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +RSpec.describe Rack::Protection::Base do + subject { described_class.new(-> {}) } + + describe '#random_string' do + it 'outputs a string of 32 characters' do + expect(subject.random_string.length).to eq(32) + end + end + + describe '#referrer' do + it 'Reads referrer from Referer header' do + env = { 'HTTP_HOST' => 'foo.com', 'HTTP_REFERER' => 'http://bar.com/valid' } + expect(subject.referrer(env)).to eq('bar.com') + end + + it 'Reads referrer from Host header when Referer header is relative' do + env = { 'HTTP_HOST' => 'foo.com', 'HTTP_REFERER' => '/valid' } + expect(subject.referrer(env)).to eq('foo.com') + end + + it 'Reads referrer from Host header when Referer header is missing' do + env = { 'HTTP_HOST' => 'foo.com' } + expect(subject.referrer(env)).to eq('foo.com') + end + + it 'Returns nil when Referer header is missing and allow_empty_referrer is false' do + env = { 'HTTP_HOST' => 'foo.com' } + subject.options[:allow_empty_referrer] = false + expect(subject.referrer(env)).to be_nil + end + + it 'Returns nil when Referer header is invalid' do + env = { 'HTTP_HOST' => 'foo.com', 'HTTP_REFERER' => 'http://bar.com/bad|uri' } + expect(subject.referrer(env)).to be_nil + end + end +end diff --git a/rack-protection/spec/lib/rack/protection/content_security_policy_spec.rb b/rack-protection/spec/lib/rack/protection/content_security_policy_spec.rb new file mode 100644 index 0000000000..35a26ad837 --- /dev/null +++ b/rack-protection/spec/lib/rack/protection/content_security_policy_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +RSpec.describe Rack::Protection::ContentSecurityPolicy do + it_behaves_like 'any rack application' + + it 'should set the Content Security Policy' do + expect( + get('/', {}, 'wants' => 'text/html').headers['Content-Security-Policy'] + ).to eq("default-src 'self'") + end + + it 'should not set the Content Security Policy for other content types' do + headers = get('/', {}, 'wants' => 'text/foo').headers + expect(headers['Content-Security-Policy']).to be_nil + expect(headers['Content-Security-Policy-Report-Only']).to be_nil + end + + it 'should allow changing the protection settings' do + mock_app do + use Rack::Protection::ContentSecurityPolicy, default_src: 'none', script_src: 'https://cdn.mybank.net', style_src: 'https://cdn.mybank.net', img_src: 'https://cdn.mybank.net', connect_src: 'https://api.mybank.com', frame_src: 'self', font_src: 'https://cdn.mybank.net', object_src: 'https://cdn.mybank.net', media_src: 'https://cdn.mybank.net', report_uri: '/my_amazing_csp_report_parser', sandbox: 'allow-scripts' + + run DummyApp + end + + headers = get('/', {}, 'wants' => 'text/html').headers + expect(headers['Content-Security-Policy']).to eq('connect-src https://api.mybank.com; default-src none; font-src https://cdn.mybank.net; frame-src self; img-src https://cdn.mybank.net; media-src https://cdn.mybank.net; object-src https://cdn.mybank.net; report-uri /my_amazing_csp_report_parser; sandbox allow-scripts; script-src https://cdn.mybank.net; style-src https://cdn.mybank.net') + expect(headers['Content-Security-Policy-Report-Only']).to be_nil + end + + it 'should allow setting CSP3 no arg directives' do + mock_app do + use Rack::Protection::ContentSecurityPolicy, block_all_mixed_content: true, disown_opener: true, upgrade_insecure_requests: true + + run DummyApp + end + + headers = get('/', {}, 'wants' => 'text/html').headers + expect(headers['Content-Security-Policy']).to eq("block-all-mixed-content; default-src 'self'; disown-opener; upgrade-insecure-requests") + end + + it 'should ignore CSP3 no arg directives unless they are set to true' do + mock_app do + use Rack::Protection::ContentSecurityPolicy, block_all_mixed_content: false, disown_opener: 'false', upgrade_insecure_requests: 'foo' + + run DummyApp + end + + headers = get('/', {}, 'wants' => 'text/html').headers + expect(headers['Content-Security-Policy']).to eq("default-src 'self'") + end + + it 'should allow changing report only' do + # I have no clue what other modes are available + mock_app do + use Rack::Protection::ContentSecurityPolicy, report_uri: '/my_amazing_csp_report_parser', report_only: true + run DummyApp + end + + headers = get('/', {}, 'wants' => 'text/html').headers + expect(headers['Content-Security-Policy']).to be_nil + expect(headers['Content-Security-Policy-Report-Only']).to eq("default-src 'self'; report-uri /my_amazing_csp_report_parser") + end + + it 'should not override the header if already set' do + mock_app with_headers('Content-Security-Policy' => 'default-src: none') + expect(get('/', {}, 'wants' => 'text/html').headers['Content-Security-Policy']).to eq('default-src: none') + end +end diff --git a/rack-protection/spec/lib/rack/protection/cookie_tossing_spec.rb b/rack-protection/spec/lib/rack/protection/cookie_tossing_spec.rb new file mode 100644 index 0000000000..f24b9920b6 --- /dev/null +++ b/rack-protection/spec/lib/rack/protection/cookie_tossing_spec.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +RSpec.describe Rack::Protection::CookieTossing do + it_behaves_like 'any rack application' + + context 'with default reaction' do + before(:each) do + mock_app do + use Rack::Protection::CookieTossing + run DummyApp + end + end + + it 'accepts requests with a single session cookie' do + get '/', {}, 'HTTP_COOKIE' => 'rack.session=SESSION_TOKEN' + expect(last_response).to be_ok + end + + it 'denies requests with duplicate session cookies' do + get '/', {}, 'HTTP_COOKIE' => 'rack.session=EVIL_SESSION_TOKEN; rack.session=SESSION_TOKEN' + expect(last_response).not_to be_ok + end + + it 'denies requests with sneaky encoded session cookies' do + get '/', {}, 'HTTP_COOKIE' => 'rack.session=EVIL_SESSION_TOKEN; rack.%73ession=SESSION_TOKEN' + expect(last_response).not_to be_ok + end + + it 'adds the correct Set-Cookie header' do + get '/some/path', {}, 'HTTP_COOKIE' => 'rack.%73ession=EVIL_SESSION_TOKEN; rack.session=EVIL_SESSION_TOKEN; rack.session=SESSION_TOKEN' + + expected_header = <<-END.chomp +rack.%2573ession=; domain=example.org; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT +rack.%2573ession=; domain=example.org; path=/some; expires=Thu, 01 Jan 1970 00:00:00 GMT +rack.%2573ession=; domain=example.org; path=/some/path; expires=Thu, 01 Jan 1970 00:00:00 GMT +rack.session=; domain=example.org; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT +rack.session=; domain=example.org; path=/some; expires=Thu, 01 Jan 1970 00:00:00 GMT +rack.session=; domain=example.org; path=/some/path; expires=Thu, 01 Jan 1970 00:00:00 GMT + END + expect(last_response.headers['Set-Cookie']).to eq(expected_header) + end + end + + context 'with redirect reaction' do + before(:each) do + mock_app do + use Rack::Protection::CookieTossing, reaction: :redirect + run DummyApp + end + end + + it 'redirects requests with duplicate session cookies' do + get '/', {}, 'HTTP_COOKIE' => 'rack.session=EVIL_SESSION_TOKEN; rack.session=SESSION_TOKEN' + expect(last_response).to be_redirect + expect(last_response.location).to eq('/') + end + + it 'redirects requests with sneaky encoded session cookies' do + get '/path', {}, 'HTTP_COOKIE' => 'rack.%73ession=EVIL_SESSION_TOKEN; rack.session=SESSION_TOKEN' + expect(last_response).to be_redirect + expect(last_response.location).to eq('/path') + end + end + + context 'with custom session key' do + it 'denies requests with duplicate session cookies' do + mock_app do + use Rack::Protection::CookieTossing, session_key: '_session' + run DummyApp + end + + get '/', {}, 'HTTP_COOKIE' => '_session=EVIL_SESSION_TOKEN; _session=SESSION_TOKEN' + expect(last_response).not_to be_ok + end + end +end diff --git a/rack-protection/spec/lib/rack/protection/encrypted_cookie_spec.rb b/rack-protection/spec/lib/rack/protection/encrypted_cookie_spec.rb new file mode 100644 index 0000000000..de2a659bc1 --- /dev/null +++ b/rack-protection/spec/lib/rack/protection/encrypted_cookie_spec.rb @@ -0,0 +1,562 @@ +# frozen_string_literal: true + +RSpec.describe Rack::Protection::EncryptedCookie do + let(:incrementor) do + lambda do |env| + env['rack.session']['counter'] ||= 0 + env['rack.session']['counter'] += 1 + hash = env['rack.session'].dup + hash.delete('session_id') + Rack::Response.new(hash.inspect).to_a + end + end + + let(:session_id) do + lambda do |env| + Rack::Response.new(env['rack.session'].to_hash.inspect).to_a + end + end + + let(:session_option) do + lambda do |opt| + lambda do |env| + Rack::Response.new(env['rack.session.options'][opt].inspect).to_a + end + end + end + + let(:nothing) do + lambda do |_env| + Rack::Response.new('Nothing').to_a + end + end + + let(:renewer) do + lambda do |env| + env['rack.session.options'][:renew] = true + Rack::Response.new('Nothing').to_a + end + end + + let(:only_session_id) do + lambda do |env| + Rack::Response.new(env['rack.session']['session_id'].to_s).to_a + end + end + + let(:bigcookie) do + lambda do |env| + env['rack.session']['cookie'] = 'big' * 3000 + Rack::Response.new(env['rack.session'].inspect).to_a + end + end + + let(:destroy_session) do + lambda do |env| + env['rack.session'].destroy + Rack::Response.new('Nothing').to_a + end + end + + def response_for(options = {}) + request_options = options.fetch(:request, {}) + cookie = if options[:cookie].is_a?(Rack::Response) + options[:cookie]['Set-Cookie'] + else + options[:cookie] + end + request_options['HTTP_COOKIE'] = cookie || '' + + app_with_cookie = Rack::Protection::EncryptedCookie.new(*options[:app]) + app_with_cookie = Rack::Lint.new(app_with_cookie) + Rack::MockRequest.new(app_with_cookie).get('/', request_options) + end + + def random_cipher_secret + OpenSSL::Cipher.new('aes-256-gcm').random_key.unpack1('H*') + end + + let(:secret) { random_cipher_secret } + let(:warnings) { [] } + + before do + local_warnings = warnings + + Rack::Protection::EncryptedCookie.class_eval do + define_method(:warn) { |m| local_warnings << m } + end + end + + after do + Rack::Protection::EncryptedCookie.class_eval { remove_method :warn } + end + + describe 'Base64' do + it 'uses base64 to encode' do + coder = Rack::Protection::EncryptedCookie::Base64.new + str = 'fuuuuu' + expect(coder.encode(str)).to eq([str].pack('m0')) + end + + it 'uses base64 to decode' do + coder = Rack::Protection::EncryptedCookie::Base64.new + str = ['fuuuuu'].pack('m0') + expect(coder.decode(str)).to eq(str.unpack1('m0')) + end + + it 'handles non-strict base64 encoding' do + coder = Rack::Protection::EncryptedCookie::Base64.new + str = ['A' * 256].pack('m') + expect(coder.decode(str)).to eq('A' * 256) + end + + describe 'Marshal' do + it 'marshals and base64 encodes' do + coder = Rack::Protection::EncryptedCookie::Base64::Marshal.new + str = 'fuuuuu' + expect(coder.encode(str)).to eq([::Marshal.dump(str)].pack('m0')) + end + + it 'marshals and base64 decodes' do + coder = Rack::Protection::EncryptedCookie::Base64::Marshal.new + str = [::Marshal.dump('fuuuuu')].pack('m0') + expect(coder.decode(str)).to eq(::Marshal.load(str.unpack1('m0'))) + end + + it 'rescues failures on decode' do + coder = Rack::Protection::EncryptedCookie::Base64::Marshal.new + expect(coder.decode('lulz')).to be_nil + end + end + + describe 'JSON' do + it 'JSON and base64 encodes' do + coder = Rack::Protection::EncryptedCookie::Base64::JSON.new + obj = %w[fuuuuu] + expect(coder.encode(obj)).to eq([::JSON.dump(obj)].pack('m0')) + end + + it 'JSON and base64 decodes' do + coder = Rack::Protection::EncryptedCookie::Base64::JSON.new + str = [::JSON.dump(%w[fuuuuu])].pack('m0') + expect(coder.decode(str)).to eq(::JSON.parse(str.unpack1('m0'))) + end + + it 'rescues failures on decode' do + coder = Rack::Protection::EncryptedCookie::Base64::JSON.new + expect(coder.decode('lulz')).to be_nil + end + end + + describe 'ZipJSON' do + it 'jsons, deflates, and base64 encodes' do + coder = Rack::Protection::EncryptedCookie::Base64::ZipJSON.new + obj = %w[fuuuuu] + json = JSON.dump(obj) + expect(coder.encode(obj)).to eq([Zlib::Deflate.deflate(json)].pack('m0')) + end + + it 'base64 decodes, inflates, and decodes json' do + coder = Rack::Protection::EncryptedCookie::Base64::ZipJSON.new + obj = %w[fuuuuu] + json = JSON.dump(obj) + b64 = [Zlib::Deflate.deflate(json)].pack('m0') + expect(coder.decode(b64)).to eq(obj) + end + + it 'rescues failures on decode' do + coder = Rack::Protection::EncryptedCookie::Base64::ZipJSON.new + expect(coder.decode('lulz')).to be_nil + end + end + end + + it 'warns if no secret is given' do + Rack::Protection::EncryptedCookie.new(incrementor) + expect(warnings.first).to match(/no secret/i) + warnings.clear + Rack::Protection::EncryptedCookie.new(incrementor, secret: secret) + expect(warnings).to be_empty + end + + it 'warns if secret is to short' do + Rack::Protection::EncryptedCookie.new(incrementor, secret: secret[0, 16]) + expect(warnings.first).to match(/secret is not long enough/i) + warnings.clear + Rack::Protection::EncryptedCookie.new(incrementor, secret: secret) + expect(warnings).to be_empty + end + + it "doesn't warn if coder is configured to handle encoding" do + Rack::Protection::EncryptedCookie.new( + incrementor, coder: Object.new, let_coder_handle_secure_encoding: true + ) + expect(warnings).to be_empty + end + + it 'still warns if coder is not set' do + Rack::Protection::EncryptedCookie.new( + incrementor, + let_coder_handle_secure_encoding: true + ) + expect(warnings.first).to match(/no secret/i) + end + + it 'uses a coder' do + identity = Class.new do + attr_reader :calls + + def initialize + @calls = [] + end + + def encode(str) + @calls << :encode + str + end + + def decode(str) + @calls << :decode + str + end + end.new + response = response_for(app: [incrementor, { coder: identity }]) + + expect(response['Set-Cookie']).to include('rack.session=') + expect(response.body).to eq('{"counter"=>1}') + expect(identity.calls).to eq(%i[decode encode]) + end + + it 'creates a new cookie' do + response = response_for(app: incrementor) + expect(response['Set-Cookie']).to include('rack.session=') + expect(response.body).to eq('{"counter"=>1}') + end + + it 'loads from a cookie' do + response = response_for(app: incrementor) + + response = response_for(app: incrementor, cookie: response) + expect(response.body).to eq('{"counter"=>2}') + + response = response_for(app: incrementor, cookie: response) + expect(response.body).to eq('{"counter"=>3}') + end + + it 'renew session id' do + response = response_for(app: incrementor) + cookie = response['Set-Cookie'] + response = response_for(app: only_session_id, cookie: cookie) + cookie = response['Set-Cookie'] if response['Set-Cookie'] + + expect(response.body).to_not eq('') + old_session_id = response.body + + response = response_for(app: renewer, cookie: cookie) + cookie = response['Set-Cookie'] if response['Set-Cookie'] + response = response_for(app: only_session_id, cookie: cookie) + + expect(response.body).to_not eq('') + expect(response.body).to_not eq(old_session_id) + end + + it 'destroys session' do + response = response_for(app: incrementor) + response = response_for(app: only_session_id, cookie: response) + + expect(response.body).to_not eq('') + old_session_id = response.body + + response = response_for(app: destroy_session, cookie: response) + response = response_for(app: only_session_id, cookie: response) + + expect(response.body).to_not eq('') + expect(response.body).to_not eq(old_session_id) + end + + it 'survives broken cookies' do + response = response_for( + app: incrementor, + cookie: 'rack.session=blarghfasel' + ) + expect(response.body).to eq('{"counter"=>1}') + + response = response_for( + app: [incrementor, { secret: secret }], + cookie: 'rack.session=' + ) + expect(response.body).to eq('{"counter"=>1}') + end + + it 'barks on too big cookies' do + expect do + response_for(app: bigcookie, request: { fatal: true }) + end.to raise_error Rack::MockRequest::FatalWarning + end + + it 'loads from a cookie with integrity hash' do + app = [incrementor, { secret: secret }] + + response = response_for(app: app) + response = response_for(app: app, cookie: response) + expect(response.body).to eq('{"counter"=>2}') + + response = response_for(app: app, cookie: response) + expect(response.body).to eq('{"counter"=>3}') + + app = [incrementor, { secret: random_cipher_secret }] + + response = response_for(app: app, cookie: response) + expect(response.body).to eq('{"counter"=>1}') + end + + it 'loads from a cookie with accept-only integrity hash for graceful key rotation' do + response = response_for(app: [incrementor, { secret: secret }]) + + new_secret = random_cipher_secret + + app = [incrementor, { secret: new_secret, old_secret: secret }] + response = response_for(app: app, cookie: response) + expect(response.body).to eq('{"counter"=>2}') + + newer_secret = random_cipher_secret + + app = [incrementor, { secret: newer_secret, old_secret: new_secret }] + response = response_for(app: app, cookie: response) + expect(response.body).to eq('{"counter"=>3}') + end + + it 'loads from a legacy hmac cookie' do + legacy_session = Rack::Protection::EncryptedCookie::Base64::Marshal.new.encode({ 'counter' => 1, 'session_id' => 'abcdef' }) + legacy_secret = 'test legacy secret' + legacy_digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('SHA1'), legacy_secret, legacy_session) + + legacy_cookie = "rack.session=#{legacy_session}--#{legacy_digest}; path=/; HttpOnly" + + app = [incrementor, { secret: secret, legacy_hmac_secret: legacy_secret }] + response = response_for(app: app, cookie: legacy_cookie) + expect(response.body).to eq('{"counter"=>2}') + end + + it 'ignores tampered with session cookies' do + app = [incrementor, { secret: secret }] + response = response_for(app: app) + expect(response.body).to eq('{"counter"=>1}') + + response = response_for(app: app, cookie: response) + expect(response.body).to eq('{"counter"=>2}') + + ctxt, iv, auth_tag = response['Set-Cookie'].split('--', 3) + tampered_with_cookie = [ctxt, iv, auth_tag.reverse].join('--') + + response = response_for(app: app, cookie: tampered_with_cookie) + expect(response.body).to eq('{"counter"=>1}') + end + + it 'ignores tampered with legacy hmac cookie' do + legacy_session = Rack::Protection::EncryptedCookie::Base64::Marshal.new.encode({ 'counter' => 1, 'session_id' => 'abcdef' }) + legacy_secret = 'test legacy secret' + legacy_digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('SHA1'), legacy_secret, legacy_session).reverse + + legacy_cookie = "rack.session=#{legacy_session}--#{legacy_digest}; path=/; HttpOnly" + + app = [incrementor, { secret: secret, legacy_hmac_secret: legacy_secret }] + response = response_for(app: app, cookie: legacy_cookie) + expect(response.body).to eq('{"counter"=>1}') + end + + it 'supports either of secret or old_secret' do + app = [incrementor, { secret: secret }] + response = response_for(app: app) + expect(response.body).to eq('{"counter"=>1}') + + response = response_for(app: app, cookie: response) + expect(response.body).to eq('{"counter"=>2}') + + app = [incrementor, { old_secret: secret }] + response = response_for(app: app) + expect(response.body).to eq('{"counter"=>1}') + + response = response_for(app: app, cookie: response) + expect(response.body).to eq('{"counter"=>2}') + end + + it 'supports custom digest class for legacy hmac cookie' do + legacy_hmac = OpenSSL::Digest::SHA256 + legacy_session = Rack::Protection::EncryptedCookie::Base64::Marshal.new.encode({ 'counter' => 1, 'session_id' => 'abcdef' }) + legacy_secret = 'test legacy secret' + legacy_digest = OpenSSL::HMAC.hexdigest(legacy_hmac.new, legacy_secret, legacy_session) + legacy_cookie = "rack.session=#{Rack::Utils.escape legacy_session}--#{legacy_digest}; path=/; HttpOnly" + + app = [incrementor, { + secret: secret, legacy_hmac_secret: legacy_secret, legacy_hmac: legacy_hmac + }] + + response = response_for(app: app, cookie: legacy_cookie) + expect(response.body).to eq('{"counter"=>2}') + + response = response_for(app: app, cookie: response) + expect(response.body).to eq('{"counter"=>3}') + end + + it 'can handle Rack::Lint middleware' do + response = response_for(app: incrementor) + + lint = Rack::Lint.new(session_id) + response = response_for(app: lint, cookie: response) + expect(response.body).to_not be_nil + end + + it 'can handle middleware that inspects the env' do + class TestEnvInspector + def initialize(app) + @app = app + end + + def call(env) + env.inspect + @app.call(env) + end + end + + response = response_for(app: incrementor) + + inspector = TestEnvInspector.new(session_id) + response = response_for(app: inspector, cookie: response) + expect(response.body).to_not be_nil + end + + it 'returns the session id in the session hash' do + response = response_for(app: incrementor) + expect(response.body).to eq('{"counter"=>1}') + + response = response_for(app: session_id, cookie: response) + expect(response.body).to match(/"session_id"=>/) + expect(response.body).to match(/"counter"=>1/) + end + + it 'does not return a cookie if set to secure but not using ssl' do + app = [incrementor, { secure: true }] + + response = response_for(app: app) + expect(response['Set-Cookie']).to be_nil + + response = response_for(app: app, request: { 'HTTPS' => 'on' }) + expect(response['Set-Cookie']).to_not be_nil + expect(response['Set-Cookie']).to match(/secure/) + end + + it 'does not return a cookie if cookie was not read/written' do + response = response_for(app: nothing) + expect(response['Set-Cookie']).to be_nil + end + + it 'does not return a cookie if cookie was not written (only read)' do + response = response_for(app: session_id) + expect(response['Set-Cookie']).to be_nil + end + + it 'returns even if not read/written if :expire_after is set' do + app = [nothing, { expire_after: 3600 }] + request = { 'rack.session' => { 'not' => 'empty' } } + response = response_for(app: app, request: request) + expect(response['Set-Cookie']).to_not be_nil + end + + it 'returns no cookie if no data was written and no session was created previously, even if :expire_after is set' do + app = [nothing, { expire_after: 3600 }] + response = response_for(app: app) + expect(response['Set-Cookie']).to be_nil + end + + it "exposes :secret in env['rack.session.option']" do + response = response_for(app: [session_option[:secret], { secret: secret }]) + expect(response.body).to eq(secret.inspect) + end + + it "exposes :coder in env['rack.session.option']" do + response = response_for(app: session_option[:coder]) + expect(response.body).to match(/Base64::Marshal/) + end + + it 'exposes correct :coder when a secret is used' do + response = response_for(app: session_option[:coder], secret: secret) + expect(response.body).to match(/Marshal/) + end + + it 'allows passing in a hash with session data from middleware in front' do + request = { 'rack.session' => { foo: 'bar' } } + response = response_for(app: session_id, request: request) + expect(response.body).to match(/foo/) + end + + it 'allows modifying session data with session data from middleware in front' do + request = { 'rack.session' => { foo: 'bar' } } + response = response_for(app: incrementor, request: request) + expect(response.body).to match(/counter/) + expect(response.body).to match(/foo/) + end + + it "allows more than one '--' in the cookie when calculating legacy digests" do + @counter = 0 + app = lambda do |env| + env['rack.session']['message'] ||= '' + env['rack.session']['message'] << "#{@counter += 1}--" + hash = env['rack.session'].dup + hash.delete('session_id') + Rack::Response.new(hash['message']).to_a + end + # another example of an unsafe coder is Base64.urlsafe_encode64 + unsafe_coder = Class.new do + def encode(hash); hash.inspect end + def decode(str); eval(str) if str; end + end.new + + legacy_session = unsafe_coder.encode('message' => "#{@counter += 1}--#{@counter += 1}--", 'session_id' => 'abcdef') + legacy_secret = 'test legacy secret' + legacy_digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('SHA1'), legacy_secret, legacy_session) + legacy_cookie = "rack.session=#{Rack::Utils.escape legacy_session}--#{legacy_digest}; path=/; HttpOnly" + + _app = [app, { + secret: secret, legacy_hmac_secret: legacy_secret, + legacy_hmac_coder: unsafe_coder + }] + + response = response_for(app: _app, cookie: legacy_cookie) + expect(response.body).to eq('1--2--3--') + end + + it 'allows for non-strict encoded cookie' do + long_session_app = lambda do |env| + env['rack.session']['value'] = 'A' * 256 + env['rack.session']['counter'] = 1 + hash = env['rack.session'].dup + hash.delete('session_id') + Rack::Response.new(hash.inspect).to_a + end + + non_strict_coder = Class.new do + def encode(str) + [Marshal.dump(str)].pack('m') + end + + def decode(str) + return unless str + + Marshal.load(str.unpack1('m')) + end + end.new + + non_strict_response = response_for(app: [ + long_session_app, { coder: non_strict_coder } + ]) + + response = response_for(app: [ + incrementor + ], cookie: non_strict_response) + + expect(response.body).to match(%("value"=>"#{'A' * 256}")) + expect(response.body).to match('"counter"=>2') + expect(response.body).to match(/\A{[^}]+}\z/) + end +end diff --git a/rack-protection/spec/lib/rack/protection/encryptor_spec.rb b/rack-protection/spec/lib/rack/protection/encryptor_spec.rb new file mode 100644 index 0000000000..cb7a2a401f --- /dev/null +++ b/rack-protection/spec/lib/rack/protection/encryptor_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +RSpec.describe Rack::Protection::Encryptor do + let(:secret) do + OpenSSL::Cipher.new(Rack::Protection::Encryptor::CIPHER).random_key + end + + it 'encrypted message contains ciphertext iv and auth_tag' do + msg = Rack::Protection::Encryptor.encrypt_message('hello world', secret) + + ctxt, iv, auth_tag = msg.split(Rack::Protection::Encryptor::DELIMITER, 3) + + expect(ctxt).not_to be_empty + expect(iv).not_to be_empty + expect(auth_tag).not_to be_empty + end + + it 'encrypted message is decryptable' do + cmsg = Rack::Protection::Encryptor.encrypt_message('hello world', secret) + pmsg = Rack::Protection::Encryptor.decrypt_message(cmsg, secret) + + expect(pmsg).to eql('hello world') + end + + it 'encryptor and decryptor handles overly long keys' do + new_secret = "#{secret}abcdef123456" + + # These methos should truncate the long key (so OpenSSL raise exceptions) + cmsg = Rack::Protection::Encryptor.encrypt_message('hello world', new_secret) + pmsg = Rack::Protection::Encryptor.decrypt_message(cmsg, new_secret) + + expect(pmsg).to eq('hello world') + end + + it 'decrypt returns nil for junk messages' do + pmsg = Rack::Protection::Encryptor.decrypt_message('aaa--bbb-ccc', secret) + + expect(pmsg).to be_nil + end + + it 'decrypt returns nil for tampered messages' do + cmsg = Rack::Protection::Encryptor.encrypt_message('hello world', secret) + + csplit = cmsg.split(Rack::Protection::Encryptor::DELIMITER, 3) + csplit[2] = csplit.last.reverse + + tampered_msg = csplit.join(Rack::Protection::Encryptor::DELIMITER) + pmsg = Rack::Protection::Encryptor.decrypt_message(tampered_msg, secret) + + expect(pmsg).to be_nil + end +end diff --git a/rack-protection/spec/lib/rack/protection/escaped_params_spec.rb b/rack-protection/spec/lib/rack/protection/escaped_params_spec.rb new file mode 100644 index 0000000000..d6b1727b3e --- /dev/null +++ b/rack-protection/spec/lib/rack/protection/escaped_params_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +RSpec.describe Rack::Protection::EscapedParams do + it_behaves_like 'any rack application' + + context 'escaping' do + it 'escapes html entities' do + mock_app do |env| + request = Rack::Request.new(env) + [200, { 'Content-Type' => 'text/plain' }, [request.params['foo']]] + end + get '/', foo: '' + expect(body).to eq('<bar>') + end + + it 'leaves normal params untouched' do + mock_app do |env| + request = Rack::Request.new(env) + [200, { 'Content-Type' => 'text/plain' }, [request.params['foo']]] + end + get '/', foo: 'bar' + expect(body).to eq('bar') + end + + it 'copes with nested arrays' do + mock_app do |env| + request = Rack::Request.new(env) + [200, { 'Content-Type' => 'text/plain' }, [request.params['foo']['bar']]] + end + get '/', foo: { bar: '' } + expect(body).to eq('<bar>') + end + + it 'leaves cache-breaker params untouched' do + mock_app do |_env| + [200, { 'Content-Type' => 'text/plain' }, ['hi']] + end + + get '/?95df8d9bf5237ad08df3115ee74dcb10' + expect(body).to eq('hi') + end + + it 'leaves TempFiles untouched' do + mock_app do |env| + request = Rack::Request.new(env) + [200, { 'Content-Type' => 'text/plain' }, ["#{request.params['file'][:filename]}\n#{request.params['file'][:tempfile].read}\n#{request.params['other']}"]] + end + + temp_file = File.open('_escaped_params_tmp_file', 'w') + begin + temp_file.write('hello world') + temp_file.close + + post '/', file: Rack::Test::UploadedFile.new(temp_file.path), other: '' + expect(body).to eq("_escaped_params_tmp_file\nhello world\n<bar>") + ensure + File.unlink(temp_file.path) + end + end + end +end diff --git a/rack-protection/spec/lib/rack/protection/form_token_spec.rb b/rack-protection/spec/lib/rack/protection/form_token_spec.rb new file mode 100644 index 0000000000..b85923a99f --- /dev/null +++ b/rack-protection/spec/lib/rack/protection/form_token_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +RSpec.describe Rack::Protection::FormToken do + let(:token) { described_class.random_token } + let(:masked_token) { described_class.token(session) } + let(:bad_token) { Base64.strict_encode64('badtoken') } + let(:session) { { csrf: token } } + + it_behaves_like 'any rack application' + + it 'denies post requests without any token' do + expect(post('/')).not_to be_ok + end + + it 'accepts post requests with correct X-CSRF-Token header' do + post('/', {}, 'rack.session' => session, 'HTTP_X_CSRF_TOKEN' => token) + expect(last_response).to be_ok + end + + it 'accepts post requests with masked X-CSRF-Token header' do + post('/', {}, 'rack.session' => session, 'HTTP_X_CSRF_TOKEN' => masked_token) + expect(last_response).to be_ok + end + + it 'denies post requests with wrong X-CSRF-Token header' do + post('/', {}, 'rack.session' => session, 'HTTP_X_CSRF_TOKEN' => bad_token) + expect(last_response).not_to be_ok + end + + it 'accepts post form requests with correct authenticity_token field' do + post('/', { 'authenticity_token' => token }, 'rack.session' => session) + expect(last_response).to be_ok + end + + it 'accepts post form requests with masked authenticity_token field' do + post('/', { 'authenticity_token' => masked_token }, 'rack.session' => session) + expect(last_response).to be_ok + end + + it 'denies post form requests with wrong authenticity_token field' do + post('/', { 'authenticity_token' => bad_token }, 'rack.session' => session) + expect(last_response).not_to be_ok + end + + it 'accepts ajax requests without a valid token' do + expect(post('/', {}, 'HTTP_X_REQUESTED_WITH' => 'XMLHttpRequest')).to be_ok + end +end diff --git a/rack-protection/spec/lib/rack/protection/frame_options_spec.rb b/rack-protection/spec/lib/rack/protection/frame_options_spec.rb new file mode 100644 index 0000000000..23ba8f11ce --- /dev/null +++ b/rack-protection/spec/lib/rack/protection/frame_options_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +RSpec.describe Rack::Protection::FrameOptions do + it_behaves_like 'any rack application' + + it 'should set the X-Frame-Options' do + expect(get('/', {}, 'wants' => 'text/html').headers['X-Frame-Options']).to eq('SAMEORIGIN') + end + + it 'should not set the X-Frame-Options for other content types' do + expect(get('/', {}, 'wants' => 'text/foo').headers['X-Frame-Options']).to be_nil + end + + it 'should allow changing the protection mode' do + # I have no clue what other modes are available + mock_app do + use Rack::Protection::FrameOptions, frame_options: :deny + run DummyApp + end + + expect(get('/', {}, 'wants' => 'text/html').headers['X-Frame-Options']).to eq('DENY') + end + + it 'should allow changing the protection mode to a string' do + # I have no clue what other modes are available + mock_app do + use Rack::Protection::FrameOptions, frame_options: 'ALLOW-FROM foo' + run DummyApp + end + + expect(get('/', {}, 'wants' => 'text/html').headers['X-Frame-Options']).to eq('ALLOW-FROM foo') + end + + it 'should not override the header if already set' do + mock_app with_headers('X-Frame-Options' => 'allow') + expect(get('/', {}, 'wants' => 'text/html').headers['X-Frame-Options']).to eq('allow') + end +end diff --git a/rack-protection/spec/lib/rack/protection/http_origin_spec.rb b/rack-protection/spec/lib/rack/protection/http_origin_spec.rb new file mode 100644 index 0000000000..0471dd85aa --- /dev/null +++ b/rack-protection/spec/lib/rack/protection/http_origin_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +RSpec.describe Rack::Protection::HttpOrigin do + it_behaves_like 'any rack application' + + before(:each) do + mock_app do + use Rack::Protection::HttpOrigin + run DummyApp + end + end + + %w[GET HEAD POST PUT DELETE].each do |method| + it "accepts #{method} requests with no Origin" do + expect(send(method.downcase, '/')).to be_ok + end + end + + %w[GET HEAD].each do |method| + it "accepts #{method} requests with non-permitted Origin" do + expect(send(method.downcase, '/', {}, 'HTTP_ORIGIN' => 'http://malicious.com')).to be_ok + end + end + + %w[GET HEAD POST PUT DELETE].each do |method| + it "accepts #{method} requests when allow_if is true" do + mock_app do + use Rack::Protection::HttpOrigin, allow_if: ->(env) { env.key?('HTTP_ORIGIN') } + run DummyApp + end + expect(send(method.downcase, '/', {}, 'HTTP_ORIGIN' => 'http://any.domain.com')).to be_ok + end + end + + %w[POST PUT DELETE].each do |method| + it "denies #{method} requests with non-permitted Origin" do + expect(send(method.downcase, '/', {}, 'HTTP_ORIGIN' => 'http://malicious.com')).not_to be_ok + end + + it "accepts #{method} requests with permitted Origin" do + mock_app do + use Rack::Protection::HttpOrigin, permitted_origins: ['http://www.friend.com'] + run DummyApp + end + expect(send(method.downcase, '/', {}, 'HTTP_ORIGIN' => 'http://www.friend.com')).to be_ok + end + end +end diff --git a/rack-protection/spec/lib/rack/protection/ip_spoofing_spec.rb b/rack-protection/spec/lib/rack/protection/ip_spoofing_spec.rb new file mode 100644 index 0000000000..2cb201f6e7 --- /dev/null +++ b/rack-protection/spec/lib/rack/protection/ip_spoofing_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +RSpec.describe Rack::Protection::IPSpoofing do + it_behaves_like 'any rack application' + + it 'accepts requests without X-Forward-For header' do + get('/', {}, 'HTTP_CLIENT_IP' => '1.2.3.4', 'HTTP_X_REAL_IP' => '4.3.2.1') + expect(last_response).to be_ok + end + + it 'accepts requests with proper X-Forward-For header' do + get('/', {}, 'HTTP_CLIENT_IP' => '1.2.3.4', + 'HTTP_X_FORWARDED_FOR' => '192.168.1.20, 1.2.3.4, 127.0.0.1') + expect(last_response).to be_ok + end + + it 'denies requests where the client spoofs X-Forward-For but not the IP' do + get('/', {}, 'HTTP_CLIENT_IP' => '1.2.3.4', 'HTTP_X_FORWARDED_FOR' => '1.2.3.5') + expect(last_response).not_to be_ok + end + + it 'denies requests where the client spoofs the IP but not X-Forward-For' do + get('/', {}, 'HTTP_CLIENT_IP' => '1.2.3.5', + 'HTTP_X_FORWARDED_FOR' => '192.168.1.20, 1.2.3.4, 127.0.0.1') + expect(last_response).not_to be_ok + end + + it 'denies requests where IP and X-Forward-For are spoofed but not X-Real-IP' do + get('/', {}, + 'HTTP_CLIENT_IP' => '1.2.3.5', + 'HTTP_X_FORWARDED_FOR' => '1.2.3.5', + 'HTTP_X_REAL_IP' => '1.2.3.4') + expect(last_response).not_to be_ok + end +end diff --git a/rack-protection/spec/lib/rack/protection/json_csrf_spec.rb b/rack-protection/spec/lib/rack/protection/json_csrf_spec.rb new file mode 100644 index 0000000000..06312665a3 --- /dev/null +++ b/rack-protection/spec/lib/rack/protection/json_csrf_spec.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +RSpec.describe Rack::Protection::JsonCsrf do + it_behaves_like 'any rack application' + + module DummyAppWithBody + module Closeable + def close + @closed = true + end + + def closed? + @closed + end + end + + def self.body + @body ||= begin + body = ['ok'] + body.extend(Closeable) + body + end + end + + def self.call(env) + Thread.current[:last_env] = env + [200, { 'Content-Type' => 'application/json' }, body] + end + end + + describe 'json response' do + before do + mock_app { |_e| [200, { 'Content-Type' => 'application/json' }, []] } + end + + it 'denies get requests with json responses with a remote referrer' do + expect(get('/', {}, 'HTTP_REFERER' => 'http://evil.com')).not_to be_ok + end + + it 'closes the body returned by the app if it denies the get request' do + mock_app DummyAppWithBody do |_e| + [200, { 'Content-Type' => 'application/json' }, []] + end + + get('/', {}, 'HTTP_REFERER' => 'http://evil.com') + + expect(DummyAppWithBody.body).to be_closed + end + + it 'accepts requests with json responses with a remote referrer when allow_if is true' do + mock_app do + use Rack::Protection::JsonCsrf, allow_if: ->(env) { env['HTTP_REFERER'] == 'http://good.com' } + run proc { |_e| [200, { 'Content-Type' => 'application/json' }, []] } + end + + expect(get('/', {}, 'HTTP_REFERER' => 'http://good.com')).to be_ok + end + + it "accepts requests with json responses with a remote referrer when there's an origin header set" do + expect(get('/', {}, 'HTTP_REFERER' => 'http://good.com', 'HTTP_ORIGIN' => 'http://good.com')).to be_ok + end + + it "accepts requests with json responses with a remote referrer when there's an x-origin header set" do + expect(get('/', {}, 'HTTP_REFERER' => 'http://good.com', 'HTTP_X_ORIGIN' => 'http://good.com')).to be_ok + end + + it 'accepts get requests with json responses with a local referrer' do + expect(get('/', {}, 'HTTP_REFERER' => '/')).to be_ok + end + + it 'accepts get requests with json responses with no referrer' do + expect(get('/', {})).to be_ok + end + + it 'accepts XHR requests' do + expect(get('/', {}, 'HTTP_REFERER' => 'http://evil.com', 'HTTP_X_REQUESTED_WITH' => 'XMLHttpRequest')).to be_ok + end + end + + describe 'not json response' do + it 'accepts get requests with 304 headers' do + mock_app { |_e| [304, {}, []] } + expect(get('/', {}).status).to eq(304) + end + end + + describe 'with drop_session as default reaction' do + it 'still denies' do + mock_app do + use Rack::Protection, reaction: :drop_session + run proc { |_e| [200, { 'Content-Type' => 'application/json' }, []] } + end + + session = { foo: :bar } + get('/', {}, 'HTTP_REFERER' => 'http://evil.com', 'rack.session' => session) + expect(last_response).not_to be_ok + end + end +end diff --git a/rack-protection/spec/lib/rack/protection/path_traversal_spec.rb b/rack-protection/spec/lib/rack/protection/path_traversal_spec.rb new file mode 100644 index 0000000000..1eca34bdc8 --- /dev/null +++ b/rack-protection/spec/lib/rack/protection/path_traversal_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +RSpec.describe Rack::Protection::PathTraversal do + it_behaves_like 'any rack application' + + context 'escaping' do + before do + mock_app { |e| [200, { 'Content-Type' => 'text/plain' }, [e['PATH_INFO']]] } + end + + %w[/foo/bar /foo/bar/ / /.f /a.x].each do |path| + it("does not touch #{path.inspect}") { expect(get(path).body).to eq(path) } + end + + { # yes, this is ugly, feel free to change that + '/..' => '/', '/a/../b' => '/b', '/a/../b/' => '/b/', '/a/.' => '/a/', + '/%2e.' => '/', '/a/%2E%2e/b' => '/b', '/a%2f%2E%2e%2Fb/' => '/b/', + '//' => '/', '/%2fetc%2Fpasswd' => '/etc/passwd' + }.each do |a, b| + it("replaces #{a.inspect} with #{b.inspect}") { expect(get(a).body).to eq(b) } + end + + it 'should be able to deal with PATH_INFO = nil (fcgi?)' do + app = Rack::Protection::PathTraversal.new(proc { 42 }) + expect(app.call({})).to eq(42) + end + end + + context "PATH_INFO's encoding" do + before do + @app = Rack::Protection::PathTraversal.new(proc { |e| [200, { 'Content-Type' => 'text/plain' }, [e['PATH_INFO'].encoding.to_s]] }) + end + + it 'should remain unchanged as ASCII-8BIT' do + body = @app.call({ 'PATH_INFO' => '/'.encode('ASCII-8BIT') })[2][0] + expect(body).to eq('ASCII-8BIT') + end + end +end diff --git a/rack-protection/spec/lib/rack/protection/protection_spec.rb b/rack-protection/spec/lib/rack/protection/protection_spec.rb new file mode 100644 index 0000000000..e8dc55df5d --- /dev/null +++ b/rack-protection/spec/lib/rack/protection/protection_spec.rb @@ -0,0 +1,139 @@ +# frozen_string_literal: true + +RSpec.describe Rack::Protection do + it_behaves_like 'any rack application' + + it 'passes on options' do + mock_app do + use Rack::Protection, track: ['HTTP_FOO'] + run proc { |_e| [200, { 'Content-Type' => 'text/plain' }, ['hi']] } + end + + session = { foo: :bar } + get '/', {}, 'rack.session' => session, 'HTTP_ACCEPT_ENCODING' => 'a' + get '/', {}, 'rack.session' => session, 'HTTP_ACCEPT_ENCODING' => 'b' + expect(session[:foo]).to eq(:bar) + + get '/', {}, 'rack.session' => session, 'HTTP_FOO' => 'BAR' + expect(session).to be_empty + end + + it 'passes errors through if :reaction => :report is used' do + mock_app do + use Rack::Protection, reaction: :report + run proc { |e| [200, { 'Content-Type' => 'text/plain' }, [e['protection.failed'].to_s]] } + end + + session = { foo: :bar } + post('/', {}, 'rack.session' => session, 'HTTP_ORIGIN' => 'http://malicious.com') + expect(last_response).to be_ok + expect(body).to eq('true') + end + + describe '#react' do + it 'prevents attacks and warns about it' do + io = StringIO.new + mock_app do + use Rack::Protection, logger: Logger.new(io) + run DummyApp + end + post('/', {}, 'rack.session' => {}, 'HTTP_ORIGIN' => 'http://malicious.com') + expect(io.string).to match(/prevented.*Origin/) + end + + it 'reports attacks if reaction is to report' do + io = StringIO.new + mock_app do + use Rack::Protection, reaction: :report, logger: Logger.new(io) + run DummyApp + end + post('/', {}, 'rack.session' => {}, 'HTTP_ORIGIN' => 'http://malicious.com') + expect(io.string).to match(/reported.*Origin/) + expect(io.string).not_to match(/prevented.*Origin/) + end + + it 'passes errors to reaction method if specified' do + io = StringIO.new + Rack::Protection::Base.send(:define_method, :special) { |*args| io << args.inspect } + mock_app do + use Rack::Protection, reaction: :special, logger: Logger.new(io) + run DummyApp + end + post('/', {}, 'rack.session' => {}, 'HTTP_ORIGIN' => 'http://malicious.com') + expect(io.string).to match(/HTTP_ORIGIN.*malicious.com/) + expect(io.string).not_to match(/reported|prevented/) + end + end + + describe '#html?' do + context 'given an appropriate content-type header' do + subject { Rack::Protection::Base.new(nil).html? 'content-type' => 'text/html' } + it { is_expected.to be_truthy } + end + + context 'given an appropriate content-type header of text/xml' do + subject { Rack::Protection::Base.new(nil).html? 'content-type' => 'text/xml' } + it { is_expected.to be_truthy } + end + + context 'given an appropriate content-type header of application/xml' do + subject { Rack::Protection::Base.new(nil).html? 'content-type' => 'application/xml' } + it { is_expected.to be_truthy } + end + + context 'given an inappropriate content-type header' do + subject { Rack::Protection::Base.new(nil).html? 'content-type' => 'image/gif' } + it { is_expected.to be_falsey } + end + + context 'given no content-type header' do + subject { Rack::Protection::Base.new(nil).html?({}) } + it { is_expected.to be_falsey } + end + end + + describe '#instrument' do + let(:env) { { 'rack.protection.attack' => 'base' } } + let(:instrumenter) { double('Instrumenter') } + + after do + app.instrument(env) + end + + context 'with an instrumenter specified' do + let(:app) { Rack::Protection::Base.new(nil, instrumenter: instrumenter) } + + it { expect(instrumenter).to receive(:instrument).with('rack.protection', env) } + end + + context 'with no instrumenter specified' do + let(:app) { Rack::Protection::Base.new(nil) } + + it { expect(instrumenter).not_to receive(:instrument) } + end + end + + describe 'new' do + it 'should allow disable session protection' do + mock_app do + use Rack::Protection, without_session: true + run DummyApp + end + + session = { foo: :bar } + get '/', {}, 'rack.session' => session, 'HTTP_USER_AGENT' => 'a' + get '/', {}, 'rack.session' => session, 'HTTP_USER_AGENT' => 'b' + expect(session[:foo]).to eq :bar + end + + it 'should allow disable CSRF protection' do + mock_app do + use Rack::Protection, without_session: true + run DummyApp + end + + post('/', {}, 'HTTP_REFERER' => 'http://example.com/foo', 'HTTP_HOST' => 'example.org') + expect(last_response).to be_ok + end + end +end diff --git a/rack-protection/spec/lib/rack/protection/remote_referrer_spec.rb b/rack-protection/spec/lib/rack/protection/remote_referrer_spec.rb new file mode 100644 index 0000000000..92165ecaf4 --- /dev/null +++ b/rack-protection/spec/lib/rack/protection/remote_referrer_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +RSpec.describe Rack::Protection::RemoteReferrer do + it_behaves_like 'any rack application' + + it 'accepts post requests with no referrer' do + expect(post('/')).to be_ok + end + + it 'does not accept post requests with no referrer if allow_empty_referrer is false' do + mock_app do + use Rack::Protection::RemoteReferrer, allow_empty_referrer: false + run DummyApp + end + expect(post('/')).not_to be_ok + end + + it 'should allow post request with a relative referrer' do + expect(post('/', {}, 'HTTP_REFERER' => '/')).to be_ok + end + + it 'accepts post requests with the same host in the referrer' do + post('/', {}, 'HTTP_REFERER' => 'http://example.com/foo', 'HTTP_HOST' => 'example.com') + expect(last_response).to be_ok + end + + it 'denies post requests with a remote referrer' do + post('/', {}, 'HTTP_REFERER' => 'http://example.com/foo', 'HTTP_HOST' => 'example.org') + expect(last_response).not_to be_ok + end +end diff --git a/rack-protection/spec/lib/rack/protection/remote_token_spec.rb b/rack-protection/spec/lib/rack/protection/remote_token_spec.rb new file mode 100644 index 0000000000..8693ba1c68 --- /dev/null +++ b/rack-protection/spec/lib/rack/protection/remote_token_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +RSpec.describe Rack::Protection::RemoteToken do + let(:token) { described_class.random_token } + let(:masked_token) { described_class.token(session) } + let(:bad_token) { Base64.strict_encode64('badtoken') } + let(:session) { { csrf: token } } + + it_behaves_like 'any rack application' + + it 'accepts post requests with no referrer' do + expect(post('/')).to be_ok + end + + it 'accepts post requests with a local referrer' do + expect(post('/', {}, 'HTTP_REFERER' => '/')).to be_ok + end + + it 'denies post requests with a remote referrer and no token' do + post('/', {}, 'HTTP_REFERER' => 'http://example.com/foo', 'HTTP_HOST' => 'example.org') + expect(last_response).not_to be_ok + end + + it 'accepts post requests with a remote referrer and correct X-CSRF-Token header' do + post('/', {}, 'HTTP_REFERER' => 'http://example.com/foo', 'HTTP_HOST' => 'example.org', + 'rack.session' => session, 'HTTP_X_CSRF_TOKEN' => token) + expect(last_response).to be_ok + end + + it 'accepts post requests with a remote referrer and masked X-CSRF-Token header' do + post('/', {}, 'HTTP_REFERER' => 'http://example.com/foo', 'HTTP_HOST' => 'example.org', + 'rack.session' => session, 'HTTP_X_CSRF_TOKEN' => masked_token) + expect(last_response).to be_ok + end + + it 'denies post requests with a remote referrer and wrong X-CSRF-Token header' do + post('/', {}, 'HTTP_REFERER' => 'http://example.com/foo', 'HTTP_HOST' => 'example.org', + 'rack.session' => session, 'HTTP_X_CSRF_TOKEN' => bad_token) + expect(last_response).not_to be_ok + end + + it 'accepts post form requests with a remote referrer and correct authenticity_token field' do + post('/', { 'authenticity_token' => token }, 'HTTP_REFERER' => 'http://example.com/foo', + 'HTTP_HOST' => 'example.org', 'rack.session' => session) + expect(last_response).to be_ok + end + + it 'accepts post form requests with a remote referrer and masked authenticity_token field' do + post('/', { 'authenticity_token' => masked_token }, 'HTTP_REFERER' => 'http://example.com/foo', + 'HTTP_HOST' => 'example.org', 'rack.session' => session) + expect(last_response).to be_ok + end + + it 'denies post form requests with a remote referrer and wrong authenticity_token field' do + post('/', { 'authenticity_token' => bad_token }, 'HTTP_REFERER' => 'http://example.com/foo', + 'HTTP_HOST' => 'example.org', 'rack.session' => session) + expect(last_response).not_to be_ok + end +end diff --git a/rack-protection/spec/lib/rack/protection/session_hijacking_spec.rb b/rack-protection/spec/lib/rack/protection/session_hijacking_spec.rb new file mode 100644 index 0000000000..e39497b1a9 --- /dev/null +++ b/rack-protection/spec/lib/rack/protection/session_hijacking_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +RSpec.describe Rack::Protection::SessionHijacking do + it_behaves_like 'any rack application' + + it 'accepts a session without changes to tracked parameters' do + session = { foo: :bar } + get '/', {}, 'rack.session' => session + get '/', {}, 'rack.session' => session + expect(session[:foo]).to eq(:bar) + end + + it 'denies requests with a changing User-Agent header' do + session = { foo: :bar } + get '/', {}, 'rack.session' => session, 'HTTP_USER_AGENT' => 'a' + get '/', {}, 'rack.session' => session, 'HTTP_USER_AGENT' => 'b' + expect(session).to be_empty + end + + it 'accepts requests with a changing Accept-Encoding header' do + # this is tested because previously it led to clearing the session + session = { foo: :bar } + get '/', {}, 'rack.session' => session, 'HTTP_ACCEPT_ENCODING' => 'a' + get '/', {}, 'rack.session' => session, 'HTTP_ACCEPT_ENCODING' => 'b' + expect(session).not_to be_empty + end + + it 'accepts requests with a changing Version header' do + session = { foo: :bar } + get '/', {}, 'rack.session' => session, 'HTTP_VERSION' => '1.0' + get '/', {}, 'rack.session' => session, 'HTTP_VERSION' => '1.1' + expect(session[:foo]).to eq(:bar) + end +end diff --git a/rack-protection/spec/lib/rack/protection/strict_transport_spec.rb b/rack-protection/spec/lib/rack/protection/strict_transport_spec.rb new file mode 100644 index 0000000000..62602fa03a --- /dev/null +++ b/rack-protection/spec/lib/rack/protection/strict_transport_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +RSpec.describe Rack::Protection::StrictTransport do + it_behaves_like 'any rack application' + + it 'should set the Strict-Transport-Security header' do + expect(get('/', {}, 'wants' => 'text/html').headers['Strict-Transport-Security']).to eq('max-age=31536000') + end + + it 'should allow changing the max-age option' do + mock_app do + use Rack::Protection::StrictTransport, max_age: 16_070_400 + run DummyApp + end + + expect(get('/', {}, 'wants' => 'text/html').headers['Strict-Transport-Security']).to eq('max-age=16070400') + end + + it 'should allow switching on the include_subdomains option' do + mock_app do + use Rack::Protection::StrictTransport, include_subdomains: true + run DummyApp + end + + expect(get('/', {}, 'wants' => 'text/html').headers['Strict-Transport-Security']).to eq('max-age=31536000; includeSubDomains') + end + + it 'should allow switching on the preload option' do + mock_app do + use Rack::Protection::StrictTransport, preload: true + run DummyApp + end + + expect(get('/', {}, 'wants' => 'text/html').headers['Strict-Transport-Security']).to eq('max-age=31536000; preload') + end + + it 'should allow switching on all the options' do + mock_app do + use Rack::Protection::StrictTransport, preload: true, include_subdomains: true + run DummyApp + end + + expect(get('/', {}, 'wants' => 'text/html').headers['Strict-Transport-Security']).to eq('max-age=31536000; includeSubDomains; preload') + end +end diff --git a/rack-protection/spec/lib/rack/protection/xss_header_spec.rb b/rack-protection/spec/lib/rack/protection/xss_header_spec.rb new file mode 100644 index 0000000000..2fef0edbf7 --- /dev/null +++ b/rack-protection/spec/lib/rack/protection/xss_header_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +RSpec.describe Rack::Protection::XSSHeader do + it_behaves_like 'any rack application' + + it 'should set the X-XSS-Protection' do + expect(get('/', {}, 'wants' => 'text/html;charset=utf-8').headers['X-XSS-Protection']).to eq('1; mode=block') + end + + it 'should set the X-XSS-Protection for XHTML' do + expect(get('/', {}, 'wants' => 'application/xhtml+xml').headers['X-XSS-Protection']).to eq('1; mode=block') + end + + it 'should not set the X-XSS-Protection for other content types' do + expect(get('/', {}, 'wants' => 'application/foo').headers['X-XSS-Protection']).to be_nil + end + + it 'should allow changing the protection mode' do + # I have no clue what other modes are available + mock_app do + use Rack::Protection::XSSHeader, xss_mode: :foo + run DummyApp + end + + expect(get('/', {}, 'wants' => 'application/xhtml').headers['X-XSS-Protection']).to eq('1; mode=foo') + end + + it 'should not override the header if already set' do + mock_app with_headers('X-XSS-Protection' => '0') + expect(get('/', {}, 'wants' => 'text/html').headers['X-XSS-Protection']).to eq('0') + end + + it 'should set the X-Content-Type-Options' do + expect(get('/', {}, 'wants' => 'text/html').header['X-Content-Type-Options']).to eq('nosniff') + end + + it 'should set the X-Content-Type-Options for other content types' do + expect(get('/', {}, 'wants' => 'application/foo').header['X-Content-Type-Options']).to eq('nosniff') + end + + it 'should allow changing the nosniff-mode off' do + mock_app do + use Rack::Protection::XSSHeader, nosniff: false + run DummyApp + end + + expect(get('/').headers['X-Content-Type-Options']).to be_nil + end + + it 'should not override the header if already set X-Content-Type-Options' do + mock_app with_headers('X-Content-Type-Options' => 'sniff') + expect(get('/', {}, 'wants' => 'text/html').headers['X-Content-Type-Options']).to eq('sniff') + end +end diff --git a/rack-protection/spec/spec_helper.rb b/rack-protection/spec/spec_helper.rb new file mode 100644 index 0000000000..a764ce2fa3 --- /dev/null +++ b/rack-protection/spec/spec_helper.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require 'rack/protection' +require 'rack/test' +require 'rack' + +Dir[File.expand_path('support/**/*.rb', __dir__)].sort.each { |f| require f } + +# This file was generated by the `rspec --init` command. Conventionally, all +# specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. +# The generated `.rspec` file contains `--require spec_helper` which will cause this +# file to always be loaded, without a need to explicitly require it in any files. +# +# Given that it is always loaded, you are encouraged to keep this file as +# light-weight as possible. Requiring heavyweight dependencies from this file +# will add to the boot time of your test suite on EVERY test run, even for an +# individual file that may not need all of that loaded. Instead, make a +# separate helper file that requires this one and then use it only in the specs +# that actually need it. +# +# The `.rspec` file also contains a few flags that are not defaults but that +# users commonly want. +# +# See https://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration +RSpec.configure do |config| + # The settings below are suggested to provide a good initial experience + # with RSpec, but feel free to customize to your heart's content. + config.disable_monkey_patching! + + # These two settings work together to allow you to limit a spec run + # to individual examples or groups you care about by tagging them with + # `:focus` metadata. When nothing is tagged with `:focus`, all examples + # get run. + config.filter_run :focus + config.run_all_when_everything_filtered = true + + # Many RSpec users commonly either run the entire suite or an individual + # file, and it's useful to allow more verbose output when running an + # individual spec file. + if config.files_to_run.one? + # Use the documentation formatter for detailed output, + # unless a formatter has already been configured + # (e.g. via a command-line flag). + config.default_formatter = 'doc' + end + + # Print the 10 slowest examples and example groups at the + # end of the spec run, to help surface which specs are running + # particularly slow. + config.profile_examples = 5 + + # Run specs in random order to surface order dependencies. If you find an + # order dependency and want to debug it, you can fix the order by providing + # the seed, which is printed after each run. + # --seed 1234 + config.order = :random + + # Seed global randomization in this process using the `--seed` CLI option. + # Setting this allows you to use `--seed` to deterministically reproduce + # test failures related to randomization by passing the same `--seed` value + # as the one that triggered the failure. + Kernel.srand config.seed + + # rspec-expectations config goes here. You can use an alternate + # assertion/expectation library such as wrong or the stdlib/minitest + # assertions if you prefer. + config.expect_with :rspec do |expectations| + # Enable only the newer, non-monkey-patching expect syntax. + # For more details, see: + # - http://myronmars.to/n/dev-blog/2012/06/rspecs-new-expectation-syntax + expectations.syntax = :expect + end + + # rspec-mocks config goes here. You can use an alternate test double + # library (such as bogus or mocha) by changing the `mock_with` option here. + config.mock_with :rspec do |mocks| + # Enable only the newer, non-monkey-patching expect syntax. + # For more details, see: + # - http://teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/ + mocks.syntax = :expect + + # Prevents you from mocking or stubbing a method that does not exist on + # a real object. This is generally recommended. + mocks.verify_partial_doubles = true + end + + config.include Rack::Test::Methods + config.include SpecHelpers +end diff --git a/rack-protection/spec/support/dummy_app.rb b/rack-protection/spec/support/dummy_app.rb new file mode 100644 index 0000000000..fb83d36bb3 --- /dev/null +++ b/rack-protection/spec/support/dummy_app.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module DummyApp + def self.call(env) + Thread.current[:last_env] = env + body = (env['REQUEST_METHOD'] == 'HEAD' ? '' : 'ok') + [200, { 'Content-Type' => env['wants'] || 'text/plain' }, [body]] + end +end diff --git a/rack-protection/spec/support/not_implemented_as_pending.rb b/rack-protection/spec/support/not_implemented_as_pending.rb new file mode 100644 index 0000000000..2ef371b377 --- /dev/null +++ b/rack-protection/spec/support/not_implemented_as_pending.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +# see http://blog.101ideas.cz/posts/pending-examples-via-not-implemented-error-in-rspec.html +module NotImplementedAsPending + def self.included(base) + base.class_eval do + alias_method :__finish__, :finish + remove_method :finish + end + end + + def finish(reporter) + if @exception.is_a?(NotImplementedError) + from = @exception.backtrace[0] + message = "#{@exception.message} (from #{from})" + @pending_declared_in_example = message + metadata[:pending] = true + @exception = nil + end + + __finish__(reporter) + end + + RSpec::Core::Example.send :include, self +end diff --git a/rack-protection/spec/support/rack_monkey_patches.rb b/rack-protection/spec/support/rack_monkey_patches.rb new file mode 100644 index 0000000000..574d367e08 --- /dev/null +++ b/rack-protection/spec/support/rack_monkey_patches.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +version = if defined? Gem.loaded_specs&.include?('rack') + Gem.loaded_specs['rack'].version.to_s + else + "#{Rack.release}.0" + end + +if version == '1.3' + Rack::Session::Abstract::ID.class_eval do + private + + def prepare_session(env) + session_was = env[ENV_SESSION_KEY] + env[ENV_SESSION_KEY] = SessionHash.new(self, env) + env[ENV_SESSION_OPTIONS_KEY] = OptionsHash.new(self, env, @default_options) + env[ENV_SESSION_KEY].merge! session_was if session_was + end + end +end + +unless Rack::MockResponse.method_defined? :header + Rack::MockResponse.send(:alias_method, :header, :headers) +end diff --git a/rack-protection/spec/support/shared_examples.rb b/rack-protection/spec/support/shared_examples.rb new file mode 100644 index 0000000000..c8eeaad69b --- /dev/null +++ b/rack-protection/spec/support/shared_examples.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +RSpec.shared_examples_for 'any rack application' do + it 'should not interfere with normal get requests' do + expect(get('/')).to be_ok + expect(body).to eq('ok') + end + + it 'should not interfere with normal head requests' do + expect(head('/')).to be_ok + end + + it 'should not leak changes to env' do + klass = described_class + detector = Struct.new(:app) do + def call(env) + was = env.dup + res = app.call(env) + was.each do |k, v| + next if env[k] == v + + raise "env[#{k.inspect}] changed from #{v.inspect} to #{env[k].inspect}" + end + res + end + end + + mock_app do + use Rack::Head + use(Rack::Config) { |e| e['rack.session'] ||= {} } + use detector + use klass + run DummyApp + end + + expect(get('/..', foo: '')).to be_ok + end + + it 'allows passing on values in env' do + klass = described_class + changer = Struct.new(:app) do + def call(env) + env['foo.bar'] = 42 + app.call(env) + end + end + detector = Struct.new(:app) do + def call(env) + app.call(env) + end + end + + expect_any_instance_of(detector).to receive(:call).with( + hash_including('foo.bar' => 42) + ).and_call_original + + mock_app do + use Rack::Head + use(Rack::Config) { |e| e['rack.session'] ||= {} } + use changer + use klass + use detector + run DummyApp + end + + expect(get('/')).to be_ok + end +end diff --git a/rack-protection/spec/support/spec_helpers.rb b/rack-protection/spec/support/spec_helpers.rb new file mode 100644 index 0000000000..d9f40a2777 --- /dev/null +++ b/rack-protection/spec/support/spec_helpers.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require 'forwardable' + +module SpecHelpers + extend Forwardable + def_delegators :last_response, :body, :headers, :status, :errors + def_delegators :current_session, :env_for + attr_writer :app + + def app + @app ||= nil + @app || mock_app(DummyApp) + end + + def mock_app(app = nil, &block) + app = block if app.nil? && (block.arity == 1) + if app + klass = described_class + mock_app do + use Rack::Head + use(Rack::Config) { |e| e['rack.session'] ||= {} } + use klass + run app + end + else + @app = Rack::Lint.new Rack::Builder.new(&block).to_app + end + end + + def with_headers(headers) + proc { [200, { 'Content-Type' => 'text/plain' }.merge(headers), ['ok']] } + end + + def env + Thread.current[:last_env] + end +end diff --git a/sinatra-contrib/.gitignore b/sinatra-contrib/.gitignore new file mode 100644 index 0000000000..91628b84b0 --- /dev/null +++ b/sinatra-contrib/.gitignore @@ -0,0 +1,3 @@ +doc/ +.bundle/ +Gemfile.lock diff --git a/sinatra-contrib/.rspec b/sinatra-contrib/.rspec new file mode 100644 index 0000000000..0d786ba0e1 --- /dev/null +++ b/sinatra-contrib/.rspec @@ -0,0 +1,3 @@ +--color +--warnings +--require spec_helper diff --git a/sinatra-contrib/Gemfile b/sinatra-contrib/Gemfile new file mode 100644 index 0000000000..e8a225c563 --- /dev/null +++ b/sinatra-contrib/Gemfile @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +source 'https://rubygems.org' +gemspec + +gem 'rack-protection', path: '../rack-protection' +gem 'sinatra', path: '..' + +gem 'rack-test', github: 'rack/rack-test' + +group :development, :test do + platform :jruby do + gem 'json' + gem 'rdoc' + gem 'therubyrhino' + gem 'jar-dependencies', '= 0.4.1' # Gem::LoadError with jar-dependencies 0.4.2 + end + + platform :jruby, :ruby do + gem 'hamlit', '>= 3' + gem 'liquid' + # Use main until there's a slim release that can be used with Tilt 2.1.0 + # https://github.com/slim-template/slim/pull/910 + gem 'slim', github: 'slim-template/slim' + end + + platform :ruby do + gem 'execjs', '>= 2.0.0' + gem 'nokogiri', '>= 1.13.6' + gem 'redcarpet', '>= 3.5.1' + gem 'yajl-ruby' + end + + gem 'multi_json' +end + +rack_version = ENV['rack'].to_s +rack_version = nil if rack_version.empty? || (rack_version == 'stable') +rack_version = { github: 'rack/rack' } if rack_version == 'head' +gem 'rack', rack_version + +tilt_version = ENV['tilt'].to_s +tilt_version = nil if tilt_version.empty? || (tilt_version == 'stable') +tilt_version = { github: 'jeremyevans/tilt' } if tilt_version == 'head' +gem 'tilt', tilt_version diff --git a/sinatra-contrib/LICENSE b/sinatra-contrib/LICENSE new file mode 100644 index 0000000000..9522a75e1a --- /dev/null +++ b/sinatra-contrib/LICENSE @@ -0,0 +1,23 @@ +The MIT License (MIT) + +Copyright (c) 2008-2017 Nicolas Sanguinetti, entp.com, Konstantin Haase +Copyright (c) 2015-2017 Zachary Scott + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/sinatra-contrib/README.md b/sinatra-contrib/README.md new file mode 100644 index 0000000000..02f4992809 --- /dev/null +++ b/sinatra-contrib/README.md @@ -0,0 +1,180 @@ +# Sinatra::Contrib + +Collection of common Sinatra extensions, semi-officially supported. + +## Goals + +* For every future Sinatra release, have at least one fully compatible release +* High code quality, high test coverage +* Include plugins people usually ask for a lot + +## Included extensions + +### Common Extensions + +These are common extension which will not add significant overhead or change any +behavior of already existing APIs. They do not add any dependencies not already +installed with this gem. + +Currently included: + +* [`sinatra/capture`][sinatra-capture]: Let's you capture the content of blocks in templates. + +* [`sinatra/config_file`][sinatra-config-file]: Allows loading configuration from yaml files. + +* [`sinatra/content_for`][sinatra-content-for]: Adds Rails-style `content_for` helpers to Haml, Erb, Erubi + and Slim. + +* [`sinatra/cookies`][sinatra-cookies]: A `cookies` helper for reading and writing cookies. + +* [`sinatra/engine_tracking`][sinatra-engine-tracking]: Adds methods like `haml?` that allow helper + methods to check whether they are called from within a template. + +* [`sinatra/json`][sinatra-json]: Adds a `#json` helper method to return JSON documents. + +* [`sinatra/link_header`][sinatra-link-header]: Helpers for generating `link` HTML tags and + corresponding `Link` HTTP headers. Adds `link`, `stylesheet` and `prefetch` + helper methods. + +* [`sinatra/multi_route`][sinatra-multi-route]: Adds ability to define one route block for multiple + routes and multiple or custom HTTP verbs. + +* [`sinatra/namespace`][sinatra-namespace]: Adds namespace support to Sinatra. + +* [`sinatra/respond_with`][sinatra-respond-with]: Choose action and/or template automatically + depending on the incoming request. Adds helpers `respond_to` and + `respond_with`. + +* [`sinatra/custom_logger`][sinatra-custom-logger]: This extension allows you to define your own + logger instance using +logger+ setting. That logger then will + be available as #logger helper method in your routes and views. + +* [`sinatra/required_params`][sinatra-required-params]: Ensure if required query parameters exist + +### Custom Extensions + +These extensions may add additional dependencies and enhance the behavior of the +existing APIs. + +Currently included: + +* [`sinatra/reloader`][sinatra-reloader]: Automatically reloads Ruby files on code changes. **DEPRECATED**: Please consider +consider using an alternative like [rerun](https://github.com/alexch/rerun) or +[rack-unreloader](https://github.com/jeremyevans/rack-unreloader) instead. + +### Other Tools + +* [`sinatra/extension`][sinatra-extension]: Mixin for writing your own Sinatra extensions. + +* [`sinatra/test_helpers`][sinatra-test-helpers]: Helper methods to ease testing your Sinatra + application. Partly extracted from Sinatra. Testing framework agnostic + +* `sinatra/quiet_logger`: Extension to exclude specific pathes from access log. + It works by patching Rack::CommonLogger + +## Installation + +Add `gem 'sinatra-contrib'` to *Gemfile*, then execute `bundle install`. + +If you don't use Bundler, install the gem manually by executing `gem install sinatra-contrib` in your command line. + +### Git + +If you want to use the gem from git, for whatever reason, you can do the following: + +```ruby +github 'sinatra/sinatra' do + gem 'sinatra-contrib' +end +``` + +Within this block you can also specify other gems from this git repository. + +## Usage + +### Classic Style + +A single extension (example: sinatra-content-for): + +``` ruby +require 'sinatra' +require 'sinatra/content_for' +``` + +Common extensions: + +``` ruby +require 'sinatra' +require 'sinatra/contrib' +``` + +All extensions: + +``` ruby +require 'sinatra' +require 'sinatra/contrib/all' +``` + +### Modular Style + +A single extension (example: sinatra-content-for): + +``` ruby +require 'sinatra/base' +require 'sinatra/content_for' +require 'sinatra/namespace' + +class MyApp < Sinatra::Base + # Note: Some modules are extensions, some helpers, see the specific + # documentation or the source + helpers Sinatra::ContentFor + register Sinatra::Namespace +end +``` + +Common extensions: + +``` ruby +require 'sinatra/base' +require 'sinatra/contrib' + +class MyApp < Sinatra::Base + register Sinatra::Contrib +end +``` + +All extensions: + +``` ruby +require 'sinatra/base' +require 'sinatra/contrib/all' + +class MyApp < Sinatra::Base + register Sinatra::Contrib +end +``` + +### Documentation + +For more info check the [official docs](http://www.sinatrarb.com/contrib/) and +[api docs](https://www.rubydoc.info/gems/sinatra-contrib). + +[sinatra-reloader]: http://www.sinatrarb.com/contrib/reloader +[sinatra-namespace]: http://www.sinatrarb.com/contrib/namespace +[sinatra-content-for]: http://www.sinatrarb.com/contrib/content_for +[sinatra-cookies]: http://www.sinatrarb.com/contrib/cookies +[sinatra-streaming]: http://www.sinatrarb.com/contrib/streaming +[sinatra-webdav]: http://www.sinatrarb.com/contrib/webdav +[sinatra-runner]: http://www.sinatrarb.com/contrib/runner +[sinatra-extension]: http://www.sinatrarb.com/contrib/extension +[sinatra-test-helpers]: https://github.com/sinatra/sinatra/blob/main/sinatra-contrib/lib/sinatra/test_helpers.rb +[sinatra-required-params]: http://www.sinatrarb.com/contrib/required_params +[sinatra-custom-logger]: http://www.sinatrarb.com/contrib/custom_logger +[sinatra-multi-route]: http://www.sinatrarb.com/contrib/multi_route +[sinatra-json]: http://www.sinatrarb.com/contrib/json +[sinatra-respond-with]: http://www.sinatrarb.com/contrib/respond_with +[sinatra-config-file]: http://www.sinatrarb.com/contrib/config_file +[sinatra-link-header]: http://www.sinatrarb.com/contrib/link_header +[sinatra-capture]: http://www.sinatrarb.com/contrib/capture +[sinatra-engine-tracking]: https://github.com/sinatra/sinatra/blob/main/sinatra-contrib/lib/sinatra/engine_tracking.rb + diff --git a/sinatra-contrib/Rakefile b/sinatra-contrib/Rakefile new file mode 100644 index 0000000000..3bb065c38c --- /dev/null +++ b/sinatra-contrib/Rakefile @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +$LOAD_PATH.unshift File.expand_path('lib', __dir__) +require 'open-uri' +require 'yaml' +require 'sinatra/contrib/version' + +desc 'run specs' +task(:spec) { ruby '-S rspec' } +task(test: :spec) +task(default: :spec) + +namespace :doc do + task :readmes do + Dir.glob 'lib/sinatra/*.rb' do |file| + puts "Trying file... #{file}" + excluded_files = %w[lib/sinatra/contrib.rb lib/sinatra/decompile.rb] + next if excluded_files.include?(file) + + doc = File.read(file)[/^module Sinatra(\n)+( #[^\n]*\n)*/m].scan(/^ *#(?!#) ?(.*)\n/).join("\n") + file = "doc/#{file[4..-4].tr('/_', '-')}.rdoc" + Dir.mkdir 'doc' unless File.directory? 'doc' + puts "writing #{file}" + File.open(file, 'w') { |f| f << doc } + end + end + + task :index do + doc = File.read('README.md') + file = 'doc/sinatra-contrib-readme.md' + Dir.mkdir 'doc' unless File.directory? 'doc' + puts "writing #{file}" + File.open(file, 'w') { |f| f << doc } + end + + task all: %i[readmes index] +end + +desc 'generate documentation' +task doc: 'doc:all' + +desc 'generate gemspec' +task 'sinatra-contrib.gemspec' do + content = File.read 'sinatra-contrib.gemspec' + + fields = { + authors: `git shortlog -sn`.scan(/[^\d\s].*/), + email: `git shortlog -sne`.scan(/[^<]+@[^>]+/), + files: `git ls-files`.split("\n").grep_v(/^(\.|Gemfile)/) + } + + fields.each do |field, values| + updated = " s.#{field} = [" + updated << values.map { |v| "\n %p" % v }.join(',') + updated << "\n ]" + content.sub!(/ s\.#{field} = \[\n( .*\n)* \]/, updated) + end + + File.open('sinatra-contrib.gemspec', 'w') { |f| f << content } +end + +task gemspec: 'sinatra-contrib.gemspec' + +task release: :gemspec do + sh <<-SH + rm -Rf sinatra-contrib*.gem && + gem build sinatra-contrib.gemspec && + gem install sinatra-contrib*.gem --local && + gem push sinatra-contrib*.gem && + git commit --allow-empty -a -m '#{Sinatra::Contrib::VERSION} release' && + git tag -s v#{Sinatra::Contrib::VERSION} -m '#{Sinatra::Contrib::VERSION} release' && + git push && (git push origin main || true) && + git push --tags && (git push origin --tags || true) + SH +end diff --git a/sinatra-contrib/ideas.md b/sinatra-contrib/ideas.md new file mode 100644 index 0000000000..fc4df978f6 --- /dev/null +++ b/sinatra-contrib/ideas.md @@ -0,0 +1,29 @@ +* Extension that does something like this: + + def build(*) + if settings.memcached? + use Rack::Cache, :backend => :memcached + use Rack::Session::Memcached + # ... + end + super + end + +* `sinatra-smart-cache`: update cache header only if arguments are more + restrictive than curent value, set caching headers that way for most helper + methods (i.e. `send_file`) + +* Some verbose logging extension: Log what filters, routes, error handlers, + templates, and so on is used. + +* Form helpers, with forms as first class objects that accepts hashes or + something, so the form meta data can also be used to expose a JSON API or + similar, possibly defining routes (like "Sinatra's Hat"), strictly using + the ActiveModel API. + +* Extend `sinatra-content-for` to support Liquid, Radius, Markaby, Nokogiri and + Builder. At least the first two probably involve patching Tilt. + +* Rewrite of `sinatra-compass`? + +* Helpers for HTML escaping and such. diff --git a/sinatra-contrib/lib/sinatra/capture.rb b/sinatra-contrib/lib/sinatra/capture.rb new file mode 100644 index 0000000000..ef9e78e80e --- /dev/null +++ b/sinatra-contrib/lib/sinatra/capture.rb @@ -0,0 +1,113 @@ +require 'sinatra/base' +require 'sinatra/engine_tracking' + +module Sinatra + # + # = Sinatra::Capture + # + # Extension that enables blocks inside other extensions. + # It currently works for erb, slim and haml. + # Enables mixing of different template languages. + # + # Example: + # + # # in hello_world.erb + # + # Say + # <% a = capture do %>World<% end %> + # Hello <%= a %>! + # + # # in hello_world.slim + # + # | Say + # - a = capture do + # | World + # | Hello #{a}! + # + # # in hello_world.haml + # + # Say + # - a = capture do + # World + # Hello #{a.strip}! + # + # + # You can also use nested blocks. + # + # Example + # + # # in hello_world.erb + # + # Say + # <% a = capture do %> + # <% b = capture do %>World<% end %> + # <%= b %>! + # <% end %> + # Hello <%= a.strip %> + # + # + # The main advantage of capture is mixing of different template engines. + # + # Example + # + # # in mix_me_up.slim + # + # - two = capture do + # - erb "<%= 1 + 1 %>" + # | 1 + 1 = #{two} + # + # == Usage + # + # === Classic Application + # + # In a classic application simply require the helpers, and start using them: + # + # require "sinatra" + # require "sinatra/capture" + # + # # The rest of your classic application code goes here... + # + # === Modular Application + # + # In a modular application you need to require the helpers, and then tell + # the application you will use them: + # + # require "sinatra/base" + # require "sinatra/capture" + # + # class MyApp < Sinatra::Base + # helpers Sinatra::Capture + # + # # The rest of your modular application code goes here... + # end + # + module Capture + include Sinatra::EngineTracking + + def capture(*args, &block) + return block[*args] if ruby? + + if haml? && Tilt[:haml] == Tilt::HamlTemplate && defined?(Haml::Buffer) + buffer = Haml::Buffer.new(nil, Haml::Options.new.for_buffer) + with_haml_buffer(buffer) { capture_haml(*args, &block) } + else + buf_was = @_out_buf + @_out_buf = '' + begin + raw = block[*args] + captured = block.binding.eval('@_out_buf') + captured.empty? ? raw : captured + ensure + @_out_buf = buf_was + end + end + end + + def capture_later(&block) + engine = current_engine + proc { |*a| with_engine(engine) { @capture = capture(*a, &block) } } + end + end + + helpers Capture +end diff --git a/sinatra-contrib/lib/sinatra/config_file.rb b/sinatra-contrib/lib/sinatra/config_file.rb new file mode 100644 index 0000000000..c3c0a1aa47 --- /dev/null +++ b/sinatra-contrib/lib/sinatra/config_file.rb @@ -0,0 +1,172 @@ +require 'sinatra/base' +require 'yaml' +require 'erb' + +module Sinatra + # = Sinatra::ConfigFile + # + # Sinatra::ConfigFile is an extension that allows you to load the + # application's configuration from YAML files. It automatically detects if + # the files contain specific environment settings and it will use those + # corresponding to the current one. + # + # You can access those options through +settings+ within the application. If + # you try to get the value for a setting that hasn't been defined in the + # config file for the current environment, you will get whatever it was set + # to in the application. + # + # == Usage + # + # Once you have written your configurations to a YAML file you can tell the + # extension to load them. See below for more information about how these + # files are interpreted. + # + # For the examples, lets assume the following config.yml file: + # + # greeting: Welcome to my file configurable application + # + # === Classic Application + # + # require "sinatra" + # require "sinatra/config_file" + # + # config_file 'path/to/config.yml' + # + # get '/' do + # @greeting = settings.greeting + # haml :index + # end + # + # # The rest of your classic application code goes here... + # + # === Modular Application + # + # require "sinatra/base" + # require "sinatra/config_file" + # + # class MyApp < Sinatra::Base + # register Sinatra::ConfigFile + # + # config_file 'path/to/config.yml' + # + # get '/' do + # @greeting = settings.greeting + # haml :index + # end + # + # # The rest of your modular application code goes here... + # end + # + # === Config File Format + # + # In its most simple form this file is just a key-value list: + # + # foo: bar + # something: 42 + # nested: + # a: 1 + # b: 2 + # + # But it also can provide specific environment configuration. There are two + # ways to do that: at the file level and at the settings level. + # + # At the settings level (e.g. in 'path/to/config.yml'): + # + # development: + # foo: development + # bar: bar + # test: + # foo: test + # bar: bar + # production: + # foo: production + # bar: bar + # + # Or at the file level: + # + # foo: + # development: development + # test: test + # production: production + # bar: bar + # + # In either case, settings.foo will return the environment name, and + # settings.bar will return "bar". + # + # If you wish to provide defaults that may be shared among all the + # environments, this can be done by using a YAML alias, and then overwriting + # values in environments where appropriate: + # + # default: &common_settings + # foo: 'foo' + # bar: 'bar' + # + # production: + # <<: *common_settings + # bar: 'baz' # override the default value + # + module ConfigFile + # When the extension is registered sets the +environments+ setting to the + # traditional environments: development, test and production. + def self.registered(base) + base.set :environments, %w[test production development] + end + + # Loads the configuration from the YAML files whose +paths+ are passed as + # arguments, filtering the settings for the current environment. Note that + # these +paths+ can actually be globs. + def config_file(*paths) + Dir.chdir(root || '.') do + paths.each do |pattern| + Dir.glob(pattern) do |file| + raise UnsupportedConfigType unless ['.yml', '.yaml', '.erb'].include?(File.extname(file)) + + logger.info "loading config file '#{file}'" if logging? && respond_to?(:logger) + document = ERB.new(File.read(file)).result + yaml = YAML.respond_to?(:unsafe_load) ? YAML.unsafe_load(document) : YAML.load(document) + config = config_for_env(yaml) + config.each_pair { |key, value| set(key, value) } + end + end + end + end + + class UnsupportedConfigType < StandardError + def message + 'Invalid config file type, use .yml, .yaml or .erb' + end + end + + private + + # Given a +hash+ containing application configuration it returns + # settings applicable to the current environment. Note: It gives + # precedence to environment settings defined at the root-level. + def config_for_env(hash) + return from_environment_key(hash) if environment_keys?(hash) + + hash.each_with_object(IndifferentHash[]) do |(k, v), acc| + if environment_keys?(v) + acc.merge!(k => v[environment.to_s]) if v.key?(environment.to_s) + else + acc.merge!(k => v) + end + end + end + + # Given a +hash+ returns the settings corresponding to the current + # environment. + def from_environment_key(hash) + hash[environment.to_s] || hash[environment.to_sym] || {} + end + + # Returns true if supplied with a hash that has any recognized + # +environments+ in its root keys. + def environment_keys?(hash) + hash.is_a?(Hash) && hash.any? { |k, _| environments.include?(k.to_s) } + end + end + + register ConfigFile + Delegator.delegate :config_file +end diff --git a/sinatra-contrib/lib/sinatra/content_for.rb b/sinatra-contrib/lib/sinatra/content_for.rb new file mode 100644 index 0000000000..499f860d01 --- /dev/null +++ b/sinatra-contrib/lib/sinatra/content_for.rb @@ -0,0 +1,197 @@ +# frozen_string_literal: true + +require 'sinatra/base' +require 'sinatra/capture' + +module Sinatra + # = Sinatra::ContentFor + # + # Sinatra::ContentFor is a set of helpers that allows you to capture + # blocks inside views to be rendered later during the request. The most + # common use is to populate different parts of your layout from your view. + # + # The currently supported engines are: Erb, Erubi, Haml and Slim. + # + # == Usage + # + # You call +content_for+, generally from a view, to capture a block of markup + # giving it an identifier: + # + # # index.erb + # <% content_for :some_key do %> + # ... + # <% end %> + # + # Then, you call +yield_content+ with that identifier, generally from a + # layout, to render the captured block: + # + # # layout.erb + # <%= yield_content :some_key %> + # + # If you have provided +yield_content+ with a block and no content for the + # specified key is found, it will render the results of the block provided + # to yield_content. + # + # # layout.erb + # <% yield_content :some_key_with_no_content do %> + # ... + # <% end %> + # + # === Classic Application + # + # To use the helpers in a classic application all you need to do is require + # them: + # + # require "sinatra" + # require "sinatra/content_for" + # + # # Your classic application code goes here... + # + # === Modular Application + # + # To use the helpers in a modular application you need to require them, and + # then, tell the application you will use them: + # + # require "sinatra/base" + # require "sinatra/content_for" + # + # class MyApp < Sinatra::Base + # helpers Sinatra::ContentFor + # + # # The rest of your modular application code goes here... + # end + # + # == And How Is This Useful? + # + # For example, some of your views might need a few javascript tags and + # stylesheets, but you don't want to force this files in all your pages. + # Then you can put <%= yield_content :scripts_and_styles %> on your + # layout, inside the tag, and each view can call content_for + # setting the appropriate set of tags that should be added to the layout. + # + # == Limitations + # + # Due to the rendering process limitation using <%= yield_content %> + # from within nested templates do not work above the <%= yield %> statement. + # For more details https://github.com/sinatra/sinatra-contrib/issues/140#issuecomment-48831668 + # + # # app.rb + # get '/' do + # erb :body, :layout => :layout do + # erb :foobar + # end + # end + # + # # foobar.erb + # <% content_for :one do %> + # + # <% end %> + # <% content_for :two do %> + # + # <% end %> + # + # Using <%= yield_content %> before <%= yield %> will cause only the second + # alert to display: + # + # # body.erb + # # Display only second alert + # <%= yield_content :one %> + # <%= yield %> + # <%= yield_content :two %> + # + # # body.erb + # # Display both alerts + # <%= yield %> + # <%= yield_content :one %> + # <%= yield_content :two %> + # + module ContentFor + include Capture + + # Capture a block of content to be rendered later. For example: + # + # <% content_for :head do %> + # + # <% end %> + # + # You can also pass an immediate value instead of a block: + # + # <% content_for :title, "foo" %> + # + # You can call +content_for+ multiple times with the same key + # (in the example +:head+), and when you render the blocks for + # that key all of them will be rendered, in the same order you + # captured them. + # + # Your blocks can also receive values, which are passed to them + # by yield_content + def content_for(key, value = nil, options = {}, &block) + block ||= proc { |*| value } + clear_content_for(key) if options[:flush] + content_blocks[key.to_sym] << capture_later(&block) + end + + # Check if a block of content with the given key was defined. For + # example: + # + # <% content_for :head do %> + # + # <% end %> + # + # <% if content_for? :head %> + # content "head" was defined. + # <% end %> + def content_for?(key) + content_blocks[key.to_sym].any? + end + + # Unset a named block of content. For example: + # + # <% clear_content_for :head %> + def clear_content_for(key) + content_blocks.delete(key.to_sym) if content_for?(key) + end + + # Render the captured blocks for a given key. For example: + # + # + # Example + # <%= yield_content :head %> + # + # + # Would render everything you declared with content_for + # :head before closing the tag. + # + # You can also pass values to the content blocks by passing them + # as arguments after the key: + # + # <%= yield_content :head, 1, 2 %> + # + # Would pass 1 and 2 to all the blocks registered + # for :head. + def yield_content(key, *args, &block) + if block_given? && !content_for?(key) + haml? && Tilt[:haml] == Tilt::HamlTemplate && respond_to?(:capture_haml) ? capture_haml(*args, &block) : yield(*args) + else + content = content_blocks[key.to_sym].map { |b| capture(*args, &b) } + content.join.tap do |c| + if block_given? && (erb? || erubi?) + @_out_buf << c + end + end + end + end + + private + + def content_blocks + @content_blocks ||= Hash.new { |h, k| h[k] = [] } + end + end + + helpers ContentFor +end diff --git a/sinatra-contrib/lib/sinatra/contrib.rb b/sinatra-contrib/lib/sinatra/contrib.rb new file mode 100644 index 0000000000..3601ee44bb --- /dev/null +++ b/sinatra-contrib/lib/sinatra/contrib.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require 'sinatra/contrib/setup' + +module Sinatra + module Contrib + ## + # Common middleware that doesn't bring run time overhead if not used + # or breaks if external dependencies are missing. Will extend + # Sinatra::Application by default. + module Common + register :ConfigFile, 'sinatra/config_file' + register :MultiRoute, 'sinatra/multi_route' + register :Namespace, 'sinatra/namespace' + register :RespondWith, 'sinatra/respond_with' + + helpers :Capture, 'sinatra/capture' + helpers :ContentFor, 'sinatra/content_for' + helpers :Cookies, 'sinatra/cookies' + helpers :EngineTracking, 'sinatra/engine_tracking' + helpers :JSON, 'sinatra/json' + helpers :LinkHeader, 'sinatra/link_header' + helpers :Streaming, 'sinatra/streaming' + helpers :RequiredParams, 'sinatra/required_params' + end + + ## + # Other extensions you don't want to be loaded unless needed. + module Custom + register :Reloader, 'sinatra/reloader' + end + + ## + # Stuff that aren't Sinatra extensions, technically. + autoload :Extension, 'sinatra/extension' + autoload :TestHelpers, 'sinatra/test_helpers' + end + + register Sinatra::Contrib::Common +end diff --git a/sinatra-contrib/lib/sinatra/contrib/all.rb b/sinatra-contrib/lib/sinatra/contrib/all.rb new file mode 100644 index 0000000000..c217a112a0 --- /dev/null +++ b/sinatra-contrib/lib/sinatra/contrib/all.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +require 'sinatra/contrib' +Sinatra.register Sinatra::Contrib::All diff --git a/sinatra-contrib/lib/sinatra/contrib/setup.rb b/sinatra-contrib/lib/sinatra/contrib/setup.rb new file mode 100644 index 0000000000..b2ae15e944 --- /dev/null +++ b/sinatra-contrib/lib/sinatra/contrib/setup.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require 'sinatra/base' +require 'sinatra/contrib/version' + +module Sinatra + module Contrib + module Loader + def extensions + @extensions ||= { helpers: [], register: [] } + end + + def register(name, path) + autoload name, path, :register + end + + def helpers(name, path) + autoload name, path, :helpers + end + + def autoload(name, path, method = nil) + extensions[method] << name if method + Sinatra.autoload(name, path) + end + + def registered(base) + @extensions.each do |method, list| + list = list.map { |name| Sinatra.const_get name } + base.send(method, *list) unless base == ::Sinatra::Application + end + end + end + + module Common + extend Loader + end + + module Custom + extend Loader + end + + module All + def self.registered(base) + base.register Common, Custom + end + end + + extend Loader + def self.registered(base) + base.register Common, Custom + end + end +end diff --git a/sinatra-contrib/lib/sinatra/contrib/version.rb b/sinatra-contrib/lib/sinatra/contrib/version.rb new file mode 100644 index 0000000000..7d2eafe2fd --- /dev/null +++ b/sinatra-contrib/lib/sinatra/contrib/version.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Sinatra + module Contrib + VERSION = '3.0.5' + end +end diff --git a/sinatra-contrib/lib/sinatra/cookies.rb b/sinatra-contrib/lib/sinatra/cookies.rb new file mode 100644 index 0000000000..742f45d7d0 --- /dev/null +++ b/sinatra-contrib/lib/sinatra/cookies.rb @@ -0,0 +1,345 @@ +# frozen_string_literal: true + +require 'sinatra/base' + +module Sinatra + # = Sinatra::Cookies + # + # Easy way to deal with cookies + # + # == Usage + # + # Allows you to read cookies: + # + # get '/' do + # "value: #{cookies[:something]}" + # end + # + # And of course to write cookies: + # + # get '/set' do + # cookies[:something] = 'foobar' + # redirect to('/') + # end + # + # And generally behaves like a hash: + # + # get '/demo' do + # cookies.merge! 'foo' => 'bar', 'bar' => 'baz' + # cookies.keep_if { |key, value| key.start_with? 'b' } + # foo, bar = cookies.values_at 'foo', 'bar' + # "size: #{cookies.length}" + # end + # + # === Classic Application + # + # In a classic application simply require the helpers, and start using them: + # + # require "sinatra" + # require "sinatra/cookies" + # + # # The rest of your classic application code goes here... + # + # === Modular Application + # + # In a modular application you need to require the helpers, and then tell + # the application to use them: + # + # require "sinatra/base" + # require "sinatra/cookies" + # + # class MyApp < Sinatra::Base + # helpers Sinatra::Cookies + # + # # The rest of your modular application code goes here... + # end + # + module Cookies + class Jar + include Enumerable + attr_reader :options + + def initialize(app) + @response_string = nil + @response_hash = {} + @response = app.response + @request = app.request + @deleted = [] + + @options = { + path: @request.script_name.to_s.empty? ? '/' : @request.script_name, + domain: @request.host == 'localhost' ? nil : @request.host, + secure: @request.secure?, + httponly: true + } + + return unless app.settings.respond_to? :cookie_options + + @options.merge! app.settings.cookie_options + end + + def ==(other) + other.respond_to? :to_hash and to_hash == other.to_hash + end + + def [](key) + response_cookies[key.to_s] || request_cookies[key.to_s] + end + + def []=(key, value) + set(key, value: value) + end + + if Hash.method_defined? :assoc + def assoc(key) + to_hash.assoc(key.to_s) + end + end + + def clear + each_key { |k| delete(k) } + end + + def compare_by_identity? + false + end + + def default + nil + end + + alias default_proc default + + def delete(key) + result = self[key] + @response.delete_cookie(key.to_s, @options) + result + end + + def delete_if + return enum_for(__method__) unless block_given? + + each { |k, v| delete(k) if yield(k, v) } + self + end + + def each(&block) + return enum_for(__method__) unless block_given? + + to_hash.each(&block) + end + + def each_key(&block) + return enum_for(__method__) unless block_given? + + to_hash.each_key(&block) + end + + alias each_pair each + + def each_value(&block) + return enum_for(__method__) unless block_given? + + to_hash.each_value(&block) + end + + def empty? + to_hash.empty? + end + + def fetch(key, &block) + response_cookies.fetch(key.to_s) do + request_cookies.fetch(key.to_s, &block) + end + end + + if Hash.method_defined? :flatten + def flatten + to_hash.flatten + end + end + + def has_key?(key) + response_cookies.key? key.to_s or request_cookies.key? key.to_s + end + + def has_value?(value) + response_cookies.value? value or request_cookies.value? value + end + + def hash + to_hash.hash + end + + alias include? has_key? + alias member? has_key? + + def inspect + "<##{self.class}: #{to_hash.inspect[1..-2]}>" + end + + if Hash.method_defined? :invert + def invert + to_hash.invert + end + end + + def keep_if + return enum_for(__method__) unless block_given? + + delete_if { |*a| !yield(*a) } + end + + def key(value) + to_hash.key(value) + end + + alias key? has_key? + + def keys + to_hash.keys + end + + def length + to_hash.length + end + + def merge(other, &block) + to_hash.merge(other, &block) + end + + def merge!(other) + other.each_pair do |key, value| + self[key] = if block_given? && include?(key) + yield(key.to_s, self[key], value) + else + value + end + end + end + + def rassoc(value) + to_hash.rassoc(value) + end + + def rehash + response_cookies.rehash + request_cookies.rehash + self + end + + def reject(&block) + return enum_for(__method__) unless block_given? + + to_hash.reject(&block) + end + + alias reject! delete_if + + def replace(other) + select! { |k, _v| other.include?(k) or other.include?(k.to_s) } + merge! other + end + + def select(&block) + return enum_for(__method__) unless block_given? + + to_hash.select(&block) + end + + alias select! keep_if if Hash.method_defined? :select! + + def set(key, options = {}) + @response.set_cookie key.to_s, @options.merge(options) + end + + def shift + key, value = to_hash.shift + delete(key) + [key, value] + end + + alias size length + + if Hash.method_defined? :sort + def sort(&block) + to_hash.sort(&block) + end + end + + alias store []= + + def to_hash + request_cookies.merge(response_cookies) + end + + def to_a + to_hash.to_a + end + + def to_s + to_hash.to_s + end + + alias update merge! + alias value? has_value? + + def values + to_hash.values + end + + def values_at(*list) + list.map { |k| self[k] } + end + + private + + def warn(message) + super "#{caller.first[/^[^:]:\d+:/]} warning: #{message}" + end + + def deleted + parse_response + @deleted + end + + def response_cookies + parse_response + @response_hash + end + + def parse_response + string = @response['Set-Cookie'] + return if @response_string == string + + hash = {} + + string.each_line do |line| + key, value = line.split(';', 2).first.to_s.split('=', 2) + next if key.nil? + + key = Rack::Utils.unescape(key) + if line =~ /expires=Thu, 01[-\s]Jan[-\s]1970/ + @deleted << key + else + @deleted.delete key + hash[key] = value + end + end + + @response_hash.replace hash + @response_string = string + end + + def request_cookies + @request.cookies.reject { |key, _value| deleted.include? key } + end + end + + def cookies + @cookies ||= Jar.new(self) + end + end + + helpers Cookies +end diff --git a/sinatra-contrib/lib/sinatra/custom_logger.rb b/sinatra-contrib/lib/sinatra/custom_logger.rb new file mode 100644 index 0000000000..eb3295601a --- /dev/null +++ b/sinatra-contrib/lib/sinatra/custom_logger.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +module Sinatra + # = Sinatra::CustomLogger + # + # CustomLogger extension allows you to define your own logger instance + # using +logger+ setting. That logger then will be available + # as #logger helper method in your routes and views. + # + # == Usage + # + # === Classic Application + # + # To define your custom logger instance in a classic application: + # + # require 'sinatra' + # require 'sinatra/custom_logger' + # require 'logger' + # + # set :logger, Logger.new(STDOUT) + # + # get '/' do + # logger.info 'Some message' # STDOUT logger is used + # # Other code... + # end + # + # === Modular Application + # + # The same for modular application: + # + # require 'sinatra/base' + # require 'sinatra/custom_logger' + # require 'logger' + # + # class MyApp < Sinatra::Base + # helpers Sinatra::CustomLogger + # + # configure :development, :production do + # logger = Logger.new(File.open("#{root}/log/#{environment}.log", 'a')) + # logger.level = Logger::DEBUG if development? + # set :logger, logger + # end + # + # get '/' do + # logger.info 'Some message' # File-based logger is used + # # Other code... + # end + # end + # + module CustomLogger + def logger + if settings.respond_to?(:logger) + settings.logger + else + request.logger + end + end + end + + helpers CustomLogger +end diff --git a/sinatra-contrib/lib/sinatra/engine_tracking.rb b/sinatra-contrib/lib/sinatra/engine_tracking.rb new file mode 100644 index 0000000000..52ebf858fb --- /dev/null +++ b/sinatra-contrib/lib/sinatra/engine_tracking.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require 'sinatra/base' + +module Sinatra + # Adds methods like `haml?` that allow helper methods to check whether they + # are called from within a template. + module EngineTracking + attr_reader :current_engine + + # @return [Boolean] Returns true if current engine is `:erb`. + def erb? + @current_engine == :erb + end + + # Returns true if the current engine is `:erubi`, or `Tilt[:erb]` is set + # to Tilt::ErubiTemplate. + # + # @return [Boolean] Returns true if current engine is `:erubi`. + def erubi? + @current_engine == :erubi or + (erb? && Tilt[:erb] == Tilt::ErubiTemplate) + end + + # @return [Boolean] Returns true if current engine is `:haml`. + def haml? + @current_engine == :haml + end + + # @return [Boolean] Returns true if current engine is `:builder`. + def builder? + @current_engine == :builder + end + + # @return [Boolean] Returns true if current engine is `:liquid`. + def liquid? + @current_engine == :liquid + end + + # @return [Boolean] Returns true if current engine is `:markdown`. + def markdown? + @current_engine == :markdown + end + + # @return [Boolean] Returns true if current engine is `:rdoc`. + def rdoc? + @current_engine == :rdoc + end + + # @return [Boolean] Returns true if current engine is `:markaby`. + def markaby? + @current_engine == :markaby + end + + # @return [Boolean] Returns true if current engine is `:nokogiri`. + def nokogiri? + @current_engine == :nokogiri + end + + # @return [Boolean] Returns true if current engine is `:slim`. + def slim? + @current_engine == :slim + end + + # @return [Boolean] Returns true if current engine is `:ruby`. + def ruby? + @current_engine == :ruby + end + + def initialize(*) + @current_engine = :ruby + super + end + + # @param engine [Symbol, String] Name of Engine to shift to. + def with_engine(engine) + engine_was = @current_engine + @current_engine = engine.to_sym + yield + ensure + @current_engine = engine_was + end + + private + + def render(engine, *) + with_engine(engine) { super } + end + end + + helpers EngineTracking +end diff --git a/sinatra-contrib/lib/sinatra/extension.rb b/sinatra-contrib/lib/sinatra/extension.rb new file mode 100644 index 0000000000..7e6b609180 --- /dev/null +++ b/sinatra-contrib/lib/sinatra/extension.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require 'sinatra/base' + +module Sinatra + # = Sinatra::Extension + # + # Sinatra::Extension is a mixin that provides some syntactic sugar + # for your extensions. It allows you to call almost any + # Sinatra::Base method directly inside your extension + # module. This means you can use +get+ to define a route, +before+ + # to define a before filter, +set+ to define a setting and so on. + # + # Is important to be aware that this mixin remembers the method calls you + # make, and then, when your extension is registered, replays them on the + # Sinatra application that has been extended. In order to do that, it + # defines a registered method, so, if your extension defines one + # too, remember to call +super+. + # + # == Usage + # + # Just require the mixin and extend your extension with it: + # + # require 'sinatra/extension' + # + # module MyExtension + # extend Sinatra::Extension + # + # # set some settings for development + # configure :development do + # set :reload_stuff, true + # end + # + # # define a route + # get '/' do + # 'Hello World' + # end + # + # # The rest of your extension code goes here... + # end + # + # You can also create an extension with the +new+ method: + # + # MyExtension = Sinatra::Extension.new do + # # Your extension code goes here... + # end + # + # This is useful when you just want to pass a block to + # Sinatra::Base.register. + module Extension + def self.new(&block) + ext = Module.new.extend(self) + ext.class_eval(&block) + ext + end + + def settings + self + end + + def configure(*args, &block) + record(:configure, *args) { |c| c.instance_exec(c, &block) } + end + + def registered(base = nil, &block) + base ? replay(base) : record(:class_eval, &block) + end + + private + + def record(method, *args, &block) + recorded_methods << [method, args, block] + end + + def replay(object) + recorded_methods.each { |m, a, b| object.send(m, *a, &b) } + end + + def recorded_methods + @recorded_methods ||= [] + end + + def method_missing(method, *args, &block) + return super unless Sinatra::Base.respond_to? method + + record(method, *args, &block) + DontCall.new(method) + end + + class DontCall < BasicObject + def initialize(method) @method = method end + def method_missing(*) raise "not supposed to use result of #{@method}!" end + def inspect; "#<#{self.class}: #{@method}>" end + end + end +end diff --git a/sinatra-contrib/lib/sinatra/json.rb b/sinatra-contrib/lib/sinatra/json.rb new file mode 100644 index 0000000000..31c4199ad9 --- /dev/null +++ b/sinatra-contrib/lib/sinatra/json.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +require 'sinatra/base' +require 'multi_json' +module Sinatra + # = Sinatra::JSON + # + # Sinatra::JSON adds a helper method, called +json+, for (obviously) + # json generation. + # + # == Usage + # + # === Classic Application + # + # In a classic application simply require the helper, and start using it: + # + # require "sinatra" + # require "sinatra/json" + # + # # define a route that uses the helper + # get '/' do + # json :foo => 'bar' + # end + # + # # The rest of your classic application code goes here... + # + # === Modular Application + # + # In a modular application you need to require the helper, and then tell the + # application you will use it: + # + # require "sinatra/base" + # require "sinatra/json" + # + # class MyApp < Sinatra::Base + # + # # define a route that uses the helper + # get '/' do + # json :foo => 'bar' + # end + # + # # The rest of your modular application code goes here... + # end + # + # === Encoders + # + # By default it will try to call +to_json+ on the object, but if it doesn't + # respond to that message, it will use its own rather simple encoder. You can + # easily change that anyways. To use +JSON+, simply require it: + # + # require 'json' + # + # The same goes for Yajl::Encoder: + # + # require 'yajl' + # + # For other encoders, besides requiring them, you need to define the + # :json_encoder setting. For instance, for the +Whatever+ encoder: + # + # require 'whatever' + # set :json_encoder, Whatever + # + # To force +json+ to simply call +to_json+ on the object: + # + # set :json_encoder, :to_json + # + # Actually, it can call any method: + # + # set :json_encoder, :my_fancy_json_method + # + # === Content-Type + # + # It will automatically set the content type to "application/json". As + # usual, you can easily change that, with the :json_content_type + # setting: + # + # set :json_content_type, :js + # + # === Overriding the Encoder and the Content-Type + # + # The +json+ helper will also take two options :encoder and + # :content_type. The values of this options are the same as the + # :json_encoder and :json_content_type settings, + # respectively. You can also pass those to the json method: + # + # get '/' do + # json({:foo => 'bar'}, :encoder => :to_json, :content_type => :js) + # end + # + module JSON + class << self + def encode(object) + ::MultiJson.dump(object) + end + end + + def json(object, options = {}) + content_type resolve_content_type(options) + resolve_encoder_action object, resolve_encoder(options) + end + + private + + def resolve_content_type(options = {}) + options[:content_type] || settings.json_content_type + end + + def resolve_encoder(options = {}) + options[:json_encoder] || settings.json_encoder + end + + def resolve_encoder_action(object, encoder) + %i[encode generate].each do |method| + return encoder.send(method, object) if encoder.respond_to? method + end + raise "#{encoder} does not respond to #generate nor #encode" unless encoder.is_a? Symbol + + object.__send__(encoder) + end + end + + Base.set :json_encoder do + ::MultiJson + end + + Base.set :json_content_type, :json + + # Load the JSON helpers in modular style automatically + Base.helpers JSON +end diff --git a/sinatra-contrib/lib/sinatra/link_header.rb b/sinatra-contrib/lib/sinatra/link_header.rb new file mode 100644 index 0000000000..fcf1dcb41e --- /dev/null +++ b/sinatra-contrib/lib/sinatra/link_header.rb @@ -0,0 +1,132 @@ +require 'sinatra/base' + +module Sinatra + # = Sinatra::LinkHeader + # + # Sinatra::LinkHeader adds a set of helper methods to generate link + # HTML tags and their corresponding Link HTTP headers. + # + # == Usage + # + # Once you had set up the helpers in your application (see below), you will + # be able to call the following methods from inside your route handlers, + # filters and templates: + # + # +prefetch+:: + # Sets the Link HTTP headers and returns HTML tags to prefetch the given + # resources. + # + # +stylesheet+:: + # Sets the Link HTTP headers and returns HTML tags to use the given + # stylesheets. + # + # +link+:: + # Sets the Link HTTP headers and returns the corresponding HTML tags + # for the given resources. + # + # +link_headers+:: + # Returns the corresponding HTML tags for the current Link HTTP headers. + # + # === Classic Application + # + # In a classic application simply require the helpers, and start using them: + # + # require "sinatra" + # require "sinatra/link_header" + # + # # The rest of your classic application code goes here... + # + # === Modular Application + # + # In a modular application you need to require the helpers, and then tell + # the application you will use them: + # + # require "sinatra/base" + # require "sinatra/link_header" + # + # class MyApp < Sinatra::Base + # helpers Sinatra::LinkHeader + # + # # The rest of your modular application code goes here... + # end + # + module LinkHeader + ## + # Sets Link HTTP header and returns HTML tags for telling the browser to + # prefetch given resources (only supported by Opera and Firefox at the + # moment). + def prefetch(*urls) + link(:prefetch, *urls) + end + + ## + # Sets Link HTTP header and returns HTML tags for using stylesheets. + def stylesheet(*urls) + urls << {} unless urls.last.respond_to? :to_hash + urls.last[:type] ||= mime_type(:css) + link(:stylesheet, *urls) + end + + ## + # Sets Link HTTP header and returns corresponding HTML tags. + # + # Example: + # + # # Sets header: + # # Link: ; rel="next" + # # Returns String: + # # '' + # link '/foo', :rel => :next + # + # # Multiple URLs + # link :stylesheet, '/a.css', '/b.css' + def link(*urls) + opts = urls.last.respond_to?(:to_hash) ? urls.pop : {} + opts[:rel] = urls.shift unless urls.first.respond_to? :to_str + options = opts.map { |k, v| " #{k}=#{v.to_s.inspect}" } + html_pattern = "" + http_pattern = ['<%s>', *options].join ';' + link = (response['Link'] ||= '') + + urls.map do |url| + link << ",\n" unless link.empty? + link << (http_pattern % url) + html_pattern % url + end.join "\n" + end + + ## + # Takes the current value of th Link header(s) and generates HTML tags + # from it. + # + # Example: + # + # get '/' do + # # You can of course use fancy helpers like #link, #stylesheet + # # or #prefetch + # response["Link"] = '; rel="next"' + # haml :some_page + # end + # + # __END__ + # + # @@ layout + # %head= link_headers + # %body= yield + def link_headers + yield if block_given? + return '' unless response.include? 'Link' + + response['Link'].split(",\n").map do |line| + url, *opts = line.split(';').map(&:strip) + "" + end.join "\n" + end + + def self.registered(_base) + puts "WARNING: #{self} is a helpers module, not an extension." + end + end + + helpers LinkHeader +end diff --git a/sinatra-contrib/lib/sinatra/multi_route.rb b/sinatra-contrib/lib/sinatra/multi_route.rb new file mode 100644 index 0000000000..6de5acb2f0 --- /dev/null +++ b/sinatra-contrib/lib/sinatra/multi_route.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require 'sinatra/base' + +module Sinatra + # = Sinatra::MultiRoute + # + # Create multiple routes with one statement. + # + # == Usage + # + # Use this extension to create a handler for multiple routes: + # + # get '/foo', '/bar' do + # # ... + # end + # + # Or for multiple verbs: + # + # route :get, :post, '/' do + # # ... + # end + # + # Or for multiple verbs and multiple routes: + # + # route :get, :post, ['/foo', '/bar'] do + # # ... + # end + # + # Or even for custom verbs: + # + # route 'LIST', '/' do + # # ... + # end + # + # === Classic Application + # + # To use the extension in a classic application all you need to do is require + # it: + # + # require "sinatra" + # require "sinatra/multi_route" + # + # # Your classic application code goes here... + # + # === Modular Application + # + # To use the extension in a modular application you need to require it, and + # then, tell the application you will use it: + # + # require "sinatra/base" + # require "sinatra/multi_route" + # + # class MyApp < Sinatra::Base + # register Sinatra::MultiRoute + # + # # The rest of your modular application code goes here... + # end + # + module MultiRoute + def head(*args, &block) super(*route_args(args), &block) end + def delete(*args, &block) super(*route_args(args), &block) end + def get(*args, &block) super(*route_args(args), &block) end + def options(*args, &block) super(*route_args(args), &block) end + def patch(*args, &block) super(*route_args(args), &block) end + def post(*args, &block) super(*route_args(args), &block) end + def put(*args, &block) super(*route_args(args), &block) end + + def route(*args, &block) + options = Hash === args.last ? args.pop : {} + routes = [*args.pop] + args.each do |verb| + verb = verb.to_s.upcase if Symbol === verb + routes.each do |route| + super(verb, route, options, &block) + end + end + end + + private + + def route_args(args) + options = Hash === args.last ? args.pop : {} + [args, options] + end + end + + register MultiRoute +end diff --git a/sinatra-contrib/lib/sinatra/namespace.rb b/sinatra-contrib/lib/sinatra/namespace.rb new file mode 100644 index 0000000000..6a00d3753f --- /dev/null +++ b/sinatra-contrib/lib/sinatra/namespace.rb @@ -0,0 +1,375 @@ +# frozen_string_literal: true + +require 'sinatra/base' +require 'mustermann' + +module Sinatra + # = Sinatra::Namespace + # + # Sinatra::Namespace is an extension that adds namespaces to an + # application. This namespaces will allow you to share a path prefix for the + # routes within the namespace, and define filters, conditions and error + # handlers exclusively for them. Besides that, you can also register helpers + # and extensions that will be used only within the namespace. + # + # == Usage + # + # Once you have loaded the extension (see below), you can use the +namespace+ + # method to define namespaces in your application. + # + # You can define a namespace by a path prefix: + # + # namespace '/blog' do + # get { haml :blog } + # get '/:entry_permalink' do + # @entry = Entry.find_by_permalink!(params[:entry_permalink]) + # haml :entry + # end + # + # # More blog routes... + # end + # + # by a condition: + # + # namespace :host_name => 'localhost' do + # get('/admin/dashboard') { haml :dashboard } + # get('/admin/login') { haml :login } + # + # # More admin routes... + # end + # + # or both: + # + # namespace '/admin', :host_name => 'localhost' do + # get('/dashboard') { haml :dashboard } + # get('/login') { haml :login } + # post('/login') { login_user } + # + # # More admin routes... + # end + # + # Regex is also accepted: + # + # namespace /\/posts\/([^\/&?]+)\// do + # get { haml :blog } + # + # # More blog routes... + # end + # + # When you define a filter or an error handler, or register an extension or a + # set of helpers within a namespace, they only affect the routes defined in + # it. For instance, lets define a before filter to prevent the access of + # unauthorized users to the admin section of the application: + # + # namespace '/admin' do + # helpers AdminHelpers + # before { authenticate unless request.path_info == '/admin/login' } + # + # get '/dashboard' do + # # Only authenticated users can access here... + # haml :dashboard + # end + # + # # More admin routes... + # end + # + # get '/' do + # # Any user can access here... + # haml :index + # end + # + # Well, they actually also affect the nested namespaces: + # + # namespace '/admin' do + # helpers AdminHelpers + # before { authenticate unless request.path_info == '/admin/login' } + # + # namespace '/users' do + # get do + # # Only authenticated users can access here... + # @users = User.all + # haml :users + # end + # + # # More user admin routes... + # end + # + # # More admin routes... + # end + # + # Redirecting within the namespace can be done using redirect_to: + # + # namespace '/admin' do + # get '/foo' do + # redirect_to '/bar' # Redirects to /admin/bar + # end + # + # get '/foo' do + # redirect '/bar' # Redirects to /bar + # end + # end + # + # === Classic Application Setup + # + # To be able to use namespaces in a classic application all you need to do is + # require the extension: + # + # require "sinatra" + # require "sinatra/namespace" + # + # namespace '/users' do + # end + # + # === Modular Application Setup + # + # To be able to use namespaces in a modular application all you need to do is + # require the extension, and then, register it: + # + # require "sinatra/base" + # require "sinatra/namespace" + # + # class MyApp < Sinatra::Base + # register Sinatra::Namespace + # + # namespace '/users' do + # end + # end + # + # === Within an extension + # + # To be able to use namespaces within an extension, you need to first create + # an extension. This includes defining the `registered(app)` method in the + # module. + # + # require 'sinatra/base' # For creating Sinatra extensions + # require 'sinatra/namespace' # To create namespaces + # + # module Zomg # Keep everything under "Zomg" namespace for sanity + # module Routes # Define a new "Routes" module + # + # def self.registered(app) + # # First, register the Namespace extension + # app.register Sinatra::Namespace + # + # # This defines an `/api` namespace on the application + # app.namespace '/api' do + # get '/users' do + # # do something with `GET "/api/users"` + # end + # end + # + # end + # end + # + # # Lastly, register the extension to use in our app + # Sinatra.register Routes + # end + # + # In order to use this extension, is the same as any other Sinatra extension: + # + # module Zomg + # # Define our app class, we use modular app for this example + # class App < Sinatra::Base + # # this gives us all the namespaces we defined earlier + # register Routes + # + # get '/' do + # "Welcome to my application!" + # end + # end + # end + # + # Zomg::App.run! # Don't forget to start your app ;) + # + # Phew! That was a mouthful. + # + # I hope that helps you use `Sinatra::Namespace` in every way imaginable! + # + module Namespace + def self.new(base, pattern, conditions = {}, &block) + Module.new do + # quelch uninitialized variable warnings, since these get used by compile method. + @pattern = nil + @conditions = nil + extend NamespacedMethods + include InstanceMethods + @base = base + @extensions = [] + @errors = {} + @pattern, @conditions = compile(pattern, conditions) + @templates = Hash.new { |_h, k| @base.templates[k] } + namespace = self + before { extend(@namespace = namespace) } + class_eval(&block) + end + end + + module InstanceMethods + def settings + @namespace + end + + def template_cache + super.fetch(:nested, @namespace) { Tilt::Cache.new } + end + + def redirect_to(uri, *args) + redirect("#{@namespace.pattern}#{uri}", *args) + end + end + + module SharedMethods + def namespace(pattern, conditions = {}, &block) + Sinatra::Namespace.new(self, pattern, conditions, &block) + end + end + + module NamespacedMethods + include SharedMethods + attr_reader :base, :templates + + ALLOWED_ENGINES = %i[ + erb erubi haml hamlit builder nokogiri + liquid markdown rdoc asciidoc markaby + rabl slim yajl + ] + + def self.prefixed(*names) + names.each { |n| define_method(n) { |*a, &b| prefixed(n, *a, &b) } } + end + + prefixed :before, :after, :delete, :get, :head, :options, :patch, :post, :put + + def helpers(*extensions, &block) + class_eval(&block) if block_given? + include(*extensions) if extensions.any? + end + + def register(*extensions, &block) + extensions << Module.new(&block) if block_given? + @extensions += extensions + extensions.each do |extension| + extend extension + extension.registered(self) if extension.respond_to?(:registered) + end + end + + def invoke_hook(name, *args) + @extensions.each { |e| e.send(name, *args) if e.respond_to?(name) } + end + + def not_found(&block) + error(Sinatra::NotFound, &block) + end + + def errors + base.errors.merge(namespace_errors) + end + + def namespace_errors + @errors + end + + def error(*codes, &block) + args = Sinatra::Base.send(:compile!, 'ERROR', /.*/, block) + codes = codes.map { |c| Array(c) }.flatten + codes << Exception if codes.empty? + codes << Sinatra::NotFound if codes.include?(404) + + codes.each do |c| + errors = @errors[c] ||= [] + errors << args + end + end + + def respond_to(*args) + return @conditions[:provides] || base.respond_to if args.empty? + + @conditions[:provides] = args + end + + def set(key, value = self, &block) + return key.each { |k, v| set(k, v) } if key.respond_to?(:each) && block.nil? && (value == self) + raise ArgumentError, "may not set #{key}" unless ([:views] + ALLOWED_ENGINES).include?(key) + + block ||= proc { value } + singleton_class.send(:define_method, key, &block) + end + + def enable(*opts) + opts.each { |key| set(key, true) } + end + + def disable(*opts) + opts.each { |key| set(key, false) } + end + + def template(name, &block) + first_location = caller_locations.first + filename = first_location.path + line = first_location.lineno + templates[name] = [block, filename, line] + end + + def layout(name = :layout, &block) + template name, &block + end + + def pattern + @pattern + end + + private + + def app + base.respond_to?(:base) ? base.base : base + end + + def compile(pattern, conditions, default_pattern = nil) + if pattern.respond_to? :to_hash + conditions = conditions.merge pattern.to_hash + pattern = nil + end + base_pattern = @pattern + base_conditions = @conditions + pattern ||= default_pattern + [prefixed_path(base_pattern, pattern), + (base_conditions || {}).merge(conditions)] + end + + def prefixed_path(a, b) + return a || b || /.*/ unless a && b + return Mustermann.new(b) if a == /.*/ + + Mustermann.new(a) + Mustermann.new(b) + end + + def prefixed(method, pattern = nil, conditions = {}, &block) + default = %r{(?:/.*)?} if (method == :before) || (method == :after) + pattern, conditions = compile pattern, conditions, default + result = base.send(method, pattern, **conditions, &block) + invoke_hook :route_added, method.to_s.upcase, pattern, block + result + end + + def method_missing(method, *args, &block) + base.send(method, *args, &block) + end + + def respond_to?(method, include_private = false) + super || base.respond_to?(method, include_private) + end + end + + module BaseMethods + include SharedMethods + end + + def self.extended(base) + base.extend BaseMethods + end + end + + register Sinatra::Namespace + Delegator.delegate :namespace +end diff --git a/sinatra-contrib/lib/sinatra/quiet_logger.rb b/sinatra-contrib/lib/sinatra/quiet_logger.rb new file mode 100644 index 0000000000..ee0ff8dafd --- /dev/null +++ b/sinatra-contrib/lib/sinatra/quiet_logger.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module Sinatra + # = Sinatra::QuietLogger + # + # QuietLogger extension allows you to define paths excluded + # from logging using the +quiet_logger_prefixes+ setting. + # It is inspired from rails quiet_logger, but handles multiple paths. + # + # == Usage + # + # === Classic Application + # + # You have to require the quiet_logger, set the prefixes + # and register the extension in your application. + # + # require 'sinatra' + # require 'sinatra/quiet_logger' + # + # set :quiet_logger_prefixes, %w(css js images fonts) + # register Sinatra::QuietLogger + # + # === Modular Application + # + # The same for modular application: + # + # require 'sinatra/base' + # require 'sinatra/quiet_logger' + # + # set :quiet_logger_prefixes, %w(css js images fonts) + # + # class App < Sinatra::Base + # register Sinatra::QuietLogger + # end + # + module QuietLogger + def self.registered(app) + quiet_logger_prefixes = begin + app.settings.quiet_logger_prefixes.join('|') + rescue StandardError + '' + end + return warn('You need to specify the paths you wish to exclude from logging via `set :quiet_logger_prefixes, %w(images css fonts)`') if quiet_logger_prefixes.empty? + + const_set('QUIET_LOGGER_REGEX', %r(\A/{0,2}(?:#{quiet_logger_prefixes}))) + ::Rack::CommonLogger.prepend( + ::Module.new do + def log(env, *) + super unless env['PATH_INFO'] =~ QUIET_LOGGER_REGEX + end + end + ) + end + end +end diff --git a/sinatra-contrib/lib/sinatra/reloader.rb b/sinatra-contrib/lib/sinatra/reloader.rb new file mode 100644 index 0000000000..8f7f19a9f6 --- /dev/null +++ b/sinatra-contrib/lib/sinatra/reloader.rb @@ -0,0 +1,432 @@ +# frozen_string_literal: true + +require 'sinatra/base' + +module Sinatra + # = Sinatra::Reloader + # + # DEPRECATED: Please consider using an alternative like + # rerun or rack-unreloader instead. + # + # Extension to reload modified files. Useful during development, + # since it will automatically require files defining routes, filters, + # error handlers and inline templates, with every incoming request, + # but only if they have been updated. + # + # == Usage + # + # === Classic Application + # + # To enable the reloader in a classic application all you need to do is + # require it: + # + # require "sinatra" + # require "sinatra/reloader" if development? + # + # # Your classic application code goes here... + # + # === Modular Application + # + # To enable the reloader in a modular application all you need to do is + # require it, and then, register it: + # + # require "sinatra/base" + # require "sinatra/reloader" + # + # class MyApp < Sinatra::Base + # configure :development do + # register Sinatra::Reloader + # end + # + # # Your modular application code goes here... + # end + # + # == Using the Reloader in Other Environments + # + # By default, the reloader is only enabled for the development + # environment. Similar to registering the reloader in a modular + # application, a classic application requires manually enabling the + # extension for it to be available in a non-development environment. + # + # require "sinatra" + # require "sinatra/reloader" + # + # configure :production do + # enable :reloader + # end + # + # == Changing the Reloading Policy + # + # You can refine the reloading policy with +also_reload+ and + # +dont_reload+, to customize which files should, and should not, be + # reloaded, respectively. You can also use +after_reload+ to execute a + # block after any file being reloaded. + # + # === Classic Application + # + # Simply call the methods: + # + # require "sinatra" + # require "sinatra/reloader" if development? + # + # also_reload '/path/to/some/file' + # dont_reload '/path/to/other/file' + # after_reload do + # puts 'reloaded' + # end + # + # # Your classic application code goes here... + # + # === Modular Application + # + # Call the methods inside the +configure+ block: + # + # require "sinatra/base" + # require "sinatra/reloader" + # + # class MyApp < Sinatra::Base + # configure :development do + # register Sinatra::Reloader + # also_reload '/path/to/some/file' + # dont_reload '/path/to/other/file' + # after_reload do + # puts 'reloaded' + # end + # end + # + # # Your modular application code goes here... + # end + # + module Reloader + # Watches a file so it can tell when it has been updated, and what + # elements does it contain. + class Watcher + # Represents an element of a Sinatra application that may need to + # be reloaded. An element could be: + # * a route + # * a filter + # * an error handler + # * a middleware + # * inline templates + # + # Its +representation+ attribute is there to allow to identify the + # element within an application, that is, to match it with its + # Sinatra's internal representation. + class Element < Struct.new(:type, :representation) + end + + # Collection of file +Watcher+ that can be associated with a + # Sinatra application. That way, we can know which files belong + # to a given application and which files have been modified. It + # also provides a mechanism to inform a Watcher of the elements + # defined in the file being watched and if its changes should be + # ignored. + class List + @app_list_map = Hash.new { |hash, key| hash[key] = new } + + # Returns the +List+ for the application +app+. + def self.for(app) + @app_list_map[app] + end + + # Creates a new +List+ instance. + def initialize + @path_watcher_map = Hash.new do |hash, key| + hash[key] = Watcher.new(key) + end + end + + # Lets the +Watcher+ for the file located at +path+ know that the + # +element+ is defined there, and adds the +Watcher+ to the +List+, + # if it isn't already there. + def watch(path, element) + watcher_for(path).elements << element + end + + # Tells the +Watcher+ for the file located at +path+ to ignore + # the file changes, and adds the +Watcher+ to the +List+, if + # it isn't already there. + def ignore(path) + watcher_for(path).ignore + end + + # Adds a +Watcher+ for the file located at +path+ to the + # +List+, if it isn't already there. + def watcher_for(path) + @path_watcher_map[File.expand_path(path)] + end + alias watch_file watcher_for + + # Returns an array with all the watchers in the +List+. + def watchers + @path_watcher_map.values + end + + # Returns an array with all the watchers in the +List+ that + # have been updated. + def updated + watchers.find_all(&:updated?) + end + end + + attr_reader :path, :elements, :mtime + + # Creates a new +Watcher+ instance for the file located at +path+. + def initialize(path) + @ignore = nil + @path = path + @elements = [] + update + end + + # Indicates whether or not the file being watched has been modified. + def updated? + !ignore? && !removed? && mtime != File.mtime(path) + end + + # Updates the mtime of the file being watched. + def update + @mtime = File.mtime(path) + end + + # Indicates whether or not the file being watched has inline + # templates. + def inline_templates? + elements.any? { |element| element.type == :inline_templates } + end + + # Informs that the modifications to the file being watched + # should be ignored. + def ignore + @ignore = true + end + + # Indicates whether or not the modifications to the file being + # watched should be ignored. + def ignore? + !!@ignore + end + + # Indicates whether or not the file being watched has been removed. + def removed? + !File.exist?(path) + end + end + + MUTEX_FOR_PERFORM = Mutex.new + + # Allow a block to be executed after any file being reloaded + @@after_reload = [] + def after_reload(&block) + @@after_reload << block + end + + # When the extension is registered it extends the Sinatra application + # +klass+ with the modules +BaseMethods+ and +ExtensionMethods+ and + # defines a before filter to +perform+ the reload of the modified files. + def self.registered(klass) + @reloader_loaded_in ||= {} + return if @reloader_loaded_in[klass] + + @reloader_loaded_in[klass] = true + + klass.extend BaseMethods + klass.extend ExtensionMethods + klass.set(:reloader) { klass.development? } + klass.set(:reload_templates) { klass.reloader? } + klass.before do + if klass.reloader? + MUTEX_FOR_PERFORM.synchronize { Reloader.perform(klass) } + end + end + klass.set(:inline_templates, klass.app_file) if klass == Sinatra::Application + end + + # Reloads the modified files, adding, updating and removing the + # needed elements. + def self.perform(klass) + reloaded_paths = [] + Watcher::List.for(klass).updated.each do |watcher| + klass.set(:inline_templates, watcher.path) if watcher.inline_templates? + watcher.elements.each { |element| klass.deactivate(element) } + # Deletes all old elements. + watcher.elements.delete_if { true } + $LOADED_FEATURES.delete(watcher.path) + require watcher.path + watcher.update + reloaded_paths << watcher.path + end + return if reloaded_paths.empty? + + @@after_reload.each do |block| + block.arity.zero? ? block.call : block.call(reloaded_paths) + end + # Prevents after_reload from increasing each time it's reloaded. + @@after_reload.delete_if do |blk| + path, = blk.source_location + path && reloaded_paths.include?(path) + end + end + + # Contains the methods defined in Sinatra::Base that are overridden. + module BaseMethods + # Protects Sinatra::Base.run! from being called more than once. + def run!(*args) + if settings.reloader? + super unless running? + else + super + end + end + + # Does everything Sinatra::Base#route does, but it also tells the + # +Watcher::List+ for the Sinatra application to watch the defined + # route. + # + # Note: We are using #compile! so we don't interfere with extensions + # changing #route. + def compile!(verb, path, block, **options) + source_location = block.respond_to?(:source_location) ? + block.source_location.first : caller_files[1] + signature = super + watch_element( + source_location, :route, { verb: verb, signature: signature } + ) + signature + end + + # Does everything Sinatra::Base#inline_templates= does, but it also + # tells the +Watcher::List+ for the Sinatra application to watch the + # inline templates in +file+ or the file who made the call to this + # method. + def inline_templates=(file = nil) + file = (caller_files[1] || File.expand_path($0)) if file.nil? || file == true + watch_element(file, :inline_templates) + super + end + + # Does everything Sinatra::Base#use does, but it also tells the + # +Watcher::List+ for the Sinatra application to watch the middleware + # being used. + def use(middleware, *args, &block) + path = caller_files[1] || File.expand_path($0) + watch_element(path, :middleware, [middleware, args, block]) + super + end + + # Does everything Sinatra::Base#add_filter does, but it also tells + # the +Watcher::List+ for the Sinatra application to watch the defined + # filter. + def add_filter(type, path = nil, **options, &block) + source_location = block.respond_to?(:source_location) ? + block.source_location.first : caller_files[1] + result = super + watch_element(source_location, :"#{type}_filter", filters[type].last) + result + end + + # Does everything Sinatra::Base#error does, but it also tells the + # +Watcher::List+ for the Sinatra application to watch the defined + # error handler. + def error(*codes, &block) + path = caller_files[1] || File.expand_path($0) + result = super + codes.each do |c| + watch_element(path, :error, code: c, handler: @errors[c]) + end + result + end + + # Does everything Sinatra::Base#register does, but it also lets the + # reloader know that an extension is being registered, because the + # elements defined in its +registered+ method need a special treatment. + def register(*extensions, &block) + start_registering_extension + result = super + stop_registering_extension + result + end + + # Does everything Sinatra::Base#register does and then registers the + # reloader in the +subclass+. + def inherited(subclass) + result = super + subclass.register Sinatra::Reloader + result + end + end + + # Contains the methods that the extension adds to the Sinatra application. + module ExtensionMethods + # Removes the +element+ from the Sinatra application. + def deactivate(element) + case element.type + when :route + verb = element.representation[:verb] + signature = element.representation[:signature] + (routes[verb] ||= []).delete(signature) + when :middleware + @middleware.delete(element.representation) + when :before_filter + filters[:before].delete(element.representation) + when :after_filter + filters[:after].delete(element.representation) + when :error + code = element.representation[:code] + handler = element.representation[:handler] + @errors.delete(code) if @errors[code] == handler + end + end + + # Indicates with a +glob+ which files should be reloaded if they + # have been modified. It can be called several times. + def also_reload(*glob) + Dir[*glob].each { |path| Watcher::List.for(self).watch_file(path) } + end + + # Indicates with a +glob+ which files should not be reloaded even if + # they have been modified. It can be called several times. + def dont_reload(*glob) + Dir[*glob].each { |path| Watcher::List.for(self).ignore(path) } + end + + private + + # attr_reader :register_path warn on -w (private attribute) + def register_path; @register_path ||= nil; end + + # Indicates an extesion is being registered. + def start_registering_extension + @register_path = caller_files[2] + end + + # Indicates the extesion has already been registered. + def stop_registering_extension + @register_path = nil + end + + # Indicates whether or not an extension is being registered. + def registering_extension? + !register_path.nil? + end + + # Builds a Watcher::Element from +type+ and +representation+ and + # tells the Watcher::List for the current application to watch it + # in the file located at +path+. + # + # If an extension is being registered, it also tells the list to + # watch it in the file where the extension has been registered. + # This prevents the duplication of the elements added by the + # extension in its +registered+ method with every reload. + def watch_element(path, type, representation = nil) + list = Watcher::List.for(self) + element = Watcher::Element.new(type, representation) + list.watch(path, element) + list.watch(register_path, element) if registering_extension? + end + end + end + + register Reloader + Delegator.delegate :also_reload, :dont_reload +end diff --git a/sinatra-contrib/lib/sinatra/required_params.rb b/sinatra-contrib/lib/sinatra/required_params.rb new file mode 100644 index 0000000000..dc286f2862 --- /dev/null +++ b/sinatra-contrib/lib/sinatra/required_params.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require 'sinatra/base' + +module Sinatra + # = Sinatra::RequiredParams + # + # Ensure required query parameters + # + # == Usage + # + # Set required query parameter keys in the argument. + # It'll halt with 400 if required keys don't exist. + # + # get '/simple_keys' do + # required_params :p1, :p2 + # end + # + # Complicated pattern is also fine. + # + # get '/complicated_keys' do + # required_params :p1, :p2 => [:p3, :p4] + # end + # + # === Classic Application + # + # In a classic application simply require the helpers, and start using them: + # + # require "sinatra" + # require "sinatra/required_params" + # + # # The rest of your classic application code goes here... + # + # === Modular Application + # + # In a modular application you need to require the helpers, and then tell + # the application to use them: + # + # require "sinatra/base" + # require "sinatra/required_params" + # + # class MyApp < Sinatra::Base + # helpers Sinatra::RequiredParams + # + # # The rest of your modular application code goes here... + # end + # + module RequiredParams + def required_params(*keys) + _required_params(params, *keys) + end + + private + + def _required_params(p, *keys) + keys.each do |key| + if key.is_a?(Hash) + _required_params(p, *key.keys) + key.each do |k, v| + _required_params(p[k.to_s], v) + end + elsif key.is_a?(Array) + _required_params(p, *key) + else + halt 400 unless p.respond_to?(:key?) && p&.key?(key.to_s) + end + end + true + end + end + + helpers RequiredParams +end diff --git a/sinatra-contrib/lib/sinatra/respond_with.rb b/sinatra-contrib/lib/sinatra/respond_with.rb new file mode 100644 index 0000000000..498decfa57 --- /dev/null +++ b/sinatra-contrib/lib/sinatra/respond_with.rb @@ -0,0 +1,273 @@ +# frozen_string_literal: true + +require 'sinatra/json' +require 'sinatra/base' + +module Sinatra + # + # = Sinatra::RespondWith + # + # These extensions let Sinatra automatically choose what template to render or + # action to perform depending on the request's Accept header. + # + # Example: + # + # # Without Sinatra::RespondWith + # get '/' do + # data = { :name => 'example' } + # request.accept.each do |type| + # case type.to_s + # when 'text/html' + # halt haml(:index, :locals => data) + # when 'text/json' + # halt data.to_json + # when 'application/atom+xml' + # halt nokogiri(:'index.atom', :locals => data) + # when 'application/xml', 'text/xml' + # halt nokogiri(:'index.xml', :locals => data) + # when 'text/plain' + # halt 'just an example' + # end + # end + # error 406 + # end + # + # # With Sinatra::RespondWith + # get '/' do + # respond_with :index, :name => 'example' do |f| + # f.txt { 'just an example' } + # end + # end + # + # Both helper methods +respond_to+ and +respond_with+ let you define custom + # handlers like the one above for +text/plain+. +respond_with+ additionally + # takes a template name and/or an object to offer the following default + # behavior: + # + # * If a template name is given, search for a template called + # +name.format.engine+ (+index.xml.nokogiri+ in the above example). + # * If a template name is given, search for a templated called +name.engine+ + # for engines known to result in the requested format (+index.haml+). + # * If a file extension associated with the mime type is known to Sinatra, and + # the object responds to +to_extension+, call that method and use the result + # (+data.to_json+). + # + # == Security + # + # Since methods are triggered based on client input, this can lead to security + # issues (but not as severe as those might appear in the first place: keep in + # mind that only known file extensions are used). You should limit + # the possible formats you serve. + # + # This is possible with the +provides+ condition: + # + # get '/', :provides => [:html, :json, :xml, :atom] do + # respond_with :index, :name => 'example' + # end + # + # However, since you have to set +provides+ for every route, this extension + # adds an app global (class method) `respond_to`, that lets you define content + # types for all routes: + # + # respond_to :html, :json, :xml, :atom + # get('/a') { respond_with :index, :name => 'a' } + # get('/b') { respond_with :index, :name => 'b' } + # + # == Custom Types + # + # Use the +on+ method for defining actions for custom types: + # + # get '/' do + # respond_to do |f| + # f.xml { nokogiri :index } + # f.on('application/custom') { custom_action } + # f.on('text/*') { data.to_s } + # f.on('*/*') { "matches everything" } + # end + # end + # + # Definition order does not matter. + module RespondWith + class Format + def initialize(app) + @app = app + @map = {} + @generic = {} + @default = nil + end + + def on(type, &block) + @app.settings.mime_types(type).each do |mime| + case mime + when '*/*' then @default = block + when %r{^([^/]+)/\*$} then @generic[$1] = block + else @map[mime] = block + end + end + end + + def finish + yield self if block_given? + mime_type = @app.content_type || + @app.request.preferred_type(@map.keys) || + @app.request.preferred_type || + 'text/html' + type = mime_type.split(/\s*;\s*/, 2).first + handlers = [@map[type], @generic[type[%r{^[^/]+}]], @default].compact + handlers.each do |block| + if (result = block.call(type)) + @app.content_type mime_type + @app.halt result + end + end + @app.halt 500, 'Unknown template engine' + end + + def method_missing(method, *args, &block) + return super if args.any? || block.nil? || !@app.mime_type(method) + + on(method, &block) + end + end + + module Helpers + include Sinatra::JSON + + def respond_with(template, object = nil, &block) + unless Symbol === template + object = template + template = nil + end + format = Format.new(self) + format.on '*/*' do |type| + exts = settings.ext_map[type] + exts << :xml if type.end_with? '+xml' + if template + args = template_cache.fetch(type, template) { template_for(template, exts) } + if args.any? + locals = { object: object } + locals.merge! object.to_hash if object.respond_to? :to_hash + + renderer = args.first + options = args[1..] + [{ locals: locals }] + + halt send(renderer, *options) + end + end + if object + exts.each do |ext| + halt json(object) if ext == :json + next unless object.respond_to? method = "to_#{ext}" + + halt(*object.send(method)) + end + end + false + end + format.finish(&block) + end + + def respond_to(&block) + Format.new(self).finish(&block) + end + + private + + def template_for(name, exts) + # in production this is cached, so don't worry too much about runtime + possible = [] + settings.template_engines[:all].each do |engine| + exts.each { |ext| possible << [engine, "#{name}.#{ext}"] } + end + + exts.each do |ext| + settings.template_engines[ext].each { |e| possible << [e, name] } + end + + possible.each do |engine, template| + klass = Tilt.default_mapping.template_map[engine.to_s] || + Tilt.lazy_map[engine.to_s].fetch(0, [])[0] + + find_template(settings.views, template, klass) do |file| + next unless File.exist? file + + return settings.rendering_method(engine) << template.to_sym + end + end + [] # nil or false would not be cached + end + end + + def remap_extensions + ext_map.clear + Rack::Mime::MIME_TYPES.each { |e, t| ext_map[t] << e[1..].to_sym } + ext_map['text/javascript'] << 'js' + ext_map['text/xml'] << 'xml' + end + + def mime_type(*) + result = super + remap_extensions + result + end + + def respond_to(*formats) + @respond_to ||= nil + + if formats.any? + @respond_to ||= [] + @respond_to.concat formats + elsif @respond_to.nil? && superclass.respond_to?(:respond_to) + superclass.respond_to + else + @respond_to + end + end + + def rendering_method(engine) + return [engine] if Sinatra::Templates.method_defined? engine + return [:mab] if engine.to_sym == :markaby + + %i[render engine] + end + + private + + def compile!(verb, path, block, **options) + options[:provides] ||= respond_to if respond_to + super + end + + def self.jrubyify(engs) + not_supported = [:markdown] + engs.each_key do |key| + engs[key].collect! { |eng| eng == :yajl ? :json_pure : eng } + engs[key].delete_if { |eng| not_supported.include?(eng) } + end + engs + end + + def self.engines + engines = { + xml: %i[builder nokogiri], + html: %i[erb erubi haml hamlit slim liquid + mab markdown rdoc], + all: (Sinatra::Templates.instance_methods.map(&:to_sym) + + [:mab] - %i[find_template markaby]), + json: [:yajl] + } + engines.default = [] + defined?(JRUBY_VERSION) ? jrubyify(engines) : engines + end + + def self.registered(base) + base.set :ext_map, Hash.new { |h, k| h[k] = [] } + base.set :template_engines, engines + base.remap_extensions + base.helpers Helpers + end + end + + register RespondWith + Delegator.delegate :respond_to +end diff --git a/sinatra-contrib/lib/sinatra/runner.rb b/sinatra-contrib/lib/sinatra/runner.rb new file mode 100644 index 0000000000..b2124618c9 --- /dev/null +++ b/sinatra-contrib/lib/sinatra/runner.rb @@ -0,0 +1,164 @@ +# frozen_string_literal: true + +require 'open-uri' +require 'net/http' +require 'timeout' + +module Sinatra + # NOTE: This feature is experimental, and missing tests! + # + # Helps you spinning up and shutting down your own sinatra app. This is especially helpful for running + # real network tests against a sinatra backend. + # + # The backend server could look like the following (in test/server.rb). + # + # require "sinatra" + # + # get "/" do + # "Cheers from test server" + # end + # + # get "/ping" do + # "1" + # end + # + # Note that you need to implement a ping action for internal use. + # + # Next, you need to write your runner. + # + # require 'sinatra/runner' + # + # class Runner < Sinatra::Runner + # def app_file + # File.expand_path("server.rb", __dir__) + # end + # end + # + # Override Runner#app_file, #command, #port, #protocol and #ping_path for customization. + # + # **Don't forget to override #app_file specific to your application!** + # + # Wherever you need this test backend, here's how you manage it. The following example assumes you + # have a test in your app that needs to be run against your test backend. + # + # runner = ServerRunner.new + # runner.run + # + # # ..tests against localhost:4567 here.. + # + # runner.kill + # + # For an example, check https://github.com/apotonick/roar/blob/master/test/integration/runner.rb + class Runner + def app_file + File.expand_path('server.rb', __dir__) + end + + def run + @pipe = start + @started = Time.now + warn "#{server} up and running on port #{port}" if ping + end + + def kill + return unless pipe + + Process.kill('KILL', pipe.pid) + rescue NotImplementedError + system "kill -9 #{pipe.pid}" + rescue Errno::ESRCH + end + + def get(url) + Timeout.timeout(1) { get_url("#{protocol}://127.0.0.1:#{port}#{url}") } + end + + def get_stream(url = '/stream', &block) + Net::HTTP.start '127.0.0.1', port do |http| + request = Net::HTTP::Get.new url + http.request request do |response| + response.read_body(&block) + end + end + end + + def get_response(url) + Net::HTTP.start '127.0.0.1', port do |http| + request = Net::HTTP::Get.new url + http.request request do |response| + response + end + end + end + + def log + @log ||= '' + loop { @log << pipe.read_nonblock(1) } + rescue Exception + @log + end + + private + + attr_accessor :pipe + + def start + IO.popen(command) + end + + # to be overwritten + def command + "bundle exec ruby #{app_file} -p #{port} -e production" + end + + def ping(timeout = 30) + loop do + return if alive? + + if Time.now - @started > timeout + warn command, log + raise 'timeout' + else + sleep 0.1 + end + end + end + + def alive? + 3.times { get(ping_path) } + true + rescue EOFError, SystemCallError, OpenURI::HTTPError, Timeout::Error + false + end + + # to be overwritten + def ping_path + '/ping' + end + + # to be overwritten + def port + 4567 + end + + def protocol + 'http' + end + + def get_url(url) + uri = URI.parse(url) + + return uri.read unless protocol == 'https' + + get_https_url(uri) + end + + def get_https_url(uri) + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = true + http.verify_mode = OpenSSL::SSL::VERIFY_NONE + request = Net::HTTP::Get.new(uri.request_uri) + http.request(request).body + end + end +end diff --git a/sinatra-contrib/lib/sinatra/streaming.rb b/sinatra-contrib/lib/sinatra/streaming.rb new file mode 100644 index 0000000000..e54eb27d7a --- /dev/null +++ b/sinatra-contrib/lib/sinatra/streaming.rb @@ -0,0 +1,246 @@ +# frozen_string_literal: true + +require 'sinatra/base' + +module Sinatra + # = Sinatra::Streaming + # + # Sinatra 1.3 introduced the +stream+ helper. This addon improves the + # streaming API by making the stream object imitate an IO object, turning + # it into a real Deferrable and making the body play nicer with middleware + # unaware of streaming. + # + # == IO-like behavior + # + # This is useful when passing the stream object to a library expecting an + # IO or StringIO object. + # + # get '/' do + # stream do |out| + # out.puts "Hello World!", "How are you?" + # out.write "Written #{out.pos} bytes so far!\n" + # out.putc(65) unless out.closed? + # out.flush + # end + # end + # + # == Better Middleware Handling + # + # Blocks passed to #map! or #map will actually be applied when streaming + # takes place (as you might have suspected, #map! applies modifications + # to the current body, while #map creates a new one): + # + # class StupidMiddleware + # def initialize(app) @app = app end + # + # def call(env) + # status, headers, body = @app.call(env) + # body.map! { |e| e.upcase } + # [status, headers, body] + # end + # end + # + # use StupidMiddleware + # + # get '/' do + # stream do |out| + # out.puts "still" + # sleep 1 + # out.puts "streaming" + # end + # end + # + # Even works if #each is used to generate an Enumerator: + # + # def call(env) + # status, headers, body = @app.call(env) + # body = body.each.map { |s| s.upcase } + # [status, headers, body] + # end + # + # Note that both examples violate the Rack specification. + # + # == Setup + # + # In a classic application: + # + # require "sinatra" + # require "sinatra/streaming" + # + # In a modular application: + # + # require "sinatra/base" + # require "sinatra/streaming" + # + # class MyApp < Sinatra::Base + # helpers Sinatra::Streaming + # end + module Streaming + def stream(*) + stream = super + stream.extend Stream + stream.app = self + env['async.close'].callback { stream.close } if env.key? 'async.close' + stream + end + + module Stream + attr_accessor :app, :lineno, :pos, :transformer, :closed + alias tell pos + alias closed? closed + + def self.extended(obj) + obj.closed = false + obj.lineno = 0 + obj.pos = 0 + obj.callback { obj.closed = true } + obj.errback { obj.closed = true } + end + + def <<(data) + raise IOError, 'not opened for writing' if closed? + + @transformer ||= nil + data = data.to_s + data = @transformer[data] if @transformer + @pos += data.bytesize + super(data) + end + + def each + # that way body.each.map { ... } works + return self unless block_given? + + super + end + + def map(&block) + # dup would not copy the mixin + clone.map!(&block) + end + + def map!(&block) + @transformer ||= nil + + if @transformer + inner = @transformer + outer = block + block = proc { |value| outer[inner[value]] } + end + @transformer = block + self + end + + def write(data) + self << data + data.to_s.bytesize + end + + alias syswrite write + alias write_nonblock write + + def print(*args) + args.each { |arg| self << arg } + nil + end + + def printf(format, *args) + print(format.to_s % args) + end + + def putc(c) + print c.chr + end + + def puts(*args) + args.each { |arg| self << "#{arg}\n" } + nil + end + + def close_read + raise IOError, 'closing non-duplex IO for reading' + end + + def closed_read? + true + end + + def closed_write? + closed? + end + + def external_encoding + Encoding.find settings.default_encoding + rescue NameError + settings.default_encoding + end + + def settings + app.settings + end + + def rewind + @pos = @lineno = 0 + end + + def not_open_for_reading(*) + raise IOError, 'not opened for reading' + end + + alias bytes not_open_for_reading + alias eof? not_open_for_reading + alias eof not_open_for_reading + alias getbyte not_open_for_reading + alias getc not_open_for_reading + alias gets not_open_for_reading + alias read not_open_for_reading + alias read_nonblock not_open_for_reading + alias readbyte not_open_for_reading + alias readchar not_open_for_reading + alias readline not_open_for_reading + alias readlines not_open_for_reading + alias readpartial not_open_for_reading + alias sysread not_open_for_reading + alias ungetbyte not_open_for_reading + alias ungetc not_open_for_reading + private :not_open_for_reading + + def enum_not_open_for_reading(*) + not_open_for_reading if block_given? + enum_for(:not_open_for_reading) + end + + alias chars enum_not_open_for_reading + alias each_line enum_not_open_for_reading + alias each_byte enum_not_open_for_reading + alias each_char enum_not_open_for_reading + alias lines enum_not_open_for_reading + undef enum_not_open_for_reading + + def dummy(*) end + alias flush dummy + alias fsync dummy + alias internal_encoding dummy + alias pid dummy + undef dummy + + def seek(*) + 0 + end + + alias sysseek seek + + def sync + true + end + + def tty? + false + end + + alias isatty tty? + end + end + + helpers Streaming +end diff --git a/sinatra-contrib/lib/sinatra/test_helpers.rb b/sinatra-contrib/lib/sinatra/test_helpers.rb new file mode 100644 index 0000000000..27b01efdf0 --- /dev/null +++ b/sinatra-contrib/lib/sinatra/test_helpers.rb @@ -0,0 +1,202 @@ +# frozen_string_literal: true + +require 'sinatra/base' +require 'rack' +begin + require 'rack/test' +rescue LoadError + abort 'Add rack-test to your Gemfile to use this module!' +end +require 'forwardable' + +module Sinatra + Base.set :environment, :test + + # Helper methods to ease testing your Sinatra application. Partly extracted + # from Sinatra. Testing framework agnostic. + module TestHelpers + include Rack::Test::Methods + extend Forwardable + attr_accessor :settings + + # @!group Instance Methods delegated to last_response + + # @!method body + # + # Body of last_response + # + # @see https://www.rubydoc.info/github/rack/rack/main/Rack/Response#body-instance_method + # @return [String] body of the last response + + # @!method headers + # + # Headers of last_response + # + # @return [Hash] hash of the last response + + # @!method status + # + # HTTP status of last_response + # + # @return [Integer] HTTP status of the last response + + # @!method errors + # + # Errors of last_response + # + # @return [Array] errors of the last response + def_delegators :last_response, :body, :headers, :status, :errors + # @!endgroup + + # @!group Class Methods delegated to app + + # @!method configure(*envs) {|_self| ... } + # @!scope class + # @yieldparam _self [Sinatra::Base] the object that the method was called on + # + # Set configuration options for Sinatra and/or the app. Allows scoping of + # settings for certain environments. + + # @!method set(option, value = (not_set = true), ignore_setter = false, &block) + # @!scope class + # Sets an option to the given value. If the value is a proc, the proc will + # be called every time the option is accessed. + # @raise [ArgumentError] + + # @!method enable(*opts) + # @!scope class + # + # Same as calling `set :option, true` for each of the given options. + + # @!method disable(*opts) + # @!scope class + # + # Same as calling `set :option, false` for each of the given options. + + # @!method use(middleware, *args, &block) + # @!scope class + # Use the specified Rack middleware + + # @!method helpers(*extensions, &block) + # @!scope class + # + # Makes the methods defined in the block and in the Modules given in + # `extensions` available to the handlers and templates. + + # @!method register(*extensions, &block) + # @!scope class + # Register an extension. Alternatively take a block from which an + # extension will be created and registered on the fly. + + def_delegators :app, :configure, :set, :enable, :disable, :use, :helpers, :register + # @!endgroup + + # @!group Instance Methods delegated to current_session + + # @!method env_for(uri = "", opts = {}) + # + # Return the Rack environment used for a request to `uri`. + # + # @return [Hash] + def_delegators :current_session, :env_for + # @!endgroup + + # @!group Instance Methods delegated to rack_mock_session + # @!method cookie_jar + # + # Returns a {https://www.rubydoc.info/github/rack/rack-test/Rack/Test/CookieJar Rack::Test::CookieJar}. + # + # @return [Rack::Test::CookieJar] + def_delegators :rack_mock_session, :cookie_jar + + # @!endgroup + + # Instantiate and configure a mock Sinatra app. + # + # Takes a `base` app class, or defaults to Sinatra::Base, and instantiates + # an app instance. Any given code in `block` is `class_eval`'d on this new + # instance before the instance is returned. + # + # @param base [Sinatra::Base] App base class + # + # @return [Sinatra] Configured mocked app + def mock_app(base = Sinatra::Base, &block) + inner = nil + @app = Sinatra.new(base) do + inner = self + class_eval(&block) + end + @settings = inner + app + end + + # Replaces the configured app. + # + # @param base [Sinatra::Base] a configured app + def app=(base) + @app = base + end + + alias set_app app= + + # Returns a Rack::Lint-wrapped Sinatra app. + # + # If no app has been configured, a new subclass of Sinatra::Base will be + # used and stored. + # + # (Rack::Lint validates your application and the requests and + # responses according to the Rack spec.) + # + # @return [Sinatra::Base] + def app + @app ||= Class.new Sinatra::Base + Rack::Lint.new @app + end + + unless method_defined? :options + # Processes an OPTIONS request in the context of the current session. + # + # @param uri [String] + # @param params [Hash] + # @param env [Hash] + def options(uri, params = {}, env = {}, &block) + env = env_for(uri, env.merge(method: 'OPTIONS', params: params)) + current_session.send(:process_request, uri, env, &block) + end + end + + unless method_defined? :patch + # Processes a PATCH request in the context of the current session. + # + # @param uri [String] + # @param params [Hash] + # @param env [Hash] + def patch(uri, params = {}, env = {}, &block) + env = env_for(uri, env.merge(method: 'PATCH', params: params)) + current_session.send(:process_request, uri, env, &block) + end + end + + # @return [Boolean] + def last_request? + last_request + true + rescue Rack::Test::Error + false + end + + # @raise [Rack::Test:Error] If sessions are not enabled for app + # @return [Hash] Session of last request, or the empty Hash + def session + return {} unless last_request? + raise Rack::Test::Error, 'session not enabled for app' unless last_env['rack.session'] || app.session? + + last_request.session + end + + # @return The env of the last request + def last_env + last_request.env + end + end +end diff --git a/sinatra-contrib/lib/sinatra/webdav.rb b/sinatra-contrib/lib/sinatra/webdav.rb new file mode 100644 index 0000000000..70ef7f632c --- /dev/null +++ b/sinatra-contrib/lib/sinatra/webdav.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require 'sinatra/base' + +module Sinatra + # = Sinatra::WebDAV + # + # This extensions provides WebDAV verbs, as defined by RFC 4918 + # (https://tools.ietf.org/html/rfc4918). To use this in your app, + # just +register+ it: + # + # require 'sinatra/base' + # require 'sinatra/webdav' + # + # class Application < Sinatra::Base + # register Sinatra::WebDAV + # + # # Now you can use any WebDAV verb: + # propfind '/2014/january/21' do + # 'I have a lunch at 9 PM' + # end + # end + # + # You can use it in classic application just by requring the extension: + # + # require 'sinatra' + # require 'sinatra/webdav' + # + # mkcol '/2015' do + # 'You started 2015!' + # end + # + module WebDAV + def self.registered(_) + Sinatra::Request.include WebDAV::Request + end + + module Request + def self.included(base) + base.class_eval do + alias_method :_safe?, :safe? + alias_method :_idempotent?, :idempotent? + + def safe? + _safe? or propfind? + end + + def idempotent? + _idempotent? or propfind? or move? or unlock? # or lock? + end + end + end + + def propfind? + request_method == 'PROPFIND' + end + + def proppatch? + request_method == 'PROPPATCH' + end + + def mkcol? + request_method == 'MKCOL' + end + + def copy? + request_method == 'COPY' + end + + def move? + request_method == 'MOVE' + end + + # def lock? + # request_method == 'LOCK' + # end + + def unlock? + request_method == 'UNLOCK' + end + end + + def propfind(path, opts = {}, &bk) route 'PROPFIND', path, opts, &bk end + def proppatch(path, opts = {}, &bk) route 'PROPPATCH', path, opts, &bk end + def mkcol(path, opts = {}, &bk) route 'MKCOL', path, opts, &bk end + def copy(path, opts = {}, &bk) route 'COPY', path, opts, &bk end + def move(path, opts = {}, &bk) route 'MOVE', path, opts, &bk end + # def lock(path, opts = {}, &bk) route 'LOCK', path, opts, &bk end + def unlock(path, opts = {}, &bk) route 'UNLOCK', path, opts, &bk end + end + + register WebDAV + Delegator.delegate :propfind, :proppatch, :mkcol, :copy, :move, :unlock # :lock +end diff --git a/sinatra-contrib/sinatra-contrib.gemspec b/sinatra-contrib/sinatra-contrib.gemspec new file mode 100644 index 0000000000..35eae935fb --- /dev/null +++ b/sinatra-contrib/sinatra-contrib.gemspec @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +version = File.read(File.expand_path('../VERSION', __dir__)).strip + +Gem::Specification.new do |s| + s.name = 'sinatra-contrib' + s.version = version + s.description = 'Collection of useful Sinatra extensions' + s.homepage = 'http://sinatrarb.com/contrib/' + s.license = 'MIT' + s.summary = s.description + s.authors = ['https://github.com/sinatra/sinatra/graphs/contributors'] + s.email = 'sinatrarb@googlegroups.com' + s.files = Dir['lib/**/*.rb'] + [ + 'LICENSE', + 'README.md', + 'Rakefile', + 'ideas.md', + 'sinatra-contrib.gemspec' + ] + + unless s.respond_to?(:metadata) + raise <<-WARN +RubyGems 2.0 or newer is required to protect against public gem pushes. You can update your rubygems version by running: + gem install rubygems-update + update_rubygems: + gem update --system + WARN + end + + s.metadata = { + 'source_code_uri' => 'https://github.com/sinatra/sinatra/tree/main/sinatra-contrib', + 'homepage_uri' => 'http://sinatrarb.com/contrib/', + 'documentation_uri' => 'https://www.rubydoc.info/gems/sinatra-contrib', + 'rubygems_mfa_required' => 'true' + } + + s.required_ruby_version = '>= 2.6.0' + + s.add_dependency 'multi_json' + s.add_dependency 'mustermann', '~> 3.0' + s.add_dependency 'rack-protection', version + s.add_dependency 'sinatra', version + s.add_dependency 'tilt', '~> 2.0' + + s.add_development_dependency 'asciidoctor' + s.add_development_dependency 'builder' + s.add_development_dependency 'erubi' + s.add_development_dependency 'haml' + s.add_development_dependency 'liquid' + s.add_development_dependency 'markaby' + s.add_development_dependency 'nokogiri' + s.add_development_dependency 'rack-test', '~> 2' + s.add_development_dependency 'rake', '>= 12.3.3' + s.add_development_dependency 'redcarpet' + s.add_development_dependency 'rspec', '~> 3' + s.add_development_dependency 'slim' +end diff --git a/sinatra-contrib/spec/capture_spec.rb b/sinatra-contrib/spec/capture_spec.rb new file mode 100644 index 0000000000..faf207d831 --- /dev/null +++ b/sinatra-contrib/spec/capture_spec.rb @@ -0,0 +1,113 @@ +# -*- coding: utf-8 -*- +require 'slim' +require 'spec_helper' + +RSpec.describe Sinatra::Capture do + subject do + Sinatra.new do + enable :inline_templates + helpers Sinatra::Capture + end.new! + end + Tilt.prefer Tilt::ERBTemplate + + extend Forwardable + def_delegators :subject, :capture, :capture_later + + def render(engine, template) + subject.send(:render, engine, template.to_sym).strip.gsub(/\s+/, ' ') + end + + shared_examples_for "a template language" do |engine| + lang = engine + if engine == :erubi + lang = :erb + end + if engine == :hamlit + lang = :haml + end + require "#{engine}" + + it "captures content" do + expect(render(engine, "simple_#{lang}")).to eq("Say Hello World!") + end + + it "allows nested captures" do + expect(render(engine, "nested_#{lang}")).to eq("Say Hello World!") + end + end + + describe('haml') { it_behaves_like "a template language", :haml } + describe('hamlit') { it_behaves_like "a template language", :hamlit } + describe('slim') { it_behaves_like "a template language", :slim } + describe('erubi') { it_behaves_like "a template language", :erubi } + + describe 'erb' do + it_behaves_like "a template language", :erb + + it "handles utf-8 encoding" do + expect(render(:erb, "utf_8")).to eq("UTF-8 –") + end + + it "handles ISO-8859-1 encoding" do + expect(render(:erb, "iso_8859_1")).to eq("ISO-8859-1 -") + end + end + + describe 'without templates' do + it 'captures empty blocks' do + expect(capture {}).to be_nil + end + end +end + +__END__ + +@@ simple_erb +Say +<% a = capture do %>World<% end %> +Hello <%= a %>! + +@@ nested_erb +Say +<% a = capture do %> + <% b = capture do %>World<% end %> + <%= b %>! +<% end %> +Hello <%= a.strip %> + +@@ simple_slim +| Say +- a = capture do + | World +| Hello #{a.strip}! + +@@ nested_slim +| Say +- a = capture do + - b = capture do + | World + | #{b.strip}! +| Hello #{a.strip} + +@@ simple_haml +Say +- a = capture do + World +Hello #{a.strip}! + +@@ nested_haml +Say +- a = capture do + - b = capture do + World + #{b.strip}! +Hello #{a.strip} + +@@ utf_8 +<% a = capture do %>–<% end %> +UTF-8 <%= a %> + +@@ iso_8859_1 +<% a = capture do %>-<% end %> +ISO-8859-1 <%= a.force_encoding("iso-8859-1") %> diff --git a/sinatra-contrib/spec/config_file/config.txt b/sinatra-contrib/spec/config_file/config.txt new file mode 100644 index 0000000000..e69de29bb2 diff --git a/sinatra-contrib/spec/config_file/key_value.yaml b/sinatra-contrib/spec/config_file/key_value.yaml new file mode 100644 index 0000000000..49c7fbaf9b --- /dev/null +++ b/sinatra-contrib/spec/config_file/key_value.yaml @@ -0,0 +1,7 @@ +--- +foo: bar +bar: <%= "bar" %> +something: 42 +nested: + a: 1 + b: 2 diff --git a/sinatra-contrib/spec/config_file/key_value.yml b/sinatra-contrib/spec/config_file/key_value.yml new file mode 100644 index 0000000000..49c7fbaf9b --- /dev/null +++ b/sinatra-contrib/spec/config_file/key_value.yml @@ -0,0 +1,7 @@ +--- +foo: bar +bar: <%= "bar" %> +something: 42 +nested: + a: 1 + b: 2 diff --git a/sinatra-contrib/spec/config_file/key_value.yml.erb b/sinatra-contrib/spec/config_file/key_value.yml.erb new file mode 100644 index 0000000000..381e39404c --- /dev/null +++ b/sinatra-contrib/spec/config_file/key_value.yml.erb @@ -0,0 +1,6 @@ +--- +foo: <%= "bar" %> +something: <%= 42 %> +nested: + a: <%= 1 %> + b: <%= 2 %> diff --git a/sinatra-contrib/spec/config_file/key_value_override.yml b/sinatra-contrib/spec/config_file/key_value_override.yml new file mode 100644 index 0000000000..03dd92d5a9 --- /dev/null +++ b/sinatra-contrib/spec/config_file/key_value_override.yml @@ -0,0 +1,2 @@ +--- +foo: foo diff --git a/sinatra-contrib/spec/config_file/missing_env.yml b/sinatra-contrib/spec/config_file/missing_env.yml new file mode 100644 index 0000000000..8814566827 --- /dev/null +++ b/sinatra-contrib/spec/config_file/missing_env.yml @@ -0,0 +1,4 @@ +--- +foo: + production: 10 + development: 20 \ No newline at end of file diff --git a/sinatra-contrib/spec/config_file/with_env_defaults.yml b/sinatra-contrib/spec/config_file/with_env_defaults.yml new file mode 100644 index 0000000000..a7be1a3a27 --- /dev/null +++ b/sinatra-contrib/spec/config_file/with_env_defaults.yml @@ -0,0 +1,16 @@ +--- +default: &default + foo: default + bar: baz + +development: + <<: *default + foo: development + +production: + <<: *default + foo: production + +test: + <<: *default + foo: test diff --git a/sinatra-contrib/spec/config_file/with_envs.yml b/sinatra-contrib/spec/config_file/with_envs.yml new file mode 100644 index 0000000000..61934d63d6 --- /dev/null +++ b/sinatra-contrib/spec/config_file/with_envs.yml @@ -0,0 +1,7 @@ +--- +development: + foo: development +production: + foo: production +test: + foo: test diff --git a/sinatra-contrib/spec/config_file/with_nested_envs.yml b/sinatra-contrib/spec/config_file/with_nested_envs.yml new file mode 100644 index 0000000000..22e759d8eb --- /dev/null +++ b/sinatra-contrib/spec/config_file/with_nested_envs.yml @@ -0,0 +1,11 @@ +--- +database: + production: + adapter: postgresql + database: foo_production + development: + adapter: sqlite + database: db/development.db + test: + adapter: sqlite + database: db/test.db \ No newline at end of file diff --git a/sinatra-contrib/spec/config_file_spec.rb b/sinatra-contrib/spec/config_file_spec.rb new file mode 100644 index 0000000000..65b38efafe --- /dev/null +++ b/sinatra-contrib/spec/config_file_spec.rb @@ -0,0 +1,106 @@ +require 'spec_helper' + +RSpec.describe Sinatra::ConfigFile do + def config_file(*args, &block) + mock_app do + register Sinatra::ConfigFile + set :root, File.expand_path('config_file', __dir__) + instance_eval(&block) if block + config_file(*args) + end + end + + it 'should set options from a simple config_file' do + config_file 'key_value.yml' + expect(settings.foo).to eq('bar') + expect(settings.something).to eq(42) + end + + it 'should create indifferent hashes' do + config_file 'key_value.yml' + expect(settings.nested['a']).to eq(1) + expect(settings.nested[:a]).to eq(1) + end + + it 'should render options in ERB tags when using .yml files' do + config_file 'key_value.yml' + expect(settings.bar).to eq "bar" + expect(settings.something).to eq 42 + expect(settings.nested['a']).to eq 1 + expect(settings.nested[:a]).to eq 1 + expect(settings.nested['b']).to eq 2 + expect(settings.nested[:b]).to eq 2 + end + + it 'should render options in ERB tags when using .yml.erb files' do + config_file 'key_value.yml.erb' + expect(settings.foo).to eq("bar") + expect(settings.something).to eq(42) + expect(settings.nested['a']).to eq(1) + expect(settings.nested[:a]).to eq(1) + expect(settings.nested['b']).to eq(2) + expect(settings.nested[:b]).to eq(2) + end + + it 'should render options in ERB tags when using .yaml files' do + config_file 'key_value.yaml' + expect(settings.foo).to eq("bar") + expect(settings.something).to eq(42) + expect(settings.nested['a']).to eq(1) + expect(settings.nested[:a]).to eq(1) + expect(settings.nested['b']).to eq(2) + expect(settings.nested[:b]).to eq(2) + end + + it 'should raise error if config file extension is not .yml, .yaml or .erb' do + expect{ config_file 'config.txt' }.to raise_error(Sinatra::ConfigFile::UnsupportedConfigType) + end + + it 'should recognize env specific settings per file' do + config_file 'with_envs.yml' + expect(settings.foo).to eq('test') + end + + it 'should recognize env specific settings per setting' do + config_file 'with_nested_envs.yml' + expect(settings.database[:adapter]).to eq('sqlite') + end + + it 'should not set present values to nil if the current env is missing' do + # first let's check the test is actually working properly + config_file('missing_env.yml') { set :foo => 42, :environment => :production } + expect(settings.foo).to eq(10) + # now test it + config_file('missing_env.yml') { set :foo => 42, :environment => :test } + expect(settings.foo).to eq(42) + end + + it 'should prioritize settings in latter files' do + # first let's check the test is actually working properly + config_file 'key_value.yml' + expect(settings.foo).to eq('bar') + # now test it + config_file 'key_value_override.yml' + expect(settings.foo).to eq('foo') + end + + context 'when file contains superfluous environments' do + before { config_file 'with_env_defaults.yml' } + + it 'loads settings for the current environment anyway' do + expect { settings.foo }.not_to raise_error + end + end + + context 'when file contains defaults' do + before { config_file 'with_env_defaults.yml' } + + it 'uses the overridden value' do + expect(settings.foo).to eq('test') + end + + it 'uses the default value' do + expect(settings.bar).to eq('baz') + end + end +end diff --git a/sinatra-contrib/spec/content_for/different_key.erb b/sinatra-contrib/spec/content_for/different_key.erb new file mode 100644 index 0000000000..af8144125c --- /dev/null +++ b/sinatra-contrib/spec/content_for/different_key.erb @@ -0,0 +1 @@ +<% content_for :bar do %>bar<% end %> diff --git a/sinatra-contrib/spec/content_for/different_key.erubis b/sinatra-contrib/spec/content_for/different_key.erubis new file mode 100644 index 0000000000..af8144125c --- /dev/null +++ b/sinatra-contrib/spec/content_for/different_key.erubis @@ -0,0 +1 @@ +<% content_for :bar do %>bar<% end %> diff --git a/sinatra-contrib/spec/content_for/different_key.haml b/sinatra-contrib/spec/content_for/different_key.haml new file mode 100644 index 0000000000..d909fc2f77 --- /dev/null +++ b/sinatra-contrib/spec/content_for/different_key.haml @@ -0,0 +1,2 @@ +- content_for :bar do + bar diff --git a/sinatra-contrib/spec/content_for/different_key.hamlit b/sinatra-contrib/spec/content_for/different_key.hamlit new file mode 100644 index 0000000000..d909fc2f77 --- /dev/null +++ b/sinatra-contrib/spec/content_for/different_key.hamlit @@ -0,0 +1,2 @@ +- content_for :bar do + bar diff --git a/sinatra-contrib/spec/content_for/different_key.slim b/sinatra-contrib/spec/content_for/different_key.slim new file mode 100644 index 0000000000..c76a30c678 --- /dev/null +++ b/sinatra-contrib/spec/content_for/different_key.slim @@ -0,0 +1,2 @@ +- content_for :bar do + | bar diff --git a/sinatra-contrib/spec/content_for/footer.erb b/sinatra-contrib/spec/content_for/footer.erb new file mode 100644 index 0000000000..d5a77c9021 --- /dev/null +++ b/sinatra-contrib/spec/content_for/footer.erb @@ -0,0 +1,3 @@ +<% if content_for? :foo %> + <%= yield_content :foo %> +<% end %> \ No newline at end of file diff --git a/sinatra-contrib/spec/content_for/footer.erubis b/sinatra-contrib/spec/content_for/footer.erubis new file mode 100644 index 0000000000..d5a77c9021 --- /dev/null +++ b/sinatra-contrib/spec/content_for/footer.erubis @@ -0,0 +1,3 @@ +<% if content_for? :foo %> + <%= yield_content :foo %> +<% end %> \ No newline at end of file diff --git a/sinatra-contrib/spec/content_for/footer.haml b/sinatra-contrib/spec/content_for/footer.haml new file mode 100644 index 0000000000..239fac236e --- /dev/null +++ b/sinatra-contrib/spec/content_for/footer.haml @@ -0,0 +1,2 @@ +- if content_for? :foo + = yield_content :foo \ No newline at end of file diff --git a/sinatra-contrib/spec/content_for/footer.hamlit b/sinatra-contrib/spec/content_for/footer.hamlit new file mode 100644 index 0000000000..f76703fec9 --- /dev/null +++ b/sinatra-contrib/spec/content_for/footer.hamlit @@ -0,0 +1,2 @@ +- if content_for? :foo + != yield_content :foo diff --git a/sinatra-contrib/spec/content_for/footer.slim b/sinatra-contrib/spec/content_for/footer.slim new file mode 100644 index 0000000000..239fac236e --- /dev/null +++ b/sinatra-contrib/spec/content_for/footer.slim @@ -0,0 +1,2 @@ +- if content_for? :foo + = yield_content :foo \ No newline at end of file diff --git a/sinatra-contrib/spec/content_for/layout.erb b/sinatra-contrib/spec/content_for/layout.erb new file mode 100644 index 0000000000..a385d0cfdf --- /dev/null +++ b/sinatra-contrib/spec/content_for/layout.erb @@ -0,0 +1 @@ +<%= yield_content :foo %> diff --git a/sinatra-contrib/spec/content_for/layout.erubis b/sinatra-contrib/spec/content_for/layout.erubis new file mode 100644 index 0000000000..a385d0cfdf --- /dev/null +++ b/sinatra-contrib/spec/content_for/layout.erubis @@ -0,0 +1 @@ +<%= yield_content :foo %> diff --git a/sinatra-contrib/spec/content_for/layout.haml b/sinatra-contrib/spec/content_for/layout.haml new file mode 100644 index 0000000000..aabdfaaec5 --- /dev/null +++ b/sinatra-contrib/spec/content_for/layout.haml @@ -0,0 +1 @@ += yield_content :foo diff --git a/sinatra-contrib/spec/content_for/layout.hamlit b/sinatra-contrib/spec/content_for/layout.hamlit new file mode 100644 index 0000000000..aabdfaaec5 --- /dev/null +++ b/sinatra-contrib/spec/content_for/layout.hamlit @@ -0,0 +1 @@ += yield_content :foo diff --git a/sinatra-contrib/spec/content_for/layout.slim b/sinatra-contrib/spec/content_for/layout.slim new file mode 100644 index 0000000000..aabdfaaec5 --- /dev/null +++ b/sinatra-contrib/spec/content_for/layout.slim @@ -0,0 +1 @@ += yield_content :foo diff --git a/sinatra-contrib/spec/content_for/multiple_blocks.erb b/sinatra-contrib/spec/content_for/multiple_blocks.erb new file mode 100644 index 0000000000..032031f928 --- /dev/null +++ b/sinatra-contrib/spec/content_for/multiple_blocks.erb @@ -0,0 +1,4 @@ +<% content_for :foo do %>foo<% end %> +<% content_for :foo do %>bar<% end %> +<% content_for :baz do %>WON'T RENDER ME<% end %> +<% content_for :foo do %>baz<% end %> diff --git a/sinatra-contrib/spec/content_for/multiple_blocks.erubis b/sinatra-contrib/spec/content_for/multiple_blocks.erubis new file mode 100644 index 0000000000..032031f928 --- /dev/null +++ b/sinatra-contrib/spec/content_for/multiple_blocks.erubis @@ -0,0 +1,4 @@ +<% content_for :foo do %>foo<% end %> +<% content_for :foo do %>bar<% end %> +<% content_for :baz do %>WON'T RENDER ME<% end %> +<% content_for :foo do %>baz<% end %> diff --git a/sinatra-contrib/spec/content_for/multiple_blocks.haml b/sinatra-contrib/spec/content_for/multiple_blocks.haml new file mode 100644 index 0000000000..1cea892e17 --- /dev/null +++ b/sinatra-contrib/spec/content_for/multiple_blocks.haml @@ -0,0 +1,8 @@ +- content_for :foo do + foo +- content_for :foo do + bar +- content_for :baz do + WON'T RENDER ME +- content_for :foo do + baz diff --git a/sinatra-contrib/spec/content_for/multiple_blocks.hamlit b/sinatra-contrib/spec/content_for/multiple_blocks.hamlit new file mode 100644 index 0000000000..1cea892e17 --- /dev/null +++ b/sinatra-contrib/spec/content_for/multiple_blocks.hamlit @@ -0,0 +1,8 @@ +- content_for :foo do + foo +- content_for :foo do + bar +- content_for :baz do + WON'T RENDER ME +- content_for :foo do + baz diff --git a/sinatra-contrib/spec/content_for/multiple_blocks.slim b/sinatra-contrib/spec/content_for/multiple_blocks.slim new file mode 100644 index 0000000000..6a9eb46807 --- /dev/null +++ b/sinatra-contrib/spec/content_for/multiple_blocks.slim @@ -0,0 +1,8 @@ +- content_for :foo do + | foo +- content_for :foo do + | bar +- content_for :baz do + | WON'T RENDER ME +- content_for :foo do + | baz diff --git a/sinatra-contrib/spec/content_for/multiple_yields.erb b/sinatra-contrib/spec/content_for/multiple_yields.erb new file mode 100644 index 0000000000..37a8ee8f04 --- /dev/null +++ b/sinatra-contrib/spec/content_for/multiple_yields.erb @@ -0,0 +1,3 @@ +<%= yield_content :foo %> +<%= yield_content :foo %> +<%= yield_content :foo %> diff --git a/sinatra-contrib/spec/content_for/multiple_yields.erubis b/sinatra-contrib/spec/content_for/multiple_yields.erubis new file mode 100644 index 0000000000..37a8ee8f04 --- /dev/null +++ b/sinatra-contrib/spec/content_for/multiple_yields.erubis @@ -0,0 +1,3 @@ +<%= yield_content :foo %> +<%= yield_content :foo %> +<%= yield_content :foo %> diff --git a/sinatra-contrib/spec/content_for/multiple_yields.haml b/sinatra-contrib/spec/content_for/multiple_yields.haml new file mode 100644 index 0000000000..4c7b81875c --- /dev/null +++ b/sinatra-contrib/spec/content_for/multiple_yields.haml @@ -0,0 +1,3 @@ += yield_content :foo += yield_content :foo += yield_content :foo diff --git a/sinatra-contrib/spec/content_for/multiple_yields.hamlit b/sinatra-contrib/spec/content_for/multiple_yields.hamlit new file mode 100644 index 0000000000..4c7b81875c --- /dev/null +++ b/sinatra-contrib/spec/content_for/multiple_yields.hamlit @@ -0,0 +1,3 @@ += yield_content :foo += yield_content :foo += yield_content :foo diff --git a/sinatra-contrib/spec/content_for/multiple_yields.slim b/sinatra-contrib/spec/content_for/multiple_yields.slim new file mode 100644 index 0000000000..4c7b81875c --- /dev/null +++ b/sinatra-contrib/spec/content_for/multiple_yields.slim @@ -0,0 +1,3 @@ += yield_content :foo += yield_content :foo += yield_content :foo diff --git a/sinatra-contrib/spec/content_for/parameter_value.erb b/sinatra-contrib/spec/content_for/parameter_value.erb new file mode 100644 index 0000000000..b22350c1f1 --- /dev/null +++ b/sinatra-contrib/spec/content_for/parameter_value.erb @@ -0,0 +1 @@ +<% content_for :foo, 'foo' %> diff --git a/sinatra-contrib/spec/content_for/parameter_value.erubis b/sinatra-contrib/spec/content_for/parameter_value.erubis new file mode 100644 index 0000000000..b22350c1f1 --- /dev/null +++ b/sinatra-contrib/spec/content_for/parameter_value.erubis @@ -0,0 +1 @@ +<% content_for :foo, 'foo' %> diff --git a/sinatra-contrib/spec/content_for/parameter_value.haml b/sinatra-contrib/spec/content_for/parameter_value.haml new file mode 100644 index 0000000000..6f492b2819 --- /dev/null +++ b/sinatra-contrib/spec/content_for/parameter_value.haml @@ -0,0 +1 @@ +- content_for :foo, 'foo' diff --git a/sinatra-contrib/spec/content_for/parameter_value.hamlit b/sinatra-contrib/spec/content_for/parameter_value.hamlit new file mode 100644 index 0000000000..6f492b2819 --- /dev/null +++ b/sinatra-contrib/spec/content_for/parameter_value.hamlit @@ -0,0 +1 @@ +- content_for :foo, 'foo' diff --git a/sinatra-contrib/spec/content_for/parameter_value.slim b/sinatra-contrib/spec/content_for/parameter_value.slim new file mode 100644 index 0000000000..6f492b2819 --- /dev/null +++ b/sinatra-contrib/spec/content_for/parameter_value.slim @@ -0,0 +1 @@ +- content_for :foo, 'foo' diff --git a/sinatra-contrib/spec/content_for/passes_values.erb b/sinatra-contrib/spec/content_for/passes_values.erb new file mode 100644 index 0000000000..6034243ec9 --- /dev/null +++ b/sinatra-contrib/spec/content_for/passes_values.erb @@ -0,0 +1 @@ +<%= yield_content :foo, 1, 2 %> \ No newline at end of file diff --git a/sinatra-contrib/spec/content_for/passes_values.erubis b/sinatra-contrib/spec/content_for/passes_values.erubis new file mode 100644 index 0000000000..6034243ec9 --- /dev/null +++ b/sinatra-contrib/spec/content_for/passes_values.erubis @@ -0,0 +1 @@ +<%= yield_content :foo, 1, 2 %> \ No newline at end of file diff --git a/sinatra-contrib/spec/content_for/passes_values.haml b/sinatra-contrib/spec/content_for/passes_values.haml new file mode 100644 index 0000000000..26723d9e3c --- /dev/null +++ b/sinatra-contrib/spec/content_for/passes_values.haml @@ -0,0 +1 @@ +!= yield_content :foo, 1, 2 diff --git a/sinatra-contrib/spec/content_for/passes_values.hamlit b/sinatra-contrib/spec/content_for/passes_values.hamlit new file mode 100644 index 0000000000..26723d9e3c --- /dev/null +++ b/sinatra-contrib/spec/content_for/passes_values.hamlit @@ -0,0 +1 @@ +!= yield_content :foo, 1, 2 diff --git a/sinatra-contrib/spec/content_for/passes_values.slim b/sinatra-contrib/spec/content_for/passes_values.slim new file mode 100644 index 0000000000..24a1a55e0e --- /dev/null +++ b/sinatra-contrib/spec/content_for/passes_values.slim @@ -0,0 +1 @@ +== yield_content :foo, 1, 2 diff --git a/sinatra-contrib/spec/content_for/same_key.erb b/sinatra-contrib/spec/content_for/same_key.erb new file mode 100644 index 0000000000..c6e07b7592 --- /dev/null +++ b/sinatra-contrib/spec/content_for/same_key.erb @@ -0,0 +1 @@ +<% content_for :foo do %>foo<% end %> diff --git a/sinatra-contrib/spec/content_for/same_key.erubis b/sinatra-contrib/spec/content_for/same_key.erubis new file mode 100644 index 0000000000..c6e07b7592 --- /dev/null +++ b/sinatra-contrib/spec/content_for/same_key.erubis @@ -0,0 +1 @@ +<% content_for :foo do %>foo<% end %> diff --git a/sinatra-contrib/spec/content_for/same_key.haml b/sinatra-contrib/spec/content_for/same_key.haml new file mode 100644 index 0000000000..6af88d8440 --- /dev/null +++ b/sinatra-contrib/spec/content_for/same_key.haml @@ -0,0 +1,2 @@ +- content_for :foo do + foo diff --git a/sinatra-contrib/spec/content_for/same_key.hamlit b/sinatra-contrib/spec/content_for/same_key.hamlit new file mode 100644 index 0000000000..6af88d8440 --- /dev/null +++ b/sinatra-contrib/spec/content_for/same_key.hamlit @@ -0,0 +1,2 @@ +- content_for :foo do + foo diff --git a/sinatra-contrib/spec/content_for/same_key.slim b/sinatra-contrib/spec/content_for/same_key.slim new file mode 100644 index 0000000000..b8d0a9bfbb --- /dev/null +++ b/sinatra-contrib/spec/content_for/same_key.slim @@ -0,0 +1,2 @@ +- content_for :foo do + | foo diff --git a/sinatra-contrib/spec/content_for/takes_values.erb b/sinatra-contrib/spec/content_for/takes_values.erb new file mode 100644 index 0000000000..98b2b43859 --- /dev/null +++ b/sinatra-contrib/spec/content_for/takes_values.erb @@ -0,0 +1 @@ +<% content_for :foo do |a, b| %><%= a %> <%= b %><% end %> \ No newline at end of file diff --git a/sinatra-contrib/spec/content_for/takes_values.erubis b/sinatra-contrib/spec/content_for/takes_values.erubis new file mode 100644 index 0000000000..98b2b43859 --- /dev/null +++ b/sinatra-contrib/spec/content_for/takes_values.erubis @@ -0,0 +1 @@ +<% content_for :foo do |a, b| %><%= a %> <%= b %><% end %> \ No newline at end of file diff --git a/sinatra-contrib/spec/content_for/takes_values.haml b/sinatra-contrib/spec/content_for/takes_values.haml new file mode 100644 index 0000000000..440591803b --- /dev/null +++ b/sinatra-contrib/spec/content_for/takes_values.haml @@ -0,0 +1,3 @@ +- content_for :foo do |a, b| + %i= a + =b diff --git a/sinatra-contrib/spec/content_for/takes_values.hamlit b/sinatra-contrib/spec/content_for/takes_values.hamlit new file mode 100644 index 0000000000..440591803b --- /dev/null +++ b/sinatra-contrib/spec/content_for/takes_values.hamlit @@ -0,0 +1,3 @@ +- content_for :foo do |a, b| + %i= a + =b diff --git a/sinatra-contrib/spec/content_for/takes_values.slim b/sinatra-contrib/spec/content_for/takes_values.slim new file mode 100644 index 0000000000..e668498764 --- /dev/null +++ b/sinatra-contrib/spec/content_for/takes_values.slim @@ -0,0 +1,3 @@ +- content_for :foo do |a, b| + i= a + = b diff --git a/sinatra-contrib/spec/content_for/yield_block.erb b/sinatra-contrib/spec/content_for/yield_block.erb new file mode 100644 index 0000000000..d1d98ad385 --- /dev/null +++ b/sinatra-contrib/spec/content_for/yield_block.erb @@ -0,0 +1 @@ +<% yield_content :foo do %>baz<% end %> diff --git a/sinatra-contrib/spec/content_for/yield_block.erubis b/sinatra-contrib/spec/content_for/yield_block.erubis new file mode 100644 index 0000000000..d1d98ad385 --- /dev/null +++ b/sinatra-contrib/spec/content_for/yield_block.erubis @@ -0,0 +1 @@ +<% yield_content :foo do %>baz<% end %> diff --git a/sinatra-contrib/spec/content_for/yield_block.haml b/sinatra-contrib/spec/content_for/yield_block.haml new file mode 100644 index 0000000000..1abbec77cb --- /dev/null +++ b/sinatra-contrib/spec/content_for/yield_block.haml @@ -0,0 +1,2 @@ += yield_content :foo do + baz diff --git a/sinatra-contrib/spec/content_for/yield_block.hamlit b/sinatra-contrib/spec/content_for/yield_block.hamlit new file mode 100644 index 0000000000..14b963e1d7 --- /dev/null +++ b/sinatra-contrib/spec/content_for/yield_block.hamlit @@ -0,0 +1,2 @@ +!= yield_content :foo do + baz diff --git a/sinatra-contrib/spec/content_for/yield_block.slim b/sinatra-contrib/spec/content_for/yield_block.slim new file mode 100644 index 0000000000..805c0b21a5 --- /dev/null +++ b/sinatra-contrib/spec/content_for/yield_block.slim @@ -0,0 +1,2 @@ += yield_content :foo do + | baz diff --git a/sinatra-contrib/spec/content_for_spec.rb b/sinatra-contrib/spec/content_for_spec.rb new file mode 100644 index 0000000000..12c980301f --- /dev/null +++ b/sinatra-contrib/spec/content_for_spec.rb @@ -0,0 +1,280 @@ +require 'spec_helper' + +RSpec.describe Sinatra::ContentFor do + subject do + Sinatra.new do + helpers Sinatra::ContentFor + set :views, File.expand_path("content_for", __dir__) + end.new! + end + + Tilt.prefer Tilt::ERBTemplate + require 'hamlit' + Tilt.register Tilt::HamlTemplate, :haml + + extend Forwardable + def_delegators :subject, :content_for, :clear_content_for, :yield_content + def render(engine, template) + subject.send(:render, engine, template, :layout => false).gsub(/\s/, '') + end + + describe "without templates" do + it 'renders blocks declared with the same key you use when rendering' do + content_for(:foo) { "foo" } + expect(yield_content(:foo)).to eq("foo") + end + + it 'renders blocks more than once' do + content_for(:foo) { "foo" } + 3.times { expect(yield_content(:foo)).to eq("foo") } + end + + it 'does not render a block with a different key' do + content_for(:bar) { "bar" } + expect(yield_content(:foo)).to be_empty + end + + it 'renders default content if no block matches the key and a default block is specified' do + expect(yield_content(:foo) {}).to be_nil + expect(yield_content(:foo) { "foo" }).to eq("foo") + end + + it 'renders multiple blocks with the same key' do + content_for(:foo) { "foo" } + content_for(:foo) { "bar" } + content_for(:bar) { "WON'T RENDER ME" } + content_for(:foo) { "baz" } + expect(yield_content(:foo)).to eq("foobarbaz") + end + + it 'renders multiple blocks more than once' do + content_for(:foo) { "foo" } + content_for(:foo) { "bar" } + content_for(:bar) { "WON'T RENDER ME" } + content_for(:foo) { "baz" } + 3.times { expect(yield_content(:foo)).to eq("foobarbaz") } + end + + it 'passes values to the blocks' do + content_for(:foo) { |a| a.upcase } + expect(yield_content(:foo, 'a')).to eq("A") + expect(yield_content(:foo, 'b')).to eq("B") + end + + it 'clears named blocks with the specified key' do + content_for(:foo) { "foo" } + expect(yield_content(:foo)).to eq("foo") + clear_content_for(:foo) + expect(yield_content(:foo)).to be_empty + end + + it 'takes an immediate value instead of a block' do + content_for(:foo, "foo") + expect(yield_content(:foo)).to eq("foo") + end + + context 'when flush option was disabled' do + it 'append content' do + content_for(:foo, "foo") + content_for(:foo, "bar") + expect(yield_content(:foo)).to eq("foobar") + end + end + + context 'when flush option was enabled' do + it 'flush first content' do + content_for(:foo, "foo") + content_for(:foo, "bar", flush: true) + expect(yield_content(:foo)).to eq("bar") + end + end + end + + # TODO: liquid markaby builder nokogiri + engines = %w[erb erubi haml hamlit slim] + + engines.each do |inner| + describe inner.capitalize do + before :all do + begin + require inner + rescue LoadError => e + skip "Skipping: " << e.message + end + end + + describe "with yield_content in Ruby" do + it 'renders blocks declared with the same key you use when rendering' do + render inner, :same_key + expect(yield_content(:foo).strip).to eq("foo") + end + + it 'renders blocks more than once' do + render inner, :same_key + 3.times { expect(yield_content(:foo).strip).to eq("foo") } + end + + it 'does not render a block with a different key' do + render inner, :different_key + expect(yield_content(:foo)).to be_empty + end + + it 'renders default content if no block matches the key and a default block is specified' do + render inner, :different_key + expect(yield_content(:foo) { "foo" }).to eq("foo") + end + + it 'renders multiple blocks with the same key' do + render inner, :multiple_blocks + expect(yield_content(:foo).gsub(/\s/, '')).to eq("foobarbaz") + end + + it 'renders multiple blocks more than once' do + render inner, :multiple_blocks + 3.times { expect(yield_content(:foo).gsub(/\s/, '')).to eq("foobarbaz") } + end + + it 'passes values to the blocks' do + render inner, :takes_values + expect(yield_content(:foo, 1, 2).gsub(/\s/, '')).to eq("12") + end + end + + describe "with content_for in Ruby" do + it 'renders blocks declared with the same key you use when rendering' do + content_for(:foo) { "foo" } + expect(render(inner, :layout)).to eq("foo") + end + + it 'renders blocks more than once' do + content_for(:foo) { "foo" } + expect(render(inner, :multiple_yields)).to eq("foofoofoo") + end + + it 'does not render a block with a different key' do + content_for(:bar) { "foo" } + expect(render(inner, :layout)).to be_empty + end + + it 'renders multiple blocks with the same key' do + content_for(:foo) { "foo" } + content_for(:foo) { "bar" } + content_for(:bar) { "WON'T RENDER ME" } + content_for(:foo) { "baz" } + expect(render(inner, :layout)).to eq("foobarbaz") + end + + it 'renders multiple blocks more than once' do + content_for(:foo) { "foo" } + content_for(:foo) { "bar" } + content_for(:bar) { "WON'T RENDER ME" } + content_for(:foo) { "baz" } + expect(render(inner, :multiple_yields)).to eq("foobarbazfoobarbazfoobarbaz") + end + + it 'passes values to the blocks' do + content_for(:foo) { |a,b| "#{a}#{b}" } + expect(render(inner, :passes_values)).to eq("12") + end + + it 'clears named blocks with the specified key' do + content_for(:foo) { "foo" } + expect(render(inner, :layout)).to eq("foo") + clear_content_for(:foo) + expect(render(inner, :layout)).to be_empty + end + end + + describe "with content_for? in Ruby" do + it 'renders block if key is set' do + content_for(:foo) { "foot" } + expect(render(inner, :footer)).to eq("foot") + end + + it 'does not render a block if different key' do + content_for(:different_key) { "foot" } + expect(render(inner, :footer)).to be_empty + end + end + + engines.each do |outer| + describe "with yield_content in #{outer.capitalize}" do + def body + last_response.body.gsub(/\s/, '') + end + + before :all do + begin + require outer + rescue LoadError => e + skip "Skipping: " << e.message + end + end + + before do + mock_app do + helpers Sinatra::ContentFor + set inner, :layout_engine => outer + set :views, File.expand_path("content_for", __dir__) + get('/:view') { render(inner, params[:view].to_sym) } + get('/:layout/:view') do + render inner, params[:view].to_sym, :layout => params[:layout].to_sym + end + end + end + + describe 'with a default content block' do + describe 'when content_for key exists' do + it 'ignores default content and renders content' do + expect(get('/yield_block/same_key')).to be_ok + expect(body).to eq("foo") + end + end + + describe 'when content_for key is missing' do + it 'renders default content block' do + expect(get('/yield_block/different_key')).to be_ok + expect(body).to eq("baz") + end + end + end + + it 'renders content set as parameter' do + expect(get('/parameter_value')).to be_ok + expect(body).to eq("foo") + end + + it 'renders blocks declared with the same key you use when rendering' do + expect(get('/same_key')).to be_ok + expect(body).to eq("foo") + end + + it 'renders blocks more than once' do + expect(get('/multiple_yields/same_key')).to be_ok + expect(body).to eq("foofoofoo") + end + + it 'does not render a block with a different key' do + expect(get('/different_key')).to be_ok + expect(body).to be_empty + end + + it 'renders multiple blocks with the same key' do + expect(get('/multiple_blocks')).to be_ok + expect(body).to eq("foobarbaz") + end + + it 'renders multiple blocks more than once' do + expect(get('/multiple_yields/multiple_blocks')).to be_ok + expect(body).to eq("foobarbazfoobarbazfoobarbaz") + end + + it 'passes values to the blocks' do + expect(get('/passes_values/takes_values')).to be_ok + expect(body).to eq("12") + end + end + end + end + end +end diff --git a/sinatra-contrib/spec/cookies_spec.rb b/sinatra-contrib/spec/cookies_spec.rb new file mode 100644 index 0000000000..852f642574 --- /dev/null +++ b/sinatra-contrib/spec/cookies_spec.rb @@ -0,0 +1,825 @@ +require 'spec_helper' + +RSpec.describe Sinatra::Cookies do + def cookie_route(*cookies, headers: {}, &block) + result = nil + set_cookie(cookies) + @cookie_app.get('/') do + result = instance_eval(&block) + "ok" + end + get '/', {}, headers || {} + expect(last_response).to be_ok + expect(body).to eq("ok") + result + end + + def cookies(*set_cookies) + cookie_route(*set_cookies) { cookies } + end + + before do + app = nil + mock_app do + helpers Sinatra::Cookies + app = self + end + @cookie_app = app + clear_cookies + end + + describe :cookie_route do + it 'runs the block' do + ran = false + cookie_route { ran = true } + expect(ran).to be true + end + + it 'returns the block result' do + expect(cookie_route { 42 }).to eq(42) + end + end + + describe :== do + it 'is comparable to hashes' do + expect(cookies).to eq({}) + end + + it 'is comparable to anything that responds to to_hash' do + other = Struct.new(:to_hash).new({}) + expect(cookies).to eq(other) + end + end + + describe :[] do + it 'allows access to request cookies' do + expect(cookies("foo=bar")["foo"]).to eq("bar") + end + + it 'takes symbols as keys' do + expect(cookies("foo=bar")[:foo]).to eq("bar") + end + + it 'returns nil for missing keys' do + expect(cookies("foo=bar")['bar']).to be_nil + end + + it 'allows access to response cookies' do + expect(cookie_route do + response.set_cookie 'foo', 'bar' + cookies['foo'] + end).to eq('bar') + end + + it 'favors response cookies over request cookies' do + expect(cookie_route('foo=bar') do + response.set_cookie 'foo', 'baz' + cookies['foo'] + end).to eq('baz') + end + + + it 'takes the last value for response cookies' do + expect(cookie_route do + response.set_cookie 'foo', 'bar' + response.set_cookie 'foo', 'baz' + cookies['foo'] + end).to eq('baz') + end + end + + describe :[]= do + it 'sets cookies to httponly' do + expect(cookie_route do + cookies['foo'] = 'bar' + response['Set-Cookie'].lines.detect { |l| l.start_with? 'foo=' } + end).to include('HttpOnly') + end + + it 'sets domain to nil if localhost' do + headers = {'HTTP_HOST' => 'localhost'} + expect(cookie_route(headers: headers) do + cookies['foo'] = 'bar' + response['Set-Cookie'] + end).not_to include("domain") + end + + it 'sets the domain' do + expect(cookie_route do + cookies['foo'] = 'bar' + response['Set-Cookie'].lines.detect { |l| l.start_with? 'foo=' } + end).to include('domain=example.org') + end + + it 'sets path to / by default' do + expect(cookie_route do + cookies['foo'] = 'bar' + response['Set-Cookie'].lines.detect { |l| l.start_with? 'foo=' } + end).to include('path=/') + end + + it 'sets path to the script_name if app is nested' do + expect(cookie_route do + request.script_name = '/foo' + cookies['foo'] = 'bar' + response['Set-Cookie'].lines.detect { |l| l.start_with? 'foo=' } + end).to include('path=/foo') + end + + it 'sets a cookie' do + cookie_route { cookies['foo'] = 'bar' } + expect(cookie_jar['foo']).to eq('bar') + end + + it 'adds a value to the cookies hash' do + expect(cookie_route do + cookies['foo'] = 'bar' + cookies['foo'] + end).to eq('bar') + end + end + + describe :assoc do + it 'behaves like Hash#assoc' do + cookies('foo=bar').assoc('foo') == ['foo', 'bar'] + end + end if Hash.method_defined? :assoc + + describe :clear do + it 'removes request cookies from cookies hash' do + jar = cookies('foo=bar') + expect(jar['foo']).to eq('bar') + jar.clear + expect(jar['foo']).to be_nil + end + + it 'removes response cookies from cookies hash' do + expect(cookie_route do + cookies['foo'] = 'bar' + cookies.clear + cookies['foo'] + end).to be_nil + end + + it 'expires existing cookies' do + expect(cookie_route("foo=bar") do + cookies.clear + response['Set-Cookie'] + end).to include("foo=;", "expires=", "1970 00:00:00") + end + end + + describe :compare_by_identity? do + it { expect(cookies).not_to be_compare_by_identity } + end + + describe :default do + it { expect(cookies.default).to be_nil } + end + + describe :default_proc do + it { expect(cookies.default_proc).to be_nil } + end + + describe :delete do + it 'removes request cookies from cookies hash' do + jar = cookies('foo=bar') + expect(jar['foo']).to eq('bar') + jar.delete 'foo' + expect(jar['foo']).to be_nil + end + + it 'removes response cookies from cookies hash' do + expect(cookie_route do + cookies['foo'] = 'bar' + cookies.delete 'foo' + cookies['foo'] + end).to be_nil + end + + it 'expires existing cookies' do + expect(cookie_route("foo=bar") do + cookies.delete 'foo' + response['Set-Cookie'] + end).to include("foo=;", "expires=", "1970 00:00:00") + end + + it 'honours the app cookie_options' do + @cookie_app.class_eval do + set :cookie_options, { + :path => '/foo', + :domain => 'bar.com', + :secure => true, + :httponly => true + } + end + cookie_header = cookie_route("foo=bar") do + cookies.delete 'foo' + response['Set-Cookie'] + end + expect(cookie_header).to include("path=/foo;", "domain=bar.com;", "secure;", "HttpOnly") + end + + it 'does not touch other cookies' do + expect(cookie_route("foo=bar", "bar=baz") do + cookies.delete 'foo' + cookies['bar'] + end).to eq('baz') + end + + it 'returns the previous value for request cookies' do + expect(cookie_route("foo=bar") do + cookies.delete "foo" + end).to eq("bar") + end + + it 'returns the previous value for response cookies' do + expect(cookie_route do + cookies['foo'] = 'bar' + cookies.delete "foo" + end).to eq("bar") + end + + it 'returns nil for non-existing cookies' do + expect(cookie_route { cookies.delete("foo") }).to be_nil + end + end + + describe :delete_if do + it 'deletes cookies that match the block' do + expect(cookie_route('foo=bar') do + cookies['bar'] = 'baz' + cookies['baz'] = 'foo' + cookies.delete_if { |*a| a.include? 'bar' } + cookies.values_at 'foo', 'bar', 'baz' + end).to eq([nil, nil, 'foo']) + end + end + + describe :each do + it 'loops through cookies' do + keys = [] + foo = nil + bar = nil + + cookie_route('foo=bar', 'bar=baz') do + cookies.each do |key, value| + foo = value if key == 'foo' + bar = value if key == 'bar' + keys << key + end + end + + expect(keys.sort).to eq(['bar', 'foo']) + expect(foo).to eq('bar') + expect(bar).to eq('baz') + end + + it 'favors response over request cookies' do + seen = false + key = nil + value = nil + cookie_route('foo=bar') do + cookies[:foo] = 'baz' + cookies.each do |k,v| + key = k + value = v + end + end + expect(key).to eq('foo') + expect(value).to eq('baz') + expect(seen).to eq(false) + end + + it 'does not loop through deleted cookies' do + cookie_route('foo=bar') do + cookies.delete :foo + cookies.each { fail } + end + end + + it 'returns an enumerator' do + keys = [] + cookie_route('foo=bar') do + enum = cookies.each + enum.each { |key, value| keys << key } + end + keys.each{ |key| expect(key).to eq('foo')} + end + end + + describe :each_key do + it 'loops through cookies' do + keys = [] + + cookie_route('foo=bar', 'bar=baz') do + cookies.each_key do |key| + keys << key + end + end + + expect(keys.sort).to eq(['bar', 'foo']) + end + + it 'only yields keys once' do + seen = false + cookie_route('foo=bar') do + cookies[:foo] = 'baz' + end + expect(seen).to eq(false) + end + + it 'does not loop through deleted cookies' do + cookie_route('foo=bar') do + cookies.delete :foo + cookies.each_key { fail } + end + end + + it 'returns an enumerator' do + keys = [] + cookie_route('foo=bar') do + enum = cookies.each_key + enum.each { |key| keys << key } + end + keys.each{ |key| expect(key).to eq('foo')} + end + end + + describe :each_pair do + it 'loops through cookies' do + keys = [] + foo = nil + bar = nil + + cookie_route('foo=bar', 'bar=baz') do + cookies.each_pair do |key, value| + foo = value if key == 'foo' + bar = value if key == 'bar' + keys << key + end + end + + expect(keys.sort).to eq(['bar', 'foo']) + expect(foo).to eq('bar') + expect(bar).to eq('baz') + end + + it 'favors response over request cookies' do + seen = false + key = nil + value = nil + cookie_route('foo=bar') do + cookies[:foo] = 'baz' + cookies.each_pair do |k, v| + key = k + value = v + end + end + expect(key).to eq('foo') + expect(value).to eq('baz') + expect(seen).to eq(false) + end + + it 'does not loop through deleted cookies' do + cookie_route('foo=bar') do + cookies.delete :foo + cookies.each_pair { fail } + end + end + + it 'returns an enumerator' do + keys = [] + cookie_route('foo=bar') do + enum = cookies.each_pair + enum.each { |key, value| keys << key } + end + keys.each{ |key| expect(key).to eq('foo')} + end + end + + describe :each_value do + it 'loops through cookies' do + values = [] + + cookie_route('foo=bar', 'bar=baz') do + cookies.each_value do |value| + values << value + end + end + + expect(values.sort).to eq(['bar', 'baz']) + end + + it 'favors response over request cookies' do + value = nil + cookie_route('foo=bar') do + cookies[:foo] = 'baz' + cookies.each_value do |v| + value = v + end + end + expect(value).to eq('baz') + end + + it 'does not loop through deleted cookies' do + cookie_route('foo=bar') do + cookies.delete :foo + cookies.each_value { fail } + end + end + + it 'returns an enumerator' do + enum = nil + cookie_route('foo=bar') do + enum = cookies.each_value + end + enum.each { |value| expect(value).to eq('bar') } + end + end + + describe :empty? do + it 'returns true if there are no cookies' do + expect(cookies).to be_empty + end + + it 'returns false if there are request cookies' do + expect(cookies('foo=bar')).not_to be_empty + end + + it 'returns false if there are response cookies' do + expect(cookie_route do + cookies['foo'] = 'bar' + cookies.empty? + end).to be false + end + + it 'becomes true if response cookies are removed' do + expect(cookie_route do + cookies['foo'] = 'bar' + cookies.delete :foo + cookies.empty? + end).to be true + end + + it 'becomes true if request cookies are removed' do + expect(cookie_route('foo=bar') do + cookies.delete :foo + cookies.empty? + end).to be_truthy + end + + it 'becomes true after clear' do + expect(cookie_route('foo=bar', 'bar=baz') do + cookies['foo'] = 'bar' + cookies.clear + cookies.empty? + end).to be_truthy + end + end + + describe :fetch do + it 'returns values from request cookies' do + expect(cookies('foo=bar').fetch('foo')).to eq('bar') + end + + it 'returns values from response cookies' do + expect(cookie_route do + cookies['foo'] = 'bar' + cookies.fetch('foo') + end).to eq('bar') + end + + it 'favors response over request cookies' do + expect(cookie_route('foo=baz') do + cookies['foo'] = 'bar' + cookies.fetch('foo') + end).to eq('bar') + end + + it 'raises an exception if key does not exist' do + error = if defined? JRUBY_VERSION + IndexError + else + KeyError + end + expect { cookies.fetch('foo') }.to raise_exception(error) + end + + it 'returns the block result if missing' do + expect(cookies.fetch('foo') { 'bar' }).to eq('bar') + end + end + + describe :flatten do + it { expect(cookies('foo=bar').flatten).to eq({'foo' => 'bar'}.flatten) } + end if Hash.method_defined? :flatten + + describe :has_key? do + it 'checks request cookies' do + expect(cookies('foo=bar')).to have_key('foo') + end + + it 'checks response cookies' do + jar = cookies + jar['foo'] = 'bar' + expect(jar).to have_key(:foo) + end + + it 'does not use deleted cookies' do + jar = cookies('foo=bar') + jar.delete :foo + expect(jar).not_to have_key('foo') + end + end + + describe :has_value? do + it 'checks request cookies' do + expect(cookies('foo=bar')).to have_value('bar') + end + + it 'checks response cookies' do + jar = cookies + jar[:foo] = 'bar' + expect(jar).to have_value('bar') + end + + it 'does not use deleted cookies' do + jar = cookies('foo=bar') + jar.delete :foo + expect(jar).not_to have_value('bar') + end + end + + describe :include? do + it 'checks request cookies' do + expect(cookies('foo=bar')).to include('foo') + end + + it 'checks response cookies' do + jar = cookies + jar['foo'] = 'bar' + expect(jar).to include(:foo) + end + + it 'does not use deleted cookies' do + jar = cookies('foo=bar') + jar.delete :foo + expect(jar).not_to include('foo') + end + end + + describe :keep_if do + it 'removes entries' do + jar = cookies('foo=bar', 'bar=baz') + jar.keep_if { |*args| args == ['bar', 'baz'] } + expect(jar).to eq({'bar' => 'baz'}) + end + end + + describe :key do + it 'checks request cookies' do + expect(cookies('foo=bar').key('bar')).to eq('foo') + end + + it 'checks response cookies' do + jar = cookies + jar['foo'] = 'bar' + expect(jar.key('bar')).to eq('foo') + end + + it 'returns nil when missing' do + expect(cookies('foo=bar').key('baz')).to be_nil + end + end + + describe :key? do + it 'checks request cookies' do + expect(cookies('foo=bar').key?('foo')).to be true + end + + it 'checks response cookies' do + jar = cookies + jar['foo'] = 'bar' + expect(jar.key?(:foo)).to be true + end + + it 'does not use deleted cookies' do + jar = cookies('foo=bar') + jar.delete :foo + expect(jar.key?('foo')).to be false + end + end + + describe :keys do + it { expect(cookies('foo=bar').keys).to eq(['foo']) } + end + + describe :length do + it { expect(cookies.length).to eq(0) } + it { expect(cookies('foo=bar').length).to eq(1) } + end + + describe :member? do + it 'checks request cookies' do + expect(cookies('foo=bar').member?('foo')).to be true + end + + it 'checks response cookies' do + jar = cookies + jar['foo'] = 'bar' + expect(jar.member?(:foo)).to be true + end + + it 'does not use deleted cookies' do + jar = cookies('foo=bar') + jar.delete :foo + expect(jar.member?('foo')).to be false + end + end + + describe :merge do + it 'is mergable with a hash' do + expect(cookies('foo=bar').merge(:bar => :baz)).to eq({"foo" => "bar", :bar => :baz}) + end + + it 'does not create cookies' do + jar = cookies('foo=bar') + jar.merge(:bar => 'baz') + expect(jar).not_to include(:bar) + end + + it 'takes a block for conflict resolution' do + update = {'foo' => 'baz', 'bar' => 'baz'} + merged = cookies('foo=bar').merge(update) do |key, old, other| + expect(key).to eq('foo') + expect(old).to eq('bar') + expect(other).to eq('baz') + 'foo' + end + expect(merged['foo']).to eq('foo') + end + end + + describe :merge! do + it 'creates cookies' do + jar = cookies('foo=bar') + jar.merge! :bar => 'baz' + expect(jar).to include('bar') + end + + it 'overrides existing values' do + jar = cookies('foo=bar') + jar.merge! :foo => "baz" + expect(jar["foo"]).to eq("baz") + end + + it 'takes a block for conflict resolution' do + update = {'foo' => 'baz', 'bar' => 'baz'} + jar = cookies('foo=bar') + jar.merge!(update) do |key, old, other| + expect(key).to eq('foo') + expect(old).to eq('bar') + expect(other).to eq('baz') + 'foo' + end + expect(jar['foo']).to eq('foo') + end + end + + describe :rassoc do + it 'behaves like Hash#assoc' do + cookies('foo=bar').rassoc('bar') == ['foo', 'bar'] + end + end if Hash.method_defined? :rassoc + + describe :reject do + it 'removes entries from new hash' do + jar = cookies('foo=bar', 'bar=baz') + sub = jar.reject { |*args| args == ['bar', 'baz'] } + expect(sub).to eq({'foo' => 'bar'}) + expect(jar['bar']).to eq('baz') + end + end + + describe :reject! do + it 'removes entries' do + jar = cookies('foo=bar', 'bar=baz') + jar.reject! { |*args| args == ['bar', 'baz'] } + expect(jar).to eq({'foo' => 'bar'}) + end + end + + describe :replace do + it 'replaces entries' do + jar = cookies('foo=bar', 'bar=baz') + jar.replace 'foo' => 'baz', 'baz' => 'bar' + expect(jar).to eq({'foo' => 'baz', 'baz' => 'bar'}) + end + end + + describe :set do + it 'sets a cookie' do + cookie_route { cookies.set('foo', value: 'bar') } + expect(cookie_jar['foo']).to eq('bar') + end + + it 'sets a cookie with HttpOnly' do + expect(cookie_route do + request.script_name = '/foo' + cookies.set('foo', value: 'bar', httponly: true) + response['Set-Cookie'].lines.detect { |l| l.start_with? 'foo=' } + end).to include('HttpOnly') + end + + it 'sets a cookie without HttpOnly' do + expect(cookie_route do + request.script_name = '/foo' + cookies.set('foo', value: 'bar', httponly: false) + response['Set-Cookie'].lines.detect { |l| l.start_with? 'foo=' } + end).not_to include('HttpOnly') + end + end + + describe :select do + it 'removes entries from new hash' do + jar = cookies('foo=bar', 'bar=baz') + sub = jar.select { |*args| args != ['bar', 'baz'] } + expect(sub).to eq({'foo' => 'bar'}.select { true }) + expect(jar['bar']).to eq('baz') + end + end + + describe :select! do + it 'removes entries' do + jar = cookies('foo=bar', 'bar=baz') + jar.select! { |*args| args != ['bar', 'baz'] } + expect(jar).to eq({'foo' => 'bar'}) + end + end if Hash.method_defined? :select! + + describe :shift do + it 'removes from the hash' do + jar = cookies('foo=bar') + expect(jar.shift).to eq(['foo', 'bar']) + expect(jar).not_to include('bar') + end + end + + describe :size do + it { expect(cookies.size).to eq(0) } + it { expect(cookies('foo=bar').size).to eq(1) } + end + + describe :update do + it 'creates cookies' do + jar = cookies('foo=bar') + jar.update :bar => 'baz' + expect(jar).to include('bar') + end + + it 'overrides existing values' do + jar = cookies('foo=bar') + jar.update :foo => "baz" + expect(jar["foo"]).to eq("baz") + end + + it 'takes a block for conflict resolution' do + merge = {'foo' => 'baz', 'bar' => 'baz'} + jar = cookies('foo=bar') + jar.update(merge) do |key, old, other| + expect(key).to eq('foo') + expect(old).to eq('bar') + expect(other).to eq('baz') + 'foo' + end + expect(jar['foo']).to eq('foo') + end + end + + describe :value? do + it 'checks request cookies' do + expect(cookies('foo=bar').value?('bar')).to be true + end + + it 'checks response cookies' do + jar = cookies + jar[:foo] = 'bar' + expect(jar.value?('bar')).to be true + end + + it 'does not use deleted cookies' do + jar = cookies('foo=bar') + jar.delete :foo + expect(jar.value?('bar')).to be false + end + end + + describe :values do + it { expect(cookies('foo=bar', 'bar=baz').values.sort).to eq(['bar', 'baz']) } + end + + describe :values_at do + it { expect(cookies('foo=bar', 'bar=baz').values_at('foo')).to eq(['bar']) } + end +end diff --git a/sinatra-contrib/spec/custom_logger_spec.rb b/sinatra-contrib/spec/custom_logger_spec.rb new file mode 100644 index 0000000000..cb04ce35d4 --- /dev/null +++ b/sinatra-contrib/spec/custom_logger_spec.rb @@ -0,0 +1,43 @@ +require 'spec_helper' +require 'sinatra/custom_logger' + +RSpec.describe Sinatra::CustomLogger do + before do + rack_logger = @rack_logger = double + mock_app do + helpers Sinatra::CustomLogger + + before do + env['rack.logger'] = rack_logger + end + + get '/' do + logger.info 'Logged message' + 'Response' + end + end + end + + describe '#logger' do + it 'falls back to request.logger' do + expect(@rack_logger).to receive(:info).with('Logged message') + get '/' + end + + context 'logger setting is set' do + before do + custom_logger = @custom_logger = double + @app.class_eval do + configure do + set :logger, custom_logger + end + end + end + + it 'calls custom logger' do + expect(@custom_logger).to receive(:info).with('Logged message') + get '/' + end + end + end +end diff --git a/sinatra-contrib/spec/extension_spec.rb b/sinatra-contrib/spec/extension_spec.rb new file mode 100644 index 0000000000..fb3239cd8a --- /dev/null +++ b/sinatra-contrib/spec/extension_spec.rb @@ -0,0 +1,32 @@ +require 'spec_helper' + +RSpec.describe Sinatra::Extension do + module ExampleExtension + extend Sinatra::Extension + + set :foo, :bar + settings.set :bar, :blah + + configure :test, :production do + set :reload_stuff, false + end + + configure :development do + set :reload_stuff, true + end + + get '/' do + "from extension, yay" + end + end + + before { mock_app { register ExampleExtension }} + + it('allows using set') { expect(settings.foo).to eq(:bar) } + it('implements configure') { expect(settings.reload_stuff).to be false } + + it 'allows defing routes' do + expect(get('/')).to be_ok + expect(body).to eq("from extension, yay") + end +end diff --git a/sinatra-contrib/spec/json_spec.rb b/sinatra-contrib/spec/json_spec.rb new file mode 100644 index 0000000000..ec76c42f86 --- /dev/null +++ b/sinatra-contrib/spec/json_spec.rb @@ -0,0 +1,115 @@ +require 'multi_json' + +require 'spec_helper' +require 'okjson' + +RSpec.shared_examples_for "a json encoder" do |lib, const| + before do + begin + require lib if lib + @encoder = eval(const) + rescue LoadError + skip "unable to load #{lib}" + end + end + + it "allows setting :encoder to #{const}" do + enc = @encoder + mock_app { get('/') { json({'foo' => 'bar'}, :encoder => enc) }} + results_in 'foo' => 'bar' + end + + it "allows setting settings.json_encoder to #{const}" do + enc = @encoder + mock_app do + set :json_encoder, enc + get('/') { json 'foo' => 'bar' } + end + results_in 'foo' => 'bar' + end +end + +RSpec.describe Sinatra::JSON do + def mock_app(&block) + super do + class_eval(&block) + end + end + + def results_in(obj) + expect(OkJson.decode(get('/').body)).to eq(obj) + end + + it "encodes objects to json out of the box" do + mock_app { get('/') { json :foo => [1, 'bar', nil] } } + results_in 'foo' => [1, 'bar', nil] + end + + it "sets the content type to 'application/json'" do + mock_app { get('/') { json({}) } } + expect(get('/')["Content-Type"]).to include("application/json") + end + + it "allows overriding content type with :content_type" do + mock_app { get('/') { json({}, :content_type => "foo/bar") } } + expect(get('/')["Content-Type"]).to eq("foo/bar") + end + + it "accepts shorthands for :content_type" do + mock_app { get('/') { json({}, :content_type => :js) } } + expect(get('/')["Content-Type"]).to eq("application/javascript;charset=utf-8") + end + + it 'calls generate on :encoder if available' do + enc = Object.new + def enc.generate(obj) obj.inspect end + mock_app { get('/') { json(42, :encoder => enc) }} + expect(get('/').body).to eq('42') + end + + it 'calls encode on :encoder if available' do + enc = Object.new + def enc.encode(obj) obj.inspect end + mock_app { get('/') { json(42, :encoder => enc) }} + expect(get('/').body).to eq('42') + end + + it 'sends :encoder as method call if it is a Symbol' do + mock_app { get('/') { json(42, :encoder => :inspect) }} + expect(get('/').body).to eq('42') + end + + it 'calls generate on settings.json_encoder if available' do + enc = Object.new + def enc.generate(obj) obj.inspect end + mock_app do + set :json_encoder, enc + get('/') { json 42 } + end + expect(get('/').body).to eq('42') + end + + it 'calls encode on settings.json_encode if available' do + enc = Object.new + def enc.encode(obj) obj.inspect end + mock_app do + set :json_encoder, enc + get('/') { json 42 } + end + expect(get('/').body).to eq('42') + end + + it 'sends settings.json_encode as method call if it is a Symbol' do + mock_app do + set :json_encoder, :inspect + get('/') { json 42 } + end + expect(get('/').body).to eq('42') + end + + describe('Yajl') { it_should_behave_like "a json encoder", "yajl", "Yajl::Encoder" } unless defined? JRUBY_VERSION + describe('JSON') { it_should_behave_like "a json encoder", "json", "::JSON" } + describe('OkJson') { it_should_behave_like "a json encoder", nil, "OkJson" } + describe('to_json') { it_should_behave_like "a json encoder", "json", ":to_json" } + describe('without') { it_should_behave_like "a json encoder", nil, "Sinatra::JSON" } +end diff --git a/sinatra-contrib/spec/link_header_spec.rb b/sinatra-contrib/spec/link_header_spec.rb new file mode 100644 index 0000000000..f18cabb0fe --- /dev/null +++ b/sinatra-contrib/spec/link_header_spec.rb @@ -0,0 +1,99 @@ +require 'spec_helper' + +RSpec.describe Sinatra::LinkHeader do + before do + mock_app do + helpers Sinatra::LinkHeader + before('/') { link 'something', :rel => 'from-filter', :foo => :bar } + + get '/' do + link :something, 'booyah' + end + + get '/style' do + stylesheet '/style.css' + end + + get '/prefetch' do + prefetch '/foo' + end + + get '/link_headers' do + response['Link'] = " ;bar=\"baz\"" + stylesheet '/style.css' + prefetch '/foo' + link_headers + end + end + end + + describe :link do + it "sets link headers" do + get '/' + expect(headers['Link'].lines).to include('; rel="something"') + end + + it "returns link html tags" do + get '/' + expect(body).to eq('') + end + + it "takes an options hash" do + get '/' + elements = ["", "foo=\"bar\"", "rel=\"from-filter\""] + expect(headers['Link'].split(",\n").first.strip.split('; ').sort).to eq(elements) + end + end + + describe :stylesheet do + it 'sets link headers' do + get '/style' + expect(headers['Link']).to match(%r{^;}) + end + + it 'sets type to text/css' do + get '/style' + expect(headers['Link']).to include('type="text/css"') + end + + it 'sets rel to stylesheet' do + get '/style' + expect(headers['Link']).to include('rel="stylesheet"') + end + + it 'returns html tag' do + get '/style' + expect(body).to match(%r{^;}) + end + + it 'sets rel to prefetch' do + get '/prefetch' + expect(headers['Link']).to include('rel="prefetch"') + end + + it 'returns html tag' do + get '/prefetch' + expect(body).to eq('') + end + end + + describe :link_headers do + it 'generates html for all link headers' do + get '/link_headers' + expect(body).to include('') + expect(body).to include('') + end + end +end diff --git a/sinatra-contrib/spec/multi_route_spec.rb b/sinatra-contrib/spec/multi_route_spec.rb new file mode 100644 index 0000000000..39f8c4e2ee --- /dev/null +++ b/sinatra-contrib/spec/multi_route_spec.rb @@ -0,0 +1,59 @@ +require 'spec_helper' + +RSpec.describe Sinatra::MultiRoute do + + it 'does not break normal routing' do + mock_app do + register Sinatra::MultiRoute + get('/') { 'normal' } + end + + expect(get('/')).to be_ok + expect(body).to eq('normal') + end + + it 'supports multiple routes' do + mock_app do + register Sinatra::MultiRoute + get('/foo', '/bar') { 'paths' } + end + + expect(get('/foo')).to be_ok + expect(body).to eq('paths') + expect(get('/bar')).to be_ok + expect(body).to eq('paths') + end + + it 'triggers conditions' do + count = 0 + mock_app do + register Sinatra::MultiRoute + set(:some_condition) { |_| count += 1 } + get('/foo', '/bar', :some_condition => true) { 'paths' } + end + + expect(count).to eq(4) + end + + it 'supports multiple verbs' do + mock_app do + register Sinatra::MultiRoute + route('PUT', 'POST', '/') { 'verb' } + end + + expect(post('/')).to be_ok + expect(body).to eq('verb') + expect(put('/')).to be_ok + expect(body).to eq('verb') + end + + it 'takes symbols as verbs' do + mock_app do + register Sinatra::MultiRoute + route(:get, '/baz') { 'symbol as verb' } + end + + expect(get('/baz')).to be_ok + expect(body).to eq('symbol as verb') + end +end diff --git a/sinatra-contrib/spec/namespace/foo.erb b/sinatra-contrib/spec/namespace/foo.erb new file mode 100644 index 0000000000..45b983be36 --- /dev/null +++ b/sinatra-contrib/spec/namespace/foo.erb @@ -0,0 +1 @@ +hi diff --git a/sinatra-contrib/spec/namespace/nested/foo.erb b/sinatra-contrib/spec/namespace/nested/foo.erb new file mode 100644 index 0000000000..ab4a98190c --- /dev/null +++ b/sinatra-contrib/spec/namespace/nested/foo.erb @@ -0,0 +1 @@ +ho diff --git a/sinatra-contrib/spec/namespace_spec.rb b/sinatra-contrib/spec/namespace_spec.rb new file mode 100644 index 0000000000..0382dda53c --- /dev/null +++ b/sinatra-contrib/spec/namespace_spec.rb @@ -0,0 +1,882 @@ +require 'spec_helper' + +RSpec.describe Sinatra::Namespace do + verbs = [:get, :head, :post, :put, :delete, :options, :patch] + + def mock_app(&block) + super do + register Sinatra::Namespace + class_eval(&block) + end + end + + def namespace(*args, &block) + mock_app { namespace(*args, &block) } + end + + verbs.each do |verb| + describe "HTTP #{verb.to_s.upcase}" do + + it 'prefixes the path with the namespace' do + namespace('/foo') { send(verb, '/bar') { 'baz' }} + expect(send(verb, '/foo/bar')).to be_ok + expect(body).to eq('baz') unless verb == :head + expect(send(verb, '/foo/baz')).not_to be_ok + end + + describe 'redirect_to' do + it 'redirect within namespace' do + namespace('/foo') { send(verb, '/bar') { redirect_to '/foo_bar' }} + expect(send(verb, '/foo/bar')).to be_redirect + expect(send(verb, '/foo/bar').location).to include("/foo/foo_bar") + end + end + + context 'when namespace is a string' do + it 'accepts routes with no path' do + namespace('/foo') { send(verb) { 'bar' } } + expect(send(verb, '/foo')).to be_ok + expect(body).to eq('bar') unless verb == :head + end + + it 'accepts the path as a named parameter' do + namespace('/foo') { send(verb, '/:bar') { params[:bar] }} + expect(send(verb, '/foo/bar')).to be_ok + expect(body).to eq('bar') unless verb == :head + expect(send(verb, '/foo/baz')).to be_ok + expect(body).to eq('baz') unless verb == :head + end + + it 'accepts the path as a regular expression' do + namespace('/foo') { send(verb, /\/\d\d/) { 'bar' }} + expect(send(verb, '/foo/12')).to be_ok + expect(body).to eq 'bar' unless verb == :head + expect(send(verb, '/foo/123')).not_to be_ok + end + end + + context 'when namespace is a named parameter' do + it 'accepts routes with no path' do + namespace('/:foo') { send(verb) { 'bar' } } + expect(send(verb, '/foo')).to be_ok + expect(body).to eq('bar') unless verb == :head + end + + it 'sets the parameter correctly' do + namespace('/:foo') { send(verb, '/bar') { params[:foo] }} + expect(send(verb, '/foo/bar')).to be_ok + expect(body).to eq('foo') unless verb == :head + expect(send(verb, '/fox/bar')).to be_ok + expect(body).to eq('fox') unless verb == :head + expect(send(verb, '/foo/baz')).not_to be_ok + end + + it 'accepts the path as a named parameter' do + namespace('/:foo') { send(verb, '/:bar') { params[:bar] }} + expect(send(verb, '/foo/bar')).to be_ok + expect(body).to eq('bar') unless verb == :head + expect(send(verb, '/foo/baz')).to be_ok + expect(body).to eq('baz') unless verb == :head + end + + it 'accepts the path as regular expression' do + namespace('/:foo') { send(verb, %r{/bar}) { params[:foo] }} + expect(send(verb, '/foo/bar')).to be_ok + expect(body).to eq('foo') unless verb == :head + expect(send(verb, '/fox/bar')).to be_ok + expect(body).to eq('fox') unless verb == :head + expect(send(verb, '/foo/baz')).not_to be_ok + end + end + + context 'when namespace is a regular expression' do + it 'accepts routes with no path' do + namespace(%r{/foo}) { send(verb) { 'bar' } } + expect(send(verb, '/foo')).to be_ok + expect(body).to eq('bar') unless verb == :head + end + + it 'accepts the path as a named parameter' do + namespace(%r{/foo}) { send(verb, '/:bar') { params[:bar] }} + expect(send(verb, '/foo/bar')).to be_ok + expect(body).to eq('bar') unless verb == :head + expect(send(verb, '/foo/baz')).to be_ok + expect(body).to eq('baz') unless verb == :head + end + + it 'accepts the path as a regular expression' do + namespace(/\/\d\d/) { send(verb, /\/\d\d/) { 'foo' }} + expect(send(verb, '/23/12')).to be_ok + expect(body).to eq('foo') unless verb == :head + expect(send(verb, '/123/12')).not_to be_ok + end + + describe "before/after filters" do + it 'trigger before filter' do + ran = false + namespace(/\/foo\/([^\/&?]+)\/bar\/([^\/&?]+)\//) { before { ran = true };} + + send(verb, '/bar/') + expect(ran).to eq(false) + + send(verb, '/foo/1/bar/1/') + expect(ran).to eq(true) + end + + it 'trigger after filter' do + ran = false + namespace(/\/foo\/([^\/&?]+)\/bar\/([^\/&?]+)\//) { after { ran = true };} + + send(verb, '/bar/') + expect(ran).to eq(false) + + send(verb, '/foo/1/bar/1/') + expect(ran).to eq(true) + end + end + + describe 'helpers' do + it 'are defined using the helpers method' do + namespace(/\/foo\/([^\/&?]+)\/bar\/([^\/&?]+)\//) do + helpers do + def foo + 'foo' + end + end + + send verb, '' do + foo.to_s + end + end + + expect(send(verb, '/foo/1/bar/1/')).to be_ok + expect(body).to eq('foo') unless verb == :head + end + end + end + + context 'when namespace is a splat' do + it 'accepts the path as a splat' do + namespace('/*') { send(verb, '/*') { params[:splat].join ' - ' }} + expect(send(verb, '/foo/bar')).to be_ok + expect(body).to eq('foo - bar') unless verb == :head + end + end + + describe 'before-filters' do + specify 'are triggered' do + ran = false + namespace('/foo') { before { ran = true }} + send(verb, '/foo') + expect(ran).to be true + end + + specify 'are not triggered for a different namespace' do + ran = false + namespace('/foo') { before { ran = true }} + send(verb, '/fox') + expect(ran).to be false + end + end + + describe 'after-filters' do + specify 'are triggered' do + ran = false + namespace('/foo') { after { ran = true }} + send(verb, '/foo') + expect(ran).to be true + end + + specify 'are not triggered for a different namespace' do + ran = false + namespace('/foo') { after { ran = true }} + send(verb, '/fox') + expect(ran).to be false + end + end + + describe 'conditions' do + context 'when the namespace has no prefix' do + specify 'are accepted in the namespace' do + mock_app do + namespace(:host_name => 'example.com') { send(verb) { 'yes' }} + send(verb, '/') { 'no' } + end + send(verb, '/', {}, 'HTTP_HOST' => 'example.com') + expect(last_response).to be_ok + expect(body).to eq('yes') unless verb == :head + send(verb, '/', {}, 'HTTP_HOST' => 'example.org') + expect(last_response).to be_ok + expect(body).to eq('no') unless verb == :head + end + + specify 'are accepted in the route definition' do + namespace :host_name => 'example.com' do + send(verb, '/foo', :provides => :txt) { 'ok' } + end + expect(send(verb, '/foo', {}, 'HTTP_HOST' => 'example.com', 'HTTP_ACCEPT' => 'text/plain')).to be_ok + expect(send(verb, '/foo', {}, 'HTTP_HOST' => 'example.com', 'HTTP_ACCEPT' => 'text/html')).not_to be_ok + expect(send(verb, '/foo', {}, 'HTTP_HOST' => 'example.org', 'HTTP_ACCEPT' => 'text/plain')).not_to be_ok + end + + specify 'are accepted in the before-filter' do + ran = false + namespace :provides => :txt do + before('/foo', :host_name => 'example.com') { ran = true } + send(verb, '/*') { 'ok' } + end + send(verb, '/foo', {}, 'HTTP_HOST' => 'example.org', 'HTTP_ACCEPT' => 'text/plain') + expect(ran).to be false + send(verb, '/foo', {}, 'HTTP_HOST' => 'example.com', 'HTTP_ACCEPT' => 'text/html') + expect(ran).to be false + send(verb, '/bar', {}, 'HTTP_HOST' => 'example.com', 'HTTP_ACCEPT' => 'text/plain') + expect(ran).to be false + send(verb, '/foo', {}, 'HTTP_HOST' => 'example.com', 'HTTP_ACCEPT' => 'text/plain') + expect(ran).to be true + end + + specify 'are accepted in the after-filter' do + ran = false + namespace :provides => :txt do + after('/foo', :host_name => 'example.com') { ran = true } + send(verb, '/*') { 'ok' } + end + send(verb, '/foo', {}, 'HTTP_HOST' => 'example.org', 'HTTP_ACCEPT' => 'text/plain') + expect(ran).to be false + send(verb, '/foo', {}, 'HTTP_HOST' => 'example.com', 'HTTP_ACCEPT' => 'text/html') + expect(ran).to be false + send(verb, '/bar', {}, 'HTTP_HOST' => 'example.com', 'HTTP_ACCEPT' => 'text/plain') + expect(ran).to be false + send(verb, '/foo', {}, 'HTTP_HOST' => 'example.com', 'HTTP_ACCEPT' => 'text/plain') + expect(ran).to be true + end + end + + context 'when the namespace is a string' do + specify 'are accepted in the namespace' do + namespace '/foo', :host_name => 'example.com' do + send(verb) { 'ok' } + end + expect(send(verb, '/foo', {}, 'HTTP_HOST' => 'example.com')).to be_ok + expect(send(verb, '/foo', {}, 'HTTP_HOST' => 'example.org')).not_to be_ok + end + + specify 'are accepted in the before-filter' do + namespace '/foo' do + before { @yes = nil } + before(:host_name => 'example.com') { @yes = 'yes' } + send(verb) { @yes || 'no' } + end + send(verb, '/foo', {}, 'HTTP_HOST' => 'example.com') + expect(last_response).to be_ok + expect(body).to eq('yes') unless verb == :head + send(verb, '/foo', {}, 'HTTP_HOST' => 'example.org') + expect(last_response).to be_ok + expect(body).to eq('no') unless verb == :head + end + + specify 'are accepted in the after-filter' do + ran = false + namespace '/foo' do + before(:host_name => 'example.com') { ran = true } + send(verb) { 'ok' } + end + send(verb, '/foo', {}, 'HTTP_HOST' => 'example.org') + expect(ran).to be false + send(verb, '/foo', {}, 'HTTP_HOST' => 'example.com') + expect(ran).to be true + end + + specify 'are accepted in the route definition' do + namespace '/foo' do + send(verb, :host_name => 'example.com') { 'ok' } + end + expect(send(verb, '/foo', {}, 'HTTP_HOST' => 'example.com')).to be_ok + expect(send(verb, '/foo', {}, 'HTTP_HOST' => 'example.org')).not_to be_ok + end + + context 'when the namespace has a condition' do + specify 'are accepted in the before-filter' do + ran = false + namespace '/', :provides => :txt do + before(:host_name => 'example.com') { ran = true } + send(verb) { 'ok' } + end + send(verb, '/', {}, 'HTTP_HOST' => 'example.org', 'HTTP_ACCEPT' => 'text/plain') + expect(ran).to be false + send(verb, '/', {}, 'HTTP_HOST' => 'example.com', 'HTTP_ACCEPT' => 'text/html') + expect(ran).to be false + send(verb, '/', {}, 'HTTP_HOST' => 'example.com', 'HTTP_ACCEPT' => 'text/plain') + expect(ran).to be true + end + + specify 'are accepted in the filters' do + ran = false + namespace '/f', :provides => :txt do + before('oo', :host_name => 'example.com') { ran = true } + send(verb, '/*') { 'ok' } + end + send(verb, '/foo', {}, 'HTTP_HOST' => 'example.org', 'HTTP_ACCEPT' => 'text/plain') + expect(ran).to be false + send(verb, '/foo', {}, 'HTTP_HOST' => 'example.com', 'HTTP_ACCEPT' => 'text/html') + expect(ran).to be false + send(verb, '/far', {}, 'HTTP_HOST' => 'example.com', 'HTTP_ACCEPT' => 'text/plain') + expect(ran).to be false + send(verb, '/foo', {}, 'HTTP_HOST' => 'example.com', 'HTTP_ACCEPT' => 'text/plain') + expect(ran).to be true + end + end + end + end + + describe 'helpers' do + it 'are defined using the helpers method' do + namespace '/foo' do + helpers do + def magic + 42 + end + end + + send verb, '/bar' do + magic.to_s + end + end + + expect(send(verb, '/foo/bar')).to be_ok + expect(body).to eq('42') unless verb == :head + end + + it 'can be defined as normal methods' do + namespace '/foo' do + def magic + 42 + end + + send verb, '/bar' do + magic.to_s + end + end + + expect(send(verb, '/foo/bar')).to be_ok + expect(body).to eq('42') unless verb == :head + end + + it 'can be defined using module mixins' do + mixin = Module.new do + def magic + 42 + end + end + + namespace '/foo' do + helpers mixin + send verb, '/bar' do + magic.to_s + end + end + + expect(send(verb, '/foo/bar')).to be_ok + expect(body).to eq('42') unless verb == :head + end + + specify 'are unavailable outside the namespace where they are defined' do + mock_app do + namespace '/foo' do + def magic + 42 + end + + send verb, '/bar' do + magic.to_s + end + end + + send verb, '/' do + magic.to_s + end + end + + expect { send verb, '/' }.to raise_error(NameError) + end + + specify 'are unavailable outside the namespace that they are mixed into' do + mixin = Module.new do + def magic + 42 + end + end + + mock_app do + namespace '/foo' do + helpers mixin + send verb, '/bar' do + magic.to_s + end + end + + send verb, '/' do + magic.to_s + end + end + + expect { send verb, '/' }.to raise_error(NameError) + end + + specify 'are available to nested namespaces' do + mock_app do + helpers do + def magic + 42 + end + end + + namespace '/foo' do + send verb, '/bar' do + magic.to_s + end + end + end + + expect(send(verb, '/foo/bar')).to be_ok + expect(body).to eq('42') unless verb == :head + end + + specify 'can call super from nested definitions' do + mock_app do + helpers do + def magic + 42 + end + end + + namespace '/foo' do + def magic + super - 19 + end + + send verb, '/bar' do + magic.to_s + end + end + end + + expect(send(verb, '/foo/bar')).to be_ok + expect(body).to eq('23') unless verb == :head + end + end + + describe 'nesting' do + it 'routes to nested namespaces' do + namespace '/foo' do + namespace '/bar' do + send(verb, '/baz') { 'OKAY!!11!'} + end + end + + expect(send(verb, '/foo/bar/baz')).to be_ok + expect(body).to eq('OKAY!!11!') unless verb == :head + end + + it 'works correctly if deep nesting' do + namespace '/a' do + namespace '/b' do + namespace '/c' do + send(verb, '') { 'hey' } + end + end + end + + expect(send(verb, '/a/b/c')).to be_ok + expect(body).to eq('hey') unless verb == :head + end + + it 'exposes helpers to nested namespaces' do + namespace '/foo' do + helpers do + def magic + 42 + end + end + + namespace '/bar' do + send verb, '/baz' do + magic.to_s + end + end + end + + expect(send(verb, '/foo/bar/baz')).to be_ok + expect(body).to eq('42') unless verb == :head + end + + specify 'does not provide access to nested helper methods' do + namespace '/foo' do + namespace '/bar' do + def magic + 42 + end + + send verb, '/baz' do + magic.to_s + end + end + + send verb do + magic.to_s + end + end + + expect { send verb, '/foo' }.to raise_error(NameError) + end + + it 'accepts a nested namespace as a named parameter' do + namespace('/:a') { namespace('/:b') { send(verb) { params[:a] }}} + expect(send(verb, '/foo/bar')).to be_ok + expect(body).to eq('foo') unless verb == :head + end + end + + describe 'error handling' do + it 'can be customized using the not_found block' do + namespace('/de') do + not_found { 'nicht gefunden' } + end + expect(send(verb, '/foo').status).to eq 404 + expect(last_response.body).not_to eq 'nicht gefunden' unless verb == :head + expect(get('/en/foo').status).to eq 404 + expect(last_response.body).not_to eq 'nicht gefunden' unless verb == :head + expect(get('/de/foo').status).to eq 404 + expect(last_response.body).to eq 'nicht gefunden' unless verb == :head + end + + it 'can be customized for specific error codes' do + namespace('/de') do + error(404) { 'nicht gefunden' } + end + expect(send(verb, '/foo').status).to eq 404 + expect(last_response.body).not_to eq 'nicht gefunden' unless verb == :head + expect(get('/en/foo').status).to eq 404 + expect(last_response.body).not_to eq 'nicht gefunden' unless verb == :head + expect(get('/de/foo').status).to eq 404 + expect(last_response.body).to eq 'nicht gefunden' unless verb == :head + end + + it 'falls back to the handler defined in the base app' do + mock_app do + error(404) { 'not found...' } + namespace('/en') do + end + namespace('/de') do + error(404) { 'nicht gefunden' } + end + end + expect(send(verb, '/foo').status).to eq 404 + expect(last_response.body).to eq 'not found...' unless verb == :head + expect(get('/en/foo').status).to eq 404 + expect(last_response.body).to eq 'not found...' unless verb == :head + expect(get('/de/foo').status).to eq 404 + expect(last_response.body).to eq 'nicht gefunden' unless verb == :head + end + + it 'can be customized for specific Exception classes' do + mock_app do + class AError < StandardError; end + class BError < AError; end + + error(AError) do + body('auth failed') + 401 + end + + namespace('/en') do + get '/foo' do + raise BError + end + end + + namespace('/de') do + error(AError) do + body('methode nicht erlaubt') + 406 + end + + get '/foo' do + raise BError + end + end + end + expect(get('/en/foo').status).to eq 401 + expect(last_response.body).to eq 'auth failed' unless verb == :head + expect(get('/de/foo').status).to eq 406 + expect(last_response.body).to eq 'methode nicht erlaubt' unless verb == :head + end + + it "allows custom error handlers when namespace is declared as /en/:id. Issue #119" do + mock_app { + class CError < StandardError; + end + + error { raise "should not come here" } + + namespace('/en/:id') do + error(CError) { 201 } + get '/?' do + raise CError + end + end + } + + expect(get('/en/1').status).to eq(201) + end + end + + unless verb == :head + describe 'templates' do + specify 'default to the base app\'s template' do + mock_app do + template(:foo) { 'hi' } + send(verb, '/') { erb :foo } + namespace '/foo' do + send(verb) { erb :foo } + end + end + + expect(send(verb, '/').body).to eq 'hi' + expect(send(verb, '/foo').body).to eq 'hi' + end + + specify 'can be nested' do + mock_app do + template(:foo) { 'hi' } + send(verb, '/') { erb :foo } + namespace '/foo' do + template(:foo) { 'ho' } + send(verb) { erb :foo } + end + end + + expect(send(verb, '/').body).to eq 'hi' + expect(send(verb, '/foo').body).to eq 'ho' + end + + specify 'can use a custom views directory' do + mock_app do + set :views, File.expand_path('namespace', __dir__) + send(verb, '/') { erb :foo } + namespace('/foo') do + set :views, File.expand_path('namespace/nested', __dir__) + send(verb) { erb :foo } + end + end + + expect(send(verb, '/').body).to eq "hi\n" + expect(send(verb, '/foo').body).to eq "ho\n" + end + + specify 'default to the base app\'s layout' do + mock_app do + layout { 'he said: <%= yield %>' } + template(:foo) { 'hi' } + send(verb, '/') { erb :foo } + namespace '/foo' do + template(:foo) { 'ho' } + send(verb) { erb :foo } + end + end + + expect(send(verb, '/').body).to eq 'he said: hi' + expect(send(verb, '/foo').body).to eq 'he said: ho' + end + + specify 'can define nested layouts' do + mock_app do + layout { 'Hello <%= yield %>!' } + template(:foo) { 'World' } + send(verb, '/') { erb :foo } + namespace '/foo' do + layout { 'Hi <%= yield %>!' } + send(verb) { erb :foo } + end + end + + expect(send(verb, '/').body).to eq 'Hello World!' + expect(send(verb, '/foo').body).to eq 'Hi World!' + end + + specify 'can render strings' do + mock_app do + namespace '/foo' do + send(verb) { erb 'foo' } + end + end + + expect(send(verb, '/foo').body).to eq 'foo' + end + + specify 'can render strings nested' do + mock_app do + namespace '/foo' do + namespace '/bar' do + send(verb) { erb 'bar' } + end + end + end + + expect(send(verb, '/foo/bar').body).to eq 'bar' + end + end + end + + describe 'extensions' do + specify 'provide read access to settings' do + value = nil + mock_app do + set :foo, 42 + namespace '/foo' do + value = foo + end + end + expect(value).to eq 42 + end + + specify 'can be registered within a namespace' do + a = b = nil + extension = Module.new { define_method(:views) { 'CUSTOM!!!' } } + mock_app do + namespace '/' do + register extension + a = views + end + b = views + end + expect(a).to eq 'CUSTOM!!!' + expect(b).not_to eq 'CUSTOM!!!' + end + + specify 'trigger the route_added hook' do + route = nil + extension = Module.new + extension.singleton_class.class_eval do + define_method(:route_added) { |*r| route = r } + end + mock_app do + namespace '/f' do + register extension + get('oo') { } + end + get('/bar') { } + end + expect(route[1]).to eq(Mustermann.new '/foo') + end + + specify 'prevent app-global settings from being changed' do + expect { namespace('/') { set :foo, :bar }}.to raise_error(ArgumentError) + end + end + end + end + + describe 'settings' do + it 'provides access to top-level settings' do + mock_app do + set :foo, 'ok' + + namespace '/foo' do + get '/bar' do + settings.foo + end + end + end + + expect(get('/foo/bar').status).to eq(200) + expect(last_response.body).to eq('ok') + end + + it 'sets hashes correctly' do + mock_app do + namespace '/foo' do + set erb: 'o', haml: 'k' + get '/bar' do + settings.erb + settings.haml + end + end + end + + expect(get('/foo/bar').status).to eq(200) + expect(last_response.body).to eq('ok') + end + + it 'uses some repro' do + mock_app do + set :foo, 42 + + namespace '/foo' do + get '/bar' do + #settings.respond_to?(:foo).to_s + settings.foo.to_s + end + end + end + + expect(get('/foo/bar').status).to eq(200) + expect(last_response.body).to eq('42') + end + + it 'allows checking setting existence with respond_to?' do + mock_app do + set :foo, 42 + + namespace '/foo' do + get '/bar' do + settings.respond_to?(:foo).to_s + end + end + end + + expect(get('/foo/bar').status).to eq(200) + expect(last_response.body).to eq('true') + end + + it 'avoids executing filters even if prefix matches with other namespace' do + mock_app do + helpers do + def dump_args(*args) + args.inspect + end + end + + namespace '/foo' do + helpers do + def dump_args(*args) + super(:foo, *args) + end + end + get('') { dump_args } + end + + namespace '/foo-bar' do + helpers do + def dump_args(*args) + super(:foo_bar, *args) + end + end + get('') { dump_args } + end + end + + get '/foo-bar' + expect(last_response.body).to eq('[:foo_bar]') + end + end + + it 'forbids unknown engine settings' do + expect { + mock_app do + namespace '/foo' do + set :unknownsetting + end + end + }.to raise_error(ArgumentError, 'may not set unknownsetting') + end +end diff --git a/sinatra-contrib/spec/okjson.rb b/sinatra-contrib/spec/okjson.rb new file mode 100644 index 0000000000..65b6072510 --- /dev/null +++ b/sinatra-contrib/spec/okjson.rb @@ -0,0 +1,581 @@ +# Copyright 2011 Keith Rarick +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +# See https://github.com/kr/okjson for updates. + +require 'stringio' + +# Some parts adapted from +# http://golang.org/src/pkg/json/decode.go and +# http://golang.org/src/pkg/utf8/utf8.go +module OkJson + extend self + + + # Decodes a json document in string s and + # returns the corresponding ruby value. + # String s must be valid UTF-8. If you have + # a string in some other encoding, convert + # it first. + # + # String values in the resulting structure + # will be UTF-8. + def decode(s) + ts = lex(s) + v, ts = textparse(ts) + if ts.length > 0 + raise Error, 'trailing garbage' + end + v + end + + + # Parses a "json text" in the sense of RFC 4627. + # Returns the parsed value and any trailing tokens. + # Note: this is almost the same as valparse, + # except that it does not accept atomic values. + def textparse(ts) + if ts.length < 0 + raise Error, 'empty' + end + + typ, _, val = ts[0] + case typ + when '{' then objparse(ts) + when '[' then arrparse(ts) + else + raise Error, "unexpected #{val.inspect}" + end + end + + + # Parses a "value" in the sense of RFC 4627. + # Returns the parsed value and any trailing tokens. + def valparse(ts) + if ts.length < 0 + raise Error, 'empty' + end + + typ, _, val = ts[0] + case typ + when '{' then objparse(ts) + when '[' then arrparse(ts) + when :val,:str then [val, ts[1..-1]] + else + raise Error, "unexpected #{val.inspect}" + end + end + + + # Parses an "object" in the sense of RFC 4627. + # Returns the parsed value and any trailing tokens. + def objparse(ts) + ts = eat('{', ts) + obj = {} + + if ts[0][0] == '}' + return obj, ts[1..-1] + end + + k, v, ts = pairparse(ts) + obj[k] = v + + if ts[0][0] == '}' + return obj, ts[1..-1] + end + + loop do + ts = eat(',', ts) + + k, v, ts = pairparse(ts) + obj[k] = v + + if ts[0][0] == '}' + return obj, ts[1..-1] + end + end + end + + + # Parses a "member" in the sense of RFC 4627. + # Returns the parsed values and any trailing tokens. + def pairparse(ts) + (typ, _, k), ts = ts[0], ts[1..-1] + if typ != :str + raise Error, "unexpected #{k.inspect}" + end + ts = eat(':', ts) + v, ts = valparse(ts) + [k, v, ts] + end + + + # Parses an "array" in the sense of RFC 4627. + # Returns the parsed value and any trailing tokens. + def arrparse(ts) + ts = eat('[', ts) + arr = [] + + if ts[0][0] == ']' + return arr, ts[1..-1] + end + + v, ts = valparse(ts) + arr << v + + if ts[0][0] == ']' + return arr, ts[1..-1] + end + + loop do + ts = eat(',', ts) + + v, ts = valparse(ts) + arr << v + + if ts[0][0] == ']' + return arr, ts[1..-1] + end + end + end + + + def eat(typ, ts) + if ts[0][0] != typ + raise Error, "expected #{typ} (got #{ts[0].inspect})" + end + ts[1..-1] + end + + + # Sans s and returns a list of json tokens, + # excluding white space (as defined in RFC 4627). + def lex(s) + ts = [] + while s.length > 0 + typ, lexeme, val = tok(s) + if typ == nil + raise Error, "invalid character at #{s[0,10].inspect}" + end + if typ != :space + ts << [typ, lexeme, val] + end + s = s[lexeme.length..-1] + end + ts + end + + + # Scans the first token in s and + # returns a 3-element list, or nil + # if no such token exists. + # + # The first list element is one of + # '{', '}', ':', ',', '[', ']', + # :val, :str, and :space. + # + # The second element is the lexeme. + # + # The third element is the value of the + # token for :val and :str, otherwise + # it is the lexeme. + def tok(s) + case s[0] + when ?{ then ['{', s[0,1], s[0,1]] + when ?} then ['}', s[0,1], s[0,1]] + when ?: then [':', s[0,1], s[0,1]] + when ?, then [',', s[0,1], s[0,1]] + when ?[ then ['[', s[0,1], s[0,1]] + when ?] then [']', s[0,1], s[0,1]] + when ?n then nulltok(s) + when ?t then truetok(s) + when ?f then falsetok(s) + when ?" then strtok(s) + when Spc then [:space, s[0,1], s[0,1]] + when ?\t then [:space, s[0,1], s[0,1]] + when ?\n then [:space, s[0,1], s[0,1]] + when ?\r then [:space, s[0,1], s[0,1]] + else numtok(s) + end + end + + + def nulltok(s); s[0,4] == 'null' && [:val, 'null', nil] end + def truetok(s); s[0,4] == 'true' && [:val, 'true', true] end + def falsetok(s); s[0,5] == 'false' && [:val, 'false', false] end + + + def numtok(s) + m = /-?([1-9][0-9]+|[0-9])([.][0-9]+)?([eE][+-]?[0-9]+)?/.match(s) + if m && m.begin(0) == 0 + if m[3] && !m[2] + [:val, m[0], Integer(m[1])*(10**Integer(m[3][1..-1]))] + elsif m[2] + [:val, m[0], Float(m[0])] + else + [:val, m[0], Integer(m[0])] + end + end + end + + + def strtok(s) + m = /"([^"\\]|\\["\/\\bfnrt]|\\u[0-9a-fA-F]{4})*"/.match(s) + if ! m + raise Error, "invalid string literal at #{abbrev(s)}" + end + [:str, m[0], unquote(m[0])] + end + + + def abbrev(s) + t = s[0,10] + p = t['`'] + t = t[0,p] if p + t = t + '...' if t.length < s.length + '`' + t + '`' + end + + + # Converts a quoted json string literal q into a UTF-8-encoded string. + # The rules are different than for Ruby, so we cannot use eval. + # Unquote will raise an error if q contains control characters. + def unquote(q) + q = q[1...-1] + a = q.dup # allocate a big enough string + r, w = 0, 0 + while r < q.length + c = q[r] + case true + when c == ?\\ + r += 1 + if r >= q.length + raise Error, "string literal ends with a \"\\\": \"#{q}\"" + end + + case q[r] + when ?",?\\,?/,?' + a[w] = q[r] + r += 1 + w += 1 + when ?b,?f,?n,?r,?t + a[w] = Unesc[q[r]] + r += 1 + w += 1 + when ?u + r += 1 + uchar = begin + hexdec4(q[r,4]) + rescue RuntimeError => e + raise Error, "invalid escape sequence \\u#{q[r,4]}: #{e}" + end + r += 4 + if surrogate? uchar + if q.length >= r+6 + uchar1 = hexdec4(q[r+2,4]) + uchar = subst(uchar, uchar1) + if uchar != Ucharerr + # A valid pair; consume. + r += 6 + end + end + end + w += ucharenc(a, w, uchar) + else + raise Error, "invalid escape char #{q[r]} in \"#{q}\"" + end + when c == ?", c < Spc + raise Error, "invalid character in string literal \"#{q}\"" + else + # Copy anything else byte-for-byte. + # Valid UTF-8 will remain valid UTF-8. + # Invalid UTF-8 will remain invalid UTF-8. + a[w] = c + r += 1 + w += 1 + end + end + a[0,w] + end + + + # Encodes unicode character u as UTF-8 + # bytes in string a at position i. + # Returns the number of bytes written. + def ucharenc(a, i, u) + case true + when u <= Uchar1max + a[i] = (u & 0xff).chr + 1 + when u <= Uchar2max + a[i+0] = (Utag2 | ((u>>6)&0xff)).chr + a[i+1] = (Utagx | (u&Umaskx)).chr + 2 + when u <= Uchar3max + a[i+0] = (Utag3 | ((u>>12)&0xff)).chr + a[i+1] = (Utagx | ((u>>6)&Umaskx)).chr + a[i+2] = (Utagx | (u&Umaskx)).chr + 3 + else + a[i+0] = (Utag4 | ((u>>18)&0xff)).chr + a[i+1] = (Utagx | ((u>>12)&Umaskx)).chr + a[i+2] = (Utagx | ((u>>6)&Umaskx)).chr + a[i+3] = (Utagx | (u&Umaskx)).chr + 4 + end + end + + + def hexdec4(s) + if s.length != 4 + raise Error, 'short' + end + (nibble(s[0])<<12) | (nibble(s[1])<<8) | (nibble(s[2])<<4) | nibble(s[3]) + end + + + def subst(u1, u2) + if Usurr1 <= u1 && u1 < Usurr2 && Usurr2 <= u2 && u2 < Usurr3 + return ((u1-Usurr1)<<10) | (u2-Usurr2) + Usurrself + end + return Ucharerr + end + + + def unsubst(u) + if u < Usurrself || u > Umax || surrogate?(u) + return Ucharerr, Ucharerr + end + u -= Usurrself + [Usurr1 + ((u>>10)&0x3ff), Usurr2 + (u&0x3ff)] + end + + + def surrogate?(u) + Usurr1 <= u && u < Usurr3 + end + + + def nibble(c) + case true + when ?0 <= c && c <= ?9 then c.ord - ?0.ord + when ?a <= c && c <= ?z then c.ord - ?a.ord + 10 + when ?A <= c && c <= ?Z then c.ord - ?A.ord + 10 + else + raise Error, "invalid hex code #{c}" + end + end + + + # Encodes x into a json text. It may contain only + # Array, Hash, String, Numeric, true, false, nil. + # (Note, this list excludes Symbol.) + # X itself must be an Array or a Hash. + # No other value can be encoded, and an error will + # be raised if x contains any other value, such as + # Nan, Infinity, Symbol, and Proc, or if a Hash key + # is not a String. + # Strings contained in x must be valid UTF-8. + def encode(x) + case x + when Hash then objenc(x) + when Array then arrenc(x) + else + raise Error, 'root value must be an Array or a Hash' + end + end + + + def valenc(x) + case x + when Hash then objenc(x) + when Array then arrenc(x) + when String then strenc(x) + when Numeric then numenc(x) + when true then "true" + when false then "false" + when nil then "null" + else + raise Error, "cannot encode #{x.class}: #{x.inspect}" + end + end + + + def objenc(x) + '{' + x.map{|k,v| keyenc(k) + ':' + valenc(v)}.join(',') + '}' + end + + + def arrenc(a) + '[' + a.map{|x| valenc(x)}.join(',') + ']' + end + + + def keyenc(k) + case k + when String then strenc(k) + else + raise Error, "Hash key is not a string: #{k.inspect}" + end + end + + + def strenc(s) + t = StringIO.new + t.putc(?") + r = 0 + while r < s.length + case s[r] + when ?" then t.print('\\"') + when ?\\ then t.print('\\\\') + when ?\b then t.print('\\b') + when ?\f then t.print('\\f') + when ?\n then t.print('\\n') + when ?\r then t.print('\\r') + when ?\t then t.print('\\t') + else + c = s[r] + case true + when Spc <= c && c <= ?~ + t.putc(c) + when true + u, size = uchardec(s, r) + r += size - 1 # we add one more at the bottom of the loop + if u < 0x10000 + t.print('\\u') + hexenc4(t, u) + else + u1, u2 = unsubst(u) + t.print('\\u') + hexenc4(t, u1) + t.print('\\u') + hexenc4(t, u2) + end + else + # invalid byte; skip it + end + end + r += 1 + end + t.putc(?") + t.string + end + + + def hexenc4(t, u) + t.putc(Hex[(u>>12)&0xf]) + t.putc(Hex[(u>>8)&0xf]) + t.putc(Hex[(u>>4)&0xf]) + t.putc(Hex[u&0xf]) + end + + + def numenc(x) + if x.nan? || x.infinite? + return 'null' + end rescue nil + "#{x}" + end + + + # Decodes unicode character u from UTF-8 + # bytes in string s at position i. + # Returns u and the number of bytes read. + def uchardec(s, i) + n = s.length - i + return [Ucharerr, 1] if n < 1 + + c0 = s[i].ord + + # 1-byte, 7-bit sequence? + if c0 < Utagx + return [c0, 1] + end + + # unexpected continuation byte? + return [Ucharerr, 1] if c0 < Utag2 + + # need continuation byte + return [Ucharerr, 1] if n < 2 + c1 = s[i+1].ord + return [Ucharerr, 1] if c1 < Utagx || Utag2 <= c1 + + # 2-byte, 11-bit sequence? + if c0 < Utag3 + u = (c0&Umask2)<<6 | (c1&Umaskx) + return [Ucharerr, 1] if u <= Uchar1max + return [u, 2] + end + + # need second continuation byte + return [Ucharerr, 1] if n < 3 + c2 = s[i+2].ord + return [Ucharerr, 1] if c2 < Utagx || Utag2 <= c2 + + # 3-byte, 16-bit sequence? + if c0 < Utag4 + u = (c0&Umask3)<<12 | (c1&Umaskx)<<6 | (c2&Umaskx) + return [Ucharerr, 1] if u <= Uchar2max + return [u, 3] + end + + # need third continuation byte + return [Ucharerr, 1] if n < 4 + c3 = s[i+3].ord + return [Ucharerr, 1] if c3 < Utagx || Utag2 <= c3 + + # 4-byte, 21-bit sequence? + if c0 < Utag5 + u = (c0&Umask4)<<18 | (c1&Umaskx)<<12 | (c2&Umaskx)<<6 | (c3&Umaskx) + return [Ucharerr, 1] if u <= Uchar3max + return [u, 4] + end + + return [Ucharerr, 1] + end + + + class Error < ::StandardError + end + + + Utagx = 0x80 # 1000 0000 + Utag2 = 0xc0 # 1100 0000 + Utag3 = 0xe0 # 1110 0000 + Utag4 = 0xf0 # 1111 0000 + Utag5 = 0xF8 # 1111 1000 + Umaskx = 0x3f # 0011 1111 + Umask2 = 0x1f # 0001 1111 + Umask3 = 0x0f # 0000 1111 + Umask4 = 0x07 # 0000 0111 + Uchar1max = (1<<7) - 1 + Uchar2max = (1<<11) - 1 + Uchar3max = (1<<16) - 1 + Ucharerr = 0xFFFD # unicode "replacement char" + Usurrself = 0x10000 + Usurr1 = 0xd800 + Usurr2 = 0xdc00 + Usurr3 = 0xe000 + Umax = 0x10ffff + + Spc = ' '[0] + Unesc = {?b=>?\b, ?f=>?\f, ?n=>?\n, ?r=>?\r, ?t=>?\t} + Hex = '0123456789abcdef' +end diff --git a/sinatra-contrib/spec/quiet_logger_spec.rb b/sinatra-contrib/spec/quiet_logger_spec.rb new file mode 100644 index 0000000000..92142e179b --- /dev/null +++ b/sinatra-contrib/spec/quiet_logger_spec.rb @@ -0,0 +1,34 @@ +require 'spec_helper' +require 'sinatra/quiet_logger' +require 'logger' + +RSpec.describe Sinatra::QuietLogger do + + it 'logs just paths not excluded' do + log = StringIO.new + logger = Logger.new(log) + mock_app do + use Rack::CommonLogger, logger + set :quiet_logger_prefixes, %w(quiet asset) + register Sinatra::QuietLogger + get('/log') { 'in log' } + get('/quiet') { 'not in log' } + end + + get('/log') + get('/quiet') + + str = log.string + expect(str).to include('GET /log') + expect(str).to_not include('GET /quiet') + end + + it 'warns about not setting quiet_logger_prefixes' do + expect { + mock_app do + register Sinatra::QuietLogger + end + }.to output("You need to specify the paths you wish to exclude from logging via `set :quiet_logger_prefixes, %w(images css fonts)`\n").to_stderr + end + +end diff --git a/sinatra-contrib/spec/reloader/app.rb.erb b/sinatra-contrib/spec/reloader/app.rb.erb new file mode 100644 index 0000000000..cd979fee57 --- /dev/null +++ b/sinatra-contrib/spec/reloader/app.rb.erb @@ -0,0 +1,40 @@ +class <%= name %> < <%= parent %> +<% if enable_reloader %> + register Sinatra::Reloader + enable :reloader +<% end %> +<% unless inline_templates.nil? %> + enable :inline_templates +<% end %> + +<% extensions.each do |extension| %> + register <%= extension %> +<% end %> + +<% middlewares.each do |middleware| %> + use <%= middleware %> +<% end %> + +<% filters.each do |filter| %> + <%= filter %> +<% end %> + +<% errors.each do |number, code| %> + error <%= number %> do + <%= code %> + end +<% end %> + +<% routes.each do |route| %> + <%= route %> +<% end %> +end + +<% unless inline_templates.nil? %> +__END__ + +<% inline_templates.each_pair do |name, content| %> +@@<%= name %> +<%= content %> +<% end %> +<% end %> diff --git a/sinatra-contrib/spec/reloader_spec.rb b/sinatra-contrib/spec/reloader_spec.rb new file mode 100644 index 0000000000..8d98a3e19d --- /dev/null +++ b/sinatra-contrib/spec/reloader_spec.rb @@ -0,0 +1,497 @@ +require 'spec_helper' +require 'fileutils' + +RSpec.describe Sinatra::Reloader do + # Returns the temporary directory. + def tmp_dir + File.expand_path('../tmp', __dir__) + end + + # Returns the path of the Sinatra application file created by + # +setup_example_app+. + def app_file_path + File.join(tmp_dir, "example_app_#{$example_app_counter}.rb") + end + + # Returns the name of the Sinatra application created by + # +setup_example_app+: 'ExampleApp1' for the first application, + # 'ExampleApp2' fo the second one, and so on... + def app_name + "ExampleApp#{$example_app_counter}" + end + + # Returns the (constant of the) Sinatra application created by + # +setup_example_app+. + def app_const + Module.const_get(app_name) + end + + # Writes a file with a Sinatra application using the template + # located at specs/reloader/app.rb.erb. It expects an + # +options+ hash, with an array of strings containing the + # application's routes (+:routes+ key), a hash with the inline + # template's names as keys and the bodys as values + # (+:inline_templates+ key) and an optional application name + # (+:name+) otherwise +app_name+ is used. + # + # It ensures to change the written file's mtime when it already + # exists. + def write_app_file(options={}) + options[:routes] ||= ['get("/foo") { erb :foo }'] + options[:inline_templates] ||= nil + options[:extensions] ||= [] + options[:middlewares] ||= [] + options[:filters] ||= [] + options[:errors] ||= {} + options[:name] ||= app_name + options[:enable_reloader] = true unless options[:enable_reloader] === false + options[:parent] ||= 'Sinatra::Base' + + update_file(app_file_path) do |f| + template_path = File.expand_path('reloader/app.rb.erb', __dir__) + template = Tilt.new(template_path, nil, :trim => '<>') + f.write template.render(Object.new, options) + end + end + + alias update_app_file write_app_file + + # It calls File.open(path, 'w', &block) all the times + # needed to change the file's mtime. + def update_file(path, &block) + original_mtime = File.exist?(path) ? File.mtime(path) : Time.at(0) + new_time = original_mtime + 1 + File.open(path, 'w', &block) + File.utime(new_time, new_time, path) + end + + # Writes a Sinatra application to a file, requires the file, sets + # the new application as the one being tested and enables the + # reloader. + def setup_example_app(options={}) + $example_app_counter ||= 0 + $example_app_counter += 1 + + FileUtils.mkdir_p(tmp_dir) + write_app_file(options) + $LOADED_FEATURES.delete app_file_path + require app_file_path + self.app = app_const + app_const.enable :reloader + end + + after(:all) { FileUtils.rm_rf(tmp_dir) } + + describe "default route reloading mechanism" do + before(:each) do + setup_example_app(:routes => ['get("/foo") { "foo" }']) + end + + it "doesn't mess up the application" do + expect(get('/foo').body).to eq('foo') + end + + it "knows when a route has been modified" do + update_app_file(:routes => ['get("/foo") { "bar" }']) + expect(get('/foo').body).to eq('bar') + end + + it "knows when a route has been added" do + update_app_file( + :routes => ['get("/foo") { "foo" }', 'get("/bar") { "bar" }'] + ) + expect(get('/foo').body).to eq('foo') + expect(get('/bar').body).to eq('bar') + end + + it "knows when a route has been removed" do + update_app_file(:routes => ['get("/bar") { "bar" }']) + expect(get('/foo').status).to eq(404) + end + + it "doesn't try to reload a removed file" do + update_app_file(:routes => ['get("/foo") { "i shall not be reloaded" }']) + FileUtils.rm app_file_path + expect(get('/foo').body.strip).to eq('foo') + end + end + + describe "default inline templates reloading mechanism" do + before(:each) do + setup_example_app( + :routes => ['get("/foo") { erb :foo }'], + :inline_templates => { :foo => 'foo' } + ) + end + + it "doesn't mess up the application" do + expect(get('/foo').body.strip).to eq('foo') + end + + it "reloads inline templates in the app file" do + update_app_file( + :routes => ['get("/foo") { erb :foo }'], + :inline_templates => { :foo => 'bar' } + ) + expect(get('/foo').body.strip).to eq('bar') + end + + it "reloads inline templates in other file" do + setup_example_app(:routes => ['get("/foo") { erb :foo }']) + template_file_path = File.join(tmp_dir, 'templates.rb') + File.open(template_file_path, 'w') do |f| + f.write "__END__\n\n@@foo\nfoo" + end + require template_file_path + app_const.inline_templates= template_file_path + expect(get('/foo').body.strip).to eq('foo') + update_file(template_file_path) do |f| + f.write "__END__\n\n@@foo\nbar" + end + expect(get('/foo').body.strip).to eq('bar') + end + end + + describe "default middleware reloading mechanism" do + it "knows when a middleware has been added" do + setup_example_app(:routes => ['get("/foo") { "foo" }']) + update_app_file( + :routes => ['get("/foo") { "foo" }'], + :middlewares => [Rack::Head] + ) + get('/foo') # ...to perform the reload + expect(app_const.middleware).not_to be_empty + end + + it "knows when a middleware has been removed" do + setup_example_app( + :routes => ['get("/foo") { "foo" }'], + :middlewares => [Rack::Head] + ) + update_app_file(:routes => ['get("/foo") { "foo" }']) + get('/foo') # ...to perform the reload + expect(app_const.middleware).to be_empty + end + end + + describe "default filter reloading mechanism" do + it "knows when a before filter has been added" do + setup_example_app(:routes => ['get("/foo") { "foo" }']) + expect { + update_app_file( + :routes => ['get("/foo") { "foo" }'], + :filters => ['before { @hi = "hi" }'] + ) + get('/foo') # ...to perform the reload + }.to change { app_const.filters[:before].size }.by(1) + end + + it "knows when an after filter has been added" do + setup_example_app(:routes => ['get("/foo") { "foo" }']) + expect { + update_app_file( + :routes => ['get("/foo") { "foo" }'], + :filters => ['after { @bye = "bye" }'] + ) + get('/foo') # ...to perform the reload + }.to change { app_const.filters[:after].size }.by(1) + end + + it "knows when a before filter has been removed" do + setup_example_app( + :routes => ['get("/foo") { "foo" }'], + :filters => ['before { @hi = "hi" }'] + ) + expect { + update_app_file(:routes => ['get("/foo") { "foo" }']) + get('/foo') # ...to perform the reload + }.to change { app_const.filters[:before].size }.by(-1) + end + + it "knows when an after filter has been removed" do + setup_example_app( + :routes => ['get("/foo") { "foo" }'], + :filters => ['after { @bye = "bye" }'] + ) + expect { + update_app_file(:routes => ['get("/foo") { "foo" }']) + get('/foo') # ...to perform the reload + }.to change { app_const.filters[:after].size }.by(-1) + end + end + + describe "error reloading" do + before do + setup_example_app( + :routes => ['get("/secret") { 403 }'], + :errors => { 403 => "'Access forbiden'" } + ) + end + + it "doesn't mess up the application" do + expect(get('/secret')).to be_client_error + expect(get('/secret').body.strip).to eq('Access forbiden') + end + + it "knows when a error has been added" do + update_app_file(:errors => { 404 => "'Nowhere'" }) + expect(get('/nowhere')).to be_not_found + expect(get('/nowhere').body).to eq('Nowhere') + end + + it "knows when a error has been removed" do + update_app_file(:routes => ['get("/secret") { 403 }']) + expect(get('/secret')).to be_client_error + expect(get('/secret').body).not_to eq('Access forbiden') + end + + it "knows when a error has been modified" do + update_app_file( + :routes => ['get("/secret") { 403 }'], + :errors => { 403 => "'What are you doing here?'" } + ) + expect(get('/secret')).to be_client_error + expect(get('/secret').body).to eq('What are you doing here?') + end + end + + describe "extension reloading" do + it "doesn't duplicate routes with every reload" do + module ::RouteExtension + def self.registered(klass) + klass.get('/bar') { 'bar' } + end + end + + setup_example_app( + :routes => ['get("/foo") { "foo" }'], + :extensions => ['RouteExtension'] + ) + + expect { + update_app_file( + :routes => ['get("/foo") { "foo" }'], + :extensions => ['RouteExtension'] + ) + get('/foo') # ...to perform the reload + }.to_not change { app_const.routes['GET'].size } + end + + it "doesn't duplicate middleware with every reload" do + module ::MiddlewareExtension + def self.registered(klass) + klass.use Rack::Head + end + end + + setup_example_app( + :routes => ['get("/foo") { "foo" }'], + :extensions => ['MiddlewareExtension'] + ) + + expect { + update_app_file( + :routes => ['get("/foo") { "foo" }'], + :extensions => ['MiddlewareExtension'] + ) + get('/foo') # ...to perform the reload + }.to_not change { app_const.middleware.size } + end + + it "doesn't duplicate before filters with every reload" do + module ::BeforeFilterExtension + def self.registered(klass) + klass.before { @hi = 'hi' } + end + end + + setup_example_app( + :routes => ['get("/foo") { "foo" }'], + :extensions => ['BeforeFilterExtension'] + ) + + expect { + update_app_file( + :routes => ['get("/foo") { "foo" }'], + :extensions => ['BeforeFilterExtension'] + ) + get('/foo') # ...to perform the reload + }.to_not change { app_const.filters[:before].size } + end + + it "doesn't duplicate after filters with every reload" do + module ::AfterFilterExtension + def self.registered(klass) + klass.after { @bye = 'bye' } + end + end + + setup_example_app( + :routes => ['get("/foo") { "foo" }'], + :extensions => ['AfterFilterExtension'] + ) + + expect { + update_app_file( + :routes => ['get("/foo") { "foo" }'], + :extensions => ['AfterFilterExtension'] + ) + get('/foo') # ...to perform the reload + }.to_not change { app_const.filters[:after].size } + end + end + + describe ".dont_reload" do + before(:each) do + setup_example_app( + :routes => ['get("/foo") { erb :foo }'], + :inline_templates => { :foo => 'foo' } + ) + end + + it "allows to specify a file to stop from being reloaded" do + app_const.dont_reload app_file_path + update_app_file(:routes => ['get("/foo") { "bar" }']) + expect(get('/foo').body.strip).to eq('foo') + end + + it "allows to specify a glob to stop matching files from being reloaded" do + app_const.dont_reload '**/*.rb' + update_app_file(:routes => ['get("/foo") { "bar" }']) + expect(get('/foo').body.strip).to eq('foo') + end + + it "doesn't interfere with other application's reloading policy" do + app_const.dont_reload '**/*.rb' + setup_example_app(:routes => ['get("/foo") { "foo" }']) + update_app_file(:routes => ['get("/foo") { "bar" }']) + expect(get('/foo').body.strip).to eq('bar') + end + end + + describe ".also_reload" do + before(:each) do + setup_example_app(:routes => ['get("/foo") { Foo.foo }']) + @foo_path = File.join(tmp_dir, 'foo.rb') + update_file(@foo_path) do |f| + f.write 'class Foo; def self.foo() "foo" end end' + end + $LOADED_FEATURES.delete @foo_path + require @foo_path + app_const.also_reload @foo_path + end + + it "allows to specify a file to be reloaded" do + expect(get('/foo').body.strip).to eq('foo') + update_file(@foo_path) do |f| + f.write 'class Foo; def self.foo() "bar" end end' + end + expect(get('/foo').body.strip).to eq('bar') + end + + it "allows to specify glob to reaload matching files" do + expect(get('/foo').body.strip).to eq('foo') + update_file(@foo_path) do |f| + f.write 'class Foo; def self.foo() "bar" end end' + end + expect(get('/foo').body.strip).to eq('bar') + end + + it "doesn't try to reload a removed file" do + update_file(@foo_path) do |f| + f.write 'class Foo; def self.foo() "bar" end end' + end + FileUtils.rm @foo_path + expect(get('/foo').body.strip).to eq('foo') + end + + it "doesn't interfere with other application's reloading policy" do + app_const.also_reload '**/*.rb' + setup_example_app(:routes => ['get("/foo") { Foo.foo }']) + expect(get('/foo').body.strip).to eq('foo') + update_file(@foo_path) do |f| + f.write 'class Foo; def self.foo() "bar" end end' + end + expect(get('/foo').body.strip).to eq('foo') + end + end + + describe ".after_reload" do + before(:each) do + $reloaded = nil + setup_example_app(:routes => ['get("/foo") { Foo.foo }']) + @foo_path = File.join(tmp_dir, 'foo.rb') + update_file(@foo_path) do |f| + f.write 'class Foo; def self.foo() "foo" end end' + end + $LOADED_FEATURES.delete @foo_path + require @foo_path + app_const.also_reload @foo_path + end + + it "allows block execution after reloading files" do + app_const.after_reload do |files| + $reloaded = files + end + expect($reloaded).to eq(nil) + expect(get('/foo').body.strip).to eq('foo') + expect($reloaded).to eq(nil) # after_reload was not called + update_file(@foo_path) do |f| + f.write 'class Foo; def self.foo() "bar" end end' + end + expect(get("/foo").body.strip).to eq("bar") # Makes the reload happen + expect($reloaded.size).to eq(1) + expect(File.basename($reloaded[0])).to eq(File.basename(@foo_path)) + end + + it "does not break block without input param" do + app_const.after_reload do + $reloaded = "worked without param" + end + expect($reloaded).to eq(nil) + expect(get('/foo').body.strip).to eq('foo') + expect($reloaded).to eq(nil) # after_reload was not called + update_file(@foo_path) do |f| + f.write 'class Foo; def self.foo() "bar" end end' + end + expect { get("/foo") }.to_not raise_error # Makes the reload happen + expect($reloaded).to eq("worked without param") + end + + it "handles lambdas with arity 0" do + user_proc = -> { $reloaded = "lambda?=true arity=0" } + expect { user_proc.call(1) }.to raise_error(ArgumentError) # What we avoid + app_const.after_reload(&user_proc) + expect($reloaded).to eq(nil) + expect(get('/foo').body.strip).to eq('foo') + expect($reloaded).to eq(nil) # after_reload was not called + update_file(@foo_path) do |f| + f.write 'class Foo; def self.foo() "bar" end end' + end + expect { get("/foo") }.to_not raise_error # Makes the reload happen + expect($reloaded).to eq("lambda?=true arity=0") + end + end + + it "automatically registers the reloader in the subclasses" do + class ::Parent < Sinatra::Base + register Sinatra::Reloader + enable :reloader + end + + setup_example_app( + :routes => ['get("/foo") { "foo" }'], + :enable_reloader => false, + :parent => 'Parent' + ) + + update_app_file( + :routes => ['get("/foo") { "bar" }'], + :enable_reloader => false, + :parent => 'Parent' + ) + + expect(get('/foo').body).to eq('bar') + end + +end diff --git a/sinatra-contrib/spec/required_params_spec.rb b/sinatra-contrib/spec/required_params_spec.rb new file mode 100644 index 0000000000..e539c72bc4 --- /dev/null +++ b/sinatra-contrib/spec/required_params_spec.rb @@ -0,0 +1,72 @@ +require_relative 'spec_helper' + +RSpec.describe Sinatra::RequiredParams do + context "#required_params" do + context "simple keys" do + before do + mock_app do + helpers Sinatra::RequiredParams + get('/') { required_params(:p1, :p2) } + end + end + it 'return 400 if required params do not exist' do + get('/') + expect(last_response.status).to eq(400) + end + it 'return 400 if required params do not exist partially' do + get('/', :p1 => 1) + expect(last_response.status).to eq(400) + end + it 'return 200 if required params exist' do + get('/', :p1 => 1, :p2 => 2) + expect(last_response.status).to eq(200) + end + it 'return 200 if required params exist with array' do + get('/', :p1 => 1, :p2 => [31, 32, 33]) + expect(last_response.status).to eq(200) + end + end + context "hash keys" do + before do + mock_app do + helpers Sinatra::RequiredParams + get('/') { required_params(:p1, :p2 => :p21) } + end + end + it 'return 400 if required params do not exist' do + get('/') + expect(last_response.status).to eq(400) + end + it 'return 200 if required params exist' do + get('/', :p1 => 1, :p2 => {:p21 => 21}) + expect(last_response.status).to eq(200) + end + it 'return 400 if p2 is not a hash' do + get('/', :p1 => 1, :p2 => 2) + expect(last_response.status).to eq(400) + end + end + context "complex keys" do + before do + mock_app do + helpers Sinatra::RequiredParams + get('/') { required_params(:p1 => [:p11, {:p12 => :p121, :p122 => [:p123, {:p124 => :p1241}]}]) } + end + end + it 'return 400 if required params do not exist' do + get('/') + expect(last_response.status).to eq(400) + end + it 'return 200 if required params exist' do + get('/', :p1 => {:p11 => 11, :p12 => {:p121 => 121}, :p122 => {:p123 => 123, :p124 => {:p1241 => 1241}}}) + expect(last_response.status).to eq(200) + end + end + end + + context "#_required_params" do + it "is invisible" do + expect { _required_params }.to raise_error(NameError) + end + end +end diff --git a/sinatra-contrib/spec/respond_with/bar.erb b/sinatra-contrib/spec/respond_with/bar.erb new file mode 100644 index 0000000000..fc971581f0 --- /dev/null +++ b/sinatra-contrib/spec/respond_with/bar.erb @@ -0,0 +1 @@ +guten Tag! \ No newline at end of file diff --git a/sinatra-contrib/spec/respond_with/bar.json.erb b/sinatra-contrib/spec/respond_with/bar.json.erb new file mode 100644 index 0000000000..37f123d033 --- /dev/null +++ b/sinatra-contrib/spec/respond_with/bar.json.erb @@ -0,0 +1 @@ +json! \ No newline at end of file diff --git a/sinatra-contrib/spec/respond_with/baz.yajl b/sinatra-contrib/spec/respond_with/baz.yajl new file mode 100644 index 0000000000..5d7e679e48 --- /dev/null +++ b/sinatra-contrib/spec/respond_with/baz.yajl @@ -0,0 +1 @@ +json = "yajl!" diff --git a/sinatra-contrib/spec/respond_with/foo.html.erb b/sinatra-contrib/spec/respond_with/foo.html.erb new file mode 100644 index 0000000000..534038b055 --- /dev/null +++ b/sinatra-contrib/spec/respond_with/foo.html.erb @@ -0,0 +1 @@ +Hello <%= name %>! \ No newline at end of file diff --git a/sinatra-contrib/spec/respond_with_spec.rb b/sinatra-contrib/spec/respond_with_spec.rb new file mode 100644 index 0000000000..9550358283 --- /dev/null +++ b/sinatra-contrib/spec/respond_with_spec.rb @@ -0,0 +1,311 @@ +require 'multi_json' + +require 'spec_helper' +require 'okjson' + +RSpec.describe Sinatra::RespondWith do + def respond_app(&block) + mock_app do + set :app_file, __FILE__ + set :views, root + '/respond_with' + register Sinatra::RespondWith + class_eval(&block) + end + end + + def respond_to(*args, &block) + respond_app { get('/') { respond_to(*args, &block) } } + end + + def respond_with(*args, &block) + respond_app { get('/') { respond_with(*args, &block) } } + end + + def req(*types) + path = types.shift if types.first.is_a?(String) && types.first.start_with?('/') + accept = types.map { |t| Sinatra::Base.mime_type(t).to_s }.join ',' + get (path || '/'), {}, 'HTTP_ACCEPT' => accept + end + + describe "Helpers#respond_to" do + it 'allows defining handlers by file extensions' do + respond_to do |format| + format.html { "html!" } + format.json { "json!" } + end + + expect(req(:html).body).to eq("html!") + expect(req(:json).body).to eq("json!") + end + + it 'respects quality' do + respond_to do |format| + format.html { "html!" } + format.json { "json!" } + end + + expect(req("text/html;q=0.7, application/json;q=0.3").body).to eq("html!") + expect(req("text/html;q=0.3, application/json;q=0.7").body).to eq("json!") + end + + it 'allows using mime types' do + respond_to do |format| + format.on('text/html') { "html!" } + format.json { "json!" } + end + + expect(req(:html).body).to eq("html!") + end + + it 'allows using wildcards in format matchers' do + respond_to do |format| + format.on('text/*') { "text!" } + format.json { "json!" } + end + + expect(req(:html).body).to eq("text!") + end + + it 'allows using catch all wildcards in format matchers' do + respond_to do |format| + format.on('*/*') { "anything!" } + format.json { "json!" } + end + + expect(req(:html).body).to eq("anything!") + end + + it 'prefers concret over generic' do + respond_to do |format| + format.on('text/*') { "text!" } + format.on('*/*') { "anything!" } + format.json { "json!" } + end + + expect(req(:json).body).to eq("json!") + expect(req(:html).body).to eq("text!") + end + + it 'does not set up default handlers' do + respond_to + expect(req).not_to be_ok + expect(status).to eq(500) + expect(body).to eq("Unknown template engine") + end + end + + describe "Helpers#respond_with" do + describe "matching" do + it 'allows defining handlers by file extensions' do + respond_with(:ignore) do |format| + format.html { "html!" } + format.json { "json!" } + end + + expect(req(:html).body).to eq("html!") + expect(req(:json).body).to eq("json!") + end + + it 'respects quality' do + respond_with(:ignore) do |format| + format.html { "html!" } + format.json { "json!" } + end + + expect(req("text/html;q=0.7, application/json;q=0.3").body).to eq("html!") + expect(req("text/html;q=0.3, application/json;q=0.7").body).to eq("json!") + end + + it 'allows using mime types' do + respond_with(:ignore) do |format| + format.on('text/html') { "html!" } + format.json { "json!" } + end + + expect(req(:html).body).to eq("html!") + end + + it 'allows using wildcards in format matchers' do + respond_with(:ignore) do |format| + format.on('text/*') { "text!" } + format.json { "json!" } + end + + expect(req(:html).body).to eq("text!") + end + + it 'allows using catch all wildcards in format matchers' do + respond_with(:ignore) do |format| + format.on('*/*') { "anything!" } + format.json { "json!" } + end + + expect(req(:html).body).to eq("anything!") + end + + it 'prefers concret over generic' do + respond_with(:ignore) do |format| + format.on('text/*') { "text!" } + format.on('*/*') { "anything!" } + format.json { "json!" } + end + + expect(req(:json).body).to eq("json!") + expect(req(:html).body).to eq("text!") + end + end + + describe "default behavior" do + it 'converts objects to json out of the box' do + respond_with 'a' => 'b' + expect(OkJson.decode(req(:json).body)).to eq({'a' => 'b'}) + end + + it 'handles multiple routes correctly' do + respond_app do + get('/') { respond_with 'a' => 'b' } + get('/:name') { respond_with 'a' => params[:name] } + end + expect(OkJson.decode(req('/', :json).body)).to eq({'a' => 'b'}) + expect(OkJson.decode(req('/b', :json).body)).to eq({'a' => 'b'}) + expect(OkJson.decode(req('/c', :json).body)).to eq({'a' => 'c'}) + end + + it "calls to_EXT if available" do + respond_with Struct.new(:to_pdf).new("hello") + expect(req(:pdf).body).to eq("hello") + end + + it 'results in a 500 if format cannot be produced' do + respond_with({}) + expect(req(:html)).not_to be_ok + expect(status).to eq(500) + expect(body).to eq("Unknown template engine") + end + end + + describe 'templates' do + it 'looks for templates with name.target.engine' do + respond_with :foo, :name => 'World' + expect(req(:html)).to be_ok + expect(body).to eq("Hello World!") + end + + it 'looks for templates with name.engine for specific engines' do + respond_with :bar + expect(req(:html)).to be_ok + expect(body).to eq("guten Tag!") + end + + it 'does not use name.engine for engines producing other formats' do + respond_with :not_html + expect(req(:html)).not_to be_ok + expect(status).to eq(500) + expect(body).to eq("Unknown template engine") + end + + it 'falls back to #json if no template is found' do + respond_with :foo, :name => 'World' + expect(req(:json)).to be_ok + expect(OkJson.decode(body)).to eq({'name' => 'World'}) + end + + it 'favors templates over #json' do + respond_with :bar, :name => 'World' + expect(req(:json)).to be_ok + expect(body).to eq('json!') + end + + it 'falls back to to_EXT if no template is found' do + object = {:name => 'World'} + def object.to_pdf; "hi" end + respond_with :foo, object + expect(req(:pdf)).to be_ok + expect(body).to eq("hi") + end + + unless defined? JRUBY_VERSION + it 'uses yajl for json' do + respond_with :baz + expect(req(:json)).to be_ok + expect(body).to eq("\"yajl!\"") + end + end + end + + describe 'customizing' do + it 'allows customizing' do + respond_with(:foo, :name => 'World') { |f| f.html { 'html!' }} + expect(req(:html)).to be_ok + expect(body).to eq("html!") + end + + it 'falls back to default behavior if none matches' do + respond_with(:foo, :name => 'World') { |f| f.json { 'json!' }} + expect(req(:html)).to be_ok + expect(body).to eq("Hello World!") + end + + it 'favors generic rule over default behavior' do + respond_with(:foo, :name => 'World') { |f| f.on('*/*') { 'generic!' }} + expect(req(:html)).to be_ok + expect(body).to eq("generic!") + end + end + + describe "inherited" do + it "registers RespondWith in an inherited app" do + app = Sinatra.new do + set :app_file, __FILE__ + set :views, root + '/respond_with' + register Sinatra::RespondWith + + get '/a' do + respond_with :json + end + end + + self.app = Sinatra.new(app) + expect(req('/a', :json)).not_to be_ok + end + end + end + + describe :respond_to do + it 'acts as global provides condition' do + respond_app do + respond_to :json, :html + get('/a') { 'ok' } + get('/b') { 'ok' } + end + + expect(req('/b', :xml)).not_to be_ok + expect(req('/b', :html)).to be_ok + end + + it 'still allows provides' do + respond_app do + respond_to :json, :html + get('/a') { 'ok' } + get('/b', :provides => :json) { 'ok' } + end + + expect(req('/b', :html)).not_to be_ok + expect(req('/b', :json)).to be_ok + end + + it 'plays well with namespaces' do + respond_app do + register Sinatra::Namespace + namespace '/a' do + respond_to :json + get { 'json' } + end + get('/b') { 'anything' } + end + + expect(req('/a', :html)).not_to be_ok + expect(req('/b', :html)).to be_ok + end + end +end diff --git a/sinatra-contrib/spec/spec_helper.rb b/sinatra-contrib/spec/spec_helper.rb new file mode 100644 index 0000000000..6447df3346 --- /dev/null +++ b/sinatra-contrib/spec/spec_helper.rb @@ -0,0 +1,83 @@ +ENV['RACK_ENV'] = 'test' +require 'sinatra/contrib' + +# This file was generated by the `rspec --init` command. Conventionally, all +# specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. +# The generated `.rspec` file contains `--require spec_helper` which will cause this +# file to always be loaded, without a need to explicitly require it in any files. +# +# Given that it is always loaded, you are encouraged to keep this file as +# light-weight as possible. Requiring heavyweight dependencies from this file +# will add to the boot time of your test suite on EVERY test run, even for an +# individual file that may not need all of that loaded. Instead, make a +# separate helper file that requires this one and then use it only in the specs +# that actually need it. +# +# The `.rspec` file also contains a few flags that are not defaults but that +# users commonly want. +# +# See https://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration +RSpec.configure do |config| + # The settings below are suggested to provide a good initial experience + # with RSpec, but feel free to customize to your heart's content. + config.disable_monkey_patching! + + # These two settings work together to allow you to limit a spec run + # to individual examples or groups you care about by tagging them with + # `:focus` metadata. When nothing is tagged with `:focus`, all examples + # get run. + config.filter_run :focus + config.run_all_when_everything_filtered = true + + # Many RSpec users commonly either run the entire suite or an individual + # file, and it's useful to allow more verbose output when running an + # individual spec file. + if config.files_to_run.one? + # Use the documentation formatter for detailed output, + # unless a formatter has already been configured + # (e.g. via a command-line flag). + config.default_formatter = 'doc' + end + + # Print the 10 slowest examples and example groups at the + # end of the spec run, to help surface which specs are running + # particularly slow. + config.profile_examples = 5 + + # Run specs in random order to surface order dependencies. If you find an + # order dependency and want to debug it, you can fix the order by providing + # the seed, which is printed after each run. + # --seed 1234 + config.order = :random + + # Seed global randomization in this process using the `--seed` CLI option. + # Setting this allows you to use `--seed` to deterministically reproduce + # test failures related to randomization by passing the same `--seed` value + # as the one that triggered the failure. + Kernel.srand config.seed + + # rspec-expectations config goes here. You can use an alternate + # assertion/expectation library such as wrong or the stdlib/minitest + # assertions if you prefer. + config.expect_with :rspec do |expectations| + # Enable only the newer, non-monkey-patching expect syntax. + # For more details, see: + # - http://myronmars.to/n/dev-blog/2012/06/rspecs-new-expectation-syntax + expectations.syntax = :expect + end + + # rspec-mocks config goes here. You can use an alternate test double + # library (such as bogus or mocha) by changing the `mock_with` option here. + config.mock_with :rspec do |mocks| + # Enable only the newer, non-monkey-patching expect syntax. + # For more details, see: + # - http://teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/ + mocks.syntax = :expect + + # Prevents you from mocking or stubbing a method that does not exist on + # a real object. This is generally recommended. + mocks.verify_partial_doubles = true + end + + config.include Sinatra::TestHelpers +end diff --git a/sinatra-contrib/spec/streaming_spec.rb b/sinatra-contrib/spec/streaming_spec.rb new file mode 100644 index 0000000000..eacef01858 --- /dev/null +++ b/sinatra-contrib/spec/streaming_spec.rb @@ -0,0 +1,414 @@ +require 'spec_helper' + +RSpec.describe Sinatra::Streaming do + def stream(&block) + rack_middleware = @use + out = nil + mock_app do + rack_middleware.each { |args| use(*args) } + helpers Sinatra::Streaming + get('/') { out = stream(&block) } + end + get('/') + out + end + + def use(*args) + @use << args + end + + before do + @use = [] + end + + context 'stream test helper' do + it 'runs the given block' do + ran = false + stream { ran = true } + expect(ran).to be true + end + + it 'returns the stream object' do + out = stream { } + expect(out).to be_a(Sinatra::Helpers::Stream) + end + + it 'fires a request against that stream' do + stream { |out| out << "Hello World!" } + expect(last_response).to be_ok + expect(body).to eq("Hello World!") + end + + it 'passes the stream object to the block' do + passed = nil + returned = stream { |out| passed = out } + expect(passed).to eq(returned) + end + end + + context Sinatra::Streaming::Stream do + it 'should extend the stream object' do + out = stream { } + expect(out).to be_a(Sinatra::Streaming::Stream) + end + + it 'should not extend stream objects of other apps' do + out = nil + mock_app { get('/') { out = stream { }}} + get('/') + expect(out).to be_a(Sinatra::Helpers::Stream) + expect(out).not_to be_a(Sinatra::Streaming::Stream) + end + end + + context 'app' do + it 'is the app instance the stream was created from' do + out = stream { } + expect(out.app).to be_a(Sinatra::Base) + end + end + + context 'lineno' do + it 'defaults to 0' do + expect(stream { }.lineno).to eq(0) + end + + it 'does not increase on write' do + stream do |out| + out << "many\nlines\n" + expect(out.lineno).to eq(0) + end + end + + it 'is writable' do + out = stream { } + out.lineno = 10 + expect(out.lineno).to eq(10) + end + end + + context 'pos' do + it 'defaults to 0' do + expect(stream { }.pos).to eq(0) + end + + it 'increases when writing data' do + stream do |out| + expect(out.pos).to eq(0) + out << 'hi' + expect(out.pos).to eq(2) + end + end + + it 'is writable' do + out = stream { } + out.pos = 10 + expect(out.pos).to eq(10) + end + + it 'aliased to #tell' do + out = stream { } + expect(out.tell).to eq(0) + out.pos = 10 + expect(out.tell).to eq(10) + end + end + + context 'closed' do + it 'returns false while streaming' do + stream { |out| expect(out).not_to be_closed } + end + + it 'returns true after streaming' do + expect(stream {}).to be_closed + end + end + + context 'map!' do + it 'applies transformations later' do + stream do |out| + out.map! { |s| s.upcase } + out << 'ok' + end + expect(body).to eq("OK") + end + + it 'is chainable' do + stream do |out| + out.map! { |s| s.upcase } + out.map! { |s| s.reverse } + out << 'ok' + end + expect(body).to eq("KO") + end + + it 'works with middleware' do + middleware = Class.new do + def initialize(app) @app = app end + def call(env) + status, headers, body = @app.call(env) + body.map! { |s| s.upcase } + [status, headers, body] + end + end + + use middleware + stream { |out| out << "ok" } + expect(body).to eq("OK") + end + + it 'modifies each value separately' do + stream do |out| + out.map! { |s| s.reverse } + out << "ab" << "cd" + end + expect(body).to eq("badc") + end + end + + context 'map' do + it 'works with middleware' do + middleware = Class.new do + def initialize(app) @app = app end + def call(env) + status, headers, body = @app.call(env) + [status, headers, body.map(&:upcase)] + end + end + + use middleware + stream { |out| out << "ok" } + expect(body).to eq("OK") + end + + it 'is chainable' do + middleware = Class.new do + def initialize(app) @app = app end + def call(env) + status, headers, body = @app.call(env) + [status, headers, body.map(&:upcase).map(&:reverse)] + end + end + + use middleware + stream { |out| out << "ok" } + expect(body).to eq("KO") + end + + it 'can be written as each.map' do + middleware = Class.new do + def initialize(app) @app = app end + def call(env) + status, headers, body = @app.call(env) + [status, headers, body.each.map(&:upcase)] + end + end + + use middleware + stream { |out| out << "ok" } + expect(body).to eq("OK") + end + + it 'does not modify the original body' do + stream do |out| + out.map { |s| s.reverse } + out << 'ok' + end + expect(body).to eq('ok') + end + end + + context 'write' do + it 'writes to the stream' do + stream { |out| out.write 'hi' } + expect(body).to eq('hi') + end + + it 'returns the number of bytes' do + stream do |out| + expect(out.write('hi')).to eq(2) + expect(out.write('hello')).to eq(5) + end + end + + it 'accepts non-string objects' do + stream do |out| + expect(out.write(12)).to eq(2) + end + end + + it 'should be aliased to syswrite' do + stream { |out| expect(out.syswrite('hi')).to eq(2) } + expect(body).to eq('hi') + end + + it 'should be aliased to write_nonblock' do + stream { |out| expect(out.write_nonblock('hi')).to eq(2) } + expect(body).to eq('hi') + end + end + + context 'print' do + it 'writes to the stream' do + stream { |out| out.print('hi') } + expect(body).to eq('hi') + end + + it 'accepts multiple arguments' do + stream { |out| out.print(1, 2, 3, 4) } + expect(body).to eq('1234') + end + + it 'returns nil' do + stream { |out| expect(out.print('hi')).to be_nil } + end + end + + context 'printf' do + it 'writes to the stream' do + stream { |out| out.printf('hi') } + expect(body).to eq('hi') + end + + it 'interpolates the format string' do + stream { |out| out.printf("%s: %d", "answer", 42) } + expect(body).to eq('answer: 42') + end + + it 'returns nil' do + stream { |out| expect(out.printf('hi')).to be_nil } + end + end + + context 'putc' do + it 'writes the first character of a string' do + stream { |out| out.putc('hi') } + expect(body).to eq('h') + end + + it 'writes the character corresponding to an integer' do + stream { |out| out.putc(42) } + expect(body).to eq('*') + end + + it 'returns nil' do + stream { |out| expect(out.putc('hi')).to be_nil } + end + end + + context 'puts' do + it 'writes to the stream' do + stream { |out| out.puts('hi') } + expect(body).to eq("hi\n") + end + + it 'accepts multiple arguments' do + stream { |out| out.puts(1, 2, 3, 4) } + expect(body).to eq("1\n2\n3\n4\n") + end + + it 'returns nil' do + stream { |out| expect(out.puts('hi')).to be_nil } + end + end + + context 'close' do + it 'sets #closed? to true' do + stream do |out| + out.close + expect(out).to be_closed + end + end + + it 'sets #closed_write? to true' do + stream do |out| + expect(out).not_to be_closed_write + out.close + expect(out).to be_closed_write + end + end + + it 'fires callbacks' do + stream do |out| + fired = false + out.callback { fired = true } + out.close + expect(fired).to be true + end + end + + it 'prevents from further writing' do + stream do |out| + out.close + expect { out << 'hi' }.to raise_error(IOError, 'not opened for writing') + end + end + end + + context 'close_read' do + it 'raises the appropriate exception' do + expect { stream { |out| out.close_read }}. + to raise_error(IOError, "closing non-duplex IO for reading") + end + end + + context 'closed_read?' do + it('returns true') { stream { |out| expect(out).to be_closed_read }} + end + + context 'rewind' do + it 'resets pos' do + stream do |out| + out << 'hi' + out.rewind + expect(out.pos).to eq(0) + end + end + + it 'resets lineno' do + stream do |out| + out.lineno = 10 + out.rewind + expect(out.lineno).to eq(0) + end + end + end + + raises = %w[ + bytes eof? eof getbyte getc gets read read_nonblock readbyte readchar + readline readlines readpartial sysread ungetbyte ungetc + ] + + enum = %w[chars each_line each_byte each_char lines] + dummies = %w[flush fsync internal_encoding pid] + + raises.each do |method| + context method do + it 'raises the appropriate exception' do + expect { stream { |out| out.public_send(method) }}. + to raise_error(IOError, "not opened for reading") + end + end + end + + enum.each do |method| + context method do + it 'creates an Enumerator' do + stream { |out| expect(out.public_send(method)).to be_a(Enumerator) } + end + + it 'calling each raises the appropriate exception' do + expect { stream { |out| out.public_send(method).each { }}}. + to raise_error(IOError, "not opened for reading") + end + end + end + + dummies.each do |method| + context method do + it 'returns nil' do + stream { |out| expect(out.public_send(method)).to be_nil } + end + end + end +end diff --git a/sinatra.gemspec b/sinatra.gemspec index d28e980ab9..45c93b05c7 100644 --- a/sinatra.gemspec +++ b/sinatra.gemspec @@ -1,84 +1,54 @@ -Gem::Specification.new do |s| - s.specification_version = 2 if s.respond_to? :specification_version= - s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version= +# frozen_string_literal: true - s.name = 'sinatra' - s.version = '0.10.1' - s.date = '2009-10-08' +version = File.read(File.expand_path('VERSION', __dir__)).strip - s.description = "Classy web-development dressed in a DSL" - s.summary = "Classy web-development dressed in a DSL" +Gem::Specification.new 'sinatra', version do |s| + s.description = 'Sinatra is a DSL for quickly creating web applications in Ruby with minimal effort.' + s.summary = 'Classy web-development dressed in a DSL' + s.authors = ['Blake Mizerany', 'Ryan Tomayko', 'Simon Rozet', 'Konstantin Haase'] + s.email = 'sinatrarb@googlegroups.com' + s.homepage = 'http://sinatrarb.com/' + s.license = 'MIT' + s.files = Dir['README*.md', 'lib/**/*', 'examples/*'] + [ + '.yardopts', + 'AUTHORS.md', + 'CHANGELOG.md', + 'CONTRIBUTING.md', + 'Gemfile', + 'LICENSE', + 'MAINTENANCE.md', + 'Rakefile', + 'SECURITY.md', + 'sinatra.gemspec', + 'VERSION' + ] + s.extra_rdoc_files = %w[README.md LICENSE] + s.rdoc_options = %w[--line-numbers --title Sinatra --main README.rdoc --encoding=UTF-8] - s.authors = ["Blake Mizerany", "Ryan Tomayko", "Simon Rozet"] - s.email = "sinatrarb@googlegroups.com" + unless s.respond_to?(:metadata) + raise <<-WARN +RubyGems 2.0 or newer is required to protect against public gem pushes. You can update your rubygems version by running: + gem install rubygems-update + update_rubygems: + gem update --system + WARN + end - # = MANIFEST = - s.files = %w[ - AUTHORS - CHANGES - LICENSE - README.jp.rdoc - README.rdoc - Rakefile - lib/sinatra.rb - lib/sinatra/base.rb - lib/sinatra/images/404.png - lib/sinatra/images/500.png - lib/sinatra/main.rb - lib/sinatra/showexceptions.rb - lib/tilt.rb - sinatra.gemspec - test/base_test.rb - test/builder_test.rb - test/contest.rb - test/data/reload_app_file.rb - test/erb_test.rb - test/extensions_test.rb - test/filter_test.rb - test/haml_test.rb - test/helper.rb - test/helpers_test.rb - test/mapped_error_test.rb - test/middleware_test.rb - test/options_test.rb - test/request_test.rb - test/response_test.rb - test/result_test.rb - test/route_added_hook_test.rb - test/routing_test.rb - test/sass_test.rb - test/server_test.rb - test/sinatra_test.rb - test/static_test.rb - test/templates_test.rb - test/views/error.builder - test/views/error.erb - test/views/error.haml - test/views/error.sass - test/views/foo/hello.test - test/views/hello.builder - test/views/hello.erb - test/views/hello.haml - test/views/hello.sass - test/views/hello.test - test/views/layout2.builder - test/views/layout2.erb - test/views/layout2.haml - test/views/layout2.test - ] - # = MANIFEST = + s.metadata = { + 'source_code_uri' => 'https://github.com/sinatra/sinatra', + 'changelog_uri' => 'https://github.com/sinatra/sinatra/blob/main/CHANGELOG.md', + 'homepage_uri' => 'http://sinatrarb.com/', + 'bug_tracker_uri' => 'https://github.com/sinatra/sinatra/issues', + 'mailing_list_uri' => 'http://groups.google.com/group/sinatrarb', + 'documentation_uri' => 'https://www.rubydoc.info/gems/sinatra' + } - s.test_files = s.files.select {|path| path =~ /^test\/.*_test.rb/} + s.required_ruby_version = '>= 2.6.0' - s.extra_rdoc_files = %w[README.rdoc LICENSE] - s.add_dependency 'rack', '>= 1.0' - s.add_development_dependency 'shotgun', '>= 0.3', '< 1.0' - s.add_development_dependency 'rack-test', '>= 0.3.0' + s.add_dependency 'mustermann', '~> 3.0' + s.add_dependency 'rack', '~> 2.2', '>= 2.2.4' + s.add_dependency 'rack-protection', version + s.add_dependency 'tilt', '~> 2.0' - s.has_rdoc = true - s.homepage = "http://sinatra.rubyforge.org" - s.rdoc_options = ["--line-numbers", "--inline-source", "--title", "Sinatra", "--main", "README.rdoc"] - s.require_paths = %w[lib] - s.rubyforge_project = 'sinatra' - s.rubygems_version = '1.1.1' + s.add_development_dependency 'rack-test', '~> 2' end diff --git a/test/asciidoctor_test.rb b/test/asciidoctor_test.rb new file mode 100644 index 0000000000..fd0def74eb --- /dev/null +++ b/test/asciidoctor_test.rb @@ -0,0 +1,72 @@ +require_relative 'test_helper' + +begin + require 'asciidoctor' + + class AsciidoctorTest < Minitest::Test + def asciidoc_app(&block) + mock_app do + set :views, __dir__ + '/views' + get('/', &block) + end + get '/' + end + + it 'renders inline AsciiDoc strings' do + asciidoc_app { asciidoc '== Hiya' } + assert ok? + assert_match %r{Hiya}, body + end + + it 'uses the correct engine' do + engine = Tilt::AsciidoctorTemplate + assert_equal engine, Tilt[:ad] + assert_equal engine, Tilt[:adoc] + assert_equal engine, Tilt[:asciidoc] + end + + it 'renders .asciidoc files in views path' do + asciidoc_app { asciidoc :hello } + assert ok? + assert_match %r{Hello from AsciiDoc}, body + end + + it 'raises error if template not found' do + mock_app { get('/') { asciidoc :no_such_template } } + assert_raises(Errno::ENOENT) { get('/') } + end + + it 'renders with inline layouts' do + mock_app do + layout { 'THIS. IS. #{yield.upcase}!' } + get('/') { asciidoc 'Sparta', :layout_engine => :str } + end + get '/' + assert ok? + assert_include body, 'THIS. IS.' + assert_include body, '

SPARTA

' + end + + it 'renders with file layouts' do + asciidoc_app do + asciidoc 'Hello World', :layout => :layout2, :layout_engine => :erb + end + assert ok? + assert_include body, 'ERB Layout!' + assert_include body, '

Hello World

' + end + + it 'can be used in a nested fashion for partials and whatnot' do + mock_app do + template(:inner) { 'hi' } + template(:outer) { '<%= asciidoc :inner %>' } + get('/') { erb :outer } + end + get '/' + assert ok? + assert_match %r{.*hi

.*
}m, body + end + end +rescue LoadError + warn "#{$!}: skipping asciidoc tests" +end diff --git a/test/base_test.rb b/test/base_test.rb index 7a5d11302c..2bccf2e921 100644 --- a/test/base_test.rb +++ b/test/base_test.rb @@ -1,15 +1,17 @@ -require File.dirname(__FILE__) + '/helper' - -class BaseTest < Test::Unit::TestCase - def test_default - assert true - end +require_relative 'test_helper' +class BaseTest < Minitest::Test describe 'Sinatra::Base subclasses' do class TestApp < Sinatra::Base - get '/' do - 'Hello World' + get('/') { 'Hello World' } + end + + class TestKeywordArgumentInitializerApp < Sinatra::Base + def initialize(argument:) + @argument = argument end + + get('/') { "Hello World with Keyword Arguments: #{@argument}" } end it 'include Rack::Utils' do @@ -54,6 +56,34 @@ class TestApp < Sinatra::Base TestApp.configure { context = self } assert_equal self, context end + + it "allows constructor to receive keyword arguments" do + app = TestKeywordArgumentInitializerApp.new(argument: "some argument") + request = Rack::MockRequest.new(app) + + response = request.get('/') + + assert response.ok? + assert_equal 'Hello World with Keyword Arguments: some argument', response.body + end + end + + describe "Sinatra::Base#new" do + it 'returns a wrapper' do + assert_equal Sinatra::Wrapper, Sinatra::Base.new.class + end + + it 'implements a nice inspect' do + assert_equal '#', Sinatra::Base.new.inspect + end + + it 'exposes settings' do + assert_equal Sinatra::Base.settings, Sinatra::Base.new.settings + end + + it 'exposes helpers' do + assert_equal 'image/jpeg', Sinatra::Base.new.helpers.mime_type(:jpg) + end end describe "Sinatra::Base as Rack middleware" do @@ -71,7 +101,7 @@ class TestMiddleware < Sinatra::Base end it 'exposes the downstream app' do - middleware = TestMiddleware.new(app) + middleware = TestMiddleware.new!(app) assert_same app, middleware.app end @@ -81,9 +111,7 @@ def route_missing super end - get '/' do - 'Hello from middleware' - end + get('/') { 'Hello from middleware' } end middleware = TestMiddleware.new(app) @@ -107,9 +135,7 @@ def route_missing end class TestMiddleware < Sinatra::Base - get '/low-level-forward' do - app.call(env) - end + get('/low-level-forward') { app.call(env) } end it 'can call the downstream app directly and return result' do @@ -144,7 +170,6 @@ class TestMiddleware < Sinatra::Base class TestMiddlewareContentLength < Sinatra::Base get '/forward' do - res = forward 'From after explicit forward!' end end diff --git a/test/builder_test.rb b/test/builder_test.rb index 04ab3a582e..ef1c95aa41 100644 --- a/test/builder_test.rb +++ b/test/builder_test.rb @@ -1,12 +1,15 @@ -require File.dirname(__FILE__) + '/helper' +require_relative 'test_helper' + +begin require 'builder' -class BuilderTest < Test::Unit::TestCase - def builder_app(&block) - mock_app { - set :views, File.dirname(__FILE__) + '/views' - get '/', &block - } +class BuilderTest < Minitest::Test + def builder_app(options = {}, &block) + mock_app do + set :views, __dir__ + '/views' + set options + get('/', &block) + end get '/' end @@ -16,50 +19,73 @@ def builder_app(&block) assert_equal %{\n}, body end + it 'defaults content type to xml' do + builder_app { builder 'xml.instruct!' } + assert ok? + assert_equal "application/xml;charset=utf-8", response['Content-Type'] + end + + it 'defaults allows setting content type per route' do + builder_app do + content_type :html + builder 'xml.instruct!' + end + assert ok? + assert_equal "text/html;charset=utf-8", response['Content-Type'] + end + + it 'defaults allows setting content type globally' do + builder_app(:builder => { :content_type => 'html' }) do + builder 'xml.instruct!' + end + assert ok? + assert_equal "text/html;charset=utf-8", response['Content-Type'] + end + it 'renders inline blocks' do - builder_app { + builder_app do @name = "Frank & Mary" - builder do |xml| - xml.couple @name - end - } + builder { |xml| xml.couple @name } + end assert ok? assert_equal "Frank & Mary\n", body end it 'renders .builder files in views path' do - builder_app { + builder_app do @name = "Blue" builder :hello - } + end assert ok? assert_equal %(You're my boy, Blue!\n), body end it "renders with inline layouts" do - mock_app { - layout do - %(xml.layout { xml << yield }) - end + mock_app do + layout { %(xml.layout { xml << yield }) } get('/') { builder %(xml.em 'Hello World') } - } + end get '/' assert ok? assert_equal "\nHello World\n\n", body end it "renders with file layouts" do - builder_app { + builder_app do builder %(xml.em 'Hello World'), :layout => :layout2 - } + end assert ok? assert_equal "\nHello World\n\n", body end it "raises error if template not found" do - mock_app { + mock_app do get('/') { builder :no_such_template } - } - assert_raise(Errno::ENOENT) { get('/') } + end + assert_raises(Errno::ENOENT) { get('/') } end end + +rescue LoadError + warn "#{$!}: skipping builder tests" +end diff --git a/test/compile_test.rb b/test/compile_test.rb new file mode 100644 index 0000000000..6bbadd2489 --- /dev/null +++ b/test/compile_test.rb @@ -0,0 +1,144 @@ +require_relative 'test_helper' + +class CompileTest < Minitest::Test + def self.parses pattern, example, expected_params, mtype = :sinatra, mopts = {} + it "parses #{example} with #{pattern} into params #{expected_params}" do + compiled = mock_app { set :mustermann_opts, :type => mtype }.send(:compile, pattern, mopts) + params = compiled.params(example) + fail %Q{"#{example}" does not parse on pattern "#{pattern}".} unless params + + assert_equal expected_params, params, "Pattern #{pattern} does not match path #{example}." + end + end + + def self.fails pattern, example, mtype = :sinatra, mopts = {} + it "does not parse #{example} with #{pattern}" do + compiled = mock_app { set :mustermann_opts, :type => mtype }.send(:compile, pattern, mopts) + match = compiled.match(example) + fail %Q{"#{pattern}" does parse "#{example}" but it should fail} if match + end + end + + def self.raises pattern, mtype = :sinatra, mopts = {} + it "does not compile #{pattern}" do + assert_raises(Mustermann::CompileError, %Q{Pattern "#{pattern}" compiles but it should not}) do + mock_app { set :mustermann_opts, :type => mtype }.send(:compile, pattern, mopts) + end + end + end + + parses "/", "/", {} + + parses "/foo", "/foo", {} + + parses "/:foo", "/foo", "foo" => "foo" + parses "/:foo", "/foo.bar", "foo" => "foo.bar" + parses "/:foo", "/foo%2Fbar", "foo" => "foo/bar" + parses "/:foo", "/%0Afoo", "foo" => "\nfoo" + fails "/:foo", "/foo?" + fails "/:foo", "/foo/bar" + fails "/:foo", "/" + fails "/:foo", "/foo/" + + parses "/föö", "/f%C3%B6%C3%B6", {} + + parses "/:foo/:bar", "/foo/bar", "foo" => "foo", "bar" => "bar" + + parses "/hello/:person", "/hello/Frank", "person" => "Frank" + + parses "/?:foo?/?:bar?", "/hello/world", "foo" => "hello", "bar" => "world" + parses "/?:foo?/?:bar?", "/hello", "foo" => "hello", "bar" => nil + parses "/?:foo?/?:bar?", "/", "foo" => nil, "bar" => nil + parses "/?:foo?/?:bar?", "", "foo" => nil, "bar" => nil + + parses "/*", "/", "splat" => [""] + parses "/*", "/foo", "splat" => ["foo"] + parses "/*", "/foo/bar", "splat" => ["foo/bar"] + + parses "/:foo/*", "/foo/bar/baz", "foo" => "foo", "splat" => ["bar/baz"] + + parses "/:foo/:bar", "/user@example.com/name", "foo" => "user@example.com", "bar" => "name" + + parses "/test$/", "/test$/", {} + + parses "/te+st/", "/te+st/", {} + fails "/te+st/", "/test/" + fails "/te+st/", "/teeest/" + + parses "/test(bar)/", "/testbar/", {} + + parses "/path with spaces", "/path%20with%20spaces", {} + parses "/path with spaces", "/path%2Bwith%2Bspaces", {} + parses "/path with spaces", "/path+with+spaces", {} + + parses "/foo&bar", "/foo&bar", {} + + parses "/:foo/*", "/hello%20world/how%20are%20you", "foo" => "hello world", "splat" => ["how are you"] + + parses "/*/foo/*/*", "/bar/foo/bling/baz/boom", "splat" => ["bar", "bling", "baz/boom"] + parses "/*/foo/*/*rest", "/bar/foo/bling/baz/boom", "splat" => ["bar", "bling"], "rest" => "baz/boom" + fails "/*/foo/*/*", "/bar/foo/baz" + + parses "/test.bar", "/test.bar", {} + fails "/test.bar", "/test0bar" + + parses "/:file.:ext", "/pony.jpg", "file" => "pony", "ext" => "jpg" + parses "/:file.:ext", "/pony%2Ejpg", "file" => "pony", "ext" => "jpg" + fails "/:file.:ext", "/.jpg" + + parses "/:name.?:format?", "/foo", "name" => "foo", "format" => nil + parses "/:name.?:format?", "/foo.bar", "name" => "foo", "format" => "bar" + parses "/:name.?:format?", "/foo%2Ebar", "name" => "foo", "format" => "bar" + + parses "/:user@?:host?", "/foo@bar", "user" => "foo", "host" => "bar" + parses "/:user@?:host?", "/foo.foo@bar", "user" => "foo.foo", "host" => "bar" + parses "/:user@?:host?", "/foo@bar.bar", "user" => "foo", "host" => "bar.bar" + + # From https://gist.github.com/2154980#gistcomment-169469. + # + parses "/:name(.:format)?", "/foo", "name" => "foo", "format" => nil + parses "/:name(.:format)?", "/foo.bar", "name" => "foo", "format" => "bar" + parses "/:name(.:format)?", "/foo.", "name" => "foo.", "format" => nil + + parses "/:id/test.bar", "/3/test.bar", {"id" => "3"} + parses "/:id/test.bar", "/2/test.bar", {"id" => "2"} + parses "/:id/test.bar", "/2E/test.bar", {"id" => "2E"} + parses "/:id/test.bar", "/2e/test.bar", {"id" => "2e"} + parses "/:id/test.bar", "/%2E/test.bar", {"id" => "."} + parses "/{id}/test.bar", "/%2E/test.bar", {"id" => "."} + + parses '/10/:id', '/10/test', "id" => "test" + parses '/10/:id', '/10/te.st', "id" => "te.st" + + parses '/10.1/:id', '/10.1/test', "id" => "test" + parses '/10.1/:id', '/10.1/te.st', "id" => "te.st" + parses '/:foo/:id', '/10.1/te.st', "foo" => "10.1", "id" => "te.st" + parses '/:foo/:id', '/10.1.2/te.st', "foo" => "10.1.2", "id" => "te.st" + parses '/:foo.:bar/:id', '/10.1/te.st', "foo" => "10", "bar" => "1", "id" => "te.st" + + parses '/:a/:b.?:c?', '/a/b', "a" => "a", "b" => "b", "c" => nil + parses '/:a/:b.?:c?', '/a/b.c', "a" => "a", "b" => "b", "c" => "c" + parses '/:a/:b.?:c?', '/a.b/c', "a" => "a.b", "b" => "c", "c" => nil + parses '/:a/:b.?:c?', '/a.b/c.d', "a" => "a.b", "b" => "c", "c" => "d" + fails '/:a/:b.?:c?', '/a.b/c.d/e' + + parses "/:file.:ext", "/pony%2ejpg", "file" => "pony", "ext" => "jpg" + parses "/:file.:ext", "/pony%E6%AD%A3%2Ejpg", "file" => "pony正", "ext" => "jpg" + parses "/:file.:ext", "/pony%e6%ad%a3%2ejpg", "file" => "pony正", "ext" => "jpg" + parses "/:file.:ext", "/pony正%2Ejpg", "file" => "pony正", "ext" => "jpg" + parses "/:file.:ext", "/pony正%2ejpg", "file" => "pony正", "ext" => "jpg" + parses "/:file.:ext", "/pony正..jpg", "file" => "pony正.", "ext" => "jpg" + + parses "/:name.:format", "/file.tar.gz", "name" => "file.tar", "format" => "gz" + parses "/:name.:format1.:format2", "/file.tar.gz", "name" => "file", "format1" => "tar", "format2" => "gz" + parses "/:name.:format1.:format2", "/file.temp.tar.gz", "name" => "file.temp", "format1" => "tar", "format2" => "gz" + + # From issue #688. + # + parses "/articles/10.1103/:doi", "/articles/10.1103/PhysRevLett.110.026401", "doi" => "PhysRevLett.110.026401" + + # Mustermann anchoring + fails "/bar", "/foo/bar", :regexp + raises "^/foo/bar$", :regexp + parses "^/foo/bar$", "/foo/bar", {}, :regexp, :check_anchors => false +end diff --git a/test/contest.rb b/test/contest.rb index de7160104d..2b68c4148b 100644 --- a/test/contest.rb +++ b/test/contest.rb @@ -1,38 +1,58 @@ -require "test/unit" +# Copyright (c) 2009 Damian Janowski and Michel Martens for Citrusbyte +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +require "rubygems" +require "minitest/autorun" -# Test::Unit loads a default test if the suite is empty, and the only -# purpose of that test is to fail. As having empty contexts is a common -# practice, we decided to overwrite TestSuite#empty? in order to -# allow them. Having a failure when no tests have been defined seems -# counter-intuitive. -class Test::Unit::TestSuite - unless method_defined?(:empty?) - def empty? - false - end - end -end +# Contest adds +teardown+, +test+ and +context+ as class methods, and the +# instance methods +setup+ and +teardown+ now iterate on the corresponding +# blocks. Note that all setup and teardown blocks must be defined with the +# block syntax. Adding setup or teardown instance methods defeats the purpose +# of this library. +class Minitest::Test + def self.setup(&block) setup_blocks << block end + def self.teardown(&block) teardown_blocks << block end + def self.setup_blocks() @setup_blocks ||= [] end + def self.teardown_blocks() @teardown_blocks ||= [] end -# We added setup, test and context as class methods, and the instance -# method setup now iterates on the setup blocks. Note that all setup -# blocks must be defined with the block syntax. Adding a setup instance -# method defeats the purpose of this library. -class Test::Unit::TestCase - def self.setup(&block) - setup_blocks << block + def setup_blocks(base = self.class) + setup_blocks base.superclass if base.superclass.respond_to? :setup_blocks + base.setup_blocks.each do |block| + instance_eval(&block) + end end - def setup - self.class.setup_blocks.each do |block| + def teardown_blocks(base = self.class) + teardown_blocks base.superclass if base.superclass.respond_to? :teardown_blocks + base.teardown_blocks.each do |block| instance_eval(&block) end end - def self.context(name, &block) - subclass = Class.new(self.superclass) - subclass.setup_blocks.unshift(*setup_blocks) - subclass.class_eval(&block) - const_set(context_name(name), subclass) + alias setup setup_blocks + alias teardown teardown_blocks + + def self.context(*name, &block) + subclass = Class.new(self) + remove_tests(subclass) + subclass.class_eval(&block) if block_given? + const_set(context_name(name.join(" ")), subclass) end def self.test(name, &block) @@ -46,19 +66,26 @@ class << self private - def self.setup_blocks - @setup_blocks ||= [] - end - def self.context_name(name) - "Test#{sanitize_name(name).gsub(/(^| )(\w)/) { $2.upcase }}".to_sym + # "Test#{sanitize_name(name).gsub(/(^| )(\w)/) { $2.upcase }}".to_sym + name = "Test#{sanitize_name(name).gsub(/(^| )(\w)/) { $2.upcase }}" + name.tr(" ", "_").to_sym end def self.test_name(name) - "test_#{sanitize_name(name).gsub(/\s+/,'_')}".to_sym + name = "test_#{sanitize_name(name).gsub(/\s+/,'_')}_0" + name = name.succ while method_defined? name + name.to_sym end def self.sanitize_name(name) - name.gsub(/\W+/, ' ').strip + # name.gsub(/\W+/, ' ').strip + name.gsub(/\W+/, ' ') + end + + def self.remove_tests(subclass) + subclass.public_instance_methods.grep(/^test_/).each do |meth| + subclass.send(:undef_method, meth.to_sym) + end end end diff --git a/test/data/reload_app_file.rb b/test/data/reload_app_file.rb deleted file mode 100644 index 673ab7cd92..0000000000 --- a/test/data/reload_app_file.rb +++ /dev/null @@ -1,3 +0,0 @@ -$reload_count += 1 - -$reload_app.get('/') { 'Hello from reload file' } diff --git a/test/delegator_test.rb b/test/delegator_test.rb new file mode 100644 index 0000000000..da2b5d9996 --- /dev/null +++ b/test/delegator_test.rb @@ -0,0 +1,171 @@ +require_relative 'test_helper' + +class DelegatorTest < Minitest::Test + class Mirror + attr_reader :last_call + def method_missing(*a, &b) + @last_call = [*a.map(&:to_s)] + @last_call << b if b + end + end + + def self.delegates(name) + it "delegates #{name}" do + m = mirror { send name } + assert_equal [name.to_s], m.last_call + end + + it "delegates #{name} with arguments" do + m = mirror { send name, "foo", "bar" } + assert_equal [name.to_s, "foo", "bar"], m.last_call + end + + it "delegates #{name} with block" do + block = proc { } + m = mirror { send(name, &block) } + assert_equal [name.to_s, block], m.last_call + end + end + + setup do + @target_was = Sinatra::Delegator.target + end + + def teardown + Sinatra::Delegator.target = @target_was + end + + def delegation_app(&block) + mock_app { Sinatra::Delegator.target = self } + delegate(&block) + end + + def mirror(&block) + mirror = Mirror.new + Sinatra::Delegator.target = mirror + delegate(&block) + end + + def delegate(&block) + assert Sinatra::Delegator.target != Sinatra::Application + Object.new.extend(Sinatra::Delegator).instance_eval(&block) if block + Sinatra::Delegator.target + end + + def target + Sinatra::Delegator.target + end + + it 'defaults to Sinatra::Application as target' do + assert_equal Sinatra::Application, Sinatra::Delegator.target + end + + %w[get put post delete options patch link unlink].each do |verb| + it "delegates #{verb} correctly" do + delegation_app do + send(verb, '/hello') { 'Hello World' } + end + + request = Rack::MockRequest.new(@app) + response = request.request(verb.upcase, '/hello', {}) + assert response.ok? + assert_equal 'Hello World', response.body + end + end + + it "delegates head correctly" do + delegation_app do + head '/hello' do + response['X-Hello'] = 'World!' + 'remove me' + end + end + + request = Rack::MockRequest.new(@app) + response = request.request('HEAD', '/hello', {}) + assert response.ok? + assert_equal 'World!', response['X-Hello'] + assert_equal '', response.body + end + + it "delegates before with keyword arguments correctly" do + delegation_app do + set(:foo) do |something| + something + end + before(foo: 'bar') do + :nothing + end + end + end + + it "registers extensions with the delegation target" do + app, mixin = mirror, Module.new + Sinatra.register mixin + assert_equal ["register", mixin.to_s], app.last_call + end + + it "registers helpers with the delegation target" do + app, mixin = mirror, Module.new + Sinatra.helpers mixin + assert_equal ["helpers", mixin.to_s], app.last_call + end + + it "registers middleware with the delegation target" do + app, mixin = mirror, Module.new + Sinatra.use mixin + assert_equal ["use", mixin.to_s], app.last_call + end + + it "should work with method_missing proxies for options" do + mixin = Module.new do + def respond_to?(method, *) + method.to_sym == :options or super + end + + def method_missing(method, *args, &block) + return super unless method.to_sym == :options + {:some => :option} + end + end + + value = nil + mirror do + extend mixin + value = options + end + + assert_equal({:some => :option}, value) + end + + it "delegates crazy method names" do + Sinatra::Delegator.delegate "foo:bar:" + method = mirror { send "foo:bar:" }.last_call.first + assert_equal "foo:bar:", method + end + + delegates 'get' + delegates 'patch' + delegates 'put' + delegates 'post' + delegates 'delete' + delegates 'head' + delegates 'options' + delegates 'template' + delegates 'layout' + delegates 'before' + delegates 'after' + delegates 'error' + delegates 'not_found' + delegates 'configure' + delegates 'set' + delegates 'mime_type' + delegates 'enable' + delegates 'disable' + delegates 'use' + delegates 'development?' + delegates 'test?' + delegates 'production?' + delegates 'helpers' + delegates 'settings' +end diff --git a/test/encoding_test.rb b/test/encoding_test.rb new file mode 100644 index 0000000000..fce8f3169c --- /dev/null +++ b/test/encoding_test.rb @@ -0,0 +1,19 @@ +require_relative 'test_helper' +require 'erb' + +class BaseTest < Minitest::Test + setup do + @base = Sinatra.new(Sinatra::Base) + @base.set :views, __dir__ + "/views" + end + + it 'allows unicode strings in ascii templates per default (1.9)' do + next unless defined? Encoding + @base.new!.erb(File.read(@base.views + "/ascii.erb").encode("ASCII"), {}, :value => "åkej") + end + + it 'allows ascii strings in unicode templates per default (1.9)' do + next unless defined? Encoding + @base.new!.erb(:utf8, {}, :value => "Some Lyrics".encode("ASCII")) + end +end diff --git a/test/erb_test.rb b/test/erb_test.rb index cc68c5ce34..646576f36e 100644 --- a/test/erb_test.rb +++ b/test/erb_test.rb @@ -1,14 +1,27 @@ -require File.dirname(__FILE__) + '/helper' +require_relative 'test_helper' + +class ERBTest < Minitest::Test + def engine + Tilt::ERBTemplate + end + + def setup + Tilt.prefer engine, :erb + super + end -class ERBTest < Test::Unit::TestCase def erb_app(&block) - mock_app { - set :views, File.dirname(__FILE__) + '/views' - get '/', &block - } + mock_app do + set :views, __dir__ + '/views' + get('/', &block) + end get '/' end + it 'uses the correct engine' do + assert_equal engine, Tilt[:erb] + end + it 'renders inline ERB strings' do erb_app { erb '<%= 1 + 1 %>' } assert ok? @@ -22,60 +35,81 @@ def erb_app(&block) end it 'takes a :locals option' do - erb_app { + erb_app do locals = {:foo => 'Bar'} erb '<%= foo %>', :locals => locals - } + end assert ok? assert_equal 'Bar', body end it "renders with inline layouts" do - mock_app { + mock_app do layout { 'THIS. IS. <%= yield.upcase %>!' } get('/') { erb 'Sparta' } - } + end get '/' assert ok? assert_equal 'THIS. IS. SPARTA!', body end it "renders with file layouts" do - erb_app { - erb 'Hello World', :layout => :layout2 - } + erb_app { erb 'Hello World', :layout => :layout2 } assert ok? - assert_equal "ERB Layout!\nHello World\n", body + assert_body "ERB Layout!\nHello World" end it "renders erb with blocks" do - mock_app { + mock_app do def container @_out_buf << "THIS." yield @_out_buf << "SPARTA!" end def is; "IS." end - get '/' do - erb '<% container do %> <%= is %> <% end %>' - end - } + get('/') { erb '<% container do %> <%= is %> <% end %>' } + end get '/' assert ok? assert_equal 'THIS. IS. SPARTA!', body end it "can be used in a nested fashion for partials and whatnot" do - mock_app { + mock_app do template(:inner) { "<%= 'hi' %>" } template(:outer) { "<%= erb :inner %>" } - get '/' do - erb :outer - end - } + get('/') { erb :outer } + end get '/' assert ok? assert_equal 'hi', body end + + it "can render truly nested layouts by accepting a layout and a block with the contents" do + mock_app do + template(:main_outer_layout) { "

Title

\n<%= yield %>" } + template(:an_inner_layout) { "

Subtitle

\n<%= yield %>" } + template(:a_page) { "

Contents.

\n" } + get('/') do + erb :main_outer_layout, :layout => false do + erb :an_inner_layout do + erb :a_page + end + end + end + end + get '/' + assert ok? + assert_body "

Title

\n

Subtitle

\n

Contents.

\n" + end +end + +begin + require 'erubi' + class ErubiTest < ERBTest + def engine; Tilt::ErubiTemplate end + end +rescue LoadError + warn "#{$!}: skipping erubi tests" end diff --git a/test/erubis_test.rb b/test/erubis_test.rb deleted file mode 100644 index 447f25fa21..0000000000 --- a/test/erubis_test.rb +++ /dev/null @@ -1,82 +0,0 @@ -require File.dirname(__FILE__) + '/helper' -require 'erubis' - -class ERubisTest < Test::Unit::TestCase - def erubis_app(&block) - mock_app { - set :views, File.dirname(__FILE__) + '/views' - get '/', &block - } - get '/' - end - - it 'renders inline ERubis strings' do - erubis_app { erubis '<%= 1 + 1 %>' } - assert ok? - assert_equal '2', body - end - - it 'renders .erubis files in views path' do - erubis_app { erubis :hello } - assert ok? - assert_equal "Hello World\n", body - end - - it 'takes a :locals option' do - erubis_app { - locals = {:foo => 'Bar'} - erubis '<%= foo %>', :locals => locals - } - assert ok? - assert_equal 'Bar', body - end - - it "renders with inline layouts" do - mock_app { - layout { 'THIS. IS. <%= yield.upcase %>!' } - get('/') { erubis 'Sparta' } - } - get '/' - assert ok? - assert_equal 'THIS. IS. SPARTA!', body - end - - it "renders with file layouts" do - erubis_app { - erubis 'Hello World', :layout => :layout2 - } - assert ok? - assert_equal "ERubis Layout!\nHello World\n", body - end - - it "renders erubis with blocks" do - mock_app { - def container - @_out_buf << "THIS." - yield - @_out_buf << "SPARTA!" - end - def is; "IS." end - get '/' do - erubis '<% container do %> <%= is %> <% end %>' - end - } - get '/' - assert ok? - assert_equal 'THIS. IS. SPARTA!', body - end - - it "can be used in a nested fashion for partials and whatnot" do - mock_app { - template(:inner) { "<%= 'hi' %>" } - template(:outer) { "<%= erubis :inner %>" } - get '/' do - erubis :outer - end - } - - get '/' - assert ok? - assert_equal 'hi', body - end -end diff --git a/test/extensions_test.rb b/test/extensions_test.rb index 8e4a54388f..cff43e3d88 100644 --- a/test/extensions_test.rb +++ b/test/extensions_test.rb @@ -1,6 +1,6 @@ -require File.dirname(__FILE__) + '/helper' +require_relative 'test_helper' -class ExtensionsTest < Test::Unit::TestCase +class ExtensionsTest < Minitest::Test module FooExtensions def foo end @@ -42,9 +42,7 @@ def fizz!(name); end end it 'allows extending by passing a block' do - Sinatra::Base.register { - def im_in_ur_anonymous_module; end - } + Sinatra::Base.register { def im_in_ur_anonymous_module; end } assert Sinatra::Base.respond_to?(:im_in_ur_anonymous_module) end diff --git a/test/filter_test.rb b/test/filter_test.rb index 33f36b6d9c..5324969084 100644 --- a/test/filter_test.rb +++ b/test/filter_test.rb @@ -1,18 +1,18 @@ -require File.dirname(__FILE__) + '/helper' +require_relative 'test_helper' -class BeforeFilterTest < Test::Unit::TestCase +class BeforeFilterTest < Minitest::Test it "executes filters in the order defined" do count = 0 mock_app do get('/') { 'Hello World' } - before { + before do assert_equal 0, count count = 1 - } - before { + end + before do assert_equal 1, count count = 2 - } + end end get '/' @@ -22,11 +22,11 @@ class BeforeFilterTest < Test::Unit::TestCase end it "can modify the request" do - mock_app { + mock_app do get('/foo') { 'foo' } get('/bar') { 'bar' } before { request.path_info = '/bar' } - } + end get '/foo' assert ok? @@ -34,10 +34,10 @@ class BeforeFilterTest < Test::Unit::TestCase end it "can modify instance variables available to routes" do - mock_app { + mock_app do before { @foo = 'bar' } get('/foo') { @foo } - } + end get '/foo' assert ok? @@ -45,28 +45,28 @@ class BeforeFilterTest < Test::Unit::TestCase end it "allows redirects" do - mock_app { + mock_app do before { redirect '/bar' } get('/foo') do fail 'before block should have halted processing' 'ORLY?!' end - } + end get '/foo' assert redirect? - assert_equal '/bar', response['Location'] + assert_equal 'http://example.org/bar', response['Location'] assert_equal '', body end it "does not modify the response with its return value" do - mock_app { + mock_app do before { 'Hello World!' } - get '/foo' do + get('/foo') do assert_equal [], response.body 'cool' end - } + end get '/foo' assert ok? @@ -74,12 +74,12 @@ class BeforeFilterTest < Test::Unit::TestCase end it "does modify the response with halt" do - mock_app { + mock_app do before { halt 302, 'Hi' } get '/foo' do "should not happen" end - } + end get '/foo' assert_equal 302, response.status @@ -87,35 +87,101 @@ class BeforeFilterTest < Test::Unit::TestCase end it "gives you access to params" do - mock_app { + mock_app do before { @foo = params['foo'] } get('/foo') { @foo } - } + end get '/foo?foo=cool' assert ok? assert_equal 'cool', body end + it "properly unescapes parameters" do + mock_app do + before { @foo = params['foo'] } + get('/foo') { @foo } + end + + get '/foo?foo=bar%3Abaz%2Fbend' + assert ok? + assert_equal 'bar:baz/bend', body + end + it "runs filters defined in superclasses" do base = Class.new(Sinatra::Base) base.before { @foo = 'hello from superclass' } - mock_app(base) { - get('/foo') { @foo } - } + mock_app(base) { get('/foo') { @foo } } get '/foo' assert_equal 'hello from superclass', body end + + it 'does not run before filter when serving static files' do + ran_filter = false + mock_app do + before { ran_filter = true } + set :static, true + set :public_folder, __dir__ + end + get "/#{File.basename(__FILE__)}" + assert ok? + assert_equal File.read(__FILE__), body + assert !ran_filter + end + + it 'takes an optional route pattern' do + ran_filter = false + mock_app do + before("/b*") { ran_filter = true } + get('/foo') { } + get('/bar') { } + end + get '/foo' + assert !ran_filter + get '/bar' + assert ran_filter + end + + it 'generates block arguments from route pattern' do + subpath = nil + mock_app do + before("/foo/:sub") { |s| subpath = s } + get('/foo/*') { } + end + get '/foo/bar' + assert_equal subpath, 'bar' + end + + it 'can catch exceptions in before filters and handle them properly' do + doodle = '' + mock_app do + before do + doodle += 'This begins' + raise StandardError, "before" + end + get "/" do + doodle = 'and runs' + end + error 500 do + "Error handled #{env['sinatra.error'].message}" + end + end + + doodle = '' + get '/' + assert_equal 'Error handled before', body + assert_equal 'This begins', doodle + end end -class AfterFilterTest < Test::Unit::TestCase - it "executes filters in the order defined" do +class AfterFilterTest < Minitest::Test + it "executes before and after filters in correct order" do invoked = 0 mock_app do before { invoked = 2 } - get('/') { invoked += 2 } + get('/') { invoked += 2; 'hello' } after { invoked *= 2 } end @@ -129,14 +195,14 @@ class AfterFilterTest < Test::Unit::TestCase count = 0 mock_app do get('/') { 'Hello World' } - after { + after do assert_equal 0, count count = 1 - } - after { + end + after do assert_equal 1, count count = 2 - } + end end get '/' @@ -146,22 +212,22 @@ class AfterFilterTest < Test::Unit::TestCase end it "allows redirects" do - mock_app { + mock_app do get('/foo') { 'ORLY' } after { redirect '/bar' } - } + end get '/foo' assert redirect? - assert_equal '/bar', response['Location'] + assert_equal 'http://example.org/bar', response['Location'] assert_equal '', body end it "does not modify the response with its return value" do - mock_app { + mock_app do get('/foo') { 'cool' } after { 'Hello World!' } - } + end get '/foo' assert ok? @@ -169,12 +235,12 @@ class AfterFilterTest < Test::Unit::TestCase end it "does modify the response with halt" do - mock_app { + mock_app do get '/foo' do "should not be returned" end after { halt 302, 'Hi' } - } + end get '/foo' assert_equal 302, response.status @@ -185,11 +251,269 @@ class AfterFilterTest < Test::Unit::TestCase count = 2 base = Class.new(Sinatra::Base) base.after { count *= 2 } - mock_app(base) { - get('/foo') { count += 2 } - } + mock_app(base) do + get('/foo') do + count += 2 + "ok" + end + end get '/foo' assert_equal 8, count end + + it "respects content type set in superclass filter" do + base = Class.new(Sinatra::Base) + base.before { content_type :json } + mock_app(base) do + get('/foo'){ {foo: :bar}.to_json } + end + + get '/foo' + assert_equal 'application/json', response.headers['Content-Type'] + end + + it 'does not run after filter when serving static files' do + ran_filter = false + mock_app do + after { ran_filter = true } + set :static, true + set :public_folder, __dir__ + end + get "/#{File.basename(__FILE__)}" + assert ok? + assert_equal File.read(__FILE__), body + assert !ran_filter + end + + it 'takes an optional route pattern' do + ran_filter = false + mock_app do + after("/b*") { ran_filter = true } + get('/foo') { } + get('/bar') { } + end + get '/foo' + assert !ran_filter + get '/bar' + assert ran_filter + end + + it 'changes to path_info from a pattern matching before filter are respected when routing' do + mock_app do + before('/foo') { request.path_info = '/bar' } + get('/bar') { 'blah' } + end + get '/foo' + assert ok? + assert_equal 'blah', body + end + + it 'generates block arguments from route pattern' do + subpath = nil + mock_app do + after("/foo/:sub") { |s| subpath = s } + get('/foo/*') { } + end + get '/foo/bar' + assert_equal subpath, 'bar' + end + + it 'is possible to access url params from the route param' do + ran = false + mock_app do + get('/foo/*') { } + before('/foo/:sub') do + assert_equal params[:sub], 'bar' + ran = true + end + end + get '/foo/bar' + assert ran + end + + it 'is possible to apply host_name conditions to before filters with no path' do + ran = false + mock_app do + before(:host_name => 'example.com') { ran = true } + get('/') { 'welcome' } + end + get('/', {}, { 'HTTP_HOST' => 'example.org' }) + assert !ran + get('/', {}, { 'HTTP_HOST' => 'example.com' }) + assert ran + end + + it 'is possible to apply host_name conditions to before filters with a path' do + ran = false + mock_app do + before('/foo', :host_name => 'example.com') { ran = true } + get('/') { 'welcome' } + end + get('/', {}, { 'HTTP_HOST' => 'example.com' }) + assert !ran + get('/foo', {}, { 'HTTP_HOST' => 'example.org' }) + assert !ran + get('/foo', {}, { 'HTTP_HOST' => 'example.com' }) + assert ran + end + + it 'is possible to apply host_name conditions to after filters with no path' do + ran = false + mock_app do + after(:host_name => 'example.com') { ran = true } + get('/') { 'welcome' } + end + get('/', {}, { 'HTTP_HOST' => 'example.org' }) + assert !ran + get('/', {}, { 'HTTP_HOST' => 'example.com' }) + assert ran + end + + it 'is possible to apply host_name conditions to after filters with a path' do + ran = false + mock_app do + after('/foo', :host_name => 'example.com') { ran = true } + get('/') { 'welcome' } + end + get('/', {}, { 'HTTP_HOST' => 'example.com' }) + assert !ran + get('/foo', {}, { 'HTTP_HOST' => 'example.org' }) + assert !ran + get('/foo', {}, { 'HTTP_HOST' => 'example.com' }) + assert ran + end + + it 'is possible to apply user_agent conditions to before filters with no path' do + ran = false + mock_app do + before(:user_agent => /foo/) { ran = true } + get('/') { 'welcome' } + end + get('/', {}, { 'HTTP_USER_AGENT' => 'bar' }) + assert !ran + get('/', {}, { 'HTTP_USER_AGENT' => 'foo' }) + assert ran + end + + it 'is possible to apply user_agent conditions to before filters with a path' do + ran = false + mock_app do + before('/foo', :user_agent => /foo/) { ran = true } + get('/') { 'welcome' } + end + get('/', {}, { 'HTTP_USER_AGENT' => 'foo' }) + assert !ran + get('/foo', {}, { 'HTTP_USER_AGENT' => 'bar' }) + assert !ran + get('/foo', {}, { 'HTTP_USER_AGENT' => 'foo' }) + assert ran + end + + it 'can add params' do + mock_app do + before { params['foo'] = 'bar' } + get('/') { params['foo'] } + end + + get '/' + assert_body 'bar' + end + + it 'can add params on a single path' do + mock_app do + before('/hi'){ params['foo'] = 'bar' } + get('/hi') { params['foo'] } + end + + get '/hi' + assert_body 'bar' + end + + # ref: issue #1567 + it 'can add params on named parameters path' do + mock_app do + before('/:id/hi'){ params['foo'] = 'bar' } + get('/:id/hi') { params['foo'] } + end + + get '/:id/hi' + assert_body 'bar' + end + + it 'can remove params' do + mock_app do + before { params.delete('foo') } + get('/') { params['foo'].to_s } + end + + get '/?foo=bar' + assert_body '' + end + + it 'is possible to apply user_agent conditions to after filters with no path' do + ran = false + mock_app do + after(:user_agent => /foo/) { ran = true } + get('/') { 'welcome' } + end + get('/', {}, { 'HTTP_USER_AGENT' => 'bar' }) + assert !ran + get('/', {}, { 'HTTP_USER_AGENT' => 'foo' }) + assert ran + end + + it 'is possible to apply user_agent conditions to after filters with a path' do + ran = false + mock_app do + after('/foo', :user_agent => /foo/) { ran = true } + get('/') { 'welcome' } + end + get('/', {}, { 'HTTP_USER_AGENT' => 'foo' }) + assert !ran + get('/foo', {}, { 'HTTP_USER_AGENT' => 'bar' }) + assert !ran + get('/foo', {}, { 'HTTP_USER_AGENT' => 'foo' }) + assert ran + end + + it 'only triggers provides condition if conforms with current Content-Type' do + mock_app do + before(:provides => :txt) { @type = 'txt' } + before(:provides => :html) { @type = 'html' } + get('/') { @type } + end + + get('/', {}, { 'HTTP_ACCEPT' => '*/*' }) + assert_body 'txt' + end + + it 'can catch exceptions in after filters and handle them properly' do + doodle = '' + mock_app do + after do + doodle += ' and after' + raise StandardError, "after" + end + get "/foo" do + doodle = 'Been now' + raise StandardError, "now" + end + get "/" do + doodle = 'Been now' + end + error 500 do + "Error handled #{env['sinatra.error'].message}" + end + end + + get '/foo' + assert_equal 'Error handled now', body + assert_equal 'Been now and after', doodle + + doodle = '' + get '/' + assert_equal 'Error handled after', body + assert_equal 'Been now and after', doodle + end end diff --git a/test/haml_test.rb b/test/haml_test.rb index 3d6ed69e09..766d3d9551 100644 --- a/test/haml_test.rb +++ b/test/haml_test.rb @@ -1,12 +1,14 @@ -require File.dirname(__FILE__) + '/helper' +require_relative 'test_helper' + +begin require 'haml' -class HAMLTest < Test::Unit::TestCase +class HAMLTest < Minitest::Test def haml_app(&block) - mock_app { - set :views, File.dirname(__FILE__) + '/views' - get '/', &block - } + mock_app do + set :views, __dir__ + '/views' + get('/', &block) + end get '/' end @@ -23,35 +25,29 @@ def haml_app(&block) end it "renders with inline layouts" do - mock_app { - layout { %q(%h1= 'THIS. IS. ' + yield.upcase) } + mock_app do + layout { %q(%h1!= 'THIS. IS. ' + yield.upcase) } get('/') { haml '%em Sparta' } - } + end get '/' assert ok? - assert_equal "

THIS. IS. SPARTA

\n", body + assert_equal "

THIS. IS. SPARTA\n

\n", body end it "renders with file layouts" do - haml_app { - haml 'Hello World', :layout => :layout2 - } + haml_app { haml 'Hello World', :layout => :layout2 } assert ok? - assert_equal "

HAML Layout!

\n

Hello World

\n", body + assert_equal "

HAML Layout!

\n

Hello World\n

\n", body end it "raises error if template not found" do - mock_app { - get('/') { haml :no_such_template } - } - assert_raise(Errno::ENOENT) { get('/') } + mock_app { get('/') { haml :no_such_template } } + assert_raises(Errno::ENOENT) { get('/') } end it "passes HAML options to the Haml engine" do mock_app { - get '/' do - haml "!!!\n%h1 Hello World", :format => :html5 - end + get('/') { haml "!!!\n%h1 Hello World", :format => :html5 } } get '/' assert ok? @@ -59,32 +55,55 @@ def haml_app(&block) end it "passes default HAML options to the Haml engine" do - mock_app { + mock_app do set :haml, {:format => :html5} - get '/' do - haml "!!!\n%h1 Hello World" - end - } + get('/') { haml "!!!\n%h1 Hello World" } + end get '/' assert ok? assert_equal "\n

Hello World

\n", body end it "merges the default HAML options with the overrides and passes them to the Haml engine" do - mock_app { - set :haml, {:format => :html5, :attr_wrapper => '"'} # default HAML attr are - get '/' do - haml "!!!\n%h1{:class => :header} Hello World" - end - get '/html4' do + mock_app do + set :haml, {:format => :html5} + get('/') { haml "!!!\n%h1{:class => :header} Hello World" } + get('/html4') { haml "!!!\n%h1{:class => 'header'} Hello World", :format => :html4 - end - } + } + end get '/' assert ok? - assert_equal "\n

Hello World

\n", body + assert_equal "\n

Hello World

\n", body get '/html4' assert ok? assert_match(/^ { :foo => 'bar' }} + assert_equal "bar\n", body + end + + it "can render truly nested layouts by accepting a layout and a block with the contents" do + mock_app do + template(:main_outer_layout) { "%h1 Title\n!= yield" } + template(:an_inner_layout) { "%h2 Subtitle\n!= yield" } + template(:a_page) { "%p Contents." } + get('/') do + haml :main_outer_layout, :layout => false do + haml :an_inner_layout do + haml :a_page + end + end + end + end + get '/' + assert ok? + assert_body "

Title

\n

Subtitle

\n

Contents.

\n" + end +end + +rescue LoadError + warn "#{$!}: skipping haml tests" end diff --git a/test/helper.rb b/test/helper.rb deleted file mode 100644 index 70699295c1..0000000000 --- a/test/helper.rb +++ /dev/null @@ -1,76 +0,0 @@ -ENV['RACK_ENV'] = 'test' - -begin - require 'rack' -rescue LoadError - require 'rubygems' - require 'rack' -end - -testdir = File.dirname(__FILE__) -$LOAD_PATH.unshift testdir unless $LOAD_PATH.include?(testdir) - -libdir = File.dirname(File.dirname(__FILE__)) + '/lib' -$LOAD_PATH.unshift libdir unless $LOAD_PATH.include?(libdir) - -require 'contest' -require 'rack/test' -require 'sinatra/base' - -class Sinatra::Base - # Allow assertions in request context - include Test::Unit::Assertions -end - -Sinatra::Base.set :environment, :test - -class Test::Unit::TestCase - include Rack::Test::Methods - - class << self - alias_method :it, :test - end - - alias_method :response, :last_response - - setup do - Sinatra::Base.set :environment, :test - end - - # Sets up a Sinatra::Base subclass defined with the block - # given. Used in setup or individual spec methods to establish - # the application. - def mock_app(base=Sinatra::Base, &block) - @app = Sinatra.new(base, &block) - end - - def app - Rack::Lint.new(@app) - end - - def body - response.body.to_s - end - - # Delegate other missing methods to response. - def method_missing(name, *args, &block) - if response && response.respond_to?(name) - response.send(name, *args, &block) - else - super - end - end - - # Also check response since we delegate there. - def respond_to?(symbol, include_private=false) - super || (response && response.respond_to?(symbol, include_private)) - end - - # Do not output warnings for the duration of the block. - def silence_warnings - $VERBOSE, v = nil, $VERBOSE - yield - ensure - $VERBOSE = v - end -end diff --git a/test/helpers_test.rb b/test/helpers_test.rb index 61ccdf8fc5..ee1298b213 100644 --- a/test/helpers_test.rb +++ b/test/helpers_test.rb @@ -1,101 +1,311 @@ -require File.dirname(__FILE__) + '/helper' +require_relative 'test_helper' +require 'date' +require 'json' -class HelpersTest < Test::Unit::TestCase +class HelpersTest < Minitest::Test def test_default assert true end - describe 'status' do - setup do - mock_app { - get '/' do - status 207 - nil - end - } + def status_app(code, &block) + code += 2 if [204, 304].include? code + block ||= proc { } + mock_app do + get('/') do + status code + instance_eval(&block).inspect + end end + get '/' + end + describe 'status' do it 'sets the response status code' do - get '/' + status_app 207 assert_equal 207, response.status end end + describe 'bad_request?' do + it 'is true for status == 400' do + status_app(400) { bad_request? } + assert_body 'true' + end + + it 'is false for status gt 400' do + status_app(401) { bad_request? } + assert_body 'false' + end + + it 'is false for status lt 400' do + status_app(399) { bad_request? } + assert_body 'false' + end + end + + describe 'not_found?' do + it 'is true for status == 404' do + status_app(404) { not_found? } + assert_body 'true' + end + + it 'is false for status gt 404' do + status_app(405) { not_found? } + assert_body 'false' + end + + it 'is false for status lt 404' do + status_app(403) { not_found? } + assert_body 'false' + end + end + + describe 'informational?' do + it 'is true for 1xx status' do + status_app(100 + rand(100)) { informational? } + assert_body 'true' + end + + it 'is false for status > 199' do + status_app(200 + rand(400)) { informational? } + assert_body 'false' + end + end + + describe 'success?' do + it 'is true for 2xx status' do + status_app(200 + rand(100)) { success? } + assert_body 'true' + end + + it 'is false for status < 200' do + status_app(100 + rand(100)) { success? } + assert_body 'false' + end + + it 'is false for status > 299' do + status_app(300 + rand(300)) { success? } + assert_body 'false' + end + end + + describe 'redirect?' do + it 'is true for 3xx status' do + status_app(300 + rand(100)) { redirect? } + assert_body 'true' + end + + it 'is false for status < 300' do + status_app(200 + rand(100)) { redirect? } + assert_body 'false' + end + + it 'is false for status > 399' do + status_app(400 + rand(200)) { redirect? } + assert_body 'false' + end + end + + describe 'client_error?' do + it 'is true for 4xx status' do + status_app(400 + rand(100)) { client_error? } + assert_body 'true' + end + + it 'is false for status < 400' do + status_app(200 + rand(200)) { client_error? } + assert_body 'false' + end + + it 'is false for status > 499' do + status_app(500 + rand(100)) { client_error? } + assert_body 'false' + end + end + + describe 'server_error?' do + it 'is true for 5xx status' do + status_app(500 + rand(100)) { server_error? } + assert_body 'true' + end + + it 'is false for status < 500' do + status_app(200 + rand(300)) { server_error? } + assert_body 'false' + end + end + describe 'body' do - it 'takes a block for defered body generation' do - mock_app { - get '/' do - body { 'Hello World' } - end - } + it 'takes a block for deferred body generation' do + mock_app do + get('/') { body { 'Hello World' } } + end get '/' assert_equal 'Hello World', body end it 'takes a String, Array, or other object responding to #each' do - mock_app { + mock_app { get('/') { body 'Hello World' } } + + get '/' + assert_equal 'Hello World', body + end + + it 'can be used with other objects' do + mock_app do get '/' do - body 'Hello World' + body :hello => 'from json' end - } + + after do + if Hash === response.body + body response.body[:hello] + end + end + end get '/' - assert_equal 'Hello World', body + assert_body 'from json' + end + + it 'can be set in after filter' do + mock_app do + get('/') { body 'route' } + after { body 'filter' } + end + + get '/' + assert_body 'filter' end end describe 'redirect' do it 'uses a 302 when only a path is given' do - mock_app { - get '/' do + mock_app do + get('/') do redirect '/foo' fail 'redirect should halt' end - } + end get '/' assert_equal 302, status assert_equal '', body - assert_equal '/foo', response['Location'] + assert_equal 'http://example.org/foo', response['Location'] end it 'uses the code given when specified' do - mock_app { - get '/' do + mock_app do + get('/') do redirect '/foo', 301 fail 'redirect should halt' end - } + end get '/' assert_equal 301, status assert_equal '', body - assert_equal '/foo', response['Location'] + assert_equal 'http://example.org/foo', response['Location'] end it 'redirects back to request.referer when passed back' do - mock_app { - get '/try_redirect' do - redirect back - end - } + mock_app { get('/try_redirect') { redirect back } } request = Rack::MockRequest.new(@app) response = request.get('/try_redirect', 'HTTP_REFERER' => '/foo') assert_equal 302, response.status - assert_equal '/foo', response['Location'] + assert_equal 'http://example.org/foo', response['Location'] + end + + it 'redirects using a non-standard HTTP port' do + mock_app { get('/') { redirect '/foo' } } + + request = Rack::MockRequest.new(@app) + response = request.get('/', 'SERVER_PORT' => '81') + assert_equal 'http://example.org:81/foo', response['Location'] + end + + it 'redirects using a non-standard HTTPS port' do + mock_app { get('/') { redirect '/foo' } } + + request = Rack::MockRequest.new(@app) + response = request.get('/', 'SERVER_PORT' => '444') + assert_equal 'http://example.org:444/foo', response['Location'] + end + + it 'uses 303 for post requests if request is HTTP 1.1' do + mock_app { post('/') { redirect '/'} } + post('/', {}, 'HTTP_VERSION' => 'HTTP/1.1') + assert_equal 303, status + assert_equal '', body + assert_equal 'http://example.org/', response['Location'] + end + + it 'uses 302 for post requests if request is HTTP 1.0' do + mock_app { post('/') { redirect '/'} } + post('/', {}, 'HTTP_VERSION' => 'HTTP/1.0') + assert_equal 302, status + assert_equal '', body + assert_equal 'http://example.org/', response['Location'] + end + + it 'works behind a reverse proxy' do + mock_app { get('/') { redirect '/foo' } } + + request = Rack::MockRequest.new(@app) + response = request.get('/', 'HTTP_X_FORWARDED_HOST' => 'example.com', 'SERVER_PORT' => '8080') + assert_equal 'http://example.com/foo', response['Location'] + end + + it 'accepts absolute URIs' do + mock_app do + get('/') do + redirect 'http://google.com' + fail 'redirect should halt' + end + end + + get '/' + assert_equal 302, status + assert_equal '', body + assert_equal 'http://google.com', response['Location'] + end + + it 'accepts absolute URIs with a different schema' do + mock_app do + get('/') do + redirect 'mailto:jsmith@example.com' + fail 'redirect should halt' + end + end + + get '/' + assert_equal 302, status + assert_equal '', body + assert_equal 'mailto:jsmith@example.com', response['Location'] + end + + it 'accepts a URI object instead of a String' do + mock_app do + get('/') { redirect URI.parse('http://sinatrarb.com') } + end + + get '/' + assert_equal 302, status + assert_equal '', body + assert_equal 'http://sinatrarb.com', response['Location'] end end describe 'error' do it 'sets a status code and halts' do - mock_app { - get '/' do + mock_app do + get('/') do error 501 fail 'error should halt' end - } + end get '/' assert_equal 501, status @@ -103,25 +313,103 @@ def test_default end it 'takes an optional body' do - mock_app { - get '/' do + mock_app do + get('/') do error 501, 'FAIL' fail 'error should halt' end - } + end get '/' assert_equal 501, status assert_equal 'FAIL', body end - it 'uses a 500 status code when first argument is a body' do - mock_app { + it 'should not invoke error handler when setting status inside an error handler' do + mock_app do + disable :raise_errors + not_found do + body "not_found handler" + status 404 + end + + error do + body "error handler" + status 404 + end + + get '/' do + raise + end + end + + get '/' + assert_equal 404, status + assert_equal 'error handler', body + end + + it 'should not reset the content-type to html for error handlers' do + mock_app do + disable :raise_errors + before { content_type "application/json" } + not_found { JSON.dump("error" => "Not Found") } + end + + get '/' + assert_equal 404, status + assert_equal 'application/json', response.content_type + end + + it 'should not invoke error handler when halting with 500 inside an error handler' do + mock_app do + disable :raise_errors + not_found do + body "not_found handler" + halt 404 + end + + error do + body "error handler" + halt 404 + end + get '/' do + raise + end + end + + get '/' + assert_equal 404, status + assert_equal 'error handler', body + end + + it 'should not invoke not_found handler when halting with 404 inside a not found handler' do + mock_app do + disable :raise_errors + + not_found do + body "not_found handler" + halt 500 + end + + error do + body "error handler" + halt 500 + end + end + + get '/' + assert_equal 500, status + assert_equal 'not_found handler', body + end + + it 'uses a 500 status code when first argument is a body' do + mock_app do + get('/') do error 'FAIL' fail 'error should halt' end - } + end get '/' assert_equal 500, status @@ -131,27 +419,40 @@ def test_default describe 'not_found' do it 'halts with a 404 status' do - mock_app { - get '/' do + mock_app do + get('/') do not_found fail 'not_found should halt' end - } + end get '/' assert_equal 404, status assert_equal '', body end + + it 'does not set a X-Cascade header' do + mock_app do + get('/') do + not_found + fail 'not_found should halt' + end + end + + get '/' + assert_equal 404, status + assert_nil response.headers['X-Cascade'] + end end describe 'headers' do it 'sets headers on the response object when given a Hash' do - mock_app { - get '/' do + mock_app do + get('/') do headers 'X-Foo' => 'bar', 'X-Baz' => 'bling' 'kthx' end - } + end get '/' assert ok? @@ -161,12 +462,12 @@ def test_default end it 'returns the response headers hash when no hash provided' do - mock_app { - get '/' do + mock_app do + get('/') do headers['X-Foo'] = 'bar' 'kthx' end - } + end get '/' assert ok? @@ -176,35 +477,98 @@ def test_default describe 'session' do it 'uses the existing rack.session' do - mock_app { - get '/' do + mock_app do + get('/') do session[:foo] end - } + end - get '/', {}, { 'rack.session' => { :foo => 'bar' } } + get('/', {}, { 'rack.session' => { :foo => 'bar' } }) assert_equal 'bar', body end it 'creates a new session when none provided' do - mock_app { + mock_app do enable :sessions - get '/' do - assert session.empty? + get('/') do + assert session[:foo].nil? session[:foo] = 'bar' redirect '/hi' end - get '/hi' do + get('/hi') do "hi #{session[:foo]}" end - } + end get '/' follow_redirect! assert_equal 'hi bar', body end + + it 'inserts session middleware' do + mock_app do + enable :sessions + + get('/') do + assert env['rack.session'] + assert env['rack.session.options'] + 'ok' + end + end + + get '/' + assert_body 'ok' + end + + it 'sets a default session secret' do + mock_app do + enable :sessions + + get('/') do + secret = env['rack.session.options'][:secret] + assert secret + assert_equal secret, settings.session_secret + 'ok' + end + end + + get '/' + assert_body 'ok' + end + + it 'allows disabling session secret' do + mock_app do + enable :sessions + disable :session_secret + + get('/') do + assert !env['rack.session.options'].include?(:session_secret) + 'ok' + end + end + + # Silence warnings since Rack::Session::Cookie complains about the non-present session secret + silence_warnings do + get '/' + end + assert_body 'ok' + end + + it 'accepts an options hash' do + mock_app do + set :sessions, :foo => :bar + + get('/') do + assert_equal env['rack.session.options'][:foo], :bar + 'ok' + end + end + + get '/' + assert_body 'ok' + end end describe 'mime_type' do @@ -228,16 +592,22 @@ def test_default it 'returns the argument when given a media type string' do assert_equal 'text/plain', mime_type('text/plain') end + + it 'turns AcceptEntry into String' do + type = mime_type(Sinatra::Request::AcceptEntry.new('text/plain')) + assert_equal String, type.class + assert_equal 'text/plain', type + end end test 'Base.mime_type registers mime type' do - mock_app { + mock_app do mime_type :foo, 'application/foo' - get '/' do + get('/') do "foo is #{mime_type(:foo)}" end - } + end get '/' assert_equal 'foo is application/foo', body @@ -245,40 +615,40 @@ def test_default describe 'content_type' do it 'sets the Content-Type header' do - mock_app { - get '/' do + mock_app do + get('/') do content_type 'text/plain' 'Hello World' end - } + end get '/' - assert_equal 'text/plain', response['Content-Type'] + assert_equal 'text/plain;charset=utf-8', response['Content-Type'] assert_equal 'Hello World', body end it 'takes media type parameters (like charset=)' do - mock_app { - get '/' do - content_type 'text/html', :charset => 'utf-8' + mock_app do + get('/') do + content_type 'text/html', :charset => 'latin1' "

Hello, World

" end - } + end get '/' assert ok? - assert_equal 'text/html;charset=utf-8', response['Content-Type'] + assert_equal 'text/html;charset=latin1', response['Content-Type'] assert_equal "

Hello, World

", body end it "looks up symbols in Rack's mime types dictionary" do Rack::Mime::MIME_TYPES['.foo'] = 'application/foo' - mock_app { - get '/foo.xml' do + mock_app do + get('/foo.xml') do content_type :foo "I AM FOO" end - } + end get '/foo.xml' assert ok? @@ -287,20 +657,147 @@ def test_default end it 'fails when no mime type is registered for the argument provided' do - mock_app { - get '/foo.xml' do + mock_app do + get('/foo.xml') do content_type :bizzle "I AM FOO" end - } + end - assert_raise(RuntimeError) { get '/foo.xml' } + assert_raises(RuntimeError) { get '/foo.xml' } + end + + it 'only sets default charset for specific mime types' do + tests_ran = false + mock_app do + mime_type :foo, 'text/foo' + mime_type :bar, 'application/bar' + mime_type :baz, 'application/baz' + add_charset << mime_type(:baz) + get('/') do + assert_equal content_type(:txt), 'text/plain;charset=utf-8' + assert_equal content_type(:css), 'text/css;charset=utf-8' + assert_equal content_type(:html), 'text/html;charset=utf-8' + assert_equal content_type(:foo), 'text/foo;charset=utf-8' + assert_equal content_type(:xml), 'application/xml;charset=utf-8' + assert_equal content_type(:xhtml), 'application/xhtml+xml;charset=utf-8' + assert_equal content_type(:js), 'application/javascript;charset=utf-8' + assert_equal content_type(:json), 'application/json' + assert_equal content_type(:bar), 'application/bar' + assert_equal content_type(:png), 'image/png' + assert_equal content_type(:baz), 'application/baz;charset=utf-8' + tests_ran = true + "done" + end + end + + get '/' + assert tests_ran + end + + it 'handles already present params' do + mock_app do + get('/') do + content_type 'foo/bar;level=1', :charset => 'utf-8' + 'ok' + end + end + + get '/' + assert_equal 'foo/bar;level=1, charset=utf-8', response['Content-Type'] + end + + it 'does not add charset if present' do + mock_app do + get('/') do + content_type 'text/plain;charset=utf-16' + 'ok' + end + end + + get '/' + assert_equal 'text/plain;charset=utf-16', response['Content-Type'] + end + + it 'properly encodes parameters with delimiter characters' do + mock_app do + before '/comma' do + content_type 'image/png', :comment => 'Hello, world!' + end + before '/semicolon' do + content_type 'image/png', :comment => 'semi;colon' + end + before '/quote' do + content_type 'image/png', :comment => '"Whatever."' + end + + get('*') { 'ok' } + end + + get '/comma' + assert_equal 'image/png;comment="Hello, world!"', response['Content-Type'] + get '/semicolon' + assert_equal 'image/png;comment="semi;colon"', response['Content-Type'] + get '/quote' + assert_equal 'image/png;comment="\"Whatever.\""', response['Content-Type'] + end + end + + describe 'attachment' do + def attachment_app(filename=nil) + mock_app do + get('/attachment') do + attachment filename + response.write("") + end + end + end + + it 'sets the Content-Type response header' do + attachment_app('test.xml') + get '/attachment' + assert_equal 'application/xml;charset=utf-8', response['Content-Type'] + assert_equal '', body + end + + it 'sets the Content-Type response header without extname' do + attachment_app('test') + get '/attachment' + assert_equal 'text/html;charset=utf-8', response['Content-Type'] + assert_equal '', body + end + + it 'sets the Content-Type response header with extname' do + mock_app do + get('/attachment') do + content_type :atom + attachment 'test.xml' + response.write("") + end + end + + get '/attachment' + assert_equal 'application/atom+xml', response['Content-Type'] + assert_equal '', body + end + + it 'escapes filename in the Content-Disposition header according to the multipart form data spec in WHATWG living standard' do + mock_app do + get('/attachment') do + attachment "test.xml\";\r\next=.txt" + response.write("") + end + end + + get '/attachment' + assert_equal 'attachment; filename="test.xml%22;%0D%0Aext=.txt"', response['Content-Disposition'] + assert_equal '', body end end describe 'send_file' do setup do - @file = File.dirname(__FILE__) + '/file.txt' + @file = __dir__ + '/file.txt' File.open(@file, 'wb') { |io| io.write('Hello World') } end @@ -328,7 +825,19 @@ def send_file_app(opts={}) it 'sets the Content-Type response header if a mime-type can be located' do send_file_app get '/file.txt' - assert_equal 'text/plain', response['Content-Type'] + assert_equal 'text/plain;charset=utf-8', response['Content-Type'] + end + + it 'sets the Content-Type response header if type option is set to a file extension' do + send_file_app :type => 'html' + get '/file.txt' + assert_equal 'text/html;charset=utf-8', response['Content-Type'] + end + + it 'sets the Content-Type response header if type option is set to a mime type' do + send_file_app :type => 'application/octet-stream' + get '/file.txt' + assert_equal 'application/octet-stream', response['Content-Type'] end it 'sets the Content-Length response header' do @@ -343,11 +852,16 @@ def send_file_app(opts={}) assert_equal File.mtime(@file).httpdate, response['Last-Modified'] end + it 'allows passing in a different Last-Modified response header with :last_modified' do + time = Time.now + send_file_app :last_modified => time + get '/file.txt' + assert_equal time.httpdate, response['Last-Modified'] + end + it "returns a 404 when not found" do mock_app { - get '/' do - send_file 'this-file-does-not-exist.txt' - end + get('/') { send_file 'this-file-does-not-exist.txt' } } get '/' assert not_found? @@ -365,157 +879,1066 @@ def send_file_app(opts={}) assert_equal 'attachment; filename="file.txt"', response['Content-Disposition'] end + it "does not set add a file name if filename is false" do + send_file_app :disposition => 'inline', :filename => false + get '/file.txt' + assert_equal 'inline', response['Content-Disposition'] + end + + it "sets the Content-Disposition header when :disposition set to 'inline'" do + send_file_app :disposition => 'inline' + get '/file.txt' + assert_equal 'inline; filename="file.txt"', response['Content-Disposition'] + end + + it "does not raise an error when :disposition set to a frozen string" do + send_file_app :disposition => 'inline'.freeze + get '/file.txt' + assert_equal 'inline; filename="file.txt"', response['Content-Disposition'] + end + it "sets the Content-Disposition header when :filename provided" do send_file_app :filename => 'foo.txt' get '/file.txt' assert_equal 'attachment; filename="foo.txt"', response['Content-Disposition'] end + + it 'allows setting a custom status code' do + send_file_app :status => 201 + get '/file.txt' + assert_status 201 + end + + it "is able to send files with unknown mime type" do + @file = __dir__ + '/file.foobar' + File.open(@file, 'wb') { |io| io.write('Hello World') } + send_file_app + get '/file.txt' + assert_equal 'application/octet-stream', response['Content-Type'] + end + + it "does not override Content-Type if already set and no explicit type is given" do + path = @file + mock_app do + get('/') do + content_type :png + send_file path + end + end + get '/' + assert_equal 'image/png', response['Content-Type'] + end + + it "does override Content-Type even if already set, if explicit type is given" do + path = @file + mock_app do + get('/') do + content_type :png + send_file path, :type => :gif + end + end + get '/' + assert_equal 'image/gif', response['Content-Type'] + end + + it 'can have :status option as a string' do + path = @file + mock_app do + post '/' do + send_file path, :status => '422' + end + end + post '/' + assert_equal response.status, 422 + end end describe 'cache_control' do setup do - mock_app { - get '/' do - cache_control :public, :no_cache, :max_age => 60 + mock_app do + get('/foo') do + cache_control :public, :no_cache, :max_age => 60.0 'Hello World' end - } + + get('/bar') do + cache_control :public, :no_cache + 'Hello World' + end + end end it 'sets the Cache-Control header' do - get '/' + get '/foo' assert_equal ['public', 'no-cache', 'max-age=60'], response['Cache-Control'].split(', ') end + + it 'last argument does not have to be a hash' do + get '/bar' + assert_equal ['public', 'no-cache'], response['Cache-Control'].split(', ') + end end describe 'expires' do setup do - mock_app { - get '/' do + mock_app do + get('/foo') do expires 60, :public, :no_cache 'Hello World' end - } + + get('/bar') { expires Time.now } + + get('/baz') { expires Time.at(0) } + + get('/bah') { expires Time.at(0), :max_age => 20 } + + get('/blah') do + obj = Object.new + def obj.method_missing(*a, &b) 60.send(*a, &b) end + def obj.is_a?(thing) 60.is_a?(thing) end + expires obj, :public, :no_cache + 'Hello World' + end + + get('/boom') { expires '9999' } + end end it 'sets the Cache-Control header' do - get '/' + get '/foo' assert_equal ['public', 'no-cache', 'max-age=60'], response['Cache-Control'].split(', ') end it 'sets the Expires header' do - get '/' - assert_not_nil response['Expires'] + get '/foo' + refute_nil response['Expires'] + end + + it 'allows passing Time.now objects' do + get '/bar' + refute_nil response['Expires'] + end + + it 'allows passing Time.at objects' do + get '/baz' + assert_equal 'Thu, 01 Jan 1970 00:00:00 GMT', response['Expires'] + end + + it 'allows max_age to be specified separately' do + get '/bah' + assert_equal 'Thu, 01 Jan 1970 00:00:00 GMT', response['Expires'] + assert_equal ['max-age=20'], response['Cache-Control'].split(', ') + end + + it 'accepts values pretending to be a Numeric (like ActiveSupport::Duration)' do + get '/blah' + assert_equal ['public', 'no-cache', 'max-age=60'], response['Cache-Control'].split(', ') + end + + it 'fails when Time.parse raises an ArgumentError' do + assert_raises(ArgumentError) { get '/boom' } end end describe 'last_modified' do - setup do - now = Time.now - mock_app { - get '/' do - body { 'Hello World' } - last_modified now - 'Boo!' - end - } - @now = now - end + it 'ignores nil' do + mock_app { get('/') { last_modified nil; 200; } } - it 'sets the Last-Modified header to a valid RFC 2616 date value' do get '/' - assert_equal @now.httpdate, response['Last-Modified'] + assert ! response['Last-Modified'] end - it 'returns a body when conditional get misses' do - get '/' - assert_equal 200, status - assert_equal 'Boo!', body + it 'does not change a status other than 200' do + mock_app do + get('/') do + status 299 + last_modified Time.at(0) + 'ok' + end + end + + get('/', {}, 'HTTP_IF_MODIFIED_SINCE' => 'Sun, 26 Sep 2030 23:43:52 GMT') + assert_status 299 + assert_body 'ok' end - it 'halts when a conditional GET matches' do - get '/', {}, { 'HTTP_IF_MODIFIED_SINCE' => @now.httpdate } - assert_equal 304, status - assert_equal '', body + [Time.now, DateTime.now, Date.today, Time.now.to_i, + Struct.new(:to_time).new(Time.now) ].each do |last_modified_time| + describe "with #{last_modified_time.class.name}" do + setup do + mock_app do + get('/') do + last_modified last_modified_time + 'Boo!' + end + end + wrapper = Object.new.extend Sinatra::Helpers + @last_modified_time = wrapper.time_for last_modified_time + end + + # fixes strange missing test error when running complete test suite. + it("does not complain about missing tests") { } + + context "when there's no If-Modified-Since header" do + it 'sets the Last-Modified header to a valid RFC 2616 date value' do + get '/' + assert_equal @last_modified_time.httpdate, response['Last-Modified'] + end + + it 'conditional GET misses and returns a body' do + get '/' + assert_equal 200, status + assert_equal 'Boo!', body + end + end + + context "when there's an invalid If-Modified-Since header" do + it 'sets the Last-Modified header to a valid RFC 2616 date value' do + get('/', {}, { 'HTTP_IF_MODIFIED_SINCE' => 'a really weird date' }) + assert_equal @last_modified_time.httpdate, response['Last-Modified'] + end + + it 'conditional GET misses and returns a body' do + get('/', {}, { 'HTTP_IF_MODIFIED_SINCE' => 'a really weird date' }) + assert_equal 200, status + assert_equal 'Boo!', body + end + end + + context "when the resource has been modified since the If-Modified-Since header date" do + it 'sets the Last-Modified header to a valid RFC 2616 date value' do + get('/', {}, { 'HTTP_IF_MODIFIED_SINCE' => (@last_modified_time - 1).httpdate }) + assert_equal @last_modified_time.httpdate, response['Last-Modified'] + end + + it 'conditional GET misses and returns a body' do + get('/', {}, { 'HTTP_IF_MODIFIED_SINCE' => (@last_modified_time - 1).httpdate }) + assert_equal 200, status + assert_equal 'Boo!', body + end + + it 'does not rely on string comparison' do + mock_app do + get('/compare') do + last_modified "Mon, 18 Oct 2010 20:57:11 GMT" + "foo" + end + end + + get('/compare', {}, { 'HTTP_IF_MODIFIED_SINCE' => 'Sun, 26 Sep 2010 23:43:52 GMT' }) + assert_equal 200, status + assert_equal 'foo', body + get('/compare', {}, { 'HTTP_IF_MODIFIED_SINCE' => 'Sun, 26 Sep 2030 23:43:52 GMT' }) + assert_equal 304, status + assert_equal '', body + end + end + + context "when the resource has been modified on the exact If-Modified-Since header date" do + it 'sets the Last-Modified header to a valid RFC 2616 date value' do + get('/', {}, { 'HTTP_IF_MODIFIED_SINCE' => @last_modified_time.httpdate }) + assert_equal @last_modified_time.httpdate, response['Last-Modified'] + end + + it 'conditional GET matches and halts' do + get( '/', {}, { 'HTTP_IF_MODIFIED_SINCE' => @last_modified_time.httpdate }) + assert_equal 304, status + assert_equal '', body + end + end + + context "when the resource hasn't been modified since the If-Modified-Since header date" do + it 'sets the Last-Modified header to a valid RFC 2616 date value' do + get('/', {}, { 'HTTP_IF_MODIFIED_SINCE' => (@last_modified_time + 1).httpdate }) + assert_equal @last_modified_time.httpdate, response['Last-Modified'] + end + + it 'conditional GET matches and halts' do + get('/', {}, { 'HTTP_IF_MODIFIED_SINCE' => (@last_modified_time + 1).httpdate }) + assert_equal 304, status + assert_equal '', body + end + end + + context "If-Unmodified-Since" do + it 'results in 200 if resource has not been modified' do + get('/', {}, { 'HTTP_IF_UNMODIFIED_SINCE' => 'Sun, 26 Sep 2030 23:43:52 GMT' }) + assert_equal 200, status + assert_equal 'Boo!', body + end + + it 'results in 412 if resource has been modified' do + get('/', {}, { 'HTTP_IF_UNMODIFIED_SINCE' => Time.at(0).httpdate }) + assert_equal 412, status + assert_equal '', body + end + end + end end end describe 'etag' do - setup do - mock_app { - get '/' do - body { 'Hello World' } - etag 'FOO' - 'Boo!' + context "safe requests" do + it 'returns 200 for normal requests' do + mock_app do + get('/') do + etag 'foo' + 'ok' + end end - } - end - it 'sets the ETag header' do - get '/' - assert_equal '"FOO"', response['ETag'] - end + get '/' + assert_status 200 + assert_body 'ok' + end - it 'returns a body when conditional get misses' do - get '/' - assert_equal 200, status - assert_equal 'Boo!', body + context "If-None-Match" do + it 'returns 304 when If-None-Match is *' do + mock_app do + get('/') do + etag 'foo' + 'ok' + end + end + + get('/', {}, 'HTTP_IF_NONE_MATCH' => '*') + assert_status 304 + assert_body '' + end + + it 'returns 200 when If-None-Match is * for new resources' do + mock_app do + get('/') do + etag 'foo', :new_resource => true + 'ok' + end + end + + get('/', {}, 'HTTP_IF_NONE_MATCH' => '*') + assert_status 200 + assert_body 'ok' + end + + it 'returns 304 when If-None-Match is * for existing resources' do + mock_app do + get('/') do + etag 'foo', :new_resource => false + 'ok' + end + end + + get('/', {}, 'HTTP_IF_NONE_MATCH' => '*') + assert_status 304 + assert_body '' + end + + it 'returns 304 when If-None-Match is the etag' do + mock_app do + get('/') do + etag 'foo' + 'ok' + end + end + + get('/', {}, 'HTTP_IF_NONE_MATCH' => '"foo"') + assert_status 304 + assert_body '' + end + + it 'returns 304 when If-None-Match includes the etag' do + mock_app do + get('/') do + etag 'foo' + 'ok' + end + end + + get('/', {}, 'HTTP_IF_NONE_MATCH' => '"bar", "foo"') + assert_status 304 + assert_body '' + end + + it 'returns 200 when If-None-Match does not include the etag' do + mock_app do + get('/') do + etag 'foo' + 'ok' + end + end + + get('/', {}, 'HTTP_IF_NONE_MATCH' => '"bar"') + assert_status 200 + assert_body 'ok' + end + + it 'ignores If-Modified-Since if If-None-Match does not match' do + mock_app do + get('/') do + etag 'foo' + last_modified Time.at(0) + 'ok' + end + end + + get('/', {}, 'HTTP_IF_NONE_MATCH' => '"bar"') + assert_status 200 + assert_body 'ok' + end + + it 'does not change a status code other than 2xx or 304' do + mock_app do + get('/') do + status 499 + etag 'foo' + 'ok' + end + end + + get('/', {}, 'HTTP_IF_NONE_MATCH' => '"foo"') + assert_status 499 + assert_body 'ok' + end + + it 'does change 2xx status codes' do + mock_app do + get('/') do + status 299 + etag 'foo' + 'ok' + end + end + + get('/', {}, 'HTTP_IF_NONE_MATCH' => '"foo"') + assert_status 304 + assert_body '' + end + + it 'does not send a body on 304 status codes' do + mock_app do + get('/') do + status 304 + etag 'foo' + 'ok' + end + end + + get('/', {}, 'HTTP_IF_NONE_MATCH' => '"foo"') + assert_status 304 + assert_body '' + end + end + + context "If-Match" do + it 'returns 200 when If-Match is the etag' do + mock_app do + get('/') do + etag 'foo' + 'ok' + end + end + + get('/', {}, 'HTTP_IF_MATCH' => '"foo"') + assert_status 200 + assert_body 'ok' + end + + it 'returns 200 when If-Match includes the etag' do + mock_app do + get('/') do + etag 'foo' + 'ok' + end + end + + get('/', {}, 'HTTP_IF_MATCH' => '"foo", "bar"') + assert_status 200 + assert_body 'ok' + end + + it 'returns 200 when If-Match is *' do + mock_app do + get('/') do + etag 'foo' + 'ok' + end + end + + get('/', {}, 'HTTP_IF_MATCH' => '*') + assert_status 200 + assert_body 'ok' + end + + it 'returns 412 when If-Match is * for new resources' do + mock_app do + get('/') do + etag 'foo', :new_resource => true + 'ok' + end + end + + get('/', {}, 'HTTP_IF_MATCH' => '*') + assert_status 412 + assert_body '' + end + + it 'returns 200 when If-Match is * for existing resources' do + mock_app do + get('/') do + etag 'foo', :new_resource => false + 'ok' + end + end + + get('/', {}, 'HTTP_IF_MATCH' => '*') + assert_status 200 + assert_body 'ok' + end + + it 'returns 412 when If-Match does not include the etag' do + mock_app do + get('/') do + etag 'foo' + 'ok' + end + end + + get('/', {}, 'HTTP_IF_MATCH' => '"bar"') + assert_status 412 + assert_body '' + end + end end - it 'halts when a conditional GET matches' do - get '/', {}, { 'HTTP_IF_NONE_MATCH' => '"FOO"' } - assert_equal 304, status - assert_equal '', body + context "idempotent requests" do + it 'returns 200 for normal requests' do + mock_app do + put('/') do + etag 'foo' + 'ok' + end + end + + put '/' + assert_status 200 + assert_body 'ok' + end + + context "If-None-Match" do + it 'returns 412 when If-None-Match is *' do + mock_app do + put('/') do + etag 'foo' + 'ok' + end + end + + put('/', {}, 'HTTP_IF_NONE_MATCH' => '*') + assert_status 412 + assert_body '' + end + + it 'returns 200 when If-None-Match is * for new resources' do + mock_app do + put('/') do + etag 'foo', :new_resource => true + 'ok' + end + end + + put('/', {}, 'HTTP_IF_NONE_MATCH' => '*') + assert_status 200 + assert_body 'ok' + end + + it 'returns 412 when If-None-Match is * for existing resources' do + mock_app do + put('/') do + etag 'foo', :new_resource => false + 'ok' + end + end + + put('/', {}, 'HTTP_IF_NONE_MATCH' => '*') + assert_status 412 + assert_body '' + end + + it 'returns 412 when If-None-Match is the etag' do + mock_app do + put '/' do + etag 'foo' + 'ok' + end + end + + put('/', {}, 'HTTP_IF_NONE_MATCH' => '"foo"') + assert_status 412 + assert_body '' + end + + it 'returns 412 when If-None-Match includes the etag' do + mock_app do + put('/') do + etag 'foo' + 'ok' + end + end + + put('/', {}, 'HTTP_IF_NONE_MATCH' => '"bar", "foo"') + assert_status 412 + assert_body '' + end + + it 'returns 200 when If-None-Match does not include the etag' do + mock_app do + put('/') do + etag 'foo' + 'ok' + end + end + + put('/', {}, 'HTTP_IF_NONE_MATCH' => '"bar"') + assert_status 200 + assert_body 'ok' + end + + it 'ignores If-Modified-Since if If-None-Match does not match' do + mock_app do + put('/') do + etag 'foo' + last_modified Time.at(0) + 'ok' + end + end + + put('/', {}, 'HTTP_IF_NONE_MATCH' => '"bar"') + assert_status 200 + assert_body 'ok' + end + end + + context "If-Match" do + it 'returns 200 when If-Match is the etag' do + mock_app do + put('/') do + etag 'foo' + 'ok' + end + end + + put('/', {}, 'HTTP_IF_MATCH' => '"foo"') + assert_status 200 + assert_body 'ok' + end + + it 'returns 200 when If-Match includes the etag' do + mock_app do + put('/') do + etag 'foo' + 'ok' + end + end + + put('/', {}, 'HTTP_IF_MATCH' => '"foo", "bar"') + assert_status 200 + assert_body 'ok' + end + + it 'returns 200 when If-Match is *' do + mock_app do + put('/') do + etag 'foo' + 'ok' + end + end + + put('/', {}, 'HTTP_IF_MATCH' => '*') + assert_status 200 + assert_body 'ok' + end + + it 'returns 412 when If-Match is * for new resources' do + mock_app do + put('/') do + etag 'foo', :new_resource => true + 'ok' + end + end + + put('/', {}, 'HTTP_IF_MATCH' => '*') + assert_status 412 + assert_body '' + end + + it 'returns 200 when If-Match is * for existing resources' do + mock_app do + put('/') do + etag 'foo', :new_resource => false + 'ok' + end + end + + put('/', {}, 'HTTP_IF_MATCH' => '*') + assert_status 200 + assert_body 'ok' + end + + it 'returns 412 when If-Match does not include the etag' do + mock_app do + put('/') do + etag 'foo' + 'ok' + end + end + + put('/', {}, 'HTTP_IF_MATCH' => '"bar"') + assert_status 412 + assert_body '' + end + end end - it 'should handle multiple ETag values in If-None-Match header' do - get '/', {}, { 'HTTP_IF_NONE_MATCH' => '"BAR", *' } - assert_equal 304, status - assert_equal '', body + context "post requests" do + it 'returns 200 for normal requests' do + mock_app do + post('/') do + etag 'foo' + 'ok' + end + end + + post('/') + assert_status 200 + assert_body 'ok' + end + + context "If-None-Match" do + it 'returns 200 when If-None-Match is *' do + mock_app do + post('/') do + etag 'foo' + 'ok' + end + end + + post('/', {}, 'HTTP_IF_NONE_MATCH' => '*') + assert_status 200 + assert_body 'ok' + end + + it 'returns 200 when If-None-Match is * for new resources' do + mock_app do + post('/') do + etag 'foo', :new_resource => true + 'ok' + end + end + + post('/', {}, 'HTTP_IF_NONE_MATCH' => '*') + assert_status 200 + assert_body 'ok' + end + + it 'returns 412 when If-None-Match is * for existing resources' do + mock_app do + post('/') do + etag 'foo', :new_resource => false + 'ok' + end + end + + post('/', {}, 'HTTP_IF_NONE_MATCH' => '*') + assert_status 412 + assert_body '' + end + + it 'returns 412 when If-None-Match is the etag' do + mock_app do + post('/') do + etag 'foo' + 'ok' + end + end + + post('/', {}, 'HTTP_IF_NONE_MATCH' => '"foo"') + assert_status 412 + assert_body '' + end + + it 'returns 412 when If-None-Match includes the etag' do + mock_app do + post('/') do + etag 'foo' + 'ok' + end + end + + post('/', {}, 'HTTP_IF_NONE_MATCH' => '"bar", "foo"') + assert_status 412 + assert_body '' + end + + it 'returns 200 when If-None-Match does not include the etag' do + mock_app do + post('/') do + etag 'foo' + 'ok' + end + end + + post('/', {}, 'HTTP_IF_NONE_MATCH' => '"bar"') + assert_status 200 + assert_body 'ok' + end + + it 'ignores If-Modified-Since if If-None-Match does not match' do + mock_app do + post('/') do + etag 'foo' + last_modified Time.at(0) + 'ok' + end + end + + post('/', {}, 'HTTP_IF_NONE_MATCH' => '"bar"') + assert_status 200 + assert_body 'ok' + end + end + + context "If-Match" do + it 'returns 200 when If-Match is the etag' do + mock_app do + post('/') do + etag 'foo' + 'ok' + end + end + + post('/', {}, 'HTTP_IF_MATCH' => '"foo"') + assert_status 200 + assert_body 'ok' + end + + it 'returns 200 when If-Match includes the etag' do + mock_app do + post('/') do + etag 'foo' + 'ok' + end + end + + post('/', {}, 'HTTP_IF_MATCH' => '"foo", "bar"') + assert_status 200 + assert_body 'ok' + end + + it 'returns 412 when If-Match is *' do + mock_app do + post('/') do + etag 'foo' + 'ok' + end + end + + post('/', {}, 'HTTP_IF_MATCH' => '*') + assert_status 412 + assert_body '' + end + + it 'returns 412 when If-Match is * for new resources' do + mock_app do + post('/') do + etag 'foo', :new_resource => true + 'ok' + end + end + + post('/', {}, 'HTTP_IF_MATCH' => '*') + assert_status 412 + assert_body '' + end + + it 'returns 200 when If-Match is * for existing resources' do + mock_app do + post('/') do + etag 'foo', :new_resource => false + 'ok' + end + end + + post('/', {}, 'HTTP_IF_MATCH' => '*') + assert_status 200 + assert_body 'ok' + end + + it 'returns 412 when If-Match does not include the etag' do + mock_app do + post('/') do + etag 'foo' + 'ok' + end + end + + post('/', {}, 'HTTP_IF_MATCH' => '"bar"') + assert_status 412 + assert_body '' + end + end end it 'uses a weak etag with the :weak option' do - mock_app { - get '/' do + mock_app do + get('/') do etag 'FOO', :weak "that's weak, dude." end - } + end get '/' assert_equal 'W/"FOO"', response['ETag'] end + + it 'raises an ArgumentError for an invalid strength' do + mock_app do + get('/') do + etag 'FOO', :w00t + "that's weak, dude." + end + end + assert_raises(ArgumentError) { get('/') } + end end describe 'back' do it "makes redirecting back pretty" do - mock_app { - get '/foo' do - redirect back - end - } + mock_app { get('/foo') { redirect back } } - get '/foo', {}, 'HTTP_REFERER' => 'http://github.com' + get('/foo', {}, 'HTTP_REFERER' => 'http://github.com') assert redirect? assert_equal "http://github.com", response.location end end + describe 'uri' do + it 'generates absolute urls' do + mock_app { get('/') { uri }} + get '/' + assert_equal 'http://example.org/', body + end + + it 'includes path_info' do + mock_app { get('/:name') { uri }} + get '/foo' + assert_equal 'http://example.org/foo', body + end + + it 'allows passing an alternative to path_info' do + mock_app { get('/:name') { uri '/bar' }} + get '/foo' + assert_equal 'http://example.org/bar', body + end + + it 'includes script_name' do + mock_app { get('/:name') { uri '/bar' }} + get '/foo', {}, { "SCRIPT_NAME" => '/foo' } + assert_equal 'http://example.org/foo/bar', body + end + + it 'handles integer input' do + mock_app { get('/') { uri 123 }} + get '/' + assert_equal 'http://example.org/123', body + end + + it 'handles absolute URIs' do + mock_app { get('/') { uri 'http://google.com' }} + get '/' + assert_equal 'http://google.com', body + end + + it 'handles different protocols' do + mock_app { get('/') { uri 'mailto:jsmith@example.com' }} + get '/' + assert_equal 'mailto:jsmith@example.com', body + end + + it 'is aliased to #url' do + mock_app { get('/') { url }} + get '/' + assert_equal 'http://example.org/', body + end + + it 'is aliased to #to' do + mock_app { get('/') { to }} + get '/' + assert_equal 'http://example.org/', body + end + + it 'is case-insensitive' do + mock_app { get('/:foo') { uri params[:foo] }} + assert_equal get('HtTP://google.com').body, get('http://google.com').body + end + + it 'generates relative link for invalid path' do + mock_app { get('/') { uri 'htt^p://google.com' }} + get '/' + assert_equal 'http://example.org/htt^p://google.com', body + end + end + + describe 'logger' do + it 'logging works when logging is enabled' do + mock_app do + enable :logging + get('/') do + logger.info "Program started" + logger.warn "Nothing to do!" + end + end + io = StringIO.new + get '/', {}, 'rack.errors' => io + assert io.string.include?("INFO -- : Program started") + assert io.string.include?("WARN -- : Nothing to do") + end + + it 'logging works when logging is disable, but no output is produced' do + mock_app do + disable :logging + get('/') do + logger.info "Program started" + logger.warn "Nothing to do!" + end + end + io = StringIO.new + get '/', {}, 'rack.errors' => io + assert !io.string.include?("INFO -- : Program started") + assert !io.string.include?("WARN -- : Nothing to do") + end + + it 'does not create a logger when logging is set to nil' do + mock_app do + set :logging, nil + get('/') { logger.inspect } + end + + get '/' + assert_body 'nil' + end + end + module ::HelperOne; def one; '1'; end; end module ::HelperTwo; def two; '2'; end; end describe 'Adding new helpers' do it 'takes a list of modules to mix into the app' do - mock_app { + mock_app do helpers ::HelperOne, ::HelperTwo - get '/one' do - one - end + get('/one') { one } - get '/two' do - two - end - } + get('/two') { two } + end get '/one' assert_equal '1', body @@ -525,36 +1948,104 @@ module ::HelperTwo; def two; '2'; end; end end it 'takes a block to mix into the app' do - mock_app { + mock_app do helpers do def foo 'foo' end end - get '/' do - foo - end - } + get('/') { foo } + end get '/' assert_equal 'foo', body end it 'evaluates the block in class context so that methods can be aliased' do - mock_app { + mock_app do + helpers { alias_method :h, :escape_html } + + get('/') { h('42 < 43') } + end + + get '/' + assert ok? + assert_equal '42 < 43', body + end + + it 'prepends modules so previously-defined methods can be overridden consistently' do + skip <<-EOS + This test will be helpful after switching #helpers's code from Module#include to Module#prepend + See more details: https://github.com/sinatra/sinatra/pull/1214 + EOS + mock_app do helpers do - alias_method :h, :escape_html + def one; nil end + def two; nil end end - get '/' do - h('42 < 43') + helpers ::HelperOne do + def two; '2' end end - } + get('/one') { one } + get('/two') { two } + end + + get '/one' + assert_equal '1', body + + get '/two' + assert_equal '2', body + end + + module HelpersOverloadingBaseHelper + def my_test + 'BaseHelper#test' + end + end + + class HelpersOverloadingIncludeAndOverride < Sinatra::Base + helpers HelpersOverloadingBaseHelper + + get '/' do + my_test + end + + helpers do + def my_test + 'InlineHelper#test' + end + end + end + + it 'uses overloaded inline helper' do + mock_app(HelpersOverloadingIncludeAndOverride) get '/' assert ok? - assert_equal '42 < 43', body + assert_equal 'InlineHelper#test', body + end + + module HelperWithIncluded + def self.included(base) + base.extend(ClassMethods) + end + + module ClassMethods + def nickname(name) + # do something. + end + end + end + + class ServerApp < Sinatra::Base + helpers HelperWithIncluded + # `nickname` method should be available. + end + + it 'calls included method of helpers' do + assert ServerApp.respond_to?(:nickname) end end end diff --git a/test/indifferent_hash_test.rb b/test/indifferent_hash_test.rb new file mode 100644 index 0000000000..2bec6e766e --- /dev/null +++ b/test/indifferent_hash_test.rb @@ -0,0 +1,294 @@ +# frozen_string_literal: true +# +# We don't need the full test helper for this standalone class. +# +require 'minitest/autorun' unless defined?(Minitest) + +require_relative '../lib/sinatra/indifferent_hash' + +class TestIndifferentHashBasics < Minitest::Test + def test_flattened_constructor + hash = Sinatra::IndifferentHash[:a, 1, ?b, 2] + assert_equal 1, hash[?a] + assert_equal 2, hash[?b] + end + + def test_pairs_constructor + hash = Sinatra::IndifferentHash[[[:a, 1], [?b, 2]]] + assert_equal 1, hash[?a] + assert_equal 2, hash[?b] + end + + def test_default_block + hash = Sinatra::IndifferentHash.new { |h, k| h[k] = k.upcase } + assert_nil hash.default + assert_equal ?A, hash.default(:a) + end + + def test_default_object + hash = Sinatra::IndifferentHash.new(:a=>1, ?b=>2) + assert_equal({ ?a=>1, ?b=>2 }, hash.default) + assert_equal({ ?a=>1, ?b=>2 }, hash[:a]) + end + + def test_default_assignment + hash = Sinatra::IndifferentHash.new + hash.default = { :a=>1, ?b=>2 } + assert_equal({ ?a=>1, ?b=>2 }, hash.default) + assert_equal({ ?a=>1, ?b=>2 }, hash[:a]) + end + + def test_assignment + hash = Sinatra::IndifferentHash.new + hash[:a] = :a + hash[?b] = :b + hash[3] = 3 + hash[:simple_nested] = { :a=>:a, ?b=>:b } + + assert_equal :a, hash[?a] + assert_equal :b, hash[?b] + assert_equal 3, hash[3] + assert_equal({ ?a=>:a, ?b=>:b }, hash['simple_nested']) + assert_nil hash[?d] + end + + def test_merge! + # merge! is already mostly tested by the different constructors, so we + # really just need to test the block form here + hash = Sinatra::IndifferentHash[:a=>'a', ?b=>'b', 3=>3] + hash.merge!(?a=>'A', :b=>'B', :d=>'D') do |key, oldval, newval| + "#{oldval}*#{key}*#{newval}" + end + + assert_equal({ ?a=>'a*a*A', ?b=>'b*b*B', 3=>3, ?d=>'D' }, hash) + end +end + +class TestIndifferentHash < Minitest::Test + def setup + @hash = Sinatra::IndifferentHash[:a=>:a, ?b=>:b, 3=>3, + :simple_nested=>{ :a=>:a, ?b=>:b }, + :nested=>{ :a=>[{ :a=>:a, ?b=>:b }, :c, 4], ?f=>:f, 7=>7 } + ] + end + + def test_hash_constructor + assert_equal :a, @hash[?a] + assert_equal :b, @hash[?b] + assert_equal 3, @hash[3] + assert_equal({ ?a=>:a, ?b=>:b }, @hash['nested'][?a][0]) + assert_equal :c, @hash['nested'][?a][1] + assert_equal 4, @hash['nested'][?a][2] + assert_equal :f, @hash['nested'][?f] + assert_equal 7, @hash['nested'][7] + assert_equal :a, @hash['simple_nested'][?a] + assert_equal :b, @hash['simple_nested'][?b] + assert_nil @hash[?d] + end + + def test_assoc + assert_nil @hash.assoc(:d) + assert_equal [?a, :a], @hash.assoc(:a) + assert_equal [?b, :b], @hash.assoc(:b) + end + + def test_rassoc + assert_nil @hash.rassoc(:d) + assert_equal [?a, :a], @hash.rassoc(:a) + assert_equal [?b, :b], @hash.rassoc(:b) + assert_equal ['simple_nested', { ?a=>:a, ?b=>:b }], @hash.rassoc(:a=>:a, ?b=>:b) + end + + def test_fetch + assert_raises(KeyError) { @hash.fetch(:d) } + assert_equal 1, @hash.fetch(:d, 1) + assert_equal 2, @hash.fetch(:d) { 2 } + assert_equal ?d, @hash.fetch(:d) { |k| k } + assert_equal :a, @hash.fetch(:a, 1) + assert_equal :a, @hash.fetch(:a) { 2 } + end + + def test_symbolic_retrieval + assert_equal :a, @hash[:a] + assert_equal :b, @hash[:b] + assert_equal({ ?a=>:a, ?b=>:b }, @hash[:nested][:a][0]) + assert_equal :c, @hash[:nested][:a][1] + assert_equal 4, @hash[:nested][:a][2] + assert_equal :f, @hash[:nested][:f] + assert_equal 7, @hash[:nested][7] + assert_equal :a, @hash[:simple_nested][:a] + assert_equal :b, @hash[:simple_nested][:b] + assert_nil @hash[:d] + end + + def test_key + assert_nil @hash.key(:d) + assert_equal ?a, @hash.key(:a) + assert_equal 'simple_nested', @hash.key(:a=>:a, ?b=>:b) + end + + def test_key? + assert_operator @hash, :key?, :a + assert_operator @hash, :key?, ?b + assert_operator @hash, :key?, 3 + refute_operator @hash, :key?, :d + end + + def test_value? + assert_operator @hash, :value?, :a + assert_operator @hash, :value?, :b + assert_operator @hash, :value?, 3 + assert_operator @hash, :value?, { :a=>:a, ?b=>:b } + refute_operator @hash, :value?, :d + end + + def test_delete + @hash.delete(:a) + @hash.delete(?b) + assert_nil @hash[:a] + assert_nil @hash[?b] + end + + def test_dig + assert_equal :a, @hash.dig(:a) + assert_equal :b, @hash.dig(?b) + assert_nil @hash.dig(:d) + + assert_equal :a, @hash.dig(:simple_nested, :a) + assert_equal :b, @hash.dig('simple_nested', ?b) + assert_nil @hash.dig('simple_nested', :d) + + assert_equal :a, @hash.dig(:nested, :a, 0, :a) + assert_equal :b, @hash.dig('nested', ?a, 0, ?b) + assert_nil @hash.dig('nested', ?a, 0, :d) + end + + def test_slice + assert_equal Sinatra::IndifferentHash[a: :a], @hash.slice(:a) + assert_equal Sinatra::IndifferentHash[b: :b], @hash.slice(?b) + assert_equal Sinatra::IndifferentHash[3 => 3], @hash.slice(3) + assert_equal Sinatra::IndifferentHash.new, @hash.slice(:d) + assert_equal Sinatra::IndifferentHash[a: :a, b: :b, 3 => 3], @hash.slice(:a, :b, 3) + assert_equal Sinatra::IndifferentHash[simple_nested: { a: :a, ?b => :b }], @hash.slice(:simple_nested) + assert_equal Sinatra::IndifferentHash[nested: { a: [{ a: :a, ?b => :b }, :c, 4], ?f => :f, 7 => 7 }], @hash.slice(:nested) + end + + def test_fetch_values + assert_raises(KeyError) { @hash.fetch_values(3, :d) } + assert_equal [:a, :b, 3, ?D], @hash.fetch_values(:a, ?b, 3, :d) { |k| k.upcase } + end + + def test_values_at + assert_equal [:a, :b, 3, nil], @hash.values_at(:a, ?b, 3, :d) + end + + def test_merge + # merge just calls merge!, which is already thoroughly tested + hash2 = @hash.merge(?a=>1, :q=>2) { |key, oldval, newval| "#{oldval}*#{key}*#{newval}" } + + refute_equal @hash, hash2 + assert_equal 'a*a*1', hash2[:a] + assert_equal 2, hash2[?q] + end + + def test_merge_with_multiple_argument + hash = Sinatra::IndifferentHash.new.merge({a: 1}, {b: 2}, {c: 3}) + assert_equal 1, hash[?a] + assert_equal 2, hash[?b] + assert_equal 3, hash[?c] + + hash2 = Sinatra::IndifferentHash[d: 4] + hash3 = {e: 5} + hash.merge!(hash2, hash3) + + assert_equal 4, hash[?d] + assert_equal 5, hash[?e] + end + + def test_replace + @hash.replace(?a=>1, :q=>2) + assert_equal({ ?a=>1, ?q=>2 }, @hash) + end + + def test_transform_values! + @hash.transform_values! { |v| v.is_a?(Hash) ? Hash[v.to_a] : v } + + assert_instance_of Sinatra::IndifferentHash, @hash[:simple_nested] + end + + def test_transform_values + hash2 = @hash.transform_values { |v| v.respond_to?(:upcase) ? v.upcase : v } + + refute_equal @hash, hash2 + assert_equal :A, hash2[:a] + assert_equal :A, hash2[?a] + end + + def test_transform_keys! + @hash.transform_keys! { |k| k.respond_to?(:to_sym) ? k.to_sym : k } + + assert_equal :a, @hash[:a] + assert_equal :a, @hash[?a] + end + + def test_transform_keys + hash2 = @hash.transform_keys { |k| k.respond_to?(:upcase) ? k.upcase : k } + + refute_equal @hash, hash2 + refute_operator hash2, :key?, :a + refute_operator hash2, :key?, ?a + assert_equal :a, hash2[:A] + assert_equal :a, hash2[?A] + end + + def test_select + hash = @hash.select { |k, v| v == :a } + assert_equal Sinatra::IndifferentHash[a: :a], hash + assert_instance_of Sinatra::IndifferentHash, hash + + hash2 = @hash.select { |k, v| true } + assert_equal @hash, hash2 + assert_instance_of Sinatra::IndifferentHash, hash2 + + enum = @hash.select + assert_instance_of Enumerator, enum + end + + def test_select! + @hash.select! { |k, v| v == :a } + assert_equal Sinatra::IndifferentHash[a: :a], @hash + end + + def test_reject + hash = @hash.reject { |k, v| v != :a } + assert_equal Sinatra::IndifferentHash[a: :a], hash + assert_instance_of Sinatra::IndifferentHash, hash + + hash2 = @hash.reject { |k, v| false } + assert_equal @hash, hash2 + assert_instance_of Sinatra::IndifferentHash, hash2 + + enum = @hash.reject + assert_instance_of Enumerator, enum + end + + def test_reject! + @hash.reject! { |k, v| v != :a } + assert_equal Sinatra::IndifferentHash[a: :a], @hash + end + + def test_compact + hash_with_nil_values = @hash.merge({?z => nil}) + compacted_hash = hash_with_nil_values.compact + assert_equal @hash, compacted_hash + assert_instance_of Sinatra::IndifferentHash, compacted_hash + + empty_hash = Sinatra::IndifferentHash.new + compacted_hash = empty_hash.compact + assert_equal empty_hash, compacted_hash + + non_empty_hash = Sinatra::IndifferentHash[a: :a] + compacted_hash = non_empty_hash.compact + assert_equal non_empty_hash, compacted_hash + end +end diff --git a/test/integration/app.rb b/test/integration/app.rb new file mode 100644 index 0000000000..dbc9e3a275 --- /dev/null +++ b/test/integration/app.rb @@ -0,0 +1,81 @@ +$stderr.puts "loading" +require 'sinatra' + +require_relative 'rainbows' if RUBY_ENGINE == 'ruby' + +configure do + set :foo, :bar +end + +get '/app_file' do + content_type :txt + settings.app_file +end + +get '/ping' do + 'pong' +end + +get '/stream' do + stream do |out| + sleep 0.1 + out << "a" + sleep 1.25 + out << "b" + end +end + +get '/mainonly' do + object = Object.new + begin + object.send(:get, '/foo') { } + 'false' + rescue NameError + 'true' + end +end + +set :out, nil +get '/async' do + stream(:keep_open) { |o| (settings.out = o) << "hi!"; sleep 1 } +end + +get '/send' do + settings.out << params[:msg] if params[:msg] + settings.out.close if params[:close] + "ok" +end + +get '/send_file' do + file = File.expand_path '../views/a/in_a.str', __dir__ + send_file file +end + +get '/streaming' do + headers['Content-Length'] = '46' + stream do |out| + out << "It's gonna be legen -\n" + sleep 0.5 + out << " (wait for it) \n" + puts headers + sleep 1 + out << "- dary!\n" + end +end + +class Subclass < Sinatra::Base + set :out, nil + get '/subclass/async' do + stream(:keep_open) { |o| (settings.out = o) << "hi!"; sleep 1 } + end + + get '/subclass/send' do + settings.out << params[:msg] if params[:msg] + settings.out.close if params[:close] + "ok" + end +end + +use Subclass + +$stderr.puts "starting" diff --git a/test/integration/rainbows.conf b/test/integration/rainbows.conf new file mode 100644 index 0000000000..31742e961b --- /dev/null +++ b/test/integration/rainbows.conf @@ -0,0 +1,3 @@ +Rainbows! do + use :EventMachine +end diff --git a/test/integration/rainbows.rb b/test/integration/rainbows.rb new file mode 100644 index 0000000000..895e19a2be --- /dev/null +++ b/test/integration/rainbows.rb @@ -0,0 +1,20 @@ +require 'rainbows' + +module Rack + module Handler + class Rainbows + def self.run(app, **options) + rainbows_options = { + listeners: ["#{options[:Host]}:#{options[:Port]}"], + worker_processes: 1, + timeout: 30, + config_file: ::File.expand_path('rainbows.conf', __dir__), + } + + ::Rainbows::HttpServer.new(app, rainbows_options).start.join + end + end + + register :rainbows, ::Rack::Handler::Rainbows + end +end diff --git a/test/integration_async_helper.rb b/test/integration_async_helper.rb new file mode 100644 index 0000000000..b246c4ad7b --- /dev/null +++ b/test/integration_async_helper.rb @@ -0,0 +1,23 @@ +require File.expand_path('integration_helper', __dir__) + +module IntegrationAsyncHelper + Server = IntegrationHelper::BaseServer + + def it(message, &block) + Server.all_async.each do |server| + next unless server.installed? + super("with #{server.name}: #{message}") { server.run_test(self, &block) } + end + end + + def self.extend_object(obj) + super + + base_port = 5100 + Process.pid % 100 + servers = %w(rainbows puma) + + servers.each_with_index do |server, index| + Server.run(server, base_port+index, async: true) + end + end +end diff --git a/test/integration_async_test.rb b/test/integration_async_test.rb new file mode 100644 index 0000000000..614f93fcaa --- /dev/null +++ b/test/integration_async_test.rb @@ -0,0 +1,40 @@ +require_relative 'test_helper' +require File.expand_path('integration_async_helper', __dir__) + +# These tests are like integration_test, but they test asynchronous streaming. +class IntegrationAsyncTest < Minitest::Test + extend IntegrationAsyncHelper + attr_accessor :server + + it 'streams async' do + Timeout.timeout(3) do + chunks = [] + server.get_stream '/async' do |chunk| + next if chunk.empty? + chunks << chunk + case chunk + when "hi!" then server.get "/send?msg=hello" + when "hello" then server.get "/send?close=1" + end + end + + assert_equal ['hi!', 'hello'], chunks + end + end + + it 'streams async from subclass' do + Timeout.timeout(3) do + chunks = [] + server.get_stream '/subclass/async' do |chunk| + next if chunk.empty? + chunks << chunk + case chunk + when "hi!" then server.get "/subclass/send?msg=hello" + when "hello" then server.get "/subclass/send?close=1" + end + end + + assert_equal ['hi!', 'hello'], chunks + end + end +end diff --git a/test/integration_helper.rb b/test/integration_helper.rb new file mode 100644 index 0000000000..997fa701c1 --- /dev/null +++ b/test/integration_helper.rb @@ -0,0 +1,146 @@ +require 'sinatra/base' +require 'rbconfig' +require 'open-uri' +require 'sinatra/runner' + +module IntegrationHelper + class BaseServer < Sinatra::Runner + extend Enumerable + attr_accessor :server, :port + alias name server + + def self.all + @all ||= [] + end + + def self.all_async + @all_async ||= [] + end + + def self.each(&block) + all.each(&block) + end + + def self.run(server, port, async: false) + new(server, port, async).run + end + + def app_file + File.expand_path('integration/app.rb', __dir__) + end + + def environment + "development" + end + + def initialize(server, port, async) + @installed, @pipe, @server, @port = nil, nil, server, port + ENV['PUMA_MIN_THREADS'] = '1' if server == 'puma' + if async + Server.all_async << self + else + Server.all << self + end + end + + def run + return unless installed? + kill + @log = "" + super + at_exit { kill } + end + + def installed? + return @installed unless @installed.nil? + s = server == 'HTTP' ? 'net/http/server' : server + require s + @installed = true + rescue LoadError + warn "#{server} is not installed, skipping integration tests" + @installed = false + end + + def command + @command ||= begin + cmd = ["APP_ENV=#{environment}", "exec"] + if RbConfig.respond_to? :ruby + cmd << RbConfig.ruby.inspect + else + file, dir = RbConfig::CONFIG.values_at('ruby_install_name', 'bindir') + cmd << File.expand_path(file, dir).inspect + end + cmd << "-w" unless net_http_server? + cmd << "-I" << File.expand_path('../lib', __dir__).inspect + cmd << app_file.inspect << '-s' << server << '-o' << '127.0.0.1' << '-p' << port + cmd << "-e" << environment.to_s << '2>&1' + cmd.join " " + end + end + + def webrick? + name.to_s == "webrick" + end + + def rainbows? + name.to_s == "rainbows" + end + + def puma? + name.to_s == "puma" + end + + def falcon? + name.to_s == "falcon" + end + + def trinidad? + name.to_s == "trinidad" + end + + def net_http_server? + name.to_s == 'HTTP' + end + + def warnings + log.scan(%r[(?:\(eval|lib/sinatra).*warning:.*$]) + end + + def run_test(target, &block) + retries ||= 3 + target.server = self + run unless alive? + target.instance_eval(&block) + rescue Exception => error + retries -= 1 + kill + retries < 0 ? retry : raise(error) + end + end + + Server = BaseServer + + def it(message, &block) + Server.each do |server| + next unless server.installed? + super("with #{server.name}: #{message}") { server.run_test(self, &block) } + end + end + + def self.extend_object(obj) + super + + base_port = 5000 + Process.pid % 100 + servers = Sinatra::Base.server.dup + + # TruffleRuby doesn't support `Fiber.set_scheduler` yet + if RUBY_ENGINE == "truffleruby" && !Fiber.respond_to?(:set_scheduler) + warn "skip falcon server" + servers.delete('falcon') + end + + servers.each_with_index do |server, index| + Server.run(server, base_port+index) + end + end +end diff --git a/test/integration_test.rb b/test/integration_test.rb new file mode 100644 index 0000000000..74b21cf56b --- /dev/null +++ b/test/integration_test.rb @@ -0,0 +1,70 @@ +require_relative 'test_helper' +require File.expand_path('integration_helper', __dir__) + +# These tests start a real server and talk to it over TCP. +# Every test runs with every detected server. +# +# See test/integration/app.rb for the code of the app we test against. +class IntegrationTest < Minitest::Test + extend IntegrationHelper + attr_accessor :server + + it('sets the app_file') { assert_equal server.app_file, server.get("/app_file") } + it('only extends main') { assert_equal "true", server.get("/mainonly") } + + it 'logs once in development mode' do + next if server.puma? or server.falcon? or server.rainbows? or RUBY_ENGINE == 'jruby' + random = "%064x" % Kernel.rand(2**256-1) + server.get "/ping?x=#{random}" + count = server.log.scan("GET /ping?x=#{random}").count + if server.net_http_server? + assert_equal 0, count + elsif server.webrick? + assert(count > 0) + else + assert_equal(1, count) + end + end + + it 'streams' do + next if server.webrick? or server.trinidad? + times, chunks = [Process.clock_gettime(Process::CLOCK_MONOTONIC)], [] + server.get_stream do |chunk| + next if chunk.empty? + chunks << chunk + times << Process.clock_gettime(Process::CLOCK_MONOTONIC) + end + assert_equal ["a", "b"], chunks + int1 = (times[1] - times[0]).round 2 + int2 = (times[2] - times[1]).round 2 + assert_operator 1, :>, int1 + assert_operator 1, :<, int2 + end + + it 'starts the correct server' do + exp = %r{ + ==\sSinatra\s\(v#{Sinatra::VERSION}\)\s + has\staken\sthe\sstage\son\s\d+\sfor\sdevelopment\s + with\sbackup\sfrom\s#{server} + }ix + + # because Net HTTP Server logs to $stderr by default + assert_match exp, server.log unless server.net_http_server? + end + + it 'does not generate warnings' do + assert_raises(OpenURI::HTTPError) { server.get '/' } + server.get '/app_file' + assert_equal [], server.warnings + end + + it 'sets the Content-Length response header when sending files' do + response = server.get_response '/send_file' + assert response['Content-Length'] + end + + it "doesn't ignore Content-Length header when streaming" do + response = server.get_response '/streaming' + assert_equal '46', response['Content-Length'] + end +end diff --git a/test/liquid_test.rb b/test/liquid_test.rb new file mode 100644 index 0000000000..747d539e17 --- /dev/null +++ b/test/liquid_test.rb @@ -0,0 +1,77 @@ +require_relative 'test_helper' + +begin +require 'liquid' + +class LiquidTest < Minitest::Test + def liquid_app(&block) + mock_app do + set :views, __dir__ + '/views' + get('/', &block) + end + get '/' + end + + it 'renders inline liquid strings' do + liquid_app { liquid '

Hiya

' } + assert ok? + assert_equal "

Hiya

", body + end + + it 'renders .liquid files in views path' do + liquid_app { liquid :hello } + assert ok? + assert_equal "

Hello From Liquid

\n", body + end + + it "renders with inline layouts" do + mock_app do + layout { "

THIS. IS. {{ yield }}

" } + get('/') { liquid 'SPARTA' } + end + get '/' + assert ok? + assert_equal "

THIS. IS. SPARTA

", body + end + + it "renders with file layouts" do + liquid_app { liquid 'Hello World', :layout => :layout2 } + assert ok? + assert_equal "

Liquid Layout!

\n

Hello World

\n", body + end + + it "raises error if template not found" do + mock_app { get('/') { liquid :no_such_template } } + assert_raises(Errno::ENOENT) { get('/') } + end + + it "allows passing locals" do + liquid_app { + liquid '{{ value }}', :locals => { :value => 'foo' } + } + assert ok? + assert_equal 'foo', body + end + + it "can render truly nested layouts by accepting a layout and a block with the contents" do + mock_app do + template(:main_outer_layout) { "

Title

\n{{ yield }}" } + template(:an_inner_layout) { "

Subtitle

\n{{ yield }}" } + template(:a_page) { "

Contents.

\n" } + get('/') do + liquid :main_outer_layout, :layout => false do + liquid :an_inner_layout do + liquid :a_page + end + end + end + end + get '/' + assert ok? + assert_body "

Title

\n

Subtitle

\n

Contents.

\n" + end +end + +rescue LoadError + warn "#{$!}: skipping liquid tests" +end diff --git a/test/mapped_error_test.rb b/test/mapped_error_test.rb index 337de3f626..0ea2019f5c 100644 --- a/test/mapped_error_test.rb +++ b/test/mapped_error_test.rb @@ -1,4 +1,4 @@ -require File.dirname(__FILE__) + '/helper' +require_relative 'test_helper' class FooError < RuntimeError end @@ -6,96 +6,138 @@ class FooError < RuntimeError class FooNotFound < Sinatra::NotFound end -class MappedErrorTest < Test::Unit::TestCase +class FooSpecialError < Sinatra::Error + def http_status; 501 end +end + +class FooStatusOutOfRangeError < Sinatra::Error + def code; 4000 end +end + +class FooWithCode < Sinatra::Error + def code; 419 end +end + +class FirstError < RuntimeError; end +class SecondError < RuntimeError; end + +class MappedErrorTest < Minitest::Test def test_default assert true end describe 'Exception Mappings' do it 'invokes handlers registered with ::error when raised' do - mock_app { + mock_app do set :raise_errors, false error(FooError) { 'Foo!' } - get '/' do - raise FooError - end - } + get('/') { raise FooError } + end get '/' assert_equal 500, status assert_equal 'Foo!', body end + it 'passes the exception object to the error handler' do + mock_app do + set :raise_errors, false + error(FooError) { |e| assert_equal(FooError, e.class) } + get('/') { raise FooError } + end + get('/') + end + it 'uses the Exception handler if no matching handler found' do - mock_app { + mock_app do set :raise_errors, false error(Exception) { 'Exception!' } - get '/' do - raise FooError - end - } + get('/') { raise FooError } + end + + get '/' + assert_equal 500, status + assert_equal 'Exception!', body + end + + it 'walks down inheritance chain for errors' do + mock_app do + set :raise_errors, false + error(RuntimeError) { 'Exception!' } + get('/') { raise FooError } + end get '/' assert_equal 500, status assert_equal 'Exception!', body end + it 'favors subclass handler over superclass handler if available' do + mock_app do + set :raise_errors, false + error(Exception) { 'Exception!' } + error(FooError) { 'FooError!' } + error(RuntimeError) { 'Exception!' } + get('/') { raise FooError } + end + + get '/' + assert_equal 500, status + assert_equal 'FooError!', body + end + it "sets env['sinatra.error'] to the rescued exception" do - mock_app { + mock_app do set :raise_errors, false - error(FooError) { + error(FooError) do assert env.include?('sinatra.error') assert env['sinatra.error'].kind_of?(FooError) 'looks good' - } - get '/' do - raise FooError end - } + get('/') { raise FooError } + end get '/' assert_equal 'looks good', body end - it "raises without calling the handler when the raise_errors options is set" do - mock_app { + it "raises errors from the app when raise_errors set and no handler defined" do + mock_app do set :raise_errors, true - error(FooError) { "she's not there." } - get '/' do - raise FooError - end - } - assert_raise(FooError) { get '/' } + get('/') { raise FooError } + end + assert_raises(FooError) { get '/' } end - it "never raises Sinatra::NotFound beyond the application" do - mock_app { + it "calls error handlers before raising errors even when raise_errors is set" do + mock_app do set :raise_errors, true - get '/' do - raise Sinatra::NotFound - end - } - assert_nothing_raised { get '/' } + error(FooError) { "she's there." } + get('/') { raise FooError } + end + get '/' + assert_equal 500, status + end + + it "never raises Sinatra::NotFound beyond the application" do + mock_app(Sinatra::Application) do + get('/') { raise Sinatra::NotFound } + end + get '/' assert_equal 404, status end it "cascades for subclasses of Sinatra::NotFound" do - mock_app { + mock_app do set :raise_errors, true error(FooNotFound) { "foo! not found." } - get '/' do - raise FooNotFound - end - } - assert_nothing_raised { get '/' } + get('/') { raise FooNotFound } + end + get '/' assert_equal 404, status assert_equal 'foo! not found.', body end it 'has a not_found method for backwards compatibility' do - mock_app { - not_found do - "Lost, are we?" - end - } + mock_app { not_found { "Lost, are we?" } } get '/test' assert_equal 404, status @@ -106,12 +148,10 @@ def test_default base = Class.new(Sinatra::Base) base.error(FooError) { 'base class' } - mock_app(base) { + mock_app(base) do set :raise_errors, false - get '/' do - raise FooError - end - } + get('/') { raise FooError } + end get '/' assert_equal 'base class', body @@ -121,66 +161,125 @@ def test_default base = Class.new(Sinatra::Base) base.error(FooError) { 'base class' } - mock_app(base) { + mock_app(base) do set :raise_errors, false error(FooError) { 'subclass' } - get '/' do - raise FooError - end - } + get('/') { raise FooError } + end get '/' assert_equal 'subclass', body end + + it 'honors Exception#http_status if present' do + mock_app do + set :raise_errors, false + error(501) { 'Foo!' } + get('/') { raise FooSpecialError } + end + get '/' + assert_equal 501, status + assert_equal 'Foo!', body + end + + it 'does not use Exception#code by default' do + mock_app do + set :raise_errors, false + get('/') { raise FooWithCode } + end + get '/' + assert_equal 500, status + end + + it 'uses Exception#code if use_code is enabled' do + mock_app do + set :raise_errors, false + set :use_code, true + get('/') { raise FooWithCode } + end + get '/' + assert_equal 419, status + end + + it 'does not rely on Exception#code for invalid codes' do + mock_app do + set :raise_errors, false + set :use_code, true + get('/') { raise FooStatusOutOfRangeError } + end + get '/' + assert_equal 500, status + end + + it "allows a stack of exception_handlers" do + mock_app do + set :raise_errors, false + error(FirstError) { 'First!' } + error(SecondError) { 'Second!' } + get('/'){ raise SecondError } + end + get '/' + assert_equal 500, status + assert_equal 'Second!', body + end + + it "allows an exception handler to pass control to the next exception handler" do + mock_app do + set :raise_errors, false + error(500, FirstError) { 'First!' } + error(500, SecondError) { pass } + get('/') { raise 500 } + end + get '/' + assert_equal 500, status + assert_equal 'First!', body + end + + it "allows an exception handler to handle the exception" do + mock_app do + set :raise_errors, false + error(500, FirstError) { 'First!' } + error(500, SecondError) { 'Second!' } + get('/') { raise 500 } + end + get '/' + assert_equal 500, status + assert_equal 'Second!', body + end end describe 'Custom Error Pages' do it 'allows numeric status code mappings to be registered with ::error' do - mock_app { + mock_app do set :raise_errors, false error(500) { 'Foo!' } - get '/' do - [500, {}, 'Internal Foo Error'] - end - } + get('/') { [500, {}, 'Internal Foo Error'] } + end get '/' assert_equal 500, status assert_equal 'Foo!', body end it 'allows ranges of status code mappings to be registered with :error' do - mock_app { + mock_app do set :raise_errors, false error(500..550) { "Error: #{response.status}" } - get '/' do - [507, {}, 'A very special error'] - end - } + get('/') { [507, {}, 'A very special error'] } + end get '/' assert_equal 507, status assert_equal 'Error: 507', body end - class FooError < RuntimeError - end - - it 'runs after exception mappings and overwrites body' do - mock_app { + it 'allows passing more than one range' do + mock_app do set :raise_errors, false - error FooError do - response.status = 502 - 'from exception mapping' - end - error(500) { 'from 500 handler' } - error(502) { 'from custom error page' } - - get '/' do - raise FooError - end - } + error(409..411, 503..509) { "Error: #{response.status}" } + get('/') { [507, {}, 'A very special error'] } + end get '/' - assert_equal 502, status - assert_equal 'from custom error page', body + assert_equal 507, status + assert_equal 'Error: 507', body end end end diff --git a/test/markaby_test.rb b/test/markaby_test.rb new file mode 100644 index 0000000000..26d54af2a0 --- /dev/null +++ b/test/markaby_test.rb @@ -0,0 +1,80 @@ +require_relative 'test_helper' + +begin +require 'markaby' + +class MarkabyTest < Minitest::Test + def markaby_app(&block) + mock_app do + set :views, __dir__ + '/views' + get('/', &block) + end + get '/' + end + + it 'renders inline markaby strings' do + markaby_app { markaby 'h1 "Hiya"' } + assert ok? + assert_equal "

Hiya

", body + end + + it 'renders .markaby files in views path' do + markaby_app { markaby :hello } + assert ok? + assert_equal "

Hello From Markaby

", body + end + + it "renders with inline layouts" do + mock_app do + layout { 'h1 { text "THIS. IS. "; yield }' } + get('/') { markaby 'em "SPARTA"' } + end + get '/' + assert ok? + assert_equal "

THIS. IS. SPARTA

", body + end + + it "renders with file layouts" do + markaby_app { markaby 'text "Hello World"', :layout => :layout2 } + assert ok? + assert_equal "

Markaby Layout!

Hello World

", body + end + + it 'renders inline markaby blocks' do + markaby_app { markaby { h1 'Hiya' } } + assert ok? + assert_equal "

Hiya

", body + end + + it 'renders inline markaby blocks with inline layouts' do + markaby_app do + settings.layout { 'h1 { text "THIS. IS. "; yield }' } + markaby { em 'SPARTA' } + end + assert ok? + assert_equal "

THIS. IS. SPARTA

", body + end + + it 'renders inline markaby blocks with file layouts' do + markaby_app { markaby(:layout => :layout2) { text "Hello World" } } + assert ok? + assert_equal "

Markaby Layout!

Hello World

", body + end + + it "raises error if template not found" do + mock_app { get('/') { markaby :no_such_template } } + assert_raises(Errno::ENOENT) { get('/') } + end + + it "allows passing locals" do + markaby_app { + markaby 'text value', :locals => { :value => 'foo' } + } + assert ok? + assert_equal 'foo', body + end +end + +rescue LoadError + warn "#{$!}: skipping markaby tests" +end diff --git a/test/markdown_test.rb b/test/markdown_test.rb new file mode 100644 index 0000000000..642391c077 --- /dev/null +++ b/test/markdown_test.rb @@ -0,0 +1,89 @@ +require_relative 'test_helper' + +MarkdownTest = proc do + def markdown_app(&block) + mock_app do + set :views, __dir__ + '/views' + get('/', &block) + end + get '/' + end + + def setup + Tilt.prefer engine, 'markdown', 'mkd', 'md' + super + end + + it 'uses the correct engine' do + assert_equal engine, Tilt[:md] + assert_equal engine, Tilt[:mkd] + assert_equal engine, Tilt[:markdown] + end + + it 'renders inline markdown strings' do + markdown_app { markdown '# Hiya' } + assert ok? + assert_like "

Hiya

\n", body + end + + it 'renders .markdown files in views path' do + markdown_app { markdown :hello } + assert ok? + assert_like "

Hello From Markdown

", body + end + + it "raises error if template not found" do + mock_app { get('/') { markdown :no_such_template } } + assert_raises(Errno::ENOENT) { get('/') } + end + + it "renders with inline layouts" do + mock_app do + layout { 'THIS. IS. #{yield.upcase}!' } + get('/') { markdown 'Sparta', :layout_engine => :str } + end + get '/' + assert ok? + assert_like 'THIS. IS.

SPARTA

!', body + end + + it "renders with file layouts" do + markdown_app { + markdown 'Hello World', :layout => :layout2, :layout_engine => :erb + } + assert ok? + assert_body "ERB Layout!\n

Hello World

" + end + + it "can be used in a nested fashion for partials and whatnot" do + mock_app do + template(:inner) { "hi" } + template(:outer) { "<%= markdown :inner %>" } + get('/') { erb :outer } + end + + get '/' + assert ok? + assert_like '

hi

', body + end +end + +[ + "Tilt::PandocTemplate", + "Tilt::CommonMarkerTemplate", + "Tilt::KramdownTemplate", + "Tilt::RedcarpetTemplate", + "Tilt::RDiscountTemplate" +].each do |template_name| + begin + template = Object.const_get(template_name) + + klass = Class.new(Minitest::Test) { define_method(:engine) { template } } + klass.class_eval(&MarkdownTest) + + name = template_name.split('::').last.sub(/Template$/, 'Test') + Object.const_set name, klass + rescue LoadError, NameError + warn "#{$!}: skipping markdown tests with #{template_name}" + end +end diff --git a/test/middleware_test.rb b/test/middleware_test.rb index ee34f35d54..aa6fcdb9e9 100644 --- a/test/middleware_test.rb +++ b/test/middleware_test.rb @@ -1,15 +1,15 @@ -require File.dirname(__FILE__) + '/helper' +require_relative 'test_helper' -class MiddlewareTest < Test::Unit::TestCase +class MiddlewareTest < Minitest::Test setup do - @app = mock_app(Sinatra::Application) { - get '/*' do + @app = mock_app(Sinatra::Application) do + get('/*')do response.headers['X-Tests'] = env['test.ran']. map { |n| n.split('::').last }. join(', ') env['PATH_INFO'] end - } + end end class MockMiddleware < Struct.new(:app) @@ -65,4 +65,46 @@ def call(env) assert_equal "/FOO", body assert_equal "UpcaseMiddleware", response['X-Tests'] end + + class FreezeMiddleware < MockMiddleware + def call(env) + req = Rack::Request.new(env) + req.update_param('bar', 'baz'.freeze) + super + end + end + + it "works when middleware adds a frozen param" do + @app.use FreezeMiddleware + get '/Foo' + end + + class SpecialConstsMiddleware < MockMiddleware + def call(env) + req = Rack::Request.new(env) + req.update_param('s', :s) + req.update_param('i', 1) + req.update_param('c', 3.to_c) + req.update_param('t', true) + req.update_param('f', false) + req.update_param('n', nil) + super + end + end + + it "handles params when the params contains true/false values" do + @app.use SpecialConstsMiddleware + get '/' + end + + class KeywordArgumentIntializationMiddleware < MockMiddleware + def initialize(app, **) + super app + end + end + + it "handles keyword arguments" do + @app.use KeywordArgumentIntializationMiddleware, argument: "argument" + get '/' + end end diff --git a/test/nokogiri_test.rb b/test/nokogiri_test.rb new file mode 100644 index 0000000000..0801fbddd6 --- /dev/null +++ b/test/nokogiri_test.rb @@ -0,0 +1,67 @@ +require_relative 'test_helper' + +begin +require 'nokogiri' + +class NokogiriTest < Minitest::Test + def nokogiri_app(&block) + mock_app do + set :views, __dir__ + '/views' + get('/', &block) + end + get '/' + end + + it 'renders inline Nokogiri strings' do + nokogiri_app { nokogiri '' } + assert ok? + assert_body %(\n) + end + + it 'renders inline blocks' do + nokogiri_app do + @name = "Frank & Mary" + nokogiri { |xml| xml.couple @name } + end + assert ok? + assert_body %(\nFrank & Mary\n) + end + + it 'renders .nokogiri files in views path' do + nokogiri_app do + @name = "Blue" + nokogiri :hello + end + assert ok? + assert_body "\nYou're my boy, Blue!\n" + end + + it "renders with inline layouts" do + next if Tilt::VERSION <= "1.1" + mock_app do + layout { %(xml.layout { xml << yield }) } + get('/') { nokogiri %(xml.em 'Hello World') } + end + get '/' + assert ok? + assert_body %(\n\n Hello World\n\n) + end + + it "renders with file layouts" do + next if Tilt::VERSION <= "1.1" + nokogiri_app { + nokogiri %(xml.em 'Hello World'), :layout => :layout2 + } + assert ok? + assert_body %(\n\n Hello World\n\n) + end + + it "raises error if template not found" do + mock_app { get('/') { nokogiri :no_such_template } } + assert_raises(Errno::ENOENT) { get('/') } + end +end + +rescue LoadError + warn "#{$!}: skipping nokogiri tests" +end diff --git a/test/options_test.rb b/test/options_test.rb deleted file mode 100644 index 0249b934d0..0000000000 --- a/test/options_test.rb +++ /dev/null @@ -1,376 +0,0 @@ -require File.dirname(__FILE__) + '/helper' - -class OptionsTest < Test::Unit::TestCase - setup do - @base = Sinatra.new(Sinatra::Base) - @application = Sinatra.new(Sinatra::Application) - @base.set :environment, :development - @application.set :environment, :development - end - - it 'sets options to literal values' do - @base.set(:foo, 'bar') - assert @base.respond_to?(:foo) - assert_equal 'bar', @base.foo - end - - it 'sets options to Procs' do - @base.set(:foo, Proc.new { 'baz' }) - assert @base.respond_to?(:foo) - assert_equal 'baz', @base.foo - end - - it "sets multiple options with a Hash" do - @base.set :foo => 1234, - :bar => 'Hello World', - :baz => Proc.new { 'bizzle' } - assert_equal 1234, @base.foo - assert_equal 'Hello World', @base.bar - assert_equal 'bizzle', @base.baz - end - - it 'inherits option methods when subclassed' do - @base.set :foo, 'bar' - @base.set :biz, Proc.new { 'baz' } - - sub = Class.new(@base) - assert sub.respond_to?(:foo) - assert_equal 'bar', sub.foo - assert sub.respond_to?(:biz) - assert_equal 'baz', sub.biz - end - - it 'overrides options in subclass' do - @base.set :foo, 'bar' - @base.set :biz, Proc.new { 'baz' } - sub = Class.new(@base) - sub.set :foo, 'bling' - assert_equal 'bling', sub.foo - assert_equal 'bar', @base.foo - end - - it 'creates setter methods when first defined' do - @base.set :foo, 'bar' - assert @base.respond_to?('foo=') - @base.foo = 'biz' - assert_equal 'biz', @base.foo - end - - it 'creates predicate methods when first defined' do - @base.set :foo, 'hello world' - assert @base.respond_to?(:foo?) - assert @base.foo? - @base.set :foo, nil - assert !@base.foo? - end - - it 'uses existing setter methods if detected' do - class << @base - def foo - @foo - end - def foo=(value) - @foo = 'oops' - end - end - - @base.set :foo, 'bam' - assert_equal 'oops', @base.foo - end - - it "sets multiple options to true with #enable" do - @base.enable :sessions, :foo, :bar - assert @base.sessions - assert @base.foo - assert @base.bar - end - - it "sets multiple options to false with #disable" do - @base.disable :sessions, :foo, :bar - assert !@base.sessions - assert !@base.foo - assert !@base.bar - end - - it 'enables MethodOverride middleware when :methodoverride is enabled' do - @base.set :methodoverride, true - @base.put('/') { 'okay' } - @app = @base - post '/', {'_method'=>'PUT'}, {} - assert_equal 200, status - assert_equal 'okay', body - end - - it 'is accessible from instances via #settings' do - assert_equal :development, @base.new.settings.environment - end - - describe 'clean_trace' do - def clean_backtrace(trace) - Sinatra::Base.new.send(:clean_backtrace, trace) - end - - it 'is enabled on Base' do - assert @base.clean_trace? - end - - it 'is enabled on Application' do - assert @application.clean_trace? - end - - it 'does nothing when disabled' do - backtrace = [ - "./lib/sinatra/base.rb", - "./myapp:42", - ("#{Gem.dir}/some/lib.rb" if defined?(Gem)) - ].compact - - klass = Class.new(Sinatra::Base) - klass.disable :clean_trace - - assert_equal backtrace, klass.new.send(:clean_backtrace, backtrace) - end - - it 'removes sinatra lib paths from backtrace when enabled' do - backtrace = [ - "./lib/sinatra/base.rb", - "./lib/sinatra/compat.rb:42", - "./lib/sinatra/main.rb:55 in `foo'" - ] - assert clean_backtrace(backtrace).empty? - end - - it 'removes ./ prefix from backtrace paths when enabled' do - assert_equal ['myapp.rb:42'], clean_backtrace(['./myapp.rb:42']) - end - - if defined?(Gem) - it 'removes gem lib paths from backtrace when enabled' do - assert clean_backtrace(["#{Gem.dir}/some/lib"]).empty? - end - end - end - - describe 'run' do - it 'is disabled on Base' do - assert ! @base.run? - end - - it 'is enabled on Application when not in test environment' do - @application.set :environment, :development - assert @application.development? - assert @application.run? - - @application.set :environment, :development - assert @application.run? - end - - # TODO: it 'is enabled when $0 == app_file' - end - - describe 'raise_errors' do - it 'is enabled on Base' do - assert @base.raise_errors? - end - - it 'is enabled on Application only in test' do - @application.set(:environment, :development) - assert @application.development? - assert ! @application.raise_errors? - - @application.set(:environment, :production) - assert ! @application.raise_errors? - - @application.set(:environment, :test) - assert @application.raise_errors? - end - end - - describe 'show_exceptions' do - %w[development test production none].each do |environment| - it "is disabled on Base in #{environment} environments" do - @base.set(:environment, environment) - assert ! @base.show_exceptions? - end - end - - it 'is enabled on Application only in development' do - @base.set(:environment, :development) - assert @application.development? - assert @application.show_exceptions? - - @application.set(:environment, :test) - assert ! @application.show_exceptions? - - @base.set(:environment, :production) - assert ! @base.show_exceptions? - end - - it 'returns a friendly 500' do - klass = Sinatra.new(Sinatra::Application) - mock_app(klass) { - enable :show_exceptions - - get '/' do - raise StandardError - end - } - - get '/' - assert_equal 500, status - assert body.include?("StandardError") - assert body.include?("show_exceptions option") - end - end - - describe 'dump_errors' do - it 'is disabled on Base' do - assert ! @base.dump_errors? - end - - it 'is enabled on Application' do - assert @application.dump_errors? - end - - it 'dumps exception with backtrace to rack.errors' do - klass = Sinatra.new(Sinatra::Application) - - mock_app(klass) { - disable :raise_errors - - error do - error = @env['rack.errors'].instance_variable_get(:@error) - error.rewind - - error.read - end - - get '/' do - raise - end - } - - get '/' - assert body.include?("RuntimeError") && body.include?("options_test.rb") - end - end - - describe 'sessions' do - it 'is disabled on Base' do - assert ! @base.sessions? - end - - it 'is disabled on Application' do - assert ! @application.sessions? - end - - # TODO: it 'uses Rack::Session::Cookie when enabled' do - end - - describe 'logging' do - it 'is disabled on Base' do - assert ! @base.logging? - end - - it 'is enabled on Application when not in test environment' do - assert @application.logging? - - @application.set :environment, :test - assert ! @application.logging - end - - # TODO: it 'uses Rack::CommonLogger when enabled' do - end - - describe 'static' do - it 'is disabled on Base' do - assert ! @base.static? - end - - it 'is enabled on Application' do - assert @application.static? - end - - # TODO: it setup static routes if public is enabled - # TODO: however, that's already tested in static_test so... - end - - describe 'host' do - it 'defaults to 0.0.0.0' do - assert_equal '0.0.0.0', @base.host - assert_equal '0.0.0.0', @application.host - end - end - - describe 'port' do - it 'defaults to 4567' do - assert_equal 4567, @base.port - assert_equal 4567, @application.port - end - end - - describe 'server' do - it 'is one of thin, mongrel, webrick' do - assert_equal %w[thin mongrel webrick], @base.server - assert_equal %w[thin mongrel webrick], @application.server - end - end - - describe 'app_file' do - it 'is nil' do - assert @base.app_file.nil? - assert @application.app_file.nil? - end - end - - describe 'root' do - it 'is nil if app_file is not set' do - assert @base.root.nil? - assert @application.root.nil? - end - - it 'is equal to the expanded basename of app_file' do - @base.app_file = __FILE__ - assert_equal File.expand_path(File.dirname(__FILE__)), @base.root - - @application.app_file = __FILE__ - assert_equal File.expand_path(File.dirname(__FILE__)), @application.root - end - end - - describe 'views' do - it 'is nil if root is not set' do - assert @base.views.nil? - assert @application.views.nil? - end - - it 'is set to root joined with views/' do - @base.root = File.dirname(__FILE__) - assert_equal File.dirname(__FILE__) + "/views", @base.views - - @application.root = File.dirname(__FILE__) - assert_equal File.dirname(__FILE__) + "/views", @application.views - end - end - - describe 'public' do - it 'is nil if root is not set' do - assert @base.public.nil? - assert @application.public.nil? - end - - it 'is set to root joined with public/' do - @base.root = File.dirname(__FILE__) - assert_equal File.dirname(__FILE__) + "/public", @base.public - - @application.root = File.dirname(__FILE__) - assert_equal File.dirname(__FILE__) + "/public", @application.public - end - end - - describe 'lock' do - it 'is disabled by default' do - assert ! @base.lock? - end - end -end diff --git a/test/public/favicon.ico b/test/public/favicon.ico new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/public/hello+world.txt b/test/public/hello+world.txt new file mode 100644 index 0000000000..abd09d0c44 --- /dev/null +++ b/test/public/hello+world.txt @@ -0,0 +1 @@ +This is a test intended for the + sign in urls for static serving \ No newline at end of file diff --git a/test/rabl_test.rb b/test/rabl_test.rb new file mode 100644 index 0000000000..781569f1a9 --- /dev/null +++ b/test/rabl_test.rb @@ -0,0 +1,90 @@ +require_relative 'test_helper' + +begin +require 'rabl' +require 'ostruct' +require 'json' +require 'active_support/core_ext/array/extract_options' +require 'active_support/core_ext/hash/conversions' + +class RablTest < Minitest::Test + def rabl_app(&block) + mock_app { + set :views, __dir__ + '/views' + get '/', &block + } + get '/' + end + + it 'renders inline rabl strings' do + rabl_app do + @foo = OpenStruct.new(:baz => 'w00t') + rabl %q{ + object @foo + attributes :baz + } + end + assert ok? + assert_equal '{"openstruct":{"baz":"w00t"}}', body + end + it 'renders .rabl files in views path' do + rabl_app do + @foo = OpenStruct.new(:bar => 'baz') + rabl :hello + end + assert ok? + assert_equal '{"openstruct":{"bar":"baz"}}', body + end + + it "renders with file layouts" do + rabl_app { + @foo = OpenStruct.new(:bar => 'baz') + rabl :hello, :layout => :layout2 + } + assert ok? + assert_equal '{"qux":{"openstruct":{"bar":"baz"}}}', body + end + + it "raises error if template not found" do + mock_app { + get('/') { rabl :no_such_template } + } + assert_raises(Errno::ENOENT) { get('/') } + end + + it "passes rabl options to the rabl engine" do + mock_app do + get('/') do + @foo = OpenStruct.new(:bar => 'baz') + rabl %q{ + object @foo + attributes :bar + }, :format => 'xml' + end + end + get '/' + assert ok? + assert_body 'baz' + end + + it "passes default rabl options to the rabl engine" do + mock_app do + set :rabl, :format => 'xml' + get('/') do + @foo = OpenStruct.new(:bar => 'baz') + rabl %q{ + object @foo + attributes :bar + } + end + end + get '/' + assert ok? + assert_body 'baz' + end + +end + +rescue LoadError + warn "#{$!}: skipping rabl tests" +end diff --git a/test/rack_test.rb b/test/rack_test.rb new file mode 100644 index 0000000000..c7b3eae3d3 --- /dev/null +++ b/test/rack_test.rb @@ -0,0 +1,45 @@ +require_relative 'test_helper' +require 'rack' + +class RackTest < Minitest::Test + setup do + @foo = Sinatra.new { get('/foo') { 'foo' }} + @bar = Sinatra.new { get('/bar') { 'bar' }} + end + + def build(*middleware) + endpoint = middleware.pop + @app = Rack::Builder.app do + middleware.each { |m| use m } + run endpoint + end + end + + def check(*middleware) + build(*middleware) + assert get('/foo').ok? + assert_body 'foo' + assert get('/bar').ok? + assert_body 'bar' + end + + it 'works as middleware in front of Rack::Lock, with lock enabled' do + @foo.enable :lock + check(@foo, Rack::Lock, @bar) + end + + it 'works as middleware behind Rack::Lock, with lock enabled' do + @foo.enable :lock + check(Rack::Lock, @foo, @bar) + end + + it 'works as middleware in front of Rack::Lock, with lock disabled' do + @foo.disable :lock + check(@foo, Rack::Lock, @bar) + end + + it 'works as middleware behind Rack::Lock, with lock disabled' do + @foo.disable :lock + check(Rack::Lock, @foo, @bar) + end +end diff --git a/test/rdoc_test.rb b/test/rdoc_test.rb new file mode 100644 index 0000000000..33e7479861 --- /dev/null +++ b/test/rdoc_test.rb @@ -0,0 +1,66 @@ +require_relative 'test_helper' + +begin +require 'rdoc' +require 'rdoc/markup/to_html' + +class RdocTest < Minitest::Test + def rdoc_app(&block) + mock_app do + set :views, __dir__ + '/views' + get('/', &block) + end + get '/' + end + + it 'renders inline rdoc strings' do + rdoc_app { rdoc '= Hiya' } + assert ok? + assert_body(/]*>Hiya(¶<\/a> ↑<\/a><\/span>)?<\/h1>/) + end + + it 'renders .rdoc files in views path' do + rdoc_app { rdoc :hello } + assert ok? + assert_body(/]*>Hello From RDoc(¶<\/a> ↑<\/a><\/span>)?<\/h1>/) + end + + it "raises error if template not found" do + mock_app { get('/') { rdoc :no_such_template } } + assert_raises(Errno::ENOENT) { get('/') } + end + + it "renders with inline layouts" do + mock_app do + layout { 'THIS. IS. #{yield.upcase}!' } + get('/') { rdoc 'Sparta', :layout_engine => :str } + end + get '/' + assert ok? + assert_like 'THIS. IS.

SPARTA

!', body + end + + it "renders with file layouts" do + rdoc_app { + rdoc 'Hello World', :layout => :layout2, :layout_engine => :erb + } + assert ok? + assert_body "ERB Layout!\n

Hello World

" + end + + it "can be used in a nested fashion for partials and whatnot" do + mock_app do + template(:inner) { "hi" } + template(:outer) { "<%= rdoc :inner %>" } + get('/') { erb :outer } + end + + get '/' + assert ok? + assert_like '

hi

', body + end +end + +rescue LoadError + warn "#{$!}: skipping rdoc tests" +end diff --git a/test/readme_test.rb b/test/readme_test.rb new file mode 100644 index 0000000000..e77e4ea88d --- /dev/null +++ b/test/readme_test.rb @@ -0,0 +1,130 @@ +require_relative 'test_helper' + +# Tests to check if all the README examples work. +class ReadmeTest < Minitest::Test + example do + mock_app { get('/') { 'Hello world!' } } + get '/' + assert_body 'Hello world!' + end + + section "Routes" do + example do + mock_app do + get('/') { ".. show something .." } + + post('/') { ".. create something .." } + + put('/') { ".. replace something .." } + + patch('/') { ".. modify something .." } + + delete('/') { ".. annihilate something .." } + + options('/') { ".. appease something .." } + + link('/') { ".. affiliate something .." } + + unlink('/') { ".. separate something .." } + end + + get '/' + assert_body '.. show something ..' + + post '/' + assert_body '.. create something ..' + + put '/' + assert_body '.. replace something ..' + + patch '/' + assert_body '.. modify something ..' + + delete '/' + assert_body '.. annihilate something ..' + + options '/' + assert_body '.. appease something ..' + + link '/' + assert_body '.. affiliate something ..' + + unlink '/' + assert_body '.. separate something ..' + end + + example do + mock_app do + get('/hello/:name') do + # matches "GET /hello/foo" and "GET /hello/bar" + # params[:name] is 'foo' or 'bar' + "Hello #{params[:name]}!" + end + end + + get '/hello/foo' + assert_body 'Hello foo!' + + get '/hello/bar' + assert_body 'Hello bar!' + end + + example do + mock_app { get('/hello/:name') { |n| "Hello #{n}!" } } + + get '/hello/foo' + assert_body 'Hello foo!' + + get '/hello/bar' + assert_body 'Hello bar!' + end + + example do + mock_app do + get('/say/*/to/*') do + # matches /say/hello/to/world + params[:splat].inspect # => ["hello", "world"] + end + + get('/download/*.*') do + # matches /download/path/to/file.xml + params[:splat].inspect # => ["path/to/file", "xml"] + end + end + + get "/say/hello/to/world" + assert_body '["hello", "world"]' + + get "/download/path/to/file.xml" + assert_body '["path/to/file", "xml"]' + end + + example do + mock_app do + get(%r{/hello/([\w]+)}) { + "Hello, #{params[:captures].first}!" + } + end + + get '/hello/foo' + assert_body 'Hello, foo!' + + get '/hello/bar' + assert_body 'Hello, bar!' + end + + example do + mock_app do + get( %r{/hello/([\w]+)}) { |c| + "Hello, #{c}!" + } + end + + get '/hello/foo' + assert_body 'Hello, foo!' + + get '/hello/bar' + assert_body 'Hello, bar!' + end + end +end diff --git a/test/request_test.rb b/test/request_test.rb index f213646cdf..78663b9b6d 100644 --- a/test/request_test.rb +++ b/test/request_test.rb @@ -1,6 +1,7 @@ -require File.dirname(__FILE__) + '/helper' +require_relative 'test_helper' +require 'stringio' -class RequestTest < Test::Unit::TestCase +class RequestTest < Minitest::Test it 'responds to #user_agent' do request = Sinatra::Request.new({'HTTP_USER_AGENT' => 'Test'}) assert request.respond_to?(:user_agent) @@ -16,6 +17,15 @@ class RequestTest < Test::Unit::TestCase assert_equal 'bar', request.params['foo'] end + it 'raises Sinatra::BadRequest when multipart/form-data request has no content' do + request = Sinatra::Request.new( + 'REQUEST_METHOD' => 'POST', + 'CONTENT_TYPE' => 'multipart/form-data; boundary=dummy', + 'rack.input' => StringIO.new('') + ) + assert_raises(Sinatra::BadRequest) { request.params } + end + it 'is secure when the url scheme is https' do request = Sinatra::Request.new('rack.url_scheme' => 'https') assert request.secure? @@ -30,4 +40,95 @@ class RequestTest < Test::Unit::TestCase request = Sinatra::Request.new('HTTP_X_FORWARDED_PROTO' => 'https') assert request.secure? end + + it 'is possible to marshal params' do + request = Sinatra::Request.new( + 'REQUEST_METHOD' => 'PUT', + 'CONTENT_TYPE' => 'application/x-www-form-urlencoded', + 'rack.input' => StringIO.new('foo=bar') + ) + Sinatra::IndifferentHash[request.params] + dumped = Marshal.dump(request.params) + assert_equal 'bar', Marshal.load(dumped)['foo'] + end + + it "exposes the preferred type's parameters" do + request = Sinatra::Request.new( + 'HTTP_ACCEPT' => 'image/jpeg; compress=0.25' + ) + assert_equal({ 'compress' => '0.25' }, request.preferred_type.params) + end + + it "raises Sinatra::BadRequest when params contain conflicting types" do + request = Sinatra::Request.new 'QUERY_STRING' => 'foo=&foo[]=' + assert_raises(Sinatra::BadRequest) { request.params } + end + + it "makes accept types behave like strings" do + request = Sinatra::Request.new('HTTP_ACCEPT' => 'image/jpeg; compress=0.25') + assert request.accept?('image/jpeg') + assert_equal 'image/jpeg', request.preferred_type.to_s + assert_equal 'image/jpeg; compress=0.25', request.preferred_type.to_s(true) + assert_equal 'image/jpeg', request.preferred_type.to_str + assert_equal 'image', request.preferred_type.split('/').first + + String.instance_methods.each do |method| + next unless "".respond_to? method + assert request.preferred_type.respond_to?(method), "responds to #{method}" + end + end + + it "accepts types when wildcards are requested" do + request = Sinatra::Request.new('HTTP_ACCEPT' => 'image/*') + assert request.accept?('image/jpeg') + end + + it "properly decodes MIME type parameters" do + request = Sinatra::Request.new( + 'HTTP_ACCEPT' => 'image/jpeg;unquoted=0.25;quoted="0.25";chartest="\";,\x"' + ) + expected = { 'unquoted' => '0.25', 'quoted' => '0.25', 'chartest' => '";,x' } + assert_equal(expected, request.preferred_type.params) + end + + it 'accepts */* when HTTP_ACCEPT is not present in the request' do + request = Sinatra::Request.new Hash.new + assert_equal 1, request.accept.size + assert request.accept?('text/html') + assert_equal '*/*', request.preferred_type.to_s + assert_equal '*/*', request.preferred_type.to_s(true) + end + + it 'accepts */* when HTTP_ACCEPT is blank in the request' do + request = Sinatra::Request.new 'HTTP_ACCEPT' => '' + assert_equal 1, request.accept.size + assert request.accept?('text/html') + assert_equal '*/*', request.preferred_type.to_s + assert_equal '*/*', request.preferred_type.to_s(true) + end + + it 'will not accept types not specified in HTTP_ACCEPT when HTTP_ACCEPT is provided' do + request = Sinatra::Request.new 'HTTP_ACCEPT' => 'application/json' + assert !request.accept?('text/html') + end + + it 'will accept types that fulfill HTTP_ACCEPT parameters' do + request = Sinatra::Request.new 'HTTP_ACCEPT' => 'application/rss+xml; version="http://purl.org/rss/1.0/"' + + assert request.accept?('application/rss+xml; version="http://purl.org/rss/1.0/"') + assert request.accept?('application/rss+xml; version="http://purl.org/rss/1.0/"; charset=utf-8') + assert !request.accept?('application/rss+xml; version="https://cyber.harvard.edu/rss/rss.html"') + end + + it 'will accept more generic types that include HTTP_ACCEPT parameters' do + request = Sinatra::Request.new 'HTTP_ACCEPT' => 'application/rss+xml; charset=utf-8; version="http://purl.org/rss/1.0/"' + + assert request.accept?('application/rss+xml') + assert request.accept?('application/rss+xml; version="http://purl.org/rss/1.0/"') + end + + it 'will accept types matching HTTP_ACCEPT when parameters in arbitrary order' do + request = Sinatra::Request.new 'HTTP_ACCEPT' => 'application/rss+xml; charset=utf-8; version="http://purl.org/rss/1.0/"' + assert request.accept?('application/rss+xml; version="http://purl.org/rss/1.0/"; charset=utf-8') + end end diff --git a/test/response_test.rb b/test/response_test.rb index 5aa5f4e66f..5c1e60576b 100644 --- a/test/response_test.rb +++ b/test/response_test.rb @@ -1,10 +1,10 @@ -# encoding: utf-8 +require_relative 'test_helper' -require File.dirname(__FILE__) + '/helper' +class ResponseTest < Minitest::Test + setup { @response = Sinatra::Response.new([], 200, { 'Content-Type' => 'text/html' }) } -class ResponseTest < Test::Unit::TestCase - setup do - @response = Sinatra::Response.new + def assert_same_body(a, b) + assert_equal a.to_enum(:each).to_a, b.to_enum(:each).to_a end it "initializes with 200, text/html, and empty body" do @@ -22,7 +22,7 @@ class ResponseTest < Test::Unit::TestCase it 'writes to body' do @response.body = 'Hello' @response.write ' World' - assert_equal 'Hello World', @response.body + assert_equal 'Hello World', @response.body.join end [204, 304].each do |status_code| @@ -33,10 +33,42 @@ class ResponseTest < Test::Unit::TestCase end end + [200, 201, 202, 301, 302, 400, 401, 403, 404, 500].each do |status_code| + it "will not removes the Content-Type header and body when response status + is #{status_code}" do + @response.status = status_code + @response.body = ['Hello World'] + assert_equal [ + status_code, + { 'Content-Type' => 'text/html', 'Content-Length' => '11' }, + ['Hello World'] + ], @response.finish + end + end + it 'Calculates the Content-Length using the bytesize of the body' do @response.body = ['Hello', 'World!', '✈'] - status, headers, body = @response.finish + _, headers, body = @response.finish assert_equal '14', headers['Content-Length'] - assert_equal @response.body, body + assert_same_body @response.body, body + end + + it 'does not call #to_ary or #inject on the body' do + object = Object.new + def object.inject(*) fail 'called' end + def object.to_ary(*) fail 'called' end + def object.each(*) end + @response.body = object + assert @response.finish + end + + it 'does not nest a Sinatra::Response' do + @response.body = Sinatra::Response.new ["foo"] + assert_same_body @response.body, ["foo"] + end + + it 'does not nest a Rack::Response' do + @response.body = Rack::Response.new ["foo"] + assert_same_body @response.body, ["foo"] end end diff --git a/test/result_test.rb b/test/result_test.rb index 5100abf457..293680bd1b 100644 --- a/test/result_test.rb +++ b/test/result_test.rb @@ -1,12 +1,12 @@ -require File.dirname(__FILE__) + '/helper' +require_relative 'test_helper' -class ResultTest < Test::Unit::TestCase +class ThirdPartyError < RuntimeError + def http_status; 400 end +end + +class ResultTest < Minitest::Test it "sets response.body when result is a String" do - mock_app { - get '/' do - 'Hello World' - end - } + mock_app { get('/') { 'Hello World' } } get '/' assert ok? @@ -14,11 +14,7 @@ class ResultTest < Test::Unit::TestCase end it "sets response.body when result is an Array of Strings" do - mock_app { - get '/' do - ['Hello', 'World'] - end - } + mock_app { get('/') { ['Hello', 'World'] } } get '/' assert ok? @@ -26,13 +22,13 @@ class ResultTest < Test::Unit::TestCase end it "sets response.body when result responds to #each" do - mock_app { - get '/' do + mock_app do + get('/') do res = lambda { 'Hello World' } def res.each ; yield call ; end - res + return res end - } + end get '/' assert ok? @@ -40,11 +36,7 @@ def res.each ; yield call ; end end it "sets response.body to [] when result is nil" do - mock_app { - get '/' do - nil - end - } + mock_app { get( '/') { nil } } get '/' assert ok? @@ -53,46 +45,47 @@ def res.each ; yield call ; end it "sets status, headers, and body when result is a Rack response tuple" do mock_app { - get '/' do - [205, {'Content-Type' => 'foo/bar'}, 'Hello World'] - end + get('/') { [203, {'Content-Type' => 'foo/bar'}, 'Hello World'] } } get '/' - assert_equal 205, status + assert_equal 203, status assert_equal 'foo/bar', response['Content-Type'] assert_equal 'Hello World', body end it "sets status and body when result is a two-tuple" do - mock_app { - get '/' do - [409, 'formula of'] - end - } + mock_app { get('/') { [409, 'formula of'] } } get '/' assert_equal 409, status assert_equal 'formula of', body end - it "raises a TypeError when result is a non two or three tuple Array" do + it "raises a ArgumentError when result is a non two or three tuple Array" do mock_app { - get '/' do - [409, 'formula of', 'something else', 'even more'] - end + get('/') { [409, 'formula of', 'something else', 'even more'] } } - assert_raise(TypeError) { get '/' } + assert_raises(ArgumentError) { get '/' } end - it "sets status when result is a Fixnum status code" do - mock_app { - get('/') { 205 } - } + it "sets status when result is a Integer status code" do + mock_app { get('/') { 205 } } get '/' assert_equal 205, status assert_equal '', body end + + it "sets status to 500 when raised error is not Sinatra::Error" do + mock_app do + set :raise_errors, false + get('/') { raise ThirdPartyError } + end + + get '/' + assert_equal 500, status + assert_equal '

Internal Server Error

', body + end end diff --git a/test/route_added_hook_test.rb b/test/route_added_hook_test.rb index 08fdd925dc..91a0c70be2 100644 --- a/test/route_added_hook_test.rb +++ b/test/route_added_hook_test.rb @@ -1,4 +1,4 @@ -require File.dirname(__FILE__) + '/helper' +require_relative 'test_helper' module RouteAddedTest @routes, @procs = [], [] @@ -10,17 +10,17 @@ def self.route_added(verb, path, proc) end end -class RouteAddedHookTest < Test::Unit::TestCase - setup { +class RouteAddedHookTest < Minitest::Test + setup do RouteAddedTest.routes.clear RouteAddedTest.procs.clear - } + end it "should be notified of an added route" do - mock_app(Class.new(Sinatra::Base)) { + mock_app(Class.new(Sinatra::Base)) do register RouteAddedTest get('/') {} - } + end assert_equal [["GET", "/"], ["HEAD", "/"]], RouteAddedTest.routes @@ -38,22 +38,22 @@ class RouteAddedHookTest < Test::Unit::TestCase end it "should only run once per extension" do - mock_app(Class.new(Sinatra::Base)) { + mock_app(Class.new(Sinatra::Base)) do register RouteAddedTest register RouteAddedTest get('/') {} - } + end assert_equal [["GET", "/"], ["HEAD", "/"]], RouteAddedTest.routes end - + it "should pass route blocks as an argument" do - mock_app(Class.new(Sinatra::Base)) { + mock_app(Class.new(Sinatra::Base)) do register RouteAddedTest get('/') {} - } + end - assert_kind_of Proc, RouteAddedTest.procs.first + assert_kind_of Proc, RouteAddedTest.procs.first end end diff --git a/test/routing_test.rb b/test/routing_test.rb index 6ef3254796..88c7532a03 100644 --- a/test/routing_test.rb +++ b/test/routing_test.rb @@ -1,28 +1,22 @@ -require File.dirname(__FILE__) + '/helper' +require_relative 'test_helper' # Helper method for easy route pattern matching testing def route_def(pattern) mock_app { get(pattern) { } } end -class RegexpLookAlike - class MatchData - def captures - ["this", "is", "a", "test"] - end - end - - def match(string) - ::RegexpLookAlike::MatchData.new if string == "/this/is/a/test/" +class PatternLookAlike + def to_pattern(*) + self end - def keys - ["one", "two", "three", "four"] + def params(input) + { "one" => "this", "two" => "is", "three" => "a", "four" => "test" } end end -class RoutingTest < Test::Unit::TestCase - %w[get put post delete].each do |verb| +class RoutingTest < Minitest::Test + %w[get put post delete options patch link unlink].each do |verb| it "defines #{verb.upcase} request handlers with #{verb}" do mock_app { send verb, '/hello' do @@ -52,6 +46,16 @@ class RoutingTest < Test::Unit::TestCase assert_equal '', response.body end + it "400s when request params contain conflicting types" do + mock_app { + get('/foo') { } + } + + request = Rack::MockRequest.new(@app) + response = request.request('GET', '/foo?bar=&bar[]=', {}) + assert response.bad_request? + end + it "404s when no route satisfies the request" do mock_app { get('/foo') { } @@ -60,6 +64,88 @@ class RoutingTest < Test::Unit::TestCase assert_equal 404, status end + it "404s and sets X-Cascade header when no route satisfies the request" do + mock_app { + get('/foo') { } + } + get '/bar' + assert_equal 404, status + assert_equal 'pass', response.headers['X-Cascade'] + end + + it "404s and does not set X-Cascade header when no route satisfies the request and x_cascade has been disabled" do + mock_app { + disable :x_cascade + get('/foo') { } + } + get '/bar' + assert_equal 404, status + assert_nil response.headers['X-Cascade'] + end + + + it "allows using unicode" do + mock_app do + get('/föö') { } + end + get '/f%C3%B6%C3%B6' + assert_equal 200, status + end + + it "it handles encoded slashes correctly" do + mock_app { + set :protection, :except => :path_traversal + get("/:a") { |a| a } + } + get '/foo%2Fbar' + assert_equal 200, status + assert_body "foo/bar" + end + + it "it handles encoded colons correctly" do + mock_app { + get("/\\:") { 'a' } + get("/a/\\:") { 'b' } + get("/a/\\:/b") { 'c' } + get("/a/b\\:") { 'd' } + get("/a/b\\: ") { 'e' } + } + get '/:' + assert_equal 200, status + assert_body "a" + get '/%3a' + assert_equal 200, status + assert_body "a" + + get '/a/:' + assert_equal 200, status + assert_body "b" + get '/a/%3a' + assert_equal 200, status + assert_body "b" + + get '/a/:/b' + assert_equal 200, status + assert_body "c" + get '/a/%3A/b' + assert_equal 200, status + assert_body "c" + + get '/a/b:' + assert_equal 200, status + assert_body "d" + get '/a/b%3a' + assert_equal 200, status + assert_body "d" + + get '/a/b%3a%20' + assert_equal 200, status + assert_body "e" + get '/a/b%3a+' + assert_equal 200, status + assert_body "e" + end + it "overrides the content-type in error handlers" do mock_app { before { content_type 'text/plain' } @@ -71,10 +157,124 @@ class RoutingTest < Test::Unit::TestCase get '/foo' assert_equal 404, status - assert_equal 'text/html', response["Content-Type"] + assert_equal 'text/html;charset=utf-8', response["Content-Type"] assert_equal "

Not Found

", response.body end + it "recalculates body length correctly for 404 response" do + mock_app { + get '/' do + @response["Content-Length"] = "30" + raise Sinatra::NotFound + end + } + + get "/" + assert_equal "18", response["Content-Length"] + assert_equal 404, status + end + + it "captures the exception message of a raised NotFound" do + mock_app { + get '/' do + raise Sinatra::NotFound, "This is not a drill" + end + } + + get "/" + assert_equal "19", response["Content-Length"] + assert_equal 404, status + assert_equal "This is not a drill", response.body + end + + it "captures the exception message of a raised BadRequest" do + mock_app { + get '/' do + raise Sinatra::BadRequest, "This is not a drill either" + end + } + + get "/" + assert_equal "26", response["Content-Length"] + assert_equal 400, status + assert_equal "This is not a drill either", response.body + end + + it "captures the custom exception message of a BadRequest" do + mock_app { + get('/') {} + + error Sinatra::BadRequest do + 'This is not a drill either' + end + } + + get "/", "foo" => "", "foo[]" => "" + assert_equal "26", response["Content-Length"] + assert_equal 400, status + assert_equal "This is not a drill either", response.body + end + + it "returns empty when unmatched with any regex captures" do + mock_app do + before do + # noop + end + + get '/hello' do + params.to_s + end + end + + assert get('/hello').ok? + assert_body '{}' + end + + it "uses 404 error handler for not matching route" do + mock_app { + not_found do + "nf" + end + error 404 do + "e" + end + } + + get "/" + assert_equal "e", body + assert_equal 404, status + end + + it 'matches empty PATH_INFO to "/" if no route is defined for ""' do + mock_app do + get '/' do + 'worked' + end + end + + get '/', {}, "PATH_INFO" => "" + assert ok? + assert_equal 'worked', body + end + + it 'matches empty PATH_INFO to "" if a route is defined for ""' do + mock_app do + disable :protection + + get '/' do + 'did not work' + end + + get '' do + 'worked' + end + end + + get '/', {}, "PATH_INFO" => "" + assert ok? + assert_equal 'worked', body + end + it 'takes multiple definitions of a route' do mock_app { user_agent(/Foo/) @@ -100,7 +300,9 @@ class RoutingTest < Test::Unit::TestCase mock_app { get '/:foo' do assert_equal 'bar', params['foo'] + assert params.has_key?('foo') assert_equal 'bar', params[:foo] + assert params.has_key?(:foo) 'well, alright' end } @@ -108,6 +310,17 @@ class RoutingTest < Test::Unit::TestCase assert_equal 'well, alright', body end + it "handles params without a value" do + mock_app { + get '/' do + assert_nil params.fetch('foo') + "Given: #{params.keys.sort.join(',')}" + end + } + get '/?foo' + assert_equal 'Given: foo', body + end + it "merges named params and query string params in params" do mock_app { get '/:foo' do @@ -144,11 +357,77 @@ class RoutingTest < Test::Unit::TestCase assert ok? assert_equal "foo=hello;bar=", body + get '/hello?bar=baz' + assert ok? + assert_equal "foo=hello;bar=baz", body + get '/' assert ok? assert_equal "foo=;bar=", body end + it "uses the default encoding for named params" do + mock_app { + set :default_encoding ,'ISO-8859-1' + + get '/:foo/:bar' do + "foo=#{params[:foo].encoding};bar=#{params[:bar].encoding}" + end + } + get '/f%C3%B6%C3%B6/b%C3%B6%C3%B6' + assert ok? + assert_equal 'foo=ISO-8859-1;bar=ISO-8859-1', body + end + + it "supports named captures like %r{/hello/(?[^/?#]+)}" do + mock_app { + get Regexp.new('/hello/(?[^/?#]+)') do + "Hello #{params['person']}" + end + } + get '/hello/Frank' + assert_equal 'Hello Frank', body + end + + it "supports optional named captures like %r{/page(?.[^/?#]+)?}" do + mock_app { + get Regexp.new('/page(?.[^/?#]+)?') do + "format=#{params[:format]}" + end + } + + get '/page.html' + assert ok? + assert_equal "format=.html", body + + get '/page.xml' + assert ok? + assert_equal "format=.xml", body + + get '/page' + assert ok? + assert_equal "format=", body + end + + it 'uses the default encoding for named captures' do + mock_app { + set :default_encoding ,'ISO-8859-1' + + get Regexp.new('/page(?.[^/?#]+)?') do + "format=#{params[:format].encoding};captures=#{params[:captures][0].encoding}" + end + } + get '/page.f%C3%B6' + assert ok? + assert_equal 'format=ISO-8859-1;captures=ISO-8859-1', body + end + + it 'does not concatenate params with the same name' do + mock_app { get('/:foo') { params[:foo] } } + get '/a?foo=b' + assert_body 'a' + end + it "supports single splat params like /*" do mock_app { get '/*' do @@ -217,7 +496,7 @@ class RoutingTest < Test::Unit::TestCase assert_equal 'right on', body end - it "literally matches . in paths" do + it "literally matches dot in paths" do route_def '/test.bar' get '/test.bar' @@ -226,14 +505,14 @@ class RoutingTest < Test::Unit::TestCase assert not_found? end - it "literally matches $ in paths" do + it "literally matches dollar sign in paths" do route_def '/test$/' get '/test$/' assert ok? end - it "literally matches + in paths" do + it "literally matches plus sign in paths" do route_def '/te+st/' get '/te%2Bst/' @@ -242,8 +521,19 @@ class RoutingTest < Test::Unit::TestCase assert not_found? end - it "literally matches () in paths" do - route_def '/test(bar)/' + it "does not convert plus sign into space as the value of a named param" do + mock_app do + get '/:test' do + params["test"] + end + end + get '/bob+ross' + assert ok? + assert_equal 'bob+ross', body + end + + it "literally matches parens in paths when escaped" do + route_def '/test\(bar\)/' get '/test(bar)/' assert ok? @@ -273,6 +563,30 @@ class RoutingTest < Test::Unit::TestCase assert_equal 'well, alright', body end + it "exposes params nested within arrays with indifferent hash" do + mock_app { + get '/testme' do + assert_equal 'baz', params['bar'][0]['foo'] + assert_equal 'baz', params['bar'][0][:foo] + 'well, alright' + end + } + get '/testme?bar[][foo]=baz' + assert_equal 'well, alright', body + end + + it "supports arrays within params" do + mock_app { + get '/foo' do + assert_equal ['A', 'B'], params['bar'] + 'looks good' + end + } + get '/foo?bar[]=A&bar[]=B' + assert ok? + assert_equal 'looks good', body + end + it "supports deeply nested params" do expected_params = { "emacs" => { @@ -335,6 +649,18 @@ class RoutingTest < Test::Unit::TestCase assert_equal 'looks good', body end + it "matches paths that include ampersands" do + mock_app { + get '/:name' do + 'looks good' + end + } + + get '/foo&bar' + assert ok? + assert_equal 'looks good', body + end + it "URL decodes named parameters and splats" do mock_app { get '/:foo/*' do @@ -348,9 +674,23 @@ class RoutingTest < Test::Unit::TestCase assert ok? end + it 'unescapes named parameters and splats' do + mock_app { + get '/:foo/*' do |a, b| + assert_equal "foo\xE2\x80\x8Cbar", params['foo'] + assert_predicate params['foo'], :valid_encoding? + + assert_equal ["bar\xE2\x80\x8Cbaz"], params['splat'] + end + } + + get '/foo%e2%80%8cbar/bar%e2%80%8cbaz' + assert ok? + end + it 'supports regular expressions' do mock_app { - get(/^\/foo...\/bar$/) do + get(/\/foo...\/bar/) do 'Hello World' end } @@ -360,9 +700,22 @@ class RoutingTest < Test::Unit::TestCase assert_equal 'Hello World', body end + it 'unescapes regular expression captures' do + mock_app { + get(/\/foo\/(.+)/) do |path| + path + end + } + + get '/foo/bar%e2%80%8cbaz' + assert ok? + assert_equal "bar\xE2\x80\x8Cbaz", body + assert_predicate body, :valid_encoding? + end + it 'makes regular expression captures available in params[:captures]' do mock_app { - get(/^\/fo(.*)\/ba(.*)/) do + get(/\/fo(.*)\/ba(.*)/) do assert_equal ['orooomma', 'f'], params[:captures] 'right on' end @@ -373,9 +726,32 @@ class RoutingTest < Test::Unit::TestCase assert_equal 'right on', body end + it 'makes regular expression captures available in params[:captures] for concatenated routes' do + with_regexp = Mustermann.new('/prefix') + Mustermann.new("/fo(.*)/ba(.*)", type: :regexp) + without_regexp = Mustermann.new('/prefix', type: :identity) + Mustermann.new('/baz') + mock_app { + get(with_regexp) do + assert_equal ['orooomma', 'f'], params[:captures] + 'right on' + end + get(without_regexp) do + assert !params.keys.include?(:captures) + 'no captures here' + end + } + + get '/prefix/foorooomma/baf' + assert ok? + assert_equal 'right on', body + + get '/prefix/baz' + assert ok? + assert_equal 'no captures here', body + end + it 'supports regular expression look-alike routes' do mock_app { - get(RegexpLookAlike.new) do + get(PatternLookAlike.new) do assert_equal 'this', params[:one] assert_equal 'is', params[:two] assert_equal 'a', params[:three] @@ -390,7 +766,7 @@ class RoutingTest < Test::Unit::TestCase end it 'raises a TypeError when pattern is not a String or Regexp' do - assert_raise(TypeError) { + assert_raises(TypeError) { mock_app { get(42){} } } end @@ -432,6 +808,17 @@ class RoutingTest < Test::Unit::TestCase assert_equal 'HelloWorldHowAreYou', body end + it 'sets response.status with halt' do + status_was = nil + mock_app do + after { status_was = status } + get('/') { halt 500, 'error' } + end + get '/' + assert_status 500 + assert_equal 500, status_was + end + it "transitions to the next matching route on pass" do mock_app { get '/:foo' do @@ -450,6 +837,23 @@ class RoutingTest < Test::Unit::TestCase assert_equal 'Hello World', body end + it "makes original request params available in error handler" do + mock_app { + disable :raise_errors + + get '/:foo' do + raise ArgumentError, "foo" + end + + error do + "Hello #{params['foo']}2" + end + } + + get '/bar' + assert_equal 'Hello bar2', body + end + it "transitions to 404 when passed and no subsequent route matches" do mock_app { get '/:foo' do @@ -462,6 +866,23 @@ class RoutingTest < Test::Unit::TestCase assert not_found? end + it "transitions to 404 and sets X-Cascade header when passed and no subsequent route matches" do + mock_app { + get '/:foo' do + pass + 'Hello Foo' + end + + get '/bar' do + 'Hello Bar' + end + } + + get '/foo' + assert not_found? + assert_equal 'pass', response.headers['X-Cascade'] + end + it "uses optional block passed to pass as route block if no other route is found" do mock_app { get "/" do @@ -477,6 +898,24 @@ class RoutingTest < Test::Unit::TestCase assert "this", body end + it "uses optional block passed to pass as route block if no other route is found and superclass has non-matching routes" do + base = Class.new(Sinatra::Base) + base.get('/foo') { 'foo in baseclass' } + + mock_app(base) { + get "/" do + pass do + "this" + end + "not this" + end + } + + get "/" + assert_equal 200, status + assert "this", body + end + it "passes when matching condition returns false" do mock_app { condition { params[:foo] == 'bar' } @@ -552,6 +991,18 @@ class RoutingTest < Test::Unit::TestCase assert_equal 'Hello World', body end + it "treats missing user agent like an empty string" do + mock_app do + user_agent(/.*/) + get '/' do + "Hello World" + end + end + get '/' + assert_equal 200, status + assert_equal 'Hello World', body + end + it "makes captures in user agent pattern available in params[:agent]" do mock_app { user_agent(/Foo (.*)/) @@ -564,20 +1015,226 @@ class RoutingTest < Test::Unit::TestCase assert_equal 'Hello Bar', body end + it 'matches mime_types with dots, hyphens and plus signs' do + mime_types = %w( + application/atom+xml + application/ecmascript + application/EDI-X12 + application/EDIFACT + application/json + application/javascript + application/octet-stream + application/ogg + application/pdf + application/postscript + application/rdf+xml + application/rss+xml + application/soap+xml + application/font-woff + application/xhtml+xml + application/xml + application/xml-dtd + application/xop+xml + application/zip + application/gzip + audio/basic + audio/L24 + audio/mp4 + audio/mpeg + audio/ogg + audio/vorbis + audio/vnd.rn-realaudio + audio/vnd.wave + audio/webm + image/gif + image/jpeg + image/pjpeg + image/png + image/svg+xml + image/tiff + image/vnd.microsoft.icon + message/http + message/imdn+xml + message/partial + message/rfc822 + model/example + model/iges + model/mesh + model/vrml + model/x3d+binary + model/x3d+vrml + model/x3d+xml + multipart/mixed + multipart/alternative + multipart/related + multipart/form-data + multipart/signed + multipart/encrypted + text/cmd + text/css + text/csv + text/html + text/javascript + application/javascript + text/plain + text/vcard + text/xml + video/mpeg + video/mp4 + video/ogg + video/quicktime + video/webm + video/x-matroska + video/x-ms-wmv + video/x-flv + application/vnd.oasis.opendocument.text + application/vnd.oasis.opendocument.spreadsheet + application/vnd.oasis.opendocument.presentation + application/vnd.oasis.opendocument.graphics + application/vnd.ms-excel + application/vnd.openxmlformats-officedocument.spreadsheetml.sheet + application/vnd.ms-powerpoint + application/vnd.openxmlformats-officedocument.presentationml.presentation + application/vnd.openxmlformats-officedocument.wordprocessingml.document + application/vnd.mozilla.xul+xml + application/vnd.google-earth.kml+xml + application/x-deb + application/x-dvi + application/x-font-ttf + application/x-javascript + application/x-latex + application/x-mpegURL + application/x-rar-compressed + application/x-shockwave-flash + application/x-stuffit + application/x-tar + application/x-www-form-urlencoded + application/x-xpinstall + audio/x-aac + audio/x-caf + image/x-xcf + text/x-gwt-rpc + text/x-jquery-tmpl + application/x-pkcs12 + application/x-pkcs12 + application/x-pkcs7-certificates + application/x-pkcs7-certificates + application/x-pkcs7-certreqresp + application/x-pkcs7-mime + application/x-pkcs7-mime + application/x-pkcs7-signature + ) + + mime_types.each { |mime_type| assert mime_type.match(Sinatra::Request::HEADER_VALUE_WITH_PARAMS) } + end + it "filters by accept header" do mock_app { get '/', :provides => :xml do - request.env['HTTP_ACCEPT'] + env['HTTP_ACCEPT'] + end + get '/foo', :provides => :html do + env['HTTP_ACCEPT'] + end + get '/stream', :provides => 'text/event-stream' do + env['HTTP_ACCEPT'] end } get '/', {}, { 'HTTP_ACCEPT' => 'application/xml' } assert ok? assert_equal 'application/xml', body - assert_equal 'application/xml', response.headers['Content-Type'] + assert_equal 'application/xml;charset=utf-8', response.headers['Content-Type'] - get '/', {}, { :accept => 'text/html' } + get '/', {}, {} + assert ok? + assert_equal '', body + assert_equal 'application/xml;charset=utf-8', response.headers['Content-Type'] + + get '/', {}, { 'HTTP_ACCEPT' => '*/*' } + assert ok? + assert_equal '*/*', body + assert_equal 'application/xml;charset=utf-8', response.headers['Content-Type'] + + get '/', {}, { 'HTTP_ACCEPT' => 'text/html;q=0.9' } + assert !ok? + + get '/foo', {}, { 'HTTP_ACCEPT' => 'text/html;q=0.9' } + assert ok? + assert_equal 'text/html;q=0.9', body + + get '/foo', {}, { 'HTTP_ACCEPT' => '' } + assert ok? + assert_equal '', body + + get '/foo', {}, { 'HTTP_ACCEPT' => '*/*' } + assert ok? + assert_equal '*/*', body + + get '/foo', {}, { 'HTTP_ACCEPT' => 'application/xml' } assert !ok? + + get '/stream', {}, { 'HTTP_ACCEPT' => 'text/event-stream' } + assert ok? + assert_equal 'text/event-stream', body + + get '/stream', {}, { 'HTTP_ACCEPT' => '' } + assert ok? + assert_equal '', body + + get '/stream', {}, { 'HTTP_ACCEPT' => '*/*' } + assert ok? + assert_equal '*/*', body + + get '/stream', {}, { 'HTTP_ACCEPT' => 'application/xml' } + assert !ok? + end + + it "filters by current Content-Type" do + mock_app do + before('/txt') { content_type :txt } + get('*', :provides => :txt) { 'txt' } + + before('/html') { content_type :html } + get('*', :provides => :html) { 'html' } + end + + get '/', {}, { 'HTTP_ACCEPT' => '*/*' } + assert ok? + assert_equal 'text/plain;charset=utf-8', response.headers['Content-Type'] + assert_body 'txt' + + get '/txt', {}, { 'HTTP_ACCEPT' => 'text/plain' } + assert ok? + assert_equal 'text/plain;charset=utf-8', response.headers['Content-Type'] + assert_body 'txt' + + get '/', {}, { 'HTTP_ACCEPT' => 'text/html' } + assert ok? + assert_equal 'text/html;charset=utf-8', response.headers['Content-Type'] + assert_body 'html' + end + + it "doesn't allow provides of passed routes to interfere with provides of other routes" do + mock_app do + get('/:foo', :provides => :txt) do + pass if params[:foo] != 'foo' + + 'foo' + end + + get('/bar', :provides => :html) { 'bar' } + end + + get '/foo', {}, { 'HTTP_ACCEPT' => '*/*' } + assert ok? + assert_equal 'text/plain;charset=utf-8', response.headers['Content-Type'] + assert_body 'foo' + + get '/bar', {}, { 'HTTP_ACCEPT' => '*/*' } + assert ok? + assert_equal 'text/html;charset=utf-8', response.headers['Content-Type'] + assert_body 'bar' end it "allows multiple mime types for accept header" do @@ -585,7 +1242,7 @@ class RoutingTest < Test::Unit::TestCase mock_app { get '/', :provides => types do - request.env['HTTP_ACCEPT'] + env['HTTP_ACCEPT'] end } @@ -597,18 +1254,145 @@ class RoutingTest < Test::Unit::TestCase end end - it 'degrades gracefully when optional accept header is not provided' do - mock_app { - get '/', :provides => :xml do - request.env['HTTP_ACCEPT'] - end - get '/' do - 'default' - end - } - get '/' + it 'respects user agent preferences for the content type' do + mock_app { get('/', :provides => [:png, :html]) { content_type }} + get '/', {}, { 'HTTP_ACCEPT' => 'image/png;q=0.5,text/html;q=0.8' } + assert_body 'text/html;charset=utf-8' + get '/', {}, { 'HTTP_ACCEPT' => 'image/png;q=0.8,text/html;q=0.5' } + assert_body 'image/png' + end + + it 'accepts generic types' do + mock_app do + get('/', :provides => :xml) { content_type } + get('/') { 'no match' } + end + get '/', {}, { 'HTTP_ACCEPT' => 'foo/*' } + assert_body 'no match' + get '/', {}, { 'HTTP_ACCEPT' => 'application/*' } + assert_body 'application/xml;charset=utf-8' + get '/', {}, { 'HTTP_ACCEPT' => '*/*' } + assert_body 'application/xml;charset=utf-8' + end + + it 'prefers concrete over partly generic types' do + mock_app { get('/', :provides => [:png, :html]) { content_type }} + get '/', {}, { 'HTTP_ACCEPT' => 'image/*, text/html' } + assert_body 'text/html;charset=utf-8' + get '/', {}, { 'HTTP_ACCEPT' => 'image/png, text/*' } + assert_body 'image/png' + end + + it 'prefers concrete over fully generic types' do + mock_app { get('/', :provides => [:png, :html]) { content_type }} + get '/', {}, { 'HTTP_ACCEPT' => '*/*, text/html' } + assert_body 'text/html;charset=utf-8' + get '/', {}, { 'HTTP_ACCEPT' => 'image/png, */*' } + assert_body 'image/png' + end + + it 'prefers partly generic over fully generic types' do + mock_app { get('/', :provides => [:png, :html]) { content_type }} + get '/', {}, { 'HTTP_ACCEPT' => '*/*, text/*' } + assert_body 'text/html;charset=utf-8' + get '/', {}, { 'HTTP_ACCEPT' => 'image/*, */*' } + assert_body 'image/png' + end + + it 'respects quality with generic types' do + mock_app { get('/', :provides => [:png, :html]) { content_type }} + get '/', {}, { 'HTTP_ACCEPT' => 'image/*;q=1, text/html;q=0' } + assert_body 'image/png' + get '/', {}, { 'HTTP_ACCEPT' => 'image/png;q=0.5, text/*;q=0.7' } + assert_body 'text/html;charset=utf-8' + end + + it 'supplies a default quality of 1.0' do + mock_app { get('/', :provides => [:png, :html]) { content_type }} + get '/', {}, { 'HTTP_ACCEPT' => 'image/png;q=0.5, text/*' } + assert_body 'text/html;charset=utf-8' + end + + it 'orders types with equal quality by parameter count' do + mock_app do + get('/', :provides => [:png, :jpg]) { content_type } + end + + lo_png = 'image/png;q=0.5' + hi_png = 'image/png;q=0.5;profile=FOGRA40;gamma=0.8' + jpeg = 'image/jpeg;q=0.5;compress=0.25' + + get '/', {}, { 'HTTP_ACCEPT' => "#{lo_png}, #{jpeg}" } + assert_body 'image/jpeg' + get '/', {}, { 'HTTP_ACCEPT' => "#{hi_png}, #{jpeg}" } + assert_body 'image/png' + end + + it 'ignores the quality parameter when ordering by parameter count' do + mock_app do + get('/', :provides => [:png, :jpg]) { content_type } + end + + lo_png = 'image/png' + hi_png = 'image/png;profile=FOGRA40;gamma=0.8' + jpeg = 'image/jpeg;q=1.0;compress=0.25' + + get '/', {}, { 'HTTP_ACCEPT' => "#{jpeg}, #{lo_png}" } + assert_body 'image/jpeg' + get '/', {}, { 'HTTP_ACCEPT' => "#{jpeg}, #{hi_png}" } + assert_body 'image/png' + end + + it 'properly handles quoted strings in parameters' do + mock_app do + get('/', :provides => [:png, :jpg]) { content_type } + end + + get '/', {}, { 'HTTP_ACCEPT' => 'image/png;q=0.5;profile=",image/jpeg,"' } + assert_body 'image/png' + get '/', {}, { 'HTTP_ACCEPT' => 'image/png;q=0.5,image/jpeg;q=0;x=";q=1.0"' } + assert_body 'image/png' + get '/', {}, { 'HTTP_ACCEPT' => 'image/png;q=0.5,image/jpeg;q=0;x="\";q=1.0"' } + assert_body 'image/png' + end + + it 'accepts both text/javascript and application/javascript for js' do + mock_app { get('/', :provides => :js) { content_type }} + get '/', {}, { 'HTTP_ACCEPT' => 'application/javascript' } + assert_body 'application/javascript;charset=utf-8' + get '/', {}, { 'HTTP_ACCEPT' => 'text/javascript' } + assert_body 'text/javascript;charset=utf-8' + end + + it 'accepts both text/xml and application/xml for xml' do + mock_app { get('/', :provides => :xml) { content_type }} + get '/', {}, { 'HTTP_ACCEPT' => 'application/xml' } + assert_body 'application/xml;charset=utf-8' + get '/', {}, { 'HTTP_ACCEPT' => 'text/xml' } + assert_body 'text/xml;charset=utf-8' + end + + it 'matches content-type to mime_type' do + mime_type = 'application/rss+xml;version="http://purl.org/rss/1.0/"' + mock_app do + configure { mime_type(:rss10, mime_type) } + get('/', :provides => [:rss10]) { content_type } + end + + get '/', {}, { 'HTTP_ACCEPT' => 'application/rss+xml' } assert ok? - assert_equal 'default', body + assert_body mime_type + end + + it 'handles missing mime_types with 404' do + mock_app do + configure { mime_type(:rss10, 'application/rss+xml') } + get('/', :provides => [:jpg]) { content_type } + end + + get '/', {}, { 'HTTP_ACCEPT' => 'application/rss+xml' } + assert_equal 404, status + assert_equal 'text/html;charset=utf-8', response['Content-Type'] end it 'passes a single url param as block parameters when one param is specified' do @@ -637,7 +1421,7 @@ class RoutingTest < Test::Unit::TestCase it 'passes regular expression captures as block parameters' do mock_app { - get(/^\/fo(.*)\/ba(.*)/) do |foo, bar| + get(/\/fo(.*)\/ba(.*)/) do |foo, bar| assert_equal 'orooomma', foo assert_equal 'f', bar 'looks good' @@ -664,14 +1448,27 @@ class RoutingTest < Test::Unit::TestCase assert_equal 'looks good', body end - it 'raises an ArgumentError with block arity > 1 and too many values' do + it "uses the default encoding for block parameters" do mock_app { + set :default_encoding ,'ISO-8859-1' + + get '/:foo/:bar' do |foo, bar| + "foo=#{foo.encoding};bar=#{bar.encoding}" + end + } + get '/f%C3%B6%C3%B6/b%C3%B6%C3%B6' + assert ok? + assert_equal 'foo=ISO-8859-1;bar=ISO-8859-1', body + end + + it 'raises an ArgumentError with block arity > 1 and too many values' do + mock_app do get '/:foo/:bar/:baz' do |foo, bar| 'quux' end - } + end - assert_raise(ArgumentError) { get '/a/b/c' } + assert_raises(ArgumentError) { get '/a/b/c' } end it 'raises an ArgumentError with block param arity > 1 and too few values' do @@ -681,7 +1478,7 @@ class RoutingTest < Test::Unit::TestCase end } - assert_raise(ArgumentError) { get '/a/b' } + assert_raises(ArgumentError) { get '/a/b' } end it 'succeeds if no block parameters are specified' do @@ -742,59 +1539,24 @@ def authorize(username, password) assert_equal "hey", body end - # NOTE Block params behaves differently under 1.8 and 1.9. Under 1.8, block - # param arity is lax: declaring a mismatched number of block params results - # in a warning. Under 1.9, block param arity is strict: mismatched block - # arity raises an ArgumentError. - - if RUBY_VERSION >= '1.9' - - it 'raises an ArgumentError with block param arity 1 and no values' do - mock_app { - get '/foo' do |foo| - 'quux' - end - } - - assert_raise(ArgumentError) { get '/foo' } - end - - it 'raises an ArgumentError with block param arity 1 and too many values' do - mock_app { - get '/:foo/:bar/:baz' do |foo| - 'quux' - end - } - - assert_raise(ArgumentError) { get '/a/b/c' } - end - - else - - it 'does not raise an ArgumentError with block param arity 1 and no values' do - mock_app { - get '/foo' do |foo| - 'quux' - end - } - - silence_warnings { get '/foo' } - assert ok? - assert_equal 'quux', body - end + it 'raises an ArgumentError with block param arity 1 and no values' do + mock_app { + get '/foo' do |foo| + 'quux' + end + } - it 'does not raise an ArgumentError with block param arity 1 and too many values' do - mock_app { - get '/:foo/:bar/:baz' do |foo| - 'quux' - end - } + assert_raises(ArgumentError) { get '/foo' } + end - silence_warnings { get '/a/b/c' } - assert ok? - assert_equal 'quux', body - end + it 'raises an ArgumentError with block param arity 1 and too many values' do + mock_app { + get '/:foo/:bar/:baz' do |foo| + 'quux' + end + } + assert_raises(ArgumentError) { get '/a/b/c' } end it "matches routes defined in superclasses" do @@ -831,4 +1593,104 @@ def authorize(username, password) assert ok? assert_equal 'bar in baseclass', body end + + it "adds hostname condition when it is in options" do + mock_app { + get '/foo', :host => 'host' do + 'foo' + end + } + + get '/foo' + assert not_found? + end + + it 'allows using call to fire another request internally' do + mock_app do + get '/foo' do + status, headers, body = call env.merge("PATH_INFO" => '/bar') + [status, headers, body.each.map(&:upcase)] + end + + get '/bar' do + "bar" + end + end + + get '/foo' + assert ok? + assert_body "BAR" + end + + it 'plays well with other routing middleware' do + middleware = Sinatra.new + inner_app = Sinatra.new { get('/foo') { 'hello' } } + builder = Rack::Builder.new do + use middleware + map('/test') { run inner_app } + end + + @app = builder.to_app + get '/test/foo' + assert ok? + assert_body 'hello' + end + + it 'returns the route signature' do + signature = list = nil + + mock_app do + signature = post('/') { } + list = routes['POST'] + end + + assert_equal Array, signature.class + assert_equal 3, signature.length + assert list.include?(signature) + end + + it "sets env['sinatra.route'] to the matched route" do + mock_app do + after do + assert_equal 'GET /users/:id/status', env['sinatra.route'] + end + get('/users/:id/status') { 'ok' } + end + get '/users/1/status' + end + + it 'treats routes with and without trailing slashes differently' do + mock_app do + get '/foo' do + 'Foo' + end + + get '/foo/' do + 'Foo with a slash' + end + end + + get '/foo' + assert_equal 'Foo', body + refute_equal 'Foo with a slash', body + + get '/foo/' + assert_equal 'Foo with a slash', body + end + + it 'does not treat routes with and without trailing slashes differently if :strict_paths is disabled' do + mock_app do + disable :strict_paths + + get '/foo' do + 'foo' + end + end + + get '/foo' + assert_equal 'foo', body + + get '/foo/' + assert_equal 'foo', body + end end diff --git a/test/sass_test.rb b/test/sass_test.rb deleted file mode 100644 index c8d6a73120..0000000000 --- a/test/sass_test.rb +++ /dev/null @@ -1,79 +0,0 @@ -require File.dirname(__FILE__) + '/helper' -require 'sass' - -class SassTest < Test::Unit::TestCase - def sass_app(&block) - mock_app { - set :views, File.dirname(__FILE__) + '/views' - get '/', &block - } - get '/' - end - - it 'renders inline Sass strings' do - sass_app { sass "#sass\n :background-color #FFF\n" } - assert ok? - assert_equal "#sass {\n background-color: #FFF; }\n", body - end - - it 'renders .sass files in views path' do - sass_app { sass :hello } - assert ok? - assert_equal "#sass {\n background-color: #FFF; }\n", body - end - - it 'ignores the layout option' do - sass_app { sass :hello, :layout => :layout2 } - assert ok? - assert_equal "#sass {\n background-color: #FFF; }\n", body - end - - it "raises error if template not found" do - mock_app { - get('/') { sass :no_such_template } - } - assert_raise(Errno::ENOENT) { get('/') } - end - - it "passes SASS options to the Sass engine" do - sass_app { - sass "#sass\n :background-color #FFF\n :color #000\n", :style => :compact - } - assert ok? - assert_equal "#sass { background-color: #FFF; color: #000; }\n", body - end - - it "passes default SASS options to the Sass engine" do - mock_app { - set :sass, {:style => :compact} # default Sass style is :nested - get '/' do - sass "#sass\n :background-color #FFF\n :color #000\n" - end - } - get '/' - assert ok? - assert_equal "#sass { background-color: #FFF; color: #000; }\n", body - end - - it "merges the default SASS options with the overrides and passes them to the Sass engine" do - mock_app { - set :sass, {:style => :compact, :attribute_syntax => :alternate } # default Sass attribute_syntax is :normal (with : in front) - get '/' do - sass "#sass\n background-color: #FFF\n color: #000\n" - end - get '/raised' do - sass "#sass\n :background-color #FFF\n :color #000\n", :style => :expanded # retains global attribute_syntax settings - end - get '/expanded_normal' do - sass "#sass\n :background-color #FFF\n :color #000\n", :style => :expanded, :attribute_syntax => :normal - end - } - get '/' - assert ok? - assert_equal "#sass { background-color: #FFF; color: #000; }\n", body - assert_raise(Sass::SyntaxError) { get('/raised') } - get '/expanded_normal' - assert ok? - assert_equal "#sass {\n background-color: #FFF;\n color: #000;\n}\n", body - end -end diff --git a/test/server_test.rb b/test/server_test.rb index 76d4ef972d..764295b871 100644 --- a/test/server_test.rb +++ b/test/server_test.rb @@ -1,8 +1,17 @@ -require File.dirname(__FILE__) + '/helper' +require_relative 'test_helper' +require 'stringio' module Rack::Handler class Mock - extend Test::Unit::Assertions + extend Minitest::Assertions + # Allow assertions in request context + def self.assertions + @assertions ||= 0 + end + + def self.assertions= assertions + @assertions = assertions + end def self.run(app, options={}) assert(app < Sinatra::Base) @@ -18,18 +27,18 @@ def stop register 'mock', 'Rack::Handler::Mock' end -class ServerTest < Test::Unit::TestCase +class ServerTest < Minitest::Test setup do - mock_app { + mock_app do set :server, 'mock' - set :host, 'foo.local' + set :bind, 'foo.local' set :port, 9001 - } - $stdout = File.open('/dev/null', 'wb') + end + $stderr = StringIO.new end def teardown - $stdout = STDOUT + $stderr = STDERR end it "locates the appropriate Rack handler and calls ::run" do @@ -44,4 +53,37 @@ def teardown it "falls back on the next server handler when not found" do @app.run! :server => %w[foo bar mock] end + + it "initializes Rack middleware immediately on server run" do + class MyMiddleware + @@initialized = false + def initialize(app) + @@initialized = true + end + def self.initialized + @@initialized + end + def call(env) + end + end + + @app.use MyMiddleware + assert_equal(MyMiddleware.initialized, false) + @app.run! + assert_equal(MyMiddleware.initialized, true) + end + + describe "Quiet mode" do + it "sends data to stderr when server starts and stops" do + @app.run! + assert_match(/\=\= Sinatra/, $stderr.string) + end + + context "when quiet mode is activated" do + it "does not generate Sinatra start and stop messages" do + @app.run! quiet: true + refute_match(/\=\= Sinatra/, $stderr.string) + end + end + end end diff --git a/test/settings_test.rb b/test/settings_test.rb new file mode 100644 index 0000000000..c1aa02c9c9 --- /dev/null +++ b/test/settings_test.rb @@ -0,0 +1,654 @@ +require_relative 'test_helper' + +class SettingsTest < Minitest::Test + setup do + @base = Sinatra.new(Sinatra::Base) + @base.set :environment => :foo, :app_file => nil + + @application = Sinatra.new(Sinatra::Application) + @application.set :environment => :foo, :app_file => nil + end + + it 'sets settings to literal values' do + @base.set(:foo, 'bar') + assert @base.respond_to?(:foo) + assert_equal 'bar', @base.foo + end + + it 'sets settings to Procs' do + @base.set(:foo, Proc.new { 'baz' }) + assert @base.respond_to?(:foo) + assert_equal 'baz', @base.foo + end + + it 'sets settings using a block' do + @base.set(:foo){ 'baz' } + assert @base.respond_to?(:foo) + assert_equal 'baz', @base.foo + end + + it 'raises an error with a value and a block' do + assert_raises ArgumentError do + @base.set(:fiz, 'boom!'){ 'baz' } + end + assert !@base.respond_to?(:fiz) + end + + it 'raises an error without value and block' do + assert_raises(ArgumentError) { @base.set(:fiz) } + assert !@base.respond_to?(:fiz) + end + + it 'allows setting a value to the app class' do + @base.set :base, @base + assert @base.respond_to?(:base) + assert_equal @base, @base.base + end + + it 'raises an error with the app class as value and a block' do + assert_raises ArgumentError do + @base.set(:fiz, @base) { 'baz' } + end + assert !@base.respond_to?(:fiz) + end + + it "sets multiple settings with a Hash" do + @base.set :foo => 1234, + :bar => 'Hello World', + :baz => Proc.new { 'bizzle' } + assert_equal 1234, @base.foo + assert_equal 'Hello World', @base.bar + assert_equal 'bizzle', @base.baz + end + + it 'sets multiple settings using #each' do + @base.set [["foo", "bar"]] + assert_equal "bar", @base.foo + end + + it 'inherits settings methods when subclassed' do + @base.set :foo, 'bar' + @base.set :biz, Proc.new { 'baz' } + + sub = Class.new(@base) + assert sub.respond_to?(:foo) + assert_equal 'bar', sub.foo + assert sub.respond_to?(:biz) + assert_equal 'baz', sub.biz + end + + it 'overrides settings in subclass' do + @base.set :foo, 'bar' + @base.set :biz, Proc.new { 'baz' } + sub = Class.new(@base) + sub.set :foo, 'bling' + assert_equal 'bling', sub.foo + assert_equal 'bar', @base.foo + end + + it 'creates setter methods when first defined' do + @base.set :foo, 'bar' + assert @base.respond_to?('foo=') + @base.foo = 'biz' + assert_equal 'biz', @base.foo + end + + it 'creates predicate methods when first defined' do + @base.set :foo, 'hello world' + assert @base.respond_to?(:foo?) + assert @base.foo? + @base.set :foo, nil + assert !@base.foo? + end + + it 'uses existing setter methods if detected' do + class << @base + def foo + @foo + end + def foo=(value) + @foo = 'oops' + end + end + + @base.set :foo, 'bam' + assert_equal 'oops', @base.foo + end + + it 'merges values of multiple set calls if those are hashes' do + @base.set :foo, :a => 1 + sub = Class.new(@base) + sub.set :foo, :b => 2 + assert_equal({:a => 1, :b => 2}, sub.foo) + end + + it 'merging does not affect the superclass' do + @base.set :foo, :a => 1 + sub = Class.new(@base) + sub.set :foo, :b => 2 + assert_equal({:a => 1}, @base.foo) + end + + it 'is possible to change a value from a hash to something else' do + @base.set :foo, :a => 1 + @base.set :foo, :bar + assert_equal(:bar, @base.foo) + end + + it 'merges values with values of the superclass if those are hashes' do + @base.set :foo, :a => 1 + @base.set :foo, :b => 2 + assert_equal({:a => 1, :b => 2}, @base.foo) + end + + it "sets multiple settings to true with #enable" do + @base.enable :sessions, :foo, :bar + assert @base.sessions + assert @base.foo + assert @base.bar + end + + it "sets multiple settings to false with #disable" do + @base.disable :sessions, :foo, :bar + assert !@base.sessions + assert !@base.foo + assert !@base.bar + end + + it 'is accessible from instances via #settings' do + assert_equal :foo, @base.new!.settings.environment + end + + it 'is accessible from class via #settings' do + assert_equal :foo, @base.settings.environment + end + + describe 'default_content_type' do + it 'defaults to html' do + assert_equal 'text/html', @base.default_content_type + end + + it 'can be changed' do + @base.set :default_content_type, 'application/json' + @base.get('/') { '{"a":1}' } + @app = @base + get '/' + assert_equal 200, status + assert_equal 'application/json', response.content_type + end + + it 'can be disabled' do + @base.set :default_content_type, nil + @base.error(404) { "" } + @app = @base + get '/' + assert_equal 404, status + assert_nil response.content_type + assert_empty body + end + + it 'may emit content without a content-type (to be sniffed)' do + @base.set :default_content_type, nil + @base.get('/') { raise Sinatra::BadRequest, "This is a drill" } + @app = @base + get '/' + assert_equal 400, status + assert_nil response.content_type + assert_body "This is a drill" + end + end + + describe 'methodoverride' do + it 'is disabled on Base' do + assert ! @base.method_override? + end + + it 'is enabled on Application' do + assert @application.method_override? + end + + it 'enables MethodOverride middleware' do + @base.set :method_override, true + @base.put('/') { 'okay' } + @app = @base + post '/', {'_method'=>'PUT'}, {} + assert_equal 200, status + assert_equal 'okay', body + end + + it 'is backward compatible with methodoverride' do + assert ! @base.methodoverride? + @base.enable :methodoverride + assert @base.methodoverride? + end + + it 'ignores bundler/inline from callers' do + @application.stub(:caller, ->(_){ ['/path/to/bundler/inline.rb', $0] }) do + assert_equal File.expand_path($0), File.expand_path(@application.send(:caller_files).first) + end + end + end + + describe 'run' do + it 'is disabled on Base' do + assert ! @base.run? + end + + it 'is enabled on Application except in test environment' do + assert @application.run? + + @application.set :environment, :test + assert ! @application.run? + end + end + + describe 'raise_errors' do + it 'is enabled on Base only in test' do + assert ! @base.raise_errors? + + @base.set(:environment, :test) + assert @base.raise_errors? + end + + it 'is enabled on Application only in test' do + assert ! @application.raise_errors? + + @application.set(:environment, :test) + assert @application.raise_errors? + end + end + + describe 'show_exceptions' do + it 'is disabled on Base except under development' do + assert ! @base.show_exceptions? + @base.environment = :development + assert @base.show_exceptions? + end + + it 'is disabled on Application except in development' do + assert ! @application.show_exceptions? + + @application.set(:environment, :development) + assert @application.show_exceptions? + end + + it 'returns a friendly 500' do + klass = Sinatra.new(Sinatra::Application) + mock_app(klass) { + enable :show_exceptions + + get '/' do + raise StandardError + end + } + + get '/' + assert_equal 500, status + assert body.include?("StandardError") + assert body.include?("show_exceptions setting") + end + + it 'does not attempt to show unparseable query parameters' do + klass = Sinatra.new(Sinatra::Application) + mock_app(klass) { + enable :show_exceptions + + get '/' do + raise Sinatra::BadRequest + end + } + + get '/' + assert_equal 400, status + refute body.include?('
') + refute body.include?('
') + end + + it 'does not override app-specified error handling when set to :after_handler' do + ran = false + mock_app do + set :show_exceptions, :after_handler + error(RuntimeError) { ran = true } + get('/') { raise RuntimeError } + end + + get '/' + assert_equal 500, status + assert ran + end + + it 'does catch any other exceptions when set to :after_handler' do + ran = false + mock_app do + set :show_exceptions, :after_handler + error(RuntimeError) { ran = true } + get('/') { raise ArgumentError } + end + + get '/' + assert_equal 500, status + assert !ran + end + end + + describe 'dump_errors' do + it 'is disabled on Base in test' do + @base.environment = :test + assert ! @base.dump_errors? + @base.environment = :development + assert @base.dump_errors? + @base.environment = :production + assert @base.dump_errors? + end + + it 'dumps exception with backtrace to rack.errors' do + klass = Sinatra.new(Sinatra::Application) + + mock_app(klass) { + enable :dump_errors + disable :raise_errors + + error do + error = @env['rack.errors'].instance_variable_get(:@error) + error.rewind + + error.read + end + + get '/' do + raise + end + } + + get '/' + assert body.include?("RuntimeError") && body.include?("settings_test.rb") + end + + it 'does not dump 404 errors' do + klass = Sinatra.new(Sinatra::Application) + + mock_app(klass) { + enable :dump_errors + disable :raise_errors + + error do + error = @env['rack.errors'].instance_variable_get(:@error) + error.rewind + + error.read + end + + get '/' do + raise Sinatra::NotFound + end + } + + get '/' + assert !body.include?("NotFound") && !body.include?("settings_test.rb") + end + end + + describe 'sessions' do + it 'is disabled on Base' do + assert ! @base.sessions? + end + + it 'is disabled on Application' do + assert ! @application.sessions? + end + end + + describe 'logging' do + it 'is disabled on Base' do + assert ! @base.logging? + end + + it 'is enabled on Application except in test environment' do + assert @application.logging? + + @application.set :environment, :test + assert ! @application.logging + end + end + + describe 'static' do + it 'is disabled on Base by default' do + assert ! @base.static? + end + + it 'is enabled on Base when public_folder is set and exists' do + @base.set :environment, :development + @base.set :public_folder, __dir__ + assert @base.static? + end + + it 'is enabled on Base when root is set and root/public_folder exists' do + @base.set :environment, :development + @base.set :root, __dir__ + assert @base.static? + end + + it 'is disabled on Application by default' do + assert ! @application.static? + end + + it 'is enabled on Application when public_folder is set and exists' do + @application.set :environment, :development + @application.set :public_folder, __dir__ + assert @application.static? + end + + it 'is enabled on Application when root is set and root/public_folder exists' do + @application.set :environment, :development + @application.set :root, __dir__ + assert @application.static? + end + + it 'is possible to use Module#public' do + @base.send(:define_method, :foo) { } + @base.send(:private, :foo) + assert !@base.public_method_defined?(:foo) + @base.send(:public, :foo) + assert @base.public_method_defined?(:foo) + end + + it 'is possible to use the keyword public in a sinatra app' do + app = Sinatra.new do + private + def priv; end + public + def pub; end + end + assert !app.public_method_defined?(:priv) + assert app.public_method_defined?(:pub) + end + end + + describe 'bind' do + it 'defaults to 0.0.0.0' do + assert_equal '0.0.0.0', @base.bind + assert_equal '0.0.0.0', @application.bind + end + end + + describe 'port' do + it 'defaults to 4567' do + assert_equal 4567, @base.port + assert_equal 4567, @application.port + end + end + + describe 'server' do + it 'includes webrick' do + assert @base.server.include?('webrick') + assert @application.server.include?('webrick') + end + + it 'includes puma' do + assert @base.server.include?('puma') + assert @application.server.include?('puma') + end + + it 'includes falcon on non-jruby' do + if RUBY_ENGINE != 'jruby' + assert @base.server.include?('falcon') + assert @application.server.include?('falcon') + else + assert !@base.server.include?('falcon') + assert !@application.server.include?('falcon') + end + end + end + + describe 'app_file' do + it 'is nil for base classes' do + assert_nil Sinatra::Base.app_file + assert_nil Sinatra::Application.app_file + end + + it 'defaults to the file subclassing' do + assert_equal File.expand_path(__FILE__), Sinatra.new.app_file + end + end + + describe 'root' do + it 'is nil if app_file is not set' do + assert_nil @base.root + assert_nil @application.root + end + + it 'is equal to the expanded basename of app_file' do + @base.app_file = __FILE__ + assert_equal File.expand_path(__dir__), @base.root + + @application.app_file = __FILE__ + assert_equal File.expand_path(__dir__), @application.root + end + end + + describe 'views' do + it 'is nil if root is not set' do + assert_nil @base.views + assert_nil @application.views + end + + it 'is set to root joined with views/' do + @base.root = __dir__ + assert_equal __dir__ + "/views", @base.views + + @application.root = __dir__ + assert_equal __dir__ + "/views", @application.views + end + end + + describe 'public_folder' do + it 'is nil if root is not set' do + assert_nil @base.public_folder + assert_nil @application.public_folder + end + + it 'is set to root joined with public/' do + @base.root = __dir__ + assert_equal __dir__ + "/public", @base.public_folder + + @application.root = __dir__ + assert_equal __dir__ + "/public", @application.public_folder + end + end + + describe 'public_dir' do + it 'is an alias for public_folder' do + @base.public_dir = __dir__ + assert_equal __dir__, @base.public_dir + assert_equal @base.public_folder, @base.public_dir + + @application.public_dir = __dir__ + assert_equal __dir__, @application.public_dir + assert_equal @application.public_folder, @application.public_dir + end + end + + describe 'lock' do + it 'is disabled by default' do + assert ! @base.lock? + assert ! @application.lock? + end + end + + describe 'protection' do + class MiddlewareTracker < Rack::Builder + def self.track + Rack.send :remove_const, :Builder + Rack.const_set :Builder, MiddlewareTracker + MiddlewareTracker.used.clear + yield + ensure + Rack.send :remove_const, :Builder + Rack.const_set :Builder, MiddlewareTracker.superclass + end + + def self.used + @used ||= [] + end + + def use(middleware, *) + MiddlewareTracker.used << middleware + super + end + end + + it 'sets up Rack::Protection' do + MiddlewareTracker.track do + Sinatra::Base.new + assert_include MiddlewareTracker.used, Rack::Protection + end + end + + it 'sets up Rack::Protection::PathTraversal' do + MiddlewareTracker.track do + Sinatra::Base.new + assert_include MiddlewareTracker.used, Rack::Protection::PathTraversal + end + end + + it 'does not set up Rack::Protection::PathTraversal when disabling it' do + MiddlewareTracker.track do + Sinatra.new { set :protection, :except => :path_traversal }.new + assert_include MiddlewareTracker.used, Rack::Protection + assert !MiddlewareTracker.used.include?(Rack::Protection::PathTraversal) + end + end + + it 'sets up RemoteToken if sessions are enabled' do + MiddlewareTracker.track do + Sinatra.new { enable :sessions }.new + assert_include MiddlewareTracker.used, Rack::Protection::RemoteToken + end + end + + it 'sets up RemoteToken if sessions are enabled with a custom session store' do + MiddlewareTracker.track do + Sinatra.new { + enable :sessions + set :session_store, Rack::Session::Pool + }.new + assert_include MiddlewareTracker.used, Rack::Session::Pool + assert_include MiddlewareTracker.used, Rack::Protection::RemoteToken + end + end + + it 'does not set up RemoteToken if sessions are disabled' do + MiddlewareTracker.track do + Sinatra.new.new + assert !MiddlewareTracker.used.include?(Rack::Protection::RemoteToken) + end + end + + it 'sets up RemoteToken if it is configured to' do + MiddlewareTracker.track do + Sinatra.new { set :protection, :session => true }.new + assert_include MiddlewareTracker.used, Rack::Protection::RemoteToken + end + end + end +end diff --git a/test/sinatra_test.rb b/test/sinatra_test.rb index 5c695b22ad..92a30809aa 100644 --- a/test/sinatra_test.rb +++ b/test/sinatra_test.rb @@ -1,13 +1,12 @@ -require File.dirname(__FILE__) + '/helper' +require_relative 'test_helper' -class SinatraTest < Test::Unit::TestCase +class SinatraTest < Minitest::Test it 'creates a new Sinatra::Base subclass on new' do - app = - Sinatra.new do - get '/' do - 'Hello World' - end - end + app = Sinatra.new { get('/') { 'Hello World' } } assert_same Sinatra::Base, app.superclass end + + it "responds to #template_cache" do + assert_kind_of Tilt::Cache, Sinatra::Base.new!.template_cache + end end diff --git a/test/slim_test.rb b/test/slim_test.rb new file mode 100644 index 0000000000..7eea730698 --- /dev/null +++ b/test/slim_test.rb @@ -0,0 +1,102 @@ +require_relative 'test_helper' + +begin +require 'slim' + +class SlimTest < Minitest::Test + def slim_app(&block) + mock_app do + set :views, __dir__ + '/views' + get('/', &block) + end + get '/' + end + + it 'renders inline slim strings' do + slim_app { slim "h1 Hiya\n" } + assert ok? + assert_equal "

Hiya

", body + end + + it 'renders .slim files in views path' do + slim_app { slim :hello } + assert ok? + assert_equal "

Hello From Slim

", body + end + + it "renders with inline layouts" do + mock_app do + layout { %(h1\n | THIS. IS. \n == yield.upcase ) } + get('/') { slim 'em Sparta' } + end + get '/' + assert ok? + assert_equal "

THIS. IS. SPARTA

", body + end + + it "renders with file layouts" do + slim_app { slim('| Hello World', :layout => :layout2) } + assert ok? + assert_equal "

Slim Layout!

Hello World

", body + end + + it "raises error if template not found" do + mock_app { get('/') { slim(:no_such_template) } } + assert_raises(Errno::ENOENT) { get('/') } + end + + HTML4_DOCTYPE = "" + + it "passes slim options to the slim engine" do + mock_app { get('/') { slim("x foo='bar'", :attr_quote => "'") }} + get '/' + assert ok? + assert_body "" + end + + it "passes default slim options to the slim engine" do + mock_app do + set :slim, :attr_quote => "'" + get('/') { slim("x foo='bar'") } + end + get '/' + assert ok? + assert_body "" + end + + it "merges the default slim options with the overrides and passes them to the slim engine" do + mock_app do + set :slim, :attr_quote => "'" + get('/') { slim("x foo='bar'") } + get('/other') { slim("x foo='bar'", :attr_quote => '"') } + end + get '/' + assert ok? + assert_body "" + get '/other' + assert ok? + assert_body '' + end + + it "can render truly nested layouts by accepting a layout and a block with the contents" do + mock_app do + template(:main_outer_layout) { "h1 Title\n== yield" } + template(:an_inner_layout) { "h2 Subtitle\n== yield" } + template(:a_page) { "p Contents." } + get('/') do + slim :main_outer_layout, :layout => false do + slim :an_inner_layout do + slim :a_page + end + end + end + end + get '/' + assert ok? + assert_body "

Title

\n

Subtitle

\n

Contents.

\n" + end +end + +rescue LoadError + warn "#{$!}: skipping slim tests" +end diff --git a/test/static_test.rb b/test/static_test.rb index 7df3c63bec..4d8cffb1fb 100644 --- a/test/static_test.rb +++ b/test/static_test.rb @@ -1,11 +1,11 @@ -require File.dirname(__FILE__) + '/helper' +require_relative 'test_helper' -class StaticTest < Test::Unit::TestCase +class StaticTest < Minitest::Test setup do - mock_app { + mock_app do set :static, true - set :public, File.dirname(__FILE__) - } + set :public_folder, __dir__ + end end it 'serves GET requests for files in the public directory' do @@ -18,7 +18,7 @@ class StaticTest < Test::Unit::TestCase it 'produces a body that can be iterated over multiple times' do env = Rack::MockRequest.env_for("/#{File.basename(__FILE__)}") - status, headers, body = @app.call(env) + _, _, body = @app.call(env) buf1, buf2 = [], [] body.each { |part| buf1 << part } body.each { |part| buf2 << part } @@ -26,12 +26,18 @@ class StaticTest < Test::Unit::TestCase assert_equal File.read(__FILE__), buf1.join end + it 'sets the sinatra.static_file env variable if served' do + env = Rack::MockRequest.env_for("/#{File.basename(__FILE__)}") + @app.call(env) + assert_equal File.expand_path(__FILE__), env['sinatra.static_file'] + end + it 'serves HEAD requests for files in the public directory' do head "/#{File.basename(__FILE__)}" assert ok? assert_equal '', body - assert_equal File.size(__FILE__).to_s, response['Content-Length'] assert response.headers.include?('Last-Modified') + assert_equal File.size(__FILE__).to_s, response['Content-Length'] end %w[POST PUT DELETE].each do |verb| @@ -53,6 +59,11 @@ class StaticTest < Test::Unit::TestCase assert not_found? end + it 'passes to the next handler when the path contains null bytes' do + get "/foo%00" + assert not_found? + end + it 'passes to the next handler when the static option is disabled' do @app.set :static, false get "/#{File.basename(__FILE__)}" @@ -60,7 +71,7 @@ class StaticTest < Test::Unit::TestCase end it 'passes to the next handler when the public option is nil' do - @app.set :public, nil + @app.set :public_folder, nil get "/#{File.basename(__FILE__)}" assert not_found? end @@ -70,6 +81,12 @@ class StaticTest < Test::Unit::TestCase assert not_found? end + it 'there is no path is 404 error pages' do + env = Rack::MockRequest.env_for("/dummy").tap { |env| env["PATH_INFO"] = "/