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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: c8736dc4a00f68a43630c02a5fd3f3aa21c46269
4
- data.tar.gz: 2aabc2032b24dfe14a2ef9f08bcf088d0bb8f129
3
+ metadata.gz: bf1c567fa58a115fc7f8d55fa10ca0e0d482baa9
4
+ data.tar.gz: a6c5f64bf66e7445fc3f11c48ff046d66fd701c6
5
5
  SHA512:
6
- metadata.gz: 688ceebc2a8cdcf7931d00359d3a31829d3fc0a05db7ffbf556ff99d195f4ffdc2f96b5c942be9a7f9ecc0d7b8a8702f9aa632c91d8b7fc636a446c01a134481
7
- data.tar.gz: 633293c6115d1d81558932f7f04bdbf33b8a2b880e0e9fdef5b95f7e5edc20ac44b1a84951192e4652f249bc7c65cee29f29d04c48a63c12efc213fc11434edc
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 attributes
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 = ->(obj, **opts) { Yajl::Encoder.encode(obj, opts) }
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
- json(person) #=> This will try to infer PersonSerializer from a Person instance
59
- json(person, with: AnotherSerializer)
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 json(user)
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 to methods that you can implement in your
85
- serializers, `last_modified` and `cache_key`, that will be used to prevent
86
- rendering JSON at all if possible, when using the `Granola::Rack#json` helper.
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
- If your serializer implements this method, and the `env` has the appropriate
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
- Plus, it sets appropriate `ETag` and `Last-Modified` so your clients can avoid
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
- self.json = ->(obj, **opts) { JSON.dump(obj) }
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 the Hash of attributes that should be serialized into
47
- # JSON.
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 attributes
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.(attributes, options)
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
- "application/json".freeze
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 - An Array-like structure.
89
- # serializer - A subclass of Granola::Serializer.
90
- def initialize(list, serializer)
91
- @item_serializer = serializer
92
- @list = list.map { |obj| serializer.new(obj) }
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 attributes
97
- @list.map(&:attributes)
113
+ def serialized
114
+ @list.map(&:serialized)
98
115
  end
99
116
  end
100
117
  end
@@ -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
- const_get("%sSerializer" % name)
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 NilClassSerializer < Granola::Serializer
44
- def attributes
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, and of controlling whether the object should
17
- # be rendered at all or not (issuing a 304 response in this case).
16
+ # `ETag` headers if appropriate.
18
17
  #
19
- # This expects the class mixing in this module to implement an `env` method,
20
- # that should be a Rack environment Hash.
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: A specific serializer class to use. If this is `nil`,
26
- # `Helper.serializer_class_for` will be used to infer the
27
- # serializer class.
28
- # status: The HTTP status to return on stale responses. Defaults to
29
- # `200`.
30
- # **json_options: Any other keywords passed will be forwarded to the
31
- # serializer's `#to_json` call.
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 json(object, with: nil, status: 200, **json_options)
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
- stale_check = StaleCheck.new(
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
- # Internal: Checks if a request is fresh by modified time, if applicable, by
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
- # Internal: Checks if a request is fresh by etag, if applicable, by
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
@@ -1,3 +1,3 @@
1
1
  module Granola
2
- VERSION = "0.0.4"
2
+ VERSION = "0.9.0"
3
3
  end
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
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-02-23 00:00:00.000000000 Z
11
+ date: 2015-03-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: cutest