roar-jsonapi 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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