roar-jsonapi 0.0.1
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 +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
|