├── .git-blame-ignore-revs ├── .github ├── FUNDING.yml └── workflows │ └── ci.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── .yardopts ├── CHANGELOG.md ├── Gemfile ├── README.md ├── Rakefile ├── _config.yml ├── bin ├── console └── setup ├── docs ├── building_a_rack_application.md ├── composing_applications.md ├── connection_struct.md ├── connection_struct │ ├── configuring_the_connection_struct.md │ ├── halting_the_pipe.md │ └── sharing_data_downstream.md ├── design_model.md ├── dsl_free_usage.md ├── extensions.md ├── extensions │ ├── container.md │ ├── cookies.md │ ├── dry_schema.md │ ├── flash.md │ ├── hanami_view.md │ ├── not_found.md │ ├── params.md │ ├── rails.md │ ├── redirect.md │ ├── router_params.md │ ├── session.md │ └── url.md ├── introduction.md ├── overriding_instance_methods.md ├── plugging_operations.md ├── plugging_operations │ ├── composing_operations.md │ ├── injecting_operations.md │ ├── inspecting_operations.md │ └── resolving_operations.md ├── plugs.md ├── plugs │ ├── config.md │ └── content_type.md ├── recipes │ ├── hanami_2_and_dry_rb_integration.md │ ├── hanami_router_integration.md │ ├── injecting_dependencies_through_dry_auto_inject.md │ └── using_all_restful_methods.md ├── testing.md ├── using_rack_middlewares.md └── using_rack_middlewares │ ├── composing_middlewares.md │ ├── injecting_middlewares.md │ └── inspecting_middlewares.md ├── lib ├── web_pipe.rb └── web_pipe │ ├── app.rb │ ├── conn.rb │ ├── conn_support │ ├── builder.rb │ ├── composition.rb │ ├── errors.rb │ ├── headers.rb │ └── types.rb │ ├── dsl │ ├── builder.rb │ ├── class_context.rb │ └── instance_context.rb │ ├── extensions │ ├── container │ │ └── container.rb │ ├── cookies │ │ └── cookies.rb │ ├── dry_schema │ │ ├── dry_schema.rb │ │ └── plugs │ │ │ └── sanitize_params.rb │ ├── flash │ │ └── flash.rb │ ├── hanami_view │ │ ├── hanami_view.rb │ │ └── hanami_view │ │ │ └── context.rb │ ├── not_found │ │ └── not_found.rb │ ├── params │ │ ├── params.rb │ │ └── params │ │ │ └── transf.rb │ ├── rails │ │ └── rails.rb │ ├── redirect │ │ └── redirect.rb │ ├── router_params │ │ └── router_params.rb │ ├── session │ │ └── session.rb │ └── url │ │ └── url.rb │ ├── pipe.rb │ ├── plug.rb │ ├── plugs.rb │ ├── plugs │ ├── config.rb │ └── content_type.rb │ ├── rack_support │ ├── app_with_middlewares.rb │ ├── middleware.rb │ └── middleware_specification.rb │ ├── test_support.rb │ ├── types.rb │ └── version.rb ├── spec ├── dsl │ ├── composition_spec.rb │ ├── dry_auto_inject_spec.rb │ ├── inspecting_middlewares_spec.rb │ ├── inspecting_operations_spec.rb │ ├── middleware_composition_spec.rb │ ├── middleware_injection_spec.rb │ ├── middleware_spec.rb │ ├── overriding_instance_methods_spec.rb │ ├── plug_chaining_spec.rb │ ├── plug_composition_spec.rb │ ├── plug_from_block_spec.rb │ ├── plug_from_method_spec.rb │ ├── plug_halting_spec.rb │ ├── plug_injection_spec.rb │ └── rack_spec.rb ├── extensions │ ├── container │ │ └── container_spec.rb │ ├── cookies │ │ └── cookies_spec.rb │ ├── dry_schema │ │ ├── dry_schema_spec.rb │ │ └── plugs │ │ │ └── sanitize_params_spec.rb │ ├── flash │ │ ├── flash_spec.rb │ │ └── integration │ │ │ └── flash_spec.rb │ ├── hanami_view │ │ ├── fixtures │ │ │ ├── template_with_context.html.str │ │ │ ├── template_with_input.html.str │ │ │ └── template_without_input.html.str │ │ └── hanami_view_spec.rb │ ├── not_found │ │ └── not_found_spec.rb │ ├── params │ │ ├── params │ │ │ └── transf_spec.rb │ │ └── params_spec.rb │ ├── rails │ │ └── rails_spec.rb │ ├── redirect │ │ └── redirect_spec.rb │ ├── router_params │ │ └── router_params_spec.rb │ ├── session │ │ └── session_spec.rb │ └── url │ │ └── url_spec.rb ├── spec_helper.rb ├── support │ ├── conn.rb │ └── middlewares.rb ├── unit │ └── web_pipe │ │ ├── app_spec.rb │ │ ├── conn_spec.rb │ │ ├── conn_support │ │ ├── builder_spec.rb │ │ ├── composition_spec.rb │ │ └── headers_spec.rb │ │ ├── pipe_spec.rb │ │ ├── plug_spec.rb │ │ ├── plugs │ │ ├── config_spec.rb │ │ └── content_type_spec.rb │ │ ├── rack │ │ └── middleware_specification_spec.rb │ │ └── test_support_spec.rb └── web_pipe_spec.rb └── web_pipe.gemspec /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # Use " for Strings and prefer .() over .call 2 | 4a6ebfdcefe5ad0ff95bfb0c4926cf476037c2d8 3 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: waiting-for-dev 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | name: Build 8 | runs-on: ubuntu-latest 9 | 10 | strategy: 11 | fail-fast: false 12 | matrix: 13 | ruby: ["3.0", "3.1", "3.2"] 14 | 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v3 18 | 19 | - name: Setup Ruby 20 | uses: ruby/setup-ruby@v1 21 | with: 22 | ruby-version: ${{ matrix.ruby }} 23 | bundler-cache: true 24 | 25 | - name: Rake 26 | run: bundle exec rake 27 | 28 | - name: Archive SimpleCov Report 29 | uses: actions/upload-artifact@v3 30 | with: 31 | name: coverage 32 | path: coverage 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | 10 | # rspec failure tracking 11 | .rspec_status 12 | 13 | Gemfile.lock 14 | 15 | Dockerfile 16 | docker-compose.yml 17 | .dockerignore 18 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | TargetRubyVersion: 3.0 3 | NewCops: enable 4 | SuggestExtensions: false 5 | Exclude: 6 | - vendor/**/* 7 | 8 | Metrics/BlockLength: 9 | Exclude: 10 | - spec/**/* 11 | - web_pipe.gemspec 12 | 13 | Naming/AccessorMethodName: 14 | Enabled: false 15 | 16 | Style/HashConversion: 17 | Enabled: false 18 | 19 | Style/StringLiterals: 20 | EnforcedStyle: double_quotes 21 | 22 | Style/LambdaCall: 23 | EnforcedStyle: braces 24 | -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --title 'web_pipe' 2 | --embed-mixins 3 | --output doc 4 | --readme README.md 5 | --files CHANGELOG.md 6 | --markup markdown 7 | --markup-provider=redcarpet 8 | lib/**/*.rb -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](http://keepachangelog.com/) 5 | and this project adheres to [Semantic Versioning](http://semver.org/). 6 | 7 | ## [0.16.0] - 2021-11-07 8 | ### Added 9 | - Extract the DSL as an optional convenience layer and introduce 10 | `WebPipe::Pipe` as top abstraction. 11 | [#47](https://github.com/waiting-for-dev/web_pipe/pull/47) 12 | - Be able to plug anything responding to `#to_proc`. 13 | [#47](https://github.com/waiting-for-dev/web_pipe/pull/47) 14 | - Be able to use anything responding to `#to_middlewares`. 15 | [#47](https://github.com/waiting-for-dev/web_pipe/pull/47) 16 | 17 | ## [0.15.1] - 2021-09-19 18 | ### Added 19 | - `:not_found` extension 20 | [#46](https://github.com/waiting-for-dev/web_pipe/pull/46) 21 | 22 | ## [0.15.0] - 2021-09-12 23 | ### Added 24 | - **BREAKING**. Switch `dry_view` extension with `hanami_view`. 25 | [#45](https://github.com/waiting-for-dev/web_pipe/pull/45) 26 | 27 | ## [0.14.0] - 2021-04-14 28 | ### Added 29 | - Inspecting operations 30 | [#42](https://github.com/waiting-for-dev/web_pipe/pull/42) 31 | - Inspecting middlewares 32 | [#43](https://github.com/waiting-for-dev/web_pipe/pull/43) 33 | - Testing support 34 | [#44](https://github.com/waiting-for-dev/web_pipe/pull/44) 35 | 36 | ## [0.13.0] - 2021-01-15 37 | ### Added 38 | - **BREAKING**. Ruby 2.5 deprecated. 39 | [#40](https://github.com/waiting-for-dev/web_pipe/pull/40) 40 | - Ruby 3.0 supported. 41 | [#41](https://github.com/waiting-for-dev/web_pipe/pull/41) 42 | 43 | ## [0.12.1] - 2019-03-18 44 | ### Fixed 45 | - Update rake to fix security alert 46 | 47 | ## [0.12.0] - 2019-12-30 48 | ### Added 49 | - **BREAKING**. Ruby 2.4 deprecated. 50 | - Ruby 2.7 supported. 51 | 52 | ### Fixed 53 | - Ruby 2.7 argument warnings. 54 | [[#38]](https://github.com/waiting-for-dev/web_pipe/pull/38) 55 | 56 | ## [0.11.0] - 2019-12-28 57 | ### Added 58 | - **BREAKING**. `dry-transformer` (former `transproc`) dependency is now 59 | optional. 60 | [[#37]](https://github.com/waiting-for-dev/web_pipe/pull/37) 61 | - Switch `transproc` dependency to `dry-transformer`. 62 | [[#37]](https://github.com/waiting-for-dev/web_pipe/pull/37) 63 | 64 | ## [0.10.0] - 2019-11-15 65 | ### Added 66 | - `:rails` extension integrating with Ruby On Rails. 67 | [[#36]](https://github.com/waiting-for-dev/web_pipe/pull/36) 68 | 69 | ## [0.9.0] - 2019-08-31 70 | ### Added 71 | - Comprehensive documentation. 72 | [[#35]](https://github.com/waiting-for-dev/web_pipe/pull/35) 73 | 74 | ## [0.8.0] - 2019-08-30 75 | ### Added 76 | - **BREAKING**. Rename `Rack` module to `RackSupport`. 77 | [[#34]](https://github.com/waiting-for-dev/web_pipe/pull/34) 78 | 79 | ## [0.7.0] - 2019-08-27 80 | ### Added 81 | - **BREAKING**. `Conn#config` instead of `Conn#bag` for extension configuration. 82 | [[#29]](https://github.com/waiting-for-dev/web_pipe/pull/29) 83 | 84 | - **BREAKING**. `:params` extension extracted from `:url` extension. 85 | [[#30]](https://github.com/waiting-for-dev/web_pipe/pull/30) 86 | 87 | - **BREAKING**. Router params are extracted as a param transformation. 88 | [[#30]](https://github.com/waiting-for-dev/web_pipe/pull/30) 89 | 90 | - **BREAKING**. Plugs now respond to `.call` instead of `.[]`. 91 | [[#31]](https://github.com/waiting-for-dev/web_pipe/pull/31) 92 | 93 | - **BREAKING**. `:dry-schema` extension has not a default handler. 94 | [[#32]](https://github.com/waiting-for-dev/web_pipe/pull/32) 95 | 96 | - **BREAKING**. `:dry-schema` extension stores output in `#config`. 97 | [[#32]](https://github.com/waiting-for-dev/web_pipe/pull/32) 98 | 99 | - Integration with `transproc` gem to provide any number of params 100 | transformations. 101 | [[#30]](https://github.com/waiting-for-dev/web_pipe/pull/30) 102 | 103 | - `:dry-schema` extension automatically loads `:params` extension. 104 | [[#32]](https://github.com/waiting-for-dev/web_pipe/pull/32) 105 | 106 | ## [0.6.1] - 2019-08-02 107 | ### Fixed 108 | - Fixed support for ruby 2.4. 109 | [[#28]](https://github.com/waiting-for-dev/web_pipe/pull/28) 110 | 111 | ## [0.6.0] - 2019-08-02 112 | ### Added 113 | - **BREAKING**. Rename `put` methods as `add`. 114 | [[#26]](https://github.com/waiting-for-dev/web_pipe/pull/26) 115 | 116 | - **BREAKING**. Rename taint to halt, and clean/dirty to ongoing/halted. 117 | [[#25](https://github.com/waiting-for-dev/web_pipe/pull/25)] 118 | 119 | - **BREAKING**. URL redundant methods need to be loaded from `:url` extension. 120 | [[#24](https://github.com/waiting-for-dev/web_pipe/pull/24)] 121 | 122 | - Merge router params with GET and POST params. 123 | [[#23](https://github.com/waiting-for-dev/web_pipe/pull/23)] 124 | 125 | - Extension integrating rack session. 126 | [[#21](https://github.com/waiting-for-dev/web_pipe/pull/21)] 127 | 128 | - Extension to add/delete cookies. 129 | [[#20](https://github.com/waiting-for-dev/web_pipe/pull/20)] & 130 | [[#22](https://github.com/waiting-for-dev/web_pipe/pull/22)] 131 | 132 | - Extension to easily create HTTP redirects. 133 | [[#19](https://github.com/waiting-for-dev/web_pipe/pull/19)] 134 | 135 | - Added `Conn#set_response_headers` method. 136 | [[#27](https://github.com/waiting-for-dev/web_pipe/pull/27)] 137 | 138 | 139 | ## [0.5.0] - 2019-07-26 140 | ### Added 141 | - **BREAKING**. `container` is now an extension. 142 | [[#16](https://github.com/waiting-for-dev/web_pipe/pull/16)] 143 | 144 | - Extension providing Integration with `dry-schema`. 145 | [[#18](https://github.com/waiting-for-dev/web_pipe/pull/18)] 146 | 147 | - No need to manually call `#to_proc` when composing plugs. 148 | [[#13](https://github.com/waiting-for-dev/web_pipe/pull/13)] 149 | 150 | - Extension adding flash functionality to conn. 151 | [[#15](https://github.com/waiting-for-dev/web_pipe/pull/15)] 152 | 153 | - Extensions automatically require their associated plugs, so there is no need 154 | to require them manually anymore. 155 | [[#17](https://github.com/waiting-for-dev/web_pipe/pull/17)] 156 | 157 | ### Fixed 158 | - Fixed bug not allowing middlewares to modify responses initially set with 159 | default values. 160 | [[#14](https://github.com/waiting-for-dev/web_pipe/pull/14)] 161 | 162 | 163 | ## [0.4.0] - 2019-07-17 164 | ### Added 165 | - **BREAKING**. Middlewares have to be named when used. 166 | [[#11](https://github.com/waiting-for-dev/web_pipe/pull/11)] 167 | 168 | - **BREAKING**. Middlewares have to be initialized when composed. 169 | [[#11](https://github.com/waiting-for-dev/web_pipe/pull/11)] 170 | 171 | - **BREAKING**. The array of injected plugs is now scoped within a `plugs:` 172 | kwarg. 173 | [[#11](https://github.com/waiting-for-dev/web_pipe/pull/11)] 174 | 175 | - Middlewares can be injected. 176 | [[#11](https://github.com/waiting-for-dev/web_pipe/pull/11)] 177 | 178 | - DSL helper method `compose` to add middlewares and plugs in order and in a 179 | single shot- 180 | [[#12](https://github.com/waiting-for-dev/web_pipe/pull/11)] 181 | 182 | 183 | ## [0.3.0] - 2019-07-12 184 | ### Added 185 | - **BREAKING**. When plugging with `plug:`, the operation is no longer 186 | specified through `with:`. Now it is just the second positional argument- 187 | [[#9](https://github.com/waiting-for-dev/web_pipe/pull/9)] 188 | 189 | - It is possible to plug a block- 190 | [[#9](https://github.com/waiting-for-dev/web_pipe/pull/9)] 191 | 192 | - WebPipe plug's can be composed. A WebPipe proc representation is the 193 | composition of all its operations, which is an operation itself- 194 | [[#9](https://github.com/waiting-for-dev/web_pipe/pull/9)] 195 | 196 | - WebPipe's middlewares can be composed into another WebPipe class- 197 | [[#10](https://github.com/waiting-for-dev/web_pipe/pull/10)] 198 | 199 | 200 | ## [0.2.0] - 2019-07-05 201 | ### Added 202 | - dry-view integration- 203 | [[#1](https://github.com/waiting-for-dev/web_pipe/pull/1)], 204 | [[#3](https://github.com/waiting-for-dev/web_pipe/pull/3)], 205 | [[#4](https://github.com/waiting-for-dev/web_pipe/pull/4)], 206 | [[#5](https://github.com/waiting-for-dev/web_pipe/pull/5)] & 207 | [[#6](https://github.com/waiting-for-dev/web_pipe/pull/6)] 208 | 209 | - Configuring a container in `WebPipe::Conn`- 210 | [[#2](https://github.com/waiting-for-dev/web_pipe/pull/2)] & 211 | [[#5](https://github.com/waiting-for-dev/web_pipe/pull/5)] 212 | 213 | - Plug to set `Content-Type` response header- 214 | [[#7](https://github.com/waiting-for-dev/web_pipe/pull/7)] 215 | 216 | ### Fixed 217 | - Fix key interpolation in `KeyNotFoundInBagError`- 218 | [[#8](https://github.com/waiting-for-dev/web_pipe/pull/8)] 219 | 220 | ## [0.1.0] - 2019-05-07 221 | ### Added 222 | - Initial release. 223 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | git_source(:github) { |repo_name| "https://github.com/#{repo_name}" } 6 | 7 | # Specify your gem's dependencies in web_pipe.gemspec 8 | gemspec 9 | 10 | group :development do 11 | gem "dry-auto_inject", "~> 1.0" 12 | gem "dry-schema", "~> 1.0" 13 | gem "dry-transformer", "~> 0.1" 14 | gem "pry-byebug" 15 | gem "rack-flash3", "~> 1.0" 16 | gem "rack-test", "~> 1.1" 17 | gem "rake", "~> 12.3", ">= 12.3.3" 18 | gem "redcarpet", "~> 3.4" 19 | gem "rspec", "~> 3.0" 20 | gem "rubocop", "~> 1.8" 21 | gem "rubocop-rspec", "~> 2.1" 22 | gem "yard", "~> 0.9", ">= 0.9.20" 23 | # TODO: Move to gemspec when hanami-view 2.0 is available 24 | gem "hanami-view", github: "hanami/view", tag: "v2.1.0.beta2" 25 | end 26 | 27 | group :test do 28 | gem "simplecov", require: false 29 | end 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Gem Version](https://badge.fury.io/rb/web_pipe.svg)](https://badge.fury.io/rb/web_pipe) 2 | [![Build Status](https://travis-ci.com/waiting-for-dev/web_pipe.svg?branch=master)](https://travis-ci.com/waiting-for-dev/web_pipe) 3 | 4 | # WebPipe 5 | 6 | `web_pipe` is a builder of composable rack applications through a pipe of 7 | functions on an immutable struct. 8 | 9 | > `web_pipe` plays incredibly well with `hanami 2`. If you want to create a 10 | > `hanami 2` app with `web_pipe`, you can take inspiration from this sample todo 11 | > application: 12 | > 13 | > https://github.com/waiting-for-dev/hanami_2_web_pipe_todo_app 14 | 15 | 1. [Introduction](docs/introduction.md) 16 | 1. [Design model](docs/design_model.md) 17 | 1. [Building a rack application](docs/building_a_rack_application.md) 18 | 1. [Plugging operations](docs/plugging_operations.md) 19 | 1. [Resolving operations](docs/plugging_operations/resolving_operations.md) 20 | 1. [Injecting operations](docs/plugging_operations/injecting_operations.md) 21 | 1. [Composing operations](docs/plugging_operations/composing_operations.md) 22 | 1. [Inspecting operations](docs/plugging_operations/inspecting_operations.md) 23 | 1. [Using rack middlewares](docs/using_rack_middlewares.md) 24 | 1. [Injecting middlewares](docs/using_rack_middlewares/injecting_middlewares.md) 25 | 1. [Composing middlewares](docs/using_rack_middlewares/composing_middlewares.md) 26 | 1. [Inspecting middlewares](docs/using_rack_middlewares/inspecting_middlewares.md) 27 | 1. [Composing applications](docs/composing_applications.md) 28 | 1. [Connection struct](docs/connection_struct.md) 29 | 1. [Sharing data downstream](docs/connection_struct/sharing_data_downstream.md) 30 | 1. [Halting the pipe](docs/connection_struct/halting_the_pipe.md) 31 | 1. [Configuring the connection struct](docs/connection_struct/configuring_the_connection_struct.md) 32 | 1. [Overriding instance methods](docs/overriding_instance_methods.md) 33 | 1. [DSL free usage](docs/dsl_free_usage.md) 34 | 1. [Plugs](docs/plugs.md) 35 | 1. [Config](docs/plugs/config.md) 36 | 1. [ContentType](docs/plugs/content_type.md) 37 | 1. [Testing](docs/testing.md) 38 | 1. [Extensions](docs/extensions.md) 39 | 1. [Container](docs/extensions/container.md) 40 | 1. [Cookies](docs/extensions/cookies.md) 41 | 1. [Flash](docs/extensions/flash.md) 42 | 1. [Dry Schema](docs/extensions/dry_schema.md) 43 | 1. [Hanami View](docs/extensions/hanami_view.md) 44 | 1. [Not found](docs/extensions/not_found.md) 45 | 1. [Params](docs/extensions/params.md) 46 | 1. [Rails](docs/extensions/rails.md) 47 | 1. [Redirect](docs/extensions/redirect.md) 48 | 1. [Router params](docs/extensions/router_params.md) 49 | 1. [Session](docs/extensions/session.md) 50 | 1. [URL](docs/extensions/url.md) 51 | 1. Recipes 52 | 1. [hanami 2 & dry-rb integration](docs/recipes/hanami_2_and_dry_rb_integration.md) 53 | 1. [Injecting dependencies through dry-auto_inject](docs/recipes/injecting_dependencies_through_dry_auto_inject.md) 54 | 1. [hanami-router integration](docs/recipes/hanami_router_integration.md) 55 | 1. [Using all RESTful methods](docs/recipes/using_all_restful_methods.md) 56 | 57 | ```ruby 58 | # config.ru 59 | require 'web_pipe' 60 | 61 | WebPipe.load_extensions(:params) 62 | 63 | class HelloApp 64 | include WebPipe 65 | 66 | AUTHORIZED_USERS = %w[Alice Joe] 67 | 68 | plug :html 69 | plug :authorize 70 | plug :greet 71 | 72 | private 73 | 74 | def html(conn) 75 | conn.add_response_header('Content-Type', 'text/html') 76 | end 77 | 78 | def authorize(conn) 79 | user = conn.params['user'] 80 | if AUTHORIZED_USERS.include?(user) 81 | conn.add(:user, user) 82 | else 83 | conn. 84 | set_status(401). 85 | set_response_body('

Not authorized

'). 86 | halt 87 | end 88 | end 89 | 90 | def greet(conn) 91 | conn.set_response_body("

Hello #{conn.fetch(:user)}

") 92 | end 93 | end 94 | 95 | run HelloApp.new 96 | ``` 97 | 98 | ## Current status 99 | 100 | `web_pipe` is in active development but ready to be used in any environment. 101 | Everyday needs are covered, and while you can expect some API changes, 102 | they won't be essential, and we'll document everything appropriately. 103 | 104 | ## Contributing 105 | 106 | Bug reports and pull requests are welcome on GitHub at 107 | https://github.com/waiting-for-dev/web_pipe. 108 | 109 | ## Release Policy 110 | 111 | `web_pipe` follows the principles of [semantic versioning](http://semver.org/). 112 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "rspec/core/rake_task" 5 | require "rubocop/rake_task" 6 | 7 | RSpec::Core::RakeTask.new(:spec) 8 | RuboCop::RakeTask.new 9 | 10 | desc "Run code quality checks" 11 | task lint: %i[rubocop] 12 | 13 | task default: %i[lint spec] 14 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-slate -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | require "web_pipe" 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | # (If you use this, don't forget to add pry to your Gemfile!) 11 | # require "pry" 12 | # Pry.start 13 | 14 | require "pry" 15 | Pry.start 16 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /docs/building_a_rack_application.md: -------------------------------------------------------------------------------- 1 | # Building a rack application 2 | 3 | To build a rack application with `web_pipe`, you have to include 4 | `WebPipe` module in a class: 5 | 6 | ```ruby 7 | require 'web_pipe' 8 | 9 | class MyApp 10 | include WebPipe 11 | 12 | # ... 13 | end 14 | ``` 15 | 16 | Then, you can plug the operations and add the rack middlewares you need. 17 | 18 | The instance of that class will be the rack application: 19 | 20 | ```ruby 21 | # config.ru 22 | require 'my_app' 23 | 24 | run MyApp.new 25 | ``` 26 | -------------------------------------------------------------------------------- /docs/composing_applications.md: -------------------------------------------------------------------------------- 1 | # Composing applications 2 | 3 | Previously, we have seen how to [compose plugged 4 | operations](plugging_operations/composing_operations.md) and how to [compose 5 | rack middlewares](using_rack_middlewares/composing_middlewares.md). The logical 6 | next step is composing `web_pipe` applications, which is the same as composing 7 | operations and middlewares simultaneously. 8 | 9 | The DSL method `compose` does precisely that: 10 | 11 | ```ruby 12 | class HtmlApp 13 | include WebPipe 14 | 15 | use :session, Rack::Session::Cookie, key: 'my_app.session', secret: 'long' 16 | use :csrf, Rack::Csrf, raise: true 17 | 18 | plug :content_type 19 | plug :default_status 20 | 21 | private 22 | 23 | def content_type(conn) 24 | conn.add_response_header('Content-Type' => 'text/html') 25 | end 26 | 27 | def default_status(conn) 28 | conn.set_status(404) 29 | end 30 | end 31 | 32 | class MyApp 33 | include WebPipe 34 | 35 | compose :web, HtmlApp.new 36 | # It does exactly the same as: 37 | # use :web, HtmlApp.new 38 | # plug :web, HtmlApp.new 39 | 40 | # use ... 41 | # plug ... 42 | end 43 | ``` 44 | -------------------------------------------------------------------------------- /docs/connection_struct.md: -------------------------------------------------------------------------------- 1 | # Connection struct 2 | 3 | The first operation you plug in a `web_pipe` application receives an instance of 4 | `WebPipe::Conn` automatically created. 5 | 6 | `WebPipe::Conn` is just a struct data type that contains all the information 7 | from the current web request. In this regard, you can think of it as a 8 | structured rack's env hash. 9 | 10 | Request related attributes of this struct are: 11 | 12 | - `#scheme`: `:http` or `:https`. 13 | - `#request_method`: `:get`, `:post`... 14 | - `#host`: e.g. `'www.example.org'`. 15 | - `#ip`: e.g. `'192.168.1.1'`. 16 | - `#port`: e.g. `80` or `443`. 17 | - `#script_name`: e.g. `'index.rb'`. 18 | - `#path_info`: e.g. `'/foor/bar'`. 19 | - `#query_string`: e.g. `'foo=bar&bar=foo'` 20 | - `#request_body`: e.g. `'{ id: 1 }'` 21 | - `#request_headers`: e.g. `{ 'Accept-Charset' => 'utf8' }` 22 | - `#env`: Rack's env hash. 23 | - `#request`: Rack::Request instance. 24 | 25 | Your operations must return another (or the same) instance of the struct, which 26 | will be consumed by the next operation downstream. 27 | 28 | The struct contains methods to add the response data to it: 29 | 30 | - `#set_status(code)`: makes it accessible in the `#status` attribute. 31 | - `#set_response_body(body)`: makes it accessible in the `#response_body` 32 | attribute. 33 | - `#set_response_headers(headers)`: makes them accessible in 34 | the `#response_headers` attribute. Besides, there are also 35 | `#add_response_header(key, value)` and `#delete_response_header(key)` 36 | methods. 37 | 38 | The response in the last struct returned in the pipe will be what is sent to 39 | client. 40 | 41 | Every attribute and method is [fully 42 | documented](https://www.rubydoc.info/github/waiting-for-dev/web_pipe/master/WebPipe/Conn) 43 | in the code documentation. 44 | 45 | Here we have a contrived web application which returns as response body 46 | the request body it has received: 47 | 48 | ```ruby 49 | # config.ru 50 | require 'web_pipe' 51 | 52 | class DummyApp 53 | include WebPipe 54 | 55 | plug :build_response 56 | 57 | private 58 | 59 | def build_response(conn) 60 | conn. 61 | set_status(200). 62 | add_response_header('Content-Type', 'text/html'). 63 | set_response_body( 64 | "

#{conn.request_body}

" 65 | ) 66 | end 67 | end 68 | 69 | run DummyApp.new 70 | ``` 71 | 72 | As you can see, by default, the available features are very minimal to read 73 | from a request and to write a response. However, you can pick from several 74 | (extensions)[extensions.md] which will make your life much easier. 75 | 76 | Immutability is a core design principle in `web_pipe`. All methods in 77 | `WebPipe::Conn`, which are used to add data to it (both in core behavior and 78 | extensions), return a fresh new instance. It also makes possible chaining 79 | methods in a very readable way. 80 | 81 | If you're using ruby 2.7 or greater, you can pattern match on a `WebPipe::Conn` 82 | struct, as in: 83 | 84 | ```ruby 85 | # GET http://example.org 86 | conn in { request_method:, host: } 87 | request_method 88 | # :get 89 | host 90 | # 'example.org' 91 | ``` 92 | -------------------------------------------------------------------------------- /docs/connection_struct/configuring_the_connection_struct.md: -------------------------------------------------------------------------------- 1 | # Configuring the connection struct 2 | 3 | [Extensions](../extensions.md) add extra behaviour to the connection struct. 4 | Sometimes they need some user-provided value to work properly or allow some 5 | tweak depending on user needs. 6 | 7 | For this reason, you can add configuration data to a `WebPipe::Conn` instance 8 | so that extensions can fetch it. This shared place where extensions look for 9 | what they need is the `#config` attribute, which is very similar to `#bag` 10 | except for its more private intention. 11 | 12 | In order to interact with `#config`, you can use the method `#add_config(key, 13 | value)` or [`Config` plug](../plugs/config.md). 14 | 15 | ```ruby 16 | class MyApp 17 | include WebPipe 18 | 19 | plug(:config) do |conn| 20 | conn.add_config( 21 | foo: :bar 22 | ) 23 | end 24 | end 25 | ``` 26 | -------------------------------------------------------------------------------- /docs/connection_struct/halting_the_pipe.md: -------------------------------------------------------------------------------- 1 | # Halting the pipe 2 | 3 | Each operation in a pipe takes a single `WebPipe::Conn` instance as an argument 4 | and returns another (or the same) instance. A series of operations on the 5 | connection struct is propagated until a final response is sent to the client. 6 | 7 | More often than not, you may need to conditionally stop the propagation of a 8 | pipe at a given operation. For example, you could fetch the user requesting a 9 | resource. In the case they were granted to perform the required action, you 10 | would go on. However, if they weren't, you would like to halt the connection 11 | and respond with a 4xx HTTP status code. 12 | 13 | To stop the pipe, you have to call `#halt` on the connection struct. 14 | 15 | At the implementation level, we must admit that we've not been 100% accurate 16 | until now. We said that the first operation in the pipe received a 17 | `WebPipe::Conn` instance. That's true. However, it is more precise to say that 18 | it gets a `WebPipe::Conn::Ongoing` instance `WebPipe::Conn::Ongoing` being a 19 | subclass of 20 | `WebPipe::Conn`). 21 | 22 | As long as an operation responds with a `WebPipe::Conn::Ongoing` 23 | instance, the propagation will go on. However, when an operation 24 | returns a `WebPipe::Conn::Halted` instance (another subclass of 25 | `WebPipe::Conn`) then any operation downstream will be ignored. 26 | Calling `#halt` copies all attributes to a `WebPipe::Conn::Halted` 27 | instance and returns it. 28 | 29 | This made-up example checks if the user in the request has an admin role. In 30 | that case, it returns the solicited resource. Otherwise, they're unauthorized, 31 | and they never get it. 32 | 33 | ```ruby 34 | WebPipe.load_extensions(:params) 35 | 36 | class ShowTaskApp 37 | include WebPipe 38 | 39 | plug :fetch_user 40 | plug :authorize 41 | plug :render_task 42 | 43 | private 44 | 45 | def fetch_user(conn) 46 | conn.add( 47 | :user, UserRepo.find(conn.params[:user_id]) 48 | ) 49 | end 50 | 51 | def authorize(conn) 52 | if conn.fetch(:user).admin? 53 | conn 54 | else 55 | conn. 56 | set_status(401). 57 | halt 58 | end 59 | end 60 | 61 | def render_task(conn) 62 | conn.set_response_body( 63 | TaskRepo.find(conn.params[:id]).to_json 64 | ) 65 | end 66 | end 67 | 68 | run ShowTaskApp.new 69 | ``` 70 | -------------------------------------------------------------------------------- /docs/connection_struct/sharing_data_downstream.md: -------------------------------------------------------------------------------- 1 | # Sharing data downstream 2 | 3 | Usually, you'll find the need to prepare some data in one operation with the 4 | intention for it to be consumed by another downstream operation. The connection 5 | struct has a `#bag` attribute which is helpful for this purpose. 6 | 7 | `WebPipe::Conn#bag` is a `Hash` with `Symbol` keys where the values can be 8 | anything you need to share. To help with the process, we have the following 9 | methods: 10 | 11 | - `#add(key, value)`: Assigns a value to a key. 12 | - `#fetch(key)`, `#fetch(key, default)`: Retrieves the value associated 13 | to a given key. If it is not found, `default` is returned when 14 | provided. 15 | 16 | This is a simple example of a web application that reads a `name` 17 | parameter and normalizes it before using it in the response body. 18 | 19 | ```ruby 20 | # config.ru 21 | require 'web_pipe' 22 | 23 | WebPipe.load_extensions(:params) 24 | 25 | class NormalizeNameApp 26 | include WebPipe 27 | 28 | plug :normalize_name 29 | plug :respond 30 | 31 | private 32 | 33 | def normalize_name(conn) 34 | conn.add( 35 | :name, conn.params[:name].downcase.capitalize 36 | ) 37 | end 38 | 39 | def respond(conn) 40 | conn.set_response_body( 41 | conn.fetch(:name) 42 | ) 43 | end 44 | end 45 | 46 | run NormalizeNameApp.new 47 | ``` 48 | -------------------------------------------------------------------------------- /docs/design_model.md: -------------------------------------------------------------------------------- 1 | # Design model 2 | 3 | If you are familiar with rack, you know that it models a two-way pipe. In it, 4 | each middleware has the ability to: 5 | 6 | - During the outbound trip modifying the request as it heads to the actual 7 | application. 8 | 9 | - During the return trip modifying the response as it gets back from the 10 | application. 11 | 12 | ``` 13 | 14 | ---------------------> request -----------------------> 15 | 16 | Middleware 1 Middleware 2 Application 17 | 18 | <--------------------- response <----------------------- 19 | 20 | 21 | ``` 22 | 23 | `web_pipe` follows a simpler but equally powerful model: a one-way 24 | pipe abstracted on top of rack. A struct that contains data from a 25 | web request is piped through a stack of operations (functions). Each 26 | operation takes as argument an instance of the struct and also 27 | returns an instance of it. You can add response data to the struct at 28 | any moment in the pipe. 29 | 30 | ``` 31 | 32 | Operation 1 Operation 2 Operation 3 33 | 34 | --------------------- request/response ----------------> 35 | 36 | ``` 37 | 38 | Additionally, any operation in the stack can halt the propagation of the pipe, 39 | leaving downstream operations unexecuted. In this way, the final 40 | response is the one contained in the struct at the moment the pipe was 41 | halted, or the last one if the pipe wasn't halted. 42 | 43 | As you may know, this is the same model used by Elixir's 44 | [`plug`](https://hexdocs.pm/plug/readme.html), from which `web_pipe` takes 45 | inspiration. 46 | -------------------------------------------------------------------------------- /docs/dsl_free_usage.md: -------------------------------------------------------------------------------- 1 | # DSL free usage 2 | 3 | DSL's (like the one in `web_pipe` with class methods like `plug` or 4 | `use`) provide developers with a user-friendly way to 5 | use a library. However, they usually come at the expense of increasing 6 | complexity in internal code (which sooner than later translates into some 7 | issue). 8 | 9 | `web_pipe` has tried to make an extra effort to minimize these problems. For 10 | this reason, the DSL in this library is just a layer providing convenience on 11 | top of the independent core functionality. 12 | 13 | The DSL methods delegate transparently to instances of `WebPipe::Pipe`, so you 14 | can also work directly with them and forget about magic. 15 | 16 | For instance, the following rack application written through the DSL: 17 | 18 | ```ruby 19 | # config.ru 20 | require 'web_pipe' 21 | 22 | WebPipe.load_extensions(:params) 23 | 24 | class HelloApp 25 | include WebPipe 26 | 27 | plug :fetch_name 28 | plug :render 29 | 30 | private 31 | 32 | def fetch_name(conn) 33 | conn.add(:name, conn.params['name']) 34 | end 35 | 36 | def render(conn) 37 | conn.set_response_body("Hello, #{conn.fetch(:name)}!") 38 | end 39 | end 40 | 41 | run HelloApp.new 42 | ``` 43 | 44 | is exactly equivalent to: 45 | 46 | ```ruby 47 | # config.ru 48 | require 'web_pipe' 49 | 50 | WebPipe.load_extensions(:params) 51 | 52 | app = WebPipe::Pipe.new 53 | .plug(:fetch_name, ->(conn) { conn.add(:name, conn.params['name']) }) 54 | .plug(:render, ->(conn) { conn.set_response_body("Hello, #{conn.fetch(:name)}") }) 55 | 56 | run app 57 | ``` 58 | 59 | As you see, the instance of `WebPipe::Pipe` is itself the rack application. 60 | 61 | You can provide a context object to resolve methods when only a name is given 62 | on `#plug`: 63 | 64 | ```ruby 65 | class Context 66 | def fetch_name(conn) 67 | conn.add(:name, conn.params['name']) 68 | end 69 | 70 | def render(conn) 71 | conn.set_response_body("Hello, #{conn.fetch(:name)}") 72 | end 73 | end 74 | 75 | app = WebPipe::Pipe.new(context: Context.new) 76 | .plug(:fetch_name) 77 | .plug(:render) 78 | 79 | run app 80 | ``` 81 | -------------------------------------------------------------------------------- /docs/extensions.md: -------------------------------------------------------------------------------- 1 | # Extensions 2 | 3 | `WebPipe::Conn` features are bare-bones by default: the very minimal you need 4 | to be able to build a web application. However, there are several extensions to 5 | add just the ingredients you want to use progressively. 6 | 7 | To load the extensions, you have to call `#load_extensions` method in 8 | `WebPipe`: 9 | 10 | ```ruby 11 | WebPipe.load_extensions(:params, :cookies) 12 | ``` 13 | -------------------------------------------------------------------------------- /docs/extensions/container.md: -------------------------------------------------------------------------------- 1 | # Container 2 | 3 | `:container` is a simple extension that allows you to configure a dependency 4 | injection container to be accessible from a `WebPipe::Conn` instance. 5 | 6 | The container to use must be configured under the `:container` config key. It 7 | will be accessible through the `#container` method. 8 | 9 | Although you'll usually want to configure the container in the application 10 | class (for instance, using 11 | [dry-system](https://dry-rb.org/gems/dry-system/main/)), having it at the 12 | connection struct level is useful for building other extensions that may need 13 | to access to it, like the [`hanami_view`](hanami_view.md) one. 14 | 15 | ```ruby 16 | require 'web_pipe' 17 | require 'my_container' 18 | 19 | WebPipe.load_extensions(:container) 20 | 21 | class MyApp 22 | plug :config, WebPipe::Plugs::Config.( 23 | container: MyContainer 24 | ) 25 | plug :this, :this # Resolved thanks to the container in `include` 26 | plug :that 27 | 28 | private 29 | 30 | def that(conn) 31 | conn.set_response_body( 32 | conn.container['do'].() # Resolved thanks to the container in `:config` 33 | ) 34 | end 35 | end 36 | ``` 37 | -------------------------------------------------------------------------------- /docs/extensions/cookies.md: -------------------------------------------------------------------------------- 1 | # Cookies 2 | 3 | Extension helping to deal with request and response cookies. 4 | 5 | Remember, cookies are just the value of `Set-Cookie` header. 6 | 7 | This extension adds following methods: 8 | 9 | - `#request_cookies`: Returns request cookies 10 | 11 | - `#set_cookie(key, value)` or `#set_cookie(key, value, options)`: Instructs 12 | browser to add a new cookie with given key and value. 13 | 14 | Some options can be given as keyword arguments (see [MDN reference on 15 | cookies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies) for an 16 | explanation): 17 | 18 | - `domain:` must be a string. 19 | - `path:` must be a string. 20 | - `max_age:` must be an integer with the number of seconds. 21 | - `expires:` must be a `Time`. 22 | - `secure:` must be `true` or `false`. 23 | - `http_only:` must be `true` or `false`. 24 | - `same_site:` must be one of the symbols `:none`, `:lax` or `:strict`. 25 | 26 | - `#delete_cookie(key)` or `#delete_cookie(key, options)`: Instructs browser to 27 | delete a previously sent cookie. 28 | 29 | Deleting a cookie just means setting again the same key with an expiration 30 | time in the past. 31 | 32 | It accepts `domain:` and `path:` options (see above for a description of 33 | them). 34 | 35 | Example: 36 | 37 | ```ruby 38 | require 'web_pipe' 39 | 40 | WebPipe.load_extensions(:cookies) 41 | 42 | class MyApp 43 | include WebPipe 44 | 45 | plug(:set_cookie) do |conn| 46 | conn.set_cookie('foo', 'bar', secure: true, http_only: true) 47 | end 48 | end 49 | ``` 50 | -------------------------------------------------------------------------------- /docs/extensions/dry_schema.md: -------------------------------------------------------------------------------- 1 | # Dry Schema 2 | 3 | Extension providing integration for every day 4 | [`dry-schema`](https://dry-rb.org/gems/dry-schema/) workflow to validate 5 | parameters. 6 | 7 | A plug `WebPipe::Plugs::SanitizeParams` is added so that you can use it in your 8 | pipe of operations. It takes as arguments a `dry-schema` schema and a handler. 9 | On success, it makes output available at `WebPipe::Conn#sanitized_params`. On 10 | error, it calls the given handler with the connection struct and validation 11 | result. 12 | 13 | This extension automatically loads [`:params` extension](params.md), 14 | as it takes `WebPipe::Conn#params` as input for the validation schema. 15 | 16 | Instead of providing an error handler as the second argument for the plug, you 17 | can configure it under the `:param_sanitization_handler` key. In this way, it 18 | can be reused through composition by other applications. 19 | 20 | ```ruby 21 | require 'db' 22 | require 'dry/schema' 23 | require 'web_pipe' 24 | 25 | WebPipe.load_extensions(:dry_schema) 26 | 27 | class MyApp 28 | include WebPipe 29 | 30 | Schema = Dry::Schema.Params do 31 | required(:name).filled(:string) 32 | end 33 | 34 | plug :config, WebPipe::Plugs::Config.( 35 | param_sanitization_handler: lambda do |conn, result| 36 | conn. 37 | set_status(500). 38 | set_response_body('Error with request parameters'). 39 | halt 40 | end 41 | ) 42 | 43 | plug :sanitize_params, WebPipe::Plugs::SanitizeParams.( 44 | Schema 45 | ) 46 | 47 | plug(:this) do |conn| 48 | DB.persist(:entity, conn.sanitized_params) 49 | end 50 | end 51 | ``` 52 | -------------------------------------------------------------------------------- /docs/extensions/flash.md: -------------------------------------------------------------------------------- 1 | # Flash 2 | 3 | This extension provides the typical flash messages functionality. Messages for 4 | users are stored in session to be consumed by another request after a redirect. 5 | 6 | This extension depends on 7 | [`Rack::Flash`](https://rubygems.org/gems/rack-flash3) (gem name is 8 | `rack-flash3`) and `Rack::Session` (shipped with rack) middlewares. 9 | 10 | `WebPipe::Conn#flash` contains the flash bag. You can use the `#add_flash(key, 11 | value)` method to add a message to it. 12 | 13 | There is also an `#add_flash_now(key, value)` method, which adds a message to 14 | the bag to consume it in the current request. Be aware that it is, in fact, 15 | coupling with the view layer. Something that has to be consumed in the current 16 | request should be just data given to the view layer, but it helps when it can 17 | treat both scenarios as flash messages. 18 | 19 | ```ruby 20 | require 'web_pipe' 21 | require 'rack/session/cookie' 22 | require 'rack-flash' 23 | 24 | WebPipe.load_extensions(:flash) 25 | 26 | class MyApp 27 | include WebPipe 28 | 29 | use :session, Rack::Session::Cookie, secret: 'secret' 30 | use :flash, Rack::Flash 31 | 32 | plug :add_to_flash, ->(conn) { conn.add_flash(:notice, 'Hello world') } 33 | 34 | # Usually you will end up making `conn.flash` available to your view 35 | # system: 36 | # 37 | #
<%= flash[:notice] %>
38 | end 39 | ``` 40 | -------------------------------------------------------------------------------- /docs/extensions/hanami_view.md: -------------------------------------------------------------------------------- 1 | # Hanami View 2 | 3 | This extension currently works with `hanami-view` v2.1.0.beta, which is not 4 | still released but available on the gem repository. 5 | 6 | This extension integrates with [hanami-view](https://github.com/hanami/view) 7 | rendering system to set a hanami-view output as the response body. 8 | 9 | `WebPipe::Conn#view` method is at the core of this extension. In its basic 10 | behavior, you provide to it a view instance you want to render and any 11 | exposures or options it may need: 12 | 13 | ```ruby 14 | require 'web_pipe' 15 | require 'hanami/view' 16 | require 'my_context' 17 | 18 | WebPipe.load_extensions(:hanami_view) 19 | 20 | class SayHelloView < Hanami::View 21 | config.paths = [File.join(__dir__, '..', 'templates')] 22 | config.template = 'say_hello' 23 | 24 | expose :name 25 | end 26 | 27 | class MyApp 28 | include WebPipe 29 | 30 | plug :render 31 | 32 | private 33 | 34 | def render(conn) 35 | conn.view(SayHelloView.new, name: 'Joe') 36 | end 37 | end 38 | ``` 39 | However, you can resolve a view from a container if you also use the 40 | (`:container` extension)[container.md]: 41 | 42 | ```ruby 43 | require 'hanami_view' 44 | require 'my_container' 45 | require 'web_pipe' 46 | 47 | WebPipe.load_extensions(:hanami_view, :container) 48 | 49 | class MyApp 50 | include WebPipe 51 | 52 | plug :config, WebPipe::Plugs::Config.( 53 | container: MyContainer 54 | ) 55 | plug :render 56 | 57 | def render(conn) 58 | conn.view('views.say_hello', name: 'Joe') 59 | end 60 | end 61 | ``` 62 | 63 | You can configure the view context class to use through the `:view_context_class` configuration option. The only requirement for it is to implement an initialize method accepting keyword arguments: 64 | 65 | ```ruby 66 | require 'hanami/view' 67 | require 'my_import' 68 | 69 | class MyContext < Hanami::View::Context 70 | def initialize(current_path:) 71 | @current_path = current_path 72 | end 73 | end 74 | ``` 75 | 76 | Then, you also need to configure a `:view_context_options` setting, which must be a lambda 77 | accepting a `WebPipe::Conn` instance and returning a hash matching required arguments for 78 | the view context class: 79 | 80 | ```ruby 81 | require 'web_pipe' 82 | 83 | WebPipe.load_extensions(:url) 84 | 85 | class MyApp 86 | include WebPipe 87 | 88 | plug :config, WebPipe::Plugs::Config.( 89 | view_context_class: MyContext, 90 | view_context: ->(conn) { { current_path: conn.full_path} } 91 | ) 92 | plug(:render) do |conn| 93 | conn.view(SayHelloView.new, name: 'Joe') 94 | end 95 | end 96 | ``` 97 | -------------------------------------------------------------------------------- /docs/extensions/not_found.md: -------------------------------------------------------------------------------- 1 | # Not found 2 | 3 | This extension helps to build a not-found response in a single method 4 | invocation. The `WebPipe::Conn#not_found` method will: 5 | 6 | - Set 404 as response status. 7 | - Set 'Not found' as the response body, or instead run a step configured in 8 | `:not_found_body_step` config key. 9 | - Halt the connection struct. 10 | 11 | ```ruby 12 | require 'web_pipe' 13 | 14 | WebPipe.load_extensions(:params, :not_found) 15 | 16 | class ShowItem 17 | include 'web_pipe' 18 | 19 | plug :config, WebPipe::Plugs::Config.( 20 | not_found_body_step: ->(conn) { conn.set_response_body('Nothing') } 21 | ) 22 | 23 | plug :fetch_item do |conn| 24 | conn.add(:item, Item[params['id']]) 25 | end 26 | 27 | plug :check_item do |conn| 28 | if conn.fetch(:item) 29 | conn 30 | else 31 | conn.not_found 32 | end 33 | end 34 | 35 | plug :render do |conn| 36 | conn.set_response_body(conn.fetch(:item).name) 37 | end 38 | end 39 | ``` 40 | -------------------------------------------------------------------------------- /docs/extensions/params.md: -------------------------------------------------------------------------------- 1 | # Params 2 | 3 | This extension adds a `WebPipe::Conn#params` method which returns 4 | request parameters as a Hash. Then, any number of transformations on it can be 5 | configured. 6 | 7 | When no transformations are configured, `#params` returns GET and POST 8 | parameters as a hash: 9 | 10 | ```ruby 11 | # http://www.example.com?foo=bar 12 | conn.params # => { 'foo' => 'bar' } 13 | ``` 14 | 15 | You can configure a stack of transformations to be applied to the 16 | parameter hash. For that, we lean on [`dry-transformer` 17 | gem](https://github.com/dry-rb/dry-transformer) (you have to add it yourself to 18 | your Gemfile). All hash transformations in `dry-transformer` are available by 19 | default. 20 | 21 | Transformations must be configured under `:param_transformations` 22 | key: 23 | 24 | ```ruby 25 | require 'web_pipe' 26 | 27 | WebPipe.load_extensions(:params) 28 | 29 | class MyApp 30 | incude WebPipe 31 | 32 | plug :config, WebPipe::Plugs::Config.( 33 | param_transformations: [:deep_symbolize_keys] 34 | ) 35 | 36 | plug(:this) do |conn| 37 | # http://www.example.com?foo=bar 38 | conn.params => # => { foo: 'bar' } 39 | # ... 40 | end 41 | end 42 | ``` 43 | 44 | Extra needed arguments can be provided as an array: 45 | 46 | ```ruby 47 | # ... 48 | plug :config, WebPipe::Plugs::Config.( 49 | param_transformations: [ 50 | :deep_symbolize_keys, [:reject_keys, [:zoo]] 51 | ] 52 | ) 53 | 54 | plug(:this) do |conn| 55 | # http://www.example.com?foo=bar&zoo=zoo 56 | conn.params => # => { foo: 'bar' } 57 | # ... 58 | end 59 | # ... 60 | ``` 61 | 62 | Custom transformations can be registered in `WebPipe::Params::Transf` `transproc` register: 63 | 64 | ```ruby 65 | fake = ->(_params) { { fake: :params } } 66 | WebPipe::Params::Transf.register(:fake, fake) 67 | 68 | # ... 69 | plug :config, WebPipe::Plugs::Config.( 70 | param_transformations: [:fake] 71 | ) 72 | 73 | plug(:this) do |conn| 74 | # http://www.example.com?foo=bar 75 | conn.params => # => { fake: :params } 76 | # ... 77 | end 78 | # ... 79 | ``` 80 | 81 | Your own transformation functions can depend on the `WebPipe::Conn` 82 | instance at the moment of calling `#params`. Those functions must accept 83 | the connection struct as its last argument: 84 | 85 | ```ruby 86 | add_name = ->(params, conn) { params.merge(name: conn.fetch(:name)) } 87 | WebPipe::Params::Transf.register(:add_name, add_name) 88 | 89 | # ... 90 | plug :config, WebPipe::Plugs::Config.( 91 | param_transformations: [:deep_symbolize_keys, :add_name] 92 | ) 93 | 94 | plug(:add_name) do |conn| 95 | conn.add(:name, 'Alice') 96 | end 97 | 98 | plug(:this) do |conn| 99 | # http://www.example.com?foo=bar 100 | conn.params => # => { foo: :bar, name: 'Alice' } 101 | # ... 102 | end 103 | # ... 104 | ``` 105 | Finally, you can override the configured transformations injecting another set 106 | at the moment of calling `#params`: 107 | 108 | ```ruby 109 | # ... 110 | plug :config, WebPipe::Plugs::Config.( 111 | param_transformations: [:deep_symbolize_keys] 112 | ) 113 | 114 | plug(:this) do |conn| 115 | # http://www.example.com?foo=bar&zoo=zoo 116 | conn.params([:reject_keys, ['zoo']]) => # => { 'foo' => 'zoo' } 117 | # ... 118 | end 119 | # ... 120 | -------------------------------------------------------------------------------- /docs/extensions/rails.md: -------------------------------------------------------------------------------- 1 | # Rails 2 | 3 | The first two things to keep in mind to integrate with Rails are 4 | that `WebPipe` instances are Rack applications and that rails router can 5 | perfectly [dispatch to a rack application](https://guides.rubyonrails.org/routing.html#routing-to-rack-applications). 6 | 7 | ```ruby 8 | # config/routes.rb 9 | get '/my_route', to: MyRoute.new 10 | 11 | # app/controllers/my_route.rb 12 | class MyRoute 13 | include WebPipe 14 | 15 | plug :set_response_body 16 | 17 | private 18 | 19 | def set_response_body(conn) 20 | conn.set_response_body('Hello, World!') 21 | end 22 | end 23 | ``` 24 | 25 | To do something like the previous example, you don't need to enable this 26 | extension. Notice that rails dispatched the request to our `WebPipe` rack 27 | application, which was then responsible for generating the response. In this 28 | case, it used a simple call to `#set_response_body`. 29 | 30 | It's quite possible that you don't need more than that in terms of rails 31 | integration. Of course, you want something more elaborate to generate 32 | responses. For that, you can use the view or template system you like. One 33 | option that will play especially well here is 34 | [`hanami-view`](https://github.com/hanami/view). Furthermore, we have a 35 | tailored `hanami_view` 36 | [extension](https://waiting-for-dev.github.io/web_pipe/docs/extensions/hanami_view.html). 37 | 38 | You need to use the `:rails` extension if: 39 | 40 | - You want to use `action_view` as a rendering system. 41 | - You want to use rails URL helpers from your `WebPipe` application. 42 | - You want to use controller helpers from your `WebPipe` application. 43 | 44 | Rails' responsibilities for controlling the request/response cycle are coupled 45 | to the rendering process. For this reason, even if you want to use `WebPipe` 46 | applications instead of Rails controller actions you still have to use the 47 | typical top `ApplicationController` to define some behavior for the view layer: 48 | 49 | - Which layout is applied to the template. 50 | - Which helpers will become available to the templates. 51 | 52 | By default, the controller in use is `ActionController::Base`, which means that 53 | no layout is applied and only built-in helpers (for example, 54 | `number_as_currency`) are available. You can change it via the 55 | `:rails_controller` configuration option. 56 | 57 | The main method that this extension adds to `WebPipe::Conn` is `#render`, which 58 | delegates to the [Rails implementation] 59 | (https://api.rubyonrails.org/v6.0.1/classes/ActionController/Renderer.html) as 60 | you'd do in a typical rails controller. Remember that you can provide template 61 | instance variables through the keyword `:assigns`. 62 | 63 | ```ruby 64 | # config/routes.rb 65 | get '/articles', to: ArticlesIndex.new 66 | 67 | # app/controllers/application_controller.rb 68 | class ApplicationController < ActionController::Base 69 | # By default uses the layout in `layouts/application` 70 | end 71 | 72 | # app/controllers/articles_index.rb 73 | WebPipe.load_extensions(:rails) # You can put it in an initializer 74 | 75 | class ArticlesIndex 76 | include WebPipe 77 | 78 | plug :config, WebPipe::Plugs::Config.( 79 | rails_controller: ApplicationController 80 | ) 81 | 82 | def render(conn) 83 | conn.render( 84 | template: 'articles/index', 85 | assigns: { articles: Article.all } 86 | ) 87 | end 88 | end 89 | ``` 90 | 91 | Notice that we used the keyword `template:` instead of taking advantage of 92 | automatic template lookup. We did that way so that we don't have to create an 93 | `ArticlesController`, but it's up to you. In the case of having an 94 | `ArticlesController`, we could do `conn.render(:index, assigns: { articles: 95 | Article.all })`. 96 | 97 | Besides, this extension provides with two other methods: 98 | 99 | - `url_helpers` returns Rails router [url 100 | helpers](https://api.rubyonrails.org/v6.0.1/classes/ActionView/Helpers/UrlHelper.html). 101 | - `helpers` returns the associated [controller 102 | helpers](https://api.rubyonrails.org/classes/ActionController/Helpers.html). 103 | 104 | We have placed `WebPipe` applications within `app/controllers/` directory in 105 | all the examples. However, remember you can put them wherever you like as long 106 | as you respect rails 107 | [`autoload_paths`](https://guides.rubyonrails.org/autoloading_and_reloading_constants.html#autoload-paths). 108 | -------------------------------------------------------------------------------- /docs/extensions/redirect.md: -------------------------------------------------------------------------------- 1 | # Redirect 2 | 3 | This extension helps create a redirect response. 4 | 5 | Redirect responses consist of two pieces: 6 | 7 | - The `Location` response header with the URL to which browsers should 8 | redirect. 9 | - A 3xx status code. 10 | 11 | A `#redirect(location, code)` method is added to `WebPipe::Conn`, which takes 12 | care of both steps. The `code` argument is optional, defaulting to `302`. 13 | 14 | ```ruby 15 | require 'web_pipe' 16 | 17 | WebPipe.load_extensions(:redirect) 18 | 19 | class MyApp 20 | include WebPipe 21 | 22 | plug(:redirect) do |conn| 23 | conn.redirect('/') 24 | end 25 | end 26 | ``` 27 | -------------------------------------------------------------------------------- /docs/extensions/router_params.md: -------------------------------------------------------------------------------- 1 | # Router params 2 | 3 | This extension can be used to merge placeholder parameters 4 | that usually routers support (like `get /users/:id`) to the parameters hash 5 | added through the [`:params` extension](params.md) (which is 6 | automatically loaded if using `:router_params`). 7 | 8 | This extension adds a transformation function named `:router_params`to the 9 | registry. Internally, it merges what is present in rack env's 10 | `router.params` key. 11 | 12 | It automatically integrates with 13 | [`hanami-router`](https://github.com/hanami/router). 14 | 15 | Don't forget that you have to add yourself the `:router_params` 16 | transformation to the stack. 17 | 18 | ```ruby 19 | require 'web_pipe' 20 | 21 | WebPipe.load_extensions(:router_params) 22 | 23 | class MyApp 24 | include WebPipe 25 | 26 | plug :config, WebPipe::Plugs::Config.( 27 | param_transformations: [:router_params, :deep_symbolize_keys] 28 | ) 29 | plug :this 30 | 31 | private 32 | 33 | def this(conn) 34 | # http://example.com/users/1/edit 35 | conn.params # => { id: 1 } 36 | # ... 37 | end 38 | end 39 | ``` 40 | -------------------------------------------------------------------------------- /docs/extensions/session.md: -------------------------------------------------------------------------------- 1 | # Session 2 | 3 | Wrapper around `Rack::Session` middleware to help work with sessions in your 4 | plugged operations. 5 | 6 | It depends on the `Rack::Session` middleware, which is shipped by rack. 7 | 8 | It adds the following methods to `WebPipe::Conn`: 9 | 10 | - `#fetch_session(key)`, `#fetch_session(key, default)` or 11 | `#fetch_session(key) { default }`. Returns what is stored under 12 | given session key. A default value can be given as a second 13 | argument or a block. 14 | - `#add_session(key, value)`. Adds given key/value pair to the 15 | session. 16 | - `#delete_session(key)`. Deletes given key from the session. 17 | - `#clear_session`. Deletes everything from the session. 18 | 19 | ```ruby 20 | require 'web_pipe' 21 | require 'rack/session' 22 | 23 | WebPipe.load_extensions(:session) 24 | 25 | class MyApp 26 | include WebPipe 27 | 28 | use Rack::Session::Cookie, secret: 'top_secret' 29 | 30 | plug(:add_to_session) do |conn| 31 | conn.add_session('foo', 'bar') 32 | end 33 | plug(:fetch_from_session) do |conn| 34 | conn.add( 35 | :foo, conn.fetch_session('foo') 36 | ) 37 | end 38 | end 39 | ``` 40 | -------------------------------------------------------------------------------- /docs/extensions/url.md: -------------------------------------------------------------------------------- 1 | # URL 2 | 3 | The `:url` extension adds a few methods that process the raw URL information 4 | into something more digestable. 5 | 6 | Specifically, it adds: 7 | 8 | - `#base_url`: That's schema + host + port (unless it is the default for the scheme). E.g. `'https://example.org'` or `'http://example.org:8000'`. 9 | - `#path`: That's script name (if any) + path information. E.g. `'index.rb/users/1'` or `'users/1'`. 10 | - `#full_path`: That's path + query string (if any). E.g. `'users/1?view=table'`. 11 | - `#url`: That's base url + full path. E.g. `'http://example.org:8000/users/1?view=table'`. 12 | -------------------------------------------------------------------------------- /docs/introduction.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | `web_pipe` is a rack application builder. 4 | 5 | It means that with it and a rack router (like 6 | [`hanami-router`](https://github.com/hanami/router), 7 | [`http_router`](https://github.com/joshbuddy/http_router) or plain 8 | [rack](https://github.com/rack/rack) routing methods) you can build a complete 9 | web application. However, the idea behind `web_pipe` is for it to be a 10 | decoupled component within a web framework. For this reason, it plays 11 | extremely well with the [hanami](https://hanamirb.org/) & 12 | [dry-rb](https://dry-rb.org/) ecosystems. If it helps, you can think of it as a 13 | decoupled web controller (as the C in MVC). 14 | 15 | `web_pipe` applications are built as a [pipe of 16 | operations](design_model.md) on an [immutable 17 | struct](connection_struct.md). The struct is automatically created 18 | with data from an HTTP request, and it contains methods to 19 | incrementally add data to generate an HTTP response. The pipe can 20 | be [halted](connection_struct/halting_the_pipe.md) at any moment, 21 | taking away from all operations downstream any chance to modify the 22 | response. 23 | 24 | `web_pipe` has a modular design, with only the minimal functionalities needed 25 | to build a web application enabled by default. However, it ships with several 26 | [extensions](extensions.md) to make your life easier. 27 | 28 | Following there is a simple example. It is a web application that will check 29 | the value of a `user` parameter. When it is `Alice` or `Joe`, it will kindly 30 | say hello. Otherwise, it will unauthorize: 31 | 32 | > To try this example, you can paste it to a file with the name `config.ru` and 33 | launch the rack command `rackup` within the same directory. The application 34 | will be available at `http://localhost:9292`. 35 | 36 | ```ruby 37 | require 'web_pipe' 38 | 39 | WebPipe.load_extensions(:params) 40 | 41 | class HelloApp 42 | include WebPipe 43 | 44 | AUTHORIZED_USERS = %w[Alice Joe] 45 | 46 | plug :html 47 | plug :authorize 48 | plug :greet 49 | 50 | private 51 | 52 | def html(conn) 53 | conn.add_response_header('Content-Type', 'text/html') 54 | end 55 | 56 | def authorize(conn) 57 | user = conn.params['user'] 58 | if AUTHORIZED_USERS.include?(user) 59 | conn.add(:user, user) 60 | else 61 | conn. 62 | set_status(401). 63 | set_response_body('

Not authorized

'). 64 | halt 65 | end 66 | end 67 | 68 | def greet(conn) 69 | conn.set_response_body("

Hello #{conn.fetch(:user)}

") 70 | end 71 | end 72 | 73 | run HelloApp.new 74 | ``` 75 | -------------------------------------------------------------------------------- /docs/overriding_instance_methods.md: -------------------------------------------------------------------------------- 1 | # Overriding instance methods 2 | 3 | You can override the included instance methods and use `super` to delegate to 4 | the `WebPipe`'s implementation. 5 | 6 | For instance, you might want to add some behavior to your initializer. However, 7 | consider that you need to dispatch the arguments that `WebPipe` needs. Example: 8 | 9 | ```ruby 10 | class MyApp 11 | include WebPipe 12 | 13 | attr_reader :body 14 | 15 | def initialize(body:, **kwargs) 16 | @body = body 17 | super(**kwargs) 18 | end 19 | 20 | plug :render 21 | 22 | private 23 | 24 | def render(conn) 25 | conn.set_response_body(body) 26 | end 27 | end 28 | ``` 29 | 30 | The same goes with any other instance method, like Rack's interface: 31 | 32 | ```ruby 33 | class My App 34 | include WebPipe 35 | 36 | plug :render 37 | 38 | def render(conn) 39 | conn.set_response_body(conn.env['body']) 40 | end 41 | 42 | def call(env) 43 | env['body'] = 'Hello, world!' 44 | super 45 | end 46 | end 47 | ``` 48 | -------------------------------------------------------------------------------- /docs/plugging_operations.md: -------------------------------------------------------------------------------- 1 | # Plugging operations 2 | 3 | You can plug operations into your application with the DSL method `plug`. The 4 | first argument it always takes is a symbol with the name you want 5 | to give to the operation (which is needed to allow 6 | [injection](plugging_operations/injecting_operations.md) at 7 | initialization time). 8 | 9 | ```ruby 10 | class MyApp 11 | include WebPipe 12 | 13 | plug :dummy_operation, ->(conn) { conn } 14 | end 15 | ``` 16 | 17 | Remember, an operation is just a function (in ruby, anything responding to 18 | `#call`) that takes a struct with connection information and returns another 19 | instance of it. First operation in the stack receives a struct which has been 20 | automatically created with the request data. From then on, any operation can 21 | add to it response data. 22 | -------------------------------------------------------------------------------- /docs/plugging_operations/composing_operations.md: -------------------------------------------------------------------------------- 1 | # Composing operations 2 | 3 | As we already have said, operations are functions taking a connection struct 4 | and returning a connection struct. As a result, a composition of operations is 5 | an operation in itself (as it also takes a connection struct and returns a 6 | connection struct). 7 | 8 | We can leverage that to plug a whole `web_pipe` application as an operation to 9 | another application. By doing so, you are plugging an operation which is the 10 | composition of all operations for a given application. 11 | 12 | ```ruby 13 | class HtmlApp 14 | include WebPipe 15 | 16 | plug :content_type 17 | plug :default_status 18 | 19 | private 20 | 21 | def content_type(conn) 22 | conn.add_response_header('Content-Type' => 'text/html') 23 | end 24 | 25 | def default_status(conn) 26 | conn.set_status(404) 27 | end 28 | end 29 | 30 | class MyApp 31 | include WebPipe 32 | 33 | plug :html, HtmlApp.new 34 | # plug ... 35 | end 36 | ``` 37 | -------------------------------------------------------------------------------- /docs/plugging_operations/injecting_operations.md: -------------------------------------------------------------------------------- 1 | # Injecting operations 2 | 3 | You can inject operations at the moment an application is initialized. It 4 | allows you to override what the definition declares. 5 | 6 | To this effect, you must use `plugs:` keyword argument. It must be a hash where 7 | the operations are matched by the name you gave them in its definition. 8 | 9 | That is mainly useful for testing purposes, where you can switch a heavy 10 | operation and use another lighter one. 11 | 12 | In the following example, the response body of the application will be 13 | `'Hello from injection'`: 14 | 15 | ```ruby 16 | # config.ru 17 | require 'web_pipe' 18 | 19 | class MyApp 20 | include WebPipe 21 | 22 | plug(:hello) do |conn| 23 | conn.set_response_body('Hello from definition') 24 | end 25 | end 26 | 27 | injection = lambda do |conn| 28 | conn.set_response_body('Hello from injection') 29 | end 30 | 31 | run MyApp.new(plugs: { hello: injection }) 32 | ``` 33 | -------------------------------------------------------------------------------- /docs/plugging_operations/inspecting_operations.md: -------------------------------------------------------------------------------- 1 | # Inspecting operations 2 | 3 | Once a `WebPipe` class is initialized, all its operations get resolved. It 4 | happens because they are whether [resolved](resolving_operations.md) or 5 | [injected](injecting_operations.md). The final result can be accessed through 6 | the `#operations` method: 7 | 8 | ```ruby 9 | require 'web_pipe' 10 | 11 | class MyApp 12 | include WebPipe 13 | 14 | plug(:hello) do |conn| 15 | conn.set_response_body('Hello world!') 16 | end 17 | end 18 | 19 | app = MyApp.new 20 | conn = WebPipe::ConnSupport::Builder.call(Rack::MockRequest.env_for) 21 | new_conn = app.operations[:hello].call(con) 22 | conn.response_body #=> ['Hello world!'] 23 | ``` 24 | -------------------------------------------------------------------------------- /docs/plugging_operations/resolving_operations.md: -------------------------------------------------------------------------------- 1 | # Resolving operations 2 | 3 | There are several ways you can specify how to resolve an operation. 4 | 5 | ## Instance method 6 | 7 | Operations can be plugged as methods (both public and private) in the 8 | application class: 9 | 10 | ```ruby 11 | class MyApp 12 | include WebPipe 13 | 14 | plug :html 15 | 16 | private 17 | 18 | def html(conn) 19 | conn.add_response_header('Content-Type' => 'text/html') 20 | end 21 | end 22 | ``` 23 | 24 | ## `#call` 25 | 26 | Operations can be plugged inline as anything responding to `#call`, like a 27 | `Proc` or a `lambda`: 28 | 29 | ```ruby 30 | class MyApp 31 | include WebPipe 32 | 33 | plug :html, ->(conn) { conn.add_response_header('Content-Type' => 'text/html') } 34 | end 35 | ``` 36 | 37 | ## Block 38 | 39 | In the same way that `#call`, operations can also be plugged inline as blocks: 40 | 41 | ```ruby 42 | class MyApp 43 | include WebPipe 44 | 45 | plug :html do |conn| 46 | conn.add_response_header('Content-Type' => 'text/html') 47 | end 48 | end 49 | ``` 50 | -------------------------------------------------------------------------------- /docs/plugs.md: -------------------------------------------------------------------------------- 1 | # Plugs 2 | 3 | Some groups of operations can be generalized as following the same pattern. For 4 | example, an operation setting `Content-Type` header to `text/html` is very 5 | similar to setting the same header to `application/json`. We name plugs to this 6 | level of abstraction on top of operations: plugs are operation builders. In 7 | other words, they are higher-order functions that return functions. 8 | 9 | Being just functions, we take as a convention that plugs respond to `#call` to 10 | create an operation. 11 | 12 | This library ships with some useful plugs. 13 | -------------------------------------------------------------------------------- /docs/plugs/config.md: -------------------------------------------------------------------------------- 1 | # Config 2 | 3 | The `Config` plug helps in adding configuration settings (`#config` hash 4 | attribute) to an instance of `WebPipe::Conn`. 5 | 6 | ```ruby 7 | require 'web_pipe' 8 | 9 | class MyApp 10 | include WebPipe 11 | 12 | plug :config, WebPipe::Plugs::Config.( 13 | key1: :value1, 14 | key2: :value2 15 | ) 16 | end 17 | ``` 18 | -------------------------------------------------------------------------------- /docs/plugs/content_type.md: -------------------------------------------------------------------------------- 1 | # ContentType 2 | 3 | The `ContentType` plug is just a helper to set the `Content-Type` response 4 | header. 5 | 6 | Example: 7 | 8 | ```ruby 9 | require 'web_pipe' 10 | 11 | class MyApp 12 | include WebPipe 13 | 14 | plug :html, WebPipe::Plugs::ContentType.('text/html') 15 | end 16 | ``` 17 | -------------------------------------------------------------------------------- /docs/recipes/hanami_2_and_dry_rb_integration.md: -------------------------------------------------------------------------------- 1 | # Hanami 2 and dry-rb integration 2 | 3 | `web_pipe` has been designed to integrate smoothly with the 4 | [hanami](https://hanamirb.org/) & [dry-rb](https://dry-rb.org/) ecosystems. It 5 | shares the same design principles. It ships with some extensions that even make 6 | this integration painless (like [`:dry-schema`](../extensions/dry_schema.md) 7 | extension or [`:hanami_view`](../extensions/hanami_view.md)), and it seamlessly 8 | [integrates with dry-auto_inject](injecting_dependencies_through_dry_auto_inject.md). 9 | 10 | If you want to use `web_pipe` within a hanami 2 application, you can take 11 | inspiration from this sample todo app: 12 | 13 | https://github.com/waiting-for-dev/hanami_2_web_pipe_todo_app 14 | -------------------------------------------------------------------------------- /docs/recipes/hanami_router_integration.md: -------------------------------------------------------------------------------- 1 | # hanami-router integration 2 | 3 | A `web_pipe` application instance is a rack application. 4 | Consequently, you can mount it with `hanami-router`'s' `to:` 5 | option. 6 | 7 | ```ruby 8 | # config.ru 9 | require 'hanami/router' 10 | require 'web_pipe' 11 | 12 | class MyApp 13 | include WebPipe 14 | 15 | plug :this, ->(conn) { conn.set_response_body('This') } 16 | end 17 | 18 | router = Hanami::Router.new do 19 | get 'my_app', to: MyApp.new 20 | end 21 | 22 | run router 23 | ``` 24 | 25 | To perform [string matching with 26 | variables](https://github.com/hanami/router#string-matching-with-variables) you 27 | just need to load [`:router_params` extension](../extensions/router_params.md). 28 | -------------------------------------------------------------------------------- /docs/recipes/injecting_dependencies_through_dry_auto_inject.md: -------------------------------------------------------------------------------- 1 | # Injecting dependencies through dry-auto_inject 2 | 3 | `web_pipe` allows injecting [plugs](`../plugging_operations/injecting_operations.md`) and [middlewares](`../using_rack_middlewares/injecting_middlewares.md`) at initialization time. As they are given as keyword arguments to the `#initialize` method, `web_pipe` is only compatible with the [keyword argument strategy from dry-auto_inject](https://dry-rb.org/gems/dry-auto_inject/main/injection-strategies/#keyword-arguments-code-kwargs-code). This is useful in the case you need to use other collaborator from your plugs' definitions. 4 | 5 | ```ruby 6 | WebPipe.load_extensions(:params) 7 | 8 | class CreateUserApp 9 | include WebPipe 10 | include Deps[:create_user] 11 | 12 | plug :html, WebPipe::Plugs::ContentType.('text/html') 13 | plug :create 14 | 15 | private 16 | 17 | def create(conn) 18 | create_user.(conn.params) 19 | end 20 | end 21 | ``` 22 | -------------------------------------------------------------------------------- /docs/recipes/using_all_restful_methods.md: -------------------------------------------------------------------------------- 1 | # Using all RESTful methods 2 | 3 | As you probably know, most browsers don't support some RESTful 4 | methods like `PATCH` or `PUT`. [Rack's `MethodOverride` 5 | middleware](https://github.com/rack/rack/blob/master/lib/rack/method_override.rb) 6 | provides a workaround for this limitation, allowing to override 7 | request method in rack's env if a magical `_method` parameter or 8 | `HTTP_METHOD_OVERRIDE` request header is found. 9 | 10 | You have to be aware that if you use this middleware within a 11 | `web_pipe` application (through [`use` DSL 12 | method](../using_rack_middlewares.md)), it will have no effect. 13 | When your `web_pipe` application takes control of the request, it 14 | has already gone through the router, which is the one that should 15 | read the request method set by rack. 16 | 17 | The solution for this is straightforward. Just use `MethodOverride` middleware 18 | before your router does its work. For example, in `config.ru`: 19 | 20 | ```ruby 21 | # config.ru 22 | 23 | use Rack::MethodOverride 24 | 25 | # Load your router and map to web_pipe applications 26 | ``` 27 | -------------------------------------------------------------------------------- /docs/testing.md: -------------------------------------------------------------------------------- 1 | # Testing 2 | 3 | ## Testing the rack application 4 | 5 | A `WebPipe` instance is a just a rack application, so you can test it as such: 6 | 7 | ```ruby 8 | require 'web_pipe' 9 | require 'rack/mock' 10 | require 'rspec' 11 | 12 | class MyApp 13 | include WebPipe 14 | 15 | plug :response 16 | 17 | private 18 | 19 | def response(conn) 20 | conn 21 | .set_response_body('Hello!') 22 | .set_status(200) 23 | end 24 | end 25 | 26 | RSpec.describe MyApp do 27 | it 'responds with 200 status code' do 28 | env = Rack::MockRequest.env_for 29 | 30 | status, _headers, _body = described_class.new.call(env) 31 | 32 | expect(status).to be(200) 33 | end 34 | end 35 | ``` 36 | 37 | ## Testing individual operations 38 | 39 | Each operation in a pipe is an isolated function that takes a connection struct 40 | as argument. You can leverage [the inspection of 41 | operations](docs/plugging_operations/inspecting_operations.md) to unit test them. 42 | 43 | There's also a `WebPipe::TestSupport` module that you can include to get a 44 | helper method `#build_conn` to easily create a connection struct. 45 | 46 | ```ruby 47 | RSpec.describe MyApp do 48 | include WebPipe::TestSupport 49 | 50 | describe '#response' do 51 | it 'responds with 200 status code' do 52 | conn = build_conn 53 | operation = described_class.new.operations[:response] 54 | 55 | new_conn = operation.call(conn) 56 | 57 | expect(new_conn.status).to be(200) 58 | end 59 | end 60 | end 61 | ``` 62 | 63 | Check the API documentation for the options you can provide to 64 | [`#build_conn`](https://www.rubydoc.info/github/waiting-for-dev/web_pipe/master/WebPipe/TestSupport#build_conn). 65 | -------------------------------------------------------------------------------- /docs/using_rack_middlewares.md: -------------------------------------------------------------------------------- 1 | # Using rack middlewares 2 | 3 | A one-way pipe like the one `web_pipe` implements can deal with any required 4 | feature in a web application. However, usually, it is convenient to use some 5 | well-known rack middleware, so you don't have to reinvent the wheel. Even if 6 | you can add them to the router layer, `web_pipe` allows you to encapsulate them 7 | in your application definition. 8 | 9 | To add rack middlewares to the stack, you have to use the DSL method 10 | `use`. The first argument it takes is a `Symbol` with the name you want to 11 | assign to it (which is needed to allow 12 | [injection](using_rack_middlewares/injecting_middlewares.md) on 13 | initialization). Then, it must follow the middleware class and any options it 14 | may need: 15 | 16 | ```ruby 17 | class MyApp 18 | include WebPipe 19 | 20 | use :cookies, Rack::Session::Cookie, key: 'my_app.session', secret: 'long' 21 | end 22 | ``` 23 | -------------------------------------------------------------------------------- /docs/using_rack_middlewares/composing_middlewares.md: -------------------------------------------------------------------------------- 1 | # Composing middlewares 2 | 3 | In a similar way that you compose plugged operations, you can also compose rack 4 | middlewares from another application. 5 | 6 | For that, you just need to `use` another application. All the middlewares for 7 | that application will be added to the stack in the same order. 8 | 9 | ```ruby 10 | class HtmlApp 11 | include WebPipe 12 | 13 | use :session, Rack::Session::Cookie, key: 'my_app.session', secret: 'long' 14 | use :csrf, Rack::Csrf, raise: true 15 | end 16 | 17 | class MyApp 18 | include WebPipe 19 | 20 | use :html, HtmlApp.new 21 | # use ... 22 | 23 | # plug ... 24 | end 25 | ``` 26 | -------------------------------------------------------------------------------- /docs/using_rack_middlewares/injecting_middlewares.md: -------------------------------------------------------------------------------- 1 | # Injecting middlewares 2 | 3 | You can inject middlewares at the moment an application is initialized, 4 | allowing you to override what you have defined in the DSL. 5 | 6 | For that purpose, you have to use the `middlewares:` keyword argument. It must be a 7 | hash where middlewares are matched by the name you gave them in its definition. 8 | 9 | A middleware must be specified as an `Array`. The first item must be a rack 10 | middleware class. The rest of the arguments (if any) should be any options it may 11 | need. 12 | 13 | That is mainly useful for testing purposes, where you can switch a heavy 14 | middleware and use a mocked one instead. 15 | 16 | In the following example, we mock the rack session mechanism: 17 | 18 | ```ruby 19 | # config.ru 20 | require 'web_pipe' 21 | require 'rack/session/cookie' 22 | 23 | class MyApp 24 | include WebPipe 25 | 26 | use :session, Rack::Session::Cookie, key: 'my_app.session', secret: 'long' 27 | 28 | plug(:serialize_session) do |conn| 29 | conn.set_response_body(conn.env['rack.session'].inspect) 30 | end 31 | end 32 | 33 | class MockedSession 34 | attr_reader :app, :key 35 | 36 | def initialize(app, key) 37 | @app = app 38 | @key = key 39 | end 40 | 41 | def call(env) 42 | env['rack.session'] = "Mocked for '#{key}' key" 43 | end 44 | end 45 | 46 | run MyApp.new(middlewares: { session: [MockedSession, 'my_app_mocked'] }) 47 | ``` 48 | -------------------------------------------------------------------------------- /docs/using_rack_middlewares/inspecting_middlewares.md: -------------------------------------------------------------------------------- 1 | # Inspecting middlewares 2 | 3 | Once a `WebPipe` class is initialized, all its middlewares get resolved. You 4 | can access them through the `#middlewares` method. 5 | 6 | Each middleware is represented by a 7 | `WebPipe::RackSupport::MiddlewareSpecification` instance, which contains two 8 | accessors: `middleware` returns the middleware class. In contrast, `options` 9 | returns an array with the arguments provided to the middleware on 10 | initialization. 11 | 12 | Keep in mind that every middleware is resolved as an array. That is because it 13 | can be composed by a chain of middlewares built through 14 | [composition](composing_middlewares.md). 15 | 16 | 17 | ```ruby 18 | require 'web_pipe' 19 | require 'rack/session' 20 | 21 | class MyApp 22 | include WebPipe 23 | 24 | use :session, Rack::Session::Cookie, key: 'my_app.session', secret: 'long' 25 | 26 | plug(:hello) do |conn| 27 | conn.set_response_body('Hello world!') 28 | end 29 | end 30 | 31 | app = MyApp.new 32 | session_middleware = app.middlewares[:session][0] 33 | session_middleware.middleware # => Rack::Session::Cookie 34 | session_middleware.options # => [{ key: 'my_app.session', secret: 'long' }] 35 | ``` 36 | -------------------------------------------------------------------------------- /lib/web_pipe.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "dry/core/extensions" 4 | require "zeitwerk" 5 | 6 | # Entry-point for the DSL layer. 7 | # 8 | # Including this module into your class adds to it a DSL layer which makes it 9 | # convenient to interact with an instance of {WebPipe::Pipe} transparently. It 10 | # means that the DSL is actually an optional layer, and you can achieve 11 | # everything by using {WebPipe::Pipe} instances. 12 | # 13 | # Your class gets access to {WebPipe::DSL::ClassContext::DSL_METHODS} at the 14 | # class level, while {WebPipe::DSL::InstanceContext::PIPE_METHODS} are available 15 | # for every instance of it. Both groups of methods are delegating to 16 | # {WebPipe::Pipe}, so you can look there for documentation. 17 | # 18 | # @example 19 | # class HelloWorld 20 | # include WebPipe 21 | # 22 | # use :runtime, Rack::Runtime 23 | # 24 | # plug :content_type do |conn| 25 | # conn.add_response_header('Content-Type', 'plain/text') 26 | # end 27 | # 28 | # plug :render do |conn| 29 | # conn.set_response_body('Hello, World!') 30 | # end 31 | # end 32 | # 33 | # The instance of your class is itself the final rack application. When you 34 | # initialize it, you have the chance to inject different plugs or middlewares 35 | # from those defined at the class level. 36 | # 37 | # @example 38 | # HelloWorld.new( 39 | # middlewares: { 40 | # runtime: [Class.new do 41 | # def initialize(app) 42 | # @app = app 43 | # end 44 | # 45 | # def call(env) 46 | # status, headers, body = @app.call(env) 47 | # [status, headers.merge('Injected' => '1'), body] 48 | # end 49 | # end] 50 | # }, 51 | # plugs: { 52 | # render: ->(conn) { conn.set_response_body('Injected!') } 53 | # } 54 | # ) 55 | module WebPipe 56 | def self.loader 57 | Zeitwerk::Loader.for_gem.tap do |loader| 58 | loader.ignore( 59 | "#{__dir__}/web_pipe/conn_support/errors.rb", 60 | "#{__dir__}/web_pipe/extensions" 61 | ) 62 | loader.inflector.inflect("dsl" => "DSL") 63 | end 64 | end 65 | loader.setup 66 | 67 | extend Dry::Core::Extensions 68 | 69 | # Called via {Module#include}, makes available web_pipe's DSL. 70 | # 71 | # Includes an instance of `Builder`. That means that `Builder#included` is 72 | # eventually called. 73 | def self.included(klass) 74 | klass.include(DSL::Builder.new) 75 | end 76 | 77 | register_extension :container do 78 | require "web_pipe/extensions/container/container" 79 | end 80 | 81 | register_extension :cookies do 82 | require "web_pipe/extensions/cookies/cookies" 83 | end 84 | 85 | register_extension :dry_schema do 86 | require "web_pipe/extensions/dry_schema/dry_schema" 87 | require "web_pipe/extensions/dry_schema/plugs/sanitize_params" 88 | end 89 | 90 | register_extension :flash do 91 | require "web_pipe/extensions/flash/flash" 92 | end 93 | 94 | register_extension :hanami_view do 95 | require "web_pipe/extensions/hanami_view/hanami_view" 96 | end 97 | 98 | register_extension :not_found do 99 | require "web_pipe/extensions/not_found/not_found" 100 | end 101 | 102 | register_extension :params do 103 | require "web_pipe/extensions/params/params" 104 | end 105 | 106 | register_extension :rails do 107 | require "web_pipe/extensions/rails/rails" 108 | end 109 | 110 | register_extension :redirect do 111 | require "web_pipe/extensions/redirect/redirect" 112 | end 113 | 114 | register_extension :router_params do 115 | require "web_pipe/extensions/router_params/router_params" 116 | end 117 | 118 | register_extension :session do 119 | require "web_pipe/extensions/session/session" 120 | end 121 | 122 | register_extension :url do 123 | require "web_pipe/extensions/url/url" 124 | end 125 | end 126 | -------------------------------------------------------------------------------- /lib/web_pipe/app.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "dry/monads" 4 | 5 | module WebPipe 6 | # Rack app built from a chain of functions that take and return a 7 | # {WebPipe::Conn}. 8 | # 9 | # This is the abstraction encompassing a rack application built only with the 10 | # functions on {WebPipe::Conn}. {WebPipe::RackSupport::AppWithMiddlewares} 11 | # takes middlewares also into account. 12 | # 13 | # A rack application is something callable that takes the rack environment as 14 | # an argument, and returns a rack response. So, this class needs to: 15 | # 16 | # - Take rack's environment and create a {WebPipe::Conn} struct from there. 17 | # - Starting from the initial struct, apply the pipe of functions. 18 | # - Convert the last {WebPipe::Conn} back to a rack response. 19 | # 20 | # {WebPipe::Conn} can itself be of two different types (subclasses of it}: 21 | # {Conn::Ongoing} and {Conn::Halted}. The pipe is stopped on two scenarios: 22 | # 23 | # - The end of the pipe is reached. 24 | # - One function returns a {Conn::Halted}. 25 | class App 26 | include Dry::Monads::Result::Mixin 27 | 28 | # @!attribute [r] operations 29 | # @return [Array] 30 | attr_reader :operations 31 | 32 | # @param operations [Array] 33 | def initialize(operations) 34 | @operations = operations 35 | end 36 | 37 | # @param env [Hash] Rack environment 38 | # 39 | # @return env [Array] Rack response 40 | # @raise ConnSupport::Composition::InvalidOperationResult when an 41 | # operation doesn't return a {WebPipe::Conn} 42 | def call(env) 43 | extract_rack_response( 44 | apply_operations( 45 | conn_from_env( 46 | env 47 | ) 48 | ) 49 | ) 50 | end 51 | 52 | private 53 | 54 | def conn_from_env(env) 55 | ConnSupport::Builder.(env) 56 | end 57 | 58 | def apply_operations(conn) 59 | ConnSupport::Composition.new(operations).(conn) 60 | end 61 | 62 | def extract_rack_response(conn) 63 | conn.rack_response 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /lib/web_pipe/conn.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "dry/struct" 4 | require "web_pipe/conn_support/errors" 5 | 6 | module WebPipe 7 | # Struct and methods about web request and response data. 8 | # 9 | # It is meant to contain all the data coming from a web request 10 | # along with all the data needed to build a web response. It can 11 | # be built with {ConnSupport::Builder}. 12 | # 13 | # Besides data fetching methods and {#rack_response}, any other 14 | # method returns a fresh new instance of it, so it is thought to 15 | # be used in an immutable way and to allow chaining of method 16 | # calls. 17 | # 18 | # There are two subclasses (two types) for this: 19 | # {Conn::Ongoing} and {Conn::Halted}. {ConnSupport::Builder} constructs 20 | # a {Conn::Ongoing} struct, while {#halt} copies the data to a 21 | # {Conn::Halted} instance. The intention of this is to halt 22 | # operations on the web request/response cycle one a {Conn::Halted} 23 | # instance is detected. 24 | # 25 | # @example 26 | # WebPipe::ConnSupport::Builder.call(env). 27 | # set_status(404). 28 | # add_response_header('Content-Type', 'text/plain'). 29 | # set_response_body('Not found'). 30 | # halt 31 | class Conn < Dry::Struct 32 | include ConnSupport::Types 33 | 34 | # @!attribute [r] env 35 | # 36 | # Rack env hash. 37 | # 38 | # @return [Env[]] 39 | # 40 | # @see https://www.rubydoc.info/github/rack/rack/file/SPEC 41 | attribute :env, Env 42 | 43 | # @!attribute [r] request 44 | # 45 | # Rack request. 46 | # 47 | # @return [Request[]] 48 | # 49 | # @see https://www.rubydoc.info/github/rack/rack/Rack/Request 50 | attribute :request, Request 51 | 52 | # @!attribute [r] scheme 53 | # 54 | # Scheme of the request. 55 | # 56 | # @return [Scheme[]] 57 | # 58 | # @example 59 | # :http 60 | attribute :scheme, Scheme 61 | 62 | # @!attribute [r] request_method 63 | # 64 | # Method of the request. 65 | # 66 | # It is not called `:method` in order not to collide with 67 | # {Object#method}. 68 | # 69 | # @return [Method[]] 70 | # 71 | # @example 72 | # :get 73 | attribute :request_method, Method 74 | 75 | # @!attribute [r] host 76 | # 77 | # Host being requested. 78 | # 79 | # @return [Host[]] 80 | # 81 | # @example 82 | # 'www.example.org' 83 | attribute :host, Host 84 | 85 | # @!attribute [r] ip 86 | # 87 | # IP being requested. 88 | # 89 | # @return [IP[]] 90 | # 91 | # @example 92 | # '192.168.1.1' 93 | attribute :ip, Ip 94 | 95 | # @!attribute [r] port 96 | # 97 | # Port in which the request is made. 98 | # 99 | # @return [Port[]] 100 | # 101 | # @example 102 | # 443 103 | attribute :port, Port 104 | 105 | # @!attribute [r] script_name 106 | # 107 | # Script name in the URL, or the empty string if none. 108 | # 109 | # @return [ScriptName[]] 110 | # 111 | # @example 112 | # 'index.rb' 113 | attribute :script_name, ScriptName 114 | 115 | # @!attribute [r] path_info 116 | # 117 | # Besides {#script_name}, the remainder path of the URL or the 118 | # empty string if none. It is, at least, `/` when `#script_name` 119 | # is empty. 120 | # 121 | # This doesn't include the {#query_string}. 122 | # 123 | # @return [PathInfo[]] 124 | # 125 | # @example 126 | # '/foo/bar'. 127 | attribute :path_info, PathInfo 128 | 129 | # @!attribute [r] query_string 130 | # 131 | # Query String of the URL (everything after `?` , or the empty 132 | # string if none). 133 | # 134 | # @return [QueryString[]] 135 | # 136 | # @example 137 | # 'foo=bar&bar=foo' 138 | attribute :query_string, QueryString 139 | 140 | # @!attribute [r] request_body 141 | # 142 | # Body sent by the request. 143 | # 144 | # @return [RequestBody[]] 145 | # 146 | # @example 147 | # '{ resource: "foo" }' 148 | attribute :request_body, RequestBody 149 | 150 | # @!attribute [r] request_headers 151 | # 152 | # Hash of request headers. 153 | # 154 | # As per RFC2616, headers names are case insensitive. Here, they 155 | # are normalized to PascalCase acting on dashes ('-'). 156 | # 157 | # Notice that when a rack server maps headers to CGI-like 158 | # variables, both dashes and underscores (`_`) are treated as 159 | # dashes. Here, they always remain as dashes. 160 | # 161 | # @return [Headers[]] 162 | # 163 | # @see https://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.2 164 | # 165 | # @example 166 | # { 'Accept-Charset' => 'utf8' } 167 | attribute :request_headers, Headers 168 | 169 | # @!attribute [r] status 170 | # 171 | # Status sent by the response. 172 | # 173 | # @return [Status[]] 174 | # 175 | # @example 176 | # 200 177 | attribute :status, Status 178 | 179 | # @!attribute [r] response_body 180 | # 181 | # @return [ResponseBody[]] Body sent by the response. 182 | # 183 | # @example 184 | # [''] 185 | attribute :response_body, ResponseBody 186 | 187 | # @!attribute [r] response_headers 188 | # 189 | # Response headers. 190 | # 191 | # @see #request_headers for normalization details 192 | # 193 | # @return [Headers[]] 194 | # 195 | # @example 196 | # 197 | # { 'Content-Type' => 'text/html' } 198 | attribute :response_headers, Headers 199 | 200 | # @!attribute [r] bag 201 | # 202 | # Hash where anything can be stored. Keys 203 | # must be symbols. 204 | # 205 | # This can be used to store anything that is needed to be 206 | # consumed downstream in a pipe of operations action on and 207 | # returning {Conn}. 208 | # 209 | # @return [Bag[]] 210 | attribute :bag, Bag 211 | 212 | # @!attribute [r] config 213 | # 214 | # Instance level configuration. 215 | # 216 | # It is a hash where anything can be stored. Keys must be symbols. 217 | # 218 | # The idea of it is for extensions to have somewhere to store user 219 | # provided values they may need at some point. 220 | # 221 | # @return [Bag[]] 222 | attribute :config, Bag 223 | 224 | # Sets response status code. 225 | # 226 | # @param code [StatusCode] 227 | # 228 | # @return [Conn] 229 | def set_status(code) 230 | new( 231 | status: code 232 | ) 233 | end 234 | 235 | # Sets response body. 236 | # 237 | # As per rack specification, the response body must respond to 238 | # `#each`. Here, when given `content` responds to `:each` it is 239 | # set as it is as the new response body. Otherwise, what is set 240 | # is a one item array of it. 241 | # 242 | # @param content [#each, String] 243 | # 244 | # @return [Conn] 245 | # 246 | # @see https://www.rubydoc.info/github/rack/rack/master/file/SPEC#label-The+Body 247 | def set_response_body(content) 248 | new( 249 | response_body: content.respond_to?(:each) ? content : [content] 250 | ) 251 | end 252 | 253 | # Sets response headers. 254 | # 255 | # Substitues everything that was present as response headers by 256 | # the new given hash. 257 | # 258 | # Headers keys are normalized. 259 | # 260 | # @param headers [Hash] 261 | # 262 | # @return [Conn] 263 | # 264 | # @see ConnSupport::Headers.normalize_key 265 | def set_response_headers(headers) 266 | new( 267 | response_headers: ConnSupport::Headers.normalize(headers) 268 | ) 269 | end 270 | 271 | # Adds given pair to response headers. 272 | # 273 | # `key` is normalized. 274 | # 275 | # @param key [String] 276 | # @param value [String] 277 | # 278 | # @return [Conn] 279 | # 280 | # @see ConnSupport::Headers.normalize_key 281 | def add_response_header(key, value) 282 | new( 283 | response_headers: ConnSupport::Headers.add( 284 | response_headers, key, value 285 | ) 286 | ) 287 | end 288 | 289 | # Deletes pair with given key from response headers. 290 | # 291 | # It accepts a non normalized key. 292 | # 293 | # @param key [String] 294 | # 295 | # @return [Conn] 296 | # 297 | # @see ConnSupport::Headers.normalize_key 298 | def delete_response_header(key) 299 | new( 300 | response_headers: ConnSupport::Headers.delete( 301 | response_headers, key 302 | ) 303 | ) 304 | end 305 | 306 | # Reads an item from {#bag}. 307 | # 308 | # @param key [Symbol] 309 | # 310 | # @return [Object] 311 | # 312 | # @raise ConnSupport::KeyNotFoundInBagError when key is not 313 | # registered in the bag. 314 | def fetch(key, default = Types::Undefined) 315 | return bag.fetch(key, default) unless default == Types::Undefined 316 | 317 | bag.fetch(key) { raise ConnSupport::KeyNotFoundInBagError, key } 318 | end 319 | 320 | # Writes an item to the {#bag}. 321 | # 322 | # If it already exists, it is overwritten. 323 | # 324 | # @param key [Symbol] 325 | # @param value [Object] 326 | # 327 | # @return [Conn] 328 | def add(key, value) 329 | new( 330 | bag: bag.merge(key => value) 331 | ) 332 | end 333 | 334 | # Reads an item from {#config}. 335 | # 336 | # @param key [Symbol] 337 | # 338 | # @return [Object] 339 | # 340 | # @raise ConnSupport::KeyNotFoundInConfigError when key is not 341 | # present in {#config}. 342 | def fetch_config(key, default = Types::Undefined) 343 | return config.fetch(key, default) unless default == Types::Undefined 344 | 345 | config.fetch(key) { raise ConnSupport::KeyNotFoundInConfigError, key } 346 | end 347 | 348 | # Writes an item to {#config}. 349 | # 350 | # If it already exists, it is overwritten. 351 | # 352 | # @param key [Symbol] 353 | # @param value [Object] 354 | # 355 | # @return [Conn] 356 | def add_config(key, value) 357 | new( 358 | config: config.merge(key => value) 359 | ) 360 | end 361 | 362 | # Builds response in the way rack expects. 363 | # 364 | # It is useful to finish a rack application built with a 365 | # {Conn}. After every desired operation has been done, 366 | # this method has to be called before giving control back to 367 | # rack. 368 | # 369 | # @return 370 | # [Array] 371 | # 372 | # @api private 373 | def rack_response 374 | [ 375 | status, 376 | response_headers, 377 | response_body 378 | ] 379 | end 380 | 381 | # Copies all the data to a {Halted} instance and 382 | # returns it. 383 | # 384 | # @return [Halted] 385 | def halt 386 | Halted.new(attributes) 387 | end 388 | 389 | # Returns whether the instance is {Halted}. 390 | # 391 | # @return [Bool] 392 | def halted? 393 | is_a?(Halted) 394 | end 395 | 396 | # Type of {Conn} representing an ongoing request/response 397 | # cycle. 398 | class Ongoing < Conn; end 399 | 400 | # Type of {Conn} representing a halted request/response 401 | # cycle. 402 | class Halted < Conn; end 403 | end 404 | end 405 | -------------------------------------------------------------------------------- /lib/web_pipe/conn_support/builder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rack" 4 | 5 | module WebPipe 6 | module ConnSupport 7 | # @api private 8 | module Builder 9 | # rubocop:disable Metrics/MethodLength 10 | def self.call(env) 11 | rr = Rack::Request.new(env) 12 | Conn::Ongoing.new( 13 | request: rr, 14 | env: env, 15 | scheme: rr.scheme.to_sym, 16 | request_method: rr.request_method.downcase.to_sym, 17 | host: rr.host, 18 | ip: rr.ip, 19 | port: rr.port, 20 | script_name: rr.script_name, 21 | path_info: rr.path_info, 22 | query_string: rr.query_string, 23 | request_body: rr.body, 24 | request_headers: Headers.extract(env) 25 | ) 26 | end 27 | # rubocop:enable Metrics/MethodLength 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/web_pipe/conn_support/composition.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "dry/monads" 4 | 5 | module WebPipe 6 | module ConnSupport 7 | # @api private 8 | class Composition 9 | # Raised when operation doesn't return a {WebPipe::Conn}. 10 | class InvalidOperationResult < RuntimeError 11 | def initialize(returned) 12 | super( 13 | <<~MSG 14 | An operation returned +#{returned.inspect}+. To be valid, 15 | an operation must return whether a 16 | WebPipe::Conn::Ongoing or a WebPipe::Conn::Halted. 17 | MSG 18 | ) 19 | end 20 | end 21 | 22 | include Dry::Monads[:result] 23 | 24 | attr_reader :operations 25 | 26 | def initialize(operations) 27 | @operations = operations 28 | end 29 | 30 | def call(conn) 31 | extract_result( 32 | apply_operations( 33 | conn 34 | ) 35 | ) 36 | end 37 | 38 | private 39 | 40 | def apply_operations(conn) 41 | operations.reduce(Success(conn)) do |new_conn, operation| 42 | new_conn.bind { |c| apply_operation(c, operation) } 43 | end 44 | end 45 | 46 | def apply_operation(conn, operation) 47 | result = operation.(conn) 48 | case result 49 | when Conn::Ongoing 50 | Success(result) 51 | when Conn::Halted 52 | Failure(result) 53 | else 54 | raise InvalidOperationResult, result 55 | end 56 | end 57 | 58 | def extract_result(result) 59 | extract_proc = :itself.to_proc 60 | 61 | result.either(extract_proc, extract_proc) 62 | end 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/web_pipe/conn_support/errors.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module WebPipe 4 | module ConnSupport 5 | # Error raised when trying to fetch an entry in {WebPipe::Conn#bag} for an 6 | # unknown key. 7 | class KeyNotFoundInBagError < KeyError 8 | # @param key [Any] Key not found in the bag 9 | def initialize(key) 10 | super( 11 | <<~MSG 12 | Bag does not contain a key with name +#{key}+. 13 | MSG 14 | ) 15 | end 16 | end 17 | 18 | # Error raised when trying to fetch an entry in {WebPipeConn#config} for an 19 | # unknown key. 20 | class KeyNotFoundInConfigError < KeyError 21 | # @param key [Any] Key not found in config 22 | def initialize(key) 23 | super( 24 | <<~MSG 25 | Config does not contain a key with name +#{key}+. 26 | MSG 27 | ) 28 | end 29 | end 30 | 31 | # Error raised when trying to use a {WebPipe::Conn} feature which requires a 32 | # rack middleware that is not present 33 | class MissingMiddlewareError < RuntimeError 34 | # @param feature [String] Name of the feature intended to be used 35 | # @param middleware [String] Name of the missing middleware 36 | # @param gem [String] Gem name for the middleware 37 | def initialize(feature, middleware, gem) 38 | super( 39 | <<~MSG 40 | In order to use #{feature} you must use #{middleware} middleware: 41 | https://rubygems.org/gems/#{gem} 42 | MSG 43 | ) 44 | end 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/web_pipe/conn_support/headers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module WebPipe 4 | module ConnSupport 5 | # @api private 6 | module Headers 7 | # Headers which come as plain CGI-like variables (without the `HTTP_` 8 | # prefixed) from the rack server. 9 | HEADERS_AS_CGI = %w[CONTENT_TYPE CONTENT_LENGTH].freeze 10 | 11 | # Headers are all those pairs which key begins with `HTTP_` plus 12 | # those detailed in {HEADERS_AS_CGI}. 13 | def self.extract(env) 14 | Hash[ 15 | env 16 | .select { |k, _v| k.start_with?("HTTP_") } 17 | .map { |k, v| pair(k[5..], v) } 18 | .concat( 19 | env 20 | .select { |k, _v| HEADERS_AS_CGI.include?(k) } 21 | .map { |k, v| pair(k, v) } 22 | ) 23 | ] 24 | end 25 | 26 | def self.add(headers, key, value) 27 | Hash[ 28 | headers.to_a.push(pair(key, value)) 29 | ] 30 | end 31 | 32 | def self.delete(headers, key) 33 | headers.reject { |k, _v| normalize_key(key) == k } 34 | end 35 | 36 | def self.pair(key, value) 37 | [normalize_key(key), value] 38 | end 39 | 40 | # As per RFC2616, headers names are case insensitive. This 41 | # function normalizes them to PascalCase acting on dashes ('-'). 42 | # 43 | # When a rack server maps headers to CGI-like variables, both 44 | # dashes and underscores (`_`) are treated as dashes. This 45 | # function substitutes all '-' to '_'. 46 | # 47 | # See https://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.2 48 | def self.normalize_key(key) 49 | key.downcase.gsub("_", "-").split("-").map(&:capitalize).join("-") 50 | end 51 | 52 | def self.normalize(headers) 53 | headers.transform_keys { |k| normalize_key(k) } 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/web_pipe/conn_support/types.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "dry/types" 4 | require "rack/request" 5 | 6 | module WebPipe 7 | module ConnSupport 8 | # Types used in the {WebPipe::Conn} struct. 9 | # 10 | # The implementation self-describes them, but you can look at the 11 | # {WebPipe::Conn} attributes for documentation. 12 | module Types 13 | include Dry.Types() 14 | 15 | Env = Strict::Hash 16 | Request = Instance(Rack::Request) 17 | 18 | Scheme = Strict::Symbol.enum(:http, :https) 19 | Method = Strict::Symbol.enum( 20 | :get, :head, :post, :put, :delete, :connect, :options, :trace, :patch 21 | ) 22 | Host = Strict::String 23 | Ip = Strict::String.optional 24 | Port = Strict::Integer 25 | ScriptName = Strict::String 26 | PathInfo = Strict::String 27 | QueryString = Strict::String 28 | RequestBody = Interface(:gets, :each, :read, :rewind) 29 | 30 | Status = Strict::Integer 31 | .default(200) 32 | .constrained(gteq: 100, lteq: 599) 33 | ResponseBody = Interface(:each).default { [""] } 34 | 35 | Headers = Strict::Hash 36 | .map(Strict::String, Strict::String) 37 | .default { {} } 38 | 39 | Bag = Strict::Hash 40 | .map(Strict::Symbol, Strict::Any) 41 | .default { {} } 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/web_pipe/dsl/builder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module WebPipe 4 | module DSL 5 | # @api private 6 | class Builder < Module 7 | attr_reader :class_context, :instance_context 8 | 9 | def initialize 10 | @class_context = ClassContext.new 11 | @instance_context = InstanceContext.new( 12 | class_context: class_context 13 | ) 14 | super() 15 | end 16 | 17 | def included(klass) 18 | klass.extend(class_context) 19 | klass.include(instance_context) 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/web_pipe/dsl/class_context.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module WebPipe 4 | module DSL 5 | # @api private 6 | class ClassContext < Module 7 | DSL_METHODS = %i[use plug compose].freeze 8 | 9 | attr_reader :ast 10 | 11 | def initialize 12 | @ast = [] 13 | super 14 | end 15 | 16 | def extended(klass) 17 | define_dsl_methods(klass, ast) 18 | end 19 | 20 | private 21 | 22 | def define_dsl_methods(klass, ast) 23 | DSL_METHODS.each do |method| 24 | klass.define_singleton_method(method) do |*args, **kwargs, &block| 25 | ast << [method, args, kwargs, block] 26 | end 27 | end 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/web_pipe/dsl/instance_context.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module WebPipe 4 | module DSL 5 | # @api private 6 | class InstanceContext < Module 7 | PIPE_METHODS = %i[ 8 | call middlewares operations to_proc to_middlewares 9 | ].freeze 10 | 11 | attr_reader :class_context 12 | 13 | def initialize(class_context:) 14 | @class_context = class_context 15 | super() 16 | end 17 | 18 | def included(klass) 19 | klass.include(dynamic_module(class_context.ast)) 20 | end 21 | 22 | private 23 | 24 | def dynamic_module(ast) 25 | Module.new.tap do |mod| 26 | define_initialize(mod, ast) 27 | define_pipe_methods(mod) 28 | end 29 | end 30 | 31 | # rubocop:disable Metrics/MethodLength 32 | def define_initialize(mod, ast) 33 | mod.define_method(:initialize) do |plugs: {}, middlewares: {}, **kwargs| 34 | super(**kwargs) # Compatibility with dry-auto_inject 35 | acc = Pipe.new(context: self) 36 | @pipe = ast.reduce(acc) do |pipe, node| 37 | method, args, kwargs, block = node 38 | if block 39 | pipe.send(method, *args, **kwargs, &block) 40 | else 41 | pipe.send(method, *args, **kwargs) 42 | end 43 | end.inject(plugs: plugs, middleware_specifications: middlewares) 44 | end 45 | end 46 | # rubocop:enable Metrics/MethodLength 47 | 48 | def define_pipe_methods(mod) 49 | PIPE_METHODS.each do |method| 50 | mod.define_method(method) do |*args| 51 | @pipe.send(method, *args) 52 | end 53 | end 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/web_pipe/extensions/container/container.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # :nodoc: 4 | module WebPipe 5 | # See the docs for the extension linked from the README. 6 | module Container 7 | # Returns {Conn#config} `:container` value 8 | # 9 | # @return [Any] 10 | def container 11 | fetch_config(:container) 12 | end 13 | end 14 | 15 | Conn.include(Container) 16 | end 17 | -------------------------------------------------------------------------------- /lib/web_pipe/extensions/cookies/cookies.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rack/utils" 4 | 5 | # :nodoc: 6 | module WebPipe 7 | # See the docs for the extension linked from the README. 8 | module Cookies 9 | # Valid options for {#set_cookie}. 10 | SET_COOKIE_OPTIONS = Types::Strict::Hash.schema( 11 | domain?: Types::Strict::String.optional, 12 | path?: Types::Strict::String.optional, 13 | max_age?: Types::Strict::Integer.optional, 14 | expires?: Types::Strict::Time.optional, 15 | secure?: Types::Strict::Bool.optional, 16 | http_only?: Types::Strict::Bool.optional, 17 | same_site?: Types::Strict::Symbol.enum(:none, :lax, :strict).optional 18 | ) 19 | 20 | # Valid options for {#delete_cookie}. 21 | DELETE_COOKIE_OPTIONS = Types::Strict::Hash.schema( 22 | domain?: Types::Strict::String.optional, 23 | path?: Types::Strict::String.optional 24 | ) 25 | 26 | # @return [Hash] 27 | def request_cookies 28 | request.cookies 29 | end 30 | 31 | # @param key [String] 32 | # @param value [String] 33 | # @param opts [SET_COOKIE_OPTIONS[]] 34 | def set_cookie(key, value, opts = Types::EMPTY_HASH) 35 | Rack::Utils.set_cookie_header!( 36 | response_headers, 37 | key, 38 | { value: value }.merge(SET_COOKIE_OPTIONS[opts]) 39 | ) 40 | self 41 | end 42 | 43 | # @param key [String] 44 | # @param opts [DELETE_COOKIE_OPTIONS[]] 45 | def delete_cookie(key, opts = Types::EMPTY_HASH) 46 | Rack::Utils.delete_cookie_header!( 47 | response_headers, 48 | key, 49 | DELETE_COOKIE_OPTIONS[opts] 50 | ) 51 | self 52 | end 53 | end 54 | 55 | Conn.include(Cookies) 56 | end 57 | -------------------------------------------------------------------------------- /lib/web_pipe/extensions/dry_schema/dry_schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | WebPipe.load_extensions(:params) 4 | 5 | # :nodoc: 6 | module WebPipe 7 | # See the docs for the extension linked from the README. 8 | module DrySchema 9 | SANITIZED_PARAMS_KEY = :sanitized_params 10 | 11 | def sanitized_params 12 | fetch_config(SANITIZED_PARAMS_KEY) 13 | end 14 | end 15 | 16 | Conn.include(DrySchema) 17 | end 18 | -------------------------------------------------------------------------------- /lib/web_pipe/extensions/dry_schema/plugs/sanitize_params.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "web_pipe/extensions/dry_schema/dry_schema" 4 | 5 | module WebPipe 6 | module Plugs 7 | # Sanitize {Conn#params} with given `dry-schema` Schema. 8 | # 9 | # @see WebPipe::DrySchema 10 | module SanitizeParams 11 | # {Conn#config} key to store the handler. 12 | # 13 | # @return [Symbol] 14 | PARAM_SANITIZATION_HANDLER_KEY = :param_sanitization_handler 15 | 16 | # @param schema [Dry::Schema::Processor] 17 | # @param handler [ParamSanitizationHandler::Handler[]] 18 | # 19 | # @return [ConnSupport::Composition::Operation[], Types::Undefined] 20 | def self.call(schema, handler = Types::Undefined) 21 | lambda do |conn| 22 | result = schema.(conn.params) 23 | if result.success? 24 | conn.add_config(DrySchema::SANITIZED_PARAMS_KEY, result.output) 25 | else 26 | get_handler(conn, handler).(conn, result) 27 | end 28 | end 29 | end 30 | 31 | def self.get_handler(conn, handler) 32 | return handler unless handler == Types::Undefined 33 | 34 | conn.fetch_config(PARAM_SANITIZATION_HANDLER_KEY) 35 | end 36 | private_class_method :get_handler 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/web_pipe/extensions/flash/flash.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "web_pipe/conn_support/errors" 4 | 5 | # :nodoc: 6 | module WebPipe 7 | # See the docs for the extension linked from the README. 8 | module Flash 9 | RACK_FLASH_KEY = "x-rack.flash" 10 | 11 | # Returns the flash bag. 12 | # 13 | # @return [Rack::Flash::FlashHash] 14 | # 15 | # @raises ConnSupport::MissingMiddlewareError when `Rack::Flash` 16 | # is not being used as middleware 17 | def flash 18 | env.fetch(RACK_FLASH_KEY) do 19 | raise ConnSupport::MissingMiddlewareError.new( 20 | "flash", "Rack::Flash", "https://rubygems.org/gems/rack-flash3" 21 | ) 22 | end 23 | end 24 | 25 | # Adds an item to the flash bag to be consumed by next request. 26 | # 27 | # @param key [String] 28 | # @param value [String] 29 | def add_flash(key, value) 30 | flash[key] = value 31 | self 32 | end 33 | 34 | # Adds an item to the flash bag to be consumed by the same request 35 | # in process. 36 | # 37 | # @param key [String] 38 | # @param value [String] 39 | def add_flash_now(key, value) 40 | flash.now[key] = value 41 | self 42 | end 43 | end 44 | 45 | Conn.include(Flash) 46 | end 47 | -------------------------------------------------------------------------------- /lib/web_pipe/extensions/hanami_view/hanami_view.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "web_pipe/extensions/hanami_view/hanami_view/context" 4 | require "hanami/view" 5 | 6 | # :nodoc: 7 | module WebPipe 8 | # See the docs for the extension linked in the README. 9 | module HanamiView 10 | VIEW_CONTEXT_CLASS_KEY = :view_context_class 11 | private_constant :VIEW_CONTEXT_CLASS_KEY 12 | 13 | DEFAULT_VIEW_CONTEXT_CLASS = Class.new(WebPipe::HanamiView::Context) 14 | private_constant :DEFAULT_VIEW_CONTEXT_CLASS 15 | 16 | VIEW_CONTEXT_OPTIONS_KEY = :view_context_options 17 | private_constant :VIEW_CONTEXT_OPTIONS_KEY 18 | 19 | DEFAULT_VIEW_CONTEXT_OPTIONS = ->(_conn) { {} } 20 | 21 | # Sets string output of a view as response body. 22 | # 23 | # If the view is not a {Hanami::View} instance, it is resolved from 24 | # the configured container. 25 | # 26 | # `kwargs` is used as the input for the view (the arguments that 27 | # {Hanami::View#call} receives). 28 | # 29 | # @param view_spec [Hanami::View, Any] 30 | # @param kwargs [Hash] Arguments to pass along to `Hanami::View#call` 31 | # 32 | # @return WebPipe::Conn 33 | def view(view_spec, **kwargs) 34 | view_instance = view_instance(view_spec) 35 | 36 | set_response_body( 37 | view_instance.(**view_input(kwargs)).to_str 38 | ) 39 | end 40 | 41 | private 42 | 43 | def view_instance(view_spec) 44 | return view_spec if view_spec.is_a?(Hanami::View) 45 | 46 | fetch_config(:container)[view_spec] 47 | end 48 | 49 | def view_input(kwargs) 50 | return kwargs if kwargs.key?(:context) 51 | 52 | context = fetch_config( 53 | VIEW_CONTEXT_CLASS_KEY, DEFAULT_VIEW_CONTEXT_CLASS 54 | ).new( 55 | **fetch_config( 56 | VIEW_CONTEXT_OPTIONS_KEY, DEFAULT_VIEW_CONTEXT_OPTIONS 57 | ).(self) 58 | ) 59 | kwargs.merge(context: context) 60 | end 61 | end 62 | 63 | Conn.include(HanamiView) 64 | end 65 | -------------------------------------------------------------------------------- /lib/web_pipe/extensions/hanami_view/hanami_view/context.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "hanami/view" 4 | 5 | module WebPipe 6 | module HanamiView 7 | # Noop context class for Hanami::View used by default. 8 | class Context < Hanami::View::Context 9 | def initialize(**_kwargs) 10 | super() 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/web_pipe/extensions/not_found/not_found.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # :nodoc: 4 | module WebPipe 5 | # See the docs for the extension linked from the README. 6 | module NotFound 7 | # @api private 8 | RESPONSE_BODY_STEP_CONFIG_KEY = :not_found_body_step 9 | 10 | # Generates the not-found response 11 | # 12 | # @return [WebPipe::Conn::Halted] 13 | # @see NotFound 14 | def not_found 15 | set_status(404) 16 | .then do |conn| 17 | response_body_step = conn.fetch_config(RESPONSE_BODY_STEP_CONFIG_KEY, 18 | ->(c) { c.set_response_body("Not found") }) 19 | 20 | response_body_step.(conn) 21 | end.halt 22 | end 23 | 24 | Conn.include(NotFound) 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/web_pipe/extensions/params/params.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "web_pipe/extensions/params/params/transf" 4 | 5 | # :nodoc: 6 | module WebPipe 7 | # See the docs for the extension linked from the README. 8 | module Params 9 | # Key where configured transformations are set 10 | PARAM_TRANSFORMATION_KEY = :param_transformations 11 | 12 | # @param transformation_specs [Array, Types::Undefined] 13 | # @return [Any] 14 | def params(transformation_specs = Types::Undefined) 15 | specs = if transformation_specs == Types::Undefined 16 | fetch_config(PARAM_TRANSFORMATION_KEY, []) 17 | else 18 | transformation_specs 19 | end 20 | transformations = specs.reduce(Transf[:id]) do |acc, t| 21 | acc >> transformation(t) 22 | end 23 | 24 | Transf[transformations].(request.params) 25 | end 26 | 27 | private 28 | 29 | def transformation(spec) 30 | transformation = Transf[*spec] 31 | if (transformation.fn.arity - transformation.args.count) == 1 32 | transformation 33 | else 34 | Transf[spec, self] 35 | end 36 | end 37 | end 38 | 39 | Conn.include(Params) 40 | end 41 | -------------------------------------------------------------------------------- /lib/web_pipe/extensions/params/params/transf.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "dry/transformer" 4 | 5 | module WebPipe 6 | module Params 7 | # Parameter transformations from dry-transformer. 8 | module Transf 9 | extend Dry::Transformer::Registry 10 | 11 | import Dry::Transformer::HashTransformations 12 | 13 | def self.id(params) 14 | params 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/web_pipe/extensions/rails/rails.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # :nodoc: 4 | module WebPipe 5 | # See the docs for the extension linked from the README. 6 | module Rails 7 | def render(*args) 8 | set_response_body( 9 | rails_controller.renderer.render(*args) 10 | ) 11 | end 12 | 13 | # @see https://devdocs.io/rails~6.0/actioncontroller/helpers 14 | def helpers 15 | rails_controller.helpers 16 | end 17 | 18 | # @see https://api.rubyonrails.org/v6.0.1/classes/ActionView/Helpers/UrlHelper.html 19 | def url_helpers 20 | ::Rails.application.routes.url_helpers 21 | end 22 | 23 | private 24 | 25 | def rails_controller 26 | config.fetch(:rails_controller, ActionController::Base) 27 | end 28 | end 29 | 30 | Conn.include(Rails) 31 | end 32 | -------------------------------------------------------------------------------- /lib/web_pipe/extensions/redirect/redirect.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # :nodoc: 4 | module WebPipe 5 | # See the docs for the extension linked from the README. 6 | module Redirect 7 | # Location header 8 | LOCATION_HEADER = "Location" 9 | 10 | # Valid type for a redirect status code 11 | RedirectCode = Types::Strict::Integer.constrained(gteq: 300, lteq: 399) 12 | 13 | # @param location [String] 14 | # @param code [Integer] 15 | def redirect(location, code = 302) 16 | add_response_header(LOCATION_HEADER, location) 17 | .set_status(RedirectCode[code]) 18 | end 19 | end 20 | 21 | Conn.include(Redirect) 22 | end 23 | -------------------------------------------------------------------------------- /lib/web_pipe/extensions/router_params/router_params.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | WebPipe.load_extensions(:params) 4 | 5 | module WebPipe 6 | # See the docs for the extension linked from the README. 7 | module RouterParams 8 | ROUTER_PARAM_KEY = "router.params" 9 | 10 | # @param params [Hash] 11 | # @param conn [WebPipe::Conn] 12 | # 13 | # @return [Hash] 14 | def self.call(params, conn) 15 | params.merge(conn.env.fetch(ROUTER_PARAM_KEY, Types::EMPTY_HASH)) 16 | end 17 | 18 | WebPipe::Params::Transf.register(:router_params, method(:call)) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/web_pipe/extensions/session/session.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rack" 4 | 5 | # :nodoc: 6 | module WebPipe 7 | # See the docs for the extension linked from the README. 8 | module Session 9 | # Type for session keys. 10 | SESSION_KEY = Types::Strict::String 11 | 12 | # Returns Rack::Session's hash 13 | # 14 | # @return [Rack::Session::Abstract::SessionHash] 15 | def session 16 | env.fetch(Rack::RACK_SESSION) do 17 | raise ConnSupport::MissingMiddlewareError.new( 18 | "session", "Rack::Session", "https://www.rubydoc.info/github/rack/rack/Rack/Session" 19 | ) 20 | end 21 | end 22 | 23 | # Fetches given key from the session. 24 | # 25 | # @param key [SESSION_KEY[]] Session key to fetch 26 | # @param default [Any] Default value if key is not found 27 | # @yieldreturn Default value if key is not found and default is not given 28 | # @raise KeyError When key is not found and not default nor block are given 29 | # @return [Any] 30 | def fetch_session(*args, &block) 31 | SESSION_KEY[args[0]] 32 | session.fetch(*args, &block) 33 | end 34 | 35 | # Adds given key/value pair to the session. 36 | # 37 | # @param key [SESSION_KEY[]] Session key 38 | # @param value [Any] Value 39 | # @return [Conn] 40 | def add_session(key, value) 41 | session[SESSION_KEY[key]] = value 42 | self 43 | end 44 | 45 | # Deletes given key form the session. 46 | # 47 | # @param key [SESSION_KEY[]] Session key 48 | # @return [Conn] 49 | def delete_session(key) 50 | session.delete(SESSION_KEY[key]) 51 | self 52 | end 53 | 54 | # Deletes everything from the session. 55 | # 56 | # @return [Conn] 57 | def clear_session 58 | session.clear 59 | self 60 | end 61 | end 62 | 63 | Conn.include(Session) 64 | end 65 | -------------------------------------------------------------------------------- /lib/web_pipe/extensions/url/url.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # :nodoc: 4 | module WebPipe 5 | # See the docs for the extension linked from the README. 6 | module Url 7 | # Base part of the URL. 8 | # 9 | # This is {#scheme} and {#host}, adding {#port} unless it is the 10 | # default one for the scheme. 11 | # 12 | # @return [String] 13 | # 14 | # @example 15 | # 'https://example.org' 16 | # 'http://example.org:8000' 17 | def base_url 18 | request.base_url 19 | end 20 | 21 | # URL path. 22 | # 23 | # This is {#script_name} and {#path_info}. 24 | # 25 | # @return [String] 26 | # 27 | # @example 28 | # 'index.rb/users' 29 | def path 30 | request.path 31 | end 32 | 33 | # URL full path. 34 | # 35 | # This is {#path} with {#query_string} if present. 36 | # 37 | # @return [String] 38 | # 39 | # @example 40 | # '/users?id=1' 41 | def full_path 42 | request.fullpath 43 | end 44 | 45 | # Request URL. 46 | # 47 | # This is the same as {#base_url} plus {#full_path}. 48 | # 49 | # @return [String] 50 | # 51 | # @example 52 | # 'http://www.example.org:8000/users?id=1' 53 | def url 54 | request.url 55 | end 56 | end 57 | 58 | Conn.include(Url) 59 | end 60 | -------------------------------------------------------------------------------- /lib/web_pipe/pipe.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module WebPipe 4 | # Composable rack application builder. 5 | # 6 | # An instance of this class helps build rack applications that can compose. 7 | # Besides the DSL, which only adds a convenience layer, this is the higher 8 | # abstraction on the library. 9 | # 10 | # Applications are built by plugging functions that take and return a 11 | # {WebPipe::Conn} instance. That's an immutable struct that contains all the 12 | # request information alongside methods to build the response. See {#plug} for 13 | # details. 14 | # 15 | # Middlewares can also be added to the resulting application thanks to {#use}. 16 | # 17 | # Be aware that instances of this class are immutable, so methods return new 18 | # objects every time. 19 | # 20 | # The instance itself is the final rack application. 21 | # 22 | # @example 23 | # # config.ru 24 | # app = WebPipe::Pipe.new 25 | # .use(:runtime, Rack::Runtime) 26 | # .plug(:content_type) do |conn| 27 | # conn.add_response_header('Content-Type', 'text/plain') 28 | # end 29 | # .plug(:render) do |conn| 30 | # conn.set_response_body('Hello, World!') 31 | # end 32 | # 33 | # run app 34 | class Pipe 35 | # @!attribute [r] context 36 | # Object from where resolve operations. See {#plug}. 37 | attr_reader :context 38 | 39 | # @api private 40 | EMPTY_PLUGS = [].freeze 41 | 42 | # @api private 43 | EMPTY_MIDDLEWARE_SPECIFICATIONS = [].freeze 44 | 45 | # @api private 46 | attr_reader :plugs 47 | 48 | # @api private 49 | attr_reader :middleware_specifications 50 | 51 | # @param context [Any] Object from where resolve plug's operations (see 52 | # {#plug}) 53 | def initialize( 54 | context: nil, 55 | plugs: EMPTY_PLUGS, 56 | middleware_specifications: EMPTY_MIDDLEWARE_SPECIFICATIONS 57 | ) 58 | @plugs = plugs 59 | @middleware_specifications = middleware_specifications 60 | @context = context 61 | end 62 | 63 | # Names and adds a plug operation to the application. 64 | # 65 | # The operation can be provided in several ways: 66 | # 67 | # - Through the `spec` parameter as: 68 | # - Anything responding to `#call` (like a {Proc}). 69 | # - Anything responding to `#to_proc` (like another {WebPipe::Pipe} 70 | # instance or an instance of a class including {WebPipe}). 71 | # - As `nil` (default), meaning that the operation is a method in 72 | # {#context} matching the `name` parameter. 73 | # - Through a block, if the `spec` parameter is `nil`. 74 | # 75 | # @param name [Symbol] 76 | # @param spec [#call, #to_proc, String, Symbol, nil] 77 | # @yieldparam [WebPipe::Conn] 78 | # 79 | # @return [WebPipe::Pipe] A fresh new instance with the added plug. 80 | def plug(name, spec = nil, &block_spec) 81 | with( 82 | plugs: [ 83 | *plugs, 84 | Plug.new(name: name, spec: spec || block_spec) 85 | ] 86 | ) 87 | end 88 | 89 | # Names and adds a rack middleware to the final application. 90 | # 91 | # The middleware can be given in three forms: 92 | # 93 | # - As one or two arguments, the first one being a 94 | # rack middleware class, and optionally a second one with its initialization 95 | # options. 96 | # - As something responding to `#to_middlewares` with an array of 97 | # {WebPipe::RackSupport::Middleware} (like another {WebPipe::Pipe} instance 98 | # or a class including {WebPipe}), case in which all middlewares are used. 99 | # 100 | # @overload use(name, middleware_class) 101 | # @param name [Symbol] 102 | # @param middleware_class [Class] 103 | # @overload use(name, middleware_class, middleware_options) 104 | # @param name [Symbol] 105 | # @param middleware_class [Class] 106 | # @param middleware_options [Any] 107 | # @overload use(name, to_middlewares) 108 | # @param name [Symbol] 109 | # @param middleware_class [#to_middlewares] 110 | # 111 | # @return [WebPipe::Pipe] A fresh new instance with the added middleware. 112 | def use(name, *spec) 113 | with( 114 | middleware_specifications: [ 115 | *middleware_specifications, 116 | RackSupport::MiddlewareSpecification.new(name: name, spec: spec) 117 | ] 118 | ) 119 | end 120 | 121 | # Shortcut for {#plug} and {#use} a pipe at once. 122 | # 123 | # @param name [#to_sym] 124 | # @param spec [#to_proc#to_middlewares] 125 | def compose(name, spec) 126 | use(name, spec) 127 | .plug(name, spec) 128 | end 129 | 130 | # Operations {#plug}ged to the app, mapped by their names. 131 | # 132 | # @return [Hash{Symbol => Proc}] 133 | def operations 134 | @operations ||= Hash[ 135 | plugs.map { |plug| [plug.name, plug.(context)] } 136 | ] 137 | end 138 | 139 | # Middlewares {#use}d in the app, mapped by their names. 140 | # 141 | # Returns them wrapped within {WebPipe::RackSupport::Middleware} instances, 142 | # from where you can access their classes and options. 143 | # 144 | # @return [Hash{Symbol=>Array}] 145 | def middlewares 146 | @middlewares ||= Hash[ 147 | middleware_specifications.map { |mw_spec| [mw_spec.name, mw_spec.()] } 148 | ] 149 | end 150 | 151 | # @api private 152 | def to_proc 153 | ConnSupport::Composition 154 | .new(operations.values) 155 | .method(:call) 156 | end 157 | 158 | # @api private 159 | def to_middlewares 160 | middlewares.values.flatten 161 | end 162 | 163 | # @api private 164 | def inject(plugs: {}, middleware_specifications: {}) 165 | res_mw_specs = RackSupport::MiddlewareSpecification.inject( 166 | self.middleware_specifications, middleware_specifications 167 | ) 168 | res_plugs = Plug.inject( 169 | self.plugs, plugs 170 | ) 171 | with( 172 | plugs: res_plugs, 173 | middleware_specifications: res_mw_specs 174 | ) 175 | end 176 | 177 | # @api private 178 | def call(env) 179 | rack_app.(env) 180 | end 181 | 182 | private 183 | 184 | def app 185 | App.new(operations.values).freeze 186 | end 187 | 188 | def rack_app 189 | RackSupport::AppWithMiddlewares.new( 190 | to_middlewares, 191 | app 192 | ).freeze 193 | end 194 | 195 | def with(plugs: nil, middleware_specifications: nil) 196 | self.class.new( 197 | context: context, 198 | middleware_specifications: middleware_specifications || 199 | self.middleware_specifications, 200 | plugs: plugs || self.plugs 201 | ) 202 | end 203 | end 204 | end 205 | -------------------------------------------------------------------------------- /lib/web_pipe/plug.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "dry/struct" 4 | 5 | module WebPipe 6 | # @api private 7 | class Plug < Dry::Struct 8 | # Raised when the specification for an operation is invalid. 9 | class InvalidPlugError < ArgumentError 10 | def initialize(name) 11 | super( 12 | <<~MSG 13 | Plug with name +#{name}+ can't be resolved. You must provide 14 | something responding to `#call` or `#to_proc` 15 | . If nothing is given, it's expected to be a method 16 | defined in the context object. 17 | MSG 18 | ) 19 | end 20 | end 21 | Name = Types::Strict::Symbol.constructor(&:to_sym) 22 | 23 | Spec = Types.Interface(:call) | 24 | Types.Interface(:to_proc) | 25 | Types.Constant(nil) | 26 | Types::Strict::String | 27 | Types::Strict::Symbol 28 | 29 | Injections = Types::Strict::Hash.map( 30 | Name, Spec 31 | ).default(Types::EMPTY_HASH) 32 | 33 | attribute :name, Name 34 | 35 | attribute :spec, Spec 36 | 37 | def with(new_spec) 38 | new(spec: new_spec) 39 | end 40 | 41 | def call(context) 42 | if spec.respond_to?(:to_proc) 43 | spec.to_proc 44 | elsif spec.respond_to?(:call) 45 | spec 46 | elsif spec.nil? 47 | context.method(name) 48 | else 49 | raise InvalidPlugError, name 50 | end 51 | end 52 | 53 | def self.inject(plugs, injections) 54 | plugs.map do |plug| 55 | inject_plug(plug, injections) 56 | end 57 | end 58 | 59 | def self.inject_plug(plug, injections) 60 | name = plug.name 61 | if injections.key?(name) 62 | plug.with(injections[name]) 63 | else 64 | plug 65 | end 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /lib/web_pipe/plugs.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module WebPipe 4 | # Namespace for builders of operations on {WebPipe::Conn}. 5 | # 6 | # Plugs are just higher order functions: functions which return functions 7 | # (operations). For this reason, as a convention its interface is also 8 | # `#call`. 9 | module Plugs 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/web_pipe/plugs/config.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module WebPipe 4 | module Plugs 5 | # Adds given pairs to {Conn#config}. 6 | # 7 | # @example 8 | # class App 9 | # include WebPipe 10 | # 11 | # plug :config, WebPipe::Plugs::Config.(foo: :bar) 12 | # end 13 | module Config 14 | def self.call(pairs) 15 | lambda do |conn| 16 | conn.new( 17 | config: conn.config.merge(pairs) 18 | ) 19 | end 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/web_pipe/plugs/content_type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module WebPipe 4 | module Plugs 5 | # Sets `Content-Type` response header. 6 | # 7 | # @example 8 | # class App 9 | # include WebPipe 10 | # 11 | # plug :html, WebPipe::Plugs::ContentType.call('text/html') 12 | # end 13 | module ContentType 14 | # Content-Type header 15 | HEADER = "Content-Type" 16 | 17 | def self.call(content_type) 18 | ->(conn) { conn.add_response_header(HEADER, content_type) } 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/web_pipe/rack_support/app_with_middlewares.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rack" 4 | 5 | module WebPipe 6 | module RackSupport 7 | # @api private 8 | class AppWithMiddlewares 9 | attr_reader :rack_middlewares, :app, :builder 10 | 11 | def initialize(rack_middlewares, app) 12 | @rack_middlewares = rack_middlewares 13 | @app = app 14 | @builder = build_rack_app(rack_middlewares, app) 15 | end 16 | 17 | def call(env) 18 | builder.(env) 19 | end 20 | 21 | private 22 | 23 | def build_rack_app(rack_middlewares, app) 24 | Rack::Builder.new.tap do |b| 25 | rack_middlewares.each do |middleware| 26 | b.use(middleware.middleware, *middleware.options) 27 | end 28 | b.run(app) 29 | end 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/web_pipe/rack_support/middleware.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "dry/struct" 4 | 5 | module WebPipe 6 | module RackSupport 7 | # Wrapper for a rack middleware. 8 | # 9 | # Simple data structure to represent a rack middleware class with 10 | # its initialization options. 11 | class Middleware < Dry::Struct 12 | # Type for a rack middleware class. 13 | MiddlewareClass = Types.Instance(Class) 14 | 15 | # Type for the options to initialize a rack middleware. 16 | Options = Types::Strict::Array 17 | 18 | # @!attribute [r] middleware 19 | # @return [MiddlewareClass[]] Rack middleware 20 | attribute :middleware, MiddlewareClass 21 | 22 | # @!attribute [r] options 23 | # @return [Options[]] Options to initialize the rack middleware 24 | attribute :options, Options 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/web_pipe/rack_support/middleware_specification.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "dry/struct" 4 | 5 | module WebPipe 6 | module RackSupport 7 | # @api private 8 | class MiddlewareSpecification < Dry::Struct 9 | Name = Types::Strict::Symbol.constructor(&:to_sym) 10 | 11 | Spec = Types::Strict::Array 12 | 13 | Injections = Types::Strict::Hash.map( 14 | Name, Spec 15 | ).default(Types::EMPTY_HASH) 16 | 17 | attribute :name, Name 18 | 19 | attribute :spec, Spec 20 | 21 | def self.inject(middleware_specifications, injections) 22 | middleware_specifications.map do |middleware_spec| 23 | inject_middleware(middleware_spec, injections) 24 | end 25 | end 26 | 27 | def self.inject_middleware(middleware_spec, injections) 28 | name = middleware_spec.name 29 | if injections.key?(name) 30 | middleware_spec.with(injections[name]) 31 | else 32 | middleware_spec 33 | end 34 | end 35 | 36 | def call 37 | klass_or_pipe = spec[0] 38 | options = spec[1..] || Types::EMPTY_ARRAY 39 | if klass_or_pipe.respond_to?(:to_middlewares) 40 | klass_or_pipe.to_middlewares 41 | elsif klass_or_pipe.is_a?(Class) 42 | [Middleware.new(middleware: klass_or_pipe, options: options)] 43 | end 44 | end 45 | 46 | def with(new_spec) 47 | new(spec: new_spec) 48 | end 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/web_pipe/test_support.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rack/mock" 4 | 5 | module WebPipe 6 | # Test helper methods. 7 | # 8 | # This module is meant to be included in a test file to provide helper 9 | # methods. 10 | module TestSupport 11 | # Builds a {WebPipe::Conn} 12 | # 13 | # @param uri [String] URI that will be used to populate the request 14 | # attributes 15 | # @param attributes [Hash] Manually set attributes for the 16 | # struct. It overrides what is taken from the `uri` parameter 17 | # @param env_opts [Hash] Options to be added to the `env` from which the 18 | # connection struct is created. See {Rack::MockRequest.env_for}. 19 | # @return [Conn] 20 | def build_conn(uri = "", attributes: {}, env_opts: {}) 21 | env = Rack::MockRequest.env_for(uri, env_opts) 22 | ConnSupport::Builder.(env) 23 | .new(attributes) 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/web_pipe/types.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "dry/types" 4 | require "dry/core/constants" 5 | 6 | module WebPipe 7 | # Namespace for generic types. 8 | module Types 9 | include Dry.Types() 10 | include Dry::Core::Constants 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/web_pipe/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module WebPipe 4 | VERSION = "0.16.0" 5 | end 6 | -------------------------------------------------------------------------------- /spec/dsl/composition_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "support/conn" 5 | require "support/middlewares" 6 | 7 | RSpec.describe "Composition" do 8 | let(:pipe) do 9 | Class.new do 10 | # rubocop:disable Lint/ConstantDefinitionInBlock 11 | class App 12 | include WebPipe 13 | 14 | use :first_name, FirstNameMiddleware 15 | 16 | plug :gretting, ->(conn) { conn.add(:greeting, "Hello") } 17 | end 18 | # rubocop:enable Lint/ConstantDefinitionInBlock 19 | 20 | include WebPipe 21 | 22 | compose :app, App.new 23 | 24 | use :last_name, LastNameMiddleware, name: "Doe" 25 | plug :perform_greeting 26 | 27 | private 28 | 29 | def perform_greeting(conn) 30 | first_name = conn.env["first_name"] 31 | last_name = conn.env["last_name"] 32 | greeting = conn.fetch(:greeting) 33 | conn 34 | .set_response_body( 35 | "#{greeting} #{first_name} #{last_name}" 36 | ) 37 | end 38 | end.new 39 | end 40 | 41 | it "using a WebPipe composes its middlewares and plugs" do 42 | expect(pipe.(default_env).last[0]).to eq("Hello Joe Doe") 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /spec/dsl/dry_auto_inject_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "support/conn" 5 | require "dry/core" 6 | require "dry/auto_inject" 7 | 8 | RSpec.describe "Compatibility with dry-auto_inject" do 9 | context "when including web_pipe before dry-auto_inject" do 10 | let(:pipe_class) do 11 | Class.new do 12 | dependency = Class.new do 13 | def message 14 | "From dependency" 15 | end 16 | end 17 | 18 | container = Class.new do 19 | extend Dry::Core::Container::Mixin 20 | 21 | register :dependency do 22 | dependency.new 23 | end 24 | end 25 | 26 | import = Dry::AutoInject(container) 27 | 28 | include WebPipe 29 | include import[:dependency] 30 | 31 | plug :say_hello 32 | 33 | private 34 | 35 | def say_hello(conn) 36 | conn.set_response_body(dependency.message) 37 | end 38 | end 39 | end 40 | 41 | it "uses automatically injected dependencies" do 42 | expect( 43 | pipe_class.new.(default_env).last 44 | ).to eq(["From dependency"]) 45 | end 46 | 47 | it "can inject a dependency" do 48 | dependency = Struct.new(:message).new("From injected") 49 | 50 | expect( 51 | pipe_class.new(dependency: dependency).(default_env).last 52 | ).to eq(["From injected"]) 53 | end 54 | 55 | it "can inject a dependency and a plug" do 56 | dependency = Struct.new(:message).new("From injected") 57 | 58 | say_hello = ->(conn) { conn.set_response_body("#{dependency.message}, with plug injected") } 59 | 60 | expect( 61 | pipe_class.new(dependency: dependency, plugs: { say_hello: say_hello }).(default_env).last 62 | ).to eq(["From injected, with plug injected"]) 63 | end 64 | end 65 | 66 | context "when including web_pipe after dry-auto_inject" do 67 | let(:pipe_class) do 68 | Class.new do 69 | dependency = Class.new do 70 | def message 71 | "From dependency" 72 | end 73 | end 74 | 75 | container = Class.new do 76 | extend Dry::Core::Container::Mixin 77 | 78 | register :dependency do 79 | dependency.new 80 | end 81 | end 82 | 83 | import = Dry::AutoInject(container) 84 | 85 | include import[:dependency] 86 | include WebPipe 87 | 88 | plug :say_hello 89 | 90 | private 91 | 92 | def say_hello(conn) 93 | conn.set_response_body(dependency.message) 94 | end 95 | end 96 | end 97 | 98 | it "uses automatically injected dependencies" do 99 | expect( 100 | pipe_class.new.(default_env).last 101 | ).to eq(["From dependency"]) 102 | end 103 | 104 | it "can inject a dependency" do 105 | dependency = Struct.new(:message).new("From injected") 106 | 107 | expect( 108 | pipe_class.new(dependency: dependency).(default_env).last 109 | ).to eq(["From injected"]) 110 | end 111 | 112 | it "can inject a dependency and a plug" do 113 | dependency = Struct.new(:message).new("From injected") 114 | 115 | say_hello = ->(conn) { conn.set_response_body("#{dependency.message}, with plug injected") } 116 | 117 | expect( 118 | pipe_class.new(dependency: dependency, plugs: { say_hello: say_hello }).(default_env).last 119 | ).to eq(["From injected, with plug injected"]) 120 | end 121 | end 122 | end 123 | -------------------------------------------------------------------------------- /spec/dsl/inspecting_middlewares_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "support/conn" 5 | require "support/middlewares" 6 | 7 | RSpec.describe "Inspecting middlewares" do 8 | let(:pipe_class) do 9 | Class.new do 10 | include WebPipe 11 | 12 | use :first_name, FirstNameMiddleware 13 | 14 | plug :hello do |conn| 15 | conn.set_response_body("Hello") 16 | end 17 | end 18 | end 19 | 20 | it "can inspect resolved middlewares" do 21 | pipe = pipe_class.new 22 | 23 | expect(pipe.middlewares[:first_name][0].middleware).to be(FirstNameMiddleware) 24 | end 25 | 26 | it "can inspect injected middlewares" do 27 | last_name = [LastNameMiddleware, { name: "Smith" }] 28 | pipe = pipe_class.new(middlewares: { first_name: last_name }) 29 | 30 | expect(pipe.middlewares[:first_name][0].middleware).to be(LastNameMiddleware) 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /spec/dsl/inspecting_operations_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "support/conn" 5 | 6 | RSpec.describe "Inspecting operations" do 7 | let(:pipe_class) do 8 | Class.new do 9 | include WebPipe 10 | 11 | plug :one, ->(conn) { conn.set_response_body("One") } 12 | end 13 | end 14 | 15 | it "can inspect resolved operations" do 16 | pipe = pipe_class.new 17 | conn = build_conn(default_env) 18 | 19 | expect( 20 | pipe.operations[:one].(conn).response_body 21 | ).to eq(["One"]) 22 | end 23 | 24 | it "can inspect injected operations" do 25 | two = ->(conn) { conn.set_response_body("Two") } 26 | pipe = pipe_class.new(plugs: { one: two }) 27 | conn = build_conn(default_env) 28 | 29 | expect( 30 | pipe.operations[:one].(conn).response_body 31 | ).to eq(["Two"]) 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /spec/dsl/middleware_composition_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "support/conn" 5 | require "support/middlewares" 6 | 7 | RSpec.describe "Middleware composition" do 8 | let(:pipe) do 9 | Class.new do 10 | # rubocop:disable Lint/ConstantDefinitionInBlock 11 | class AppWithMiddlewares 12 | include WebPipe 13 | 14 | use :first_name, FirstNameMiddleware 15 | use :last_name, LastNameMiddleware, name: "Doe" 16 | end 17 | # rubocop:enable Lint/ConstantDefinitionInBlock 18 | 19 | include WebPipe 20 | 21 | use :app, AppWithMiddlewares.new 22 | 23 | plug :hello 24 | 25 | private 26 | 27 | def hello(conn) 28 | first_name = conn.env["first_name"] 29 | last_name = conn.env["last_name"] 30 | conn 31 | .set_response_body( 32 | "Hello #{first_name} #{last_name}" 33 | ) 34 | end 35 | end.new 36 | end 37 | 38 | it "using a WebPipe composes its middlewares" do 39 | expect(pipe.(default_env).last[0]).to eq("Hello Joe Doe") 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /spec/dsl/middleware_injection_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "support/conn" 5 | require "support/middlewares" 6 | 7 | RSpec.describe "Injecting middlewares" do 8 | let(:pipe) do 9 | Class.new do 10 | include WebPipe 11 | 12 | use :last_name, LastNameMiddleware, name: "Doe" 13 | 14 | plug :hello 15 | 16 | private 17 | 18 | def hello(conn) 19 | last_name = conn.env["last_name"] 20 | conn 21 | .set_response_body( 22 | "Hello Mr./Ms. #{last_name}" 23 | ) 24 | end 25 | end.new(middlewares: { last_name: [LastNameMiddleware, { name: "Smith" }] }) 26 | end 27 | 28 | it "can use middlewares" do 29 | expect(pipe.(default_env).last[0]).to eq("Hello Mr./Ms. Smith") 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /spec/dsl/middleware_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "support/conn" 5 | require "support/middlewares" 6 | 7 | RSpec.describe "Using rack middlewares" do 8 | let(:pipe) do 9 | Class.new do 10 | include WebPipe 11 | 12 | use :first_name, FirstNameMiddleware 13 | use :last_name, LastNameMiddleware, name: "Doe" 14 | 15 | plug :hello 16 | 17 | private 18 | 19 | def hello(conn) 20 | first_name = conn.env["first_name"] 21 | last_name = conn.env["last_name"] 22 | conn 23 | .set_response_body( 24 | "Hello #{first_name} #{last_name}" 25 | ) 26 | end 27 | end.new 28 | end 29 | 30 | it "can use middlewares" do 31 | expect(pipe.(default_env).last[0]).to eq("Hello Joe Doe") 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /spec/dsl/overriding_instance_methods_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "support/conn" 5 | 6 | RSpec.describe "Overriding instance methods" do 7 | it "can define custom initialize and call super" do 8 | pipe = Class.new do 9 | include WebPipe 10 | 11 | attr_reader :greeting 12 | 13 | def initialize(greeting:, **kwargs) 14 | @greeting = greeting 15 | super(**kwargs) 16 | end 17 | 18 | plug :name 19 | 20 | plug :render 21 | 22 | private 23 | 24 | def name 25 | raise NotImplementedError 26 | end 27 | 28 | def render(conn) 29 | conn.set_response_body(greeting + conn.fetch(:name)) 30 | end 31 | end.new(greeting: "Hello, ", plugs: { name: ->(conn) { conn.add(:name, "Alice") } }) 32 | 33 | expect(pipe.(default_env).last[0]).to eq("Hello, Alice") 34 | end 35 | 36 | it "can define custom pipe methods and call super" do 37 | pipe = Class.new do 38 | include WebPipe 39 | 40 | plug :render 41 | 42 | def call(env) 43 | env["body"] = "Hello, world!" 44 | super(env) 45 | end 46 | 47 | private 48 | 49 | def render(conn) 50 | conn.set_response_body(conn.env["body"]) 51 | end 52 | end.new 53 | 54 | expect(pipe.(default_env).last[0]).to eq("Hello, world!") 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /spec/dsl/plug_chaining_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "support/conn" 5 | 6 | RSpec.describe "Chaining plugs" do 7 | let(:pipe) do 8 | Class.new do 9 | include WebPipe 10 | 11 | plug :one 12 | plug :two 13 | 14 | private 15 | 16 | def one(conn) 17 | conn.set_response_body("One") 18 | end 19 | 20 | def two(conn) 21 | conn.set_response_body( 22 | "#{conn.response_body[0]}Two" 23 | ) 24 | end 25 | end.new 26 | end 27 | 28 | it "chains successful plugs" do 29 | expect(pipe.(default_env).last).to eq(["OneTwo"]) 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /spec/dsl/plug_composition_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "support/conn" 5 | 6 | RSpec.describe "Plug composition" do 7 | let(:pipe) do 8 | Class.new do 9 | # rubocop:disable Lint/ConstantDefinitionInBlock 10 | class One 11 | include WebPipe 12 | 13 | plug :one 14 | 15 | private 16 | 17 | def one(conn) 18 | conn.set_response_body("One") 19 | end 20 | end 21 | # rubocop:enable Lint/ConstantDefinitionInBlock 22 | 23 | include WebPipe 24 | 25 | plug :one, One.new 26 | plug :two 27 | 28 | private 29 | 30 | def two(conn) 31 | conn.set_response_body( 32 | "#{conn.response_body[0]}Two" 33 | ) 34 | end 35 | end.new 36 | end 37 | 38 | it "plugging a WebPipe composes its plug operations" do 39 | expect(pipe.(default_env).last).to eq(["OneTwo"]) 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /spec/dsl/plug_from_block_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "support/conn" 5 | 6 | RSpec.describe "Resolving plugs from a block" do 7 | let(:pipe) do 8 | Class.new do 9 | include WebPipe 10 | 11 | plug :hello do |conn| 12 | conn.set_response_body("Hello, world!") 13 | end 14 | end.new 15 | end 16 | 17 | it "can resolve operation from a block" do 18 | expect(pipe.(default_env).last).to eq(["Hello, world!"]) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /spec/dsl/plug_from_method_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "support/conn" 5 | 6 | RSpec.describe "Resolving plugs from a method" do 7 | let(:pipe) do 8 | Class.new do 9 | include WebPipe 10 | 11 | plug :hello 12 | 13 | def hello(conn) 14 | conn.set_response_body("Hello, world!") 15 | end 16 | end.new 17 | end 18 | 19 | it "can resolve operation from an internal method" do 20 | expect(pipe.(default_env).last).to eq(["Hello, world!"]) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /spec/dsl/plug_halting_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "support/conn" 5 | 6 | RSpec.describe "Plug halting" do 7 | let(:pipe) do 8 | Class.new do 9 | include WebPipe 10 | 11 | plug :halt 12 | plug :ongoing 13 | 14 | private 15 | 16 | def halt(conn) 17 | conn.set_response_body("Halted").halt 18 | end 19 | 20 | def ongoing(conn) 21 | conn.set_response_body("Ongoing") 22 | end 23 | end.new 24 | end 25 | 26 | it "halting plug stops the pipe" do 27 | expect(pipe.(default_env).last).to eq(["Halted"]) 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /spec/dsl/plug_injection_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "support/conn" 5 | 6 | RSpec.describe "Plug injection" do 7 | let(:pipe) do 8 | Class.new do 9 | include WebPipe 10 | 11 | plug :hello, ->(conn) { conn.set_response_body("Hello, world!") } 12 | end 13 | end 14 | let(:hello) { ->(conn) { conn.set_response_body("Hello, injected world!") } } 15 | 16 | it "can inject plug as dependency" do 17 | pipe_with_injection = pipe.new(plugs: { hello: hello }) 18 | 19 | expect( 20 | pipe_with_injection.(default_env).last 21 | ).to eq(["Hello, injected world!"]) 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/dsl/rack_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "rack/test" 5 | 6 | RSpec.describe "Rack application" do 7 | include Rack::Test::Methods 8 | 9 | let(:app) do 10 | Class.new do 11 | include WebPipe 12 | 13 | plug :hello, lambda { |conn| 14 | conn 15 | .set_response_body("Hello, world!") 16 | .set_status(200) 17 | } 18 | end.new 19 | end 20 | 21 | it "is a rack application" do 22 | get "/" 23 | 24 | expect(last_response.body).to eq("Hello, world!") 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/extensions/container/container_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "support/conn" 5 | 6 | RSpec.describe WebPipe::Conn do 7 | before do 8 | WebPipe.load_extensions(:container) 9 | end 10 | 11 | describe "#container" do 12 | it "returns config's container key" do 13 | conn = build_conn(default_env) 14 | container = {}.freeze 15 | 16 | new_conn = conn.add_config(:container, container) 17 | 18 | expect(new_conn.container).to be(container) 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/extensions/cookies/cookies_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "support/conn" 5 | 6 | RSpec.describe WebPipe::Conn do 7 | before { WebPipe.load_extensions(:cookies) } 8 | 9 | let(:conn) { build_conn(default_env) } 10 | 11 | describe "#request_cookies" do 12 | it "returns request cookies" do 13 | env = default_env.merge("HTTP_COOKIE" => "foo=bar") 14 | 15 | conn = build_conn(env) 16 | 17 | expect(conn.request_cookies).to eq("foo" => "bar") 18 | end 19 | end 20 | 21 | describe "#set_cookie" do 22 | it "sets given name/value pair to the Set-Cookie header" do 23 | conn = build_conn(default_env) 24 | 25 | new_conn = conn.set_cookie("foo", "bar") 26 | 27 | expect(new_conn.response_headers["Set-Cookie"]).to eq("foo=bar") 28 | end 29 | 30 | it "adds given options to the cookie value" do 31 | conn = build_conn(default_env) 32 | 33 | new_conn = conn.set_cookie("foo", "bar", path: "/") 34 | 35 | expect(new_conn.response_headers["Set-Cookie"]).to eq("foo=bar; path=/") 36 | end 37 | end 38 | 39 | describe "#delete_cookie" do 40 | it "marks given key/value pair cookie for deletion" do 41 | conn = build_conn(default_env) 42 | 43 | new_conn = conn.delete_cookie("foo") 44 | 45 | expect(new_conn.response_headers["Set-Cookie"]).to include( 46 | "foo=; max-age=0; expires=" 47 | ) 48 | end 49 | 50 | it "adds given options to the cookie value" do 51 | conn = build_conn(default_env) 52 | 53 | new_conn = conn.delete_cookie("foo", domain: "/") 54 | 55 | expect(new_conn.response_headers["Set-Cookie"]).to include( 56 | "foo=; domain=/; max-age=0; expires=" 57 | ) 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /spec/extensions/dry_schema/dry_schema_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "support/conn" 5 | 6 | RSpec.describe WebPipe::Conn do 7 | before { WebPipe.load_extensions(:dry_schema) } 8 | 9 | describe "#sanitized_params" do 10 | it "returns config's sanitized key" do 11 | conn = build_conn(default_env) 12 | sanitized_params = {}.freeze 13 | 14 | new_conn = conn.add_config(:sanitized_params, sanitized_params) 15 | 16 | expect(new_conn.fetch_config(:sanitized_params)).to be(sanitized_params) 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/extensions/dry_schema/plugs/sanitize_params_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "support/conn" 5 | require "dry/schema" 6 | require "web_pipe/extensions/dry_schema/plugs/sanitize_params" 7 | 8 | RSpec.describe WebPipe::Plugs::SanitizeParams do 9 | describe ".call" do 10 | let(:schema) do 11 | Dry::Schema.Params do 12 | required(:name) 13 | end 14 | end 15 | 16 | context "operation on success" do 17 | it "sets sanitized_params bag's key" do 18 | env = default_env.merge(Rack::QUERY_STRING => "name=Joe") 19 | conn = build_conn(env) 20 | operation = described_class.(schema) 21 | 22 | new_conn = operation.(conn) 23 | 24 | expect(new_conn.sanitized_params).to eq(name: "Joe") 25 | end 26 | end 27 | 28 | context "operation on failure" do 29 | it "uses given handler if it is injected" do 30 | configured_handler = lambda do |conn, _result| 31 | conn 32 | .set_response_body("Something went wrong") 33 | .set_status(500) 34 | .halt 35 | end 36 | injected_handler = lambda do |conn, result| 37 | conn 38 | .set_response_body(result.errors.messages.inspect) 39 | .set_status(500) 40 | .halt 41 | end 42 | conn = build_conn(default_env) 43 | .add_config(:param_sanitization_handler, configured_handler) 44 | operation = described_class.(schema, injected_handler) 45 | 46 | new_conn = operation.(conn) 47 | 48 | expect(new_conn.response_body[0]).to include("is missing") 49 | end 50 | 51 | it "uses configured handler if none is injected" do 52 | configured_handler = lambda do |conn, _result| 53 | conn 54 | .set_response_body("Something went wrong") 55 | .set_status(500) 56 | .halt 57 | end 58 | conn = build_conn(default_env) 59 | .add_config(:param_sanitization_handler, configured_handler) 60 | operation = described_class.(schema) 61 | 62 | new_conn = operation.(conn) 63 | 64 | expect(new_conn).to be_halted 65 | expect(new_conn.response_body[0]).to eq("Something went wrong") 66 | end 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /spec/extensions/flash/flash_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "support/conn" 5 | require "web_pipe/conn_support/errors" 6 | require "rack-flash" 7 | 8 | RSpec.describe WebPipe::Conn do 9 | before do 10 | WebPipe.load_extensions(:flash) 11 | end 12 | 13 | let(:flash) { Rack::Flash::FlashHash.new({}) } 14 | 15 | describe "#flash" do 16 | context "when rack-flash key is found in env" do 17 | it "returns its value" do 18 | env = default_env.merge("x-rack.flash" => flash) 19 | conn = build_conn(env) 20 | 21 | expect(conn.flash).to be(flash) 22 | end 23 | end 24 | 25 | context "when rack-flash key is not found in env" do 26 | it "raises a MissingMiddlewareError" do 27 | conn = build_conn(default_env) 28 | 29 | expect { conn.flash }.to raise_error(WebPipe::ConnSupport::MissingMiddlewareError) 30 | end 31 | end 32 | end 33 | 34 | describe "#add_flash" do 35 | it "sets given key to given value in flash" do 36 | env = default_env.merge("x-rack.flash" => flash) 37 | conn = build_conn(env) 38 | 39 | conn.add_flash(:error, "error") 40 | 41 | expect(flash[:error]).to eq("error") 42 | end 43 | end 44 | 45 | describe "#add_flash_now" do 46 | it "sets given key to given value in flash cache" do 47 | env = default_env.merge("x-rack.flash" => flash) 48 | conn = build_conn(env) 49 | 50 | conn.add_flash_now(:error, "error") 51 | 52 | expect(flash.send(:cache)[:error]).to eq("error") 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /spec/extensions/flash/integration/flash_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "support/conn" 5 | require "rack/session/cookie" 6 | require "rack-flash" 7 | 8 | RSpec.describe "Using flash" do 9 | before { WebPipe.load_extensions(:flash) } 10 | 11 | let(:pipe) do 12 | Class.new do 13 | include WebPipe 14 | 15 | use :session, Rack::Session::Cookie, secret: "secret" 16 | use :flash, Rack::Flash 17 | 18 | plug :add_to_flash 19 | plug :build_response 20 | 21 | private 22 | 23 | def add_to_flash(conn) 24 | conn 25 | .add_flash(:error, "Error") 26 | .add_flash_now(:now, "now") 27 | end 28 | 29 | def build_response(conn) 30 | conn.set_response_body( 31 | "#{conn.flash[:error]} #{conn.flash[:now]}" 32 | ) 33 | end 34 | end.new 35 | end 36 | 37 | it "can adadd and read from flash" do 38 | expect(pipe.(default_env).last[0]).to eq("Error now") 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /spec/extensions/hanami_view/fixtures/template_with_context.html.str: -------------------------------------------------------------------------------- 1 | Hello #{name} -------------------------------------------------------------------------------- /spec/extensions/hanami_view/fixtures/template_with_input.html.str: -------------------------------------------------------------------------------- 1 | Hello #{name} -------------------------------------------------------------------------------- /spec/extensions/hanami_view/fixtures/template_without_input.html.str: -------------------------------------------------------------------------------- 1 | Hello world -------------------------------------------------------------------------------- /spec/extensions/hanami_view/hanami_view_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "support/conn" 5 | require "hanami/view" 6 | require "hanami/view/context" 7 | 8 | RSpec.describe WebPipe::Conn do 9 | before do 10 | WebPipe.load_extensions(:hanami_view) 11 | end 12 | 13 | describe "#view" do 14 | let(:view_class) do 15 | Class.new(Hanami::View) do 16 | config.paths = [File.join(__dir__, "fixtures")] 17 | end 18 | end 19 | 20 | it "sets Rendered string serialization as response body" do 21 | view = Class.new(view_class) do 22 | config.template = "template_without_input" 23 | end 24 | conn = build_conn(default_env) 25 | 26 | new_conn = conn.view(view.new) 27 | 28 | expect(new_conn.response_body).to eq(["Hello world"]) 29 | end 30 | 31 | it "passes kwargs along as view's call arguments" do 32 | view = Class.new(view_class) do 33 | config.template = "template_with_input" 34 | 35 | expose :name 36 | end 37 | conn = build_conn(default_env) 38 | 39 | new_conn = conn.view(view.new, name: "Joe") 40 | 41 | expect(new_conn.response_body).to eq(["Hello Joe"]) 42 | end 43 | 44 | it "can resolve view from the container" do 45 | view = Class.new(view_class) do 46 | config.template = "template_without_input" 47 | end 48 | container = { "view" => view.new }.freeze 49 | conn = build_conn(default_env) 50 | .add_config(:container, container) 51 | 52 | new_conn = conn.view("view") 53 | 54 | expect(new_conn.response_body).to eq(["Hello world"]) 55 | end 56 | 57 | it "initializes view_context class with the generated view_context_options hash" do 58 | view = Class.new(view_class) do 59 | config.template = "template_with_input" 60 | end 61 | context_class = Class.new(Hanami::View::Context) do 62 | attr_reader :name 63 | 64 | def initialize(name:) 65 | super 66 | @name = name 67 | end 68 | end 69 | conn = build_conn(default_env) 70 | .add(:name, "Joe") 71 | .add_config(:view_context_class, context_class) 72 | .add_config(:view_context_options, ->(c) { { name: c.fetch(:name) } }) 73 | 74 | new_conn = conn.view(view.new) 75 | 76 | expect(new_conn.response_body).to eq(["Hello Joe"]) 77 | end 78 | 79 | it "respects context given at render time if given" do 80 | view = Class.new(view_class) do 81 | config.template = "template_with_input" 82 | end 83 | context = Class.new(Hanami::View::Context) do 84 | def name 85 | "Alice" 86 | end 87 | end.new 88 | conn = build_conn(default_env) 89 | 90 | new_conn = conn.view(view.new, context: context) 91 | 92 | expect(new_conn.response_body).to eq(["Hello Alice"]) 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /spec/extensions/not_found/not_found_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "support/conn" 5 | 6 | RSpec.describe WebPipe::Conn do 7 | before { WebPipe.load_extensions(:not_found) } 8 | 9 | describe "#not_found" do 10 | it "sets 404 status code" do 11 | conn = build_conn 12 | 13 | expect(conn.not_found.status).to be(404) 14 | end 15 | 16 | it "halts it" do 17 | conn = build_conn 18 | 19 | expect(conn.not_found.halted?).to be(true) 20 | end 21 | 22 | context "when no response body is configured" do 23 | it 'sets "Not found" as response body' do 24 | conn = build_conn 25 | 26 | expect(conn.not_found.response_body).to eq(["Not found"]) 27 | end 28 | end 29 | 30 | context "when a step to build the response body is configured" do 31 | it "uses it" do 32 | conn = build_conn.add_config(:not_found_body_step, 33 | ->(c) { c.set_response_body("Nothing here") }) 34 | 35 | expect(conn.not_found.response_body).to eq(["Nothing here"]) 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /spec/extensions/params/params/transf_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "web_pipe/extensions/params/params/transf" 5 | 6 | RSpec.describe WebPipe::Params::Transf do 7 | describe ".id" do 8 | it "returns same value" do 9 | expect(described_class[:id].(1)).to be(1) 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /spec/extensions/params/params_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "support/conn" 5 | 6 | RSpec.describe WebPipe::Conn do 7 | before { WebPipe.load_extensions(:params) } 8 | 9 | describe "#params" do 10 | context "without transformations" do 11 | it "returns request params" do 12 | env = default_env.merge( 13 | Rack::QUERY_STRING => "foo=bar" 14 | ) 15 | conn = build_conn(env) 16 | 17 | expect(conn.params).to eq("foo" => "bar") 18 | end 19 | end 20 | 21 | context "with transformations" do 22 | it "uses configured transformations" do 23 | env = default_env.merge( 24 | Rack::QUERY_STRING => "foo=bar&zoo=zoo" 25 | ) 26 | conn = build_conn(env) 27 | .add_config( 28 | :param_transformations, [:symbolize_keys, [:reject_keys, [:zoo]]] 29 | ) 30 | 31 | expect(conn.params).to eq(foo: "bar") 32 | end 33 | 34 | it "uses injected transformation over configured" do 35 | env = default_env.merge( 36 | Rack::QUERY_STRING => "foo=bar" 37 | ) 38 | conn = build_conn(env) 39 | .add_config( 40 | :param_transformations, [:symbolize_keys] 41 | ) 42 | 43 | expect(conn.params([:id])).to eq("foo" => "bar") 44 | end 45 | 46 | it "accepts transformations with no extra arguments" do 47 | env = default_env.merge( 48 | Rack::QUERY_STRING => "foo=bar" 49 | ) 50 | conn = build_conn(env) 51 | transformations = [:symbolize_keys] 52 | 53 | expect(conn.params(transformations)).to eq(foo: "bar") 54 | end 55 | 56 | it "accepts transformations with extra arguments" do 57 | env = default_env.merge( 58 | Rack::QUERY_STRING => "foo=bar&zoo=zoo" 59 | ) 60 | conn = build_conn(env) 61 | transformations = [[:reject_keys, ["zoo"]]] 62 | 63 | expect(conn.params(transformations)).to eq("foo" => "bar") 64 | end 65 | 66 | it "accepts transformations involving the conn instance" do 67 | WebPipe::Params::Transf.register(:from_bar, ->(value, conn) { value.merge(conn.env["bar"]) }) 68 | env = default_env.merge( 69 | Rack::QUERY_STRING => "foo=bar", 70 | "bar" => { "bar" => "foo" } 71 | ) 72 | conn = build_conn(env) 73 | transformations = [:from_bar] 74 | 75 | expect(conn.params(transformations)).to eq("foo" => "bar", "bar" => "foo") 76 | end 77 | 78 | it "accepts inline transformations" do 79 | conn = build_conn(default_env) 80 | transformations = [->(_params) { { boo: :foo } }] 81 | 82 | expect(conn.params(transformations)).to eq(boo: :foo) 83 | end 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /spec/extensions/rails/rails_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "support/conn" 5 | 6 | RSpec.describe WebPipe::Conn do 7 | before do 8 | WebPipe.load_extensions(:rails) 9 | end 10 | 11 | # rubocop:disable Lint/ConstantDefinitionInBlock 12 | module ActionController 13 | module Base 14 | def self.renderer 15 | Renderer.new 16 | end 17 | 18 | def self.helpers 19 | { 20 | helper1: "foo" 21 | } 22 | end 23 | end 24 | 25 | class Renderer 26 | def render(*args) 27 | action = args[0] 28 | case action 29 | when "show" 30 | "Show" 31 | else 32 | "Not found" 33 | end 34 | end 35 | end 36 | end 37 | # rubocop:enable Lint/ConstantDefinitionInBlock 38 | 39 | after(:all) { Object.send(:remove_const, :ActionController) } 40 | 41 | describe "#render" do 42 | it "sets rendered as response body" do 43 | conn = build_conn(default_env) 44 | 45 | new_conn = conn.render("show") 46 | 47 | expect(new_conn.response_body).to eq(["Show"]) 48 | end 49 | 50 | it "uses configured controller" do 51 | my_controller = Class.new do 52 | def self.renderer 53 | Renderer.new 54 | end 55 | 56 | # rubocop:disable Lint/ConstantDefinitionInBlock 57 | class Renderer 58 | def render(*_args) 59 | "Rendered from MyController" 60 | end 61 | end 62 | # rubocop:enable Lint/ConstantDefinitionInBlock 63 | end 64 | conn = build_conn(default_env) 65 | 66 | new_conn = conn 67 | .add_config(:rails_controller, my_controller) 68 | .render(:whatever) 69 | 70 | expect(new_conn.response_body).to eq(["Rendered from MyController"]) 71 | end 72 | end 73 | 74 | describe "#helpers" do 75 | it "returns controller helpers" do 76 | conn = build_conn(default_env) 77 | 78 | expect(conn.helpers).to eq(helper1: "foo") 79 | end 80 | 81 | it "uses configured controller" do 82 | my_controller = Class.new do 83 | def self.helpers 84 | { helper1: "bar" } 85 | end 86 | end 87 | conn = build_conn(default_env) 88 | 89 | new_conn = conn.add_config(:rails_controller, my_controller) 90 | 91 | expect(new_conn.helpers).to eq(helper1: "bar") 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /spec/extensions/redirect/redirect_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "support/conn" 5 | 6 | RSpec.describe WebPipe::Conn do 7 | before { WebPipe.load_extensions(:redirect) } 8 | 9 | describe "#redirect" do 10 | let(:conn) { build_conn(default_env) } 11 | let(:new_conn) { conn.redirect("/here") } 12 | 13 | it "uses 302 as default status code" do 14 | expect(new_conn.status).to be(302) 15 | end 16 | 17 | it "sets given path as Location header" do 18 | expect(new_conn.response_headers["Location"]).to eq("/here") 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/extensions/router_params/router_params_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "support/conn" 5 | 6 | RSpec.describe WebPipe::Params::Transf do 7 | before { WebPipe.load_extensions(:router_params) } 8 | 9 | describe ".router_params" do 10 | it "merges 'router.params' key from env into given hash" do 11 | env = default_env.merge("router.params" => { foo: :bar }) 12 | conn = build_conn(env) 13 | 14 | expect( 15 | described_class[:router_params].with(conn).(bar: :foo) 16 | ).to eq(foo: :bar, bar: :foo) 17 | end 18 | 19 | it "assumes empty hash when 'router.params' is not present" do 20 | conn = build_conn(default_env) 21 | 22 | expect( 23 | described_class[:router_params].with(conn).(bar: :foo) 24 | ).to eq(bar: :foo) 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/extensions/session/session_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "support/conn" 5 | 6 | RSpec.describe WebPipe::Conn do 7 | before { WebPipe.load_extensions(:session) } 8 | 9 | describe "#session" do 10 | context "when rack session key is found in env" do 11 | it "returns its value" do 12 | session = {} 13 | env = default_env.merge("rack.session" => session) 14 | conn = build_conn(env) 15 | 16 | expect(conn.session).to be(session) 17 | end 18 | end 19 | 20 | context "when rack session key is not found in env" do 21 | it "raises a MissingMiddlewareError" do 22 | conn = build_conn(default_env) 23 | 24 | expect { conn.session }.to raise_error(WebPipe::ConnSupport::MissingMiddlewareError) 25 | end 26 | end 27 | end 28 | 29 | describe "#fetch_session" do 30 | it "returns given item from session" do 31 | env = default_env.merge("rack.session" => { "foo" => "bar" }) 32 | conn = build_conn(env) 33 | 34 | expect(conn.fetch_session("foo")).to eq("bar") 35 | end 36 | 37 | it "returns default when not found" do 38 | env = default_env.merge("rack.session" => {}) 39 | conn = build_conn(env) 40 | 41 | expect(conn.fetch_session("foo", "bar")).to eq("bar") 42 | end 43 | 44 | it "returns what block returns when key is not found and no default is given" do 45 | env = default_env.merge("rack.session" => {}) 46 | conn = build_conn(env) 47 | 48 | expect(conn.fetch_session("foo") { "bar" }).to eq("bar") 49 | end 50 | end 51 | 52 | describe "#add_session" do 53 | it "adds given name/value pair to session" do 54 | env = default_env.merge("rack.session" => {}) 55 | conn = build_conn(env) 56 | 57 | new_conn = conn.add_session("foo", "bar") 58 | 59 | expect(new_conn.session["foo"]).to eq("bar") 60 | end 61 | end 62 | 63 | describe "#delete_session" do 64 | it "deletes given name from the session" do 65 | env = default_env.merge("rack.session" => { "foo" => "bar", "bar" => "foo" }) 66 | conn = build_conn(env) 67 | 68 | new_conn = conn.delete_session("foo") 69 | 70 | expect(new_conn.session).to eq("bar" => "foo") 71 | end 72 | end 73 | 74 | describe "#clear_session" do 75 | it "resets session" do 76 | env = default_env.merge("rack.session" => { "foo" => "bar" }) 77 | conn = build_conn(env) 78 | 79 | new_conn = conn.clear_session 80 | 81 | expect(new_conn.session).to eq({}) 82 | end 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /spec/extensions/url/url_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "support/conn" 5 | 6 | RSpec.describe WebPipe::Conn do 7 | before { WebPipe.load_extensions(:url) } 8 | 9 | def build(env) 10 | build_conn(env) 11 | end 12 | 13 | describe "#base_url" do 14 | it "returns request base url" do 15 | env = default_env.merge( 16 | Rack::HTTPS => "on", 17 | Rack::HTTP_HOST => "www.host.org:8000" 18 | ) 19 | 20 | conn = build(env) 21 | 22 | expect(conn.base_url).to eq("https://www.host.org:8000") 23 | end 24 | end 25 | 26 | describe "#path" do 27 | it "returns request path" do 28 | env = default_env.merge( 29 | Rack::SCRIPT_NAME => "index.rb", 30 | Rack::PATH_INFO => "/foo" 31 | ) 32 | 33 | conn = build(env) 34 | 35 | expect(conn.path).to eq("index.rb/foo") 36 | end 37 | end 38 | 39 | describe "#full_path" do 40 | it "returns request fullpath" do 41 | env = default_env.merge( 42 | Rack::PATH_INFO => "/foo", 43 | Rack::QUERY_STRING => "foo=bar" 44 | ) 45 | 46 | conn = build(env) 47 | 48 | expect(conn.full_path).to eq("/foo?foo=bar") 49 | end 50 | end 51 | 52 | describe "#url" do 53 | it "returns request url" do 54 | env = default_env.merge( 55 | Rack::HTTPS => "on", 56 | Rack::HTTP_HOST => "www.host.org:8000", 57 | Rack::PATH_INFO => "/home", 58 | Rack::QUERY_STRING => "foo=bar" 59 | ) 60 | 61 | conn = build(env) 62 | 63 | expect(conn.url).to eq("https://www.host.org:8000/home?foo=bar") 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "web_pipe" 4 | require "pry-byebug" 5 | require "simplecov" 6 | 7 | unless ENV["NO_COVERAGE"] 8 | SimpleCov.start do 9 | add_filter %r{^/spec/} 10 | enable_coverage :branch 11 | enable_coverage_for_eval 12 | end 13 | end 14 | 15 | # https://github.com/dry-rb/dry-configurable/issues/70 16 | WebPipe.load_extensions( 17 | *WebPipe.instance_variable_get(:@__available_extensions__).keys 18 | ) 19 | 20 | RSpec.configure do |config| 21 | # Enable flags like --only-failures and --next-failure 22 | config.example_status_persistence_file_path = ".rspec_status" 23 | 24 | # Disable RSpec exposing methods globally on `Module` and `main` 25 | config.disable_monkey_patching! 26 | 27 | config.expect_with :rspec do |c| 28 | c.syntax = :expect 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/support/conn.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rack" 4 | 5 | # Minimal rack's env. 6 | # 7 | # @return [Hash] 8 | # rubocop:disable Metrics/MethodLength 9 | def default_env 10 | { 11 | Rack::RACK_VERSION => Rack::VERSION, 12 | Rack::RACK_INPUT => StringIO.new, 13 | Rack::RACK_ERRORS => StringIO.new, 14 | Rack::RACK_MULTITHREAD => true, 15 | Rack::RACK_MULTIPROCESS => true, 16 | Rack::RACK_RUNONCE => false, 17 | Rack::RACK_URL_SCHEME => "http", 18 | # PEP333 19 | Rack::REQUEST_METHOD => Rack::GET, 20 | Rack::QUERY_STRING => "", 21 | Rack::SERVER_NAME => "www.example.org", 22 | Rack::SERVER_PORT => "80" 23 | } 24 | end 25 | # rubocop:enable Metrics/MethodLength 26 | 27 | # Helper to build a `Conn` from rack's env. 28 | # 29 | # @param env [Hash] 30 | # @return Conn [WebPipe::Conn] 31 | def build_conn(env = default_env) 32 | WebPipe::ConnSupport::Builder.(env) 33 | end 34 | -------------------------------------------------------------------------------- /spec/support/middlewares.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class FirstNameMiddleware 4 | attr_reader :app 5 | 6 | def initialize(app) 7 | @app = app 8 | end 9 | 10 | def call(env) 11 | env["first_name"] = "Joe" 12 | app.(env) 13 | end 14 | end 15 | 16 | class LastNameMiddleware 17 | attr_reader :app, :name 18 | 19 | def initialize(app, opts) 20 | @app = app 21 | @name = opts[:name] 22 | end 23 | 24 | def call(env) 25 | env["last_name"] = name 26 | app.(env) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/unit/web_pipe/app_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "support/conn" 5 | 6 | RSpec.describe WebPipe::App do 7 | describe "#call" do 8 | it "chains operations on Conn" do 9 | op1 = ->(conn) { conn.set_status(200) } 10 | op2 = ->(conn) { conn.set_response_body("foo") } 11 | 12 | app = described_class.new([op1, op2]) 13 | 14 | expect(app.(default_env)).to eq([200, {}, ["foo"]]) 15 | end 16 | 17 | it "stops chain propagation once a conn is halted" do 18 | op1 = ->(conn) { conn.set_status(200) } 19 | op2 = ->(conn) { conn.set_response_body("foo") } 20 | op3 = ->(conn) { conn.halt } 21 | op4 = ->(conn) { conn.set_response_body("bar") } 22 | 23 | app = described_class.new([op1, op2, op3, op4]) 24 | 25 | expect(app.(default_env)).to eq([200, {}, ["foo"]]) 26 | end 27 | 28 | it "raises InvalidOperationReturn when one operation does not return a Conn" do 29 | op = ->(_conn) { :foo } 30 | 31 | app = described_class.new([op]) 32 | 33 | expect do 34 | app.(default_env) 35 | end.to raise_error( 36 | WebPipe::ConnSupport::Composition::InvalidOperationResult 37 | ) 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /spec/unit/web_pipe/conn_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "web_pipe/conn_support/errors" 4 | require "support/conn" 5 | require "rack" 6 | 7 | RSpec.describe WebPipe::Conn do 8 | def build(env) 9 | build_conn(env) 10 | end 11 | 12 | describe "set_status" do 13 | it "sets status" do 14 | conn = build(default_env) 15 | 16 | new_conn = conn.set_status(404) 17 | 18 | expect(new_conn.status).to be(404) 19 | end 20 | end 21 | 22 | describe "set_response_body" do 23 | context "when value is a string" do 24 | it "sets response body as one item array of given value" do 25 | conn = build(default_env).new(response_body: ["foo"]) 26 | 27 | new_conn = conn.set_response_body("bar") 28 | 29 | expect(new_conn.response_body).to eq(["bar"]) 30 | end 31 | end 32 | 33 | context "when value responds to :each" do 34 | it "it substitutes whole response_body" do 35 | conn = build(default_env).new(response_body: ["foo"]) 36 | 37 | new_conn = conn.set_response_body(%w[bar var]) 38 | 39 | expect(new_conn.response_body).to eq(%w[bar var]) 40 | end 41 | end 42 | end 43 | 44 | describe "set_response_headers" do 45 | it "sets response headers" do 46 | conn = build(default_env) 47 | 48 | new_conn = conn.set_response_headers("foo" => "bar") 49 | 50 | expect(new_conn.response_headers).to eq("Foo" => "bar") 51 | end 52 | end 53 | 54 | describe "add_response_header" do 55 | it "adds given pair to response headers" do 56 | conn = build(default_env).new( 57 | response_headers: { "Foo" => "Foo" } 58 | ) 59 | 60 | new_conn = conn.add_response_header("foo_foo", "Bar") 61 | 62 | expect( 63 | new_conn.response_headers 64 | ).to eq("Foo" => "Foo", "Foo-Foo" => "Bar") 65 | end 66 | end 67 | 68 | describe "delete_response_header" do 69 | it "deletes response header with given key" do 70 | conn = build(default_env).new( 71 | response_headers: { "Foo" => "Bar", "Zoo-Zoo" => "Zar" } 72 | ) 73 | 74 | new_conn = conn.delete_response_header("zoo_zoo") 75 | 76 | expect(new_conn.response_headers).to eq("Foo" => "Bar") 77 | end 78 | end 79 | 80 | describe "fetch" do 81 | it "returns item in bag with given key" do 82 | conn = build(default_env).new(bag: { foo: :bar }) 83 | 84 | expect(conn.fetch(:foo)).to be(:bar) 85 | end 86 | 87 | it "raises KeyNotFoundInBagError when key does not exist" do 88 | conn = build(default_env) 89 | 90 | expect do 91 | conn.fetch(:foo) 92 | end.to raise_error(WebPipe::ConnSupport::KeyNotFoundInBagError) 93 | end 94 | 95 | it "returns default when it is given and key does not exist" do 96 | conn = build(default_env) 97 | 98 | expect(conn.fetch(:foo, :bar)).to be(:bar) 99 | end 100 | end 101 | 102 | describe "add" do 103 | it "adds key/value pair to bag" do 104 | conn = build(default_env) 105 | 106 | new_conn = conn.add(:foo, :bar) 107 | 108 | expect(new_conn.bag[:foo]).to be(:bar) 109 | end 110 | end 111 | 112 | describe "fetch_config" do 113 | it "returns item in config with given key" do 114 | conn = build(default_env).new(config: { foo: :bar }) 115 | 116 | expect(conn.fetch_config(:foo)).to be(:bar) 117 | end 118 | 119 | it "raises KeyNotFoundInConfigError when key does not exist" do 120 | conn = build(default_env) 121 | 122 | expect do 123 | conn.fetch_config(:foo) 124 | end.to raise_error(WebPipe::ConnSupport::KeyNotFoundInConfigError) 125 | end 126 | 127 | it "returns default when it is given and key does not exist" do 128 | conn = build(default_env) 129 | 130 | expect(conn.fetch_config(:foo, :bar)).to be(:bar) 131 | end 132 | end 133 | 134 | describe "add" do 135 | it "adds key/value pair to config" do 136 | conn = build(default_env) 137 | 138 | new_conn = conn.add_config(:foo, :bar) 139 | 140 | expect(new_conn.config[:foo]).to be(:bar) 141 | end 142 | end 143 | 144 | describe "#rack_response" do 145 | let(:env) { default_env.merge(Rack::RACK_SESSION => { "foo" => "bar" }) } 146 | let(:conn) do 147 | conn = build(env) 148 | conn 149 | .add_response_header("Content-Type", "text/plain") 150 | .set_status(404) 151 | .set_response_body("Not found") 152 | end 153 | let(:rack_response) { conn.rack_response } 154 | 155 | it "builds status from status attribute" do 156 | expect(conn.rack_response[0]).to be(404) 157 | end 158 | 159 | it "builds response headers from response_headers attribute" do 160 | expect(conn.rack_response[1]).to eq("Content-Type" => "text/plain") 161 | end 162 | 163 | it "builds response body from response_body attribute" do 164 | expect(conn.rack_response[2]).to eq(["Not found"]) 165 | end 166 | end 167 | 168 | describe "#halted?" do 169 | it "returns true when class is a Halted instance" do 170 | conn = build_conn(default_env).halt 171 | 172 | expect(conn.halted?).to be(true) 173 | end 174 | 175 | it "returns false when class is a Ongoing instance" do 176 | conn = build_conn(default_env) 177 | 178 | expect(conn.halted?).to be(false) 179 | end 180 | end 181 | end 182 | -------------------------------------------------------------------------------- /spec/unit/web_pipe/conn_support/builder_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "support/conn" 5 | 6 | RSpec.describe WebPipe::ConnSupport::Builder do 7 | describe ".call" do 8 | it "creates a Conn::Ongoing" do 9 | conn = described_class.(default_env) 10 | 11 | expect(conn).to be_an_instance_of(WebPipe::Conn::Ongoing) 12 | end 13 | 14 | context "env" do 15 | it "fills in with rack env" do 16 | env = default_env 17 | 18 | conn = described_class.(env) 19 | 20 | expect(conn.env).to be(env) 21 | end 22 | end 23 | 24 | context "request" do 25 | it "fills in with rack request" do 26 | env = default_env 27 | 28 | conn = described_class.(env) 29 | 30 | expect(conn.request).to be_an_instance_of(Rack::Request) 31 | end 32 | end 33 | 34 | context "scheme" do 35 | it "fills with request scheme as symbol" do 36 | env = default_env.merge(Rack::HTTPS => "on") 37 | 38 | conn = described_class.(env) 39 | 40 | expect(conn.scheme).to eq(:https) 41 | end 42 | end 43 | 44 | context "request_method" do 45 | it "fills in with downcased request method as symbol" do 46 | env = default_env.merge(Rack::REQUEST_METHOD => "POST") 47 | 48 | conn = described_class.(env) 49 | 50 | expect(conn.request_method).to eq(:post) 51 | end 52 | end 53 | 54 | context "host" do 55 | it "fills in with request host" do 56 | env = default_env.merge(Rack::HTTP_HOST => "www.host.org") 57 | 58 | conn = described_class.(env) 59 | 60 | expect(conn.host).to eq("www.host.org") 61 | end 62 | end 63 | 64 | context "ip" do 65 | it "fills in with request ip" do 66 | env = default_env.merge("REMOTE_ADDR" => "0.0.0.0") 67 | 68 | conn = described_class.(env) 69 | 70 | expect(conn.ip).to eq("0.0.0.0") 71 | end 72 | 73 | it "defaults to nil" do 74 | env = default_env 75 | 76 | conn = described_class.(env) 77 | 78 | expect(conn.ip).to be_nil 79 | end 80 | end 81 | 82 | context "port" do 83 | it "fills in with request port" do 84 | env = default_env.merge(Rack::SERVER_PORT => "443") 85 | 86 | conn = described_class.(env) 87 | 88 | expect(conn.port).to eq(443) 89 | end 90 | end 91 | 92 | context "script_name" do 93 | it "fills in with request script name" do 94 | env = default_env.merge(Rack::SCRIPT_NAME => "index.rb") 95 | 96 | conn = described_class.(env) 97 | 98 | expect(conn.script_name).to eq("index.rb") 99 | end 100 | end 101 | 102 | context "path_info" do 103 | it "fills in with request path info" do 104 | env = default_env.merge(Rack::PATH_INFO => "/foo/bar") 105 | 106 | conn = described_class.(env) 107 | 108 | expect(conn.path_info).to eq("/foo/bar") 109 | end 110 | end 111 | 112 | context "query_string" do 113 | it "fills in with request query string" do 114 | env = default_env.merge(Rack::QUERY_STRING => "foo=bar") 115 | 116 | conn = described_class.(env) 117 | 118 | expect(conn.query_string).to eq("foo=bar") 119 | end 120 | end 121 | 122 | context "request_body" do 123 | it "fills in with request body" do 124 | request_body = StringIO.new("foo=bar") 125 | env = default_env.merge( 126 | Rack::RACK_INPUT => request_body 127 | ) 128 | 129 | conn = described_class.(env) 130 | 131 | expect(conn.request_body).to eq(request_body) 132 | end 133 | end 134 | 135 | describe "#request_headers" do 136 | it "fills in with extracted headers from env" do 137 | env = default_env.merge("HTTP_FOO_BAR" => "BAR") 138 | 139 | conn = described_class.(env) 140 | 141 | expect(conn.request_headers).to eq("Foo-Bar" => "BAR") 142 | end 143 | end 144 | 145 | context "status" do 146 | it "let it to initialize with its default" do 147 | env = default_env 148 | 149 | conn = described_class.(env) 150 | 151 | expect(conn.status).to be(200) 152 | end 153 | end 154 | 155 | context "response_body" do 156 | it "let it to initialize with its default" do 157 | env = default_env 158 | 159 | conn = described_class.(env) 160 | 161 | expect(conn.response_body).to eq([""]) 162 | end 163 | end 164 | 165 | context "response_headers" do 166 | it "let it to initialize with its default" do 167 | env = default_env 168 | 169 | conn = described_class.(env) 170 | 171 | expect(conn.response_headers).to eq({}) 172 | end 173 | end 174 | end 175 | 176 | context "bag" do 177 | it "let it to initialize with its default" do 178 | env = default_env 179 | 180 | conn = described_class.(env) 181 | 182 | expect(conn.bag).to eq({}) 183 | end 184 | end 185 | end 186 | -------------------------------------------------------------------------------- /spec/unit/web_pipe/conn_support/composition_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "support/conn" 5 | 6 | RSpec.describe WebPipe::ConnSupport::Composition do 7 | let(:conn) { build_conn(default_env) } 8 | 9 | describe "#call" do 10 | it "chains operations on Conn" do 11 | op1 = ->(conn) { conn.set_status(200) } 12 | op2 = ->(conn) { conn.set_response_body("foo") } 13 | 14 | app = described_class.new([op1, op2]) 15 | 16 | expect(app.(conn)).to eq( 17 | op2.(op1.(conn)) 18 | ) 19 | end 20 | 21 | it "stops chain propagation once a conn is halted" do 22 | op1 = ->(conn) { conn.set_status(200) } 23 | op2 = ->(conn) { conn.set_response_body("foo") } 24 | op3 = ->(conn) { conn.halt } 25 | op4 = ->(conn) { conn.set_response_body("bar") } 26 | 27 | app = described_class.new([op1, op2, op3, op4]) 28 | 29 | expect(app.(conn)).to eq( 30 | op3.(op2.(op1.(conn))) 31 | ) 32 | end 33 | 34 | it "raises InvalidOperationReturn when one operation does not return a Conn" do 35 | op = ->(_conn) { :foo } 36 | 37 | app = described_class.new([op]) 38 | 39 | expect do 40 | app.(conn) 41 | end.to raise_error(WebPipe::ConnSupport::Composition::InvalidOperationResult) 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /spec/unit/web_pipe/conn_support/headers_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "support/conn" 5 | 6 | RSpec.describe WebPipe::ConnSupport::Headers do 7 | describe ".extract" do 8 | it "returns hash with env HTTP_ pairs with prefix removed" do 9 | env = default_env.merge("HTTP_F" => "BAR") 10 | 11 | headers = described_class.extract(env) 12 | 13 | expect(headers).to eq("F" => "BAR") 14 | end 15 | 16 | it "normalize keys" do 17 | env = default_env.merge("HTTP_FOO_BAR" => "foobar") 18 | 19 | headers = described_class.extract(env) 20 | 21 | expect(headers).to eq("Foo-Bar" => "foobar") 22 | end 23 | 24 | it "includes content type CGI-like var" do 25 | env = default_env.merge("CONTENT_TYPE" => "text/html") 26 | 27 | headers = described_class.extract(env) 28 | 29 | expect(headers["Content-Type"]).to eq("text/html") 30 | end 31 | 32 | it "includes content length CGI-like var" do 33 | env = default_env.merge("CONTENT_LENGTH" => "10") 34 | 35 | headers = described_class.extract(env) 36 | 37 | expect(headers["Content-Length"]).to eq("10") 38 | end 39 | 40 | it "defaults to empty hash" do 41 | headers = described_class.extract(default_env) 42 | 43 | expect(headers).to eq({}) 44 | end 45 | end 46 | 47 | describe "add" do 48 | it "adds given pair to given headers" do 49 | headers = { "Foo" => "Bar" } 50 | 51 | new_headers = described_class.add(headers, "Bar", "Foo") 52 | 53 | expect(new_headers).to eq("Foo" => "Bar", "Bar" => "Foo") 54 | end 55 | 56 | it "normalize key" do 57 | headers = described_class.add({}, "foo_foo", "Bar") 58 | 59 | expect(headers).to eq("Foo-Foo" => "Bar") 60 | end 61 | end 62 | 63 | describe "delete" do 64 | it "deletes response header with given key" do 65 | headers = { "Foo" => "Bar", "Zoo" => "Zar" } 66 | 67 | new_headers = described_class.delete(headers, "Zoo") 68 | 69 | expect(new_headers).to eq("Foo" => "Bar") 70 | end 71 | 72 | it "accepts non normalized key" do 73 | headers = { "Foo-Foo" => "Bar" } 74 | 75 | new_headers = described_class.delete(headers, "foo_foo") 76 | 77 | expect(new_headers).to eq({}) 78 | end 79 | end 80 | 81 | describe ".normalize_key" do 82 | it "does PascalCase on - and switches _ by -" do 83 | key = described_class.normalize_key("foo_bar") 84 | 85 | expect(key).to eq("Foo-Bar") 86 | end 87 | end 88 | 89 | describe ".normalize" do 90 | it "returns copy of hash with all keys normalized" do 91 | headers = described_class.normalize("foo_bar" => "zoo") 92 | 93 | expect(headers).to eq("Foo-Bar" => "zoo") 94 | end 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /spec/unit/web_pipe/pipe_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "support/conn" 5 | require "support/middlewares" 6 | 7 | RSpec.describe WebPipe::Pipe do 8 | describe "#plug" do 9 | context "when no other plugs are present" do 10 | it "initializes the queue with the given plug" do 11 | pipe = described_class.new.plug(:one, "key") 12 | 13 | expect(pipe.plugs).to eq( 14 | [WebPipe::Plug.new(name: :one, spec: "key")] 15 | ) 16 | end 17 | end 18 | 19 | context "when other plugs are present" do 20 | it "adds the new plug at the end of the queue" do 21 | pipe = described_class 22 | .new 23 | .plug(:one) 24 | .plug(:two, "key") 25 | 26 | expect(pipe.plugs).to eq( 27 | [ 28 | WebPipe::Plug.new(name: :one, spec: nil), 29 | WebPipe::Plug.new(name: :two, spec: "key") 30 | ] 31 | ) 32 | end 33 | end 34 | 35 | it "returns a new instance" do 36 | pipe1 = described_class.new 37 | 38 | pipe2 = pipe1.plug(:one) 39 | 40 | expect(pipe2).to be_an_instance_of(described_class) 41 | expect(pipe2).not_to eq(pipe1) 42 | end 43 | end 44 | 45 | describe "#use" do 46 | context "when no other middleware specifications are present" do 47 | it "initializes the queue with the given specification" do 48 | pipe = described_class.new.use(:one, FirstNameMiddleware) 49 | 50 | expect(pipe.middleware_specifications).to eq( 51 | [WebPipe::RackSupport::MiddlewareSpecification.new(name: :one, spec: [FirstNameMiddleware])] 52 | ) 53 | end 54 | end 55 | 56 | context "when other middleware specifications are present" do 57 | it "adds the new specification at the end of the queue" do 58 | pipe = described_class 59 | .new 60 | .use(:one, FirstNameMiddleware) 61 | .use(:two, LastNameMiddleware, name: "Alice") 62 | 63 | expect(pipe.middleware_specifications).to eq( 64 | [ 65 | WebPipe::RackSupport::MiddlewareSpecification.new(name: :one, spec: [FirstNameMiddleware]), 66 | WebPipe::RackSupport::MiddlewareSpecification.new(name: :two, 67 | spec: [ 68 | LastNameMiddleware, { name: "Alice" } 69 | ]) 70 | ] 71 | ) 72 | end 73 | end 74 | 75 | it "returns a new instance" do 76 | pipe1 = described_class.new 77 | 78 | pipe2 = pipe1.use(:one, Object) 79 | 80 | expect(pipe2).to be_an_instance_of(described_class) 81 | expect(pipe2).not_to eq(pipe1) 82 | end 83 | end 84 | 85 | describe "#compose" do 86 | let(:base) { described_class.new } 87 | 88 | it "adds the plug to the queue" do 89 | pipe = described_class.new.compose(:one, base) 90 | 91 | expect(pipe.plugs).to eq( 92 | [WebPipe::Plug.new(name: :one, spec: base)] 93 | ) 94 | end 95 | 96 | it "adds the middleware specification to the queue" do 97 | pipe = described_class.new.compose(:one, base) 98 | 99 | expect(pipe.middleware_specifications).to eq( 100 | [WebPipe::RackSupport::MiddlewareSpecification.new(name: :one, spec: [base])] 101 | ) 102 | end 103 | 104 | it "returns a new instance" do 105 | pipe1 = described_class.new 106 | 107 | pipe2 = pipe1.compose(:one, base) 108 | 109 | expect(pipe2).to be_an_instance_of(described_class) 110 | expect(pipe2).not_to eq(pipe1) 111 | end 112 | end 113 | 114 | describe "#operations" do 115 | it "maps plug names with resolved operations" do 116 | pipe = described_class.new 117 | .plug(:one, proc { |_conn| "one" }) 118 | .plug(:two, proc { |_conn| "two" }) 119 | 120 | operations = pipe.operations 121 | 122 | expect(operations.map { |name, op| [name, op.()] }).to eq([[:one, "one"], [:two, "two"]]) 123 | end 124 | end 125 | 126 | describe "#middlewares" do 127 | it "maps middleware specifications names with resolved middlewares" do 128 | pipe = described_class.new 129 | .use(:one, FirstNameMiddleware) 130 | .use(:two, LastNameMiddleware, name: "Alice") 131 | 132 | middlewares = pipe.middlewares 133 | 134 | expect(middlewares).to eq( 135 | { 136 | one: [WebPipe::RackSupport::Middleware.new(middleware: FirstNameMiddleware, options: [])], 137 | two: [WebPipe::RackSupport::Middleware.new(middleware: LastNameMiddleware, options: [{ name: "Alice" }])] 138 | } 139 | ) 140 | end 141 | end 142 | 143 | describe "#to_proc" do 144 | it "returns the kleisli composition of all the plugged operations" do 145 | pipe = described_class.new 146 | .plug(:one, ->(conn) { conn.set_response_body("one") }) 147 | .plug(:two, ->(conn) { conn.halt }) 148 | .plug(:three, ->(conn) { conn.set_response_body("three") }) 149 | 150 | to_proc = pipe.to_proc 151 | 152 | expect(to_proc.(build_conn).response_body).to eq(["one"]) 153 | end 154 | end 155 | 156 | describe "#to_middlewares" do 157 | it "returns all used middlewares" do 158 | pipe = described_class.new 159 | .use(:one, FirstNameMiddleware) 160 | .use(:two, LastNameMiddleware, name: "Alice") 161 | 162 | to_middlewares = pipe.to_middlewares 163 | 164 | expect(to_middlewares).to eq( 165 | [ 166 | WebPipe::RackSupport::Middleware.new(middleware: FirstNameMiddleware, options: []), 167 | WebPipe::RackSupport::Middleware.new(middleware: LastNameMiddleware, options: [{ name: "Alice" }]) 168 | ] 169 | ) 170 | end 171 | end 172 | 173 | describe "#inject" do 174 | it "substitutes matching plug operation" do 175 | pipe = described_class.new 176 | .plug(:one, "one") 177 | .plug(:two, "two") 178 | .inject(plugs: { one: "injected" }) 179 | 180 | expect(pipe.plugs).to eq( 181 | [ 182 | WebPipe::Plug.new(name: :one, spec: "injected"), 183 | WebPipe::Plug.new(name: :two, spec: "two") 184 | ] 185 | ) 186 | end 187 | 188 | it "substitutes matching middleware specifications" do 189 | pipe = described_class.new 190 | .use(:one, FirstNameMiddleware) 191 | .use(:two, LastNameMiddleware, name: "Alice") 192 | .inject(middleware_specifications: { two: [FirstNameMiddleware] }) 193 | 194 | expect(pipe.middleware_specifications).to eq( 195 | [ 196 | WebPipe::RackSupport::MiddlewareSpecification.new(name: :one, spec: [FirstNameMiddleware]), 197 | WebPipe::RackSupport::MiddlewareSpecification.new(name: :two, spec: [FirstNameMiddleware]) 198 | ] 199 | ) 200 | end 201 | end 202 | 203 | describe "#call" do 204 | it "behaves like a rack application" do 205 | pipe = described_class.new 206 | .plug :hello do |conn| 207 | conn 208 | .set_response_body("Hello, world!") 209 | .set_status(200) 210 | end 211 | 212 | expect(pipe.(default_env)).to eq([200, {}, ["Hello, world!"]]) 213 | end 214 | 215 | it "can resolve plug operation from a callable" do 216 | one = ->(conn) { conn.set_response_body("One") } 217 | pipe = described_class.new 218 | .plug(:one, one) 219 | 220 | response = pipe.(default_env) 221 | 222 | expect(response.last).to eq(["One"]) 223 | end 224 | 225 | it "can resolve plug operation from a block" do 226 | pipe = described_class.new 227 | .plug :one do |conn| 228 | conn.set_response_body("One") 229 | end 230 | 231 | response = pipe.(default_env) 232 | 233 | expect(response.last).to eq(["One"]) 234 | end 235 | 236 | it "can resolve plug operation from the context object" do 237 | context = Class.new do 238 | def self.one(conn) 239 | conn.set_response_body(["One"]) 240 | end 241 | end 242 | pipe = described_class.new(context: context) 243 | .plug(:one) 244 | 245 | response = pipe.(default_env) 246 | 247 | expect(response.last).to eq(["One"]) 248 | end 249 | 250 | it "can resolve plug operation from something responding to to_proc" do 251 | one = Class.new do 252 | def self.to_proc 253 | ->(conn) { conn.set_response_body("One") } 254 | end 255 | end 256 | pipe = described_class.new 257 | .plug(:one, one) 258 | 259 | response = pipe.(default_env) 260 | 261 | expect(response.last).to eq(["One"]) 262 | end 263 | 264 | it "can resolve plug operation from another pipe" do 265 | one = ->(conn) { conn.set_response_body("One") } 266 | two = ->(conn) { conn.set_response_body("#{conn.response_body[0]}Two") } 267 | three = ->(conn) { conn.set_response_body("#{conn.response_body[0]}Three") } 268 | pipe1 = described_class.new 269 | .plug(:two, two) 270 | .plug(:three, three) 271 | pipe2 = described_class.new 272 | .plug(:one, one) 273 | .plug(:pipe1, pipe1) 274 | 275 | response = pipe2.(default_env) 276 | 277 | expect(response.last).to eq(["OneTwoThree"]) 278 | end 279 | 280 | it "chains plug operations" do 281 | one = ->(conn) { conn.set_response_body("One") } 282 | two = ->(conn) { conn.set_response_body("#{conn.response_body[0]}Two") } 283 | pipe = described_class.new 284 | .plug(:one, one) 285 | .plug(:two, two) 286 | 287 | response = pipe.(default_env) 288 | 289 | expect(response.last).to eq(["OneTwo"]) 290 | end 291 | 292 | it "stops chain of plugs when halting" do 293 | one = ->(conn) { conn.set_response_body("One") } 294 | two = ->(conn) { conn.halt } 295 | three = ->(conn) { conn.set_response_body("Three") } 296 | pipe = described_class.new 297 | .plug(:one, one) 298 | .plug(:two, two) 299 | .plug(:three, three) 300 | 301 | response = pipe.(default_env) 302 | 303 | expect(response.last).to eq(["One"]) 304 | end 305 | 306 | it "keeps stoping the chain if halted when plugging another pipe" do 307 | one = ->(conn) { conn.set_response_body("One") } 308 | two = ->(conn) { conn.halt } 309 | three = ->(conn) { conn.set_response_body("Three") } 310 | pipe1 = described_class.new 311 | .plug(:two, two) 312 | .plug(:three, three) 313 | pipe2 = described_class.new 314 | .plug(:one, one) 315 | .plug(:pipe1, pipe1) 316 | 317 | response = pipe2.(default_env) 318 | 319 | expect(response.last).to eq(["One"]) 320 | end 321 | 322 | it "can use a middleware" do 323 | hello = lambda do |conn| 324 | first_name = conn.env["first_name"] 325 | last_name = conn.env["last_name"] 326 | conn 327 | .set_response_body( 328 | "Hello #{first_name} #{last_name}" 329 | ) 330 | end 331 | pipe = described_class.new 332 | .use(:first_name, FirstNameMiddleware) 333 | .use(:last_name, LastNameMiddleware, name: "Doe") 334 | .plug(:hello, hello) 335 | 336 | response = pipe.(default_env) 337 | 338 | expect(response.last).to eq(["Hello Joe Doe"]) 339 | end 340 | 341 | it "can use middlewares from something responding to #to_middlewares" do 342 | middlewares = Class.new do 343 | def self.to_middlewares 344 | [ 345 | WebPipe::RackSupport::Middleware.new(middleware: FirstNameMiddleware, options: []), 346 | WebPipe::RackSupport::Middleware.new(middleware: LastNameMiddleware, options: [{ name: "Doe" }]) 347 | ] 348 | end 349 | end 350 | hello = lambda do |conn| 351 | first_name = conn.env["first_name"] 352 | last_name = conn.env["last_name"] 353 | conn 354 | .set_response_body( 355 | "Hello #{first_name} #{last_name}" 356 | ) 357 | end 358 | pipe = described_class.new 359 | .use(:middlewaers, middlewares) 360 | .plug(:hello, hello) 361 | 362 | response = pipe.(default_env) 363 | 364 | expect(response.last).to eq(["Hello Joe Doe"]) 365 | end 366 | 367 | it "can use middlewares from another pipe" do 368 | pipe1 = described_class.new 369 | .use(:first_name, FirstNameMiddleware) 370 | .use(:last_name, LastNameMiddleware, name: "Doe") 371 | hello = lambda do |conn| 372 | first_name = conn.env["first_name"] 373 | last_name = conn.env["last_name"] 374 | conn 375 | .set_response_body( 376 | "Hello #{first_name} #{last_name}" 377 | ) 378 | end 379 | pipe = described_class.new 380 | .use(:pipe1, pipe1) 381 | .plug(:hello, hello) 382 | 383 | response = pipe.(default_env) 384 | 385 | expect(response.last).to eq(["Hello Joe Doe"]) 386 | end 387 | end 388 | end 389 | -------------------------------------------------------------------------------- /spec/unit/web_pipe/plug_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe WebPipe::Plug do 6 | let(:object) do 7 | Class.new do 8 | def public; end 9 | 10 | private 11 | 12 | def private; end 13 | end.new 14 | end 15 | 16 | describe "#with" do 17 | let(:name) { :name } 18 | let(:plug) { described_class.new(name: name, spec: nil) } 19 | 20 | let(:new_spec) { -> {} } 21 | let(:new_plug) { plug.with(new_spec) } 22 | 23 | it "returns new instance" do 24 | expect(new_plug).not_to be(plug) 25 | end 26 | 27 | it "keeps plug name" do 28 | expect(new_plug.name).to be(name) 29 | end 30 | 31 | it "replaces spec" do 32 | expect(new_plug.spec).to eq(new_spec) 33 | end 34 | end 35 | 36 | describe "#call" do 37 | let(:plug) { described_class.new(name: name, spec: spec) } 38 | 39 | context "when spec responds to #to_proc" do 40 | let(:name) { "name" } 41 | let(:spec) do 42 | Class.new do 43 | def to_proc 44 | -> { "hey" } 45 | end 46 | end.new 47 | end 48 | 49 | it "returns the result of calling it" do 50 | expect(plug.(object).()).to eq("hey") 51 | end 52 | end 53 | 54 | context "when spec is callable" do 55 | let(:name) { "name" } 56 | let(:spec) { -> {} } 57 | 58 | it "returns it" do 59 | expect(plug.(object)).to be(spec) 60 | end 61 | end 62 | 63 | context "when spec is nil" do 64 | let(:spec) { nil } 65 | 66 | context "when object has a public method with plug name" do 67 | let(:name) { "public" } 68 | 69 | it "returns a proc wrapping it" do 70 | expect(plug.(object)).to eq(object.method(:public)) 71 | end 72 | end 73 | 74 | context "when object has a private method with plug name" do 75 | let(:name) { "private" } 76 | 77 | it "returns a proc wrapping it" do 78 | expect(plug.(object)).to eq(object.method(:private)) 79 | end 80 | end 81 | end 82 | end 83 | 84 | describe ".inject" do 85 | it "inject specs" do 86 | plugs = [ 87 | described_class.new(name: :op1, spec: -> { "op1" }), 88 | described_class.new(name: :op2, spec: -> { "op2" }) 89 | ] 90 | injected = { op2: -> { "injected" } } 91 | 92 | result = described_class.inject( 93 | plugs, injected 94 | ) 95 | 96 | expect(result.map(&:name)).to eq(%i[op1 op2]) 97 | expect(result.map { |plug| plug.(self) }.map(&:call)).to eq(%w[op1 injected]) 98 | end 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /spec/unit/web_pipe/plugs/config_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "support/conn" 5 | 6 | RSpec.describe WebPipe::Plugs::Config do 7 | describe ".call" do 8 | it "creates an operation which adds given pairs to config" do 9 | conn = build_conn(default_env).add_config(:zoo, :zoo) 10 | operation = described_class.(foo: :bar, 11 | rar: :ror) 12 | 13 | new_conn = operation.(conn) 14 | 15 | expect(new_conn.config).to eq(zoo: :zoo, foo: :bar, rar: :ror) 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/unit/web_pipe/plugs/content_type_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "support/conn" 5 | 6 | RSpec.describe WebPipe::Plugs::ContentType do 7 | describe ".call" do 8 | it "creates an operation which adds given argument as Content-Type header" do 9 | conn = build_conn(default_env) 10 | operation = described_class.("text/html") 11 | 12 | new_conn = operation.(conn) 13 | 14 | expect(new_conn.response_headers["Content-Type"]).to eq("text/html") 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/unit/web_pipe/rack/middleware_specification_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "support/middlewares" 5 | 6 | RSpec.describe WebPipe::RackSupport::MiddlewareSpecification do 7 | let(:pipe) do 8 | Class.new do 9 | include WebPipe 10 | 11 | use :middleware, FirstNameMiddleware 12 | end 13 | end 14 | 15 | describe "#call" do 16 | context "when spec responds to to_middlewares" do 17 | it "returns an array with its WebPipe::Rack::Middleware's" do 18 | expect(described_class.new(name: :name, spec: [pipe.new]).()).to include( 19 | WebPipe::RackSupport::Middleware.new(middleware: FirstNameMiddleware, options: []) 20 | ) 21 | end 22 | end 23 | 24 | context "when spec is a class" do 25 | it "returns it as a WebPipe::RackSupport::Middleware with empty options" do 26 | expect(described_class.new(name: :name, spec: [FirstNameMiddleware]).()).to eq( 27 | [WebPipe::RackSupport::Middleware.new(middleware: FirstNameMiddleware, options: [])] 28 | ) 29 | end 30 | end 31 | 32 | context "when spec is a class with options" do 33 | it "returns it as a WebPipe::RackSupport::Middleware with given options" do 34 | expect(described_class.new(name: :name, spec: [LastNameMiddleware, { name: "Joe" }]).()).to eq( 35 | [WebPipe::RackSupport::Middleware.new(middleware: LastNameMiddleware, options: [name: "Joe"])] 36 | ) 37 | end 38 | end 39 | end 40 | 41 | describe "#with" do 42 | let(:name) { :name } 43 | let(:middleware_specification) { described_class.new(name: name, spec: [pipe.new]) } 44 | 45 | let(:new_spec) { [FirstNameMiddleware] } 46 | let(:new_middleware_specification) { middleware_specification.with(new_spec) } 47 | 48 | it "returns new instance" do 49 | expect(new_middleware_specification).not_to be(middleware_specification) 50 | end 51 | 52 | it "keeps plug name" do 53 | expect(new_middleware_specification.name).to be(name) 54 | end 55 | 56 | it "replaces spec" do 57 | expect(new_middleware_specification.spec).to eq(new_spec) 58 | end 59 | end 60 | 61 | describe ".inject" do 62 | it "inject specs" do 63 | spec1 = described_class.new(name: :middleware1, spec: [FirstNameMiddleware]) 64 | spec2 = described_class.new(name: :middleware2, spec: [pipe.new]) 65 | middleware_specifications = [ 66 | spec1, 67 | spec2 68 | ] 69 | injections = { middleware2: [FirstNameMiddleware] } 70 | 71 | result = described_class.inject( 72 | middleware_specifications, injections 73 | ) 74 | 75 | expect(result.map(&:spec)).to eq([[FirstNameMiddleware], [FirstNameMiddleware]]) 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /spec/unit/web_pipe/test_support_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe WebPipe::TestSupport do 6 | let(:klass) do 7 | Class.new { include WebPipe::TestSupport }.new 8 | end 9 | 10 | describe "#build_conn" do 11 | it "returns a WebPipe::Conn::Ongoing instance" do 12 | expect( 13 | klass.build_conn.instance_of?(WebPipe::Conn::Ongoing) 14 | ).to be(true) 15 | end 16 | 17 | it "can shortcut through uri" do 18 | conn = klass.build_conn("http://dummy.org?foo=bar") 19 | 20 | expect(conn.host).to eq("dummy.org") 21 | expect(conn.query_string).to eq("foo=bar") 22 | end 23 | 24 | it "can override attributes" do 25 | conn = klass.build_conn(attributes: { host: "foo.bar" }) 26 | 27 | expect(conn.host).to eq("foo.bar") 28 | end 29 | 30 | it "gives preference to the attributes over the uri" do 31 | conn = klass.build_conn("http://joe.doe", attributes: { host: "foo.bar" }) 32 | 33 | expect(conn.host).to eq("foo.bar") 34 | end 35 | 36 | it "can forward env options" do 37 | conn = klass.build_conn(env_opts: { method: "PUT" }) 38 | 39 | expect(conn.request_method).to be(:put) 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /spec/web_pipe_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe WebPipe do 4 | it "has a version number" do 5 | expect(WebPipe::VERSION).not_to be nil 6 | end 7 | 8 | it "configures loader" do 9 | expect do 10 | WebPipe.loader.eager_load(force: true) 11 | end.not_to raise_error 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /web_pipe.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path("lib", __dir__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require "web_pipe/version" 6 | 7 | Gem::Specification.new do |spec| 8 | spec.name = "web_pipe" 9 | spec.version = WebPipe::VERSION 10 | spec.authors = ["Marc Busqué"] 11 | spec.email = ["marc@lamarciana.com"] 12 | spec.homepage = "https://github.com/waiting-for-dev/web_pipe" 13 | spec.summary = "Rack application builder through a pipe of operations on an immutable struct." 14 | spec.licenses = ["MIT"] 15 | 16 | spec.metadata = { 17 | "bug_tracker_uri" => "https://github.com/waiting-for-dev/web_pipe/issues", 18 | "changelog_uri" => "https://github.com/waiting-for-dev/web_pipe/blob/main/CHANGELOG.md", 19 | "documentation_uri" => "https://github.com/waiting-for-dev/web_pipe/blob/main/README.md", 20 | "funding_uri" => "https://github.com/sponsors/waiting-for-dev", 21 | "label" => "web_pipe", 22 | "source_code_uri" => "https://github.com/waiting-for-dev/web_pipe", 23 | "rubygems_mfa_required" => "true" 24 | } 25 | 26 | spec.required_ruby_version = ">= 3.0" 27 | 28 | spec.files = Dir.chdir(File.expand_path(__dir__)) do 29 | `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 30 | end 31 | spec.require_paths = ["lib"] 32 | 33 | spec.add_runtime_dependency "dry-monads", "~> 1.3" 34 | spec.add_runtime_dependency "dry-struct", "~> 1.0" 35 | spec.add_runtime_dependency "dry-types", "~> 1.1" 36 | spec.add_runtime_dependency "rack", "~> 2.0" 37 | spec.add_runtime_dependency "zeitwerk", "~> 2.6" 38 | end 39 | --------------------------------------------------------------------------------