├── .gitignore ├── TODO.md ├── jellyfish.png ├── .gitmodules ├── lib ├── jellyfish │ ├── version.rb │ ├── public │ │ ├── 500.html │ │ ├── 404.html │ │ └── 302.html │ ├── normalized_path.rb │ ├── chunked_body.rb │ ├── rewrite.rb │ ├── test.rb │ ├── websocket.rb │ ├── json.rb │ ├── normalized_params.rb │ ├── builder.rb │ └── urlmap.rb └── jellyfish.rb ├── Gemfile ├── Rakefile ├── test ├── test_misc.rb ├── test_threads.rb ├── test_log.rb ├── sinatra │ ├── test_chunked_body.rb │ ├── test_base.rb │ ├── test_error.rb │ └── test_routing.rb ├── test_websocket.rb ├── test_rewrite.rb ├── test_from_readme.rb ├── test_inheritance.rb ├── test_listen.rb └── rack │ ├── test_builder.rb │ └── test_urlmap.rb ├── .gitlab-ci.yml ├── bench └── bench_builder.rb ├── config.ru ├── jellyfish.gemspec ├── LICENSE ├── CHANGES.md └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /pkg/ 2 | /coverage/ 3 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # TODO 2 | 3 | Warn if the same route was defined again? 4 | -------------------------------------------------------------------------------- /jellyfish.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/godfat/jellyfish/HEAD/jellyfish.png -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "task"] 2 | path = task 3 | url = https://github.com/godfat/gemgem.git 4 | -------------------------------------------------------------------------------- /lib/jellyfish/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Jellyfish 4 | VERSION = '1.4.0' 5 | end 6 | -------------------------------------------------------------------------------- /lib/jellyfish/public/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |

Jellyfish crashed

8 | 9 | 10 | -------------------------------------------------------------------------------- /lib/jellyfish/public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |

Jellyfish not found

8 | 9 | 10 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | 2 | source 'https://rubygems.org' 3 | 4 | gemspec 5 | 6 | gem 'rake' 7 | gem 'rack' 8 | gem 'pork' 9 | gem 'muack' 10 | gem 'websocket_parser' 11 | 12 | gem 'simplecov', :require => false if ENV['COV'] 13 | gem 'coveralls', :require => false if ENV['CI'] 14 | -------------------------------------------------------------------------------- /lib/jellyfish/public/302.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |

Jellyfish found: VAR_URL

9 | 10 | 11 | -------------------------------------------------------------------------------- /lib/jellyfish/normalized_path.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'jellyfish' 4 | require 'uri' 5 | 6 | module Jellyfish 7 | module NormalizedPath 8 | def path_info 9 | path = URI.decode_www_form_component(super, Encoding.default_external) 10 | if path.start_with?('/') then path else "/#{path}" end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | 2 | begin 3 | require "#{__dir__}/task/gemgem" 4 | rescue LoadError 5 | sh 'git submodule update --init --recursive' 6 | exec Gem.ruby, '-S', $PROGRAM_NAME, *ARGV 7 | end 8 | 9 | Gemgem.init(__dir__) do |s| 10 | require 'jellyfish/version' 11 | s.name = 'jellyfish' 12 | s.version = Jellyfish::VERSION 13 | s.files.delete('jellyfish.png') 14 | end 15 | -------------------------------------------------------------------------------- /lib/jellyfish/chunked_body.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'jellyfish' 4 | 5 | module Jellyfish 6 | class ChunkedBody 7 | include Enumerable 8 | attr_reader :body 9 | def initialize &body 10 | @body = body 11 | end 12 | 13 | def each &block 14 | if block 15 | body.call(block) 16 | else 17 | to_enum(:each) 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/jellyfish/rewrite.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Jellyfish 4 | class Rewrite < Struct.new(:app, :from, :to) 5 | def call env 6 | app.call(env.merge( 7 | 'SCRIPT_NAME' => delete_suffix(env['SCRIPT_NAME'], from), 8 | 'PATH_INFO' => "#{to}#{env['PATH_INFO']}")) 9 | end 10 | 11 | if ''.respond_to?(:delete_suffix) 12 | def delete_suffix str, suffix 13 | str.delete_suffix(suffix) 14 | end 15 | else 16 | def delete_suffix str, suffix 17 | str.sub(/#{Regexp.escape(suffix)}\z/, '') 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /test/test_misc.rb: -------------------------------------------------------------------------------- 1 | 2 | require 'jellyfish/test' 3 | 4 | describe Jellyfish do 5 | paste :jellyfish 6 | 7 | app = Class.new{ 8 | include Jellyfish 9 | handle_exceptions false 10 | get('/boom'){ halt 'string' } 11 | get 12 | }.new 13 | 14 | would 'match wildcard' do 15 | get('/a', app).should.eq [200, {}, ['']] 16 | get('/b', app).should.eq [200, {}, ['']] 17 | end 18 | 19 | would 'accept to_path body' do 20 | a = Class.new{ 21 | include Jellyfish 22 | get{ File.open(__FILE__) } 23 | }.new 24 | get('/', a).last.to_path.should.eq __FILE__ 25 | end 26 | 27 | would 'raise TypeError if we try to respond non-Response or non-Rack' do 28 | begin 29 | get('/boom', app) 30 | rescue TypeError => e 31 | e.message.should.include? '"string"' 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /test/test_threads.rb: -------------------------------------------------------------------------------- 1 | 2 | require 'jellyfish/test' 3 | 4 | describe Jellyfish do 5 | after do 6 | Muack.verify 7 | end 8 | 9 | app = Class.new{ 10 | include Jellyfish 11 | handle(StandardError){ |env| 0 } 12 | }.new 13 | 14 | exp = RuntimeError.new 15 | 16 | would "no RuntimeError: can't add a new key into hash during iteration" do 17 | # make static ancestors so that we could stub it 18 | ancestors = RuntimeError.ancestors 19 | stub(RuntimeError).ancestors{ancestors} 20 | flip = true 21 | stub(ancestors).index(anything).peek_return do |i| 22 | if flip 23 | flip = false 24 | sleep 0.0001 25 | end 26 | i 27 | end 28 | 29 | 2.times.map{ 30 | Thread.new do 31 | app.send(:best_handler, exp).call({}).should.eq 0 32 | end 33 | }.each(&:join) 34 | 35 | flip.should.eq false 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | 2 | stages: 3 | - test 4 | 5 | .test: 6 | stage: test 7 | image: ruby:${RUBY_VERSION}-bullseye 8 | variables: 9 | GIT_DEPTH: "1" 10 | GIT_SUBMODULE_STRATEGY: recursive 11 | GIT_SUBMODULE_PATHS: task 12 | # websocket_parser does not work with frozen string literal 13 | # RUBYOPT: --enable-frozen-string-literal 14 | before_script: 15 | - bundle install --retry=3 16 | - unset CI # Coverage doesn't work well with frozen literal 17 | script: 18 | - ruby -vr bundler/setup -S rake test 19 | 20 | ruby:3.0: 21 | extends: 22 | - .test 23 | variables: 24 | RUBY_VERSION: '3.0' 25 | 26 | ruby:3.1: 27 | extends: 28 | - .test 29 | variables: 30 | RUBY_VERSION: '3.1' 31 | 32 | ruby:3.2: 33 | extends: 34 | - .test 35 | variables: 36 | RUBY_VERSION: '3.2' 37 | 38 | jruby:latest: 39 | extends: 40 | - .test 41 | image: jruby:latest 42 | -------------------------------------------------------------------------------- /test/test_log.rb: -------------------------------------------------------------------------------- 1 | 2 | require 'jellyfish/test' 3 | 4 | describe Jellyfish do 5 | paste :jellyfish 6 | 7 | after do 8 | Muack.verify 9 | end 10 | 11 | app = Class.new{ 12 | include Jellyfish 13 | get('/log') { log('hi') } 14 | get('/log_error'){ 15 | log_error( 16 | Muack::API.stub(RuntimeError.new).backtrace{ ['backtrace'] }.object) 17 | } 18 | def self.name 19 | 'Name' 20 | end 21 | }.new 22 | 23 | def mock_log 24 | log = [] 25 | log.singleton_class.send(:public, :puts) 26 | mock(log).puts(is_a(String)){ |msg| log << msg } 27 | log 28 | end 29 | 30 | would "log to env['rack.errors']" do 31 | log = mock_log 32 | get('/log', app, 'rack.errors' => log) 33 | log.should.eq ['[Name] hi'] 34 | end 35 | 36 | would "log_error to env['rack.errors']" do 37 | log = mock_log 38 | get('/log_error', app, 'rack.errors' => log) 39 | log.should.eq ['[Name] # for /log_error ["backtrace"]'] 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/jellyfish/test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'pork/auto' 4 | require 'muack' 5 | require 'jellyfish' 6 | require 'rack' 7 | 8 | Pork::Suite.include(Muack::API) 9 | 10 | copy :jellyfish do 11 | module_eval(%w[options get head post put delete patch].map{ |method| 12 | <<-RUBY 13 | def #{method} path='/', a=app, env={} 14 | File.open(File::NULL) do |input| 15 | a.call({'PATH_INFO' => path , 16 | 'REQUEST_METHOD' => '#{method}'.upcase, 17 | 'SCRIPT_NAME' => '' , 18 | 'rack.input' => input , 19 | 'rack.url_scheme'=> 'http' , 20 | 'SERVER_NAME' => 'localhost' , 21 | 'SERVER_PORT' => '8080'}.merge(env)) 22 | end 23 | end 24 | RUBY 25 | }.join("\n")) 26 | end 27 | 28 | copy :stringio do 29 | def new_stringio 30 | sock = StringIO.new 31 | sock.set_encoding('ASCII-8BIT') 32 | sock 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /test/sinatra/test_chunked_body.rb: -------------------------------------------------------------------------------- 1 | 2 | require 'jellyfish/test' 3 | 4 | # stolen from sinatra 5 | describe 'Sinatra streaming_test.rb' do 6 | paste :jellyfish 7 | 8 | would 'return the concatinated body' do 9 | app = Class.new{ 10 | include Jellyfish 11 | get '/' do 12 | Jellyfish::ChunkedBody.new{ |out| 13 | out['Hello'] 14 | out[' '] 15 | out['World!'] 16 | } 17 | end 18 | }.new 19 | _, _, body = get('/', app) 20 | body.to_a.join.should.eq 'Hello World!' 21 | end 22 | 23 | would 'postpone body generation' do 24 | stream = Jellyfish::ChunkedBody.new{ |out| 25 | 10.times{ |i| out[i] } 26 | } 27 | 28 | stream.each.with_index do |s, i| 29 | s.should.eq i 30 | end 31 | end 32 | 33 | would 'give access to route specific params' do 34 | app = Class.new{ 35 | include Jellyfish 36 | get(%r{/(?\w+)}){ |m| 37 | Jellyfish::ChunkedBody.new{ |o| o[m[:name]] } 38 | } 39 | }.new 40 | _, _, body = get('/foo', app) 41 | body.to_a.join.should.eq 'foo' 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /bench/bench_builder.rb: -------------------------------------------------------------------------------- 1 | 2 | # Calculating ------------------------------------- 3 | # Jellyfish::URLMap 5.726k i/100ms 4 | # Rack::URLMap 167.000 i/100ms 5 | # ------------------------------------------------- 6 | # Jellyfish::URLMap 62.397k (± 1.2%) i/s - 314.930k 7 | # Rack::URLMap 1.702k (± 1.5%) i/s - 8.517k 8 | 9 | # Comparison: 10 | # Jellyfish::URLMap: 62397.3 i/s 11 | # Rack::URLMap: 1702.0 i/s - 36.66x slower 12 | 13 | require 'jellyfish' 14 | require 'rack' 15 | 16 | require 'benchmark/ips' 17 | 18 | num = 1000 19 | app = lambda do |_| 20 | ok = [200, {}, []] 21 | rn = lambda{ |_| ok } 22 | 23 | (0...num).each do |i| 24 | map "/#{i}" do 25 | run rn 26 | end 27 | end 28 | end 29 | 30 | jelly = Jellyfish::Builder.app(&app) 31 | rack = Rack::Builder.app(&app) 32 | path_info = 'PATH_INFO' 33 | 34 | Benchmark.ips do |x| 35 | x.report(jelly.class) do 36 | jelly.call(path_info => rand(num).to_s) 37 | end 38 | 39 | x.report(rack.class) do 40 | rack.call(path_info => rand(num).to_s) 41 | end 42 | 43 | x.compare! 44 | end 45 | -------------------------------------------------------------------------------- /test/test_websocket.rb: -------------------------------------------------------------------------------- 1 | 2 | require 'jellyfish/test' 3 | require 'stringio' 4 | 5 | describe Jellyfish::WebSocket do 6 | paste :stringio 7 | 8 | after do 9 | Muack.verify 10 | end 11 | 12 | app = Class.new do 13 | include Jellyfish 14 | controller_include Jellyfish::WebSocket 15 | get '/echo' do 16 | switch_protocol do |msg| 17 | ws_write(msg) 18 | end 19 | ws_write('ping') 20 | ws_start 21 | end 22 | end.new 23 | 24 | def create_env 25 | sock = StringIO.new 26 | sock.set_encoding('ASCII-8BIT') 27 | mock(IO).select([sock]) do # or EOFError, not sure why? 28 | sock << WebSocket::Message.new('pong').to_data * 2 29 | [[sock], [], []] 30 | end 31 | [{'REQUEST_METHOD' => 'GET', 'PATH_INFO' => '/echo', 32 | 'rack.hijack' => lambda{ sock }}, sock] 33 | end 34 | 35 | would 'ping pong' do 36 | env, sock = create_env 37 | app.call(env) 38 | sock.string.should.eq <<-HTTP.chomp.force_encoding('ASCII-8BIT') 39 | HTTP/1.1 101 Switching Protocols\r 40 | Upgrade: websocket\r 41 | Connection: Upgrade\r 42 | Sec-WebSocket-Accept: Kfh9QIsMVZcl6xEPYxPHzW8SZ8w=\r 43 | \r 44 | \x81\x04ping\x81\x04pong\x81\x04pong 45 | HTTP 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/jellyfish/websocket.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'jellyfish' 4 | require 'websocket_parser' 5 | require 'digest/sha1' 6 | 7 | module Jellyfish 8 | module WebSocket 9 | ::WebSocket.constants.each do |const| 10 | const_set(const, ::WebSocket.const_get(const)) 11 | end 12 | 13 | attr_reader :sock, :parser 14 | 15 | def switch_protocol &block 16 | key = env['HTTP_SEC_WEBSOCKET_KEY'] 17 | accept = [Digest::SHA1.digest("#{key}#{GUID}")].pack('m0') 18 | @sock = env['rack.hijack'].call 19 | sock.binmode 20 | sock.write(<<-HTTP) 21 | HTTP/1.1 101 Switching Protocols\r 22 | Upgrade: websocket\r 23 | Connection: Upgrade\r 24 | Sec-WebSocket-Accept: #{accept}\r 25 | \r 26 | HTTP 27 | @parser = Parser.new 28 | parser.on_message(&block) 29 | end 30 | 31 | def ws_start 32 | while !sock.closed? && IO.select([sock]) do 33 | ws_read 34 | end 35 | end 36 | 37 | def ws_read bytes=8192 38 | parser << sock.readpartial(bytes) 39 | rescue EOFError 40 | sock.close 41 | end 42 | 43 | def ws_write msg 44 | sock << Message.new(msg).to_data 45 | end 46 | 47 | def ws_close 48 | sock << Message.close.to_data 49 | sock.close 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /test/test_rewrite.rb: -------------------------------------------------------------------------------- 1 | 2 | require 'jellyfish/test' 3 | require 'jellyfish/urlmap' 4 | 5 | describe Jellyfish::Rewrite do 6 | paste :jellyfish 7 | 8 | lam = lambda do |env| 9 | [200, {}, ["#{env['SCRIPT_NAME']}!#{env['PATH_INFO']}"]] 10 | end 11 | 12 | def call app, path 13 | get(path, app).dig(-1, 0) 14 | end 15 | 16 | would 'map to' do 17 | app = Jellyfish::Builder.app do 18 | map '/from', to: '/to' do 19 | run lam 20 | end 21 | end 22 | 23 | expect(call(app, '/from/here')).eq '!/to/here' 24 | end 25 | 26 | would 'rewrite and fallback' do 27 | app = Jellyfish::Builder.app do 28 | map '/top' do 29 | rewrite '/from/inner' => '/to/inner', 30 | '/from/outer' => '/to/outer' do 31 | run lam 32 | end 33 | 34 | map '/from' do 35 | run lam 36 | end 37 | end 38 | end 39 | 40 | expect(call(app, '/top/from/other')).eq '/top/from!/other' 41 | expect(call(app, '/top/from/inner')).eq '/top!/to/inner' 42 | expect(call(app, '/top/from/outer')).eq '/top!/to/outer' 43 | end 44 | 45 | would 'map to with host and handle SCRIPT_NAME properly' do 46 | app = Jellyfish::Builder.app do 47 | map '/path', to: '/path', host: 'host' do 48 | run lambda{ |env| 49 | [200, {}, 50 | ["#{env['HTTP_HOST']} #{env['SCRIPT_NAME']} #{env['PATH_INFO']}"]] 51 | } 52 | end 53 | end 54 | 55 | expect(get('/path', app, 'HTTP_HOST' => 'host').dig(-1, -1)). 56 | eq 'host /path' 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/jellyfish/json.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Jellyfish; end 4 | module Jellyfish::Json 5 | module MultiJson 6 | def self.extended mod 7 | mod.const_set(:ParseError, ::MultiJson::DecodeError) 8 | end 9 | def encode hash 10 | ::MultiJson.dump(hash) 11 | end 12 | def decode json 13 | ::MultiJson.load(json) 14 | end 15 | end 16 | 17 | module YajlRuby 18 | def self.extended mod 19 | mod.const_set(:ParseError, Yajl::ParseError) 20 | end 21 | def encode hash 22 | Yajl::Encoder.encode(hash) 23 | end 24 | def decode json 25 | Yajl::Parser.parse(json) 26 | end 27 | end 28 | 29 | module Json 30 | def self.extended mod 31 | mod.const_set(:ParseError, JSON::ParserError) 32 | end 33 | def encode hash 34 | JSON.dump(hash) 35 | end 36 | def decode json 37 | JSON.parse(json) 38 | end 39 | end 40 | 41 | def self.select_json! mod, picked=false 42 | if Object.const_defined?(:MultiJson) 43 | mod.send(:extend, MultiJson) 44 | elsif Object.const_defined?(:Yajl) 45 | mod.send(:extend, YajlRuby) 46 | elsif Object.const_defined?(:JSON) 47 | mod.send(:extend, Json) 48 | elsif picked 49 | raise LoadError.new( 50 | 'No JSON library found. Tried: multi_json, yajl-ruby, json.') 51 | else 52 | # pick a json gem if available 53 | %w[multi_json yajl json].each{ |json| 54 | begin 55 | require json 56 | break 57 | rescue LoadError 58 | end 59 | } 60 | select_json!(mod, true) 61 | end 62 | end 63 | 64 | select_json!(self) 65 | end 66 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env unicorn -N -Ilib 2 | 3 | require 'jellyfish' 4 | 5 | class Jelly 6 | include Jellyfish 7 | 8 | controller_include Module.new{ 9 | def dispatch 10 | headers_merge 'content-type' => 'application/json; charset=utf-8', 11 | 'Access-Control-Allow-Origin' => '*' 12 | super 13 | end 14 | 15 | def render obj 16 | ["#{Jellyfish::Json.encode(obj)}\n"] 17 | end 18 | } 19 | 20 | handle Jellyfish::NotFound do |e| 21 | status 404 22 | body %Q|{"error":{"name":"NotFound"}}\n| 23 | end 24 | 25 | handle StandardError do |error| 26 | jellyfish.log_error(error, env['rack.errors']) 27 | 28 | name = error.class.name 29 | message = error.message 30 | 31 | status 500 32 | body render('error' => {'name' => name, 'message' => message}) 33 | end 34 | 35 | get '/users' do 36 | render [:name => 'jellyfish'] 37 | end 38 | 39 | post '/users' do 40 | render :message => "jellyfish #{request.params['name']} created." 41 | end 42 | 43 | put %r{\A/users/(?\d+)} do |match| 44 | render :message => "jellyfish ##{match[:id]} updated." 45 | end 46 | 47 | delete %r{\A/users/(?\d+)} do |match| 48 | render :message => "jellyfish ##{match[:id]} deleted." 49 | end 50 | 51 | get %r{\A/posts/(?\d+)-(?\d+)/(?\w+)} do |match| 52 | render Hash[match.names.zip(match.captures)] 53 | end 54 | 55 | get '/posts/tags/ruby' do 56 | render [] 57 | end 58 | end 59 | 60 | App = Jellyfish::Builder.app do 61 | use Rack::CommonLogger 62 | use Rack::ContentLength 63 | use Rack::Deflater 64 | 65 | run Rack::Cascade.new([Rack::File.new('public/index.html'), 66 | Rack::File.new('public'), 67 | Jelly.new]) 68 | end 69 | 70 | run App 71 | -------------------------------------------------------------------------------- /lib/jellyfish/normalized_params.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'jellyfish' 4 | require 'rack/request' 5 | 6 | module Jellyfish 7 | module NormalizedParams 8 | attr_reader :params 9 | def block_call argument, block 10 | initialize_params(argument) 11 | super 12 | end 13 | 14 | private 15 | def initialize_params argument 16 | @params = force_encoding(indifferent_params( 17 | if argument.kind_of?(MatchData) 18 | # merge captured data from matcher into params as sinatra 19 | request.params.merge(Hash[argument.names.zip(argument.captures)]) 20 | else 21 | request.params 22 | end)) 23 | end 24 | 25 | # stolen from sinatra 26 | # Enable string or symbol key access to the nested params hash. 27 | def indifferent_params(params) 28 | params = indifferent_hash.merge(params) 29 | params.each do |key, value| 30 | next unless value.is_a?(Hash) 31 | params[key] = indifferent_params(value) 32 | end 33 | end 34 | 35 | # stolen from sinatra 36 | # Creates a Hash with indifferent access. 37 | def indifferent_hash 38 | Hash.new {|hash,key| hash[key.to_s] if Symbol === key } 39 | end 40 | 41 | # stolen from sinatra 42 | # Fixes encoding issues by casting params to Encoding.default_external 43 | def force_encoding(data, encoding=Encoding.default_external) 44 | return data if data.respond_to?(:rewind) # e.g. Tempfile, File, etc 45 | if data.respond_to?(:force_encoding) 46 | data.force_encoding(encoding).encode! 47 | elsif data.respond_to?(:each_value) 48 | data.each_value{ |v| force_encoding(v, encoding) } 49 | elsif data.respond_to?(:each) 50 | data.each{ |v| force_encoding(v, encoding) } 51 | end 52 | data 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/jellyfish/builder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'jellyfish/urlmap' 4 | 5 | module Jellyfish 6 | class Builder 7 | def self.app app=nil, from=nil, to=nil, &block 8 | new(app, &block).to_app(from, to) 9 | end 10 | 11 | def initialize app=nil, &block 12 | @use, @map, @run, @warmup = [], nil, app, nil 13 | instance_eval(&block) if block_given? 14 | end 15 | 16 | def use middleware, *args, &block 17 | if @map 18 | current_map, @map = @map, nil 19 | @use.unshift(lambda{ |app| generate_map(current_map, app) }) 20 | end 21 | @use.unshift(lambda{ |app| middleware.new(app, *args, &block) }) 22 | end 23 | 24 | def run app 25 | @run = app 26 | end 27 | 28 | def warmup lam=nil, &block 29 | @warmup = lam || block 30 | end 31 | 32 | def map path, to: nil, host: nil, &block 33 | key = if host then "http://#{File.join(host, path)}" else path end 34 | (@map ||= {})[key] = [block, path, to] 35 | end 36 | 37 | def listen host, &block 38 | map('', host: host, &block) 39 | end 40 | 41 | def rewrite rules, &block 42 | rules.each do |path, to| 43 | map(path, :to => to, &block) 44 | end 45 | end 46 | 47 | def to_app from=nil, to=nil 48 | run = if @map then generate_map(@map, @run) else @run end 49 | fail 'missing run or map statement' unless run 50 | app = @use.inject(run){ |a, m| m.call(a) } 51 | result = if to then Rewrite.new(app, from, to) else app end 52 | @warmup.call(result) if @warmup 53 | result 54 | end 55 | 56 | private 57 | def generate_map current_map, app 58 | mapped = if app then {'' => app} else {} end 59 | current_map.each do |path, (block, from, to)| 60 | mapped[path] = self.class.app(app, from, to, &block) 61 | end 62 | URLMap.new(mapped) 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /test/test_from_readme.rb: -------------------------------------------------------------------------------- 1 | 2 | require 'jellyfish/test' 3 | require 'uri' 4 | require 'stringio' 5 | 6 | describe 'from README.md' do 7 | paste :stringio 8 | 9 | after do 10 | [:Tank, :Heater, :Protector].each do |const| 11 | Object.send(:remove_const, const) if Object.const_defined?(const) 12 | end 13 | Muack.verify 14 | end 15 | 16 | readme = File.read("#{__dir__}/../README.md") 17 | codes = readme.scan( 18 | /### ([^\n]+).+?``` ruby\n(.+?)\n```\n\n/m) 19 | 20 | codes.each.with_index do |(title, code, test), index| 21 | would "pass from README.md #%02d #{title}" % index do 22 | app = Rack::Builder.app{ eval(code) } 23 | 24 | test.split("\n\n").each do |t| 25 | method_path, expect = t.strip.split("\n", 2) 26 | method, path, host = method_path.split(' ') 27 | uri = URI.parse(path) 28 | pinfo, query = uri.path, uri.query 29 | 30 | sock = nil 31 | status, headers, body = File.open(File::NULL) do |input| 32 | app.call( 33 | 'SERVER_PROTOCOL'=> 'HTTP/1.1', 34 | 'REQUEST_METHOD' => method, 35 | 'HTTP_HOST' => host, 36 | 'PATH_INFO' => pinfo, 37 | 'SCRIPT_NAME' => '', 38 | 'QUERY_STRING' => query, 39 | 'rack.input' => input, 40 | 'rack.url_scheme'=> 'http', 41 | 'rack.hijack' => lambda{ 42 | sock = new_stringio 43 | # or TypeError: no implicit conversion of StringIO into IO 44 | mock(IO).select([sock]){ [[sock], [], []] } 45 | sock 46 | }) 47 | end 48 | 49 | if hijack = headers.delete('rack.hijack') 50 | sock = new_stringio 51 | hijack.call(sock) 52 | body = sock.string.each_line("\n\n") 53 | end 54 | 55 | body.extend(Enumerable) 56 | [status, headers, body.to_a].should.eq eval(expect, binding, __FILE__) 57 | end 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /test/test_inheritance.rb: -------------------------------------------------------------------------------- 1 | 2 | require 'jellyfish/test' 3 | 4 | describe 'Inheritance' do 5 | paste :jellyfish 6 | 7 | would 'inherit routes' do 8 | sup = Class.new{ 9 | include Jellyfish 10 | get('/0'){ 'a' } 11 | } 12 | app = Class.new(sup){ 13 | get('/1'){ 'b' } 14 | }.new 15 | 16 | [['/0', 'a'], ['/1', 'b']].each do |(path, expect)| 17 | _, _, body = get(path, app) 18 | body.should.eq [expect] 19 | end 20 | 21 | _, _, body = get('/0', sup.new) 22 | body.should.eq ['a'] 23 | status, _, _ = get('/1', sup.new) 24 | status.should.eq 404 25 | 26 | sup .routes['get'].size.should.eq 1 27 | app.class.routes['get'].size.should.eq 2 28 | end 29 | 30 | would 'inherit handlers' do 31 | sup = Class.new{ 32 | include Jellyfish 33 | handle(TypeError){ 'a' } 34 | get('/type') { raise TypeError } 35 | get('/argue'){ raise ArgumentError } 36 | } 37 | app = Class.new(sup){ 38 | handle(ArgumentError){ 'b' } 39 | }.new 40 | 41 | [['/type', 'a'], ['/argue', 'b']].each do |(path, expect)| 42 | _, _, body = get(path, app) 43 | body.should.eq [expect] 44 | end 45 | 46 | sup .handlers.size.should.eq 1 47 | app.class.handlers.size.should.eq 2 48 | end 49 | 50 | would 'inherit controller' do 51 | sup = Class.new{ 52 | include Jellyfish 53 | controller_include Module.new{ def f; 'a'; end } 54 | get('/0'){ f } 55 | } 56 | app = Class.new(sup){ 57 | get('/1'){ f } 58 | }.new 59 | 60 | [['/0', 'a'], ['/1', 'a']].each do |(path, expect)| 61 | _, _, body = get(path, app) 62 | body.should.eq [expect] 63 | end 64 | 65 | sup .controller_include.size.should.eq 1 66 | app.class.controller_include.size.should.eq 1 67 | end 68 | 69 | would 'inherit handle_exceptions' do 70 | sup = Class.new{ 71 | include Jellyfish 72 | handle_exceptions false 73 | } 74 | app = Class.new(sup) 75 | 76 | sup.handle_exceptions.should.eq false 77 | app.handle_exceptions.should.eq false 78 | 79 | sup.handle_exceptions true 80 | sup.handle_exceptions.should.eq true 81 | app.handle_exceptions.should.eq false 82 | 83 | sup.handle_exceptions false 84 | app.handle_exceptions true 85 | sup.handle_exceptions.should.eq false 86 | app.handle_exceptions.should.eq true 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /lib/jellyfish/urlmap.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'uri' 4 | 5 | module Jellyfish 6 | class URLMap 7 | def initialize mapped_not_chomped 8 | mapped = transform_keys(mapped_not_chomped){ |k| k.sub(%r{/+\z}, '') } 9 | keys = mapped.keys 10 | @no_host = !keys.any?{ |k| match?(k, %r{\Ahttps?://}) } 11 | 12 | string = sort_keys(keys). 13 | map{ |k| build_regexp(k) }. 14 | join('|') 15 | 16 | @mapped = mapped 17 | @routes = %r{\A(?:#{string})(?:/|\z)} 18 | end 19 | 20 | def call env 21 | path_info = env['PATH_INFO'] 22 | 23 | if @no_host 24 | if matched = @routes.match(path_info) 25 | cut_path = matched.to_s.chomp('/') 26 | script_name = key = cut_path.squeeze('/') 27 | end 28 | else 29 | host = (env['HTTP_HOST'] || env['SERVER_NAME']).to_s.downcase 30 | if matched = @routes.match("#{host}/#{path_info}") 31 | cut_path = matched.to_s[host.size + 1..-1].chomp('/') 32 | script_name = cut_path.squeeze('/') 33 | 34 | key = 35 | if matched[:host] 36 | host_with_path = 37 | if script_name.empty? 38 | host 39 | else 40 | File.join(host, script_name) 41 | end 42 | "http://#{host_with_path}" 43 | else 44 | script_name 45 | end 46 | end 47 | end 48 | 49 | if app = @mapped[key] 50 | app.call(env.merge( 51 | 'SCRIPT_NAME' => env['SCRIPT_NAME'] + script_name, 52 | 'PATH_INFO' => path_info[cut_path.size..-1])) 53 | else 54 | [404, {}, []] 55 | end 56 | end 57 | 58 | private 59 | 60 | def build_regexp path 61 | if @no_host 62 | regexp_path(path) 63 | elsif matched = path.match(%r{\Ahttps?://([^/]+)(/?.*)}) 64 | # We only need to know if we're matching against a host, 65 | # therefore just an empty group is sufficient. 66 | "(?)#{matched[1]}/#{regexp_path(matched[2])}" 67 | else 68 | "[^/]*/#{regexp_path(path)}" 69 | end 70 | end 71 | 72 | def regexp_path path 73 | Regexp.escape(path).gsub('/', '/+') 74 | end 75 | 76 | def transform_keys hash, &block 77 | if hash.respond_to?(:transform_keys) 78 | hash.transform_keys(&block) 79 | else 80 | hash.inject({}) do |result, (key, value)| 81 | result[yield(key)] = value 82 | result 83 | end 84 | end 85 | end 86 | 87 | def match? string, regexp 88 | if string.respond_to?(:match?) 89 | string.match?(regexp) 90 | else 91 | string =~ regexp 92 | end 93 | end 94 | 95 | def sort_keys keys 96 | keys.sort_by do |k| 97 | uri = URI.parse(k) 98 | 99 | [-uri.path.to_s.size, -uri.host.to_s.size] 100 | end 101 | end 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /jellyfish.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | # stub: jellyfish 1.4.0 ruby lib 3 | 4 | Gem::Specification.new do |s| 5 | s.name = "jellyfish".freeze 6 | s.version = "1.4.0" 7 | 8 | s.required_rubygems_version = Gem::Requirement.new(">= 0".freeze) if s.respond_to? :required_rubygems_version= 9 | s.require_paths = ["lib".freeze] 10 | s.authors = ["Lin Jen-Shin (godfat)".freeze] 11 | s.date = "2023-02-22" 12 | s.description = "Pico web framework for building API-centric web applications.\nFor Rack applications or Rack middleware. Around 250 lines of code.\n\nCheck [jellyfish-contrib][] for extra extensions.\n\n[jellyfish-contrib]: https://github.com/godfat/jellyfish-contrib".freeze 13 | s.email = ["godfat (XD) godfat.org".freeze] 14 | s.files = [ 15 | ".gitignore".freeze, 16 | ".gitlab-ci.yml".freeze, 17 | ".gitmodules".freeze, 18 | "CHANGES.md".freeze, 19 | "Gemfile".freeze, 20 | "LICENSE".freeze, 21 | "README.md".freeze, 22 | "Rakefile".freeze, 23 | "TODO.md".freeze, 24 | "bench/bench_builder.rb".freeze, 25 | "config.ru".freeze, 26 | "jellyfish.gemspec".freeze, 27 | "lib/jellyfish.rb".freeze, 28 | "lib/jellyfish/builder.rb".freeze, 29 | "lib/jellyfish/chunked_body.rb".freeze, 30 | "lib/jellyfish/json.rb".freeze, 31 | "lib/jellyfish/normalized_params.rb".freeze, 32 | "lib/jellyfish/normalized_path.rb".freeze, 33 | "lib/jellyfish/public/302.html".freeze, 34 | "lib/jellyfish/public/404.html".freeze, 35 | "lib/jellyfish/public/500.html".freeze, 36 | "lib/jellyfish/rewrite.rb".freeze, 37 | "lib/jellyfish/test.rb".freeze, 38 | "lib/jellyfish/urlmap.rb".freeze, 39 | "lib/jellyfish/version.rb".freeze, 40 | "lib/jellyfish/websocket.rb".freeze, 41 | "task/README.md".freeze, 42 | "task/gemgem.rb".freeze, 43 | "test/rack/test_builder.rb".freeze, 44 | "test/rack/test_urlmap.rb".freeze, 45 | "test/sinatra/test_base.rb".freeze, 46 | "test/sinatra/test_chunked_body.rb".freeze, 47 | "test/sinatra/test_error.rb".freeze, 48 | "test/sinatra/test_routing.rb".freeze, 49 | "test/test_from_readme.rb".freeze, 50 | "test/test_inheritance.rb".freeze, 51 | "test/test_listen.rb".freeze, 52 | "test/test_log.rb".freeze, 53 | "test/test_misc.rb".freeze, 54 | "test/test_rewrite.rb".freeze, 55 | "test/test_threads.rb".freeze, 56 | "test/test_websocket.rb".freeze] 57 | s.homepage = "https://github.com/godfat/jellyfish".freeze 58 | s.licenses = ["Apache-2.0".freeze] 59 | s.rubygems_version = "3.4.3".freeze 60 | s.summary = "Pico web framework for building API-centric web applications.".freeze 61 | s.test_files = [ 62 | "test/rack/test_builder.rb".freeze, 63 | "test/rack/test_urlmap.rb".freeze, 64 | "test/sinatra/test_base.rb".freeze, 65 | "test/sinatra/test_chunked_body.rb".freeze, 66 | "test/sinatra/test_error.rb".freeze, 67 | "test/sinatra/test_routing.rb".freeze, 68 | "test/test_from_readme.rb".freeze, 69 | "test/test_inheritance.rb".freeze, 70 | "test/test_listen.rb".freeze, 71 | "test/test_log.rb".freeze, 72 | "test/test_misc.rb".freeze, 73 | "test/test_rewrite.rb".freeze, 74 | "test/test_threads.rb".freeze, 75 | "test/test_websocket.rb".freeze] 76 | end 77 | -------------------------------------------------------------------------------- /test/test_listen.rb: -------------------------------------------------------------------------------- 1 | 2 | require 'jellyfish/test' 3 | require 'jellyfish/urlmap' 4 | 5 | describe Jellyfish::URLMap do 6 | paste :jellyfish 7 | 8 | lam = lambda{ |env| [200, {}, ["lam #{env['HTTP_HOST']}"]] } 9 | ram = lambda{ |env| [200, {}, ["ram #{env['HTTP_HOST']}"]] } 10 | 11 | def call app, host, path: '/', scheme: 'http' 12 | get('/', app, 13 | 'HTTP_HOST' => host, 14 | 'PATH_INFO' => path, 15 | 'rack.url_scheme' => scheme).dig(-1, 0) 16 | end 17 | 18 | would 'map host' do 19 | app = Jellyfish::Builder.app do 20 | map '/', host: 'host' do 21 | run lam 22 | end 23 | 24 | run ram 25 | end 26 | 27 | expect(call(app, 'host')).eq 'lam host' 28 | expect(call(app, 'lust')).eq 'ram lust' 29 | end 30 | 31 | would 'map host with path' do 32 | app = Jellyfish::Builder.app do 33 | map '/path', host: 'host' do 34 | run lam 35 | end 36 | 37 | map '/path' do 38 | run ram 39 | end 40 | end 41 | 42 | expect(call(app, 'host', path: '/path')).eq 'lam host' 43 | expect(call(app, 'lust', path: '/path')).eq 'ram lust' 44 | end 45 | 46 | would 'map longest path first' do 47 | app = Jellyfish::Builder.app do 48 | map '/long/path' do 49 | run lam 50 | end 51 | 52 | map '/', host: 'super-long-host' do 53 | run ram 54 | end 55 | end 56 | 57 | expect(call(app, 'super-long-host', path: '/long/path')). 58 | eq 'lam super-long-host' 59 | end 60 | 61 | would 'map host with http or https' do 62 | app = Jellyfish::Builder.app do 63 | map '/', host: 'host' do 64 | run lam 65 | end 66 | end 67 | 68 | expect(call(app, 'host')).eq 'lam host' 69 | expect(call(app, 'host', scheme: 'https')).eq 'lam host' 70 | end 71 | 72 | would 'map http with http or https' do 73 | app = Jellyfish::Builder.app do 74 | map 'http://host/' do 75 | run lam 76 | end 77 | end 78 | 79 | expect(call(app, 'host')).eq 'lam host' 80 | expect(call(app, 'host', scheme: 'https')).eq 'lam host' 81 | end 82 | 83 | would 'listen' do 84 | app = Jellyfish::Builder.app do 85 | listen 'host' do 86 | run lam 87 | end 88 | 89 | listen 'lust' do 90 | run ram 91 | end 92 | end 93 | 94 | expect(call(app, 'host')).eq 'lam host' 95 | expect(call(app, 'lust')).eq 'ram lust' 96 | expect(call(app, 'boom')).eq nil 97 | end 98 | 99 | would 'nest' do 100 | app = Jellyfish::Builder.app do 101 | listen 'host' do 102 | map '/host' do 103 | run lam 104 | end 105 | end 106 | 107 | listen 'lust' do 108 | map '/lust' do 109 | run ram 110 | end 111 | end 112 | end 113 | 114 | expect(call(app, 'host', path: '/host')).eq 'lam host' 115 | expect(call(app, 'lust', path: '/lust')).eq 'ram lust' 116 | expect(call(app, 'boom', path: '/host')).eq nil 117 | expect(call(app, 'boom', path: '/lust')).eq nil 118 | end 119 | end 120 | -------------------------------------------------------------------------------- /test/sinatra/test_base.rb: -------------------------------------------------------------------------------- 1 | 2 | require 'jellyfish/test' 3 | 4 | # stolen from sinatra 5 | describe 'Sinatra base_test.rb' do 6 | paste :jellyfish 7 | 8 | would 'process requests with #call' do 9 | app = Class.new{ 10 | include Jellyfish 11 | get '/' do 12 | 'Hello World' 13 | end 14 | }.new 15 | app.respond_to?(:call).should.eq true 16 | status, _, body = get('/', app) 17 | status.should.eq 200 18 | body .should.eq ['Hello World'] 19 | end 20 | 21 | would 'not maintain state between requests' do 22 | app = Class.new{ 23 | include Jellyfish 24 | get '/state' do 25 | @foo ||= 'new' 26 | body = "Foo: #{@foo}" 27 | @foo = 'discard' 28 | body 29 | end 30 | }.new 31 | 32 | 2.times do 33 | status, _, body = get('/state', app) 34 | status.should.eq 200 35 | body .should.eq ['Foo: new'] 36 | end 37 | end 38 | 39 | describe 'Jellyfish as a Rack middleware' do 40 | inner_app ||= lambda{ |env| 41 | [210, {'x-downstream' => 'true'}, ['Hello from downstream']] 42 | } 43 | 44 | app = Class.new{ 45 | include Jellyfish 46 | get '/' do 47 | 'Hello from middleware' 48 | end 49 | 50 | get '/low-level-forward' do 51 | status, headers, body = jellyfish.app.call(env) 52 | self.status status 53 | self.headers headers 54 | body 55 | end 56 | 57 | get '/explicit-forward' do 58 | headers_merge 'x-middleware' => 'true' 59 | status, headers, _ = jellyfish.app.call(env) 60 | self.status status 61 | self.headers headers 62 | 'Hello after explicit forward' 63 | end 64 | }.new(inner_app) 65 | 66 | would 'create a middleware that responds to #call with .new' do 67 | app.respond_to?(:call).should.eq true 68 | end 69 | 70 | would 'expose the downstream app' do 71 | app.app.object_id.should.eq inner_app.object_id 72 | end 73 | 74 | would 'intercept requests' do 75 | status, _, body = get('/', app) 76 | status.should.eq 200 77 | body .should.eq ['Hello from middleware'] 78 | end 79 | 80 | would 'forward requests downstream when no matching route found' do 81 | status, headers, body = get('/missing', app) 82 | status .should.eq 210 83 | headers['x-downstream'].should.eq 'true' 84 | body .should.eq ['Hello from downstream'] 85 | end 86 | 87 | would 'call the downstream app directly and return result' do 88 | status, headers, body = get('/low-level-forward', app) 89 | status .should.eq 210 90 | headers['x-downstream'].should.eq 'true' 91 | body .should.eq ['Hello from downstream'] 92 | end 93 | 94 | would 'forward the request and integrate the response' do 95 | status, headers, body = 96 | get('/explicit-forward', Rack::ContentLength.new(app)) 97 | 98 | status .should.eq 210 99 | headers['x-downstream'] .should.eq 'true' 100 | headers['content-length'].should.eq '28' 101 | body.to_a .should.eq ['Hello after explicit forward'] 102 | end 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /test/sinatra/test_error.rb: -------------------------------------------------------------------------------- 1 | 2 | require 'jellyfish/test' 3 | 4 | describe 'Sinatra mapped_error_test.rb' do 5 | paste :jellyfish 6 | 7 | exp = Class.new(RuntimeError) 8 | 9 | would 'invoke handlers registered with handle when raised' do 10 | app = Class.new{ 11 | include Jellyfish 12 | handle(exp){ 'Foo!' } 13 | get '/' do 14 | raise exp 15 | end 16 | }.new 17 | 18 | status, _, body = get('/', app) 19 | status.should.eq 200 20 | body .should.eq ['Foo!'] 21 | end 22 | 23 | would 'pass the exception object to the error handler' do 24 | app = Class.new{ 25 | include Jellyfish 26 | handle(exp){ |e| e.should.kind_of?(exp) } 27 | get('/'){ raise exp } 28 | }.new 29 | get('/', app) 30 | end 31 | 32 | would 'use the StandardError handler if no matching handler found' do 33 | app = Class.new{ 34 | include Jellyfish 35 | handle(StandardError){ 'StandardError!' } 36 | get('/'){ raise exp } 37 | }.new 38 | 39 | status, _, body = get('/', app) 40 | status.should.eq 200 41 | body .should.eq ['StandardError!'] 42 | end 43 | 44 | would 'favour subclass handler over superclass handler if available' do 45 | app = Class.new{ 46 | include Jellyfish 47 | handle(StandardError){ 'StandardError!' } 48 | handle(RuntimeError) { 'RuntimeError!' } 49 | get('/'){ raise exp } 50 | }.new 51 | 52 | status, _, body = get('/', app) 53 | status.should.eq 200 54 | body .should.eq ['RuntimeError!'] 55 | 56 | handlers = app.class.handlers 57 | handlers.size.should.eq 3 58 | handlers[exp].should.eq handlers[RuntimeError] 59 | end 60 | 61 | would 'pass the exception to the handler' do 62 | app = Class.new{ 63 | include Jellyfish 64 | handle(exp){ |e| 65 | e.should.kind_of?(exp) 66 | 'looks good' 67 | } 68 | get('/'){ raise exp } 69 | }.new 70 | 71 | _, _, body = get('/', app) 72 | body.should.eq ['looks good'] 73 | end 74 | 75 | would 'raise errors from the app when handle_exceptions is false' do 76 | app = Class.new{ 77 | include Jellyfish 78 | handle_exceptions false 79 | get('/'){ raise exp } 80 | }.new 81 | 82 | lambda{ get('/', app) }.should.raise(exp) 83 | end 84 | 85 | would 'call error handlers even when handle_exceptions is false' do 86 | app = Class.new{ 87 | include Jellyfish 88 | handle_exceptions false 89 | handle(exp){ "she's there." } 90 | get('/'){ raise exp } 91 | }.new 92 | 93 | _, _, body = get('/', app) 94 | body.should.eq ["she's there."] 95 | end 96 | 97 | would 'catch Jellyfish::NotFound' do 98 | app = Class.new{ 99 | include Jellyfish 100 | get('/'){ not_found } 101 | }.new 102 | 103 | status, _, _ = get('/', app) 104 | status.should.eq 404 105 | end 106 | 107 | would 'handle subclasses of Jellyfish::NotFound' do 108 | e = Class.new(Jellyfish::NotFound) 109 | app = Class.new{ 110 | include Jellyfish 111 | get('/'){ halt e.new } 112 | }.new 113 | 114 | status, _, _ = get('/', app) 115 | status.should.eq 404 116 | end 117 | 118 | would 'no longer cascade with Jellyfish::NotFound' do 119 | app = Class.new{ 120 | include Jellyfish 121 | get('/'){ not_found } 122 | }.new(Class.new{ 123 | include Jellyfish 124 | get('/'){ 'never'.should.eq 'reach' } 125 | }) 126 | 127 | status, _, _ = get('/', app) 128 | status.should.eq 404 129 | end 130 | 131 | would 'cascade with Jellyfish::Cascade' do 132 | app = Class.new{ 133 | include Jellyfish 134 | get('/'){ cascade } 135 | }.new(Class.new{ 136 | include Jellyfish 137 | get('/'){ 'reach' } 138 | }.new) 139 | 140 | status, _, body = get('/', app) 141 | status.should.eq 200 142 | body .should.eq ['reach'] 143 | end 144 | 145 | would 'inherit error mappings from base class' do 146 | sup = Class.new{ 147 | include Jellyfish 148 | handle(exp){ 'sup' } 149 | } 150 | app = Class.new(sup){ 151 | get('/'){ raise exp } 152 | }.new 153 | 154 | _, _, body = get('/', app) 155 | body.should.eq ['sup'] 156 | end 157 | 158 | would 'override error mappings in base class' do 159 | sup = Class.new{ 160 | include Jellyfish 161 | handle(exp){ 'sup' } 162 | } 163 | app = Class.new(sup){ 164 | handle(exp){ 'sub' } 165 | get('/'){ raise exp } 166 | }.new 167 | 168 | _, _, body = get('/', app) 169 | body.should.eq ['sub'] 170 | end 171 | end 172 | -------------------------------------------------------------------------------- /test/rack/test_builder.rb: -------------------------------------------------------------------------------- 1 | 2 | require 'jellyfish/test' 3 | 4 | require 'rack/lint' 5 | require 'rack/mock' 6 | require 'rack/show_exceptions' 7 | require 'rack/urlmap' 8 | 9 | describe Jellyfish::Builder do 10 | class NothingMiddleware 11 | def initialize(app) 12 | @app = app 13 | end 14 | def call(env) 15 | @@env = env 16 | response = @app.call(env) 17 | response 18 | end 19 | def self.env 20 | @@env 21 | end 22 | end 23 | 24 | def builder_to_app(&block) 25 | Rack::Lint.new Jellyfish::Builder.app(&block) 26 | end 27 | 28 | would "supports mapping" do 29 | app = builder_to_app do 30 | map '/' do |outer_env| 31 | run lambda { |inner_env| [200, {"content-type" => "text/plain"}, ['root']] } 32 | end 33 | map '/sub' do 34 | run lambda { |inner_env| [200, {"content-type" => "text/plain"}, ['sub']] } 35 | end 36 | end 37 | Rack::MockRequest.new(app).get("/").body.to_s.should.eq 'root' 38 | Rack::MockRequest.new(app).get("/sub").body.to_s.should.eq 'sub' 39 | end 40 | 41 | would "chains apps by default" do 42 | app = builder_to_app do 43 | use Rack::ShowExceptions 44 | run lambda { |env| raise "bzzzt" } 45 | end 46 | 47 | Rack::MockRequest.new(app).get("/").should.server_error? 48 | Rack::MockRequest.new(app).get("/").should.server_error? 49 | Rack::MockRequest.new(app).get("/").should.server_error? 50 | end 51 | 52 | would "supports blocks on use" do 53 | app = builder_to_app do 54 | use Rack::ShowExceptions 55 | use Rack::Auth::Basic do |username, password| 56 | 'secret' == password 57 | end 58 | 59 | run lambda { |env| [200, {"content-type" => "text/plain"}, ['Hi Boss']] } 60 | end 61 | 62 | response = Rack::MockRequest.new(app).get("/") 63 | response.should.client_error? 64 | response.status.should.eq 401 65 | 66 | # with auth... 67 | response = Rack::MockRequest.new(app).get("/", 68 | 'HTTP_AUTHORIZATION' => 'Basic ' + ["joe:secret"].pack("m*")) 69 | response.status.should.eq 200 70 | response.body.to_s.should.eq 'Hi Boss' 71 | end 72 | 73 | would "has explicit #to_app" do 74 | app = builder_to_app do 75 | use Rack::ShowExceptions 76 | run lambda { |env| raise "bzzzt" } 77 | end 78 | 79 | Rack::MockRequest.new(app).get("/").should.server_error? 80 | Rack::MockRequest.new(app).get("/").should.server_error? 81 | Rack::MockRequest.new(app).get("/").should.server_error? 82 | end 83 | 84 | would "can mix map and run for endpoints" do 85 | app = builder_to_app do 86 | map '/sub' do 87 | run lambda { |inner_env| [200, {"content-type" => "text/plain"}, ['sub']] } 88 | end 89 | run lambda { |inner_env| [200, {"content-type" => "text/plain"}, ['root']] } 90 | end 91 | 92 | Rack::MockRequest.new(app).get("/").body.to_s.should.eq 'root' 93 | Rack::MockRequest.new(app).get("/sub").body.to_s.should.eq 'sub' 94 | end 95 | 96 | would "accepts middleware-only map blocks" do 97 | app = builder_to_app do 98 | map('/foo') { use Rack::ShowExceptions } 99 | run lambda { |env| raise "bzzzt" } 100 | end 101 | 102 | proc { Rack::MockRequest.new(app).get("/") }.should.raise(RuntimeError) 103 | Rack::MockRequest.new(app).get("/foo").should.server_error? 104 | end 105 | 106 | would "yields the generated app to a block for warmup" do 107 | warmed_up_app = nil 108 | 109 | app = Rack::Builder.new do 110 | warmup { |a| warmed_up_app = a } 111 | run lambda { |env| [200, {}, []] } 112 | end.to_app 113 | 114 | warmed_up_app.should.eq app 115 | end 116 | 117 | would "initialize apps once" do 118 | app = builder_to_app do 119 | class AppClass 120 | def initialize 121 | @called = 0 122 | end 123 | def call(env) 124 | raise "bzzzt" if @called > 0 125 | @called += 1 126 | [200, {'content-type' => 'text/plain'}, ['OK']] 127 | end 128 | end 129 | 130 | use Rack::ShowExceptions 131 | run AppClass.new 132 | end 133 | 134 | Rack::MockRequest.new(app).get("/").status.should.eq 200 135 | Rack::MockRequest.new(app).get("/").should.server_error? 136 | end 137 | 138 | would "allows use after run" do 139 | app = builder_to_app do 140 | run lambda { |env| raise "bzzzt" } 141 | use Rack::ShowExceptions 142 | end 143 | 144 | Rack::MockRequest.new(app).get("/").should.server_error? 145 | Rack::MockRequest.new(app).get("/").should.server_error? 146 | Rack::MockRequest.new(app).get("/").should.server_error? 147 | end 148 | 149 | would 'complains about a missing run' do 150 | proc do 151 | Rack::Lint.new Rack::Builder.app { use Rack::ShowExceptions } 152 | end.should.raise(RuntimeError) 153 | end 154 | end 155 | -------------------------------------------------------------------------------- /lib/jellyfish.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Jellyfish 4 | autoload :VERSION , 'jellyfish/version' 5 | 6 | autoload :NormalizedParams, 'jellyfish/normalized_params' 7 | autoload :NormalizedPath , 'jellyfish/normalized_path' 8 | 9 | autoload :Builder , 'jellyfish/builder' 10 | autoload :Rewrite , 'jellyfish/rewrite' 11 | autoload :ChunkedBody, 'jellyfish/chunked_body' 12 | autoload :WebSocket , 'jellyfish/websocket' 13 | 14 | Cascade = Object.new 15 | GetValue = Object.new 16 | 17 | class Response < RuntimeError 18 | def headers 19 | @headers ||= {'content-type' => 'text/html'} 20 | end 21 | 22 | def body 23 | @body ||= [File.read("#{Jellyfish.public_root}/#{status}.html")] 24 | end 25 | end 26 | 27 | class InternalError < Response; def status; 500; end; end 28 | class NotFound < Response; def status; 404; end; end 29 | class Found < Response # this would be raised in redirect 30 | attr_reader :url 31 | def initialize url; @url = url ; end 32 | def status ; 302 ; end 33 | def headers ; super.merge('location' => url) ; end 34 | def body ; super.map{ |b| b.gsub('VAR_URL', url) }; end 35 | end 36 | 37 | # ----------------------------------------------------------------- 38 | 39 | class Controller 40 | attr_reader :routes, :jellyfish, :env 41 | def initialize routes, jellyfish 42 | @routes, @jellyfish = routes, jellyfish 43 | @status, @headers, @body = nil 44 | end 45 | 46 | def call env 47 | @env = env 48 | block_call(*dispatch) 49 | end 50 | 51 | def block_call argument, block 52 | val = instance_exec(argument, &block) 53 | [status || 200, headers || {}, body || rack_body(val || '')] 54 | rescue LocalJumpError 55 | log("Use `next' if you're trying to `return' or `break' from a block.") 56 | raise 57 | end 58 | 59 | def log message; jellyfish.log( message, env); end 60 | def log_error error; jellyfish.log_error(error, env); end 61 | def request ; @request ||= Rack::Request.new(env); end 62 | def halt *args; throw(:halt, *args) ; end 63 | def cascade ; halt(Jellyfish::Cascade) ; end 64 | def not_found ; halt(Jellyfish::NotFound.new) ; end 65 | def found url; halt(Jellyfish:: Found.new(url)); end 66 | alias_method :redirect, :found 67 | 68 | def path_info ; env['PATH_INFO'] || '/' ; end 69 | def request_method; env['REQUEST_METHOD'] || 'GET'; end 70 | 71 | %w[status headers].each do |field| 72 | module_eval <<-RUBY 73 | def #{field} value=GetValue 74 | if value == GetValue 75 | @#{field} 76 | else 77 | @#{field} = value 78 | end 79 | end 80 | RUBY 81 | end 82 | 83 | def body value=GetValue 84 | if value == GetValue 85 | @body 86 | elsif value.nil? 87 | @body = value 88 | else 89 | @body = rack_body(value) 90 | end 91 | end 92 | 93 | def headers_merge value 94 | if headers.nil? 95 | headers(value) 96 | else 97 | headers(headers.merge(value)) 98 | end 99 | end 100 | 101 | private 102 | def actions 103 | routes[request_method.downcase] || action_missing 104 | end 105 | 106 | def dispatch 107 | actions.find{ |(route, block)| 108 | case route 109 | when String 110 | break route, block if route == path_info 111 | else#Regexp, using else allows you to use custom matcher 112 | match = route.match(path_info) 113 | break match, block if match 114 | end 115 | } || action_missing 116 | end 117 | 118 | def action_missing 119 | if jellyfish.app then cascade else not_found end 120 | end 121 | 122 | def rack_body v 123 | if v.respond_to?(:each) || v.respond_to?(:to_path) then v else [v] end 124 | end 125 | end 126 | 127 | # ----------------------------------------------------------------- 128 | 129 | module DSL 130 | def routes ; @routes ||= {}; end 131 | def handlers; @handlers ||= {}; end 132 | def handle *exceptions, &block 133 | exceptions.each{ |exp| handlers[exp] = block } 134 | end 135 | def handle_exceptions value=GetValue 136 | if value == GetValue 137 | @handle_exceptions 138 | else 139 | @handle_exceptions = value 140 | end 141 | end 142 | 143 | def controller_include *value 144 | (@controller_include ||= []).push(*value) 145 | end 146 | 147 | def controller value=GetValue 148 | if value == GetValue 149 | @controller ||= controller_inject( 150 | const_set(:Controller, Class.new(Controller))) 151 | else 152 | @controller = controller_inject(value) 153 | end 154 | end 155 | 156 | def controller_inject value 157 | controller_include. 158 | inject(value){ |ctrl, mod| ctrl.__send__(:include, mod) } 159 | end 160 | 161 | %w[options get head post put delete patch].each do |method| 162 | define_method(method) do |route=//, meta={}, &block| 163 | define(method, route, meta, &block) 164 | end 165 | end 166 | 167 | def define(method, route=//, meta={}, &block) 168 | raise TypeError.new("Route #{route} should respond to :match") \ 169 | unless route.respond_to?(:match) 170 | 171 | (routes[method.to_s] ||= []) << [route, block || lambda{|_|}, meta] 172 | end 173 | 174 | def inherited sub 175 | sub.handle_exceptions(handle_exceptions) 176 | sub.controller_include(*controller_include) 177 | [:handlers, :routes].each{ |m| 178 | val = __send__(m).inject({}){ |r, (k, v)| r[k] = v.dup; r } 179 | sub.__send__(m).replace(val) # dup the routing arrays 180 | } 181 | end 182 | end 183 | 184 | # ----------------------------------------------------------------- 185 | 186 | def initialize app=nil; @app = app; end 187 | 188 | def call env 189 | ctrl = self.class.controller.new(self.class.routes, self) 190 | case res = catch(:halt){ ctrl.call(env) } 191 | when Cascade 192 | cascade(ctrl, env) 193 | when Response 194 | handle(ctrl, res, env) 195 | when Array 196 | res 197 | when NilClass # make sure we return rack triple 198 | ctrl.block_call(nil, lambda{|_|_}) 199 | else 200 | raise TypeError.new("Expect the response to be a Jellyfish::Response" \ 201 | " or Rack triple (Array), but got: #{res.inspect}") 202 | end 203 | rescue => error 204 | handle(ctrl, error, env) 205 | end 206 | 207 | def log_error error, env 208 | env['rack.errors']&. 209 | puts("[#{self.class.name}] #{error.inspect}" \ 210 | " for #{env['PATH_INFO'] || '/'} #{error.backtrace}") 211 | end 212 | 213 | def log msg, env 214 | env['rack.errors']&. 215 | puts("[#{self.class.name}] #{msg}") 216 | end 217 | 218 | private 219 | def cascade ctrl, env 220 | app.call(env) 221 | rescue => error 222 | handle(ctrl, error, env) 223 | end 224 | 225 | def handle ctrl, error, env 226 | if handler = best_handler(error) 227 | ctrl.block_call(error, handler) 228 | elsif !self.class.handle_exceptions 229 | raise error 230 | elsif error.kind_of?(Response) # InternalError ends up here if no handlers 231 | [error.status, error.headers, error.body] 232 | else # fallback and see if there's any InternalError handler 233 | log_error(error, env) 234 | handle(ctrl, InternalError.new, env) 235 | end 236 | end 237 | 238 | def best_handler error 239 | handlers = self.class.handlers 240 | if handlers.key?(error.class) 241 | handlers[error.class] 242 | else # or find the nearest match and cache it 243 | ancestors = error.class.ancestors 244 | handlers[error.class] = handlers.dup. # thread safe iteration 245 | inject([nil, Float::INFINITY]){ |(handler, val), (klass, block)| 246 | idx = ancestors.index(klass) || Float::INFINITY # lower is better 247 | if idx < val then [block, idx] else [handler, val] end }.first 248 | end 249 | end 250 | 251 | # ----------------------------------------------------------------- 252 | 253 | def self.included mod 254 | mod.__send__(:extend, DSL) 255 | mod.__send__(:attr_reader, :app) 256 | mod.handle_exceptions(true) 257 | end 258 | 259 | # ----------------------------------------------------------------- 260 | 261 | module_function 262 | def public_root 263 | "#{File.dirname(__FILE__)}/jellyfish/public" 264 | end 265 | end 266 | -------------------------------------------------------------------------------- /test/rack/test_urlmap.rb: -------------------------------------------------------------------------------- 1 | 2 | require 'jellyfish/test' 3 | require 'jellyfish/urlmap' 4 | 5 | require 'rack/mock' 6 | 7 | describe Jellyfish::URLMap do 8 | would "dispatches paths correctly" do 9 | app = lambda { |env| 10 | [200, { 11 | 'x-scriptname' => env['SCRIPT_NAME'], 12 | 'x-pathinfo' => env['PATH_INFO'], 13 | 'content-type' => 'text/plain' 14 | }, [""]] 15 | } 16 | map = Rack::Lint.new(Jellyfish::URLMap.new({ 17 | 'http://foo.org/bar' => app, 18 | '/foo' => app, 19 | '/foo/bar' => app 20 | })) 21 | 22 | res = Rack::MockRequest.new(map).get("/") 23 | res.should.not_found? 24 | 25 | res = Rack::MockRequest.new(map).get("/qux") 26 | res.should.not_found? 27 | 28 | res = Rack::MockRequest.new(map).get("/foo") 29 | res.should.ok? 30 | res["x-scriptname"].should.eq "/foo" 31 | res["x-pathinfo"].should.eq "" 32 | 33 | res = Rack::MockRequest.new(map).get("/foo/") 34 | res.should.ok? 35 | res["x-scriptname"].should.eq "/foo" 36 | res["x-pathinfo"].should.eq "/" 37 | 38 | res = Rack::MockRequest.new(map).get("/foo/bar") 39 | res.should.ok? 40 | res["x-scriptname"].should.eq "/foo/bar" 41 | res["x-pathinfo"].should.eq "" 42 | 43 | res = Rack::MockRequest.new(map).get("/foo/bar/") 44 | res.should.ok? 45 | res["x-scriptname"].should.eq "/foo/bar" 46 | res["x-pathinfo"].should.eq "/" 47 | 48 | res = Rack::MockRequest.new(map).get("/foo///bar//quux") 49 | res.status.should.eq 200 50 | res.should.ok? 51 | res["x-scriptname"].should.eq "/foo/bar" 52 | res["x-pathinfo"].should.eq "//quux" 53 | 54 | res = Rack::MockRequest.new(map).get("/foo/quux", "SCRIPT_NAME" => "/bleh") 55 | res.should.ok? 56 | res["x-scriptname"].should.eq "/bleh/foo" 57 | res["x-pathinfo"].should.eq "/quux" 58 | 59 | res = Rack::MockRequest.new(map).get("/bar", 'HTTP_HOST' => 'foo.org') 60 | res.should.ok? 61 | res["x-scriptname"].should.eq "/bar" 62 | res["x-pathinfo"].should.empty? 63 | 64 | res = Rack::MockRequest.new(map).get("/bar/", 'HTTP_HOST' => 'foo.org') 65 | res.should.ok? 66 | res["x-scriptname"].should.eq "/bar" 67 | res["x-pathinfo"].should.eq '/' 68 | end 69 | 70 | would "dispatches hosts correctly" do 71 | map = Rack::Lint.new(Jellyfish::URLMap.new("http://foo.org/" => lambda { |env| 72 | [200, 73 | { "content-type" => "text/plain", 74 | "x-position" => "foo.org", 75 | "x-host" => env["HTTP_HOST"] || env["SERVER_NAME"], 76 | }, [""]]}, 77 | "http://subdomain.foo.org/" => lambda { |env| 78 | [200, 79 | { "content-type" => "text/plain", 80 | "x-position" => "subdomain.foo.org", 81 | "x-host" => env["HTTP_HOST"] || env["SERVER_NAME"], 82 | }, [""]]}, 83 | "http://bar.org/" => lambda { |env| 84 | [200, 85 | { "content-type" => "text/plain", 86 | "x-position" => "bar.org", 87 | "x-host" => env["HTTP_HOST"] || env["SERVER_NAME"], 88 | }, [""]]}, 89 | "/" => lambda { |env| 90 | [200, 91 | { "content-type" => "text/plain", 92 | "x-position" => "default.org", 93 | "x-host" => env["HTTP_HOST"] || env["SERVER_NAME"], 94 | }, [""]]} 95 | )) 96 | 97 | res = Rack::MockRequest.new(map).get("/") 98 | res.should.ok? 99 | res["x-position"].should.eq "default.org" 100 | 101 | res = Rack::MockRequest.new(map).get("/", "HTTP_HOST" => "bar.org") 102 | res.should.ok? 103 | res["x-position"].should.eq "bar.org" 104 | 105 | res = Rack::MockRequest.new(map).get("/", "HTTP_HOST" => "foo.org") 106 | res.should.ok? 107 | res["x-position"].should.eq "foo.org" 108 | 109 | res = Rack::MockRequest.new(map).get("/", "HTTP_HOST" => "subdomain.foo.org", "SERVER_NAME" => "foo.org") 110 | res.should.ok? 111 | res["x-position"].should.eq "subdomain.foo.org" 112 | 113 | res = Rack::MockRequest.new(map).get("http://foo.org/") 114 | res.should.ok? 115 | res["x-position"].should.eq "foo.org" 116 | 117 | res = Rack::MockRequest.new(map).get("/", "HTTP_HOST" => "example.org") 118 | res.should.ok? 119 | res["x-position"].should.eq "default.org" 120 | 121 | res = Rack::MockRequest.new(map).get("/", 122 | "HTTP_HOST" => "example.org:9292", 123 | "SERVER_PORT" => "9292") 124 | res.should.ok? 125 | res["x-position"].should.eq "default.org" 126 | end 127 | 128 | would "be nestable" do 129 | map = Rack::Lint.new(Jellyfish::URLMap.new("/foo" => 130 | Jellyfish::URLMap.new("/bar" => 131 | Jellyfish::URLMap.new("/quux" => lambda { |env| 132 | [200, 133 | { "content-type" => "text/plain", 134 | "x-position" => "/foo/bar/quux", 135 | "x-pathinfo" => env["PATH_INFO"], 136 | "x-scriptname" => env["SCRIPT_NAME"], 137 | }, [""]]} 138 | )))) 139 | 140 | res = Rack::MockRequest.new(map).get("/foo/bar") 141 | res.should.not_found? 142 | 143 | res = Rack::MockRequest.new(map).get("/foo/bar/quux") 144 | res.should.ok? 145 | res["x-position"].should.eq "/foo/bar/quux" 146 | res["x-pathinfo"].should.eq "" 147 | res["x-scriptname"].should.eq "/foo/bar/quux" 148 | end 149 | 150 | would "route root apps correctly" do 151 | map = Rack::Lint.new(Jellyfish::URLMap.new("/" => lambda { |env| 152 | [200, 153 | { "content-type" => "text/plain", 154 | "x-position" => "root", 155 | "x-pathinfo" => env["PATH_INFO"], 156 | "x-scriptname" => env["SCRIPT_NAME"] 157 | }, [""]]}, 158 | "/foo" => lambda { |env| 159 | [200, 160 | { "content-type" => "text/plain", 161 | "x-position" => "foo", 162 | "x-pathinfo" => env["PATH_INFO"], 163 | "x-scriptname" => env["SCRIPT_NAME"] 164 | }, [""]]} 165 | )) 166 | 167 | res = Rack::MockRequest.new(map).get("/foo/bar") 168 | res.should.ok? 169 | res["x-position"].should.eq "foo" 170 | res["x-pathinfo"].should.eq "/bar" 171 | res["x-scriptname"].should.eq "/foo" 172 | 173 | res = Rack::MockRequest.new(map).get("/foo") 174 | res.should.ok? 175 | res["x-position"].should.eq "foo" 176 | res["x-pathinfo"].should.eq "" 177 | res["x-scriptname"].should.eq "/foo" 178 | 179 | res = Rack::MockRequest.new(map).get("/bar") 180 | res.should.ok? 181 | res["x-position"].should.eq "root" 182 | res["x-pathinfo"].should.eq "/bar" 183 | res["x-scriptname"].should.eq "" 184 | 185 | res = Rack::MockRequest.new(map).get("") 186 | res.should.ok? 187 | res["x-position"].should.eq "root" 188 | res["x-pathinfo"].should.eq "/" 189 | res["x-scriptname"].should.eq "" 190 | end 191 | 192 | would "not squeeze slashes" do 193 | map = Rack::Lint.new(Jellyfish::URLMap.new("/" => lambda { |env| 194 | [200, 195 | { "content-type" => "text/plain", 196 | "x-position" => "root", 197 | "x-pathinfo" => env["PATH_INFO"], 198 | "x-scriptname" => env["SCRIPT_NAME"] 199 | }, [""]]}, 200 | "/foo" => lambda { |env| 201 | [200, 202 | { "content-type" => "text/plain", 203 | "x-position" => "foo", 204 | "x-pathinfo" => env["PATH_INFO"], 205 | "x-scriptname" => env["SCRIPT_NAME"] 206 | }, [""]]} 207 | )) 208 | 209 | res = Rack::MockRequest.new(map).get("/http://example.org/bar") 210 | res.should.ok? 211 | res["x-position"].should.eq "root" 212 | res["x-pathinfo"].should.eq "/http://example.org/bar" 213 | res["x-scriptname"].should.eq "" 214 | end 215 | 216 | would "not be case sensitive with hosts" do 217 | map = Rack::Lint.new(Jellyfish::URLMap.new("http://example.org/" => lambda { |env| 218 | [200, 219 | { "content-type" => "text/plain", 220 | "x-position" => "root", 221 | "x-pathinfo" => env["PATH_INFO"], 222 | "x-scriptname" => env["SCRIPT_NAME"] 223 | }, [""]]} 224 | )) 225 | 226 | res = Rack::MockRequest.new(map).get("http://example.org/") 227 | res.should.ok? 228 | res["x-position"].should.eq "root" 229 | res["x-pathinfo"].should.eq "/" 230 | res["x-scriptname"].should.eq "" 231 | 232 | res = Rack::MockRequest.new(map).get("http://EXAMPLE.ORG/") 233 | res.should.ok? 234 | res["x-position"].should.eq "root" 235 | res["x-pathinfo"].should.eq "/" 236 | res["x-scriptname"].should.eq "" 237 | end 238 | end 239 | -------------------------------------------------------------------------------- /test/sinatra/test_routing.rb: -------------------------------------------------------------------------------- 1 | 2 | require 'jellyfish/test' 3 | 4 | class RegexpLookAlike 5 | class MatchData 6 | def captures 7 | ["this", "is", "a", "test"] 8 | end 9 | end 10 | 11 | def match(string) 12 | ::RegexpLookAlike::MatchData.new if string == "/this/is/a/test/" 13 | end 14 | 15 | def keys 16 | ["one", "two", "three", "four"] 17 | end 18 | end 19 | 20 | # stolen from sinatra 21 | describe 'Sinatra routing_test.rb' do 22 | paste :jellyfish 23 | 24 | %w[get put post delete options patch head].each do |verb| 25 | would "define #{verb.upcase} request handlers with #{verb}" do 26 | app = Class.new{ 27 | include Jellyfish 28 | send verb, '/hello' do 29 | 'Hello World' 30 | end 31 | }.new 32 | 33 | status, _, body = send(verb, '/hello', app) 34 | status.should.eq 200 35 | body .should.eq ['Hello World'] 36 | end 37 | end 38 | 39 | would '404s when no route satisfies the request' do 40 | app = Class.new{ 41 | include Jellyfish 42 | get('/foo'){} 43 | }.new 44 | status, _, _ = get('/bar', app) 45 | status.should.eq 404 46 | end 47 | 48 | would 'allows using unicode' do 49 | app = Class.new{ 50 | include Jellyfish 51 | controller_include Jellyfish::NormalizedPath 52 | get("/f\u{f6}\u{f6}"){} 53 | }.new 54 | status, _, _ = get('/f%C3%B6%C3%B6', app) 55 | status.should.eq 200 56 | end 57 | 58 | would 'handle encoded slashes correctly' do 59 | app = Class.new{ 60 | include Jellyfish 61 | controller_include Jellyfish::NormalizedPath 62 | get(%r{^/(.+)}){ |m| m[1] } 63 | }.new 64 | status, _, body = get('/foo%2Fbar', app) 65 | status.should.eq 200 66 | body .should.eq ['foo/bar'] 67 | end 68 | 69 | would 'override the content-type in error handlers' do 70 | app = Class.new{ 71 | include Jellyfish 72 | get{ 73 | self.headers 'content-type' => 'text/plain' 74 | status, headers, body = jellyfish.app.call(env) 75 | self.status status 76 | self.body body 77 | headers_merge(headers) 78 | } 79 | }.new(Class.new{ 80 | include Jellyfish 81 | handle Jellyfish::NotFound do 82 | headers_merge 'content-type' => 'text/html' 83 | status 404 84 | '

Not Found

' 85 | end 86 | }.new) 87 | 88 | status, headers, body = get('/foo', app) 89 | status .should.eq 404 90 | headers['content-type'].should.eq 'text/html' 91 | body .should.eq ['

Not Found

'] 92 | end 93 | 94 | would 'match empty PATH_INFO to "/" if no route is defined for ""' do 95 | app = Class.new{ 96 | include Jellyfish 97 | controller_include Jellyfish::NormalizedPath 98 | get('/'){ 'worked' } 99 | }.new 100 | 101 | status, _, body = get('', app) 102 | status.should.eq 200 103 | body .should.eq ['worked'] 104 | end 105 | 106 | would 'exposes params with indifferent hash' do 107 | app = Class.new{ 108 | include Jellyfish 109 | controller_include Jellyfish::NormalizedParams 110 | 111 | get %r{^/(?\w+)} do 112 | params['foo'].should.eq 'bar' 113 | params[:foo ].should.eq 'bar' 114 | 'well, alright' 115 | end 116 | }.new 117 | 118 | _, _, body = get('/bar', app) 119 | body.should.eq ['well, alright'] 120 | end 121 | 122 | would 'merges named params and query string params in params' do 123 | app = Class.new{ 124 | include Jellyfish 125 | controller_include Jellyfish::NormalizedParams 126 | 127 | get %r{^/(?\w+)} do 128 | params['foo'].should.eq 'bar' 129 | params['baz'].should.eq 'biz' 130 | end 131 | }.new 132 | 133 | status, _, _ = get('/bar', app, 'QUERY_STRING' => 'baz=biz') 134 | status.should.eq 200 135 | end 136 | 137 | would 'support named captures like %r{/hello/(?[^/?#]+)}' do 138 | app = Class.new{ 139 | include Jellyfish 140 | get Regexp.new('/hello/(?[^/?#]+)') do |m| 141 | "Hello #{m['person']}" 142 | end 143 | }.new 144 | 145 | _, _, body = get('/hello/Frank', app) 146 | body.should.eq ['Hello Frank'] 147 | end 148 | 149 | would 'support optional named captures' do 150 | app = Class.new{ 151 | include Jellyfish 152 | get Regexp.new('/page(?.[^/?#]+)?') do |m| 153 | "format=#{m[:format]}" 154 | end 155 | }.new 156 | 157 | status, _, body = get('/page.html', app) 158 | status.should.eq 200 159 | body .should.eq ['format=.html'] 160 | 161 | status, _, body = get('/page.xml', app) 162 | status.should.eq 200 163 | body .should.eq ['format=.xml'] 164 | 165 | status, _, body = get('/page', app) 166 | status.should.eq 200 167 | body .should.eq ['format='] 168 | end 169 | 170 | would 'not concatinate params with the same name' do 171 | app = Class.new{ 172 | include Jellyfish 173 | controller_include Jellyfish::NormalizedParams 174 | 175 | get(%r{^/(?\w+)}){ |m| params[:foo] } 176 | }.new 177 | 178 | _, _, body = get('/a', app, 'QUERY_STRING' => 'foo=b') 179 | body.should.eq ['a'] 180 | end 181 | 182 | would 'support basic nested params' do 183 | app = Class.new{ 184 | include Jellyfish 185 | get('/hi'){ request.params['person']['name'] } 186 | }.new 187 | 188 | status, _, body = get('/hi', app, 189 | 'QUERY_STRING' => 'person[name]=John+Doe') 190 | status.should.eq 200 191 | body.should.eq ['John Doe'] 192 | end 193 | 194 | would "expose nested params with indifferent hash" do 195 | app = Class.new{ 196 | include Jellyfish 197 | controller_include Jellyfish::NormalizedParams 198 | 199 | get '/testme' do 200 | params['bar']['foo'].should.eq 'baz' 201 | params['bar'][:foo ].should.eq 'baz' 202 | 'well, alright' 203 | end 204 | }.new 205 | 206 | _, _, body = get('/testme', app, 'QUERY_STRING' => 'bar[foo]=baz') 207 | body.should.eq ['well, alright'] 208 | end 209 | 210 | would 'preserve non-nested params' do 211 | app = Class.new{ 212 | include Jellyfish 213 | get '/foo' do 214 | request.params['article_id'] .should.eq '2' 215 | request.params['comment']['body'].should.eq 'awesome' 216 | request.params['comment[body]'] .should.eq nil 217 | 'looks good' 218 | end 219 | }.new 220 | 221 | status, _, body = get('/foo', app, 222 | 'QUERY_STRING' => 'article_id=2&comment[body]=awesome') 223 | status.should.eq 200 224 | body .should.eq ['looks good'] 225 | end 226 | 227 | would 'match paths that include spaces encoded with %20' do 228 | app = Class.new{ 229 | include Jellyfish 230 | controller_include Jellyfish::NormalizedPath 231 | get('/path with spaces'){ 'looks good' } 232 | }.new 233 | 234 | status, _, body = get('/path%20with%20spaces', app) 235 | status.should.eq 200 236 | body .should.eq ['looks good'] 237 | end 238 | 239 | would 'match paths that include spaces encoded with +' do 240 | app = Class.new{ 241 | include Jellyfish 242 | controller_include Jellyfish::NormalizedPath 243 | get('/path with spaces'){ 'looks good' } 244 | }.new 245 | 246 | status, _, body = get('/path+with+spaces', app) 247 | status.should.eq 200 248 | body .should.eq ['looks good'] 249 | end 250 | 251 | would 'make regular expression captures available' do 252 | app = Class.new{ 253 | include Jellyfish 254 | get(/^\/fo(.*)\/ba(.*)/) do |m| 255 | m[1..-1].should.eq ['orooomma', 'f'] 256 | 'right on' 257 | end 258 | }.new 259 | 260 | status, _, body = get('/foorooomma/baf', app) 261 | status.should.eq 200 262 | body .should.eq ['right on'] 263 | end 264 | 265 | would 'support regular expression look-alike routes' do 266 | app = Class.new{ 267 | include Jellyfish 268 | controller_include Jellyfish::NormalizedParams 269 | matcher = Object.new 270 | def matcher.match path 271 | %r{/(?\w+)/(?\w+)/(?\w+)/(?\w+)}.match(path) 272 | end 273 | 274 | get(matcher) do |m| 275 | [m, params].each do |q| 276 | q[:one] .should.eq 'this' 277 | q[:two] .should.eq 'is' 278 | q[:three].should.eq 'a' 279 | q[:four] .should.eq 'test' 280 | end 281 | 'right on' 282 | end 283 | }.new 284 | 285 | status, _, body = get('/this/is/a/test/', app) 286 | status.should.eq 200 287 | body .should.eq ['right on'] 288 | end 289 | 290 | would 'raise a TypeError when pattern is not a String or Regexp' do 291 | lambda{ Class.new{ include Jellyfish; get(42){} } }. 292 | should.raise(TypeError) 293 | end 294 | 295 | would 'match routes defined in superclasses' do 296 | sup = Class.new{ 297 | include Jellyfish 298 | get('/foo'){ 'foo' } 299 | } 300 | app = Class.new(sup){ 301 | get('/bar'){ 'bar' } 302 | }.new 303 | 304 | %w[foo bar].each do |path| 305 | status, _, body = get("/#{path}", app) 306 | status.should.eq 200 307 | body .should.eq [path] 308 | end 309 | end 310 | 311 | would 'match routes itself first then downward app' do 312 | sup = Class.new{ 313 | include Jellyfish 314 | get('/foo'){ 'foo sup' } 315 | get('/bar'){ 'bar sup' } 316 | } 317 | app = Class.new{ 318 | include Jellyfish 319 | get('/foo'){ 'foo sub' } 320 | }.new(sup.new) 321 | 322 | status, _, body = get('/foo', app) 323 | status.should.eq 200 324 | body .should.eq ['foo sub'] 325 | 326 | status, _, body = get('/bar', app) 327 | status.should.eq 200 328 | body .should.eq ['bar sup'] 329 | end 330 | 331 | would 'allow using call to fire another request internally' do 332 | app = Class.new{ 333 | include Jellyfish 334 | get '/foo' do 335 | status, headers, body = call(env.merge('PATH_INFO' => '/bar')) 336 | self.status status 337 | self.headers headers 338 | self.body body.map(&:upcase) 339 | end 340 | 341 | get '/bar' do 342 | 'bar' 343 | end 344 | }.new 345 | 346 | status, _, body = get('/foo', app) 347 | status.should.eq 200 348 | body .should.eq ['BAR'] 349 | end 350 | 351 | would 'play well with other routing middleware' do 352 | middleware = Class.new{include Jellyfish} 353 | inner_app = Class.new{include Jellyfish; get('/foo'){ 'hello' } } 354 | app = Rack::Builder.app do 355 | use middleware 356 | map('/test'){ run inner_app.new } 357 | end 358 | 359 | status, _, body = get('/test/foo', app) 360 | status.should.eq 200 361 | body .should.eq ['hello'] 362 | end 363 | end 364 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | # CHANGES 2 | 3 | ## Jellyfish 1.4.0 -- 2023-02-22 4 | 5 | ### Incompatible changes 6 | 7 | * Adopted Rack 3. Technically, just lower the cases for headers. 8 | * Internal strings are all frozen now. 9 | * `log` and `log_error` now takes the `env` for the second argument, 10 | rather than the error stream. 11 | * `handle` now takes the `env` for the third argument, 12 | rather than the error stream. 13 | 14 | ### Enhancements 15 | 16 | * The default error logging will now also show `env['PATH_INFO']`. 17 | 18 | ## Jellyfish 1.3.1 -- 2018-11-11 19 | 20 | ### Bugs fixed 21 | 22 | * Fixed `Jellyfish::Rewrite` for SCRIPT_NAME when host is also used. 23 | 24 | ## Jellyfish 1.3.0 -- 2018-11-11 25 | 26 | ### Incompatible changes 27 | 28 | * Interface for `Builder.app`, and `Builder#to_app`, and `Rewrite.new` 29 | slightly changed due to fixing the bug in `Rewrite`. You shouldn't use 30 | those directly though. 31 | * Now all strings allocated from Jellyfish are frozen. 32 | 33 | ### Bugs fixed 34 | 35 | * Fixed `Jellyfish::Rewrite`. Should properly handle SCRIPT_NAME. 36 | 37 | ## Jellyfish 1.2.2 -- 2018-09-23 38 | 39 | ### Bugs fixed 40 | 41 | * Fixed `Jellyfish::Rewrite`. Should put rewritten path in the front. 42 | 43 | ## Jellyfish 1.2.1 -- 2018-07-21 44 | 45 | ### Bugs fixed 46 | 47 | * Fixed mapping with host in some cases (longest match goes first) 48 | * Fixed scheme matching with https. Now it won't try to map against https 49 | because it's not that easy to implement and this is how `Rack::URLMap` 50 | works anyway. 51 | 52 | ## Jellyfish 1.2.0 -- 2018-07-14 53 | 54 | ### Incompatible changes 55 | 56 | * `Jellyfish::NewRelic` is extracted to 57 | [jellyfish-contrib](https://github.com/godfat/jellyfish-contrib) 58 | 59 | ### Bugs fixed 60 | 61 | * `Jellyfish::URLMap` would now properly handle nested maps like 62 | `Rack::URLMap`. 63 | 64 | ### Enhancements 65 | 66 | * `Jellyfish::URLMap` now supports listening on a specific host like 67 | `Rack::URLMap`. Beside prefixing `http://`, it also supports passing 68 | `host: host` argument to `map`, and a `listen` directive which takes a 69 | block. 70 | * Instead of using `module_eval`, DSL is now defined via `define_method` to 71 | make debugging easier. Performance shouldn't get hit. 72 | 73 | ## Jellyfish 1.1.1 -- 2015-12-22 74 | 75 | ### Enhancements 76 | 77 | * Added `Jellyfish::Rewrite` as an extension to `Jellyfish::Builder`. 78 | Please check README.md for more detail. 79 | 80 | ## Jellyfish 1.1.0 -- 2015-09-25 81 | 82 | ### Incompatible changes 83 | 84 | * `Jellyfish::Sinatra`, `Jellyfish::MultiActions`, and `Jellyfish::Swagger` 85 | were extracted to 86 | [jellyfish-contrib](https://github.com/godfat/jellyfish-contrib) 87 | 88 | ### Other enhancements 89 | 90 | * Added `Jellyfish::Builder` and `Jellyfish::URLMap` which is 36 times faster 91 | than `Rack::Builder` and `Rack::URLMap` given an application with 92 | 1000 routes. 93 | 94 | ## Jellyfish 1.0.2 -- 2014-12-09 95 | 96 | * `Jellyfish::NewRelic` is fixed. Thanks Jason R. Clark (@jasonrclark) 97 | 98 | ## Jellyfish 1.0.1 -- 2014-11-07 99 | 100 | ### Enhancements for Jellyfish core 101 | 102 | * Now `Jellyfish.handle` could take multiple exceptions as the arguments. 103 | 104 | ### Other enhancements 105 | 106 | * Introduced `Jellyfish::WebSocket` for basic websocket support. 107 | 108 | ## Jellyfish 1.0.0 -- 2014-03-17 109 | 110 | ### Incompatible changes 111 | 112 | * Renamed `forward` to `cascade` to better aligned with Rack. 113 | 114 | ### Enhancements for Jellyfish core 115 | 116 | * Introduced `log` and `log_error` for for controllers. 117 | * Introduced `not_found` to trigger 404 response. 118 | * Now we separate the idea of 404 and cascade. Use `not_found` for 404 119 | responses, and `cascade` for forwarding requests. 120 | 121 | ### Other enhancements 122 | 123 | * Now we have Jellyfish::Swagger to generate Swagger documentation. 124 | Read README.md for more detail or checkout config.ru for a full example. 125 | 126 | ## Jellyfish 0.9.2 -- 2013-09-26 127 | 128 | * Do not rescue Exception since we don't really want to rescue something 129 | like SignalException, which would break signal handling. 130 | 131 | ## Jellyfish 0.9.1 -- 2013-08-23 132 | 133 | * Fixed a thread safety bug for initializing exception handlers. 134 | 135 | ## Jellyfish 0.9.0 -- 2013-07-11 136 | 137 | ### Enhancements for Jellyfish core 138 | 139 | * We no longer use exceptions to control the flow. Use 140 | `halt(InternalError.new)` instead. However, raising exceptions 141 | would still work. Just prefer to use `halt` if you would like 142 | some performance boost. 143 | 144 | ### Incompatible changes 145 | 146 | * If you're raising `NotFound` instead of using `forward` in your app, 147 | it would no longer forward the request but show a 404 page. Always 148 | use `forward` if you intend to forward the request. 149 | 150 | ## Jellyfish 0.8.0 -- 2013-06-15 151 | 152 | ### Incompatible changes 153 | 154 | * Now there's no longer Jellyfish#controller but Jellyfish.controller, 155 | as there's no much point for making the controller per-instance. 156 | You do this to override the controller method instead: 157 | 158 | ``` ruby 159 | class MyApp 160 | include Jellyfish 161 | def self.controller 162 | MyController 163 | end 164 | class MyController < Jellyfish::Controller 165 | def hi 166 | 'hi' 167 | end 168 | end 169 | get{ hi } 170 | end 171 | ``` 172 | 173 | * You can also change the controller by assigning it. The same as above: 174 | 175 | ``` ruby 176 | class MyApp 177 | include Jellyfish 178 | class MyController < Jellyfish::Controller 179 | def hi 180 | 'hi' 181 | end 182 | end 183 | controller MyController 184 | get{ hi } 185 | end 186 | ``` 187 | 188 | ### Enhancements for Jellyfish core 189 | 190 | * Introduced Jellyfish.controller_include which makes it easy to pick 191 | modules to be included in built-in controller. 192 | * Introduced Controller#halt as a short hand for `throw :halt` 193 | * Now default route is `//`. Using `get{ 'Hello, World!' }` is effectively 194 | the same as `get(//){ 'Hello, World!' }` 195 | * Now inheritance works. 196 | * Now it raises TypeError if passing a route doesn't respond to :match. 197 | * Now Jellyfish would find the most suitable error handler to handle 198 | errors, i.e. It would find the error handler which would handle the 199 | nearest exception class in the ancestors chain. Previously it would 200 | only find the first one which matches, ignoring the rest. It would 201 | also cache the result upon a lookup. 202 | 203 | ### Enhancements for Jellyfish extension 204 | 205 | * Added `Jellyfish::ChunkedBody` which is similar to `Sinatra::Stream`. 206 | 207 | * Added `Jellyfish::MultiAction` which gives you some kind of ability to do 208 | before or after filters. See README.md for usage. 209 | 210 | * Added `Jellyfish::NormalizedParams` which gives you some kind of Sinatra 211 | flavoured params. 212 | 213 | * Added `Jellyfish::NormalizedPath` which would unescape incoming PATH_INFO 214 | so you could match '/f%C3%B6%C3%B6' with '/föö'. 215 | 216 | ### Enhancements for Jellyfish::Sinatra 217 | 218 | * Now `Jellyfish::Sinatra` includes `Jellyfish::MultiAction`, 219 | `Jellyfish::NormalizedParams`, and `Jellyfish::NormalizedPath`. 220 | 221 | ## Jellyfish 0.6.0 -- 2012-11-02 222 | 223 | ### Enhancements for Jellyfish core 224 | 225 | * Extracted Jellyfish::Controller#call and Jellyfish::Controller#block_call 226 | into Jellyfish::Controller::Call so that you can have modules which can 227 | override call and block_call. See Jellyfish::Sinatra and Jellyfish::NewRelic 228 | for an example. 229 | 230 | * Now you can use `request` in the controller, which is essentially: 231 | `@request ||= Rack::Request.new(env)`. This also means you would need 232 | Rack installed and required to use it. Other than this, there's no 233 | strict requirement for Rack. 234 | 235 | ### Enhancements for NewRelic 236 | 237 | * Added Jellyfish::NewRelic which makes you work easier with NewRelic. 238 | Here's an example of how to use it: (extracted from README) 239 | 240 | ``` ruby 241 | require 'jellyfish' 242 | class Tank 243 | include Jellyfish 244 | class MyController < Jellyfish::Controller 245 | include Jellyfish::NewRelic 246 | end 247 | def controller; MyController; end 248 | get '/' do 249 | "OK\n" 250 | end 251 | end 252 | use Rack::ContentLength 253 | use Rack::ContentType, 'text/plain' 254 | require 'cgi' # newrelic dev mode needs this and it won't require it itself 255 | require 'new_relic/rack/developer_mode' 256 | use NewRelic::Rack::DeveloperMode # GET /newrelic to read stats 257 | run Tank.new 258 | NewRelic::Agent.manual_start(:developer_mode => true) 259 | ``` 260 | 261 | ## Jellyfish 0.5.3 -- 2012-10-26 262 | 263 | ### Enhancements for Jellyfish core 264 | 265 | * Respond an empty string response if the block gives a nil. 266 | * Added Jellyfish#log method which allow you to use the same 267 | way as Jellyfish log things. 268 | * rescue LocalJumpError and give a hint if you're trying to 269 | return or break from the block. You should use `next` instead. 270 | Or you can simply pass lambda which you can safely `return`. 271 | For example: `get '/path', &lambda{ return "body" }` 272 | 273 | ### Enhancements for Sinatra flavored controller 274 | 275 | * Introduced `initialize_params` and only initialize them whenever 276 | it's not yet set, giving you the ability to initialize params 277 | before calling `block_call`, thus you can customize params more 278 | easily. An example for making NewRelic work would be like this: 279 | 280 | ``` ruby 281 | class Controller < Api::Controller 282 | include NewRelic::Agent::Instrumentation::ControllerInstrumentation 283 | 284 | def block_call argument, block 285 | path = if argument.respond_to?(:regexp) 286 | argument.regexp 287 | else 288 | argument 289 | end.to_s[1..-1] 290 | name = "#{env['REQUEST_METHOD']} #{path}" 291 | initialize_params(argument) # magic category, see: 292 | # NewRelic::MetricParser::WebTransaction::Jellyfish 293 | perform_action_with_newrelic_trace(:category => 'Controller/Jellyfish', 294 | :path => path , 295 | :name => name , 296 | :request => request , 297 | :params => params ){ super } 298 | end 299 | end 300 | 301 | module NewRelic::MetricParser::WebTransaction::Jellyfish 302 | include NewRelic::MetricParser::WebTransaction::Pattern 303 | def is_web_transaction?; true; end 304 | def category ; 'Jellyfish'; end 305 | end 306 | ``` 307 | 308 | ## Jellyfish 0.5.2 -- 2012-10-20 309 | 310 | ### Incompatible changes 311 | 312 | * `protect` method is removed and inlined, reducing the size of call stack. 313 | 314 | ### Enhancements for Jellyfish core 315 | 316 | * `log_error` is now a public method. 317 | 318 | ### Enhancements for Sinatra flavored controller 319 | 320 | * Force params encoding to Encoding.default_external 321 | 322 | ## Jellyfish 0.5.1 -- 2012-10-19 323 | 324 | * Removed accidentally added sinatra files. 325 | 326 | ## Jellyfish 0.5.0 -- 2012-10-18 327 | 328 | ### Incompatible changes 329 | 330 | * Some internal constants are removed. 331 | * Renamed `Respond` to `Response`. 332 | 333 | ### Enhancements 334 | 335 | * Now Jellyfish would always use the custom error handler to handle the 336 | particular exception even if `handle_exceptions` set to false. That is, 337 | now setting `handle_exceptions` to false would only disable default 338 | error handling. This behavior makes more sense since if you want the 339 | exception bubble out then you shouldn't define the custom error handler 340 | in the first place. If you define it, you must mean you want to use it. 341 | 342 | * Eliminated some uninitialized instance variable warnings. 343 | 344 | * Now you can access the original app via `jellyfish` in the controller. 345 | 346 | * `Jellyfish::Controller` no longer includes `Jellyfish`, which would remove 347 | those `DSL` methods accidentally included in previous version (0.4.0-). 348 | 349 | ## Jellyfish 0.4.0 -- 2012-10-14 350 | 351 | * Now you can define your own custom controller like: 352 | 353 | ``` ruby 354 | require 'jellyfish' 355 | class Heater 356 | include Jellyfish 357 | get '/status' do 358 | temperature 359 | end 360 | 361 | def controller; Controller; end 362 | class Controller < Jellyfish::Controller 363 | def temperature 364 | "30\u{2103}\n" 365 | end 366 | end 367 | end 368 | use Rack::ContentLength 369 | use Rack::ContentType, 'text/plain' 370 | run Heater.new 371 | ``` 372 | 373 | * Now it's possible to use a custom matcher instead of regular expression: 374 | 375 | ``` ruby 376 | require 'jellyfish' 377 | class Tank 378 | include Jellyfish 379 | class Matcher 380 | def match path 381 | path.reverse == 'match/' 382 | end 383 | end 384 | get Matcher.new do |match| 385 | "#{match}\n" 386 | end 387 | end 388 | use Rack::ContentLength 389 | use Rack::ContentType, 'text/plain' 390 | run Tank.new 391 | ``` 392 | 393 | * Added a Sinatra flavor controller 394 | 395 | ``` ruby 396 | require 'jellyfish' 397 | class Tank 398 | include Jellyfish 399 | def controller; Jellyfish::Sinatra; end 400 | get %r{^/(?\d+)$} do 401 | "Jelly ##{params[:id]}\n" 402 | end 403 | end 404 | use Rack::ContentLength 405 | use Rack::ContentType, 'text/plain' 406 | run Tank.new 407 | ``` 408 | 409 | ## Jellyfish 0.3.0 -- 2012-10-13 410 | 411 | * Birthday! 412 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Jellyfish [![Pipeline status](https://gitlab.com/godfat/jellyfish/badges/master/pipeline.svg)](https://gitlab.com/godfat/jellyfish/-/pipelines) 2 | 3 | by Lin Jen-Shin ([godfat](https://godfat.org)) 4 | 5 | ![logo](https://github.com/godfat/jellyfish/raw/master/jellyfish.png) 6 | 7 | ## LINKS: 8 | 9 | * [github](https://github.com/godfat/jellyfish) 10 | * [rubygems](https://rubygems.org/gems/jellyfish) 11 | * [rdoc](https://rubydoc.info/github/godfat/jellyfish) 12 | * [issues](https://github.com/godfat/jellyfish/issues) (feel free to ask for support) 13 | 14 | ## DESCRIPTION: 15 | 16 | Pico web framework for building API-centric web applications. 17 | For Rack applications or Rack middleware. Around 250 lines of code. 18 | 19 | Check [jellyfish-contrib][] for extra extensions. 20 | 21 | [jellyfish-contrib]: https://github.com/godfat/jellyfish-contrib 22 | 23 | ## DESIGN: 24 | 25 | * Learn the HTTP way instead of using some pointless helpers. 26 | * Learn the Rack way instead of wrapping around Rack functionalities. 27 | * Learn regular expression for routes instead of custom syntax. 28 | * Embrace simplicity over convenience. 29 | * Don't make things complicated only for _some_ convenience, but for 30 | _great_ convenience, or simply stay simple for simplicity. 31 | * More features are added as extensions. 32 | * Consider use [rack-protection][] if you're not only building an API server. 33 | * Consider use [websocket_parser][] if you're trying to use WebSocket. 34 | Please check example below. 35 | 36 | [rack-protection]: https://github.com/rkh/rack-protection 37 | [websocket_parser]: https://github.com/afcapel/websocket_parser 38 | 39 | ## FEATURES: 40 | 41 | * Minimal 42 | * Simple 43 | * Modular 44 | * No templates (You could use [tilt](https://github.com/rtomayko/tilt)) 45 | * No ORM (You could use [sequel](https://sequel.jeremyevans.net)) 46 | * No `dup` in `call` 47 | * Regular expression routes, e.g. `get %r{^/(?\d+)$}` 48 | * String routes, e.g. `get '/'` 49 | * Custom routes, e.g. `get Matcher.new` 50 | * Build for either Rack applications or Rack middleware 51 | * Include extensions for more features (checkout [jellyfish-contrib][]) 52 | 53 | ## WHY? 54 | 55 | Because Sinatra is too complex and inconsistent for me. 56 | 57 | ## REQUIREMENTS: 58 | 59 | * Tested with MRI (official CRuby) and JRuby. 60 | 61 | ## INSTALLATION: 62 | 63 | gem install jellyfish 64 | 65 | ## SYNOPSIS: 66 | 67 | You could also take a look at [config.ru](config.ru) as an example. 68 | 69 | ### Hello Jellyfish, your lovely config.ru 70 | 71 | ``` ruby 72 | require 'jellyfish' 73 | class Tank 74 | include Jellyfish 75 | get '/' do 76 | "Jelly Kelly\n" 77 | end 78 | end 79 | use Rack::ContentLength 80 | use Rack::ContentType, 'text/plain' 81 | run Tank.new 82 | ``` 83 | 84 | 90 | 91 | ### Regular expression routes 92 | 93 | ``` ruby 94 | require 'jellyfish' 95 | class Tank 96 | include Jellyfish 97 | get %r{^/(?\d+)$} do |match| 98 | "Jelly ##{match[:id]}\n" 99 | end 100 | end 101 | use Rack::ContentLength 102 | use Rack::ContentType, 'text/plain' 103 | run Tank.new 104 | ``` 105 | 106 | 112 | 113 | ### Custom matcher routes 114 | 115 | ``` ruby 116 | require 'jellyfish' 117 | class Tank 118 | include Jellyfish 119 | class Matcher 120 | def match path 121 | path.reverse == 'match/' 122 | end 123 | end 124 | get Matcher.new do |match| 125 | "#{match}\n" 126 | end 127 | end 128 | use Rack::ContentLength 129 | use Rack::ContentType, 'text/plain' 130 | run Tank.new 131 | ``` 132 | 133 | 139 | 140 | ### Different HTTP status and custom headers 141 | 142 | ``` ruby 143 | require 'jellyfish' 144 | class Tank 145 | include Jellyfish 146 | post '/' do 147 | headers 'X-Jellyfish-Life' => '100' 148 | headers_merge 'X-Jellyfish-Mana' => '200' 149 | body "Jellyfish 100/200\n" 150 | status 201 151 | 'return is ignored if body has already been set' 152 | end 153 | end 154 | use Rack::ContentLength 155 | use Rack::ContentType, 'text/plain' 156 | run Tank.new 157 | ``` 158 | 159 | 166 | 167 | ### Redirect helper 168 | 169 | ``` ruby 170 | require 'jellyfish' 171 | class Tank 172 | include Jellyfish 173 | get '/lookup' do 174 | found "#{env['rack.url_scheme']}://#{env['HTTP_HOST']}/" 175 | end 176 | end 177 | use Rack::ContentLength 178 | use Rack::ContentType, 'text/plain' 179 | run Tank.new 180 | ``` 181 | 182 | 192 | 193 | ### Crash-proof 194 | 195 | ``` ruby 196 | require 'jellyfish' 197 | class Tank 198 | include Jellyfish 199 | get '/crash' do 200 | raise 'crash' 201 | end 202 | end 203 | use Rack::ContentLength 204 | use Rack::ContentType, 'text/plain' 205 | run Tank.new 206 | ``` 207 | 208 | 216 | 217 | ### Custom error handler 218 | 219 | ``` ruby 220 | require 'jellyfish' 221 | class Tank 222 | include Jellyfish 223 | handle NameError do |e| 224 | status 403 225 | "No one hears you: #{e.backtrace.first}\n" 226 | end 227 | get '/yell' do 228 | yell 229 | end 230 | end 231 | use Rack::ContentLength 232 | use Rack::ContentType, 'text/plain' 233 | run Tank.new 234 | ``` 235 | 236 | 250 | 251 | ### Custom error 404 handler 252 | 253 | ``` ruby 254 | require 'jellyfish' 255 | class Tank 256 | include Jellyfish 257 | handle Jellyfish::NotFound do |e| 258 | status 404 259 | "You found nothing." 260 | end 261 | end 262 | use Rack::ContentLength 263 | use Rack::ContentType, 'text/plain' 264 | run Tank.new 265 | ``` 266 | 267 | 273 | 274 | ### Custom error handler for multiple errors 275 | 276 | ``` ruby 277 | require 'jellyfish' 278 | class Tank 279 | include Jellyfish 280 | handle Jellyfish::NotFound, NameError do |e| 281 | status 404 282 | "You found nothing." 283 | end 284 | get '/yell' do 285 | yell 286 | end 287 | end 288 | use Rack::ContentLength 289 | use Rack::ContentType, 'text/plain' 290 | run Tank.new 291 | ``` 292 | 293 | 299 | 300 | ### Access Rack::Request and params 301 | 302 | ``` ruby 303 | require 'jellyfish' 304 | class Tank 305 | include Jellyfish 306 | get '/report' do 307 | "Your name is #{request.params['name']}\n" 308 | end 309 | end 310 | use Rack::ContentLength 311 | use Rack::ContentType, 'text/plain' 312 | run Tank.new 313 | ``` 314 | 315 | 321 | 322 | ### Re-dispatch the request with modified env 323 | 324 | ``` ruby 325 | require 'jellyfish' 326 | class Tank 327 | include Jellyfish 328 | get '/report' do 329 | status, headers, body = jellyfish.call(env.merge('PATH_INFO' => '/info')) 330 | self.status status 331 | self.headers headers 332 | self.body body 333 | end 334 | get('/info'){ "OK\n" } 335 | end 336 | use Rack::ContentLength 337 | use Rack::ContentType, 'text/plain' 338 | run Tank.new 339 | ``` 340 | 341 | 347 | 348 | ### Include custom helper in built-in controller 349 | 350 | Basically it's the same as defining a custom controller and then 351 | include the helper. This is merely a short hand. See next section 352 | for defining a custom controller. 353 | 354 | ``` ruby 355 | require 'jellyfish' 356 | class Heater 357 | include Jellyfish 358 | get '/status' do 359 | temperature 360 | end 361 | 362 | module Helper 363 | def temperature 364 | "30\u{2103}\n" 365 | end 366 | end 367 | controller_include Helper 368 | end 369 | use Rack::ContentLength 370 | use Rack::ContentType, 'text/plain' 371 | run Heater.new 372 | ``` 373 | 374 | 380 | 381 | ### Define custom controller manually 382 | 383 | This is effectively the same as defining a helper module as above and 384 | include it, but more flexible and extensible. 385 | 386 | ``` ruby 387 | require 'jellyfish' 388 | class Heater 389 | include Jellyfish 390 | get '/status' do 391 | temperature 392 | end 393 | 394 | class Controller < Jellyfish::Controller 395 | def temperature 396 | "30\u{2103}\n" 397 | end 398 | end 399 | controller Controller 400 | end 401 | use Rack::ContentLength 402 | use Rack::ContentType, 'text/plain' 403 | run Heater.new 404 | ``` 405 | 406 | 412 | 413 | ### Override dispatch for processing before action 414 | 415 | We don't have before action built-in, but we could override `dispatch` in 416 | the controller to do the same thing. CAVEAT: Remember to call `super`. 417 | 418 | ``` ruby 419 | require 'jellyfish' 420 | class Tank 421 | include Jellyfish 422 | controller_include Module.new{ 423 | def dispatch 424 | @state = 'jumps' 425 | super 426 | end 427 | } 428 | 429 | get do 430 | "Jelly #{@state}.\n" 431 | end 432 | end 433 | use Rack::ContentLength 434 | use Rack::ContentType, 'text/plain' 435 | run Tank.new 436 | ``` 437 | 438 | 444 | 445 | ### Extension: Jellyfish::Builder, a faster Rack::Builder and Rack::URLMap 446 | 447 | Default `Rack::Builder` and `Rack::URLMap` is routing via linear search, 448 | which could be very slow with a large number of routes. We could use 449 | `Jellyfish::Builder` in this case because it would compile the routes 450 | into a regular expression, it would be matching much faster than 451 | linear search. 452 | 453 | Note that `Jellyfish::Builder` is not a complete compatible implementation. 454 | The followings are intentional: 455 | 456 | * There's no `Jellyfish::Builder.call` because it doesn't make sense in my 457 | opinion. Always use `Jellyfish::Builder.app` instead. 458 | 459 | * There's no `Jellyfish::Builder.parse_file` and 460 | `Jellyfish::Builder.new_from_string` because Rack servers are not 461 | going to use `Jellyfish::Builder` to parse `config.ru` at this point. 462 | We could provide this if there's a need. 463 | 464 | * `Jellyfish::URLMap` does not modify `env`, and it would call the app with 465 | another instance of Hash. Mutating data is a bad idea. 466 | 467 | * All other tests passed the same test suites for `Rack::Builder` and 468 | `Jellyfish::URLMap`. 469 | 470 | ``` ruby 471 | require 'jellyfish' 472 | 473 | run Jellyfish::Builder.app{ 474 | map '/a' do; run lambda{ |_| [200, {}, ["a\n"] ] }; end 475 | map '/b' do; run lambda{ |_| [200, {}, ["b\n"] ] }; end 476 | map '/c' do; run lambda{ |_| [200, {}, ["c\n"] ] }; end 477 | map '/d' do; run lambda{ |_| [200, {}, ["d\n"] ] }; end 478 | map '/e' do 479 | map '/f' do; run lambda{ |_| [200, {}, ["e/f\n"]] }; end 480 | map '/g' do; run lambda{ |_| [200, {}, ["e/g\n"]] }; end 481 | map '/h' do; run lambda{ |_| [200, {}, ["e/h\n"]] }; end 482 | map '/i' do; run lambda{ |_| [200, {}, ["e/i\n"]] }; end 483 | map '/' do; run lambda{ |_| [200, {}, ["e\n"]] }; end 484 | end 485 | map '/j' do; run lambda{ |_| [200, {}, ["j\n"] ] }; end 486 | map '/k' do; run lambda{ |_| [200, {}, ["k\n"] ] }; end 487 | map '/l' do; run lambda{ |_| [200, {}, ["l\n"] ] }; end 488 | map '/m' do 489 | map '/g' do; run lambda{ |_| [200, {}, ["m/g\n"]] }; end 490 | run lambda{ |_| [200, {}, ["m\n"] ] } 491 | end 492 | 493 | use Rack::ContentLength 494 | run lambda{ |_| [200, {}, ["/\n"]] } 495 | } 496 | ``` 497 | 498 | 565 | 566 | You could try a stupid benchmark yourself: 567 | 568 | ruby -Ilib bench/bench_builder.rb 569 | 570 | For a 1000 routes app, here's my result: 571 | 572 | ``` 573 | Calculating ------------------------------------- 574 | Jellyfish::URLMap 5.726k i/100ms 575 | Rack::URLMap 167.000 i/100ms 576 | ------------------------------------------------- 577 | Jellyfish::URLMap 62.397k (± 1.2%) i/s - 314.930k 578 | Rack::URLMap 1.702k (± 1.5%) i/s - 8.517k 579 | 580 | Comparison: 581 | Jellyfish::URLMap: 62397.3 i/s 582 | Rack::URLMap: 1702.0 i/s - 36.66x slower 583 | ``` 584 | 585 | #### Extension: Jellyfish::Builder#listen 586 | 587 | `listen` is a convenient way to define routing based on the host. We could 588 | also use `map` inside `listen` block. Here's a quick example that specifically 589 | listen on a particular host for long-polling and all other hosts would go to 590 | the default app. 591 | 592 | ``` ruby 593 | require 'jellyfish' 594 | 595 | long_poll = lambda{ |env| [200, {}, ["long_poll #{env['HTTP_HOST']}\n"]] } 596 | fast_app = lambda{ |env| [200, {}, ["fast_app #{env['HTTP_HOST']}\n"]] } 597 | 598 | run Jellyfish::Builder.app{ 599 | listen 'slow-app' do 600 | run long_poll 601 | end 602 | 603 | run fast_app 604 | } 605 | ``` 606 | 607 | 614 | 615 | ##### Extension: Jellyfish::Builder#listen (`map path, host:`) 616 | 617 | Alternatively, we could pass `host` as an argument to `map` so that the 618 | endpoint would only listen on a specific host. 619 | 620 | ``` ruby 621 | require 'jellyfish' 622 | 623 | long_poll = lambda{ |env| [200, {}, ["long_poll #{env['HTTP_HOST']}\n"]] } 624 | fast_app = lambda{ |env| [200, {}, ["fast_app #{env['HTTP_HOST']}\n"]] } 625 | 626 | run Jellyfish::Builder.app{ 627 | map '/', host: 'slow-app' do 628 | run long_poll 629 | end 630 | 631 | run fast_app 632 | } 633 | ``` 634 | 635 | 642 | 643 | 650 | 651 | ##### Extension: Jellyfish::Builder#listen (`map "http://#{path}"`) 652 | 653 | Or if you really prefer the `Rack::URLMap` compatible way, then you could 654 | just add `http://host` to your path prefix. `https` works, too. 655 | 656 | ``` ruby 657 | require 'jellyfish' 658 | 659 | long_poll = lambda{ |env| [200, {}, ["long_poll #{env['HTTP_HOST']}\n"]] } 660 | fast_app = lambda{ |env| [200, {}, ["fast_app #{env['HTTP_HOST']}\n"]] } 661 | 662 | run Jellyfish::Builder.app{ 663 | map 'http://slow-app' do 664 | run long_poll 665 | end 666 | 667 | run fast_app 668 | } 669 | ``` 670 | 671 | 678 | 679 | #### Extension: Jellyfish::Rewrite 680 | 681 | `Jellyfish::Builder` is mostly compatible with `Rack::Builder`, and 682 | `Jellyfish::Rewrite` is an extension to `Rack::Builder` which allows 683 | you to rewrite `env['PATH_INFO']` in an easy way. In an ideal world, 684 | we don't really need this. But in real world, we might want to have some 685 | backward compatible API which continues to work even if the API endpoint 686 | has already been changed. 687 | 688 | Suppose the old API was: `/users/me`, and we want to change to `/profiles/me`, 689 | while leaving the `/users/list` as before. We may have this: 690 | 691 | ``` ruby 692 | require 'jellyfish' 693 | 694 | users_api = lambda{ |env| [200, {}, ["/users#{env['PATH_INFO']}\n"]] } 695 | profiles_api = lambda{ |env| [200, {}, ["/profiles#{env['PATH_INFO']}\n"]] } 696 | 697 | run Jellyfish::Builder.app{ 698 | rewrite '/users/me' => '/me' do 699 | run profiles_api 700 | end 701 | map '/profiles' do 702 | run profiles_api 703 | end 704 | map '/users' do 705 | run users_api 706 | end 707 | } 708 | ``` 709 | 710 | 723 | 724 | This way, we would rewrite `/users/me` to `/profiles/me` and serve it with 725 | our profiles API app, while leaving all other paths begin with `/users` 726 | continue to work with the old users API app. 727 | 728 | ##### Extension: Jellyfish::Rewrite (`map path, to:`) 729 | 730 | Note that you could also use `map path, :to` if you prefer this API more: 731 | 732 | ``` ruby 733 | require 'jellyfish' 734 | 735 | users_api = lambda{ |env| [200, {}, ["/users#{env['PATH_INFO']}\n"]] } 736 | profiles_api = lambda{ |env| [200, {}, ["/profiles#{env['PATH_INFO']}\n"]] } 737 | 738 | run Jellyfish::Builder.app{ 739 | map '/users/me', to: '/me' do 740 | run profiles_api 741 | end 742 | map '/profiles' do 743 | run profiles_api 744 | end 745 | map '/users' do 746 | run users_api 747 | end 748 | } 749 | ``` 750 | 751 | 764 | 765 | ##### Extension: Jellyfish::Rewrite (`rewrite rules`) 766 | 767 | Note that `rewrite` takes a hash which could contain more than one rule: 768 | 769 | ``` ruby 770 | require 'jellyfish' 771 | 772 | profiles_api = lambda{ |env| [200, {}, ["/profiles#{env['PATH_INFO']}\n"]] } 773 | 774 | run Jellyfish::Builder.app{ 775 | rewrite '/users/me' => '/me', 776 | '/users/fa' => '/fa' do 777 | run profiles_api 778 | end 779 | } 780 | ``` 781 | 782 | 789 | 790 | ### Extension: NormalizedParams (with force_encoding) 791 | 792 | ``` ruby 793 | require 'jellyfish' 794 | class Tank 795 | include Jellyfish 796 | controller_include Jellyfish::NormalizedParams 797 | 798 | get %r{^/(?\d+)$} do 799 | "Jelly ##{params[:id]}\n" 800 | end 801 | end 802 | use Rack::ContentLength 803 | use Rack::ContentType, 'text/plain' 804 | run Tank.new 805 | ``` 806 | 807 | 813 | 814 | ### Extension: NormalizedPath (with unescaping) 815 | 816 | ``` ruby 817 | require 'jellyfish' 818 | class Tank 819 | include Jellyfish 820 | controller_include Jellyfish::NormalizedPath 821 | 822 | get "/\u{56e7}" do 823 | "#{env['PATH_INFO']}=#{path_info}\n" 824 | end 825 | end 826 | use Rack::ContentLength 827 | use Rack::ContentType, 'text/plain' 828 | run Tank.new 829 | ``` 830 | 831 | 837 | 838 | ### Extension: Using multiple extensions with custom controller 839 | 840 | Note that the controller should be assigned lastly in order to include 841 | modules remembered in controller_include. 842 | 843 | ``` ruby 844 | require 'jellyfish' 845 | class Tank 846 | include Jellyfish 847 | class MyController < Jellyfish::Controller 848 | include Jellyfish::WebSocket 849 | end 850 | controller_include NormalizedParams, NormalizedPath 851 | controller MyController 852 | 853 | get %r{^/(?\d+)$} do 854 | "Jelly ##{params[:id]} jumps.\n" 855 | end 856 | end 857 | use Rack::ContentLength 858 | use Rack::ContentType, 'text/plain' 859 | run Tank.new 860 | ``` 861 | 862 | 868 | 869 | ### Jellyfish as a middleware 870 | 871 | If the Jellyfish middleware cannot find a corresponding action, it would 872 | then forward the request to the lower application. We call this `cascade`. 873 | 874 | ``` ruby 875 | require 'jellyfish' 876 | class Heater 877 | include Jellyfish 878 | get '/status' do 879 | "30\u{2103}\n" 880 | end 881 | end 882 | 883 | class Tank 884 | include Jellyfish 885 | get '/' do 886 | "Jelly Kelly\n" 887 | end 888 | end 889 | 890 | use Rack::ContentLength 891 | use Rack::ContentType, 'text/plain' 892 | use Heater 893 | run Tank.new 894 | ``` 895 | 896 | 902 | 903 | ### Modify response as a middleware 904 | 905 | We could also explicitly call the lower app. This would give us more 906 | flexibility than simply forwarding it. 907 | 908 | ``` ruby 909 | require 'jellyfish' 910 | class Heater 911 | include Jellyfish 912 | get '/status' do 913 | status, headers, body = jellyfish.app.call(env) 914 | self.status status 915 | self.headers headers 916 | self.body body 917 | headers_merge('X-Temperature' => "30\u{2103}") 918 | end 919 | end 920 | 921 | class Tank 922 | include Jellyfish 923 | get '/status' do 924 | "See header X-Temperature\n" 925 | end 926 | end 927 | 928 | use Rack::ContentLength 929 | use Rack::ContentType, 'text/plain' 930 | use Heater 931 | run Tank.new 932 | ``` 933 | 934 | 941 | 942 | ### Override cascade for customized forwarding 943 | 944 | We could also override `cascade` in order to craft custom response when 945 | forwarding is happening. Note that whenever this forwarding is happening, 946 | Jellyfish won't try to merge the headers from `dispatch` method, because 947 | in this case Jellyfish is served as a pure proxy. As result we need to 948 | explicitly merge the headers if we really want. 949 | 950 | ``` ruby 951 | require 'jellyfish' 952 | class Heater 953 | include Jellyfish 954 | controller_include Module.new{ 955 | def dispatch 956 | headers_merge('X-Temperature' => "35\u{2103}") 957 | super 958 | end 959 | 960 | def cascade 961 | status, headers, body = jellyfish.app.call(env) 962 | halt [status, headers_merge(headers), body] 963 | end 964 | } 965 | end 966 | 967 | class Tank 968 | include Jellyfish 969 | get '/status' do 970 | "\n" 971 | end 972 | end 973 | 974 | use Rack::ContentLength 975 | use Rack::ContentType, 'text/plain' 976 | use Heater 977 | run Tank.new 978 | ``` 979 | 980 | 987 | 988 | ### Simple before action as a middleware 989 | 990 | ``` ruby 991 | require 'jellyfish' 992 | class Heater 993 | include Jellyfish 994 | get '/status' do 995 | env['temperature'] = 30 996 | cascade 997 | end 998 | end 999 | 1000 | class Tank 1001 | include Jellyfish 1002 | get '/status' do 1003 | "#{env['temperature']}\u{2103}\n" 1004 | end 1005 | end 1006 | 1007 | use Rack::ContentLength 1008 | use Rack::ContentType, 'text/plain' 1009 | use Heater 1010 | run Tank.new 1011 | ``` 1012 | 1013 | 1019 | 1020 | ### One huge tank 1021 | 1022 | ``` ruby 1023 | require 'jellyfish' 1024 | class Heater 1025 | include Jellyfish 1026 | get '/status' do 1027 | "30\u{2103}\n" 1028 | end 1029 | end 1030 | 1031 | class Tank 1032 | include Jellyfish 1033 | get '/' do 1034 | "Jelly Kelly\n" 1035 | end 1036 | end 1037 | 1038 | HugeTank = Rack::Builder.app do 1039 | use Rack::ContentLength 1040 | use Rack::ContentType, 'text/plain' 1041 | use Heater 1042 | run Tank.new 1043 | end 1044 | 1045 | run HugeTank 1046 | ``` 1047 | 1048 | 1054 | 1055 | ### Raise exceptions 1056 | 1057 | ``` ruby 1058 | require 'jellyfish' 1059 | class Protector 1060 | include Jellyfish 1061 | handle StandardError do |e| 1062 | "Protected: #{e}\n" 1063 | end 1064 | end 1065 | 1066 | class Tank 1067 | include Jellyfish 1068 | handle_exceptions false # default is true, setting false here would make 1069 | # the outside Protector handle the exception 1070 | get '/' do 1071 | raise "Oops, tank broken" 1072 | end 1073 | end 1074 | 1075 | use Rack::ContentLength 1076 | use Rack::ContentType, 'text/plain' 1077 | use Protector 1078 | run Tank.new 1079 | ``` 1080 | 1081 | 1087 | 1088 | ### Chunked transfer encoding (streaming) with Jellyfish::ChunkedBody 1089 | 1090 | You would need a proper server setup. 1091 | Here's an example with Rainbows and fibers: 1092 | 1093 | ``` ruby 1094 | class Tank 1095 | include Jellyfish 1096 | get '/chunked' do 1097 | ChunkedBody.new{ |out| 1098 | (0..4).each{ |i| out.call("#{i}\n") } 1099 | } 1100 | end 1101 | end 1102 | use Rack::ContentType, 'text/plain' 1103 | run Tank.new 1104 | ``` 1105 | 1106 | 1112 | 1113 | ### Chunked transfer encoding (streaming) with custom body 1114 | 1115 | ``` ruby 1116 | class Tank 1117 | include Jellyfish 1118 | class Body 1119 | def each 1120 | (0..4).each{ |i| yield "#{i}\n" } 1121 | end 1122 | end 1123 | get '/chunked' do 1124 | Body.new 1125 | end 1126 | end 1127 | use Rack::ContentType, 'text/plain' 1128 | run Tank.new 1129 | ``` 1130 | 1131 | 1137 | 1138 | ### Server Sent Event (SSE) 1139 | 1140 | ``` ruby 1141 | class Tank 1142 | include Jellyfish 1143 | class Body 1144 | def each 1145 | (0..4).each{ |i| yield "data: #{i}\n\n" } 1146 | end 1147 | end 1148 | get '/sse' do 1149 | headers_merge('content-type' => 'text/event-stream') 1150 | Body.new 1151 | end 1152 | end 1153 | run Tank.new 1154 | ``` 1155 | 1156 | 1162 | 1163 | ### Server Sent Event (SSE) with Rack Hijacking 1164 | 1165 | ``` ruby 1166 | class Tank 1167 | include Jellyfish 1168 | get '/sse' do 1169 | headers_merge( 1170 | 'content-type' => 'text/event-stream', 1171 | 'rack.hijack' => lambda do |sock| 1172 | (0..4).each do |i| 1173 | sock.write("data: #{i}\n\n") 1174 | end 1175 | sock.close 1176 | end) 1177 | end 1178 | end 1179 | run Tank.new 1180 | ``` 1181 | 1182 | 1188 | 1189 | ### Using WebSocket 1190 | 1191 | Note that this only works for Rack servers which support [hijack][]. 1192 | You're better off with a threaded server such as [Rainbows!][] with 1193 | thread based concurrency model, or [Puma][]. 1194 | 1195 | Event-driven based server is a whole different story though. Since 1196 | EventMachine is basically dead, we could see if there would be a 1197 | [Celluloid-IO][] based web server production ready in the future, 1198 | so that we could take the advantage of event based approach. 1199 | 1200 | [hijack]: https://github.com/rack/rack/blob/main/SPEC.rdoc#label-Hijacking 1201 | [Rainbows!]: https://yhbt.net/rainbows/ 1202 | [Puma]: https://puma.io 1203 | [Celluloid-IO]: https://github.com/celluloid/celluloid-io 1204 | 1205 | ``` ruby 1206 | class Tank 1207 | include Jellyfish 1208 | controller_include Jellyfish::WebSocket 1209 | get '/echo' do 1210 | switch_protocol do |msg| 1211 | ws_write(msg) 1212 | end 1213 | ws_write('Hi!') 1214 | ws_start 1215 | end 1216 | end 1217 | run Tank.new 1218 | ``` 1219 | 1220 | 1232 | 1233 | ## CONTRIBUTORS: 1234 | 1235 | * Fumin (@fumin) 1236 | * Jason R. Clark (@jasonrclark) 1237 | * Lin Jen-Shin (@godfat) 1238 | 1239 | ## LICENSE: 1240 | 1241 | Apache License 2.0 (Apache-2.0) 1242 | 1243 | Copyright (c) 2012-2023, Lin Jen-Shin (godfat) 1244 | 1245 | Licensed under the Apache License, Version 2.0 (the "License"); 1246 | you may not use this file except in compliance with the License. 1247 | You may obtain a copy of the License at 1248 | 1249 | 1250 | 1251 | Unless required by applicable law or agreed to in writing, software 1252 | distributed under the License is distributed on an "AS IS" BASIS, 1253 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 1254 | See the License for the specific language governing permissions and 1255 | limitations under the License. 1256 | --------------------------------------------------------------------------------