rack-cache 0.3.0 → 0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of rack-cache might be problematic. Click here for more details.
- data/CHANGES +43 -0
- data/README +18 -9
- data/Rakefile +1 -14
- data/TODO +13 -14
- data/doc/configuration.markdown +7 -153
- data/doc/faq.markdown +8 -0
- data/doc/index.markdown +7 -9
- data/example/sinatra/app.rb +25 -0
- data/example/sinatra/views/index.erb +44 -0
- data/lib/rack/cache.rb +5 -11
- data/lib/rack/cache/cachecontrol.rb +193 -0
- data/lib/rack/cache/context.rb +190 -52
- data/lib/rack/cache/entitystore.rb +10 -4
- data/lib/rack/cache/key.rb +52 -0
- data/lib/rack/cache/metastore.rb +52 -16
- data/lib/rack/cache/options.rb +60 -39
- data/lib/rack/cache/request.rb +11 -15
- data/lib/rack/cache/response.rb +221 -30
- data/lib/rack/cache/storage.rb +1 -2
- data/rack-cache.gemspec +9 -15
- data/test/cache_test.rb +9 -6
- data/test/cachecontrol_test.rb +139 -0
- data/test/context_test.rb +251 -169
- data/test/entitystore_test.rb +12 -11
- data/test/key_test.rb +50 -0
- data/test/metastore_test.rb +57 -14
- data/test/options_test.rb +11 -0
- data/test/request_test.rb +19 -0
- data/test/response_test.rb +164 -23
- data/test/spec_setup.rb +7 -0
- metadata +12 -20
- data/doc/events.dot +0 -27
- data/lib/rack/cache/config.rb +0 -65
- data/lib/rack/cache/config/busters.rb +0 -16
- data/lib/rack/cache/config/default.rb +0 -133
- data/lib/rack/cache/config/no-cache.rb +0 -13
- data/lib/rack/cache/core.rb +0 -299
- data/lib/rack/cache/headers.rb +0 -325
- data/lib/rack/utils/environment_headers.rb +0 -78
- data/test/config_test.rb +0 -66
- data/test/core_test.rb +0 -84
- data/test/environment_headers_test.rb +0 -69
- data/test/headers_test.rb +0 -298
- data/test/logging_test.rb +0 -45
data/CHANGES
CHANGED
@@ -1,3 +1,46 @@
|
|
1
|
+
## 0.4.0 / March 2009
|
2
|
+
|
3
|
+
* Ruby 1.9.1 / Rack 1.0 compatible.
|
4
|
+
|
5
|
+
* Invalidate cache entries that match the request URL on non-GET/HEAD
|
6
|
+
requests. i.e., POST, PUT, DELETE cause matching cache entries to
|
7
|
+
be invalidated. The cache entry is validated with the backend using
|
8
|
+
a conditional GET the next time it's requested.
|
9
|
+
|
10
|
+
* Implement "Cache-Control: max-age=N" request directive by forcing
|
11
|
+
validation when the max-age provided exceeds the age of the cache
|
12
|
+
entry. This can be disabled by setting the "allow_revalidate" option to
|
13
|
+
false.
|
14
|
+
|
15
|
+
* Properly implement "Cache-Control: no-cache" request directive by
|
16
|
+
performing a full reload. RFC 2616 states that when "no-cache" is
|
17
|
+
present in the request, the cache MUST NOT serve a stored response even
|
18
|
+
after successful validation. This is slightly different from the
|
19
|
+
"no-cache" directive in responses, which indicates that the cache must
|
20
|
+
first validate its entry with the origin. Previously, we implemented
|
21
|
+
"no-cache" on requests by passing so no new cache entry would be stored
|
22
|
+
based on the response. Now we treat it as a forced miss and enter the
|
23
|
+
response into the cache if it's cacheable. This can be disabled by
|
24
|
+
setting the "allow_reload" option to false.
|
25
|
+
|
26
|
+
* Assume identical semantics for the "Pragma: no-cache" request header
|
27
|
+
as the "Cache-Control: no-cache" directive described above.
|
28
|
+
|
29
|
+
* Less crazy logging. When the verbose option is set, a single log entry
|
30
|
+
is written with a comma separated list of trace events. For example, if
|
31
|
+
the cache was stale but validated, the following log entry would be
|
32
|
+
written: "cache: stale, valid, store". When the verbose option is false,
|
33
|
+
no logging occurs.
|
34
|
+
|
35
|
+
* Added "X-Rack-Cache" response header with the same comma separated trace
|
36
|
+
value as described above. This gives some visibility into how the cache
|
37
|
+
processed the request.
|
38
|
+
|
39
|
+
* Add support for canonicalized cache keys, as well as custom cache key
|
40
|
+
generators, which are specified in the options as :cache_key as either
|
41
|
+
any object that has a call() or as a block. Cache key generators get
|
42
|
+
passed a request object and return a cache key string.
|
43
|
+
|
1
44
|
## 0.3.0 / December 2008
|
2
45
|
|
3
46
|
* Add support for public and private cache control directives. Responses
|
data/README
CHANGED
@@ -12,7 +12,6 @@ validation (Last-Modified, ETag) information:
|
|
12
12
|
* Cache-Control: public, private, max-age, s-maxage, must-revalidate,
|
13
13
|
and proxy-revalidate.
|
14
14
|
* Portable: 100% Ruby / works with any Rack-enabled framework
|
15
|
-
* Configuration language for advanced caching policies
|
16
15
|
* Disk, memcached, and heap memory storage backends
|
17
16
|
|
18
17
|
For more information about Rack::Cache features and usage, see:
|
@@ -25,14 +24,6 @@ caching solution for small to medium sized deployments. More sophisticated /
|
|
25
24
|
high-performance caching systems (e.g., Varnish, Squid, httpd/mod-cache) may be
|
26
25
|
more appropriate for large deployments with significant throughput requirements.
|
27
26
|
|
28
|
-
Status
|
29
|
-
------
|
30
|
-
|
31
|
-
Rack::Cache is a young and experimental project that is likely to change
|
32
|
-
substantially and may not be wholly functional, consistent, fast, or correct.
|
33
|
-
The current focus is on reaching basic compliance with RFC 2616 and providing
|
34
|
-
good documentation.
|
35
|
-
|
36
27
|
Installation
|
37
28
|
------------
|
38
29
|
|
@@ -66,6 +57,24 @@ Assuming you've designed your backend application to take advantage of HTTP's
|
|
66
57
|
caching features, no further code or configuration is required for basic
|
67
58
|
caching.
|
68
59
|
|
60
|
+
Using with Rails
|
61
|
+
----------------
|
62
|
+
|
63
|
+
Add this to your `config/environment.rb`:
|
64
|
+
|
65
|
+
config.middleware.use Rack::Cache,
|
66
|
+
:verbose => true,
|
67
|
+
:metastore => 'file:/var/cache/rack/meta',
|
68
|
+
:entitystore => 'file:/var/cache/rack/body'
|
69
|
+
|
70
|
+
You should now see `Rack::Cache` listed in the middleware pipeline:
|
71
|
+
|
72
|
+
rake middleware
|
73
|
+
|
74
|
+
See the following for more information:
|
75
|
+
|
76
|
+
http://snippets.aktagon.com/snippets/302
|
77
|
+
|
69
78
|
Links
|
70
79
|
-----
|
71
80
|
|
data/Rakefile
CHANGED
@@ -31,7 +31,7 @@ end
|
|
31
31
|
|
32
32
|
# DOC =======================================================================
|
33
33
|
desc 'Build all documentation'
|
34
|
-
task :doc => %w[doc:api doc:
|
34
|
+
task :doc => %w[doc:api doc:markdown]
|
35
35
|
|
36
36
|
# requires the hanna gem:
|
37
37
|
# gem install mislav-hanna --source=http://gems.github.com
|
@@ -55,19 +55,6 @@ file 'doc/api/index.html' => FileList['lib/**/*.rb'] do |f|
|
|
55
55
|
end
|
56
56
|
CLEAN.include 'doc/api'
|
57
57
|
|
58
|
-
desc 'Build graphviz graphs'
|
59
|
-
task 'doc:graphs'
|
60
|
-
%w[pdf png svg].each do |filetype|
|
61
|
-
FileList["doc/*.dot"].each do |source|
|
62
|
-
dest = source.sub(/dot$/, filetype)
|
63
|
-
file dest => source do |f|
|
64
|
-
sh "dot -T#{filetype} #{source} -o #{f.name}"
|
65
|
-
end
|
66
|
-
task 'doc:graphs' => dest
|
67
|
-
CLEAN.include dest
|
68
|
-
end
|
69
|
-
end
|
70
|
-
|
71
58
|
desc 'Build markdown documentation files'
|
72
59
|
task 'doc:markdown'
|
73
60
|
FileList['doc/*.markdown'].each do |source|
|
data/TODO
CHANGED
@@ -1,21 +1,20 @@
|
|
1
|
-
## 0.4
|
2
|
-
|
3
|
-
- liberal, conservative, sane caching configs
|
4
|
-
- Sample apps: Rack, Rails, Sinatra, Merb, etc.
|
5
|
-
- busters.rb and no-cache.rb doc and tests
|
6
|
-
- Canonicalized URL for cache key:
|
7
|
-
- sorts params by key, then value
|
8
|
-
- urlencodes /[^ A-Za-z0-9_.-]/ host, path, and param key/value
|
9
|
-
- Custom cache keys
|
10
|
-
- Cache invalidation on PUT, POST, DELETE.
|
11
|
-
- Invalidate at the request URI; or, anything that's "near" the request URI.
|
12
|
-
- Invalidate at the URI of the Location or Content-Location response header.
|
13
|
-
|
14
1
|
## Backlog
|
15
2
|
|
3
|
+
- Move breakers.rb configuration file into rack-contrib as a middleware
|
4
|
+
component.
|
5
|
+
- Sample apps: Rack, Rails, Sinatra, Merb, etc.
|
6
|
+
- Use Bacon instead of test/spec
|
7
|
+
- Work with both memcache and memcached gems (memcached hasn't built on MacOS
|
8
|
+
for some time now).
|
9
|
+
- Fast path pass processing. We do a lot more than necessary just to determine
|
10
|
+
that the response should be passed through untouched.
|
11
|
+
- Don't purge/remove cache entries when invalidating. The entries should be
|
12
|
+
marked as stale and be forced revalidated on the next request instead of
|
13
|
+
being removed entirely.
|
16
14
|
- Add missing Expires header if we have a max-age.
|
17
|
-
- Purge/invalidate specific cache entries
|
18
15
|
- Purge/invalidate everything
|
16
|
+
- Invalidate at the URI of the Location or Content-Location response header
|
17
|
+
on POST, PUT, or DELETE that results in a redirect.
|
19
18
|
- Maximum size of cached entity
|
20
19
|
- Last-Modified factor: requests that have a Last-Modified header but no Expires
|
21
20
|
header have a TTL assigned based on the last modified age of the response:
|
data/doc/configuration.markdown
CHANGED
@@ -1,51 +1,17 @@
|
|
1
|
-
Configuration
|
2
|
-
|
1
|
+
Configuration
|
2
|
+
=============
|
3
3
|
|
4
4
|
__Rack::Cache__ includes a configuration system that can be used to specify
|
5
5
|
fairly sophisticated cache policy on a global or per-request basis.
|
6
6
|
|
7
|
-
- [Synopsis](#synopsis)
|
8
|
-
- [Setting Cache Options](#setopt)
|
9
|
-
- [Cache Option Reference](#options)
|
10
|
-
- [Configuration Machinery - Events and Transitions](#machinery)
|
11
|
-
- [Importing Configuration](#import)
|
12
|
-
- [Default Configuration Machinery](#default)
|
13
|
-
- [Notes](#notes)
|
14
|
-
|
15
|
-
<a id='synopsis'></a>
|
16
|
-
|
17
|
-
Synopsis
|
18
|
-
--------
|
19
|
-
|
20
|
-
use Rack::Cache do
|
21
|
-
# set cache related options
|
22
|
-
set :verbose, true
|
23
|
-
set :metastore, 'memcached://localhost:11211'
|
24
|
-
set :entitystore, 'file:/var/cache/rack/body'
|
25
|
-
|
26
|
-
# override events / transitions
|
27
|
-
on :receive do
|
28
|
-
pass! if request.url =~ %r|/dontcache/|
|
29
|
-
error! 402 if request.referrer =~ /digg.com/
|
30
|
-
end
|
31
|
-
|
32
|
-
on :miss do
|
33
|
-
trace 'missed: %s', request.url
|
34
|
-
end
|
35
|
-
|
36
|
-
# bring in other configuration machinery
|
37
|
-
import 'rack/cache/config/breakers'
|
38
|
-
import 'mycacheconfig'
|
39
|
-
end
|
40
|
-
|
41
7
|
<a id='setopt'></a>
|
42
8
|
|
43
9
|
Setting Cache Options
|
44
10
|
---------------------
|
45
11
|
|
46
|
-
Cache options can be set when the __Rack::Cache__ object is created
|
47
|
-
|
48
|
-
|
12
|
+
Cache options can be set when the __Rack::Cache__ object is created,
|
13
|
+
or by setting a `rack-cache.<option>` variable in __Rack__'s
|
14
|
+
__Environment__.
|
49
15
|
|
50
16
|
When the __Rack::Cache__ object is instantiated:
|
51
17
|
|
@@ -54,14 +20,6 @@ When the __Rack::Cache__ object is instantiated:
|
|
54
20
|
:metastore => 'memcached://localhost:11211/',
|
55
21
|
:entitystore => 'file:/var/cache/rack'
|
56
22
|
|
57
|
-
Using the `set` method within __Rack::Cache__'s configuration context:
|
58
|
-
|
59
|
-
use Rack::Cache do
|
60
|
-
set :verbose, true
|
61
|
-
set :metastore, 'memcached://localhost:11211/'
|
62
|
-
set :entitystore, 'file:/var/cache/rack'
|
63
|
-
end
|
64
|
-
|
65
23
|
Using __Rack__'s __Environment__:
|
66
24
|
|
67
25
|
env.merge!(
|
@@ -123,110 +81,6 @@ If any of these headers are present in the request, the response is considered
|
|
123
81
|
private and will not be cached _unless_ the response is explicitly marked public
|
124
82
|
(e.g., `Cache-Control: public`).
|
125
83
|
|
126
|
-
|
127
|
-
|
128
|
-
Configuration Machinery - Events and Transitions
|
129
|
-
------------------------------------------------
|
130
|
-
|
131
|
-
The configuration machinery is built around a series of interceptable events and
|
132
|
-
transitions controlled by a simple configuration language. The following diagram
|
133
|
-
shows each state (interceptable event) along with their possible transitions:
|
134
|
-
|
135
|
-
<p class='center'>
|
136
|
-
<img src='events.png' alt='Events and Transitions Diagram' />
|
137
|
-
</p>
|
138
|
-
|
139
|
-
Custom logic can be layered onto the `receive`, `hit`, `miss`, `fetch`, `store`,
|
140
|
-
`deliver`, and `pass` events by passing a block to the `on` method:
|
141
|
-
|
142
|
-
on :fetch do
|
143
|
-
trace 'fetched %p from backend application', request.url
|
144
|
-
end
|
145
|
-
|
146
|
-
Here, the `trace` method writes a message to the `rack.errors` stream when a
|
147
|
-
response is fetched from the backend application. The `request` object is a
|
148
|
-
[__Rack::Cache::Request__](./api/classes/Rack/Cache/Request) that can be
|
149
|
-
inspected (and modified) to determine what action should be taken next.
|
150
|
-
|
151
|
-
Event blocks are capable of performing more interesting operations:
|
152
|
-
|
153
|
-
* Transition to a different event or override default caching logic.
|
154
|
-
* Modify the request, response, cache entry, or Rack environment options.
|
155
|
-
* Set the `metastore` or `entitystore` options to select a different storage
|
156
|
-
mechanism / location dynamically.
|
157
|
-
* Collect statistics or log request/response/cache information.
|
158
|
-
|
159
|
-
When an event is triggered, the blocks associated with the event are executed in
|
160
|
-
reverse/FILO order (i.e., the block declared last runs first) until a
|
161
|
-
_transitioning statement_ is encountered. Transitioning statements are suffixed
|
162
|
-
with a bang character (e.g, `pass!`, `store!`, `error!`) and cause the current
|
163
|
-
event to halt and the machine to transition to the subsequent event; control is
|
164
|
-
not returned to the original event. The [default configuration](#default)
|
165
|
-
includes documentation on available transitions for each event.
|
166
|
-
|
167
|
-
The `next` statement can be used to exit an event block without transitioning
|
168
|
-
to another event. Subsequent event blocks are executed until a transitioning
|
169
|
-
statement is encountered:
|
170
|
-
|
171
|
-
on :fetch do
|
172
|
-
next if response.freshness_information?
|
173
|
-
|
174
|
-
if request.url =~ /\/feed$/
|
175
|
-
trace 'feed will expire in fifteen minutes'
|
176
|
-
response.ttl = 15 * 60
|
177
|
-
end
|
178
|
-
end
|
179
|
-
|
180
|
-
<a id='import'></a>
|
181
|
-
|
182
|
-
Importing Configuration
|
183
|
-
-----------------------
|
184
|
-
|
185
|
-
Since caching logic can be layered, it's possible to separate various bits of
|
186
|
-
cache policy into files for organization and reuse.
|
187
|
-
|
188
|
-
use Rack::Cache do
|
189
|
-
import 'rack/cache/config/busters'
|
190
|
-
import 'mycacheconfig'
|
191
|
-
|
192
|
-
# more stuff here
|
193
|
-
end
|
194
|
-
|
195
|
-
The `busters` and `mycacheconfig` configuration files are normal Ruby source
|
196
|
-
files (i.e., they have a `.rb` extension) situated on the `$LOAD_PATH` - the
|
197
|
-
`import` statement works like Ruby's `require` statement but the contents of the
|
198
|
-
files are evaluated in the context of the configuration machinery, as if
|
199
|
-
specified directly in the configuration block.
|
200
|
-
|
201
|
-
The `rack/cache/config/busters.rb` file makes a good example. It hooks into the
|
202
|
-
`fetch` event and adds an impractically long expiration lifetime to any response
|
203
|
-
that includes a cache busting query string:
|
204
|
-
|
205
|
-
<%= File.read('lib/rack/cache/config/busters.rb').gsub(/^/, ' ') %>
|
206
|
-
|
207
|
-
|
208
|
-
<a id='default'></a>
|
209
|
-
|
210
|
-
Default Configuration Machinery
|
211
|
-
-------------------------------
|
212
|
-
|
213
|
-
The `rack/cache/config/default.rb` file is imported when the __Rack::Cache__
|
214
|
-
object is instantiated and before any custom configuration code is executed.
|
215
|
-
It's useful to understand this configuration because it drives the default
|
216
|
-
transitioning logic.
|
217
|
-
|
218
|
-
<%= File.read('lib/rack/cache/config/default.rb').gsub(/^/, ' ') %>
|
219
|
-
|
220
|
-
<a id='notes'></a>
|
221
|
-
|
222
|
-
Notes
|
223
|
-
-----
|
224
|
-
|
225
|
-
The configuration language was inspired by [Varnish][var]'s
|
226
|
-
[VCL configuration language][vcl].
|
227
|
-
|
228
|
-
[var]: http://varnish.projects.linpro.no/
|
229
|
-
"Varnish HTTP accelerator"
|
84
|
+
### `cache_key`
|
230
85
|
|
231
|
-
|
232
|
-
"VCL(7) -- Varnish Configuration Language Manual Page"
|
86
|
+
TODO: Document custom cache keys
|
data/doc/faq.markdown
CHANGED
@@ -10,6 +10,14 @@ General
|
|
10
10
|
-------
|
11
11
|
|
12
12
|
|
13
|
+
<a class='hash' id='rails' href='#rails'>#</a>
|
14
|
+
|
15
|
+
### Q: Can I use Rack::Cache with Rails?
|
16
|
+
|
17
|
+
Rack::Cache can be used with Rails 2.3 or above. Documentation and a
|
18
|
+
sample application is forthcoming; in the mean time, see
|
19
|
+
[this example of using Rack::Cache with Rails 2.3](http://snippets.aktagon.com/snippets/302-How-to-setup-and-use-Rack-Cache-with-Rails-2-3-0-RC-1).
|
20
|
+
|
13
21
|
<a class='hash' id='why-not-squid' href='#why-not-squid'>#</a>
|
14
22
|
|
15
23
|
### Q: Why Rack::Cache? Why not Squid, Varnish, Perlbol, etc.?
|
data/doc/index.markdown
CHANGED
@@ -7,16 +7,15 @@ for [Rack][]-based applications that produce freshness (`Expires`,
|
|
7
7
|
* Validation
|
8
8
|
* Vary support
|
9
9
|
* Portable: 100% Ruby / works with any [Rack][]-enabled framework.
|
10
|
-
* [Configuration language][config] for advanced caching policies.
|
11
10
|
* Disk, memcached, and heap memory [storage backends][storage].
|
12
11
|
|
13
|
-
|
14
|
-
|
12
|
+
News
|
13
|
+
----
|
15
14
|
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
15
|
+
* [How to use Rack::Cache with Rails 2.3](http://snippets.aktagon.com/snippets/302-How-to-setup-and-use-Rack-Cache-with-Rails-2-3-0-RC-1) - it's really easy.
|
16
|
+
* [RailsLab's Advanced HTTP Caching Screencast](http://railslab.newrelic.com/2009/02/26/episode-11-advanced-http-caching)
|
17
|
+
is a really great review of HTTP caching concepts and shows how to
|
18
|
+
use Rack::Cache with Rails.
|
20
19
|
|
21
20
|
Installation
|
22
21
|
------------
|
@@ -52,8 +51,7 @@ caching.
|
|
52
51
|
More
|
53
52
|
----
|
54
53
|
|
55
|
-
* [Configuration
|
56
|
-
policy using the simple event-based configuration system.
|
54
|
+
* [Configuration Options][config] - how to set cache options.
|
57
55
|
|
58
56
|
* [Cache Storage Documentation][storage] - detailed information on the various
|
59
57
|
storage implementations available in __Rack::Cache__ and how to choose the one
|
@@ -0,0 +1,25 @@
|
|
1
|
+
require 'sinatra'
|
2
|
+
require 'rack/cache'
|
3
|
+
|
4
|
+
use Rack::Cache do
|
5
|
+
set :verbose, true
|
6
|
+
set :metastore, 'heap:/'
|
7
|
+
set :entitystore, 'heap:/'
|
8
|
+
|
9
|
+
on :receive do
|
10
|
+
pass! if request.url =~ /favicon/
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
before do
|
15
|
+
last_modified $updated_at ||= Time.now
|
16
|
+
end
|
17
|
+
|
18
|
+
get '/' do
|
19
|
+
erb :index
|
20
|
+
end
|
21
|
+
|
22
|
+
put '/' do
|
23
|
+
$updated_at = nil
|
24
|
+
redirect '/'
|
25
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
<html>
|
2
|
+
<head>
|
3
|
+
<title>Sample Rack::Cache Sinatra app</title>
|
4
|
+
<style type="text/css" media="screen">
|
5
|
+
body {
|
6
|
+
font-family: Georgia;
|
7
|
+
font-size: 24px;
|
8
|
+
text-align: center;
|
9
|
+
}
|
10
|
+
|
11
|
+
#headers {
|
12
|
+
font-size: 16px;
|
13
|
+
}
|
14
|
+
|
15
|
+
input {
|
16
|
+
font-size: 24px;
|
17
|
+
cursor: pointer;
|
18
|
+
}
|
19
|
+
</style>
|
20
|
+
</head>
|
21
|
+
<body>
|
22
|
+
<h1>Last updated at: <%= $updated_at.strftime('%l:%m:%S%P') %></h1>
|
23
|
+
|
24
|
+
<p>
|
25
|
+
<form action="/" method="post">
|
26
|
+
<input type="hidden" name="_method" value="PUT">
|
27
|
+
<input type="submit" value="Expire the cache.">
|
28
|
+
</form>
|
29
|
+
</p>
|
30
|
+
|
31
|
+
<div id="headers">
|
32
|
+
<h3>Headers:</h3>
|
33
|
+
|
34
|
+
<% response.headers.each do |key, value| %>
|
35
|
+
<p><%= key %>: <%= value %></p>
|
36
|
+
<% end %>
|
37
|
+
|
38
|
+
<h3>Params:</h3>
|
39
|
+
<% params.each do |key, value| %>
|
40
|
+
<p><%= key %>: <%= value || '(blank)' %></p>
|
41
|
+
<% end %>
|
42
|
+
</div>
|
43
|
+
</body>
|
44
|
+
</html>
|