├── .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 [](https://gitlab.com/godfat/jellyfish/-/pipelines)
2 |
3 | by Lin Jen-Shin ([godfat](https://godfat.org))
4 |
5 | 
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 |
--------------------------------------------------------------------------------