by Lin Jen-Shin (godfat)
LINKS:
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
incall
- 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) 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 useJellyfish::Builder.app
instead. -
There's no
Jellyfish::Builder.parse_file
andJellyfish::Builder.new_from_string
because Rack servers are not going to useJellyfish::Builder
to parseconfig.ru
at this point. We could provide this if there's a need. -
Jellyfish::URLMap
does not modifyenv
, 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
andJellyfish::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::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::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-2023, 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
https://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.