Skip to content

Commit 010f024

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 eb0c74f commit 010f024

22 files changed

+1382
-534
lines changed

lib/jsonapi/acts_as_resource_controller.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,7 @@ def render_results(operation_results)
234234
end
235235

236236
render_options[:location] = content[:data]["links"][:self] if (
237-
response_doc.status == :created && content[:data].class != Array
237+
response_doc.status == :created && content[:data].class != Array && content[:data]["links"]
238238
)
239239

240240
# For whatever reason, `render` ignores :status and :content_type when :body is set.

lib/jsonapi/configuration.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ class Configuration
88
:resource_key_type,
99
:route_format,
1010
:raise_if_parameters_not_allowed,
11+
:warn_on_missing_routes,
1112
:allow_include,
1213
:allow_sort,
1314
:allow_filter,
@@ -51,6 +52,8 @@ def initialize
5152

5253
self.raise_if_parameters_not_allowed = true
5354

55+
self.warn_on_missing_routes = true
56+
5457
# :none, :offset, :paged, or a custom paginator name
5558
self.default_paginator = :none
5659

@@ -235,6 +238,8 @@ def default_processor_klass=(default_processor_klass)
235238

236239
attr_writer :raise_if_parameters_not_allowed
237240

241+
attr_writer :warn_on_missing_routes
242+
238243
attr_writer :use_relationship_reflection
239244

240245
attr_writer :resource_cache

lib/jsonapi/link_builder.rb

Lines changed: 103 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -2,151 +2,182 @@ 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
6181
unless scopes.empty?
6282
"#{ scopes.first.to_s.camelize }::Engine".safe_constantize
6383
end
64-
rescue LoadError => e
84+
# :nocov:
85+
rescue LoadError => _e
6586
nil
87+
# :nocov:
6688
end
6789
end
6890

69-
def engine_path_from_resource_class(klass)
70-
path_name = engine_resources_path_name_from_class(klass)
71-
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
7295
end
7396

74-
def engine_primary_resources_path
75-
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)
76100
end
77101

78-
def engine_primary_resources_url
79-
"#{ 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
80109
end
81110

82-
def engine_resource_path(source)
83-
resource_path_name = engine_resource_path_name_from_source(source)
84-
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)
85113
end
86114

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

94-
def engine_resource_url(source)
95-
"#{ base_url }#{ engine_resource_path(source) }"
96-
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
97125

98-
def engine_resources_path_name_from_class(klass)
99-
scopes = module_scopes_from_class(klass)[1..-1]
100126
base_path_name = scopes.map { |scope| scope.underscore }.join("_")
101127
end_path_name = klass._type.to_s
102-
103-
if base_path_name.blank?
104-
"#{ end_path_name }_path"
105-
else
106-
"#{ base_path_name }_#{ end_path_name }_path"
107-
end
128+
[base_path_name, end_path_name]
108129
end
109130

110-
def format_route(route)
111-
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))
112133
end
113134

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

124-
def module_scopes_from_class(klass)
125-
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]
126145
end
127146

128-
def regular_resources_path(source_klass)
129-
@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))
130149
end
131150

132-
def regular_primary_resources_path
133-
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)
134155
end
135156

136-
def regular_primary_resources_url
137-
"#{ 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)
138162
end
139163

140-
def regular_resource_path(source)
141-
"#{regular_resources_path(source.class)}/#{source.id}"
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)
142169
end
143170

144-
def regular_resource_url(source)
145-
"#{ 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)
146177
end
147178

148-
def route_for_relationship(relationship)
149-
format_route(relationship.name)
179+
def module_scopes_from_class(klass)
180+
klass.name.to_s.split("::")[0...-1]
150181
end
151182
end
152183
end

lib/jsonapi/operation_result.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ def initialize(code, source_resource, type, resources, options = {})
5353
end
5454
end
5555

56-
class LinksObjectOperationResult < OperationResult
56+
class RelationshipOperationResult < OperationResult
5757
attr_accessor :parent_resource, :relationship
5858

5959
def initialize(code, parent_resource, relationship, options = {})

lib/jsonapi/processor.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -136,9 +136,9 @@ def show_relationship
136136

137137
parent_resource = resource_klass.find_by_key(parent_key, context: context)
138138

139-
return JSONAPI::LinksObjectOperationResult.new(:ok,
140-
parent_resource,
141-
resource_klass._relationship(relationship_type))
139+
return JSONAPI::RelationshipOperationResult.new(:ok,
140+
parent_resource,
141+
resource_klass._relationship(relationship_type))
142142
end
143143

144144
def show_related_resource

lib/jsonapi/relationship.rb

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ def initialize(name, options = {})
1414
@polymorphic = options.fetch(:polymorphic, false) == true
1515
@always_include_linkage_data = options.fetch(:always_include_linkage_data, false) == true
1616
@eager_load_on_include = options.fetch(:eager_load_on_include, true) == true
17+
18+
exclude_links(options.fetch(:exclude_links, :none))
1719
end
1820

1921
alias_method :polymorphic?, :polymorphic
@@ -60,6 +62,27 @@ def belongs_to?
6062
false
6163
end
6264

65+
def exclude_links(exclude)
66+
case exclude
67+
when :default, "default"
68+
@_exclude_links = [:self, :related]
69+
when :none, "none"
70+
@_exclude_links = []
71+
when Array
72+
@_exclude_links = exclude.collect {|link| link.to_sym}
73+
else
74+
fail "Invalid exclude_links"
75+
end
76+
end
77+
78+
def _exclude_links
79+
@_exclude_links ||= []
80+
end
81+
82+
def exclude_link?(link)
83+
_exclude_links.include?(link.to_sym)
84+
end
85+
6386
class ToOne < Relationship
6487
attr_reader :foreign_key_on
6588

@@ -70,6 +93,12 @@ def initialize(name, options = {})
7093
@foreign_key_on = options.fetch(:foreign_key_on, :self)
7194
end
7295

96+
def to_s
97+
# :nocov:
98+
"#{parent_resource}.#{name}(#{belongs_to? ? 'BelongsToOne' : 'ToOne'})"
99+
# :nocov:
100+
end
101+
73102
def belongs_to?
74103
foreign_key_on == :self
75104
end
@@ -89,6 +118,12 @@ def initialize(name, options = {})
89118
@reflect = options.fetch(:reflect, true) == true
90119
@inverse_relationship = options.fetch(:inverse_relationship, parent_resource._type.to_s.singularize.to_sym) if parent_resource
91120
end
121+
122+
def to_s
123+
# :nocov:
124+
"#{parent_resource}.#{name}(ToMany)"
125+
# :nocov:
126+
end
92127
end
93128
end
94129
end

0 commit comments

Comments
 (0)