roar-jsonapi 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +5 -0
- data/.rubocop.yml +37 -0
- data/.travis.yml +14 -0
- data/.yardopts +5 -0
- data/CONTRIBUTING.md +31 -0
- data/Gemfile +11 -0
- data/ISSUE_TEMPLATE.md +20 -0
- data/LICENSE +20 -0
- data/README.markdown +127 -0
- data/Rakefile +12 -0
- data/lib/roar/json/json_api.rb +156 -0
- data/lib/roar/json/json_api/declarative.rb +196 -0
- data/lib/roar/json/json_api/defaults.rb +25 -0
- data/lib/roar/json/json_api/document.rb +104 -0
- data/lib/roar/json/json_api/for_collection.rb +35 -0
- data/lib/roar/json/json_api/member_name.rb +57 -0
- data/lib/roar/json/json_api/meta.rb +56 -0
- data/lib/roar/json/json_api/options.rb +98 -0
- data/lib/roar/json/json_api/version.rb +7 -0
- data/roar-jsonapi.gemspec +25 -0
- data/test/jsonapi/collection_render_test.rb +399 -0
- data/test/jsonapi/fieldsets_options_test.rb +161 -0
- data/test/jsonapi/fieldsets_test.rb +293 -0
- data/test/jsonapi/member_name_test.rb +91 -0
- data/test/jsonapi/post_test.rb +78 -0
- data/test/jsonapi/render_test.rb +281 -0
- data/test/jsonapi/representer.rb +112 -0
- data/test/jsonapi/resource_linkage_test.rb +88 -0
- data/test/test_helper.rb +42 -0
- metadata +132 -0
@@ -0,0 +1,196 @@
|
|
1
|
+
module Roar
|
2
|
+
module JSON
|
3
|
+
module JSONAPI
|
4
|
+
# Declarative API for JSON API Representers.
|
5
|
+
#
|
6
|
+
# @since 0.1.0
|
7
|
+
module Declarative
|
8
|
+
# Defjne a type for this resource.
|
9
|
+
#
|
10
|
+
# @example
|
11
|
+
# type :articles
|
12
|
+
#
|
13
|
+
# @param [Symbol, String] name type name of this resource
|
14
|
+
# @return [String] type name of this resource
|
15
|
+
#
|
16
|
+
# @see http://jsonapi.org/format/#document-resource-object-identification
|
17
|
+
# @api public
|
18
|
+
def type(name = nil)
|
19
|
+
return @type unless name # original name.
|
20
|
+
|
21
|
+
heritage.record(:type, name)
|
22
|
+
@type = name.to_s
|
23
|
+
end
|
24
|
+
|
25
|
+
# Define attributes for this resource.
|
26
|
+
#
|
27
|
+
# @example
|
28
|
+
# attributes do
|
29
|
+
# property :name
|
30
|
+
# end
|
31
|
+
#
|
32
|
+
# @param [#call] block
|
33
|
+
#
|
34
|
+
# @see http://jsonapi.org/format/#document-resource-object-attributes
|
35
|
+
# @api public
|
36
|
+
def attributes(&block)
|
37
|
+
nested(:attributes, inherit: true, &block)
|
38
|
+
end
|
39
|
+
|
40
|
+
# Define a link.
|
41
|
+
#
|
42
|
+
# @example Link for a resource
|
43
|
+
# link(:self) { "http://authors/#{represented.id}" }
|
44
|
+
# @example Top-level link
|
45
|
+
# link(:self, toplevel: true) { "http://authors/#{represented.id}" }
|
46
|
+
# @example Link with options
|
47
|
+
# link(:self) do |opts|
|
48
|
+
# "http://articles?locale=#{opts[:user_options][:locale]}"
|
49
|
+
# end
|
50
|
+
#
|
51
|
+
# representer.to_json(user_options: { locale: 'de' })
|
52
|
+
#
|
53
|
+
# @param [Symbol, String] name name of the link.
|
54
|
+
# @option options [Boolean] :toplevel place link at top-level of document.
|
55
|
+
#
|
56
|
+
# @yieldparam opts [Hash] Options passed to render method
|
57
|
+
#
|
58
|
+
# @see Roar::Hypermedia::ClassMethods#link
|
59
|
+
# @see http://jsonapi.org/format/#document-links
|
60
|
+
# @api public
|
61
|
+
def link(name, options = {}, &block)
|
62
|
+
return super(name, &block) unless options[:toplevel]
|
63
|
+
for_collection.link(name, &block)
|
64
|
+
end
|
65
|
+
|
66
|
+
# Define meta information.
|
67
|
+
#
|
68
|
+
# @example Meta information for a resource
|
69
|
+
# meta do
|
70
|
+
# collection :reviewers
|
71
|
+
# end
|
72
|
+
# @example Top-level meta information
|
73
|
+
# meta toplevel: true do
|
74
|
+
# property :copyright
|
75
|
+
# end
|
76
|
+
#
|
77
|
+
# @param (see Meta::ClassMethods#meta)
|
78
|
+
# @option options [Boolean] :toplevel place meta information at top-level of document.
|
79
|
+
#
|
80
|
+
# @see Meta::ClassMethods#meta
|
81
|
+
# @see http://jsonapi.org/format/#document-meta
|
82
|
+
# @api public
|
83
|
+
def meta(options = {}, &block)
|
84
|
+
return super(&block) unless options[:toplevel]
|
85
|
+
for_collection.meta(&block)
|
86
|
+
end
|
87
|
+
|
88
|
+
# Define links and meta information for a given relationship.
|
89
|
+
#
|
90
|
+
# @example
|
91
|
+
# has_one :author, extend: AuthorDecorator do
|
92
|
+
# relationship do
|
93
|
+
# link(:self) { "/articles/#{represented.id}/relationships/author" }
|
94
|
+
# link(:related) { "/articles/#{represented.id}/author" }
|
95
|
+
# end
|
96
|
+
# end
|
97
|
+
#
|
98
|
+
# @param [#call] block
|
99
|
+
#
|
100
|
+
# @api public
|
101
|
+
def relationship(&block)
|
102
|
+
return (@relationship ||= -> {}) unless block
|
103
|
+
|
104
|
+
heritage.record(:relationship, &block)
|
105
|
+
@relationship = block
|
106
|
+
end
|
107
|
+
|
108
|
+
# Define a to-one relationship for this resource.
|
109
|
+
#
|
110
|
+
# @param [String] name name of the relationship
|
111
|
+
# @option options [Class,Module,Proc] :extend Representer to use for parsing or rendering
|
112
|
+
# @option options [Proc] :prepare Decorate the represented object
|
113
|
+
# @option options [Class,Proc] :class Class to instantiate when parsing nested fragment
|
114
|
+
# @option options [Proc] :instance Instantiate object directly when parsing nested fragment
|
115
|
+
# @param [#call] block Stuff
|
116
|
+
#
|
117
|
+
# @see http://trailblazer.to/gems/representable/3.0/function-api.html#options
|
118
|
+
# @api public
|
119
|
+
def has_one(name, options = {}, &block)
|
120
|
+
has_relationship(name, options.merge(collection: false), &block)
|
121
|
+
end
|
122
|
+
|
123
|
+
# Define a to-many relationship for this resource.
|
124
|
+
#
|
125
|
+
# @param (see #has_one)
|
126
|
+
# @option options (see #has_one)
|
127
|
+
#
|
128
|
+
# @api public
|
129
|
+
def has_many(name, options = {}, &block)
|
130
|
+
has_relationship(name, options.merge(collection: true), &block)
|
131
|
+
end
|
132
|
+
|
133
|
+
private
|
134
|
+
|
135
|
+
def has_relationship(name, options = {}, &block)
|
136
|
+
resource_decorator = options[:decorator] || options[:extends] ||
|
137
|
+
Class.new(Roar::Decorator).tap { |decorator|
|
138
|
+
decorator.send(:include, JSONAPI::Resource.new(
|
139
|
+
name,
|
140
|
+
id_key: options.fetch(:id_key, :id)
|
141
|
+
))
|
142
|
+
}
|
143
|
+
resource_decorator.instance_exec(&block) if block
|
144
|
+
|
145
|
+
resource_identifier_representer = Class.new(resource_decorator)
|
146
|
+
resource_identifier_representer.class_eval do
|
147
|
+
def to_hash(_options = {})
|
148
|
+
super(fields: { self.class.type.to_sym => [] }, include: [], wrap: false)
|
149
|
+
end
|
150
|
+
end
|
151
|
+
|
152
|
+
nested(:included, inherit: true) do
|
153
|
+
property(name, collection: options[:collection],
|
154
|
+
decorator: resource_decorator,
|
155
|
+
wrap: false)
|
156
|
+
end
|
157
|
+
|
158
|
+
nested(:relationships, inherit: true) do
|
159
|
+
nested(:"#{name}_relationship", as: MemberName.(name)) do
|
160
|
+
property name, options.merge(as: :data,
|
161
|
+
getter: ->(opts) {
|
162
|
+
object = opts[:binding].send(:exec_context, opts)
|
163
|
+
value = object.public_send(opts[:binding].getter)
|
164
|
+
# do not blow up on nil collections
|
165
|
+
if options[:collection] && value.nil?
|
166
|
+
[]
|
167
|
+
else
|
168
|
+
value
|
169
|
+
end
|
170
|
+
},
|
171
|
+
render_nil: true,
|
172
|
+
render_empty: true,
|
173
|
+
decorator: resource_identifier_representer,
|
174
|
+
wrap: false)
|
175
|
+
|
176
|
+
instance_exec(&resource_identifier_representer.relationship)
|
177
|
+
|
178
|
+
# rubocop:disable Lint/NestedMethodDefinition
|
179
|
+
def to_hash(*)
|
180
|
+
hash = super
|
181
|
+
links = Renderer::Links.new.(hash, {})
|
182
|
+
meta = render_meta({})
|
183
|
+
|
184
|
+
HashUtils.store_if_any(hash, 'links', links)
|
185
|
+
HashUtils.store_if_any(hash, 'meta', meta)
|
186
|
+
|
187
|
+
hash
|
188
|
+
end
|
189
|
+
# rubocop:enable Lint/NestedMethodDefinition
|
190
|
+
end
|
191
|
+
end
|
192
|
+
end
|
193
|
+
end
|
194
|
+
end
|
195
|
+
end
|
196
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
module Roar
|
2
|
+
module JSON
|
3
|
+
module JSONAPI
|
4
|
+
# Defines defaults for JSON API Representers.
|
5
|
+
#
|
6
|
+
# @api public
|
7
|
+
module Defaults
|
8
|
+
# Hook called when module is included
|
9
|
+
#
|
10
|
+
# @param [Class,Module] base
|
11
|
+
# the module or class including Defaults
|
12
|
+
#
|
13
|
+
# @return [undefined]
|
14
|
+
#
|
15
|
+
# @api private
|
16
|
+
# @see http://www.ruby-doc.org/core/Module.html#method-i-included
|
17
|
+
def self.included(base)
|
18
|
+
base.defaults do |name, _|
|
19
|
+
{ as: JSONAPI::MemberName.(name) }
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,104 @@
|
|
1
|
+
module Roar
|
2
|
+
module JSON
|
3
|
+
module JSONAPI
|
4
|
+
# Instance method API for JSON API Documents.
|
5
|
+
#
|
6
|
+
module Document
|
7
|
+
# Render the document as JSON
|
8
|
+
#
|
9
|
+
# @example Simple rendering
|
10
|
+
# representer.to_json
|
11
|
+
#
|
12
|
+
# @example Rendering with compount documents and sparse fieldsets
|
13
|
+
# uri = Addressable::URI.parse('/articles/1?include=author,comments.author')
|
14
|
+
# query = Rack::Utils.parse_nested_query(uri.query)
|
15
|
+
# # => {"include"=>"author", "fields"=>{"articles"=>"title,body", "people"=>"name"}}
|
16
|
+
#
|
17
|
+
# representer.to_json(
|
18
|
+
# include: query['include'],
|
19
|
+
# fields: query['fields']
|
20
|
+
# )
|
21
|
+
#
|
22
|
+
# @option options (see #to_hash)
|
23
|
+
#
|
24
|
+
# @return [String] JSON String
|
25
|
+
#
|
26
|
+
# @see http://jsonapi.org/format/#fetching-includes
|
27
|
+
# @see http://jsonapi.org/format/#fetching-sparse-fieldsets
|
28
|
+
# @api public
|
29
|
+
def to_json(options = {})
|
30
|
+
super
|
31
|
+
end
|
32
|
+
|
33
|
+
# Render the document as a Ruby Hash
|
34
|
+
#
|
35
|
+
# @option options [Array<#to_s>,#to_s,false] include
|
36
|
+
# compound documents to include, specified as a list of relationship
|
37
|
+
# paths (Array or comma-separated String) or `false`, if no compound
|
38
|
+
# documents are to be included.
|
39
|
+
#
|
40
|
+
# N.B. this syntax and behaviour for this option *is signficantly
|
41
|
+
# different* to that of the `include` option implemented in other,
|
42
|
+
# non-JSON API Representers.
|
43
|
+
# @option options [Hash{Symbol=>[Array<String>]}] fields
|
44
|
+
# fields to returned on a per-type basis.
|
45
|
+
# @option options [Hash{#to_s}=>Object] meta
|
46
|
+
# additional meta information to be rendered in the document.
|
47
|
+
# @option options [Hash{Symbol=>Symbol}] user_options
|
48
|
+
# additional arbitary options to be passed to the Representer.
|
49
|
+
#
|
50
|
+
# @return [Hash{String=>Object}]
|
51
|
+
#
|
52
|
+
# @api public
|
53
|
+
def to_hash(options = {})
|
54
|
+
document = super(Options::Include.(options, mappings))
|
55
|
+
unwrapped = options[:wrap] == false
|
56
|
+
resource = unwrapped ? document : document['data']
|
57
|
+
resource['type'] = JSONAPI::MemberName.(self.class.type)
|
58
|
+
|
59
|
+
links = Renderer::Links.new.(resource, options)
|
60
|
+
meta = render_meta(options)
|
61
|
+
|
62
|
+
resource.reject! do |_, v| v && v.empty? end
|
63
|
+
|
64
|
+
unless unwrapped
|
65
|
+
included = resource.delete('included')
|
66
|
+
|
67
|
+
HashUtils.store_if_any(document, 'included',
|
68
|
+
Fragment::Included.(included, options))
|
69
|
+
end
|
70
|
+
|
71
|
+
HashUtils.store_if_any(resource, 'links', links)
|
72
|
+
HashUtils.store_if_any(document, 'meta', meta)
|
73
|
+
|
74
|
+
document
|
75
|
+
end
|
76
|
+
|
77
|
+
private
|
78
|
+
|
79
|
+
def mappings
|
80
|
+
@mappings ||= begin
|
81
|
+
mappings = {}
|
82
|
+
mappings[:id] = find_id_mapping
|
83
|
+
mappings[:relationships] = find_relationship_mappings
|
84
|
+
mappings[:relationships]['_self'] = self.class.type
|
85
|
+
mappings
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
def find_id_mapping
|
90
|
+
self.class.definitions.detect { |definition|
|
91
|
+
definition[:as] && definition[:as].evaluate(:value) == 'id'
|
92
|
+
}.name
|
93
|
+
end
|
94
|
+
|
95
|
+
def find_relationship_mappings
|
96
|
+
included_definitions = self.class.definitions['included'].representer_module.definitions
|
97
|
+
included_definitions.each_with_object({}) do |definition, hash|
|
98
|
+
hash[definition.name] = definition.representer_module.type
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
module Roar
|
2
|
+
module JSON
|
3
|
+
module JSONAPI
|
4
|
+
# @api private
|
5
|
+
module ForCollection
|
6
|
+
def collection_representer!(_options)
|
7
|
+
singular = self # e.g. Song::Representer
|
8
|
+
|
9
|
+
nested_builder.(_base: default_nested_class, _features: [Roar::JSON, Roar::Hypermedia, JSONAPI::Defaults, JSONAPI::Meta], _block: proc do
|
10
|
+
collection :to_a, as: :data, decorator: singular, wrap: false
|
11
|
+
# rubocop:disable Lint/NestedMethodDefinition
|
12
|
+
def to_hash(options = {})
|
13
|
+
document = super(to_a: options, user_options: options[:user_options]) # [{data: {..}, data: {..}}]
|
14
|
+
|
15
|
+
links = Renderer::Links.new.(document, options)
|
16
|
+
meta = render_meta(options)
|
17
|
+
included = []
|
18
|
+
document['data'].each do |single|
|
19
|
+
included += single.delete('included') || []
|
20
|
+
end
|
21
|
+
|
22
|
+
HashUtils.store_if_any(document, 'included',
|
23
|
+
Fragment::Included.(included, options))
|
24
|
+
HashUtils.store_if_any(document, 'links', links)
|
25
|
+
HashUtils.store_if_any(document, 'meta', meta)
|
26
|
+
|
27
|
+
document
|
28
|
+
end
|
29
|
+
# rubocop:enable Lint/NestedMethodDefinition
|
30
|
+
end)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
# encoding=utf-8
|
2
|
+
|
3
|
+
module Roar
|
4
|
+
module JSON
|
5
|
+
module JSONAPI
|
6
|
+
# Member Name formatting according to the JSON API specification.
|
7
|
+
#
|
8
|
+
# @see http://jsonapi.org/format/#document-member-names
|
9
|
+
# @since 0.1.0
|
10
|
+
class MemberName
|
11
|
+
# @api private
|
12
|
+
LENIENT_FILTER_REGEXP = /([^[:alnum:][-_ ]]+)/
|
13
|
+
# @api private
|
14
|
+
STRICT_FILTER_REGEXP = /([^[0-9a-z][-_]]+)/
|
15
|
+
|
16
|
+
# @see #call
|
17
|
+
def self.call(name, options = {})
|
18
|
+
new.(name, options)
|
19
|
+
end
|
20
|
+
|
21
|
+
# Format a member name
|
22
|
+
#
|
23
|
+
# @param [String, Symbol] name
|
24
|
+
# member name.
|
25
|
+
# @option options [Boolean] :strict
|
26
|
+
# whether strict mode is enabled.
|
27
|
+
#
|
28
|
+
# Strict mode applies additional JSON Specification *RECOMMENDATIONS*,
|
29
|
+
# permitting only non-reserved, URL safe characters specified in RFC 3986.
|
30
|
+
# The member name will be lower-cased and underscores will be
|
31
|
+
# transformed to hyphens.
|
32
|
+
#
|
33
|
+
# Non-strict mode permits:
|
34
|
+
# * non-ASCII alphanumeric Unicode characters.
|
35
|
+
# * spaces, underscores and hyphens, except as the first or last character.
|
36
|
+
#
|
37
|
+
# @return [String] formatted member name.
|
38
|
+
#
|
39
|
+
# @api public
|
40
|
+
def call(name, options = {})
|
41
|
+
name = name.to_s
|
42
|
+
strict = options.fetch(:strict, true)
|
43
|
+
if strict
|
44
|
+
name.downcase!
|
45
|
+
name.gsub!(STRICT_FILTER_REGEXP, ''.freeze)
|
46
|
+
else
|
47
|
+
name.gsub!(LENIENT_FILTER_REGEXP, ''.freeze)
|
48
|
+
end
|
49
|
+
name.gsub!(/\A([-_ ])/, '')
|
50
|
+
name.gsub!(/([-_ ])\z/, '')
|
51
|
+
name.tr!('_', '-') if strict
|
52
|
+
name
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
module Roar
|
2
|
+
module JSON
|
3
|
+
module JSONAPI
|
4
|
+
# Meta information API for JSON API Representers.
|
5
|
+
#
|
6
|
+
# @api public
|
7
|
+
module Meta
|
8
|
+
# Hook called when module is included
|
9
|
+
#
|
10
|
+
# @param [Class,Module] base
|
11
|
+
# the module or class including JSONAPI
|
12
|
+
#
|
13
|
+
# @return [undefined]
|
14
|
+
#
|
15
|
+
# @api private
|
16
|
+
# @see http://www.ruby-doc.org/core/Module.html#method-i-included
|
17
|
+
def self.included(base)
|
18
|
+
base.extend ClassMethods
|
19
|
+
end
|
20
|
+
|
21
|
+
# Class level interface
|
22
|
+
module ClassMethods
|
23
|
+
# Define meta information.
|
24
|
+
#
|
25
|
+
# @example
|
26
|
+
# meta do
|
27
|
+
# property :copyright
|
28
|
+
# collection :reviewers
|
29
|
+
# end
|
30
|
+
#
|
31
|
+
# @param [#call] block
|
32
|
+
#
|
33
|
+
# @see http://jsonapi.org/format/#document-meta
|
34
|
+
# @api public
|
35
|
+
def meta(&block)
|
36
|
+
representable_attrs[:meta_representer] ||= nested_builder.(
|
37
|
+
_base: default_nested_class,
|
38
|
+
_features: [Roar::JSON, JSONAPI::Defaults],
|
39
|
+
_block: Proc.new
|
40
|
+
)
|
41
|
+
representable_attrs[:meta_representer].instance_exec(&block)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
private
|
46
|
+
|
47
|
+
def render_meta(options)
|
48
|
+
representer = representable_attrs[:meta_representer]
|
49
|
+
meta = representer ? representer.new(represented).to_hash : {}
|
50
|
+
meta.merge!(options[:meta]) if options[:meta]
|
51
|
+
meta
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|