Jellyfish Build Status Coverage Status Join the chat at https://gitter.im/godfat/jellyfish

by Lin Jen-Shin (godfat)

logo

DESCRIPTION:

Pico web framework for building API-centric web applications. For Rack applications or Rack middleware. Around 250 lines of code.

Check jellyfish-contrib for extra extensions.

DESIGN:

  • Learn the HTTP way instead of using some pointless helpers.
  • Learn the Rack way instead of wrapping around Rack functionalities.
  • Learn regular expression for routes instead of custom syntax.
  • Embrace simplicity over convenience.
  • Don't make things complicated only for some convenience, but for great convenience, or simply stay simple for simplicity.
  • More features are added as extensions.
  • Consider use rack-protection if you're not only building an API server.
  • Consider use websocket_parser if you're trying to use WebSocket. Please check example below.

FEATURES:

  • Minimal
  • Simple
  • Modular
  • No templates (You could use tilt)
  • No ORM (You could use sequel)
  • No dup in call
  • Regular expression routes, e.g. get %r{^/(?<id>\d+)$}
  • String routes, e.g. get '/'
  • Custom routes, e.g. get Matcher.new
  • Build for either Rack applications or Rack middleware
  • Include extensions for more features (checkout jellyfish-contrib)

WHY?

Because Sinatra is too complex and inconsistent for me.

REQUIREMENTS:

  • Tested with MRI (official CRuby), Rubinius and JRuby.

INSTALLATION:

gem install jellyfish

SYNOPSIS:

You could also take a look at config.ru as an example.

Hello Jellyfish, your lovely config.ru

require 'jellyfish'
class Tank
  include Jellyfish
  get '/' do
    "Jelly Kelly\n"
  end
end
use Rack::ContentLength
use Rack::ContentType, 'text/plain'
run Tank.new

Regular expression routes

require 'jellyfish'
class Tank
  include Jellyfish
  get %r{^/(?<id>\d+)$} do |match|
    "Jelly ##{match[:id]}\n"
  end
end
use Rack::ContentLength
use Rack::ContentType, 'text/plain'
run Tank.new

Custom matcher routes

require 'jellyfish'
class Tank
  include Jellyfish
  class Matcher
    def match path
      path.reverse == 'match/'
    end
  end
  get Matcher.new do |match|
    "#{match}\n"
  end
end
use Rack::ContentLength
use Rack::ContentType, 'text/plain'
run Tank.new

Different HTTP status and custom headers

require 'jellyfish'
class Tank
  include Jellyfish
  post '/' do
    headers       'X-Jellyfish-Life' => '100'
    headers_merge 'X-Jellyfish-Mana' => '200'
    body "Jellyfish 100/200\n"
    status 201
    'return is ignored if body has already been set'
  end
end
use Rack::ContentLength
use Rack::ContentType, 'text/plain'
run Tank.new

Redirect helper

require 'jellyfish'
class Tank
  include Jellyfish
  get '/lookup' do
    found "#{env['rack.url_scheme']}://#{env['HTTP_HOST']}/"
  end
end
use Rack::ContentLength
use Rack::ContentType, 'text/plain'
run Tank.new

Crash-proof

require 'jellyfish'
class Tank
  include Jellyfish
  get '/crash' do
    raise 'crash'
  end
end
use Rack::ContentLength
use Rack::ContentType, 'text/plain'
run Tank.new

Custom error handler

require 'jellyfish'
class Tank
  include Jellyfish
  handle NameError do |e|
    status 403
    "No one hears you: #{e.backtrace.first}\n"
  end
  get '/yell' do
    yell
  end
end
use Rack::ContentLength
use Rack::ContentType, 'text/plain'
run Tank.new

Custom error 404 handler

require 'jellyfish'
class Tank
  include Jellyfish
  handle Jellyfish::NotFound do |e|
    status 404
    "You found nothing."
  end
end
use Rack::ContentLength
use Rack::ContentType, 'text/plain'
run Tank.new

Custom error handler for multiple errors

require 'jellyfish'
class Tank
  include Jellyfish
  handle Jellyfish::NotFound, NameError do |e|
    status 404
    "You found nothing."
  end
  get '/yell' do
    yell
  end
end
use Rack::ContentLength
use Rack::ContentType, 'text/plain'
run Tank.new

Access Rack::Request and params

require 'jellyfish'
class Tank
  include Jellyfish
  get '/report' do
    "Your name is #{request.params['name']}\n"
  end
end
use Rack::ContentLength
use Rack::ContentType, 'text/plain'
run Tank.new

Re-dispatch the request with modified env

require 'jellyfish'
class Tank
  include Jellyfish
  get '/report' do
    status, headers, body = jellyfish.call(env.merge('PATH_INFO' => '/info'))
    self.status  status
    self.headers headers
    self.body    body
  end
  get('/info'){ "OK\n" }
end
use Rack::ContentLength
use Rack::ContentType, 'text/plain'
run Tank.new

Include custom helper in built-in controller

Basically it's the same as defining a custom controller and then include the helper. This is merely a short hand. See next section for defining a custom controller.

require 'jellyfish'
class Heater
  include Jellyfish
  get '/status' do
    temperature
  end

  module Helper
    def temperature
      "30\u{2103}\n"
    end
  end
  controller_include Helper
end
use Rack::ContentLength
use Rack::ContentType, 'text/plain'
run Heater.new

Define custom controller manually

This is effectively the same as defining a helper module as above and include it, but more flexible and extensible.

require 'jellyfish'
class Heater
  include Jellyfish
  get '/status' do
    temperature
  end

  class Controller < Jellyfish::Controller
    def temperature
      "30\u{2103}\n"
    end
  end
  controller Controller
end
use Rack::ContentLength
use Rack::ContentType, 'text/plain'
run Heater.new

Override dispatch for processing before action

We don't have before action built-in, but we could override dispatch in the controller to do the same thing. CAVEAT: Remember to call super.

require 'jellyfish'
class Tank
  include Jellyfish
  controller_include Module.new{
    def dispatch
      @state = 'jumps'
      super
    end
  }

  get do
    "Jelly #{@state}.\n"
  end
end
use Rack::ContentLength
use Rack::ContentType, 'text/plain'
run Tank.new

Extension: Jellyfish::Builder, a faster Rack::Builder and Rack::URLMap

Default Rack::Builder and Rack::URLMap is routing via linear search, which could be very slow with a large number of routes. We could use Jellyfish::Builder in this case because it would compile the routes into a regular expression, it would be matching much faster than linear search.

Note that Jellyfish::Builder is not a complete compatible implementation. The followings are intentional:

  • There's no Jellyfish::Builder.call because it doesn't make sense in my opinion. Always use Jellyfish::Builder.app instead.

  • There's no Jellyfish::Builder.parse_file and Jellyfish::Builder.new_from_string because Rack servers are not going to use Jellyfish::Builder to parse config.ru at this point. We could provide this if there's a need.

  • Jellyfish::URLMap does not modify env, and it would call the app with another instance of Hash. Mutating data is a bad idea.

  • All other tests passed the same test suites for Rack::Builder and Jellyfish::URLMap.

require 'jellyfish'

run Jellyfish::Builder.app{
  map '/a'   do; run lambda{ |_| [200, {}, ["a\n"]  ] }; end
  map '/b'   do; run lambda{ |_| [200, {}, ["b\n"]  ] }; end
  map '/c'   do; run lambda{ |_| [200, {}, ["c\n"]  ] }; end
  map '/d'   do; run lambda{ |_| [200, {}, ["d\n"]  ] }; end
  map '/e' do
    map '/f' do; run lambda{ |_| [200, {}, ["e/f\n"]] }; end
    map '/g' do; run lambda{ |_| [200, {}, ["e/g\n"]] }; end
    map '/h' do; run lambda{ |_| [200, {}, ["e/h\n"]] }; end
    map '/i' do; run lambda{ |_| [200, {}, ["e/i\n"]] }; end
    map '/'  do; run lambda{ |_| [200, {}, ["e\n"]]   }; end
  end
  map '/j'   do; run lambda{ |_| [200, {}, ["j\n"]  ] }; end
  map '/k'   do; run lambda{ |_| [200, {}, ["k\n"]  ] }; end
  map '/l'   do; run lambda{ |_| [200, {}, ["l\n"]  ] }; end
  map '/m' do
    map '/g' do; run lambda{ |_| [200, {}, ["m/g\n"]] }; end
    run lambda{ |_| [200, {}, ["m\n"]  ] }
  end

  use Rack::ContentLength
  run lambda{ |_| [200, {}, ["/\n"]] }
}

You could try a stupid benchmark yourself:

ruby -Ilib bench/bench_builder.rb

For a 1000 routes app, here's my result:

Calculating -------------------------------------
   Jellyfish::URLMap     5.726k i/100ms
        Rack::URLMap   167.000  i/100ms
-------------------------------------------------
   Jellyfish::URLMap     62.397k (± 1.2%) i/s -    314.930k
        Rack::URLMap      1.702k (± 1.5%) i/s -      8.517k

Comparison:
   Jellyfish::URLMap:    62397.3 i/s
        Rack::URLMap:     1702.0 i/s - 36.66x slower

Extension: Jellyfish::Builder#listen

listen is a convenient way to define routing based on the host. We could also use map inside listen block. Here's a quick example that specifically listen on a particular host for long-polling and all other hosts would go to the default app.

require 'jellyfish'

long_poll = lambda{ |env| [200, {}, ["long_poll #{env['HTTP_HOST']}\n"]] }
fast_app  = lambda{ |env| [200, {}, ["fast_app  #{env['HTTP_HOST']}\n"]] }

run Jellyfish::Builder.app{
  listen 'slow-app' do
    run long_poll
  end

  run fast_app
}
Extension: Jellyfish::Builder#listen (map path, host:)

Alternatively, we could pass host as an argument to map so that the endpoint would only listen on a specific host.

require 'jellyfish'

long_poll = lambda{ |env| [200, {}, ["long_poll #{env['HTTP_HOST']}\n"]] }
fast_app  = lambda{ |env| [200, {}, ["fast_app  #{env['HTTP_HOST']}\n"]] }

run Jellyfish::Builder.app{
  map '/', host: 'slow-app' do
    run long_poll
  end

  run fast_app
}
Extension: Jellyfish::Builder#listen (map "http://#{path}")

Or if you really prefer the Rack::URLMap compatible way, then you could just add http://host to your path prefix. https works, too.

require 'jellyfish'

long_poll = lambda{ |env| [200, {}, ["long_poll #{env['HTTP_HOST']}\n"]] }
fast_app  = lambda{ |env| [200, {}, ["fast_app  #{env['HTTP_HOST']}\n"]] }

run Jellyfish::Builder.app{
  map 'http://slow-app' do
    run long_poll
  end

  run fast_app
}

Extension: Jellyfish::Rewrite

Jellyfish::Builder is mostly compatible with Rack::Builder, and Jellyfish::Rewrite is an extension to Rack::Builder which allows you to rewrite env['PATH_INFO'] in an easy way. In an ideal world, we don't really need this. But in real world, we might want to have some backward compatible API which continues to work even if the API endpoint has already been changed.

Suppose the old API was: /users/me, and we want to change to /profiles/me, while leaving the /users/list as before. We may have this:

require 'jellyfish'

users_api    = lambda{ |env| [200, {}, ["/users#{env['PATH_INFO']}\n"]] }
profiles_api = lambda{ |env| [200, {}, ["/profiles#{env['PATH_INFO']}\n"]] }

run Jellyfish::Builder.app{
  rewrite '/users/me' => '/me' do
    run profiles_api
  end
  map '/profiles' do
    run profiles_api
  end
  map '/users' do
    run users_api
  end
}

This way, we would rewrite /users/me to /profiles/me and serve it with our profiles API app, while leaving all other paths begin with /users continue to work with the old users API app.

Extension: Jellyfish::Rewrite (map path, to:)

Note that you could also use map path, :to if you prefer this API more:

require 'jellyfish'

users_api    = lambda{ |env| [200, {}, ["/users#{env['PATH_INFO']}\n"]] }
profiles_api = lambda{ |env| [200, {}, ["/profiles#{env['PATH_INFO']}\n"]] }

run Jellyfish::Builder.app{
  map '/users/me', to: '/me' do
    run profiles_api
  end
  map '/profiles' do
    run profiles_api
  end
  map '/users' do
    run users_api
  end
}
Extension: Jellyfish::Rewrite (rewrite rules)

Note that rewrite takes a hash which could contain more than one rule:

require 'jellyfish'

profiles_api = lambda{ |env| [200, {}, ["/profiles#{env['PATH_INFO']}\n"]] }

run Jellyfish::Builder.app{
  rewrite '/users/me' => '/me',
          '/users/fa' => '/fa' do
    run profiles_api
  end
}

Extension: NormalizedParams (with force_encoding)

require 'jellyfish'
class Tank
  include Jellyfish
  controller_include Jellyfish::NormalizedParams

  get %r{^/(?<id>\d+)$} do
    "Jelly ##{params[:id]}\n"
  end
end
use Rack::ContentLength
use Rack::ContentType, 'text/plain'
run Tank.new

Extension: NormalizedPath (with unescaping)

require 'jellyfish'
class Tank
  include Jellyfish
  controller_include Jellyfish::NormalizedPath

  get "/\u{56e7}" do
    "#{env['PATH_INFO']}=#{path_info}\n"
  end
end
use Rack::ContentLength
use Rack::ContentType, 'text/plain'
run Tank.new

Extension: Using multiple extensions with custom controller

Note that the controller should be assigned lastly in order to include modules remembered in controller_include.

require 'jellyfish'
class Tank
  include Jellyfish
  class MyController < Jellyfish::Controller
    include Jellyfish::WebSocket
  end
  controller_include NormalizedParams, NormalizedPath
  controller MyController

  get %r{^/(?<id>\d+)$} do
    "Jelly ##{params[:id]} jumps.\n"
  end
end
use Rack::ContentLength
use Rack::ContentType, 'text/plain'
run Tank.new

Jellyfish as a middleware

If the Jellyfish middleware cannot find a corresponding action, it would then forward the request to the lower application. We call this cascade.

require 'jellyfish'
class Heater
  include Jellyfish
  get '/status' do
    "30\u{2103}\n"
  end
end

class Tank
  include Jellyfish
  get '/' do
    "Jelly Kelly\n"
  end
end

use Rack::ContentLength
use Rack::ContentType, 'text/plain'
use Heater
run Tank.new

Modify response as a middleware

We could also explicitly call the lower app. This would give us more flexibility than simply forwarding it.

require 'jellyfish'
class Heater
  include Jellyfish
  get '/status' do
    status, headers, body = jellyfish.app.call(env)
    self.status  status
    self.headers headers
    self.body    body
    headers_merge('X-Temperature' => "30\u{2103}")
  end
end

class Tank
  include Jellyfish
  get '/status' do
    "See header X-Temperature\n"
  end
end

use Rack::ContentLength
use Rack::ContentType, 'text/plain'
use Heater
run Tank.new

Override cascade for customized forwarding

We could also override cascade in order to craft custom response when forwarding is happening. Note that whenever this forwarding is happening, Jellyfish won't try to merge the headers from dispatch method, because in this case Jellyfish is served as a pure proxy. As result we need to explicitly merge the headers if we really want.

require 'jellyfish'
class Heater
  include Jellyfish
  controller_include Module.new{
    def dispatch
      headers_merge('X-Temperature' => "35\u{2103}")
      super
    end

    def cascade
      status, headers, body = jellyfish.app.call(env)
      halt [status, headers_merge(headers), body]
    end
  }
end

class Tank
  include Jellyfish
  get '/status' do
    "\n"
  end
end

use Rack::ContentLength
use Rack::ContentType, 'text/plain'
use Heater
run Tank.new

Simple before action as a middleware

require 'jellyfish'
class Heater
  include Jellyfish
  get '/status' do
    env['temperature'] = 30
    cascade
  end
end

class Tank
  include Jellyfish
  get '/status' do
    "#{env['temperature']}\u{2103}\n"
  end
end

use Rack::ContentLength
use Rack::ContentType, 'text/plain'
use Heater
run Tank.new

One huge tank

require 'jellyfish'
class Heater
  include Jellyfish
  get '/status' do
    "30\u{2103}\n"
  end
end

class Tank
  include Jellyfish
  get '/' do
    "Jelly Kelly\n"
  end
end

HugeTank = Rack::Builder.app do
  use Rack::ContentLength
  use Rack::ContentType, 'text/plain'
  use Heater
  run Tank.new
end

run HugeTank

Raise exceptions

require 'jellyfish'
class Protector
  include Jellyfish
  handle StandardError do |e|
    "Protected: #{e}\n"
  end
end

class Tank
  include Jellyfish
  handle_exceptions false # default is true, setting false here would make
                          # the outside Protector handle the exception
  get '/' do
    raise "Oops, tank broken"
  end
end

use Rack::ContentLength
use Rack::ContentType, 'text/plain'
use Protector
run Tank.new

Chunked transfer encoding (streaming) with Jellyfish::ChunkedBody

You would need a proper server setup. Here's an example with Rainbows and fibers:

class Tank
  include Jellyfish
  get '/chunked' do
    ChunkedBody.new{ |out|
      (0..4).each{ |i| out.call("#{i}\n") }
    }
  end
end
use Rack::Chunked
use Rack::ContentType, 'text/plain'
run Tank.new

Chunked transfer encoding (streaming) with custom body

class Tank
  include Jellyfish
  class Body
    def each
      (0..4).each{ |i| yield "#{i}\n" }
    end
  end
  get '/chunked' do
    Body.new
  end
end
use Rack::Chunked
use Rack::ContentType, 'text/plain'
run Tank.new

Server Sent Event (SSE)

class Tank
  include Jellyfish
  class Body
    def each
      (0..4).each{ |i| yield "data: #{i}\n\n" }
    end
  end
  get '/sse' do
    headers_merge('Content-Type' => 'text/event-stream')
    Body.new
  end
end
run Tank.new

Server Sent Event (SSE) with Rack Hijacking

class Tank
  include Jellyfish
  get '/sse' do
    headers_merge(
      'Content-Type' => 'text/event-stream',
      'rack.hijack'  => lambda do |sock|
        (0..4).each do |i|
          sock.write("data: #{i}\n\n")
        end
        sock.close
      end)
  end
end
run Tank.new

Using WebSocket

Note that this only works for Rack servers which support hijack. You're better off with a threaded server such as Rainbows! with thread based concurrency model, or Puma.

Event-driven based server is a whole different story though. Since EventMachine is basically dead, we could see if there would be a Celluloid-IO based web server production ready in the future, so that we could take the advantage of event based approach.

class Tank
  include Jellyfish
  controller_include Jellyfish::WebSocket
  get '/echo' do
    switch_protocol do |msg|
      ws_write(msg)
    end
    ws_write('Hi!')
    ws_start
  end
end
run Tank.new

CONTRIBUTORS:

  • Fumin (@fumin)
  • Jason R. Clark (@jasonrclark)
  • Lin Jen-Shin (@godfat)

LICENSE:

Apache License 2.0 (Apache-2.0)

Copyright (c) 2012-2018, Lin Jen-Shin (godfat)

Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.