lhs 21.3.1 → 23.0.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c43753bff8b039bf6b5b2384333afcc1d09da6c61f1616b06751c7a023e0fbc0
4
- data.tar.gz: ee5cd17ace1e7f82fc8a713dc493543e800658bd76909ddb026951d84f31c2df
3
+ metadata.gz: bdbfabed0cdff17a569b59c362c34716507eb5447b6e54f4b2903cf057736d9a
4
+ data.tar.gz: 2ac0567f16a82722d370de8a75a1cdabc412266957047d4ed24e87a00c655f36
5
5
  SHA512:
6
- metadata.gz: 14d9ddf1745b15e1c635060eec564a3e578f31829e24dede722ca5509a1dffeefec3fb0daaeb27c94f3c9854314d41fe9042cccfbd04ca3fdc2bab3a32bb9120
7
- data.tar.gz: ac97fc333b97f8492bc519b401b68377af441e398486ceaea183d8b006015b691d1aad9a0f518ebf5367cf4427098d767e96354813da94807021195a38f3e997
6
+ metadata.gz: 4abe219f4c365168b7b6abd9095bb1f72cf8bbf5dc2591ff8457062f50590254c6321b0d244cbac07a5a7cc88c3915cdf135dd9b9185984f7cd029fb152274ef
7
+ data.tar.gz: 80a97943d67cc14578f23ad6d20074cb329155f54abf89812f3872f84b1c698dacafa12f6616302b1d68787db735ac211295642cc44688033ee85a61be4cce51
data/README.md CHANGED
@@ -99,6 +99,8 @@ record.review # "Lunch was great
99
99
  * [Change/Update existing records](#changeupdate-existing-records)
100
100
  * [save](#save)
101
101
  * [update](#update)
102
+ * [Directly via Record](#directly-via-record)
103
+ * [per Instance](#per-instance)
102
104
  * [partial_update](#partial_update)
103
105
  * [Endpoint url parameter injection during record creation/change](#endpoint-url-parameter-injection-during-record-creationchange)
104
106
  * [Record validation](#record-validation)
@@ -119,8 +121,8 @@ record.review # "Lunch was great
119
121
  * [Record getters](#record-getters)
120
122
  * [Include linked resources (hyperlinks and hypermedia)](#include-linked-resources-hyperlinks-and-hypermedia)
121
123
  * [Generate links from parameters](#generate-links-from-parameters)
122
- * [Ensure the whole linked collection is included: includes_all](#ensure-the-whole-linked-collection-is-included-includes_all)
123
- * [Include the first linked page or single item is included: include](#include-the-first-linked-page-or-single-item-is-included-include)
124
+ * [Ensure the whole linked collection is included with includes](#ensure-the-whole-linked-collection-is-included-with-includes)
125
+ * [Include only the first linked page of a linked collection: includes_first_page](#include-only-the-first-linked-page-of-a-linked-collection-includes_first_page)
124
126
  * [Include various levels of linked data](#include-various-levels-of-linked-data)
125
127
  * [Identify and cast known records when including records](#identify-and-cast-known-records-when-including-records)
126
128
  * [Apply options for requests performed to fetch included records](#apply-options-for-requests-performed-to-fetch-included-records)
@@ -152,6 +154,7 @@ record.review # "Lunch was great
152
154
 
153
155
 
154
156
 
157
+
155
158
  ## Installation/Startup checklist
156
159
 
157
160
  - [ ] Install LHS gem, preferably via `Gemfile`
@@ -1607,6 +1610,21 @@ POST https://service.example.com/records/1z-5r1fkaj { body: "{ 'name': 'Starbuck
1607
1610
 
1608
1611
  ##### update
1609
1612
 
1613
+ ###### Directly via Record
1614
+
1615
+ ```ruby
1616
+ # app/controllers/some_controller.rb
1617
+
1618
+ Record.update(id: '1z-5r1fkaj', name: 'Steve')
1619
+
1620
+ ```
1621
+ ```
1622
+ GET https://service.example.com/records/1z-5r1fkaj
1623
+ { name: 'Steve' }
1624
+ ```
1625
+
1626
+ ###### per Instance
1627
+
1610
1628
  `update` persists the whole object after new parameters are applied through arguments.
1611
1629
 
1612
1630
  `update` will return false if persisting fails. `update!` instead will raise an exception.
@@ -2093,7 +2111,7 @@ In a service-oriented architecture using [hyperlinks](https://en.wikipedia.org/w
2093
2111
 
2094
2112
  When fetching records with LHS, you can specify in advance all the linked resources that you want to include in the results.
2095
2113
 
2096
- With `includes` or `includes_all` (to enforce fetching all remote objects for paginated endpoints), LHS ensures that all matching and explicitly linked resources are loaded and merged.
2114
+ With `includes` LHS ensures that all matching and explicitly linked resources are loaded and merged (even if the linked resources are paginated).
2097
2115
 
2098
2116
  Including linked resources/records is heavily influenced by [https://guides.rubyonrails.org/active_record_querying.html](https://guides.rubyonrails.org/active_record_querying.html#eager-loading-associations) and you should read it to understand this feature in all it's glory.
2099
2117
 
@@ -2112,16 +2130,16 @@ Presence.create(place: { href: Place.href_for(123) })
2112
2130
  POST '/presences' { place: { href: "http://datastore/places/123" } }
2113
2131
  ```
2114
2132
 
2115
- #### Ensure the whole linked collection is included: includes_all
2133
+ #### Ensure the whole linked collection is included with includes
2116
2134
 
2117
- In case endpoints are paginated and you are certain that you'll need all objects of a set and not only the first page/batch, use `includes_all`.
2135
+ In case endpoints are paginated and you are certain that you'll need all objects of a set and not only the first page/batch, use `includes`.
2118
2136
 
2119
2137
  LHS will ensure that all linked resources are around by loading all pages (parallelized/performance optimized).
2120
2138
 
2121
2139
  ```ruby
2122
2140
  # app/controllers/some_controller.rb
2123
2141
 
2124
- customer = Customer.includes_all(contracts: :products).find(1)
2142
+ customer = Customer.includes(contracts: :products).find(1)
2125
2143
  ```
2126
2144
  ```
2127
2145
  > GET https://service.example.com/customers/1
@@ -2148,14 +2166,14 @@ customer.contracts.first.products.first.name # Local Business Card
2148
2166
 
2149
2167
  ```
2150
2168
 
2151
- #### Include the first linked page or single item is included: include
2169
+ #### Include only the first linked page of a linked collection: includes_first_page
2152
2170
 
2153
- `includes` includes the first page/response when loading the linked resource. **If the endpoint is paginated, only the first page will be included.**
2171
+ `includes_first_page` includes the first page/response when loading the linked resource. **If the endpoint is paginated, only the first page will be included.**
2154
2172
 
2155
2173
  ```ruby
2156
2174
  # app/controllers/some_controller.rb
2157
2175
 
2158
- customer = Customer.includes(contracts: :products).find(1)
2176
+ customer = Customer.includes_first_page(contracts: :products).find(1)
2159
2177
  ```
2160
2178
  ```
2161
2179
  > GET https://service.example.com/customers/1
@@ -2179,7 +2197,7 @@ customer.contracts.first.products.first.name # Local Business Card
2179
2197
 
2180
2198
  #### Include various levels of linked data
2181
2199
 
2182
- The method syntax of `includes` and `includes_all`, allows you include hyperlinks stored in deep nested data strutures:
2200
+ The method syntax of `includes` allows you include hyperlinks stored in deep nested data strutures:
2183
2201
 
2184
2202
  Some examples:
2185
2203
 
@@ -2199,7 +2217,7 @@ Record.includes(campaign: [:entry, :user])
2199
2217
 
2200
2218
  #### Identify and cast known records when including records
2201
2219
 
2202
- When including linked resources with `includes` or `includes_all`, already defined records and their endpoints and configurations are used to make the requests to fetch the additional data.
2220
+ When including linked resources with `includes`, already defined records and their endpoints and configurations are used to make the requests to fetch the additional data.
2203
2221
 
2204
2222
  That also means that options for endpoints of linked resources are applied when requesting those in addition.
2205
2223
 
@@ -2261,6 +2279,17 @@ In parallel:
2261
2279
  GET https://service.example.com/places/4 { headers: { 'Authentication': 'Bearer 123' } }
2262
2280
  ```
2263
2281
 
2282
+ Here is another example, if you want to ignore errors, that occure while you fetch included resources:
2283
+
2284
+ ```ruby
2285
+ # app/controllers/some_controller.rb
2286
+
2287
+ feedback = Feedback
2288
+ .includes(campaign: :entry)
2289
+ .references(campaign: { ignored_errors: [LHC::NotFound] })
2290
+ .find(12345)
2291
+ ```
2292
+
2264
2293
  ### Record batch processing
2265
2294
 
2266
2295
  **Be careful using methods for batch processing. They could result in a lot of HTTP requests!**
@@ -24,7 +24,7 @@ Gem::Specification.new do |s|
24
24
 
25
25
  s.add_dependency 'activemodel'
26
26
  s.add_dependency 'activesupport', '>= 4.2.11'
27
- s.add_dependency 'lhc', '>= 10', '< 12'
27
+ s.add_dependency 'lhc', '>= 11.2.0', '< 12'
28
28
  s.add_dependency 'local_uri'
29
29
 
30
30
  s.add_development_dependency 'capybara'
@@ -64,11 +64,11 @@ class LHS::Record
64
64
  chain
65
65
  end
66
66
 
67
- def includes(*args)
67
+ def includes_first_page(*args)
68
68
  Chain.new(self, Include.new(Chain.unfold(args)))
69
69
  end
70
70
 
71
- def includes_all(*args)
71
+ def includes(*args)
72
72
  chain = Chain.new(self, Include.new(Chain.unfold(args)))
73
73
  chain.include_all!(args)
74
74
  chain
@@ -259,11 +259,11 @@ class LHS::Record
259
259
  push(ErrorHandling.new(error_class => handler))
260
260
  end
261
261
 
262
- def includes(*args)
262
+ def includes_first_page(*args)
263
263
  push(Include.new(Chain.unfold(args)))
264
264
  end
265
265
 
266
- def includes_all(*args)
266
+ def includes(*args)
267
267
  chain = push(Include.new(Chain.unfold(args)))
268
268
  chain.include_all!(args)
269
269
  chain
@@ -138,6 +138,7 @@ class LHS::Record
138
138
  end
139
139
 
140
140
  def extend_base_item!(data, addition, key)
141
+ return if addition.nil?
141
142
  if addition.collection?
142
143
  extend_base_item_with_collection!(data, addition, key)
143
144
  else # simple case merges hash into hash
@@ -187,7 +188,7 @@ class LHS::Record
187
188
  options = extend_with_reference(options, reference)
188
189
  addition = load_include(options, data, sub_includes, reference)
189
190
  extend_raw_data!(data, addition, included)
190
- expand_addition!(data, included, options) if no_expanded_data?(addition)
191
+ expand_addition!(data, included, options) unless expanded_data?(addition)
191
192
  end
192
193
  end
193
194
 
@@ -205,28 +206,24 @@ class LHS::Record
205
206
  def expand_addition!(data, included, reference)
206
207
  addition = data[included]
207
208
  options = options_for_data(addition)
208
- options = extend_with_reference(options, reference.except(:url))
209
+ options = extend_with_reference(options, reference)
209
210
  record = record_for_options(options) || self
210
211
  options = convert_options_to_endpoints(options) if record_for_options(options)
211
- expanded_data = begin
212
- record.request(options)
213
- rescue LHC::NotFound
214
- LHS::Data.new({}, data, record)
215
- end
212
+ expanded_data = record.request(options)
216
213
  extend_raw_data!(data, expanded_data, included)
217
214
  end
218
215
 
219
- def no_expanded_data?(addition)
216
+ def expanded_data?(addition)
220
217
  return false if addition.blank?
221
218
  if addition.item?
222
- (addition._raw.keys - [:href]).empty?
219
+ (addition._raw.keys - [:href]).any?
223
220
  elsif addition.collection?
224
- addition.all? do |item|
221
+ addition.any? do |item|
225
222
  next if item.blank?
226
223
  if item._raw.is_a?(Hash)
227
- (item._raw.keys - [:href]).empty?
224
+ (item._raw.keys - [:href]).any?
228
225
  elsif item._raw.is_a?(Array)
229
- item.any? { |item| (item._raw.keys - [:href]).empty? }
226
+ item.any? { |item| (item._raw.keys - [:href]).any? }
230
227
  end
231
228
  end
232
229
  end
@@ -234,7 +231,8 @@ class LHS::Record
234
231
 
235
232
  # Extends request options with options provided for this reference
236
233
  def extend_with_reference(options, reference)
237
- return options unless reference
234
+ return options if reference.blank?
235
+ reference = reference.except(:url)
238
236
  options ||= {}
239
237
  if options.is_a?(Array)
240
238
  options.map { |request_options| request_options.merge(reference) if request_options.present? }
@@ -348,18 +346,14 @@ class LHS::Record
348
346
  end
349
347
 
350
348
  # Load additional resources that are requested with include
351
- def load_include(options, data, sub_includes, references)
349
+ def load_include(options, _data, sub_includes, references)
352
350
  record = record_for_options(options) || self
353
351
  options = convert_options_to_endpoints(options) if record_for_options(options)
354
- begin
355
- prepare_options_for_include_request!(options, sub_includes, references)
356
- if references && references[:all] # include all linked resources
357
- load_include_all!(options, record, sub_includes, references)
358
- else # simply request first page/batch
359
- load_include_simple!(options, record)
360
- end
361
- rescue LHC::NotFound
362
- LHS::Data.new({}, data, record)
352
+ prepare_options_for_include_request!(options, sub_includes, references)
353
+ if references && references[:all] # include all linked resources
354
+ load_include_all!(options, record, sub_includes, references)
355
+ else # simply request first page/batch
356
+ load_include_simple!(options, record)
363
357
  end
364
358
  end
365
359
 
@@ -372,7 +366,7 @@ class LHS::Record
372
366
 
373
367
  def load_include_simple!(options, record)
374
368
  data = record.request(options)
375
- warn "[WARNING] You included `#{options[:url]}`, but this endpoint is paginated. You might want to use `includes_all` instead of `includes` (https://github.com/local-ch/lhs#includes_all-for-paginated-endpoints)." if paginated?(data._raw)
369
+ warn "[WARNING] You included `#{options[:url]}`, but this endpoint is paginated. You might want to use `includes_all` instead of `includes` (https://github.com/local-ch/lhs#includes_all-for-paginated-endpoints)." if data && paginated?(data._raw)
376
370
  data
377
371
  end
378
372
 
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support'
4
+
5
+ class LHS::Record
6
+
7
+ module Update
8
+ extend ActiveSupport::Concern
9
+
10
+ included do
11
+ class <<self
12
+ alias_method :update, :create
13
+ alias_method :update!, :create!
14
+ end
15
+ end
16
+ end
17
+ end
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class LHS::Record
4
+ autoload :AttributeAssignment,
5
+ 'lhs/concerns/record/attribute_assignment'
4
6
  autoload :Batch,
5
7
  'lhs/concerns/record/batch'
6
8
  autoload :Chainable,
@@ -45,9 +47,10 @@ class LHS::Record
45
47
  'lhs/concerns/record/scope'
46
48
  autoload :Tracing,
47
49
  'lhs/concerns/record/tracing'
48
- autoload :AttributeAssignment,
49
- 'lhs/concerns/record/attribute_assignment'
50
+ autoload :Update,
51
+ 'lhs/concerns/record/update'
50
52
 
53
+ include AttributeAssignment
51
54
  include Batch
52
55
  include Chainable
53
56
  include Configuration
@@ -72,7 +75,7 @@ class LHS::Record
72
75
  include Relations
73
76
  include Scope
74
77
  include Tracing
75
- include AttributeAssignment
78
+ include Update
76
79
 
77
80
  delegate :_proxy, :_endpoint, :merge_raw!, :select, :becomes, :respond_to?, to: :_data
78
81
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LHS
4
- VERSION = '21.3.1'
4
+ VERSION = '23.0.1'
5
5
  end
@@ -56,7 +56,7 @@ describe LHS::Item do
56
56
  .to_return(status: 200, body: data.to_json)
57
57
  stub_request(:get, "#{datastore}/v2/restaurants/1")
58
58
  .to_return(status: 200, body: { name: 'Casa Ferlin' }.to_json)
59
- item = Record.includes(:restaurant).find(1)
59
+ item = Record.includes_first_page(:restaurant).find(1)
60
60
  item.destroy
61
61
  end
62
62
  end
@@ -27,7 +27,7 @@ describe LHS::Proxy do
27
27
  .to_return(body: {
28
28
  items: [{ review: 'Nice restaurant' }]
29
29
  }.to_json)
30
- result = Search.where(what: 'Blumen').includes(place: :feedbacks)
30
+ result = Search.where(what: 'Blumen').includes_first_page(place: :feedbacks)
31
31
  expect(result.place.feedbacks).to be_kind_of Feedback
32
32
  expect(result.place.feedbacks.first.review).to eq 'Nice restaurant'
33
33
  end
@@ -52,7 +52,7 @@ describe LHS::Record do
52
52
  end
53
53
 
54
54
  it 'works in combination with include and includes' do
55
- records = Record.includes(:product).includes_all(:options).all(color: 'blue')
55
+ records = Record.includes_first_page(:product).includes(:options).all(color: 'blue')
56
56
  expect(records.length).to eq total
57
57
  expect(first_page_request).to have_been_requested.times(1)
58
58
  expect(second_page_request).to have_been_requested.times(1)
@@ -88,7 +88,7 @@ describe LHS::Record do
88
88
  stub_request(:get, "#{datastore}/products/LBC")
89
89
  .to_return(body: { name: 'Local Business Card' }.to_json)
90
90
  expect(lambda {
91
- Contract.includes(:product).where(entry_id: '123').all.first
91
+ Contract.includes_first_page(:product).where(entry_id: '123').all.first
92
92
  }).not_to raise_error # Multiple base endpoints found
93
93
  end
94
94
  end
@@ -25,7 +25,7 @@ describe LHS::Record do
25
25
 
26
26
  it 'allows to pass error_handling for includes to LHC' do
27
27
  handler = ->(_) { return { deleted: true } }
28
- record = Record.includes(:other).references(other: { error_handler: { LHC::NotFound => handler } }).find(id: 1)
28
+ record = Record.includes_first_page(:other).references(other: { error_handler: { LHC::NotFound => handler } }).find(id: 1)
29
29
 
30
30
  expect(record.other.deleted).to be(true)
31
31
  end
@@ -112,7 +112,7 @@ describe LHS::Record do
112
112
  end
113
113
 
114
114
  it 'explicit association configuration overrules href class casting' do
115
- place = Place.includes(:categories).find(1)
115
+ place = Place.includes_first_page(:categories).find(1)
116
116
  expect(place.categories.first).to be_kind_of NewCategory
117
117
  expect(place.categories.first.name).to eq('Pizza')
118
118
  end
@@ -108,7 +108,7 @@ describe LHS::Record do
108
108
  end
109
109
 
110
110
  it 'explicit association configuration overrules href class casting' do
111
- place = Place.includes(:category).find(1)
111
+ place = Place.includes_first_page(:category).find(1)
112
112
  expect(place.category).to be_kind_of NewCategory
113
113
  expect(place.category.name).to eq('Pizza')
114
114
  end
@@ -0,0 +1,737 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails_helper'
4
+
5
+ describe LHS::Record do
6
+ let(:datastore) { 'http://local.ch/v2' }
7
+ before { LHC.config.placeholder('datastore', datastore) }
8
+
9
+ let(:stub_campaign_request) do
10
+ stub_request(:get, "#{datastore}/content-ads/51dfc5690cf271c375c5a12d")
11
+ .to_return(body: {
12
+ 'href' => "#{datastore}/content-ads/51dfc5690cf271c375c5a12d",
13
+ 'entry' => { 'href' => "#{datastore}/local-entries/lakj35asdflkj1203va" },
14
+ 'user' => { 'href' => "#{datastore}/users/lakj35asdflkj1203va" }
15
+ }.to_json)
16
+ end
17
+
18
+ let(:stub_entry_request) do
19
+ stub_request(:get, "#{datastore}/local-entries/lakj35asdflkj1203va")
20
+ .to_return(body: { 'name' => 'Casa Ferlin' }.to_json)
21
+ end
22
+
23
+ let(:stub_user_request) do
24
+ stub_request(:get, "#{datastore}/users/lakj35asdflkj1203va")
25
+ .to_return(body: { 'name' => 'Mario' }.to_json)
26
+ end
27
+
28
+ context 'singlelevel includes' do
29
+ before do
30
+ class LocalEntry < LHS::Record
31
+ endpoint '{+datastore}/local-entries'
32
+ endpoint '{+datastore}/local-entries/{id}'
33
+ end
34
+ class User < LHS::Record
35
+ endpoint '{+datastore}/users'
36
+ endpoint '{+datastore}/users/{id}'
37
+ end
38
+ class Favorite < LHS::Record
39
+ endpoint '{+datastore}/favorites'
40
+ endpoint '{+datastore}/favorites/{id}'
41
+ end
42
+ stub_request(:get, "#{datastore}/local-entries/1")
43
+ .to_return(body: { company_name: 'local.ch' }.to_json)
44
+ stub_request(:get, "#{datastore}/users/1")
45
+ .to_return(body: { name: 'Mario' }.to_json)
46
+ stub_request(:get, "#{datastore}/favorites/1")
47
+ .to_return(body: {
48
+ local_entry: { href: "#{datastore}/local-entries/1" },
49
+ user: { href: "#{datastore}/users/1" }
50
+ }.to_json)
51
+ end
52
+
53
+ it 'includes a resource' do
54
+ favorite = Favorite.includes_first_page(:local_entry).find(1)
55
+ expect(favorite.local_entry.company_name).to eq 'local.ch'
56
+ end
57
+
58
+ it 'duplicates a class' do
59
+ expect(Favorite.object_id).not_to eq(Favorite.includes_first_page(:local_entry).object_id)
60
+ end
61
+
62
+ it 'includes a list of resources' do
63
+ favorite = Favorite.includes_first_page(:local_entry, :user).find(1)
64
+ expect(favorite.local_entry).to be_kind_of LocalEntry
65
+ expect(favorite.local_entry.company_name).to eq 'local.ch'
66
+ expect(favorite.user.name).to eq 'Mario'
67
+ end
68
+
69
+ it 'includes an array of resources' do
70
+ favorite = Favorite.includes_first_page([:local_entry, :user]).find(1)
71
+ expect(favorite.local_entry.company_name).to eq 'local.ch'
72
+ expect(favorite.user.name).to eq 'Mario'
73
+ end
74
+ end
75
+
76
+ context 'multilevel includes' do
77
+ before do
78
+ class Feedback < LHS::Record
79
+ endpoint '{+datastore}/feedbacks'
80
+ endpoint '{+datastore}/feedbacks/{id}'
81
+ end
82
+ stub_campaign_request
83
+ stub_entry_request
84
+ stub_user_request
85
+ end
86
+
87
+ it 'includes linked resources while fetching multiple resources from one service' do
88
+ stub_request(:get, "#{datastore}/feedbacks?has_reviews=true")
89
+ .to_return(status: 200, body: {
90
+ items: [
91
+ {
92
+ 'href' => "#{datastore}/feedbacks/-Sc4_pYNpqfsudzhtivfkA",
93
+ 'campaign' => { 'href' => "#{datastore}/content-ads/51dfc5690cf271c375c5a12d" }
94
+ }
95
+ ]
96
+ }.to_json)
97
+
98
+ feedbacks = Feedback.includes_first_page(campaign: :entry).where(has_reviews: true)
99
+ expect(feedbacks.first.campaign.entry.name).to eq 'Casa Ferlin'
100
+ end
101
+
102
+ it 'includes linked resources while fetching a single resource from one service' do
103
+ stub_request(:get, "#{datastore}/feedbacks/123")
104
+ .to_return(status: 200, body: {
105
+ 'href' => "#{datastore}/feedbacks/-Sc4_pYNpqfsudzhtivfkA",
106
+ 'campaign' => { 'href' => "#{datastore}/content-ads/51dfc5690cf271c375c5a12d" }
107
+ }.to_json)
108
+
109
+ feedbacks = Feedback.includes_first_page(campaign: :entry).find(123)
110
+ expect(feedbacks.campaign.entry.name).to eq 'Casa Ferlin'
111
+ end
112
+
113
+ it 'includes linked resources with array while fetching a single resource from one service' do
114
+ stub_request(:get, "#{datastore}/feedbacks/123")
115
+ .to_return(status: 200, body: {
116
+ 'href' => "#{datastore}/feedbacks/-Sc4_pYNpqfsudzhtivfkA",
117
+ 'campaign' => { 'href' => "#{datastore}/content-ads/51dfc5690cf271c375c5a12d" }
118
+ }.to_json)
119
+
120
+ feedbacks = Feedback.includes_first_page(campaign: [:entry, :user]).find(123)
121
+ expect(feedbacks.campaign.entry.name).to eq 'Casa Ferlin'
122
+ expect(feedbacks.campaign.user.name).to eq 'Mario'
123
+ end
124
+
125
+ it 'includes list of linked resources while fetching a single resource from one service' do
126
+ stub_request(:get, "#{datastore}/feedbacks/123")
127
+ .to_return(status: 200, body: {
128
+ 'href' => "#{datastore}/feedbacks/-Sc4_pYNpqfsudzhtivfkA",
129
+ 'campaign' => { 'href' => "#{datastore}/content-ads/51dfc5690cf271c375c5a12d" },
130
+ 'user' => { 'href' => "#{datastore}/users/lakj35asdflkj1203va" }
131
+ }.to_json)
132
+
133
+ feedbacks = Feedback.includes_first_page(:user, campaign: [:entry, :user]).find(123)
134
+ expect(feedbacks.campaign.entry.name).to eq 'Casa Ferlin'
135
+ expect(feedbacks.campaign.user.name).to eq 'Mario'
136
+ expect(feedbacks.user.name).to eq 'Mario'
137
+ end
138
+
139
+ context 'include objects from known services' do
140
+ let(:stub_feedback_request) do
141
+ stub_request(:get, "#{datastore}/feedbacks")
142
+ .to_return(status: 200, body: {
143
+ items: [
144
+ {
145
+ 'href' => "#{datastore}/feedbacks/-Sc4_pYNpqfsudzhtivfkA",
146
+ 'entry' => {
147
+ 'href' => "#{datastore}/local-entries/lakj35asdflkj1203va"
148
+ }
149
+ }
150
+ ]
151
+ }.to_json)
152
+ end
153
+
154
+ let(:interceptor) { spy('interceptor') }
155
+
156
+ before do
157
+ class Entry < LHS::Record
158
+ endpoint '{+datastore}/local-entries/{id}'
159
+ end
160
+ LHC.config.interceptors = [interceptor]
161
+ end
162
+
163
+ it 'uses interceptors for included links from known services' do
164
+ stub_feedback_request
165
+ stub_entry_request
166
+ expect(Feedback.includes_first_page(:entry).where.first.entry.name).to eq 'Casa Ferlin'
167
+ expect(interceptor).to have_received(:before_request).twice
168
+ end
169
+ end
170
+
171
+ context 'includes not present in response' do
172
+ before do
173
+ class Parent < LHS::Record
174
+ endpoint '{+datastore}/local-parents'
175
+ endpoint '{+datastore}/local-parents/{id}'
176
+ end
177
+
178
+ class OptionalChild < LHS::Record
179
+ endpoint '{+datastore}/local-children/{id}'
180
+ end
181
+ end
182
+
183
+ it 'handles missing but included fields in single object response' do
184
+ stub_request(:get, "#{datastore}/local-parents/1")
185
+ .to_return(status: 200, body: {
186
+ 'href' => "#{datastore}/local-parents/1",
187
+ 'name' => 'RspecName'
188
+ }.to_json)
189
+
190
+ parent = Parent.includes_first_page(:optional_children).find(1)
191
+ expect(parent).not_to be nil
192
+ expect(parent.name).to eq 'RspecName'
193
+ expect(parent.optional_children).to be nil
194
+ end
195
+
196
+ it 'handles missing but included fields in collection response' do
197
+ stub_request(:get, "#{datastore}/local-parents")
198
+ .to_return(status: 200, body: {
199
+ items: [
200
+ {
201
+ 'href' => "#{datastore}/local-parents/1",
202
+ 'name' => 'RspecParent'
203
+ }, {
204
+ 'href' => "#{datastore}/local-parents/2",
205
+ 'name' => 'RspecParent2',
206
+ 'optional_child' => {
207
+ 'href' => "#{datastore}/local-children/1"
208
+ }
209
+ }
210
+ ]
211
+ }.to_json)
212
+
213
+ stub_request(:get, "#{datastore}/local-children/1")
214
+ .to_return(status: 200, body: {
215
+ href: "#{datastore}/local_children/1",
216
+ name: 'RspecOptionalChild1'
217
+ }.to_json)
218
+
219
+ child = Parent.includes_first_page(:optional_child).where[1].optional_child
220
+ expect(child).not_to be nil
221
+ expect(child.name).to eq 'RspecOptionalChild1'
222
+ end
223
+ end
224
+ end
225
+
226
+ context 'links pointing to nowhere' do
227
+ before do
228
+ class Feedback < LHS::Record
229
+ endpoint '{+datastore}/feedbacks'
230
+ endpoint '{+datastore}/feedbacks/{id}'
231
+ end
232
+
233
+ stub_request(:get, "#{datastore}/feedbacks/123")
234
+ .to_return(status: 200, body: {
235
+ 'href' => "#{datastore}/feedbacks/-Sc4_pYNpqfsudzhtivfkA",
236
+ 'campaign' => { 'href' => "#{datastore}/content-ads/51dfc5690cf271c375c5a12d" }
237
+ }.to_json)
238
+
239
+ stub_request(:get, "#{datastore}/content-ads/51dfc5690cf271c375c5a12d")
240
+ .to_return(status: 404)
241
+ end
242
+
243
+ it 'raises LHC::NotFound for links that cannot be included' do
244
+ expect(-> {
245
+ Feedback.includes_first_page(campaign: :entry).find(123)
246
+ }).to raise_error LHC::NotFound
247
+ end
248
+
249
+ it 'ignores LHC::NotFound for links that cannot be included if configured so with reference options' do
250
+ feedback = Feedback
251
+ .includes_first_page(campaign: :entry)
252
+ .references(campaign: { ignored_errors: [LHC::NotFound] })
253
+ .find(123)
254
+ expect(feedback.campaign._raw.keys.length).to eq 1
255
+ end
256
+ end
257
+
258
+ context 'modules' do
259
+ before do
260
+ module Services
261
+ class LocalEntry < LHS::Record
262
+ endpoint '{+datastore}/local-entries'
263
+ end
264
+
265
+ class Feedback < LHS::Record
266
+ endpoint '{+datastore}/feedbacks'
267
+ end
268
+ end
269
+ stub_request(:get, "http://local.ch/v2/feedbacks?id=123")
270
+ .to_return(body: [].to_json)
271
+ end
272
+
273
+ it 'works with modules' do
274
+ Services::Feedback.includes_first_page(campaign: :entry).find(123)
275
+ end
276
+ end
277
+
278
+ context 'arrays' do
279
+ before do
280
+ class Place < LHS::Record
281
+ endpoint '{+datastore}/place'
282
+ endpoint '{+datastore}/place/{id}'
283
+ end
284
+ end
285
+
286
+ let!(:place_request) do
287
+ stub_request(:get, "#{datastore}/place/1")
288
+ .to_return(body: {
289
+ 'relations' => [
290
+ { 'href' => "#{datastore}/place/relations/2" },
291
+ { 'href' => "#{datastore}/place/relations/3" }
292
+ ]
293
+ }.to_json)
294
+ end
295
+
296
+ let!(:relation_request_1) do
297
+ stub_request(:get, "#{datastore}/place/relations/2")
298
+ .to_return(body: { name: 'Category' }.to_json)
299
+ end
300
+
301
+ let!(:relation_request_2) do
302
+ stub_request(:get, "#{datastore}/place/relations/3")
303
+ .to_return(body: { name: 'ZeFrank' }.to_json)
304
+ end
305
+
306
+ it 'includes items of arrays' do
307
+ place = Place.includes_first_page(:relations).find(1)
308
+ expect(place.relations.first.name).to eq 'Category'
309
+ expect(place.relations[1].name).to eq 'ZeFrank'
310
+ end
311
+
312
+ context 'parallel with empty links' do
313
+ let!(:place_request_2) do
314
+ stub_request(:get, "#{datastore}/place/2")
315
+ .to_return(body: {
316
+ 'relations' => []
317
+ }.to_json)
318
+ end
319
+
320
+ it 'loads places in parallel and merges included data properly' do
321
+ place = Place.includes_first_page(:relations).find(2, 1)
322
+ expect(place[0].relations.empty?).to be true
323
+ expect(place[1].relations[0].name).to eq 'Category'
324
+ expect(place[1].relations[1].name).to eq 'ZeFrank'
325
+ end
326
+ end
327
+ end
328
+
329
+ context 'empty collections' do
330
+ it 'skips including empty collections' do
331
+ class Place < LHS::Record
332
+ endpoint '{+datastore}/place'
333
+ endpoint '{+datastore}/place/{id}'
334
+ end
335
+
336
+ stub_request(:get, "#{datastore}/place/1")
337
+ .to_return(body: {
338
+ 'available_products' => {
339
+ "url" => "#{datastore}/place/1/products",
340
+ "items" => []
341
+ }
342
+ }.to_json)
343
+
344
+ place = Place.includes_first_page(:available_products).find(1)
345
+ expect(place.available_products.empty?).to eq true
346
+ end
347
+ end
348
+
349
+ context 'extend items with arrays' do
350
+ it 'extends base items with arrays' do
351
+ class Place < LHS::Record
352
+ endpoint '{+datastore}/place'
353
+ endpoint '{+datastore}/place/{id}'
354
+ end
355
+
356
+ stub_request(:get, "#{datastore}/place/1")
357
+ .to_return(body: {
358
+ 'contracts' => {
359
+ 'items' => [{ 'href' => "#{datastore}/place/1/contacts/1" }]
360
+ }
361
+ }.to_json)
362
+
363
+ stub_request(:get, "#{datastore}/place/1/contacts/1")
364
+ .to_return(body: {
365
+ 'products' => { 'href' => "#{datastore}/place/1/contacts/1/products" }
366
+ }.to_json)
367
+
368
+ place = Place.includes_first_page(:contracts).find(1)
369
+ expect(place.contracts.first.products.href).to eq "#{datastore}/place/1/contacts/1/products"
370
+ end
371
+ end
372
+
373
+ context 'unexpanded response when requesting the included collection' do
374
+ before do
375
+ class Customer < LHS::Record
376
+ endpoint '{+datastore}/customer/{id}'
377
+ end
378
+ end
379
+
380
+ let!(:customer_request) do
381
+ stub_request(:get, "#{datastore}/customer/1")
382
+ .to_return(body: {
383
+ places: {
384
+ href: "#{datastore}/places"
385
+ }
386
+ }.to_json)
387
+ end
388
+
389
+ let!(:places_request) do
390
+ stub_request(:get, "#{datastore}/places")
391
+ .to_return(body: {
392
+ items: [{ href: "#{datastore}/places/1" }]
393
+ }.to_json)
394
+ end
395
+
396
+ let!(:place_request) do
397
+ stub_request(:get, "#{datastore}/places/1")
398
+ .to_return(body: {
399
+ name: 'Casa Ferlin'
400
+ }.to_json)
401
+ end
402
+
403
+ it 'loads the collection and the single items, if not already expanded' do
404
+ place = Customer.includes_first_page(:places).find(1).places.first
405
+ assert_requested(place_request)
406
+ expect(place.name).to eq 'Casa Ferlin'
407
+ end
408
+
409
+ context 'forwarding options' do
410
+ let!(:places_request) do
411
+ stub_request(:get, "#{datastore}/places")
412
+ .with(headers: { 'Authorization' => 'Bearer 123' })
413
+ .to_return(
414
+ body: {
415
+ items: [{ href: "#{datastore}/places/1" }]
416
+ }.to_json
417
+ )
418
+ end
419
+
420
+ let!(:place_request) do
421
+ stub_request(:get, "#{datastore}/places/1")
422
+ .with(headers: { 'Authorization' => 'Bearer 123' })
423
+ .to_return(
424
+ body: {
425
+ name: 'Casa Ferlin'
426
+ }.to_json
427
+ )
428
+ end
429
+
430
+ it 'forwards options used to expand those unexpanded items' do
431
+ place = Customer
432
+ .includes_first_page(:places)
433
+ .references(places: { headers: { 'Authorization' => 'Bearer 123' } })
434
+ .find(1)
435
+ .places.first
436
+ assert_requested(place_request)
437
+ expect(place.name).to eq 'Casa Ferlin'
438
+ end
439
+ end
440
+ end
441
+
442
+ context 'includes with options' do
443
+ before do
444
+ class Customer < LHS::Record
445
+ endpoint '{+datastore}/customers/{id}'
446
+ endpoint '{+datastore}/customers'
447
+ end
448
+
449
+ class Place < LHS::Record
450
+ endpoint '{+datastore}/places'
451
+ end
452
+
453
+ stub_request(:get, "#{datastore}/places?forwarded_params=123")
454
+ .to_return(body: {
455
+ 'items' => [{ id: 1 }]
456
+ }.to_json)
457
+ end
458
+
459
+ it 'forwards includes options to requests made for those includes' do
460
+ stub_request(:get, "#{datastore}/customers/1")
461
+ .to_return(body: {
462
+ 'places' => {
463
+ 'href' => "#{datastore}/places"
464
+ }
465
+ }.to_json)
466
+ customer = Customer
467
+ .includes_first_page(:places)
468
+ .references(places: { params: { forwarded_params: 123 } })
469
+ .find(1)
470
+ expect(customer.places.first.id).to eq 1
471
+ end
472
+
473
+ it 'is chain-able' do
474
+ stub_request(:get, "#{datastore}/customers?name=Steve")
475
+ .to_return(body: [
476
+ 'places' => {
477
+ 'href' => "#{datastore}/places"
478
+ }
479
+ ].to_json)
480
+ customers = Customer
481
+ .where(name: 'Steve')
482
+ .references(places: { params: { forwarded_params: 123 } })
483
+ .includes_first_page(:places)
484
+ expect(customers.first.places.first.id).to eq 1
485
+ end
486
+ end
487
+
488
+ context 'more complex examples' do
489
+ before do
490
+ class Place < LHS::Record
491
+ endpoint 'http://datastore/places/{id}'
492
+ end
493
+ end
494
+
495
+ it 'forwards complex references' do
496
+ stub_request(:get, "http://datastore/places/123?limit=1&forwarded_params=for_place")
497
+ .to_return(body: {
498
+ 'contracts' => {
499
+ 'href' => "http://datastore/places/123/contracts"
500
+ }
501
+ }.to_json)
502
+ stub_request(:get, "http://datastore/places/123/contracts?forwarded_params=for_contracts")
503
+ .to_return(body: {
504
+ href: "http://datastore/places/123/contracts?forwarded_params=for_contracts",
505
+ items: [
506
+ { product: { 'href' => "http://datastore/products/llo" } }
507
+ ]
508
+ }.to_json)
509
+ stub_request(:get, "http://datastore/products/llo?forwarded_params=for_product")
510
+ .to_return(body: {
511
+ 'href' => "http://datastore/products/llo",
512
+ 'name' => 'Local Logo'
513
+ }.to_json)
514
+ place = Place
515
+ .options(params: { forwarded_params: 'for_place' })
516
+ .includes_first_page(contracts: :product)
517
+ .references(
518
+ contracts: {
519
+ params: { forwarded_params: 'for_contracts' },
520
+ product: { params: { forwarded_params: 'for_product' } }
521
+ }
522
+ )
523
+ .find_by(id: '123')
524
+ expect(
525
+ place.contracts.first.product.name
526
+ ).to eq 'Local Logo'
527
+ end
528
+
529
+ it 'expands empty arrays' do
530
+ stub_request(:get, "http://datastore/places/123")
531
+ .to_return(body: {
532
+ 'contracts' => {
533
+ 'href' => "http://datastore/places/123/contracts"
534
+ }
535
+ }.to_json)
536
+ stub_request(:get, "http://datastore/places/123/contracts")
537
+ .to_return(body: {
538
+ href: "http://datastore/places/123/contracts",
539
+ items: []
540
+ }.to_json)
541
+ place = Place.includes_first_page(:contracts).find('123')
542
+ expect(place.contracts.collection?).to eq true
543
+ expect(
544
+ place.contracts.as_json
545
+ ).to eq('href' => 'http://datastore/places/123/contracts', 'items' => [])
546
+ expect(place.contracts.to_a).to eq([])
547
+ end
548
+ end
549
+
550
+ context 'include and merge arrays when calling find in parallel' do
551
+ before do
552
+ class Place < LHS::Record
553
+ endpoint 'http://datastore/places/{id}'
554
+ end
555
+ stub_request(:get, 'http://datastore/places/1')
556
+ .to_return(body: {
557
+ category_relations: [{ href: 'http://datastore/category/1' }, { href: 'http://datastore/category/2' }]
558
+ }.to_json)
559
+ stub_request(:get, 'http://datastore/places/2')
560
+ .to_return(body: {
561
+ category_relations: [{ href: 'http://datastore/category/2' }, { href: 'http://datastore/category/1' }]
562
+ }.to_json)
563
+ stub_request(:get, "http://datastore/category/1").to_return(body: { name: 'Food' }.to_json)
564
+ stub_request(:get, "http://datastore/category/2").to_return(body: { name: 'Drinks' }.to_json)
565
+ end
566
+
567
+ it 'includes and merges linked resources in case of an array of links' do
568
+ places = Place
569
+ .includes_first_page(:category_relations)
570
+ .find(1, 2)
571
+ expect(places[0].category_relations[0].name).to eq 'Food'
572
+ expect(places[1].category_relations[0].name).to eq 'Drinks'
573
+ end
574
+ end
575
+
576
+ context 'single href with array response' do
577
+ it 'extends base items with arrays' do
578
+ class Sector < LHS::Record
579
+ endpoint '{+datastore}/sectors'
580
+ endpoint '{+datastore}/sectors/{id}'
581
+ end
582
+
583
+ stub_request(:get, "#{datastore}/sectors")
584
+ .with(query: hash_including(key: 'my_service'))
585
+ .to_return(body: [
586
+ {
587
+ href: "#{datastore}/sectors/1",
588
+ services: {
589
+ href: "#{datastore}/sectors/1/services"
590
+ },
591
+ keys: [
592
+ {
593
+ key: 'my_service',
594
+ language: 'de'
595
+ }
596
+ ]
597
+ }
598
+ ].to_json)
599
+
600
+ stub_request(:get, "#{datastore}/sectors/1/services")
601
+ .to_return(body: [
602
+ {
603
+ href: "#{datastore}/services/s1",
604
+ price_in_cents: 9900,
605
+ key: 'my_service_service_1'
606
+ },
607
+ {
608
+ href: "#{datastore}/services/s2",
609
+ price_in_cents: 19900,
610
+ key: 'my_service_service_2'
611
+ }
612
+ ].to_json)
613
+
614
+ sector = Sector.includes_first_page(:services).find_by(key: 'my_service')
615
+ expect(sector.services.length).to eq 2
616
+ expect(sector.services.first.key).to eq 'my_service_service_1'
617
+ end
618
+ end
619
+
620
+ context 'include for POST/create' do
621
+
622
+ before do
623
+ class Record < LHS::Record
624
+ endpoint 'https://records'
625
+ end
626
+ stub_request(:post, 'https://records/')
627
+ .with(body: { color: 'blue' }.to_json)
628
+ .to_return(
629
+ body: {
630
+ color: 'blue',
631
+ alternative_categories: [
632
+ { href: 'https://categories/blue' }
633
+ ]
634
+ }.to_json
635
+ )
636
+ stub_request(:get, 'https://categories/blue')
637
+ .to_return(
638
+ body: {
639
+ name: 'blue'
640
+ }.to_json
641
+ )
642
+ end
643
+
644
+ it 'includes the resources from the post response' do
645
+ records = Record.includes_first_page(:alternative_categories).create(color: 'blue')
646
+ expect(records.alternative_categories.first.name).to eq 'blue'
647
+ end
648
+ end
649
+
650
+ context 'nested within another structure' do
651
+ before do
652
+ class Place < LHS::Record
653
+ endpoint 'https://places/{id}'
654
+ end
655
+ stub_request(:get, "https://places/1")
656
+ .to_return(body: {
657
+ customer: {
658
+ salesforce: {
659
+ href: 'https://salesforce/customers/1'
660
+ }
661
+ }
662
+ }.to_json)
663
+ end
664
+
665
+ let!(:nested_request) do
666
+ stub_request(:get, "https://salesforce/customers/1")
667
+ .to_return(body: {
668
+ name: 'Steve'
669
+ }.to_json)
670
+ end
671
+
672
+ it 'includes data that has been nested in an additional structure' do
673
+ place = Place.includes_first_page(customer: :salesforce).find(1)
674
+ expect(nested_request).to have_been_requested
675
+ expect(place.customer.salesforce.name).to eq 'Steve'
676
+ end
677
+
678
+ context 'included data has a configured record endpoint option' do
679
+ before do
680
+ class SalesforceCustomer < LHS::Record
681
+ endpoint 'https://salesforce/customers/{id}', headers: { 'Authorization': 'Bearer 123' }
682
+ end
683
+ end
684
+
685
+ let!(:nested_request) do
686
+ stub_request(:get, "https://salesforce/customers/1")
687
+ .with(headers: { 'Authorization' => 'Bearer 123' })
688
+ .to_return(body: {
689
+ name: 'Steve'
690
+ }.to_json)
691
+ end
692
+
693
+ it 'includes data that has been nested in an additional structure' do
694
+ place = Place.includes_first_page(customer: :salesforce).find(1)
695
+ expect(nested_request).to have_been_requested
696
+ expect(place.customer.salesforce.name).to eq 'Steve'
697
+ end
698
+ end
699
+ end
700
+
701
+ context 'include empty structures' do
702
+ before do
703
+ class Place < LHS::Record
704
+ endpoint 'https://places/{id}'
705
+ end
706
+ stub_request(:get, "https://places/1")
707
+ .to_return(body: {
708
+ id: '123'
709
+ }.to_json)
710
+ end
711
+
712
+ it 'skips includes when there is nothing and also does not raise an exception' do
713
+ expect(-> {
714
+ Place.includes_first_page(contracts: :product).find(1)
715
+ }).not_to raise_exception
716
+ end
717
+ end
718
+
719
+ context 'include partially empty structures' do
720
+ before do
721
+ class Place < LHS::Record
722
+ endpoint 'https://places/{id}'
723
+ end
724
+ stub_request(:get, "https://places/1")
725
+ .to_return(body: {
726
+ id: '123',
727
+ customer: {}
728
+ }.to_json)
729
+ end
730
+
731
+ it 'skips includes when there is nothing and also does not raise an exception' do
732
+ expect(-> {
733
+ Place.includes_first_page(customer: :salesforce).find(1)
734
+ }).not_to raise_exception
735
+ end
736
+ end
737
+ end