granola 0.0.4 → 0.9.0
Sign up to get free protection for your applications and to get access to all the features.
- 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 [![Build Status](https://travis-ci.org/foca/granola.svg?branch=master)](https://travis-ci.org/foca/granola)
|
2
2
|
|
3
3
|
![A tasty bowl of Granola](https://cloud.githubusercontent.com/assets/437/4827156/9e8d33da-5f76-11e4-8574-7803e84845f2.JPG)
|
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
|