goofy 1.0.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gems +4 -0
- data/.gitignore +2 -0
- data/CHANGELOG +47 -0
- data/CONTRIBUTING +19 -0
- data/Gemfile +4 -0
- data/LICENSE +23 -0
- data/README.md +67 -0
- data/app/.rspec +2 -0
- data/app/Gemfile +13 -0
- data/app/app/controllers/application_controller.rb +3 -0
- data/app/app/services/.keep +0 -0
- data/app/config.ru +4 -0
- data/app/config/environment.rb +9 -0
- data/app/config/initializers/.keep +0 -0
- data/app/config/routes.rb +7 -0
- data/app/config/settings.rb +0 -0
- data/app/spec/helpers.rb +2 -0
- data/app/spec/helpers/goofy.rb +11 -0
- data/app/spec/spec_helper.rb +107 -0
- data/benchmark/measure.rb +35 -0
- data/bin/check_its_goofy.rb +15 -0
- data/bin/goofy +61 -0
- data/bin/goofy_generator.rb +357 -0
- data/bin/goofy_instance_creator.rb +40 -0
- data/examples/config.ru +18 -0
- data/examples/measure.rb +17 -0
- data/examples/rack-response.ru +21 -0
- data/examples/views/home.mote +7 -0
- data/examples/views/layout.mote +11 -0
- data/goofy.gemspec +26 -0
- data/lib/goofy.rb +405 -0
- data/lib/goofy/capybara.rb +13 -0
- data/lib/goofy/controller.rb +14 -0
- data/lib/goofy/controller/base.rb +21 -0
- data/lib/goofy/controller/callbacks.rb +19 -0
- data/lib/goofy/render.rb +63 -0
- data/lib/goofy/router.rb +9 -0
- data/lib/goofy/safe.rb +23 -0
- data/lib/goofy/safe/csrf.rb +47 -0
- data/lib/goofy/safe/secure_headers.rb +40 -0
- data/lib/goofy/test.rb +11 -0
- data/makefile +4 -0
- data/test/accept.rb +32 -0
- data/test/captures.rb +162 -0
- data/test/composition.rb +69 -0
- data/test/controller.rb +29 -0
- data/test/cookie.rb +34 -0
- data/test/csrf.rb +139 -0
- data/test/extension.rb +21 -0
- data/test/helper.rb +11 -0
- data/test/host.rb +29 -0
- data/test/integration.rb +114 -0
- data/test/match.rb +86 -0
- data/test/middleware.rb +46 -0
- data/test/number.rb +36 -0
- data/test/on.rb +157 -0
- data/test/param.rb +66 -0
- data/test/path.rb +86 -0
- data/test/plugin.rb +68 -0
- data/test/rack.rb +22 -0
- data/test/redirect.rb +21 -0
- data/test/render.rb +128 -0
- data/test/root.rb +83 -0
- data/test/run.rb +23 -0
- data/test/safe.rb +74 -0
- data/test/segment.rb +45 -0
- data/test/session.rb +21 -0
- data/test/settings.rb +52 -0
- data/test/views/about.erb +1 -0
- data/test/views/about.str +1 -0
- data/test/views/content-yield.erb +1 -0
- data/test/views/custom/abs_path.mote +1 -0
- data/test/views/frag.mote +1 -0
- data/test/views/home.erb +2 -0
- data/test/views/home.mote +1 -0
- data/test/views/home.str +2 -0
- data/test/views/layout-alternative.erb +2 -0
- data/test/views/layout-yield.erb +3 -0
- data/test/views/layout.erb +2 -0
- data/test/views/layout.mote +2 -0
- data/test/views/layout.str +2 -0
- data/test/views/test.erb +1 -0
- data/test/with.rb +42 -0
- metadata +271 -0
data/goofy.gemspec
ADDED
@@ -0,0 +1,26 @@
|
|
1
|
+
Gem::Specification.new do |s|
|
2
|
+
s.name = "goofy"
|
3
|
+
s.version = "1.0.2"
|
4
|
+
s.summary = "Microframework for web applications"
|
5
|
+
s.description = "Goofy is a microframework for web applications(heavily based on Cuba)."
|
6
|
+
s.authors = ["Ehsan Yousefi"]
|
7
|
+
s.email = ["ehsan.yousefi@live.com"]
|
8
|
+
s.homepage = "https://github.com/EhsanYousefi/goofy"
|
9
|
+
s.license = "MIT"
|
10
|
+
|
11
|
+
s.files = `git ls-files`.split("\n")
|
12
|
+
s.require_paths = ["lib"]
|
13
|
+
s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
|
14
|
+
|
15
|
+
s.add_dependency "rack", "~> 1.6.0"
|
16
|
+
s.add_dependency "prong", '~> 1.0'
|
17
|
+
s.add_dependency "require_all"
|
18
|
+
s.add_dependency "psych"
|
19
|
+
s.add_dependency "wisper"
|
20
|
+
s.add_dependency "racksh"
|
21
|
+
s.add_dependency "shotgun"
|
22
|
+
s.add_development_dependency "cutest"
|
23
|
+
s.add_development_dependency "rack-test"
|
24
|
+
s.add_development_dependency "tilt"
|
25
|
+
|
26
|
+
end
|
data/lib/goofy.rb
ADDED
@@ -0,0 +1,405 @@
|
|
1
|
+
require "rack"
|
2
|
+
require "time"
|
3
|
+
|
4
|
+
class Goofy
|
5
|
+
|
6
|
+
SLASH = "/".freeze
|
7
|
+
EMPTY = "".freeze
|
8
|
+
SEGMENT = "([^\\/]+)".freeze
|
9
|
+
DEFAULT = "text/html; charset=utf-8".freeze
|
10
|
+
|
11
|
+
class Response
|
12
|
+
LOCATION = "Location".freeze
|
13
|
+
|
14
|
+
attr_accessor :status
|
15
|
+
|
16
|
+
attr :body
|
17
|
+
attr :headers
|
18
|
+
|
19
|
+
def initialize(headers = {})
|
20
|
+
@status = nil
|
21
|
+
@headers = headers
|
22
|
+
@body = []
|
23
|
+
@length = 0
|
24
|
+
end
|
25
|
+
|
26
|
+
def [](key)
|
27
|
+
@headers[key]
|
28
|
+
end
|
29
|
+
|
30
|
+
def []=(key, value)
|
31
|
+
@headers[key] = value
|
32
|
+
end
|
33
|
+
|
34
|
+
def write(str)
|
35
|
+
s = str.to_s
|
36
|
+
|
37
|
+
@length += s.bytesize
|
38
|
+
@headers[Rack::CONTENT_LENGTH] = @length.to_s
|
39
|
+
@body << s
|
40
|
+
end
|
41
|
+
|
42
|
+
def redirect(path, status = 302)
|
43
|
+
@headers[LOCATION] = path
|
44
|
+
@status = status
|
45
|
+
end
|
46
|
+
|
47
|
+
def finish
|
48
|
+
[@status, @headers, @body]
|
49
|
+
end
|
50
|
+
|
51
|
+
def set_cookie(key, value)
|
52
|
+
Rack::Utils.set_cookie_header!(@headers, key, value)
|
53
|
+
end
|
54
|
+
|
55
|
+
def delete_cookie(key, value = {})
|
56
|
+
Rack::Utils.delete_cookie_header!(@headers, key, value)
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
def self.reset!
|
61
|
+
@app = nil
|
62
|
+
@prototype = nil
|
63
|
+
end
|
64
|
+
|
65
|
+
def self.app
|
66
|
+
@app ||= Rack::Builder.new
|
67
|
+
end
|
68
|
+
|
69
|
+
def self.use(middleware, *args, &block)
|
70
|
+
app.use(middleware, *args, &block)
|
71
|
+
end
|
72
|
+
|
73
|
+
def self.define(&block)
|
74
|
+
app.run new(&block)
|
75
|
+
end
|
76
|
+
|
77
|
+
def self.prototype
|
78
|
+
@prototype ||= app.to_app
|
79
|
+
end
|
80
|
+
|
81
|
+
def self.call(env)
|
82
|
+
prototype.call(env)
|
83
|
+
end
|
84
|
+
|
85
|
+
def self.plugin(mixin)
|
86
|
+
include mixin
|
87
|
+
extend mixin::ClassMethods if defined?(mixin::ClassMethods)
|
88
|
+
|
89
|
+
mixin.setup(self) if mixin.respond_to?(:setup)
|
90
|
+
end
|
91
|
+
|
92
|
+
def self.settings
|
93
|
+
@settings ||= {}
|
94
|
+
end
|
95
|
+
|
96
|
+
def self.deepclone(obj)
|
97
|
+
Marshal.load(Marshal.dump(obj))
|
98
|
+
end
|
99
|
+
|
100
|
+
def self.inherited(child)
|
101
|
+
child.settings.replace(deepclone(settings))
|
102
|
+
end
|
103
|
+
|
104
|
+
attr :env
|
105
|
+
attr :req
|
106
|
+
attr :res
|
107
|
+
attr :captures
|
108
|
+
|
109
|
+
def initialize(&blk)
|
110
|
+
@blk = blk
|
111
|
+
@captures = []
|
112
|
+
end
|
113
|
+
|
114
|
+
def settings
|
115
|
+
self.class.settings
|
116
|
+
end
|
117
|
+
|
118
|
+
def call(env)
|
119
|
+
dup.call!(env)
|
120
|
+
end
|
121
|
+
|
122
|
+
def call!(env)
|
123
|
+
@env = env
|
124
|
+
@req = settings[:req].new(env)
|
125
|
+
@res = settings[:res].new(settings[:default_headers].dup)
|
126
|
+
|
127
|
+
# This `catch` statement will either receive a
|
128
|
+
# rack response tuple via a `halt`, or will
|
129
|
+
# fall back to issuing a 404.
|
130
|
+
#
|
131
|
+
# When it `catch`es a throw, the return value
|
132
|
+
# of this whole `call!` method will be the
|
133
|
+
# rack response tuple, which is exactly what we want.
|
134
|
+
catch(:halt) do
|
135
|
+
instance_eval(&@blk)
|
136
|
+
|
137
|
+
not_found
|
138
|
+
res.finish
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
def session
|
143
|
+
env["rack.session"] || raise(RuntimeError,
|
144
|
+
"You're missing a session handler. You can get started " +
|
145
|
+
"by adding Goofy.use Rack::Session::Cookie")
|
146
|
+
end
|
147
|
+
|
148
|
+
# The heart of the path / verb / any condition matching.
|
149
|
+
#
|
150
|
+
# @example
|
151
|
+
#
|
152
|
+
# on get do
|
153
|
+
# res.write "GET"
|
154
|
+
# end
|
155
|
+
#
|
156
|
+
# on get, "signup" do
|
157
|
+
# res.write "Signup"
|
158
|
+
# end
|
159
|
+
#
|
160
|
+
# on "user/:id" do |uid|
|
161
|
+
# res.write "User: #{uid}"
|
162
|
+
# end
|
163
|
+
#
|
164
|
+
# on "styles", extension("css") do |file|
|
165
|
+
# res.write render("styles/#{file}.sass")
|
166
|
+
# end
|
167
|
+
#
|
168
|
+
def on(*args, &block)
|
169
|
+
try do
|
170
|
+
# For every block, we make sure to reset captures so that
|
171
|
+
# nesting matchers won't mess with each other's captures.
|
172
|
+
@captures = []
|
173
|
+
|
174
|
+
# We stop evaluation of this entire matcher unless
|
175
|
+
# each and every `arg` defined for this matcher evaluates
|
176
|
+
# to a non-false value.
|
177
|
+
#
|
178
|
+
# Short circuit examples:
|
179
|
+
# on true, false do
|
180
|
+
#
|
181
|
+
# # PATH_INFO=/user
|
182
|
+
# on true, "signup"
|
183
|
+
return unless args.all? { |arg| match(arg) }
|
184
|
+
|
185
|
+
# The captures we yield here were generated and assembled
|
186
|
+
# by evaluating each of the `arg`s above. Most of these
|
187
|
+
# are carried out by #consume.
|
188
|
+
yield(*captures)
|
189
|
+
|
190
|
+
if res.status.nil?
|
191
|
+
if res.body.empty?
|
192
|
+
not_found
|
193
|
+
else
|
194
|
+
res.headers[Rack::CONTENT_TYPE] ||= DEFAULT
|
195
|
+
res.status = 200
|
196
|
+
end
|
197
|
+
end
|
198
|
+
|
199
|
+
halt(res.finish)
|
200
|
+
end
|
201
|
+
end
|
202
|
+
|
203
|
+
# @private Used internally by #on to ensure that SCRIPT_NAME and
|
204
|
+
# PATH_INFO are reset to their proper values.
|
205
|
+
def try
|
206
|
+
script, path = env[Rack::SCRIPT_NAME], env[Rack::PATH_INFO]
|
207
|
+
|
208
|
+
yield
|
209
|
+
|
210
|
+
ensure
|
211
|
+
env[Rack::SCRIPT_NAME], env[Rack::PATH_INFO] = script, path
|
212
|
+
end
|
213
|
+
private :try
|
214
|
+
|
215
|
+
def consume(pattern)
|
216
|
+
matchdata = env[Rack::PATH_INFO].match(/\A\/(#{pattern})(\/|\z)/)
|
217
|
+
|
218
|
+
return false unless matchdata
|
219
|
+
|
220
|
+
path, *vars = matchdata.captures
|
221
|
+
|
222
|
+
env[Rack::SCRIPT_NAME] += "/#{path}"
|
223
|
+
env[Rack::PATH_INFO] = "#{vars.pop}#{matchdata.post_match}"
|
224
|
+
|
225
|
+
captures.push(*vars)
|
226
|
+
end
|
227
|
+
private :consume
|
228
|
+
|
229
|
+
def match(matcher, segment = SEGMENT)
|
230
|
+
case matcher
|
231
|
+
when String then consume(matcher.gsub(/:\w+/, segment))
|
232
|
+
when Regexp then consume(matcher)
|
233
|
+
when Symbol then consume(segment)
|
234
|
+
when Proc then matcher.call
|
235
|
+
else
|
236
|
+
matcher
|
237
|
+
end
|
238
|
+
end
|
239
|
+
|
240
|
+
# A matcher for files with a certain extension.
|
241
|
+
#
|
242
|
+
# @example
|
243
|
+
# # PATH_INFO=/style/app.css
|
244
|
+
# on "style", extension("css") do |file|
|
245
|
+
# res.write file # writes app
|
246
|
+
# end
|
247
|
+
def extension(ext = "\\w+")
|
248
|
+
lambda { consume("([^\\/]+?)\.#{ext}\\z") }
|
249
|
+
end
|
250
|
+
|
251
|
+
# Ensures that certain request parameters are present. Acts like a
|
252
|
+
# precondition / assertion for your route. A default value can be
|
253
|
+
# provided as a second argument. In that case, it always matches
|
254
|
+
# and the result is either the parameter or the default value.
|
255
|
+
#
|
256
|
+
# @example
|
257
|
+
# # POST with data like user[fname]=John&user[lname]=Doe
|
258
|
+
# on "signup", param("user") do |atts|
|
259
|
+
# User.create(atts)
|
260
|
+
# end
|
261
|
+
#
|
262
|
+
# on "login", param("username", "guest") do |username|
|
263
|
+
# # If not provided, username == "guest"
|
264
|
+
# end
|
265
|
+
def param(key, default = nil)
|
266
|
+
value = req[key] || default
|
267
|
+
|
268
|
+
lambda { captures << value unless value.to_s.empty? }
|
269
|
+
end
|
270
|
+
|
271
|
+
# Useful for matching against the request host (i.e. HTTP_HOST).
|
272
|
+
#
|
273
|
+
# @example
|
274
|
+
# on host("account1.example.com"), "api" do
|
275
|
+
# res.write "You have reached the API of account1."
|
276
|
+
# end
|
277
|
+
def host(hostname)
|
278
|
+
hostname === req.host
|
279
|
+
end
|
280
|
+
|
281
|
+
# If you want to match against the HTTP_ACCEPT value.
|
282
|
+
#
|
283
|
+
# @example
|
284
|
+
# # HTTP_ACCEPT=application/xml
|
285
|
+
# on accept("application/xml") do
|
286
|
+
# # automatically set to application/xml.
|
287
|
+
# res.write res["Content-Type"]
|
288
|
+
# end
|
289
|
+
def accept(mimetype)
|
290
|
+
lambda do
|
291
|
+
accept = String(env["HTTP_ACCEPT"]).split(",")
|
292
|
+
|
293
|
+
if accept.any? { |s| s.strip == mimetype }
|
294
|
+
res[Rack::CONTENT_TYPE] = mimetype
|
295
|
+
end
|
296
|
+
end
|
297
|
+
end
|
298
|
+
|
299
|
+
# Syntactic sugar for providing catch-all matches.
|
300
|
+
#
|
301
|
+
# @example
|
302
|
+
# on default do
|
303
|
+
# res.write "404"
|
304
|
+
# end
|
305
|
+
def default
|
306
|
+
true
|
307
|
+
end
|
308
|
+
|
309
|
+
# Access the root of the application.
|
310
|
+
#
|
311
|
+
# @example
|
312
|
+
#
|
313
|
+
# # GET /
|
314
|
+
# on root do
|
315
|
+
# res.write "Home"
|
316
|
+
# end
|
317
|
+
def root
|
318
|
+
env[Rack::PATH_INFO] == SLASH || env[Rack::PATH_INFO] == EMPTY
|
319
|
+
end
|
320
|
+
|
321
|
+
# Syntatic sugar for providing HTTP Verb matching.
|
322
|
+
#
|
323
|
+
# @example
|
324
|
+
# on get, "signup" do
|
325
|
+
# end
|
326
|
+
#
|
327
|
+
# on post, "signup" do
|
328
|
+
# end
|
329
|
+
def get; req.get? end
|
330
|
+
def post; req.post? end
|
331
|
+
def put; req.put? end
|
332
|
+
def delete; req.delete? end
|
333
|
+
|
334
|
+
# If you want to halt the processing of an existing handler
|
335
|
+
# and continue it via a different handler.
|
336
|
+
#
|
337
|
+
# @example
|
338
|
+
# def redirect(*args)
|
339
|
+
# run Goofy.new { on(default) { res.redirect(*args) }}
|
340
|
+
# end
|
341
|
+
#
|
342
|
+
# on "account" do
|
343
|
+
# redirect "/login" unless session["uid"]
|
344
|
+
#
|
345
|
+
# res.write "Super secure account info."
|
346
|
+
# end
|
347
|
+
def run(app)
|
348
|
+
halt app.call(req.env)
|
349
|
+
end
|
350
|
+
|
351
|
+
def halt(response)
|
352
|
+
throw :halt, response
|
353
|
+
end
|
354
|
+
|
355
|
+
# Adds ability to pass information to a nested Goofy application.
|
356
|
+
# It receives two parameters: a hash that represents the passed
|
357
|
+
# information and a block. The #vars method is used to retrieve
|
358
|
+
# a hash with the passed information.
|
359
|
+
#
|
360
|
+
# class Platforms < Goofy
|
361
|
+
# define do
|
362
|
+
# platform = vars[:platform]
|
363
|
+
#
|
364
|
+
# on default do
|
365
|
+
# res.write(platform) # => "heroku" or "salesforce"
|
366
|
+
# end
|
367
|
+
# end
|
368
|
+
# end
|
369
|
+
#
|
370
|
+
# Goofy.define do
|
371
|
+
# on "(heroku|salesforce)" do |platform|
|
372
|
+
# with(platform: platform) do
|
373
|
+
# run(Platforms)
|
374
|
+
# end
|
375
|
+
# end
|
376
|
+
# end
|
377
|
+
#
|
378
|
+
def with(dict = {})
|
379
|
+
old, env["Goofy.vars"] = vars, vars.merge(dict)
|
380
|
+
yield
|
381
|
+
ensure
|
382
|
+
env["Goofy.vars"] = old
|
383
|
+
end
|
384
|
+
|
385
|
+
# Returns a hash with the information set by the #with method.
|
386
|
+
#
|
387
|
+
# with(role: "admin", site: "main") do
|
388
|
+
# on default do
|
389
|
+
# res.write(vars.inspect)
|
390
|
+
# end
|
391
|
+
# end
|
392
|
+
# # => '{:role=>"admin", :site=>"main"}'
|
393
|
+
#
|
394
|
+
def vars
|
395
|
+
env["Goofy.vars"] ||= {}
|
396
|
+
end
|
397
|
+
|
398
|
+
def not_found
|
399
|
+
res.status = 404
|
400
|
+
end
|
401
|
+
end
|
402
|
+
|
403
|
+
Goofy.settings[:req] = Rack::Request
|
404
|
+
Goofy.settings[:res] = Goofy::Response
|
405
|
+
Goofy.settings[:default_headers] = {}
|