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 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