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