utopia 1.4.0 → 1.5.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/.travis.yml +5 -1
  4. data/Gemfile +3 -0
  5. data/README.md +11 -0
  6. data/benchmarks/hash_vs_openstruct.rb +52 -0
  7. data/benchmarks/struct_vs_class.rb +89 -0
  8. data/bin/utopia +4 -5
  9. data/lib/utopia.rb +1 -0
  10. data/lib/utopia/content.rb +24 -15
  11. data/lib/utopia/content/node.rb +69 -3
  12. data/lib/utopia/content/processor.rb +32 -5
  13. data/lib/utopia/content/transaction.rb +138 -147
  14. data/lib/utopia/content_length.rb +50 -0
  15. data/lib/utopia/controller.rb +4 -0
  16. data/lib/utopia/controller/variables.rb +1 -1
  17. data/lib/utopia/http.rb +2 -0
  18. data/lib/utopia/localization.rb +4 -8
  19. data/lib/utopia/path.rb +13 -13
  20. data/lib/utopia/static.rb +25 -14
  21. data/lib/utopia/tags/environment.rb +1 -1
  22. data/lib/utopia/tags/override.rb +1 -1
  23. data/lib/utopia/version.rb +1 -1
  24. data/setup/server/git/hooks/post-receive +32 -24
  25. data/setup/site/Gemfile +8 -0
  26. data/setup/site/Rakefile +29 -6
  27. data/setup/site/config.ru +8 -8
  28. data/setup/site/pages/_heading.xnode +1 -1
  29. data/setup/site/pages/_page.xnode +2 -2
  30. data/spec/utopia/content_spec.rb +3 -3
  31. data/spec/utopia/controller/sequence_spec.rb +1 -1
  32. data/spec/utopia/controller/variables_spec.rb +1 -1
  33. data/spec/utopia/pages/node/index.xnode +1 -1
  34. data/spec/utopia/performance_spec.rb +90 -0
  35. data/spec/utopia/performance_spec/cache/head/readme.txt +1 -0
  36. data/spec/utopia/performance_spec/cache/meta/readme.txt +1 -0
  37. data/spec/utopia/performance_spec/config.ru +39 -0
  38. data/spec/utopia/performance_spec/lib/readme.txt +1 -0
  39. data/spec/utopia/performance_spec/pages/_heading.xnode +2 -0
  40. data/spec/utopia/performance_spec/pages/_page.xnode +26 -0
  41. data/spec/utopia/performance_spec/pages/api/controller.rb +7 -0
  42. data/spec/utopia/performance_spec/pages/errors/exception.xnode +5 -0
  43. data/spec/utopia/performance_spec/pages/errors/file-not-found.xnode +5 -0
  44. data/spec/utopia/performance_spec/pages/links.yaml +2 -0
  45. data/spec/utopia/performance_spec/pages/welcome/index.xnode +17 -0
  46. data/spec/utopia/rack_helper.rb +5 -2
  47. data/spec/utopia/setup_spec.rb +93 -0
  48. data/utopia.gemspec +1 -1
  49. metadata +34 -5
  50. data/lib/utopia/mail_exceptions.rb +0 -136
@@ -24,13 +24,9 @@ module Utopia
24
24
  # If you request a URL which has localized content, a localized redirect would be returned based on the content requested.
25
25
  class Localization
26
26
  # A wrapper to provide easy access to locale related data in the request.
27
- class RequestWrapper
28
- def initialize(request)
29
- if request.is_a? Rack::Request
30
- @env = request.env
31
- else
32
- @env = request
33
- end
27
+ class Wrapper
28
+ def initialize(env)
29
+ @env = env
34
30
  end
35
31
 
36
32
  def localization
@@ -54,7 +50,7 @@ module Utopia
54
50
  end
55
51
 
56
52
  def self.[] request
57
- RequestWrapper.new(request)
53
+ Wrapper.new(request.env)
58
54
  end
59
55
 
60
56
  RESOURCE_NOT_FOUND = [400, {}, []].freeze
@@ -203,14 +203,16 @@ module Utopia
203
203
  end
204
204
 
205
205
  def +(other)
206
- if other.kind_of? Array
207
- return join(other)
208
- elsif other.kind_of? Path
206
+ if other.kind_of? Path
209
207
  if other.absolute?
210
208
  return other
211
209
  else
212
210
  return join(other.components)
213
211
  end
212
+ elsif other.kind_of? Array
213
+ return join(other)
214
+ elsif other.kind_of? String
215
+ return join(other.split(SEPARATOR, -1))
214
216
  else
215
217
  return join([other.to_s])
216
218
  end
@@ -268,27 +270,25 @@ module Utopia
268
270
  def descend(&block)
269
271
  return to_enum(:descend) unless block_given?
270
272
 
271
- parent_path = []
273
+ components = []
272
274
 
273
275
  @components.each do |component|
274
- parent_path << component
276
+ components << component
275
277
 
276
- yield self.class.new(parent_path.dup)
278
+ yield self.class.new(components.dup)
277
279
  end
278
280
  end
279
281
 
280
282
  def ascend(&block)
281
283
  return to_enum(:ascend) unless block_given?
282
284
 
283
- next_parent = self
285
+ components = self.components.dup
284
286
 
285
- begin
286
- parent = next_parent
287
+ while components.any?
288
+ yield self.class.new(components.dup)
287
289
 
288
- yield parent
289
-
290
- next_parent = parent.dirname
291
- end until next_parent.eql?(parent)
290
+ components.pop
291
+ end
292
292
  end
293
293
 
294
294
  def split(at)
@@ -144,10 +144,12 @@ module Utopia
144
144
  File.mtime(full_path).httpdate
145
145
  end
146
146
 
147
- def size
147
+ def bytesize
148
148
  File.size(full_path)
149
149
  end
150
150
 
151
+ alias size bytesize
152
+
151
153
  def each
152
154
  File.open(full_path, "rb") do |file|
153
155
  file.seek(@range.begin)
@@ -176,6 +178,9 @@ module Utopia
176
178
  return true
177
179
  end
178
180
 
181
+ CONTENT_LENGTH = Rack::CONTENT_LENGTH
182
+ CONTENT_RANGE = 'Content-Range'.freeze
183
+
179
184
  def serve(env, response_headers)
180
185
  ranges = Rack::Utils.byte_ranges(env, size)
181
186
  response = [200, response_headers, self]
@@ -186,7 +191,7 @@ module Utopia
186
191
  # No ranges, or multiple ranges (which we don't support).
187
192
  # TODO: Support multiple byte-ranges, for now just send entire file:
188
193
  response[0] = 200
189
- response[1]["Content-Length"] = size.to_s
194
+ response[1][CONTENT_LENGTH] = size.to_s
190
195
  @range = 0...size
191
196
  else
192
197
  # Partial content:
@@ -194,25 +199,25 @@ module Utopia
194
199
  partial_size = @range.count
195
200
 
196
201
  response[0] = 206
197
- response[1]["Content-Length"] = partial_size.to_s
198
- response[1]["Content-Range"] = "bytes #{@range.min}-#{@range.max}/#{size}"
202
+ response[1][CONTENT_LENGTH] = partial_size.to_s
203
+ response[1][CONTENT_RANGE] = "bytes #{@range.min}-#{@range.max}/#{size}"
199
204
  end
200
-
201
- # puts "Serving file #{full_path.inspect}, range #{@range.inspect}"
202
-
205
+
203
206
  return response
204
207
  end
205
208
  end
206
209
 
207
210
  public
208
211
 
212
+ DEFAULT_CACHE_CONTROL = 'public, max-age=3600'.freeze
213
+
209
214
  def initialize(app, **options)
210
215
  @app = app
211
216
  @root = (options[:root] || Utopia::default_root).freeze
212
217
 
213
218
  @extensions = MimeTypeLoader.extensions_for(options[:types] || MIME_TYPES[:default])
214
219
 
215
- @cache_control = (options[:cache_control] || "public, max-age=3600")
220
+ @cache_control = (options[:cache_control] || DEFAULT_CACHE_CONTROL)
216
221
 
217
222
  self.freeze
218
223
  end
@@ -237,7 +242,13 @@ module Utopia
237
242
  end
238
243
 
239
244
  attr :extensions
240
-
245
+
246
+ LAST_MODIFIED = 'Last-Modified'.freeze
247
+ CONTENT_TYPE = HTTP::CONTENT_TYPE
248
+ CACHE_CONTROL = HTTP::CACHE_CONTROL
249
+ ETAG = 'ETag'.freeze
250
+ ACCEPT_RANGES = 'Accept-Ranges'.freeze
251
+
241
252
  def call(env)
242
253
  path_info = env[Rack::PATH_INFO]
243
254
  extension = File.extname(path_info)
@@ -251,11 +262,11 @@ module Utopia
251
262
 
252
263
  if file = fetch_file(path)
253
264
  response_headers = {
254
- "Last-Modified" => file.mtime_date,
255
- "Content-Type" => @extensions[extension],
256
- "Cache-Control" => @cache_control,
257
- "ETag" => file.etag,
258
- "Accept-Ranges" => "bytes"
265
+ LAST_MODIFIED => file.mtime_date,
266
+ CONTENT_TYPE => @extensions[extension],
267
+ CACHE_CONTROL => @cache_control,
268
+ ETAG => file.etag,
269
+ ACCEPT_RANGES => "bytes"
259
270
  }
260
271
 
261
272
  if file.modified?(env)
@@ -33,7 +33,7 @@ module Utopia
33
33
  only = state[:only].split(",").collect(&:to_sym) rescue []
34
34
 
35
35
  if defined?(@environment) and only.include?(@environment)
36
- transaction.parse_xml(state.content)
36
+ transaction.parse_markup(state.content)
37
37
  end
38
38
  end
39
39
  end
@@ -26,7 +26,7 @@ module Utopia
26
26
  end
27
27
 
28
28
  def self.call(transaction, state)
29
- transaction.parse_xml(state.content)
29
+ transaction.parse_markup(state.content)
30
30
  end
31
31
  end
32
32
  end
@@ -19,5 +19,5 @@
19
19
  # THE SOFTWARE.
20
20
 
21
21
  module Utopia
22
- VERSION = "1.4.0"
22
+ VERSION = "1.5.0"
23
23
  end
@@ -26,43 +26,51 @@ require 'etc'
26
26
  # Users in group wheel can execute all commands as user http with no password.
27
27
  # %wheel ALL=(http) NOPASSWD: ALL
28
28
 
29
- def sh(*args)
30
- puts args.join(' ')
31
- system(*args)
32
- end
29
+ GIT_WORK_TREE = `git config core.worktree`.chomp
33
30
 
34
- working_tree = `git config core.worktree`.chomp
31
+ # We convert GIT_DIR to an absolute path:
32
+ ENV['GIT_DIR'] = File.join(Dir.pwd, ENV['GIT_DIR'])
35
33
 
36
- puts "Updating Site #{working_tree}..."
34
+ # We deploy the site as the user and group of the directory for the working tree:
35
+ File.stat(GIT_WORK_TREE).tap do |stat|
36
+ ENV['DEPLOY_USER'] = DEPLOY_USER = Etc.getpwuid(stat.uid).name
37
+ ENV['DEPLOY_GROUP'] = DEPLOY_GROUP = Etc.getgrgid(stat.gid).name
38
+ end
37
39
 
38
- # Figure out the user and group of the working tree:
39
- working_tree_stat = File.stat(working_tree)
40
- deploy_user = Etc.getpwuid(working_tree_stat.uid).name
41
- deploy_group = Etc.getgrgid(working_tree_stat.gid).name
40
+ WHOAMI = `whoami`.chomp!
42
41
 
43
- puts "Updating permissions..."
42
+ # We should find out if we need to use sudo or not:
43
+ SUDO = if WHOAMI != DEPLOY_USER
44
+ ["sudo", "-u", "DEPLOY_USER"]
45
+ end
44
46
 
45
- Dir.chdir(working_tree) do
46
- sh("chmod -Rf ug+rwX .")
47
- sh("chown -Rf #{deploy_user}:#{deploy_group} .")
47
+ def sh(command)
48
+ puts command.join(' ')
49
+ system(*command) or abort("Deployment failed!")
50
+ end
48
51
 
49
- puts "Updating site #{Dir.pwd} as #{deploy_user}:#{deploy_group}..."
52
+ def sudo(command)
53
+ sh([*SUDO, *command])
54
+ end
55
+
56
+ puts "Deploying to #{GIT_WORK_TREE} as #{DEPLOY_USER}:#{DEPLOY_GROUP}..."
57
+ Dir.chdir(GIT_WORK_TREE) do
58
+ # Pass on our rights to the files we just uploaded to the deployment user/group:
59
+ sh %W{chmod -Rf ug+rwX .}
60
+ sh %W{chown -Rf #{DEPLOY_USER}:#{DEPLOY_GROUP} .}
50
61
 
51
- sh("sudo -u #{deploy_user} git checkout -f")
52
- sh("sudo -u #{deploy_user} git submodule update -i")
62
+ sudo %W{git checkout -f}
63
+ sudo %W{git submodule update -i}
53
64
 
54
65
  if File.exist? 'Gemfile'
55
- sh("sudo -u #{deploy_user} bundle install --deployment") or abort("Could not setup bundle!")
66
+ sudo %W{bundle install --deployment --clean --jobs=4 --retry=2 --quiet}
56
67
  end
57
68
 
58
- ENV['DEPLOY_USER'] = deploy_user
59
- ENV['DEPLOY_GROUP'] = deploy_group
60
-
61
69
  if File.exist? 'Rakefile'
62
- sh("sudo -u #{deploy_user} bundle exec rake deploy") or abort("Deploy task failed!")
70
+ sudo %W{bundle exec rake deploy}
63
71
  end
64
72
 
65
73
  puts "Restarting server..."
66
- sh("sudo -u #{deploy_user} mkdir -p tmp") unless File.exist?('tmp')
67
- sh("sudo -u #{deploy_user} touch tmp/restart.txt")
74
+ sudo %W{mkdir -p tmp} unless File.exist?('tmp')
75
+ sudo %W{touch tmp/restart.txt}
68
76
  end
@@ -5,6 +5,14 @@ gem "utopia", "~> $UTOPIA_VERSION"
5
5
  # gem "utopia-tags-gallery"
6
6
  # gem "utopia-tags-google-analytics"
7
7
 
8
+ gem "rake"
9
+ gem "bundler"
10
+
8
11
  group :development do
12
+ # For `rake server`:
9
13
  gem "puma"
14
+
15
+ # For `rake console`:
16
+ gem "pry"
17
+ gem "rack-test"
10
18
  end
@@ -1,9 +1,32 @@
1
1
 
2
- task :server do
3
- system('puma')
4
- end
5
-
2
+ desc 'Run by git post-update hook when deployed to a web server'
6
3
  task :deploy do
7
4
  # This task is typiclly run after the site is updated but before the server is restarted.
8
- puts "Preparing to deploy site in #{Dir.pwd.inspect}..."
9
- end
5
+ end
6
+
7
+ desc 'Set up the environment for running your web application'
8
+ task :environment do
9
+ RACK_ENV = (ENV['RACK_ENV'] ||= 'development').to_sym
10
+ end
11
+
12
+ desc 'Run a server for testing your web application'
13
+ task :server => :environment do
14
+ port = ENV.fetch('SERVER_PORT', 9292)
15
+ system('puma', '-p', port)
16
+ end
17
+
18
+ desc 'Start an interactive console for your web application'
19
+ task :console => :environment do
20
+ require 'pry'
21
+ require 'rack/test'
22
+
23
+ include Rack::Test::Methods
24
+
25
+ def app
26
+ @app ||= Rack::Builder.parse_file(File.expand_path("config.ru", __dir__)).first
27
+ end
28
+
29
+ Pry.start
30
+ end
31
+
32
+ task :default => :server
@@ -14,23 +14,28 @@ require 'utopia'
14
14
  require 'rack/cache'
15
15
 
16
16
  if RACK_ENV == :production
17
+ # Handle exceptions in production with a error page and send an email notification:
17
18
  use Utopia::Exceptions::Handler
18
19
  use Utopia::Exceptions::Mailer
19
- elsif RACK_ENV == :development
20
- use Rack::ShowExceptions
20
+ else
21
+ # We want to propate exceptions up when running tests:
22
+ use Rack::ShowExceptions unless RACK_ENV == :test
23
+
24
+ # Serve the public directory in a similar way to the web server:
21
25
  use Utopia::Static, root: 'public'
22
26
  end
23
27
 
24
28
  use Rack::Sendfile
25
29
 
26
30
  if RACK_ENV == :production
31
+ # Cache dynamically generated content where possible:
27
32
  use Rack::Cache,
28
33
  metastore: "file:#{Utopia::default_root("cache/meta")}",
29
34
  entitystore: "file:#{Utopia::default_root("cache/body")}",
30
35
  verbose: RACK_ENV == :development
31
36
  end
32
37
 
33
- use Rack::ContentLength
38
+ use Utopia::ContentLength
34
39
 
35
40
  use Utopia::Redirection::Rewrite,
36
41
  '/' => '/welcome/index'
@@ -50,11 +55,6 @@ use Utopia::Controller,
50
55
 
51
56
  use Utopia::Static
52
57
 
53
- if RACK_ENV != :production
54
- # Serve static files from public/ when not running in a production environment:
55
- use Utopia::Static, root: 'public'
56
- end
57
-
58
58
  # Serve dynamic content
59
59
  use Utopia::Content,
60
60
  cache_templates: (RACK_ENV == :production),
@@ -1,2 +1,2 @@
1
- <?r @title ||= content ?>
1
+ <?r transaction.attributes[:title] ||= content ?>
2
2
  <h1><content/></h1>
@@ -4,7 +4,7 @@
4
4
  <?r response.content_type = "text/html; charset=utf-8" ?>
5
5
  <?r response.cache! ?>
6
6
 
7
- <?r if title = (attributes["title"] || @title) ?>
7
+ <?r if title = self[:title] ?>
8
8
  <title>#{title.gsub(/<.*?>/, "")} - Utopia</title>
9
9
  <?r else ?>
10
10
  <title>Utopia</title>
@@ -14,7 +14,7 @@
14
14
  <link rel="stylesheet" href="/_static/site.css" type="text/css" media="screen" />
15
15
  </head>
16
16
 
17
- <body class="#{attributes['class']}">
17
+ <body class="#{attributes[:class]}">
18
18
  <div id="header">
19
19
  <img src="/_static/utopia.svg" />
20
20
  </div>
@@ -79,15 +79,15 @@ module Utopia::ContentSpec
79
79
  expect(output.string).to be == '<h1>Hello World</h1>'
80
80
  end
81
81
 
82
- it "should fetch xml and use cache" do
82
+ it "should fetch template and use cache" do
83
83
  node_path = File.expand_path('../pages/index.xnode', __FILE__)
84
84
 
85
- template = content.fetch_xml(node_path)
85
+ template = content.fetch_template(node_path)
86
86
 
87
87
  expect(template).to be_kind_of Trenni::Template
88
88
 
89
89
  # Check that the same object is returned:
90
- expect(template).to be content.fetch_xml(node_path)
90
+ expect(template).to be content.fetch_template(node_path)
91
91
  end
92
92
  end
93
93
  end
@@ -93,7 +93,7 @@ module Utopia::Controller::SequenceSpec
93
93
 
94
94
  result = controller.process!(request, Utopia::Path["/variable"])
95
95
  expect(result).to be == nil
96
- expect(variables.to_hash).to be == {"variable" => :value}
96
+ expect(variables.to_hash).to be == {:variable => :value}
97
97
  end
98
98
 
99
99
  it "should call direct controller methods" do
@@ -53,6 +53,6 @@ RSpec.describe Utopia::Controller::Variables do
53
53
  it "should convert to hash" do
54
54
  subject << a << b
55
55
 
56
- expect(subject.to_hash).to be == {'x' => 10, 'y' => 20}
56
+ expect(subject.to_hash).to be == {x: 10, y: 20}
57
57
  end
58
58
  end
@@ -1 +1 @@
1
- #{local_path}
1
+ #{current.node.local_path}
@@ -0,0 +1,90 @@
1
+ # Copyright, 2016, by Samuel G. D. Williams. <http://www.codeotaku.com>
2
+ #
3
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ # of this software and associated documentation files (the "Software"), to deal
5
+ # in the Software without restriction, including without limitation the rights
6
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ # copies of the Software, and to permit persons to whom the Software is
8
+ # furnished to do so, subject to the following conditions:
9
+ #
10
+ # The above copyright notice and this permission notice shall be included in
11
+ # all copies or substantial portions of the Software.
12
+ #
13
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19
+ # THE SOFTWARE.
20
+
21
+ require_relative 'rack_helper'
22
+
23
+ require 'benchmark/ips' if ENV['BENCHMARK']
24
+ require 'ruby-prof' if ENV['PROFILE']
25
+ require 'flamegraph' if ENV['FLAMEGRAPH']
26
+
27
+ RSpec.describe "Utopia Performance" do
28
+ include_context "rack app", "performance_spec/config.ru"
29
+
30
+ if defined? Benchmark
31
+ def benchmark(name = nil)
32
+ Benchmark.ips do |benchmark|
33
+ benchmark.report(name) do |i|
34
+ yield i
35
+ end
36
+
37
+ benchmark.compare!
38
+ end
39
+ end
40
+ elsif defined? RubyProf
41
+ def benchmark(name)
42
+ result = RubyProf.profile do
43
+ yield 1000
44
+ end
45
+
46
+ result.eliminate_methods!([/^((?!Utopia).)*$/])
47
+ printer = RubyProf::FlatPrinter.new(result)
48
+ printer.print($stderr, min_percent: 1.0)
49
+
50
+ printer = RubyProf::GraphHtmlPrinter.new(result)
51
+ filename = name.gsub('/', '_') + '.html'
52
+ File.open(filename, "w") do |file|
53
+ printer.print(file)
54
+ end
55
+ end
56
+ elsif defined? Flamegraph
57
+ def benchmark(name)
58
+ filename = name.gsub('/', '_') + '.html'
59
+ Flamegraph.generate(filename) do
60
+ yield 1
61
+ end
62
+ end
63
+ else
64
+ def benchmark(name)
65
+ yield 1
66
+ end
67
+ end
68
+
69
+ it "should be fast to access basic page" do
70
+ env = Rack::MockRequest.env_for("/welcome/index")
71
+ status, headers, response = app.call(env)
72
+
73
+ expect(status).to be == 200
74
+
75
+ benchmark("/welcome/index") do |i|
76
+ i.times { app.call(env) }
77
+ end
78
+ end
79
+
80
+ it "should be fast to invoke a controller" do
81
+ env = Rack::MockRequest.env_for("/api/fetch")
82
+ status, headers, response = app.call(env)
83
+
84
+ expect(status).to be == 200
85
+
86
+ benchmark("/api/fetch") do |i|
87
+ i.times { app.call(env) }
88
+ end
89
+ end
90
+ end