granola 0.0.4 → 0.9.0
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.
- checksums.yaml +4 -4
- data/README.md +79 -14
- data/lib/granola.rb +35 -18
- data/lib/granola/helper.rb +9 -4
- data/lib/granola/rack.rb +20 -93
- data/lib/granola/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: bf1c567fa58a115fc7f8d55fa10ca0e0d482baa9
|
4
|
+
data.tar.gz: a6c5f64bf66e7445fc3f11c48ff046d66fd701c6
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: a6918a2d025d20e08ea0f2756adafaa5d0dbf5134a2ad05059edfff3eba26e31d36ffbd7d0ae72a394243edad1d125317b09b40e4eb02aeba0fd31a2eb3e3e55
|
7
|
+
data.tar.gz: 2117365b5ee8f946ab307a6d2020d8beb0173d06adb26fd3e974272cfa95a8911f348ca5e78023f9c0b1755c6e21fceb332f45d76bb87a947050eae907e63284
|
data/README.md
CHANGED
@@ -1,4 +1,4 @@
|
|
1
|
-
# Granola, a JSON serializer
|
1
|
+
# Granola, a JSON serializer [](https://travis-ci.org/foca/granola)
|
2
2
|
|
3
3
|

|
4
4
|
|
@@ -11,7 +11,7 @@ gets out of your way. You just write plain ruby.
|
|
11
11
|
|
12
12
|
``` ruby
|
13
13
|
class PersonSerializer < Granola::Serializer
|
14
|
-
def
|
14
|
+
def serialized
|
15
15
|
{
|
16
16
|
"name" => object.name,
|
17
17
|
"email" => object.email,
|
@@ -34,10 +34,14 @@ specific JSON backend. It defaults to the native JSON backend, but you're free
|
|
34
34
|
to change it. For example, if you were using [Yajl][]:
|
35
35
|
|
36
36
|
``` ruby
|
37
|
-
Granola.json =
|
37
|
+
Granola.json = Yajl::Encoder.method(:encode)
|
38
38
|
```
|
39
39
|
|
40
|
+
If your project already uses [MultiJson][] then we will default to whatever it's
|
41
|
+
using, so you shouldn't worry.
|
42
|
+
|
40
43
|
[Yajl]: https://github.com/brianmario/yajl-ruby
|
44
|
+
[MultiJson]: https://github.com/intridea/multi_json
|
41
45
|
|
42
46
|
## Handling lists of models
|
43
47
|
|
@@ -55,8 +59,8 @@ If your application is based on Rack, you can simply `include Granola::Rack` and
|
|
55
59
|
you get access to the following interface:
|
56
60
|
|
57
61
|
``` ruby
|
58
|
-
|
59
|
-
|
62
|
+
granola(person) #=> This will infer PersonSerializer from a Person instance
|
63
|
+
granola(person, with: AnotherSerializer)
|
60
64
|
```
|
61
65
|
|
62
66
|
*NOTE* The method relies on being an `env` method that returns the Rack
|
@@ -72,7 +76,7 @@ Cuba.plugin Granola::Rack
|
|
72
76
|
Cuba.define do
|
73
77
|
on get, "users/:id" do |id|
|
74
78
|
user = User[id]
|
75
|
-
halt
|
79
|
+
halt granola(user)
|
76
80
|
end
|
77
81
|
end
|
78
82
|
```
|
@@ -81,16 +85,77 @@ end
|
|
81
85
|
|
82
86
|
## Caching
|
83
87
|
|
84
|
-
`Granola::Serializer` gives you
|
85
|
-
serializers
|
86
|
-
|
88
|
+
`Granola::Serializer` gives you two methods that you can implement in your
|
89
|
+
serializers: `last_modified` and `cache_key`.
|
90
|
+
|
91
|
+
When using the `Granola::Rack` module, you should return a `Time` object from
|
92
|
+
your serializer's `last_modified`. Granola will use this to generate the
|
93
|
+
appropriate `Last-Modified` HTTP header. Likewise, the result of `cache_key`
|
94
|
+
will be MD5d and set as the response's `ETag` header.
|
95
|
+
|
96
|
+
If you do this, you should also make sure that the [`Rack::ConditionalGet`][cg]
|
97
|
+
middleware is in your Rack stack, as it will use these headers to avoid
|
98
|
+
generating the JSON response altogether. For example, using Cuba:
|
99
|
+
|
100
|
+
``` ruby
|
101
|
+
class UserSerializer < Granola::Serializer
|
102
|
+
def serialized
|
103
|
+
{ "id" => object.id, "name" => object.name, "email" => object.email }
|
104
|
+
end
|
105
|
+
|
106
|
+
def last_modified
|
107
|
+
object.updated_at
|
108
|
+
end
|
109
|
+
|
110
|
+
def cache_key
|
111
|
+
"user:#{object.id}:#{object.updated_at.to_i}"
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
Cuba.plugin Granola::Rack
|
116
|
+
Cuba.use Rack::ConditionalGet
|
117
|
+
|
118
|
+
Cuba.define do
|
119
|
+
on get, "users/:id" do |id|
|
120
|
+
halt granola(User[id])
|
121
|
+
end
|
122
|
+
end
|
123
|
+
```
|
124
|
+
|
125
|
+
This will avoid generating the JSON response altogether if the user sends the
|
126
|
+
appropriate `If-Modified-Since` or `If-None-Match` headers.
|
127
|
+
|
128
|
+
[cg]: http://www.rubydoc.info/github/rack/rack/Rack/ConditionalGet
|
129
|
+
|
130
|
+
## Different Formats
|
131
|
+
|
132
|
+
Although Granola out of the box only ships with JSON serialization support, it's
|
133
|
+
easy to extend and add support for different types of serialization in case your
|
134
|
+
API needs to provide multiple formats. For example, in order to add MsgPack
|
135
|
+
support (via the [msgpack-ruby][] library), you'd do this:
|
136
|
+
|
137
|
+
``` ruby
|
138
|
+
require "msgpack"
|
139
|
+
|
140
|
+
class BaseSerializer < Granola::Serializer
|
141
|
+
MIME_TYPES[:msgpack] = "application/x-msgpack".freeze
|
142
|
+
|
143
|
+
def to_msgpack(*)
|
144
|
+
MsgPack.pack(serialized)
|
145
|
+
end
|
146
|
+
end
|
147
|
+
```
|
148
|
+
|
149
|
+
Now all serializers that inherit from `BaseSerializer` can be serialized into
|
150
|
+
MsgPack. In order to use this from our Rack helpers, you'd do:
|
151
|
+
|
152
|
+
``` ruby
|
153
|
+
granola(object, as: :msgpack)
|
154
|
+
```
|
87
155
|
|
88
|
-
|
89
|
-
`If-Modified-Since` or `If-None-Match` headers, the helper will automatically
|
90
|
-
return a 304 response.
|
156
|
+
This will set the correct MIME type.
|
91
157
|
|
92
|
-
|
93
|
-
hitting the endpoint altogether.
|
158
|
+
[msgpack-ruby]: https://github.com/msgpack/msgpack-ruby
|
94
159
|
|
95
160
|
## License
|
96
161
|
|
data/lib/granola.rb
CHANGED
@@ -16,13 +16,21 @@ module Granola
|
|
16
16
|
attr_accessor :json
|
17
17
|
end
|
18
18
|
|
19
|
-
|
19
|
+
if defined?(MultiJson)
|
20
|
+
self.json = MultiJson.method(:dump)
|
21
|
+
else
|
22
|
+
self.json = JSON.method(:generate)
|
23
|
+
end
|
20
24
|
|
21
25
|
# A Serializer describes how to serialize a certain type of object, by
|
22
26
|
# declaring the structure of JSON objects.
|
23
27
|
class Serializer
|
24
28
|
attr_reader :object
|
25
29
|
|
30
|
+
# Public: Map of the default MIME type for each given type of serialization
|
31
|
+
# for this object.
|
32
|
+
MIME_TYPES = { json: "application/json".freeze }
|
33
|
+
|
26
34
|
# Public: Instantiates a list serializer that wraps around an iterable of
|
27
35
|
# objects of the type expected by this serializer class.
|
28
36
|
#
|
@@ -32,8 +40,8 @@ module Granola
|
|
32
40
|
# serializer.to_json
|
33
41
|
#
|
34
42
|
# Returns a Granola::List.
|
35
|
-
def self.list(ary)
|
36
|
-
List.new(ary, self)
|
43
|
+
def self.list(ary, *args)
|
44
|
+
List.new(ary, *args, with: self)
|
37
45
|
end
|
38
46
|
|
39
47
|
# Public: Initialize the serializer with a given object.
|
@@ -43,11 +51,13 @@ module Granola
|
|
43
51
|
@object = object
|
44
52
|
end
|
45
53
|
|
46
|
-
# Public: Returns
|
47
|
-
#
|
54
|
+
# Public: Returns a primitive Object that can be serialized into JSON,
|
55
|
+
# meaning one of `nil`, `true`, `false`, a String, a Numeric, an Array of
|
56
|
+
# primitive objects, or a Hash with String keys and primitive objects as
|
57
|
+
# values.
|
48
58
|
#
|
49
59
|
# Raises NotImplementedError unless you override in subclasses.
|
50
|
-
def
|
60
|
+
def serialized
|
51
61
|
fail NotImplementedError
|
52
62
|
end
|
53
63
|
|
@@ -57,25 +67,28 @@ module Granola
|
|
57
67
|
#
|
58
68
|
# Returns a String.
|
59
69
|
def to_json(**options)
|
60
|
-
Granola.json.(
|
70
|
+
Granola.json.(serialized, options)
|
61
71
|
end
|
62
72
|
|
63
73
|
# Public: Returns the MIME type generated by this serializer. By default
|
64
74
|
# this will be `application/json`, but you can override in your serializers
|
65
75
|
# if your API uses a different MIME type (e.g. `application/my-app+json`).
|
66
76
|
#
|
77
|
+
# type - A Symbol describing the expected mime type.
|
78
|
+
#
|
67
79
|
# Returns a String.
|
68
|
-
def mime_type
|
69
|
-
|
80
|
+
def mime_type(type = :json)
|
81
|
+
MIME_TYPES.fetch(type)
|
70
82
|
end
|
71
83
|
end
|
72
84
|
|
73
85
|
# Internal: The List serializer provides an interface for serializing lists of
|
74
|
-
# objects, wrapping around a specific serializer.
|
86
|
+
# objects, wrapping around a specific serializer. The preferred API for this
|
87
|
+
# is to use `Granola::Serializer.list`.
|
75
88
|
#
|
76
89
|
# Example:
|
77
90
|
#
|
78
|
-
# serializer = Granola::List.new(people, PersonSerializer)
|
91
|
+
# serializer = Granola::List.new(people, with: PersonSerializer)
|
79
92
|
# serializer.to_json
|
80
93
|
#
|
81
94
|
# You should use Serializer.list instead of this class.
|
@@ -85,16 +98,20 @@ module Granola
|
|
85
98
|
|
86
99
|
# Public: Instantiate a new list serializer.
|
87
100
|
#
|
88
|
-
# list
|
89
|
-
#
|
90
|
-
|
91
|
-
|
92
|
-
|
101
|
+
# list - An Array-like structure.
|
102
|
+
# *args - Any other arguments that the item serializer takes.
|
103
|
+
#
|
104
|
+
# Keywords:
|
105
|
+
# with: The subclass of Granola::Serializer to use when serializing
|
106
|
+
# specific elements in the list.
|
107
|
+
def initialize(list, *args, with: serializer)
|
108
|
+
@item_serializer = with
|
109
|
+
@list = list.map { |obj| @item_serializer.new(obj, *args) }
|
93
110
|
end
|
94
111
|
|
95
112
|
# Public: Returns an Array of Hashes that can be serialized into JSON.
|
96
|
-
def
|
97
|
-
@list.map(&:
|
113
|
+
def serialized
|
114
|
+
@list.map(&:serialized)
|
98
115
|
end
|
99
116
|
end
|
100
117
|
end
|
data/lib/granola/helper.rb
CHANGED
@@ -35,14 +35,19 @@ module Granola::Helper
|
|
35
35
|
name = object.class.name
|
36
36
|
Object.const_get("%sSerializer" % name)
|
37
37
|
rescue NameError
|
38
|
-
|
38
|
+
case object
|
39
|
+
when NilClass, TrueClass, FalseClass, Numeric, String
|
40
|
+
PrimitiveTypesSerializer
|
41
|
+
else
|
42
|
+
raise
|
43
|
+
end
|
39
44
|
end
|
40
45
|
|
41
46
|
# Internal: Null serializer that transparently handles rendering `nil` in case
|
42
47
|
# it's passed.
|
43
|
-
class
|
44
|
-
def
|
45
|
-
|
48
|
+
class PrimitiveTypesSerializer < Granola::Serializer
|
49
|
+
def serialized
|
50
|
+
object
|
46
51
|
end
|
47
52
|
end
|
48
53
|
end
|
data/lib/granola/rack.rb
CHANGED
@@ -13,29 +13,33 @@ module Granola::Rack
|
|
13
13
|
|
14
14
|
# Public: Renders a JSON representation of an object using a
|
15
15
|
# Granola::Serializer. This takes care of setting the `Last-Modified` and
|
16
|
-
# `ETag` headers if appropriate
|
17
|
-
# be rendered at all or not (issuing a 304 response in this case).
|
16
|
+
# `ETag` headers if appropriate.
|
18
17
|
#
|
19
|
-
#
|
20
|
-
#
|
18
|
+
# You can customize the response tuple by passing the status and the default
|
19
|
+
# headers, as in the following example:
|
20
|
+
#
|
21
|
+
# granola(user, status: 400, headers: { "X-Error" => "Boom!" })
|
21
22
|
#
|
22
23
|
# object - An object to serialize into JSON.
|
23
24
|
#
|
24
25
|
# Keywords:
|
25
|
-
# with:
|
26
|
-
#
|
27
|
-
#
|
28
|
-
#
|
29
|
-
#
|
30
|
-
#
|
31
|
-
#
|
26
|
+
# with: A specific serializer class to use. If this is `nil`,
|
27
|
+
# `Helper.serializer_class_for` will be used to infer the
|
28
|
+
# serializer class.
|
29
|
+
# as: A Symbol with the type of serialization desired. Defaults to
|
30
|
+
# `:json` (and it's the only one available with Granola by default)
|
31
|
+
# but could be expanded with plugins to provide serialization to,
|
32
|
+
# for example, MsgPack.
|
33
|
+
# status: The HTTP status to return on stale responses. Defaults to `200`.
|
34
|
+
# headers: A Hash of default HTTP headers. Defaults to an empty Hash.
|
35
|
+
# **opts: Any other keywords passed will be forwarded to the serializer's
|
36
|
+
# serialization backend call.
|
32
37
|
#
|
33
38
|
# Raises NameError if no specific serializer is provided and we fail to infer
|
34
39
|
# one for this object.
|
35
40
|
# Returns a Rack response tuple.
|
36
|
-
def
|
41
|
+
def granola(object, with: nil, status: 200, headers: {}, as: :json, **opts)
|
37
42
|
serializer = serializer_for(object, with: with)
|
38
|
-
headers = {}
|
39
43
|
|
40
44
|
if serializer.last_modified
|
41
45
|
headers["Last-Modified".freeze] = serializer.last_modified.httpdate
|
@@ -45,87 +49,10 @@ module Granola::Rack
|
|
45
49
|
headers["ETag".freeze] = Digest::MD5.hexdigest(serializer.cache_key)
|
46
50
|
end
|
47
51
|
|
48
|
-
|
49
|
-
env,
|
50
|
-
last_modified: headers["Last-Modified".freeze],
|
51
|
-
etag: headers["ETag".freeze]
|
52
|
-
)
|
53
|
-
|
54
|
-
if stale_check.fresh?
|
55
|
-
[304, headers, []]
|
56
|
-
else
|
57
|
-
json_string = serializer.to_json(json_options)
|
58
|
-
headers["Content-Type".freeze] = serializer.mime_type
|
59
|
-
headers["Content-Length".freeze] = json_string.length.to_s
|
60
|
-
[status, headers, [json_string]]
|
61
|
-
end
|
62
|
-
end
|
63
|
-
|
64
|
-
# Internal: Check whether a request is fresh or stale by both modified time
|
65
|
-
# and/or etag.
|
66
|
-
class StaleCheck
|
67
|
-
# Internal: Get the env Hash of the request.
|
68
|
-
attr_reader :env
|
69
|
-
|
70
|
-
# Internal: Get the Time at which the domain model was last-modified.
|
71
|
-
attr_reader :last_modified
|
72
|
-
|
73
|
-
# Internal: Get the String with the ETag ggenerated by this domain model.
|
74
|
-
attr_reader :etag
|
75
|
-
|
76
|
-
IF_MODIFIED_SINCE = "HTTP_IF_MODIFIED_SINCE".freeze
|
77
|
-
IF_NONE_MATCH = "HTTP_IF_NONE_MATCH".freeze
|
78
|
-
|
79
|
-
# Public: Initialize the check.
|
80
|
-
#
|
81
|
-
# env - Rack's env Hash.
|
82
|
-
#
|
83
|
-
# Keywords:
|
84
|
-
# last_modified: The Time at which the domain model was last modified, if
|
85
|
-
# applicable (Defaults to `nil`).
|
86
|
-
# etag: The HTTP ETag for this domain model, if applicable
|
87
|
-
# (Defaults to `nil`).
|
88
|
-
def initialize(env, last_modified: nil, etag: nil)
|
89
|
-
@env = env
|
90
|
-
@last_modified = last_modified
|
91
|
-
@etag = etag
|
92
|
-
end
|
93
|
-
|
94
|
-
# Public: Checks whether the request is fresh. A fresh request is one that
|
95
|
-
# is stored in the client's cache and doesn't need updating (so it can be
|
96
|
-
# responded to with a 304 response).
|
97
|
-
#
|
98
|
-
# Returns Boolean.
|
99
|
-
def fresh?
|
100
|
-
fresh_by_time? || fresh_by_etag?
|
101
|
-
end
|
102
|
-
|
103
|
-
# Public: Returns a Boolean denoting whether the request is stale (i.e. not
|
104
|
-
# fresh).
|
105
|
-
def stale?
|
106
|
-
!fresh?
|
107
|
-
end
|
52
|
+
headers["Content-Type".freeze] = serializer.mime_type(as)
|
108
53
|
|
109
|
-
|
110
|
-
# comparing the `If-Modified-Since` header with the last modified time of
|
111
|
-
# the domain model.
|
112
|
-
#
|
113
|
-
# Returns Boolean.
|
114
|
-
def fresh_by_time?
|
115
|
-
return false unless env.key?(IF_MODIFIED_SINCE) && !last_modified.nil?
|
116
|
-
Time.parse(last_modified) <= Time.parse(env.fetch(IF_MODIFIED_SINCE))
|
117
|
-
end
|
54
|
+
body = Enumerator.new { |y| y << serializer.public_send(:"to_#{as}", opts) }
|
118
55
|
|
119
|
-
|
120
|
-
# comparing the `If-None-Match` header with the ETag for the domain model.
|
121
|
-
#
|
122
|
-
# Returns Boolean.
|
123
|
-
def fresh_by_etag?
|
124
|
-
return false unless env.key?(IF_NONE_MATCH) && !etag.nil?
|
125
|
-
if_none_match = env.fetch(IF_NONE_MATCH, "").split(/\s*,\s*/)
|
126
|
-
return false if if_none_match.empty?
|
127
|
-
return true if if_none_match.include?("*".freeze)
|
128
|
-
if_none_match.any? { |tag| tag == etag }
|
129
|
-
end
|
56
|
+
[status, headers, body]
|
130
57
|
end
|
131
58
|
end
|
data/lib/granola/version.rb
CHANGED
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: granola
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.0
|
4
|
+
version: 0.9.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Nicolas Sanguinetti
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2015-
|
11
|
+
date: 2015-03-15 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: cutest
|