Skip to content

Commit 35d34ce

Browse files
authored
Generate links using route helpers, formalize singletons
* warn when links can not be built * add option to exclude building resource and relationship links * add to_s for relationships for prettier warning messages and debugging * add additional support for singleton resources with id resolution and routing * fix naming `LinksObjectOperationResult` => `RelationshipOperationResult` and associated methods
1 parent 1bdbed5 commit 35d34ce

21 files changed

+1022
-284
lines changed

lib/jsonapi/basic_resource.rb

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,8 @@ def inherited(subclass)
422422
subclass.abstract(false)
423423
subclass.immutable(false)
424424
subclass.caching(_caching)
425+
subclass.singleton(singleton?, (_singleton_options.dup || {}))
426+
subclass.exclude_links(_exclude_links)
425427
subclass.paginator(_paginator)
426428
subclass._attributes = (_attributes || {}).dup
427429
subclass.polymorphic(false)
@@ -628,6 +630,19 @@ def model_hint(model: _model_name, resource: _type)
628630
_model_hints[model.to_s.gsub('::', '/').underscore] = resource_type.to_s
629631
end
630632

633+
def singleton(*attrs)
634+
@_singleton = (!!attrs[0] == attrs[0]) ? attrs[0] : true
635+
@_singleton_options = attrs.extract_options!
636+
end
637+
638+
def _singleton_options
639+
@_singleton_options ||= {}
640+
end
641+
642+
def singleton?
643+
@_singleton ||= false
644+
end
645+
631646
def filters(*attrs)
632647
@_allowed_filters.merge!(attrs.inject({}) { |h, attr| h[attr] = {}; h })
633648
end
@@ -740,6 +755,24 @@ def resource_key_type
740755
@_resource_key_type ||= JSONAPI.configuration.resource_key_type
741756
end
742757

758+
# override to all resolution of masked ids to actual ids. Because singleton routes do not specify the id this
759+
# will be needed to allow lookup of singleton resources. Alternately singleton resources can override
760+
# `verify_key`
761+
def singleton_key(context)
762+
if @_singleton_options && @_singleton_options[:singleton_key]
763+
strategy = @_singleton_options[:singleton_key]
764+
case strategy
765+
when Proc
766+
key = strategy.call(context)
767+
when Symbol, String
768+
key = send(strategy, context)
769+
else
770+
raise "singleton_key must be a proc or function name"
771+
end
772+
end
773+
key
774+
end
775+
743776
def verify_key(key, context = nil)
744777
key_type = resource_key_type
745778

@@ -927,6 +960,27 @@ def mutable?
927960
!@immutable
928961
end
929962

963+
def exclude_links(exclude)
964+
case exclude
965+
when :default, "default"
966+
@_exclude_links = [:self]
967+
when :none, "none"
968+
@_exclude_links = []
969+
when Array
970+
@_exclude_links = exclude.collect {|link| link.to_sym}
971+
else
972+
fail "Invalid exclude_links"
973+
end
974+
end
975+
976+
def _exclude_links
977+
@_exclude_links ||= []
978+
end
979+
980+
def exclude_link?(link)
981+
_exclude_links.include?(link.to_sym)
982+
end
983+
930984
def caching(val = true)
931985
@caching = val
932986
end

lib/jsonapi/configuration.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ class Configuration
99
:route_format,
1010
:raise_if_parameters_not_allowed,
1111
:warn_on_route_setup_issues,
12+
:warn_on_missing_routes,
1213
:default_allow_include_to_one,
1314
:default_allow_include_to_many,
1415
:allow_sort,
@@ -57,6 +58,7 @@ def initialize
5758
self.raise_if_parameters_not_allowed = true
5859

5960
self.warn_on_route_setup_issues = true
61+
self.warn_on_missing_routes = true
6062

6163
# :none, :offset, :paged, or a custom paginator name
6264
self.default_paginator = :none
@@ -261,6 +263,8 @@ def allow_include=(allow_include)
261263

262264
attr_writer :warn_on_route_setup_issues
263265

266+
attr_writer :warn_on_missing_routes
267+
264268
attr_writer :use_relationship_reflection
265269

266270
attr_writer :resource_cache

lib/jsonapi/link_builder.rb

Lines changed: 100 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -2,59 +2,79 @@ module JSONAPI
22
class LinkBuilder
33
attr_reader :base_url,
44
:primary_resource_klass,
5-
:route_formatter,
6-
:engine_name
5+
:engine,
6+
:routes
77

88
def initialize(config = {})
99
@base_url = config[:base_url]
1010
@primary_resource_klass = config[:primary_resource_klass]
11-
@route_formatter = config[:route_formatter]
12-
@engine_name = build_engine_name
11+
@engine = build_engine
1312

14-
# Warning: These make LinkBuilder non-thread-safe. That's not a problem with the
15-
# request-specific way it's currently used, though.
16-
@resources_path_cache = JSONAPI::NaiveCache.new do |source_klass|
17-
formatted_module_path_from_class(source_klass) + format_route(source_klass._type.to_s)
13+
if engine?
14+
@routes = @engine.routes
15+
else
16+
@routes = Rails.application.routes
1817
end
18+
19+
# ToDo: Use NaiveCache for values. For this we need to not return nils and create composite keys which work
20+
# as efficient cache lookups. This could be an array of the [source.identifier, relationship] since the
21+
# ResourceIdentity will compare equality correctly
1922
end
2023

2124
def engine?
22-
!!@engine_name
25+
!!@engine
2326
end
2427

2528
def primary_resources_url
26-
if engine?
27-
engine_primary_resources_url
28-
else
29-
regular_primary_resources_url
30-
end
29+
@primary_resources_url_cached ||= "#{ base_url }#{ primary_resources_path }"
30+
rescue NoMethodError
31+
warn "primary_resources_url for #{@primary_resource_klass} could not be generated" if JSONAPI.configuration.warn_on_missing_routes
3132
end
3233

3334
def query_link(query_params)
3435
"#{ primary_resources_url }?#{ query_params.to_query }"
3536
end
3637

3738
def relationships_related_link(source, relationship, query_params = {})
38-
url = "#{ self_link(source) }/#{ route_for_relationship(relationship) }"
39+
if relationship.parent_resource.singleton?
40+
url_helper_name = singleton_related_url_helper_name(relationship)
41+
url = call_url_helper(url_helper_name)
42+
else
43+
url_helper_name = related_url_helper_name(relationship)
44+
url = call_url_helper(url_helper_name, source.id)
45+
end
46+
47+
url = "#{ base_url }#{ url }"
3948
url = "#{ url }?#{ query_params.to_query }" if query_params.present?
4049
url
50+
rescue NoMethodError
51+
warn "related_link for #{relationship} could not be generated" if JSONAPI.configuration.warn_on_missing_routes
4152
end
4253

4354
def relationships_self_link(source, relationship)
44-
"#{ self_link(source) }/relationships/#{ route_for_relationship(relationship) }"
55+
if relationship.parent_resource.singleton?
56+
url_helper_name = singleton_relationship_self_url_helper_name(relationship)
57+
url = call_url_helper(url_helper_name)
58+
else
59+
url_helper_name = relationship_self_url_helper_name(relationship)
60+
url = call_url_helper(url_helper_name, source.id)
61+
end
62+
63+
url = "#{ base_url }#{ url }"
64+
url
65+
rescue NoMethodError
66+
warn "self_link for #{relationship} could not be generated" if JSONAPI.configuration.warn_on_missing_routes
4567
end
4668

4769
def self_link(source)
48-
if engine?
49-
engine_resource_url(source)
50-
else
51-
regular_resource_url(source)
52-
end
70+
"#{ base_url }#{ resource_path(source) }"
71+
rescue NoMethodError
72+
warn "self_link for #{source.class} could not be generated" if JSONAPI.configuration.warn_on_missing_routes
5373
end
5474

5575
private
5676

57-
def build_engine_name
77+
def build_engine
5878
scopes = module_scopes_from_class(primary_resource_klass)
5979

6080
begin
@@ -68,93 +88,96 @@ def build_engine_name
6888
end
6989
end
7090

71-
def engine_path_from_resource_class(klass)
72-
path_name = engine_resources_path_name_from_class(klass)
73-
engine_name.routes.url_helpers.public_send(path_name)
91+
def call_url_helper(method, *args)
92+
routes.url_helpers.public_send(method, args)
93+
rescue NoMethodError => e
94+
raise e
7495
end
7596

76-
def engine_primary_resources_path
77-
engine_path_from_resource_class(primary_resource_klass)
97+
def path_from_resource_class(klass)
98+
url_helper_name = resources_url_helper_name_from_class(klass)
99+
call_url_helper(url_helper_name)
78100
end
79101

80-
def engine_primary_resources_url
81-
"#{ base_url }#{ engine_primary_resources_path }"
102+
def resource_path(source)
103+
url_helper_name = resource_url_helper_name_from_source(source)
104+
if source.class.singleton?
105+
call_url_helper(url_helper_name)
106+
else
107+
call_url_helper(url_helper_name, source.id)
108+
end
82109
end
83110

84-
def engine_resource_path(source)
85-
resource_path_name = engine_resource_path_name_from_source(source)
86-
engine_name.routes.url_helpers.public_send(resource_path_name, source.id)
111+
def primary_resources_path
112+
path_from_resource_class(primary_resource_klass)
87113
end
88114

89-
def engine_resource_path_name_from_source(source)
90-
scopes = module_scopes_from_class(source.class)[1..-1]
91-
base_path_name = scopes.map { |scope| scope.underscore }.join("_")
92-
end_path_name = source.class._type.to_s.singularize
93-
[base_path_name, end_path_name, "path"].reject(&:blank?).join("_")
115+
def url_helper_name_from_parts(parts)
116+
(parts << "path").reject(&:blank?).join("_")
94117
end
95118

96-
def engine_resource_url(source)
97-
"#{ base_url }#{ engine_resource_path(source) }"
98-
end
119+
def resources_path_parts_from_class(klass)
120+
if engine?
121+
scopes = module_scopes_from_class(klass)[1..-1]
122+
else
123+
scopes = module_scopes_from_class(klass)
124+
end
99125

100-
def engine_resources_path_name_from_class(klass)
101-
scopes = module_scopes_from_class(klass)[1..-1]
102126
base_path_name = scopes.map { |scope| scope.underscore }.join("_")
103127
end_path_name = klass._type.to_s
104-
105-
if base_path_name.blank?
106-
"#{ end_path_name }_path"
107-
else
108-
"#{ base_path_name }_#{ end_path_name }_path"
109-
end
128+
[base_path_name, end_path_name]
110129
end
111130

112-
def format_route(route)
113-
route_formatter.format(route)
131+
def resources_url_helper_name_from_class(klass)
132+
url_helper_name_from_parts(resources_path_parts_from_class(klass))
114133
end
115134

116-
def formatted_module_path_from_class(klass)
117-
scopes = module_scopes_from_class(klass)
118-
119-
unless scopes.empty?
120-
"/#{ scopes.map{ |scope| format_route(scope.to_s.underscore) }.compact.join('/') }/"
135+
def resource_path_parts_from_class(klass)
136+
if engine?
137+
scopes = module_scopes_from_class(klass)[1..-1]
121138
else
122-
"/"
139+
scopes = module_scopes_from_class(klass)
123140
end
124-
end
125141

126-
def module_scopes_from_class(klass)
127-
klass.name.to_s.split("::")[0...-1]
142+
base_path_name = scopes.map { |scope| scope.underscore }.join("_")
143+
end_path_name = klass._type.to_s.singularize
144+
[base_path_name, end_path_name]
128145
end
129146

130-
def regular_resources_path(source_klass)
131-
@resources_path_cache.get(source_klass)
147+
def resource_url_helper_name_from_source(source)
148+
url_helper_name_from_parts(resource_path_parts_from_class(source.class))
132149
end
133150

134-
def regular_primary_resources_path
135-
regular_resources_path(primary_resource_klass)
151+
def related_url_helper_name(relationship)
152+
relationship_parts = resource_path_parts_from_class(relationship.parent_resource)
153+
relationship_parts << relationship.name
154+
url_helper_name_from_parts(relationship_parts)
136155
end
137156

138-
def regular_primary_resources_url
139-
"#{ base_url }#{ regular_primary_resources_path }"
157+
def singleton_related_url_helper_name(relationship)
158+
relationship_parts = []
159+
relationship_parts << relationship.name
160+
relationship_parts += resource_path_parts_from_class(relationship.parent_resource)
161+
url_helper_name_from_parts(relationship_parts)
140162
end
141163

142-
def regular_resource_path(source)
143-
if source.is_a?(JSONAPI::CachedResponseFragment)
144-
# :nocov:
145-
"#{regular_resources_path(source.resource_klass)}/#{source.id}"
146-
# :nocov:
147-
else
148-
"#{regular_resources_path(source.class)}/#{source.id}"
149-
end
164+
def relationship_self_url_helper_name(relationship)
165+
relationship_parts = resource_path_parts_from_class(relationship.parent_resource)
166+
relationship_parts << "relationships"
167+
relationship_parts << relationship.name
168+
url_helper_name_from_parts(relationship_parts)
150169
end
151170

152-
def regular_resource_url(source)
153-
"#{ base_url }#{ regular_resource_path(source) }"
171+
def singleton_relationship_self_url_helper_name(relationship)
172+
relationship_parts = []
173+
relationship_parts << "relationships"
174+
relationship_parts << relationship.name
175+
relationship_parts += resource_path_parts_from_class(relationship.parent_resource)
176+
url_helper_name_from_parts(relationship_parts)
154177
end
155178

156-
def route_for_relationship(relationship)
157-
format_route(relationship.name)
179+
def module_scopes_from_class(klass)
180+
klass.name.to_s.split("::")[0...-1]
158181
end
159182
end
160183
end

lib/jsonapi/operation_result.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ def to_hash(serializer = nil)
100100
end
101101
end
102102

103-
class LinksObjectOperationResult < OperationResult
103+
class RelationshipOperationResult < OperationResult
104104
attr_accessor :parent_resource, :relationship, :resource_ids
105105

106106
def initialize(code, parent_resource, relationship, resource_ids, options = {})
@@ -112,7 +112,7 @@ def initialize(code, parent_resource, relationship, resource_ids, options = {})
112112

113113
def to_hash(serializer = nil)
114114
if serializer
115-
serializer.serialize_to_links_hash(parent_resource, relationship, resource_ids)
115+
serializer.serialize_to_relationship_hash(parent_resource, relationship, resource_ids)
116116
else
117117
# :nocov:
118118
{}

lib/jsonapi/processor.rb

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -130,11 +130,11 @@ def show_relationship
130130
find_options,
131131
nil)
132132

133-
return JSONAPI::LinksObjectOperationResult.new(:ok,
134-
parent_resource,
135-
resource_klass._relationship(relationship_type),
136-
resource_id_tree.fragments.keys,
137-
result_options)
133+
return JSONAPI::RelationshipOperationResult.new(:ok,
134+
parent_resource,
135+
resource_klass._relationship(relationship_type),
136+
resource_id_tree.fragments.keys,
137+
result_options)
138138
end
139139

140140
def show_related_resource

0 commit comments

Comments
 (0)