dhs 1.0.0

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.
Files changed (301) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/rubocop.yml +27 -0
  3. data/.github/workflows/test.yml +27 -0
  4. data/.gitignore +39 -0
  5. data/.rubocop.yml +186 -0
  6. data/.ruby-version +1 -0
  7. data/Gemfile +4 -0
  8. data/LICENSE +674 -0
  9. data/README.md +2807 -0
  10. data/Rakefile +26 -0
  11. data/dhs.gemspec +44 -0
  12. data/docs/accessing-data.png +0 -0
  13. data/lib/dhs.rb +67 -0
  14. data/lib/dhs/collection.rb +84 -0
  15. data/lib/dhs/complex.rb +158 -0
  16. data/lib/dhs/concerns/autoload_records.rb +57 -0
  17. data/lib/dhs/concerns/collection/handle_nested.rb +43 -0
  18. data/lib/dhs/concerns/collection/internal_collection.rb +74 -0
  19. data/lib/dhs/concerns/configuration.rb +20 -0
  20. data/lib/dhs/concerns/data/becomes.rb +18 -0
  21. data/lib/dhs/concerns/data/equality.rb +14 -0
  22. data/lib/dhs/concerns/data/extend.rb +87 -0
  23. data/lib/dhs/concerns/data/json.rb +14 -0
  24. data/lib/dhs/concerns/data/to_hash.rb +14 -0
  25. data/lib/dhs/concerns/inspect.rb +70 -0
  26. data/lib/dhs/concerns/is_href.rb +15 -0
  27. data/lib/dhs/concerns/item/destroy.rb +38 -0
  28. data/lib/dhs/concerns/item/endpoint_lookup.rb +27 -0
  29. data/lib/dhs/concerns/item/save.rb +55 -0
  30. data/lib/dhs/concerns/item/update.rb +50 -0
  31. data/lib/dhs/concerns/item/validation.rb +61 -0
  32. data/lib/dhs/concerns/o_auth.rb +25 -0
  33. data/lib/dhs/concerns/option_blocks.rb +26 -0
  34. data/lib/dhs/concerns/proxy/accessors.rb +132 -0
  35. data/lib/dhs/concerns/proxy/create.rb +45 -0
  36. data/lib/dhs/concerns/proxy/link.rb +25 -0
  37. data/lib/dhs/concerns/proxy/problems.rb +27 -0
  38. data/lib/dhs/concerns/record/attribute_assignment.rb +25 -0
  39. data/lib/dhs/concerns/record/batch.rb +40 -0
  40. data/lib/dhs/concerns/record/chainable.rb +465 -0
  41. data/lib/dhs/concerns/record/configuration.rb +103 -0
  42. data/lib/dhs/concerns/record/create.rb +24 -0
  43. data/lib/dhs/concerns/record/custom_setters.rb +22 -0
  44. data/lib/dhs/concerns/record/destroy.rb +18 -0
  45. data/lib/dhs/concerns/record/endpoints.rb +108 -0
  46. data/lib/dhs/concerns/record/equality.rb +14 -0
  47. data/lib/dhs/concerns/record/find.rb +86 -0
  48. data/lib/dhs/concerns/record/find_by.rb +38 -0
  49. data/lib/dhs/concerns/record/first.rb +20 -0
  50. data/lib/dhs/concerns/record/href_for.rb +19 -0
  51. data/lib/dhs/concerns/record/last.rb +27 -0
  52. data/lib/dhs/concerns/record/mapping.rb +25 -0
  53. data/lib/dhs/concerns/record/merge.rb +26 -0
  54. data/lib/dhs/concerns/record/model.rb +23 -0
  55. data/lib/dhs/concerns/record/pagination.rb +49 -0
  56. data/lib/dhs/concerns/record/provider.rb +23 -0
  57. data/lib/dhs/concerns/record/relations.rb +26 -0
  58. data/lib/dhs/concerns/record/request.rb +581 -0
  59. data/lib/dhs/concerns/record/scope.rb +25 -0
  60. data/lib/dhs/concerns/record/tracing.rb +24 -0
  61. data/lib/dhs/concerns/record/update.rb +17 -0
  62. data/lib/dhs/config.rb +24 -0
  63. data/lib/dhs/data.rb +180 -0
  64. data/lib/dhs/endpoint.rb +12 -0
  65. data/lib/dhs/interceptors/auto_oauth/interceptor.rb +33 -0
  66. data/lib/dhs/interceptors/auto_oauth/thread_registry.rb +18 -0
  67. data/lib/dhs/interceptors/extended_rollbar/handler.rb +40 -0
  68. data/lib/dhs/interceptors/extended_rollbar/interceptor.rb +20 -0
  69. data/lib/dhs/interceptors/extended_rollbar/thread_registry.rb +19 -0
  70. data/lib/dhs/interceptors/request_cycle_cache/interceptor.rb +41 -0
  71. data/lib/dhs/interceptors/request_cycle_cache/thread_registry.rb +18 -0
  72. data/lib/dhs/item.rb +59 -0
  73. data/lib/dhs/pagination/base.rb +90 -0
  74. data/lib/dhs/pagination/link.rb +21 -0
  75. data/lib/dhs/pagination/offset.rb +22 -0
  76. data/lib/dhs/pagination/page.rb +18 -0
  77. data/lib/dhs/pagination/start.rb +22 -0
  78. data/lib/dhs/pagination/total_pages.rb +9 -0
  79. data/lib/dhs/problems/base.rb +113 -0
  80. data/lib/dhs/problems/errors.rb +69 -0
  81. data/lib/dhs/problems/nested/base.rb +54 -0
  82. data/lib/dhs/problems/nested/errors.rb +16 -0
  83. data/lib/dhs/problems/nested/warnings.rb +15 -0
  84. data/lib/dhs/problems/warnings.rb +24 -0
  85. data/lib/dhs/proxy.rb +69 -0
  86. data/lib/dhs/railtie.rb +34 -0
  87. data/lib/dhs/record.rb +112 -0
  88. data/lib/dhs/rspec.rb +10 -0
  89. data/lib/dhs/test/stubbable_records.rb +34 -0
  90. data/lib/dhs/unprocessable.rb +6 -0
  91. data/lib/dhs/version.rb +5 -0
  92. data/script/ci/build.sh +18 -0
  93. data/spec/auto_oauth_spec.rb +163 -0
  94. data/spec/autoloading_spec.rb +45 -0
  95. data/spec/collection/accessors_spec.rb +31 -0
  96. data/spec/collection/collection_items_spec.rb +44 -0
  97. data/spec/collection/configurable_spec.rb +43 -0
  98. data/spec/collection/delegate_spec.rb +21 -0
  99. data/spec/collection/enumerable_spec.rb +27 -0
  100. data/spec/collection/href_spec.rb +17 -0
  101. data/spec/collection/meta_data_spec.rb +58 -0
  102. data/spec/collection/respond_to_spec.rb +20 -0
  103. data/spec/collection/to_a_spec.rb +34 -0
  104. data/spec/collection/to_ary_spec.rb +40 -0
  105. data/spec/collection/without_object_items_spec.rb +27 -0
  106. data/spec/complex/reduce_spec.rb +202 -0
  107. data/spec/concerns/record/request_spec.rb +78 -0
  108. data/spec/data/collection_spec.rb +56 -0
  109. data/spec/data/equality_spec.rb +23 -0
  110. data/spec/data/inspect_spec.rb +88 -0
  111. data/spec/data/is_item_or_collection_spec.rb +40 -0
  112. data/spec/data/item_spec.rb +106 -0
  113. data/spec/data/merge_spec.rb +27 -0
  114. data/spec/data/parent_spec.rb +39 -0
  115. data/spec/data/raw_spec.rb +48 -0
  116. data/spec/data/respond_to_spec.rb +26 -0
  117. data/spec/data/root_spec.rb +25 -0
  118. data/spec/data/select_spec.rb +27 -0
  119. data/spec/data/to_ary_spec.rb +28 -0
  120. data/spec/data/to_json_spec.rb +68 -0
  121. data/spec/dummy/Rakefile +8 -0
  122. data/spec/dummy/app/assets/images/.keep +0 -0
  123. data/spec/dummy/app/assets/javascripts/application.js +13 -0
  124. data/spec/dummy/app/assets/stylesheets/application.css +15 -0
  125. data/spec/dummy/app/controllers/application_controller.rb +26 -0
  126. data/spec/dummy/app/controllers/automatic_authentication_controller.rb +29 -0
  127. data/spec/dummy/app/controllers/concerns/.keep +0 -0
  128. data/spec/dummy/app/controllers/error_handling_with_chains_controller.rb +36 -0
  129. data/spec/dummy/app/controllers/extended_rollbar_controller.rb +10 -0
  130. data/spec/dummy/app/controllers/option_blocks_controller.rb +15 -0
  131. data/spec/dummy/app/controllers/request_cycle_cache_controller.rb +27 -0
  132. data/spec/dummy/app/helpers/application_helper.rb +4 -0
  133. data/spec/dummy/app/mailers/.keep +0 -0
  134. data/spec/dummy/app/models/.keep +0 -0
  135. data/spec/dummy/app/models/concerns/.keep +0 -0
  136. data/spec/dummy/app/models/concerns/dummy_customer/some_concern.rb +9 -0
  137. data/spec/dummy/app/models/dummy_customer.rb +7 -0
  138. data/spec/dummy/app/models/dummy_record.rb +6 -0
  139. data/spec/dummy/app/models/dummy_record_with_auto_oauth_provider.rb +6 -0
  140. data/spec/dummy/app/models/dummy_record_with_multiple_oauth_providers1.rb +7 -0
  141. data/spec/dummy/app/models/dummy_record_with_multiple_oauth_providers2.rb +7 -0
  142. data/spec/dummy/app/models/dummy_record_with_multiple_providers_per_endpoint.rb +6 -0
  143. data/spec/dummy/app/models/dummy_record_with_oauth.rb +7 -0
  144. data/spec/dummy/app/models/dummy_user.rb +6 -0
  145. data/spec/dummy/app/models/providers/customer_system.rb +7 -0
  146. data/spec/dummy/app/models/providers/internal_services.rb +7 -0
  147. data/spec/dummy/app/views/error_handling_with_chains/error.html.erb +1 -0
  148. data/spec/dummy/app/views/error_handling_with_chains/show.html.erb +3 -0
  149. data/spec/dummy/app/views/form_for.html.erb +5 -0
  150. data/spec/dummy/app/views/layouts/application.html.erb +14 -0
  151. data/spec/dummy/bin/bundle +5 -0
  152. data/spec/dummy/bin/rails +6 -0
  153. data/spec/dummy/bin/rake +6 -0
  154. data/spec/dummy/config.ru +6 -0
  155. data/spec/dummy/config/application.rb +16 -0
  156. data/spec/dummy/config/boot.rb +7 -0
  157. data/spec/dummy/config/environment.rb +7 -0
  158. data/spec/dummy/config/environments/development.rb +36 -0
  159. data/spec/dummy/config/environments/production.rb +77 -0
  160. data/spec/dummy/config/environments/test.rb +40 -0
  161. data/spec/dummy/config/initializers/assets.rb +10 -0
  162. data/spec/dummy/config/initializers/backtrace_silencers.rb +9 -0
  163. data/spec/dummy/config/initializers/cookies_serializer.rb +5 -0
  164. data/spec/dummy/config/initializers/dhs.rb +5 -0
  165. data/spec/dummy/config/initializers/filter_parameter_logging.rb +6 -0
  166. data/spec/dummy/config/initializers/inflections.rb +18 -0
  167. data/spec/dummy/config/initializers/mime_types.rb +6 -0
  168. data/spec/dummy/config/initializers/rollbar.rb +9 -0
  169. data/spec/dummy/config/initializers/session_store.rb +5 -0
  170. data/spec/dummy/config/initializers/wrap_parameters.rb +11 -0
  171. data/spec/dummy/config/locales/en.yml +23 -0
  172. data/spec/dummy/config/routes.rb +27 -0
  173. data/spec/dummy/config/secrets.yml +22 -0
  174. data/spec/dummy/lib/assets/.keep +0 -0
  175. data/spec/dummy/public/404.html +67 -0
  176. data/spec/dummy/public/422.html +67 -0
  177. data/spec/dummy/public/500.html +66 -0
  178. data/spec/dummy/public/favicon.ico +0 -0
  179. data/spec/endpoint/for_url_spec.rb +27 -0
  180. data/spec/extended_rollbar_spec.rb +67 -0
  181. data/spec/item/access_errors_spec.rb +31 -0
  182. data/spec/item/accessors_spec.rb +21 -0
  183. data/spec/item/add_error_spec.rb +21 -0
  184. data/spec/item/becomes_spec.rb +38 -0
  185. data/spec/item/blacklisted_keywords_spec.rb +28 -0
  186. data/spec/item/delegate_spec.rb +32 -0
  187. data/spec/item/destroy_spec.rb +113 -0
  188. data/spec/item/dig_spec.rb +29 -0
  189. data/spec/item/error_codes_spec.rb +55 -0
  190. data/spec/item/errors_spec.rb +324 -0
  191. data/spec/item/fetch_spec.rb +39 -0
  192. data/spec/item/getter_spec.rb +24 -0
  193. data/spec/item/internal_data_structure_spec.rb +37 -0
  194. data/spec/item/map_spec.rb +46 -0
  195. data/spec/item/nested_errors_spec.rb +27 -0
  196. data/spec/item/partial_update_spec.rb +168 -0
  197. data/spec/item/respond_to_spec.rb +31 -0
  198. data/spec/item/save_spec.rb +115 -0
  199. data/spec/item/setter_spec.rb +44 -0
  200. data/spec/item/translate_errors_spec.rb +257 -0
  201. data/spec/item/update_spec.rb +161 -0
  202. data/spec/item/validation_spec.rb +131 -0
  203. data/spec/item/warning_codes_spec.rb +55 -0
  204. data/spec/item/warnings_spec.rb +51 -0
  205. data/spec/option_blocks/ensure_reset_between_requests_spec.rb +23 -0
  206. data/spec/option_blocks/main_spec.rb +54 -0
  207. data/spec/pagination/link/current_page_spec.rb +19 -0
  208. data/spec/pagination/link/pages_left_spec.rb +36 -0
  209. data/spec/pagination/link/parallel_spec.rb +19 -0
  210. data/spec/pagination/link/total_spec.rb +45 -0
  211. data/spec/pagination/offset/pages_left_spec.rb +26 -0
  212. data/spec/pagination/parameters_spec.rb +59 -0
  213. data/spec/pagination/total_pages_spec.rb +51 -0
  214. data/spec/proxy/create_sub_resource_spec.rb +182 -0
  215. data/spec/proxy/load_spec.rb +75 -0
  216. data/spec/proxy/record_identification_spec.rb +35 -0
  217. data/spec/rails_helper.rb +13 -0
  218. data/spec/record/all_spec.rb +133 -0
  219. data/spec/record/attribute_assignment_spec.rb +28 -0
  220. data/spec/record/build_spec.rb +26 -0
  221. data/spec/record/cast_nested_data_spec.rb +80 -0
  222. data/spec/record/compact_spec.rb +93 -0
  223. data/spec/record/create_spec.rb +160 -0
  224. data/spec/record/creation_failed_spec.rb +55 -0
  225. data/spec/record/custom_setters_spec.rb +42 -0
  226. data/spec/record/definitions_spec.rb +29 -0
  227. data/spec/record/destroy_spec.rb +38 -0
  228. data/spec/record/dig_configuration_spec.rb +75 -0
  229. data/spec/record/dup_spec.rb +20 -0
  230. data/spec/record/endpoint_inheritance_spec.rb +65 -0
  231. data/spec/record/endpoint_options_spec.rb +51 -0
  232. data/spec/record/endpoint_priorities_spec.rb +24 -0
  233. data/spec/record/endpoints_spec.rb +96 -0
  234. data/spec/record/equality_spec.rb +27 -0
  235. data/spec/record/error_handling_integration_spec.rb +25 -0
  236. data/spec/record/error_handling_spec.rb +40 -0
  237. data/spec/record/expanded_spec.rb +69 -0
  238. data/spec/record/fetch_spec.rb +40 -0
  239. data/spec/record/find_by_chains_spec.rb +21 -0
  240. data/spec/record/find_by_spec.rb +76 -0
  241. data/spec/record/find_each_spec.rb +57 -0
  242. data/spec/record/find_in_batches_spec.rb +122 -0
  243. data/spec/record/find_in_parallel_spec.rb +67 -0
  244. data/spec/record/find_spec.rb +103 -0
  245. data/spec/record/first_spec.rb +39 -0
  246. data/spec/record/force_merge_spec.rb +55 -0
  247. data/spec/record/handle_includes_errors_spec.rb +33 -0
  248. data/spec/record/has_many_spec.rb +118 -0
  249. data/spec/record/has_one_spec.rb +114 -0
  250. data/spec/record/href_for_spec.rb +24 -0
  251. data/spec/record/ignore_errors_spec.rb +137 -0
  252. data/spec/record/immutable_chains_spec.rb +22 -0
  253. data/spec/record/includes_after_expansion_spec.rb +70 -0
  254. data/spec/record/includes_expanded_spec.rb +37 -0
  255. data/spec/record/includes_first_page_spec.rb +738 -0
  256. data/spec/record/includes_missing_spec.rb +57 -0
  257. data/spec/record/includes_spec.rb +690 -0
  258. data/spec/record/includes_warning_spec.rb +46 -0
  259. data/spec/record/item_key_spec.rb +81 -0
  260. data/spec/record/items_created_key_configuration_spec.rb +37 -0
  261. data/spec/record/last_spec.rb +64 -0
  262. data/spec/record/loading_twice_spec.rb +19 -0
  263. data/spec/record/mapping_spec.rb +103 -0
  264. data/spec/record/model_name_spec.rb +17 -0
  265. data/spec/record/new_spec.rb +106 -0
  266. data/spec/record/options_getter_spec.rb +25 -0
  267. data/spec/record/options_spec.rb +164 -0
  268. data/spec/record/paginatable_collection_spec.rb +360 -0
  269. data/spec/record/pagination_chain_spec.rb +101 -0
  270. data/spec/record/pagination_links_spec.rb +72 -0
  271. data/spec/record/pagination_spec.rb +71 -0
  272. data/spec/record/persisted_spec.rb +52 -0
  273. data/spec/record/provider_spec.rb +40 -0
  274. data/spec/record/references_spec.rb +95 -0
  275. data/spec/record/relation_caching_spec.rb +120 -0
  276. data/spec/record/reload_by_id_spec.rb +43 -0
  277. data/spec/record/reload_spec.rb +64 -0
  278. data/spec/record/request_spec.rb +90 -0
  279. data/spec/record/save_spec.rb +40 -0
  280. data/spec/record/scope_chains_spec.rb +39 -0
  281. data/spec/record/select_spec.rb +17 -0
  282. data/spec/record/to_ary_spec.rb +65 -0
  283. data/spec/record/to_hash_spec.rb +22 -0
  284. data/spec/record/to_json_spec.rb +22 -0
  285. data/spec/record/tracing_spec.rb +149 -0
  286. data/spec/record/update_spec.rb +61 -0
  287. data/spec/record/where_chains_spec.rb +57 -0
  288. data/spec/record/where_spec.rb +62 -0
  289. data/spec/record/where_values_hash_spec.rb +32 -0
  290. data/spec/request_cycle_cache_spec.rb +106 -0
  291. data/spec/require_dhs_spec.rb +9 -0
  292. data/spec/spec_helper.rb +6 -0
  293. data/spec/stubs/all_spec.rb +69 -0
  294. data/spec/support/fixtures/json/feedback.json +11 -0
  295. data/spec/support/fixtures/json/feedbacks.json +174 -0
  296. data/spec/support/fixtures/json/localina_content_ad.json +23 -0
  297. data/spec/support/load_json.rb +5 -0
  298. data/spec/support/request_cycle_cache.rb +10 -0
  299. data/spec/support/reset.rb +67 -0
  300. data/spec/views/form_for_spec.rb +20 -0
  301. metadata +783 -0
data/README.md ADDED
@@ -0,0 +1,2807 @@
1
+ DHS
2
+ ===
3
+
4
+ DHS ia a Rails-Gem, providing an ActiveRecord like interface to access HTTP-JSON-Services from Rails Applications. Special features provided by this gem are: Multiple endpoint configuration per resource, active-record-like query-chains, scopes, error handling, relations, request cycle cache, batch processing, including linked resources (hypermedia), data maps (data accessing), nested-resource handling, ActiveModel like backend validation conversion, formbuilder-compatible, three types of pagination support, service configuration per resource, kaminari-support and much more.
5
+
6
+ DHS uses [DHC](//github.com/DePayFi/DHC) for advanced http requests.
7
+
8
+ ## Quickstart
9
+
10
+ ```
11
+ gem 'dhs'
12
+ ```
13
+
14
+ ```ruby
15
+ # config/initializers/dhc.rb
16
+
17
+ DHC.configure do |config|
18
+ config.placeholder(:service, 'https://my.service.dev')
19
+ end
20
+ ```
21
+
22
+ ```ruby
23
+ # app/models/record.rb
24
+
25
+ class Record < DHS::Record
26
+
27
+ endpoint '{+service}/records'
28
+ endpoint '{+service}/records/{id}'
29
+
30
+ end
31
+ ```
32
+
33
+ ```ruby
34
+ # app/controllers/application_controller.rb
35
+
36
+ record = Record.find_by(email: 'somebody@mail.com')
37
+ record.review # "Lunch was great
38
+ ```
39
+
40
+ ## Installation/Startup checklist
41
+
42
+ - [x] Install DHS gem, preferably via `Gemfile`
43
+ - [x] Configure [DHC](https://github.com/DePayFi/dhc) via an `config/initializers/dhc.rb` (See: https://github.com/DePayFi/dhc#configuration)
44
+ - [x] Add `DHC::Caching` to `DHC.config.interceptors` to facilitate DHS' [Request Cycle Cache](#request-cycle-cache)
45
+ - [x] Store all DHS::Records in `app/models` for autoload/preload reasons
46
+ - [x] Request data from services via `DHS` from within your rails controllers
47
+
48
+ ## Record
49
+
50
+ ### Endpoints
51
+
52
+ > Endpoint, the entry point to a service, a process, or a queue or topic destination in service-oriented architecture
53
+
54
+ Start a record with configuring one or multiple endpoints.
55
+
56
+ ```ruby
57
+ # app/models/record.rb
58
+
59
+ class Record < DHS::Record
60
+
61
+ endpoint '{+service}/records'
62
+ endpoint '{+service}/records/{id}'
63
+ endpoint '{+service}/accociation/{accociation_id}/records'
64
+ endpoint '{+service}/accociation/{accociation_id}/records/{id}'
65
+
66
+ end
67
+ ```
68
+
69
+ You can also add request options to be used with configured endpoints:
70
+
71
+ ```ruby
72
+ # app/models/record.rb
73
+
74
+ class Record < DHS::Record
75
+
76
+ endpoint '{+service}/records', auth: { bearer: -> { access_token } }
77
+ endpoint '{+service}/records/{id}', auth: { bearer: -> { access_token } }
78
+
79
+ end
80
+ ```
81
+
82
+ -> Check [DHC](https://github.com/DePayFi/dhc) for more information about request options
83
+
84
+ #### Configure endpoint hosts
85
+
86
+ It's common practice to use different hosts accross different environments in a service-oriented architecture.
87
+
88
+ Use [DHC placeholders](https://github.com/DePayFi/dhc#configuring-placeholders) to configure different hosts per environment:
89
+
90
+ ```ruby
91
+ # config/initializers/dhc.rb
92
+
93
+ DHC.configure do |config|
94
+ config.placeholder(:search, ENV['SEARCH'])
95
+ end
96
+ ```
97
+
98
+ ```ruby
99
+ # app/models/record.rb
100
+
101
+ class Record < DHS::Record
102
+
103
+ endpoint '{+search}/api/search.json'
104
+
105
+ end
106
+ ```
107
+
108
+ **DON'T!**
109
+
110
+ Please DO NOT mix host placeholders with and endpoint's resource path, as otherwise DHS will not work properly.
111
+
112
+ ```ruby
113
+ # config/initializers/dhc.rb
114
+
115
+ DHC.configure do |config|
116
+ config.placeholder(:search, 'http://tel.search.ch/api/search.json')
117
+ end
118
+ ```
119
+
120
+ ```ruby
121
+ # app/models/record.rb
122
+
123
+ class Record < DHS::Record
124
+
125
+ endpoint '{+search}'
126
+
127
+ end
128
+ ```
129
+
130
+ #### Endpoint Priorities
131
+
132
+ DHS uses endpoint configurations to determine what endpoint to use when data is requested, in a similiar way, routes are identified in Rails to map requests to controllers.
133
+
134
+ If they are ambiguous, DHS will always use the first one found:
135
+
136
+ ```ruby
137
+ # app/models/record.rb
138
+
139
+ class Record < DHS::Record
140
+
141
+ endpoint '{+service}/records'
142
+ endpoint '{+service}/bananas'
143
+
144
+ end
145
+ ```
146
+
147
+ ```ruby
148
+ # app/controllers/some_controller.rb
149
+
150
+ Record.fetch
151
+ ```
152
+ ```
153
+ GET https://service.example.com/records
154
+ ```
155
+
156
+ **Be aware that, if you configure ambigious endpoints accross multiple classes, the order of things is not deteministic. Ambigious endpoints accross multiple classes need to be avoided.**
157
+
158
+ ### Provider
159
+
160
+ Providers in DHS allow you to group shared endpoint options under a common provider.
161
+
162
+ ```ruby
163
+ # app/models/provider/base_record.rb
164
+
165
+ module Provider
166
+ class BaseRecord < DHS::Record
167
+ provider params: { api_key: 123 }
168
+ end
169
+ end
170
+ ```
171
+
172
+ Now every record, part of that particular provider can inherit the provider's `BaseRecord`.
173
+
174
+ ```ruby
175
+ # app/models/provider/account.rb
176
+
177
+ module Provider
178
+ class Account < BaseRecord
179
+ endpoint '{+host}/records'
180
+ endpoint '{+host}/records/{id}'
181
+ end
182
+ end
183
+ ```
184
+
185
+ ```ruby
186
+ # app/controllers/some_controller.rb
187
+
188
+ Provider::Account.find(1)
189
+ ```
190
+ ```
191
+ GET https://provider/records/1?api_key=123
192
+ ```
193
+
194
+ And requests made via those provider records apply the common provider options.
195
+
196
+ ### Record inheritance
197
+
198
+ You can inherit from previously defined records and also inherit endpoints that way:
199
+
200
+ ```ruby
201
+ # app/models/base.rb
202
+
203
+ class Base < DHS::Record
204
+ endpoint '{+service}/records/{id}'
205
+ end
206
+ ```
207
+
208
+ ```ruby
209
+ # app/models/record.rb
210
+
211
+ class Record < Base
212
+ end
213
+ ```
214
+
215
+ ```ruby
216
+ # app/controllers/some_controller.rb
217
+
218
+ Record.find(1)
219
+ ```
220
+ ```
221
+ GET https://service.example.com/records/1
222
+ ```
223
+
224
+ ### Find multiple records
225
+
226
+ #### fetch
227
+
228
+ In case you want to just fetch the records endpoint, without applying any further queries or want to handle pagination, you can simply call `fetch`:
229
+
230
+ ```ruby
231
+ # app/controllers/some_controller.rb
232
+
233
+ records = Record.fetch
234
+
235
+ ```
236
+ ```
237
+ GET https://service.example.com/records
238
+ ```
239
+
240
+ #### where
241
+
242
+ You can query a service for records by using `where`:
243
+
244
+ ```ruby
245
+ # app/controllers/some_controller.rb
246
+
247
+ Record.where(color: 'blue')
248
+
249
+ ```
250
+ ```
251
+ GET https://service.example.com/records?color=blue
252
+ ```
253
+
254
+ If the provided parameter – `color: 'blue'` in this case – is not part of the endpoint path, it will be added as query parameter.
255
+
256
+ ```ruby
257
+ # app/controllers/some_controller.rb
258
+
259
+ Record.where(accociation_id: '12345')
260
+
261
+ ```
262
+ ```
263
+ GET https://service.example.com/accociation/12345/records
264
+ ```
265
+
266
+ If the provided parameter – `accociation_id` in this case – is part of the endpoint path, it will be injected into the path.
267
+
268
+ You can also provide hrefs to fetch multiple records:
269
+
270
+ ```ruby
271
+ # app/controllers/some_controller.rb
272
+
273
+ Record.where('https://service.example.com/accociation/12345/records')
274
+
275
+ ```
276
+ ```
277
+ GET https://service.example.com/accociation/12345/records
278
+ ```
279
+
280
+
281
+ #### Reuse/Dry where statements: Use scopes
282
+
283
+ In order to reuse/dry where statements organize them in scopes:
284
+
285
+ ```ruby
286
+ # app/models/record.rb
287
+
288
+ class Record < DHS::Record
289
+
290
+ endpoint '{+service}/records'
291
+ endpoint '{+service}/records/{id}'
292
+
293
+ scope :blue, -> { where(color: 'blue') }
294
+ scope :available, ->(state) { where(available: state) }
295
+
296
+ end
297
+ ```
298
+
299
+ ```ruby
300
+ # app/controllers/some_controller.rb
301
+
302
+ records = Record.blue.available(true)
303
+ ```
304
+ ```
305
+ GET https://service.example.com/records?color=blue&available=true
306
+ ```
307
+
308
+ #### all
309
+
310
+ You can fetch all remote records by using `all`. Pagination will be performed automatically (See: [Record pagination](#record-pagination))
311
+
312
+ ```ruby
313
+ # app/controllers/some_controller.rb
314
+
315
+ records = Record.all
316
+
317
+ ```
318
+ ```
319
+ GET https://service.example.com/records?limit=100
320
+ GET https://service.example.com/records?limit=100&offset=100
321
+ GET https://service.example.com/records?limit=100&offset=200
322
+ ```
323
+
324
+ ```ruby
325
+ # app/controllers/some_controller.rb
326
+
327
+ records.size # 300
328
+
329
+ ```
330
+
331
+ #### all with unpaginated endpoints
332
+
333
+ In case your record endpoints are not implementing any pagination, configure it to be `paginated: false`. Pagination will not be performed automatically in those cases:
334
+
335
+ ```ruby
336
+ # app/models/record.rb
337
+
338
+ class Record < DHS::Record
339
+ configuration paginated: false
340
+ end
341
+
342
+ ```
343
+
344
+ ```ruby
345
+ # app/controllers/some_controller.rb
346
+
347
+ records = Record.all
348
+
349
+ ```
350
+ ```
351
+ GET https://service.example.com/records
352
+ ```
353
+
354
+ #### Retrieve the amount of a collection of items: count vs. length
355
+
356
+ The different behavior of `count` and `length` is based on ActiveRecord's behavior.
357
+
358
+ `count` The total number of items available remotly via the provided endpoint/api, communicated via pagination meta data.
359
+
360
+ `length` The number of items already loaded from the endpoint/api and kept in memmory right now. In case of a paginated endpoint this can differ to what `count` returns, as it depends on how many pages have been loaded already.
361
+
362
+ ### Find single records
363
+
364
+ #### find
365
+
366
+ `find` finds a unique record by unique identifier (usually `id` or `href`). If no record is found an error is raised.
367
+
368
+ ```ruby
369
+ Record.find(123)
370
+ ```
371
+ ```
372
+ GET https://service.example.com/records/123
373
+ ```
374
+
375
+ ```ruby
376
+ Record.find('https://anotherservice.example.com/records/123')
377
+ ```
378
+ ```
379
+ GET https://anotherservice.example.com/records/123
380
+ ```
381
+
382
+ `find` can also be used to find a single unique record with parameters:
383
+
384
+ ```ruby
385
+ Record.find(another_identifier: 456)
386
+ ```
387
+ ```
388
+ GET https://service.example.com/records?another_identifier=456
389
+ ```
390
+
391
+ You can also fetch multiple records by `id` in parallel:
392
+
393
+ ```ruby
394
+ Record.find(1, 2, 3)
395
+ ```
396
+ ```
397
+ # In parallel:
398
+ GET https://service.example.com/records/1
399
+ GET https://service.example.com/records/2
400
+ GET https://service.example.com/records/3
401
+ ```
402
+
403
+ #### find_by
404
+
405
+ `find_by` finds the first record matching the specified conditions. If no record is found, `nil` is returned.
406
+
407
+ `find_by!` raises `DHC::NotFound` if nothing was found.
408
+
409
+ ```ruby
410
+ Record.find_by(color: 'blue')
411
+ ```
412
+ ```
413
+ GET https://service.example.com/records?color=blue
414
+ ```
415
+
416
+ #### first
417
+
418
+ `first` is an alias for finding the first record without parameters. If no record is found, `nil` is returned.
419
+
420
+ `first!` raises `DHC::NotFound` if nothing was found.
421
+
422
+ ```ruby
423
+ Record.first
424
+ ```
425
+ ```
426
+ GET https://service.example.com/records?limit=1
427
+ ```
428
+
429
+ `first` can also be used with options:
430
+
431
+ ```ruby
432
+ Record.first(params: { color: :blue })
433
+ ```
434
+ ```
435
+ GET https://service.example.com/records?color=blue&limit=1
436
+ ```
437
+
438
+ #### last
439
+
440
+ `last` is an alias for finding the last record without parameters. If no record is found, `nil` is returned.
441
+
442
+ `last!` raises `DHC::NotFound` if nothing was found.
443
+
444
+ ```ruby
445
+ Record.last
446
+ ```
447
+
448
+ `last` can also be used with options:
449
+
450
+ ```ruby
451
+ Record.last(params: { color: :blue })
452
+ ```
453
+
454
+ ### Work with retrieved data
455
+
456
+ After fetching [single](#find-single-records) or [multiple](#find-multiple-records) records you can navigate the received data with ease:
457
+
458
+ ```ruby
459
+ records = Record.where(color: 'blue')
460
+ records.length # 4
461
+ records.count # 400
462
+ record = records.first
463
+ record.type # 'Business'
464
+ record[:type] # 'Business'
465
+ record['type'] # 'Business'
466
+ ```
467
+
468
+ #### Automatic detection/conversion of collections
469
+
470
+ How to configure endpoints for automatic collection detection?
471
+
472
+ DHS detects automatically if the responded data is a single business object or a set of business objects (collection).
473
+
474
+ Conventionally, when the responds contains an `items` key `{ items: [] }` it's treated as a collection, but also if the responds contains a plain raw array: `[{ href: '' }]` it's also treated as a collection.
475
+
476
+ If you need to configure the attribute of the response providing the collection, configure `items_key` as explained here: [Determine collections from the response body](#determine-collections-from-the-response-body)
477
+
478
+ #### Map complex data for easy access
479
+
480
+ To influence how data is accessed, simply create methods inside your Record to access complex data structures:
481
+
482
+ ```ruby
483
+ # app/models/record.rb
484
+
485
+ class Record < DHS::Record
486
+
487
+ endpoint '{+service}/records'
488
+
489
+ def name
490
+ dig(:addresses, :first, :business, :identities, :first, :name)
491
+ end
492
+ end
493
+ ```
494
+
495
+ #### Access and identify nested records
496
+
497
+ Nested records, in nested data, are automatically casted to the correct Record class, when they provide an `href` and that `href` matches any defined endpoint of any defined Record:
498
+
499
+ ```ruby
500
+ # app/models/place.rb
501
+
502
+ class Place < DHS::Record
503
+ endpoint '{+service}/places'
504
+ endpoint '{+service}/places/{id}'
505
+
506
+ def name
507
+ dig(:addresses, :first, :business, :identities, :first, :name)
508
+ end
509
+ end
510
+ ```
511
+
512
+ ```ruby
513
+ # app/models/favorite.rb
514
+
515
+ class Favorite < DHS::Record
516
+ endpoint '{+service}/favorites'
517
+ endpoint '{+service}/favorites/{id}'
518
+ end
519
+ ```
520
+
521
+ ```ruby
522
+ # app/controllers/some_controller.rb
523
+
524
+ favorite = Favorite.includes(:place).find(123)
525
+ favorite.place.name # depay.fi AG
526
+ ```
527
+ ```
528
+ GET https://service.example.com/favorites/123
529
+
530
+ {... place: { href: 'https://service.example.com/places/456' }}
531
+
532
+ GET https://service.example.com/places/456
533
+ ```
534
+
535
+ If automatic detection of nested records does not work, make sure your Records are stored in `app/models`! See: [Insallation/Startup checklist](#installationstartup-checklist)
536
+
537
+ ##### Relations / Associations
538
+
539
+ Typically nested data is automatically casted when accessed (See: [Access and identify nested records](#access-and-identify-nested-records)), but sometimes API's don't provide dedicated endpoints to retrieve these records.
540
+ In those cases, those records are only available through other records and don't have an `href` on their own and can't be casted automatically, when accessed.
541
+
542
+ To be able to implement Record-specific logic for those nested records, you can define relations/associations.
543
+
544
+ ###### has_many
545
+
546
+ ```ruby
547
+ # app/models/location.rb
548
+
549
+ class Location < DHS::Record
550
+
551
+ endpoint '{+service}/locations/{id}'
552
+
553
+ has_many :listings
554
+
555
+ end
556
+ ```
557
+
558
+ ```ruby
559
+ # app/models/listing.rb
560
+
561
+ class Listing < DHS::Record
562
+
563
+ def supported?
564
+ type == 'SUPPORTED'
565
+ end
566
+ end
567
+ ```
568
+
569
+ ```ruby
570
+ # app/controllers/some_controller.rb
571
+
572
+ Location.find(1).listings.first.supported? # true
573
+ ```
574
+ ```
575
+ GET https://service.example.com/locations/1
576
+ {... listings: [{ type: 'SUPPORTED' }] }
577
+ ```
578
+
579
+ `class_name`: Specify the class name of the relation. Use it only if that name can't be inferred from the relation name. So has_many :photos will by default be linked to the Photo class, but if the real class name is e.g. CustomPhoto or namespaced Custom::Photo, you'll have to specify it with this option.
580
+
581
+ ```ruby
582
+ # app/models/custom/location.rb
583
+
584
+ module Custom
585
+ class Location < DHS::Record
586
+ endpoint '{+service}/locations'
587
+ endpoint '{+service}/locations/{id}'
588
+
589
+ has_many :photos, class_name: 'Custom::Photo'
590
+ end
591
+ end
592
+ ```
593
+
594
+ ```ruby
595
+ # app/models/custom/photo.rb
596
+
597
+ module Custom
598
+ class Photo < DHS::Record
599
+ end
600
+ end
601
+ ```
602
+
603
+ ###### has_one
604
+
605
+ ```ruby
606
+ # app/models/transaction.rb
607
+
608
+ class Transaction < DHS::Record
609
+
610
+ endpoint '{+service}/transaction/{id}'
611
+
612
+ has_one :user
613
+ end
614
+ ```
615
+
616
+ ```ruby
617
+ # app/models/user.rb
618
+
619
+ class User < DHS::Record
620
+
621
+ def email
622
+ self[:email_address]
623
+ end
624
+ end
625
+ ```
626
+
627
+ ```ruby
628
+ # app/controllers/some_controller.rb
629
+
630
+ Transaction.find(1).user.email_address # steve@depay.fi
631
+ ```
632
+ ```
633
+ GET https://service.example.com/transaction/1
634
+ {... user: { email_address: 'steve@depay.fi' } }
635
+ ```
636
+
637
+ `class_name`: Specify the class name of the relation. Use it only if that name can't be inferred from the relation name. So has_many :photos will by default be linked to the Photo class, but if the real class name is e.g. CustomPhoto or namespaced Custom::Photo, you'll have to specify it with this option.
638
+
639
+ ```ruby
640
+ # app/models/custom/location.rb
641
+
642
+ module Custom
643
+ class Location < DHS::Record
644
+ endpoint '{+service}/locations'
645
+ endpoint '{+service}/locations/{id}'
646
+
647
+ has_one :photo, class_name: 'Custom::Photo'
648
+ end
649
+ end
650
+ ```
651
+
652
+ ```ruby
653
+ # app/models/custom/photo.rb
654
+
655
+ module Custom
656
+ class Photo < DHS::Record
657
+ end
658
+ end
659
+ ```
660
+
661
+ #### Unwrap nested items from the response body
662
+
663
+ If the actual item data is mixed with meta data in the response body, DHS allows you to configure a record in a way to automatically unwrap items from within nested response data.
664
+
665
+ `item_key` is used to unwrap the actual object from within the response body.
666
+
667
+ ```ruby
668
+ # app/models/location.rb
669
+
670
+ class Location < DHS::Record
671
+ configuration item_key: [:response, :location]
672
+ end
673
+ ```
674
+
675
+ ```ruby
676
+ # app/controllers/some_controller.rb
677
+
678
+ location = Location.find(123)
679
+ location.id # 123
680
+ ```
681
+ ```
682
+ GET https://service.example.com/locations/123
683
+ {... response: { location: { id: 123 } } }
684
+ ```
685
+
686
+ #### Determine collections from the response body
687
+
688
+ `items_key` key used to determine the collection of items of the current page (e.g. `docs`, `items`, etc.), defaults to 'items':
689
+
690
+ ```ruby
691
+ # app/models/search.rb
692
+
693
+ class Search < DHS::Record
694
+ configuration items_key: :docs
695
+ end
696
+ ```
697
+
698
+ ```ruby
699
+ # app/controllers/some_controller.rb
700
+
701
+ search_result = Search.where(q: 'Starbucks')
702
+ search_result.first.address # Bahnhofstrasse 5, 8000 Zürich
703
+ ```
704
+ ```
705
+ GET https://service.example.com/search?q=Starbucks
706
+ {... docs: [... {... address: 'Bahnhofstrasse 5, 8000 Zürich' }] }
707
+ ```
708
+
709
+ #### Load additional data based on retrieved data
710
+
711
+ In order to load linked data from already retrieved data, you can use `load!` (or `reload!`).
712
+
713
+ ```ruby
714
+ # app/controllers/some_controller.rb
715
+
716
+ record = Record.find(1)
717
+ record.associated_thing.load!
718
+ ```
719
+ ```
720
+ GET https://things/4
721
+ { name: "Steve" }
722
+ ```
723
+ ```ruby
724
+ # app/controllers/some_controller.rb
725
+ record.associated_thing.name # Steve
726
+
727
+ record.associated_thing.load! # Does NOT create another request, as it is already loaded
728
+ record.associated_thing.reload! # Does request the data again from remote
729
+
730
+ ```
731
+ ```
732
+ GET https://things/4
733
+ { name: "Steve" }
734
+ ```
735
+
736
+ ### Chain complex queries
737
+
738
+ > [Method chaining](https://en.wikipedia.org/wiki/Method_chaining), also known as named parameter idiom, is a common syntax for invoking multiple method calls in object-oriented programming languages. Each method returns an object, allowing the calls to be chained together without requiring variables to store the intermediate results
739
+
740
+ In order to simplify and enhance preparing complex queries for performing single or multiple requests, DHS implements query chains to find single or multiple records.
741
+
742
+ DHS query chains do [lazy evaluation](https://de.wikipedia.org/wiki/Lazy_Evaluation) to only perform as many requests as needed, when the data to be retrieved is actually needed.
743
+
744
+ Any method, accessing the content of the data to be retrieved, is resolving the chain in place – like `.each`, `.first`, `.some_attribute_name`. Nevertheless, if you just want to resolve the chain in place, and nothing else, `fetch` should be the method of your choice:
745
+
746
+ ```ruby
747
+ # app/controllers/some_controller.rb
748
+
749
+ Record.where(color: 'blue').fetch
750
+ ```
751
+
752
+ #### Chain where queries
753
+
754
+ ```ruby
755
+ # app/controllers/some_controller.rb
756
+
757
+ records = Record.where(color: 'blue')
758
+ [...]
759
+ records.where(available: true).each do |record|
760
+ [...]
761
+ end
762
+ ```
763
+ ```
764
+ GET https://service.example.com/records?color=blue&available=true
765
+ ```
766
+
767
+ In case you wan't to check/debug the current values for where in the chain, you can use `where_values_hash`:
768
+
769
+ ```ruby
770
+ records.where_values_hash
771
+
772
+ # {color: 'blue', available: true}
773
+ ```
774
+
775
+ #### Expand plain collections of links: expanded
776
+
777
+ Some endpoints could respond only with a plain list of links and without any expanded data, like search results.
778
+
779
+ Use `expanded` to have DHS expand that data, by performing necessary requests in parallel:
780
+
781
+ ```ruby
782
+ # app/controllers/some_controller.rb
783
+
784
+ Search.where(what: 'Cafe').expanded
785
+ ```
786
+ ```
787
+ GET https://service.example.com/search?what=Cafe
788
+ {...
789
+ "items" : [
790
+ {"href": "https://service.example.com/records/1"},
791
+ {"href": "https://service.example.com/records/2"},
792
+ {"href": "https://service.example.com/records/3"}
793
+ ]
794
+ }
795
+
796
+ In parallel:
797
+ > GET https://service.example.com/records/1
798
+ < {... name: 'Cafe Einstein'}
799
+ > GET https://service.example.com/records/2
800
+ < {... name: 'Starbucks'}
801
+ > GET https://service.example.com/records/3
802
+ < {... name: 'Plaza Cafe'}
803
+
804
+ {
805
+ ...
806
+ "items" : [
807
+ {
808
+ "href": "https://service.example.com/records/1",
809
+ "name": 'Cafe Einstein',
810
+ ...
811
+ },
812
+ {
813
+ "href": "https://service.example.com/records/2",
814
+ "name": 'Starbucks',
815
+ ...
816
+ },
817
+ {
818
+ "href": "https://service.example.com/records/3",
819
+ "name": 'Plaza Cafe',
820
+ ...
821
+ }
822
+ ]
823
+ }
824
+ ```
825
+
826
+ You can also apply request options to `expanded`. Those options will be used to perform the additional requests to expand the data:
827
+
828
+ ```ruby
829
+ # app/controllers/some_controller.rb
830
+
831
+ Search.where(what: 'Cafe').expanded(auth: { bearer: access_token })
832
+ ```
833
+
834
+ #### Error handling with chains
835
+
836
+ One benefit of chains is lazy evaluation. But that also means they only get resolved when data is accessed. This makes it hard to catch errors with normal `rescue` blocks:
837
+
838
+ ```ruby
839
+ # app/controllers/some_controller.rb
840
+
841
+ def show
842
+ @records = Record.where(color: blue) # returns a chain, nothing is resolved, no http requests are performed
843
+ rescue => e
844
+ # never ending up here, because the http requests are actually performed in the view, when the query chain is resolved
845
+ end
846
+ ```
847
+
848
+ ```ruby
849
+ # app/views/some/view.haml
850
+
851
+ = @records.each do |record| # .each resolves the query chain, leads to http requests beeing performed, which might raises an exception
852
+ = record.name
853
+ ```
854
+
855
+ To simplify error handling with chains, you can also chain error handlers to be resolved, as part of the chain.
856
+
857
+ If you need to render some different view in Rails based on an DHS error raised during rendering the view, please proceed as following:
858
+
859
+ ```ruby
860
+ # app/controllers/some_controller.rb
861
+
862
+ def show
863
+ @records = Record
864
+ .rescue(DHC::Error, ->(error){ rescue_from(error) })
865
+ .where(color: 'blue')
866
+ render 'show'
867
+ render_error if @error
868
+ end
869
+
870
+ private
871
+
872
+ def rescue_from(error)
873
+ @error = error
874
+ nil
875
+ end
876
+
877
+ def render_error
878
+ self.response_body = nil # required to not raise AbstractController::DoubleRenderError
879
+ render 'error'
880
+ end
881
+ ```
882
+ ```
883
+ > GET https://service.example.com/records?color=blue
884
+ < 406
885
+ ```
886
+
887
+ In case no matching error handler is found the error gets re-raised.
888
+
889
+ -> Read more about [DHC error types/classes](https://github.com/DePayFi/dhc#exceptions)
890
+
891
+ If you want to inject values for the failing records, that might not have been found, you can inject values for them with error handlers:
892
+
893
+ ```ruby
894
+ # app/controllers/some_controller.rb
895
+
896
+ data = Record
897
+ .rescue(DHC::Unauthorized, ->(response) { Record.new(name: 'unknown') })
898
+ .find(1, 2, 3)
899
+
900
+ data[1].name # 'unknown'
901
+ ```
902
+ ```
903
+ In parallel:
904
+ > GET https://service.example.com/records/1
905
+ < 200
906
+ > GET https://service.example.com/records/2
907
+ < 400
908
+ > GET https://service.example.com/records/3
909
+ < 200
910
+ ```
911
+
912
+ -> Read more about [DHC error types/classes](https://github.com/DePayFi/dhc#exceptions)
913
+
914
+ **If an error handler returns `nil` an empty DHS::Record is returned, not `nil`!**
915
+
916
+ In case you want to ignore errors and continue working with `nil` in those cases,
917
+ please use `ignore`:
918
+
919
+ ```ruby
920
+ # app/controllers/some_controller.rb
921
+
922
+ record = Record.ignore(DHC::NotFound).find_by(color: 'blue')
923
+
924
+ record # nil
925
+ ```
926
+
927
+ #### Resolve chains: fetch
928
+
929
+ In case you need to resolve a query chain in place, use `fetch`:
930
+
931
+ ```ruby
932
+ # app/controllers/some_controller.rb
933
+
934
+ records = Record.where(color: 'blue').fetch
935
+ ```
936
+
937
+ #### Add request options to a query chain: options
938
+
939
+ You can apply options to the request chain. Those options will be forwarded to the request perfomed by the chain/query:
940
+
941
+ ```ruby
942
+ # app/controllers/some_controller.rb
943
+
944
+ options = { auth: { bearer: '123456' } } # authenticated with OAuth token
945
+
946
+ ```
947
+
948
+ ```ruby
949
+ # app/controllers/some_controller.rb
950
+
951
+ AuthenticatedRecord = Record.options(options)
952
+
953
+ ```
954
+
955
+ ```ruby
956
+ # app/controllers/some_controller.rb
957
+
958
+ blue_records = AuthenticatedRecord.where(color: 'blue')
959
+
960
+ ```
961
+ ```
962
+ GET https://service.example.com/records?color=blue { headers: { 'Authentication': 'Bearer 123456' } }
963
+ ```
964
+
965
+ ```ruby
966
+ # app/controllers/some_controller.rb
967
+
968
+ AuthenticatedRecord.create(color: 'red')
969
+
970
+ ```
971
+ ```
972
+ POST https://service.example.com/records { body: '{ color: "red" }' }, headers: { 'Authentication': 'Bearer 123456' } }
973
+ ```
974
+
975
+ ```ruby
976
+ # app/controllers/some_controller.rb
977
+
978
+ record = AuthenticatedRecord.find(123)
979
+
980
+ ```
981
+ ```
982
+ GET https://service.example.com/records/123 { headers: { 'Authentication': 'Bearer 123456' } }
983
+ ```
984
+
985
+ ```ruby
986
+ # app/controllers/some_controller.rb
987
+
988
+ authenticated_record = record.options(options) # starting a new chain based on the found record
989
+
990
+ ```
991
+
992
+ ```ruby
993
+ # app/controllers/some_controller.rb
994
+
995
+ authenticated_record.valid?
996
+
997
+ ```
998
+ ```
999
+ POST https://service.example.com/records/validate { body: '{...}', headers: { 'Authentication': 'Bearer 123456' } }
1000
+ ```
1001
+
1002
+ ```ruby
1003
+ # app/controllers/some_controller.rb
1004
+
1005
+ authenticated_record.save
1006
+ ```
1007
+ ```
1008
+ POST https://service.example.com/records { body: '{...}', headers: { 'Authentication': 'Bearer 123456' } }
1009
+ ```
1010
+
1011
+ ```ruby
1012
+ # app/controllers/some_controller.rb
1013
+
1014
+ authenticated_record.destroy
1015
+
1016
+ ```
1017
+ ```
1018
+ DELETE https://service.example.com/records/123 { headers: { 'Authentication': 'Bearer 123456' } }
1019
+ ```
1020
+
1021
+ ```ruby
1022
+ # app/controllers/some_controller.rb
1023
+
1024
+ authenticated_record.update(name: 'Steve')
1025
+
1026
+ ```
1027
+ ```
1028
+ POST https://service.example.com/records/123 { body: '{...}', headers: { 'Authentication': 'Bearer 123456' } }
1029
+ ```
1030
+
1031
+ #### Control pagination within a query chain
1032
+
1033
+ `page` sets the page that you want to request.
1034
+
1035
+ `per` sets the amount of items requested per page.
1036
+
1037
+ `limit` is an alias for `per`. **But without providing arguments, it resolves the query and provides the current response limit per page**
1038
+
1039
+ ```ruby
1040
+ # app/controllers/some_controller.rb
1041
+
1042
+ Record.page(3).per(20).where(color: 'blue')
1043
+
1044
+ ```
1045
+ ```
1046
+ GET https://service.example.com/records?offset=40&limit=20&color=blue
1047
+ ```
1048
+
1049
+ ```ruby
1050
+ # app/controllers/some_controller.rb
1051
+
1052
+ Record.page(3).per(20).where(color: 'blue')
1053
+
1054
+ ```
1055
+ ```
1056
+ GET https://service.example.com/records?offset=40&limit=20&color=blue
1057
+ ```
1058
+
1059
+ The applied pagination strategy depends on whats configured for the particular record: See [Record pagination](#record-pagination)
1060
+
1061
+ ### Record pagination
1062
+
1063
+ You can configure pagination on a per record base.
1064
+ DHS differentiates between the [pagination strategy](#pagination-strategy) (how items/pages are navigated and calculated) and [pagination keys](#pagination-keys) (how stuff is named and accessed).
1065
+
1066
+ #### Pagination strategy
1067
+
1068
+ ##### Pagination strategy: offset (default)
1069
+
1070
+ The offset pagination strategy is DHS's default pagination strategy, so nothing needs to be (re-)configured.
1071
+
1072
+ The `offset` pagination strategy starts with 0 and offsets by the amount of items, thay you've already recived – typically `limit`.
1073
+
1074
+ ```ruby
1075
+ # app/models/record.rb
1076
+
1077
+ class Search < DHS::Record
1078
+ endpoint '{+service}/search'
1079
+ end
1080
+ ```
1081
+
1082
+ ```ruby
1083
+ # app/controllers/some_controller.rb
1084
+
1085
+ Record.all
1086
+
1087
+ ```
1088
+ ```
1089
+ GET https://service.example.com/records?limit=100
1090
+ {
1091
+ items: [{...}, ...],
1092
+ total: 300,
1093
+ limit: 100,
1094
+ offset: 0
1095
+ }
1096
+ In parallel:
1097
+ GET https://service.example.com/records?limit=100&offset=100
1098
+ GET https://service.example.com/records?limit=100&offset=200
1099
+ ```
1100
+
1101
+ ##### Pagination strategy: page
1102
+
1103
+ In comparison to the `offset` strategy, the `page` strategy just increases by 1 (page) and sends the next batch of items for the next page.
1104
+
1105
+ ```ruby
1106
+ # app/models/record.rb
1107
+
1108
+ class Search < DHS::Record
1109
+ configuration pagination_strategy: 'page', pagination_key: 'page'
1110
+
1111
+ endpoint '{+service}/search'
1112
+ end
1113
+ ```
1114
+
1115
+ ```ruby
1116
+ # app/controllers/some_controller.rb
1117
+
1118
+ Record.all
1119
+
1120
+ ```
1121
+ ```
1122
+ GET https://service.example.com/records?limit=100
1123
+ {
1124
+ items: [{...}, ...],
1125
+ total: 300,
1126
+ limit: 100,
1127
+ page: 1
1128
+ }
1129
+ In parallel:
1130
+ GET https://service.example.com/records?limit=100&page=2
1131
+ GET https://service.example.com/records?limit=100&page=3
1132
+ ```
1133
+
1134
+ ##### Pagination strategy: total_pages
1135
+
1136
+ The `total_pages` strategy is based on the `page` strategy with the only difference
1137
+ that the responding api provides `total_pages` over `total_items`.
1138
+
1139
+ ```ruby
1140
+ # app/models/transaction.rb
1141
+
1142
+ class Transaction < DHS::Record
1143
+ configuration pagination_strategy: 'total_pages', pagination_key: 'page', total_key: 'total_pages'
1144
+
1145
+ endpoint '{+service}/transactions'
1146
+ end
1147
+ ```
1148
+
1149
+ ```ruby
1150
+ # app/controllers/some_controller.rb
1151
+
1152
+ Record.all
1153
+
1154
+ ```
1155
+ ```
1156
+ GET https://service.example.com/transactions?limit=100
1157
+ {
1158
+ items: [{...}, ...],
1159
+ total_pages: 3,
1160
+ limit: 100,
1161
+ page: 1
1162
+ }
1163
+ In parallel:
1164
+ GET https://service.example.com/records?limit=100&page=2
1165
+ GET https://service.example.com/records?limit=100&page=3
1166
+ ```
1167
+
1168
+
1169
+ ##### Pagination strategy: start
1170
+
1171
+ In comparison to the `offset` strategy, the `start` strategy indicates with which item the current page starts.
1172
+ Typically it starts with 1 and if you get 100 items per page, the next start is 101.
1173
+
1174
+ ```ruby
1175
+ # app/models/record.rb
1176
+
1177
+ class Search < DHS::Record
1178
+ configuration pagination_strategy: 'start', pagination_key: 'startAt'
1179
+
1180
+ endpoint '{+service}/search'
1181
+ end
1182
+ ```
1183
+
1184
+ ```ruby
1185
+ # app/controllers/some_controller.rb
1186
+
1187
+ Record.all
1188
+
1189
+ ```
1190
+ ```
1191
+ GET https://service.example.com/records?limit=100
1192
+ {
1193
+ items: [{...}, ...],
1194
+ total: 300,
1195
+ limit: 100,
1196
+ page: 1
1197
+ }
1198
+ In parallel:
1199
+ GET https://service.example.com/records?limit=100&startAt=101
1200
+ GET https://service.example.com/records?limit=100&startAt=201
1201
+ ```
1202
+
1203
+ ##### Pagination strategy: link
1204
+
1205
+ The `link` strategy continuously follows in-response embedded links to following pages until the last page is reached (indicated by no more `next` link).
1206
+
1207
+ *WARNING*
1208
+
1209
+ Loading all pages from a resource paginated with links only can result in very poor performance, as pages can only be loaded sequentially!
1210
+
1211
+ ```ruby
1212
+ # app/models/record.rb
1213
+
1214
+ class Search < DHS::Record
1215
+ configuration pagination_strategy: 'link'
1216
+
1217
+ endpoint '{+service}/search'
1218
+ end
1219
+ ```
1220
+
1221
+ ```ruby
1222
+ # app/controllers/some_controller.rb
1223
+
1224
+ Record.all
1225
+
1226
+ ```
1227
+ ```
1228
+ GET https://service.example.com/records?limit=100
1229
+ {
1230
+ items: [{...}, ...],
1231
+ limit: 100,
1232
+ next: {
1233
+ href: 'https://service.example.com/records?from_record_id=p62qM5p0NK_qryO52Ze-eg&limit=100'
1234
+ }
1235
+ }
1236
+ Sequentially:
1237
+ GET https://service.example.com/records?from_record_id=p62qM5p0NK_qryO52Ze-eg&limit=100
1238
+ GET https://service.example.com/records?from_record_id=xcaoXBmuMyFFEcFDSgNgDQ&limit=100
1239
+ ```
1240
+
1241
+ #### Pagination keys
1242
+
1243
+ ##### limit_key
1244
+
1245
+ `limit_key` sets the key used to indicate how many items you want to retrieve per page e.g. `size`, `limit`, etc.
1246
+ In case the `limit_key` parameter differs for how it needs to be requested from how it's provided in the reponse, use `body` and `parameter` subkeys.
1247
+
1248
+ ```ruby
1249
+ # app/models/record.rb
1250
+
1251
+ class Record < DHS::Record
1252
+ configuration limit_key: { body: [:pagination, :max], parameter: :max }
1253
+
1254
+ endpoint '{+service}/records'
1255
+ end
1256
+ ```
1257
+
1258
+ ```ruby
1259
+ # app/controllers/some_controller.rb
1260
+
1261
+ records = Record.where(color: 'blue')
1262
+ records.limit # 20
1263
+ ```
1264
+ ```
1265
+ GET https://service.example.com/records?color=blue&max=100
1266
+ { ...
1267
+ items: [...],
1268
+ pagination: { max: 20 }
1269
+ }
1270
+ ```
1271
+
1272
+ ##### pagination_key
1273
+
1274
+ `pagination_key` defines which key to use to paginate a page (e.g. `offset`, `page`, `startAt` etc.).
1275
+ In case the `limit_key` parameter differs for how it needs to be requested from how it's provided in the reponse, use `body` and `parameter` subkeys.
1276
+
1277
+ ```ruby
1278
+ # app/models/record.rb
1279
+
1280
+ class Record < DHS::Record
1281
+ configuration pagination_key: { body: [:pagination, :page], parameter: :page }, pagination_strategy: :page
1282
+
1283
+ endpoint '{+service}/records'
1284
+ end
1285
+ ```
1286
+
1287
+ ```ruby
1288
+ # app/controllers/some_controller.rb
1289
+
1290
+ records = Record.where(color: 'blue').all
1291
+ records.length # 300
1292
+ ```
1293
+ ```
1294
+ GET https://service.example.com/records?color=blue&limit=100
1295
+ {... pagination: { page: 1 } }
1296
+ In parallel:
1297
+ GET https://service.example.com/records?color=blue&limit=100&page=2
1298
+ {... pagination: { page: 2 } }
1299
+ GET https://service.example.com/records?color=blue&limit=100&page=3
1300
+ {... pagination: { page: 3 } }
1301
+ ```
1302
+
1303
+ ##### total_key
1304
+
1305
+ `total_key` defines which key to user for pagination to describe the total amount of remote items (e.g. `total`, `totalResults`, etc.).
1306
+
1307
+ ```ruby
1308
+ # app/models/record.rb
1309
+
1310
+ class Record < DHS::Record
1311
+ configuration total_key: [:pagination, :total]
1312
+
1313
+ endpoint '{+service}/records'
1314
+ end
1315
+ ```
1316
+
1317
+ ```ruby
1318
+ # app/controllers/some_controller.rb
1319
+
1320
+ records = Record.where(color: 'blue').fetch
1321
+ records.length # 100
1322
+ records.count # 300
1323
+ ```
1324
+ ```
1325
+ GET https://service.example.com/records?color=blue&limit=100
1326
+ {... pagination: { total: 300 } }
1327
+ ```
1328
+
1329
+ #### Pagination links
1330
+
1331
+ ##### next?
1332
+
1333
+ `next?` Tells you if there is a next link or not.
1334
+
1335
+ ```ruby
1336
+ # app/controllers/some_controller.rb
1337
+
1338
+ @records = Record.where(color: 'blue').fetch
1339
+ ```
1340
+ ```
1341
+ GET https://service.example.com/records?color=blue&limit=100
1342
+ {... items: [...], next: 'https://service.example.com/records?color=blue&limit=100&offset=100' }
1343
+ ```
1344
+
1345
+ ```ruby
1346
+ # app/views/some_view.haml
1347
+
1348
+ - if @records.next?
1349
+ = render partial: 'next_arrow'
1350
+ ```
1351
+
1352
+ ##### previous?
1353
+
1354
+ `previous?` Tells you if there is a previous link or not.
1355
+
1356
+ ```ruby
1357
+ # app/controllers/some_controller.rb
1358
+
1359
+ @records = Record.where(color: 'blue').fetch
1360
+ ```
1361
+ ```
1362
+ GET https://service.example.com/records?color=blue&limit=100
1363
+ {... items: [...], previous: 'https://service.example.com/records?color=blue&limit=100&offset=100' }
1364
+ ```
1365
+
1366
+ ```ruby
1367
+ # app/views/some_view.haml
1368
+
1369
+ - if @records.previous?
1370
+ = render partial: 'previous_arrow'
1371
+ ```
1372
+
1373
+ #### Kaminari support (limited)
1374
+
1375
+ DHS implements an interface that makes it partially working with Kaminari.
1376
+
1377
+ The kaminari’s page parameter is in params[:page]. For example, you can use kaminari to render paginations based on DHS Records. Typically, your code will look like this:
1378
+
1379
+ ```ruby
1380
+ # controller
1381
+ @items = Record.page(params[:page]).per(100)
1382
+ ```
1383
+
1384
+ ```ruby
1385
+ # view
1386
+ = paginate @items
1387
+ ```
1388
+
1389
+ ### Build, create and update records
1390
+
1391
+ #### Create new records
1392
+
1393
+ ##### create
1394
+
1395
+ `create` will return the object in memory if persisting fails, providing validation errors in `.errors` (See [record validation](#record-validation)).
1396
+
1397
+ `create!` instead will raise an exception.
1398
+
1399
+ `create` always builds the data of the local object first, before it tries to sync with an endpoint. So even if persisting fails, the local object is build.
1400
+
1401
+ ```ruby
1402
+ # app/controllers/some_controller.rb
1403
+
1404
+ record = Record.create(
1405
+ text: 'Hello world'
1406
+ )
1407
+
1408
+ ```
1409
+ ```
1410
+ POST https://service.example.com/records { body: "{ 'text' : 'Hello world' }" }
1411
+ ```
1412
+
1413
+ -> See [record validation](#record-validation) for how to handle validation errors when creating records.
1414
+
1415
+ ###### Unwrap nested data when creation response nests created record data
1416
+
1417
+ `item_created_key` key used to merge record data thats nested in the creation response body:
1418
+
1419
+ ```ruby
1420
+ # app/models/location.rb
1421
+
1422
+ class Location < DHS::Record
1423
+
1424
+ configuration item_created_key: [:response, :location]
1425
+
1426
+ end
1427
+ ```
1428
+
1429
+ ```ruby
1430
+ # app/controllers/some_controller.rb
1431
+
1432
+ location.create(lat: '47.3920152', long: '8.5127981')
1433
+ location.address # Förrlibuckstrasse 62, 8005 Zürich
1434
+ ```
1435
+ ```
1436
+ POST https://service.example.com/locations { body: "{ 'lat': '47.3920152', long: '8.5127981' }" }
1437
+ {... { response: { location: {... address: 'Förrlibuckstrasse 62, 8005 Zürich' } } } }
1438
+ ```
1439
+
1440
+ ###### Create records through associations: Nested sub resources
1441
+
1442
+ ```ruby
1443
+ # app/models/restaurant.rb
1444
+
1445
+ class Restaurant < DHS::Record
1446
+ endpoint '{+service}/restaurants/{id}'
1447
+ end
1448
+
1449
+ ```
1450
+
1451
+ ```ruby
1452
+ # app/models/feedback.rb
1453
+
1454
+ class Feedback < DHS::Record
1455
+ endpoint '{+service}/restaurants/{restaurant_id}/feedbacks'
1456
+ end
1457
+
1458
+ ```
1459
+
1460
+ ```ruby
1461
+ # app/controllers/some_controller.rb
1462
+
1463
+ restaurant = Restaurant.find(1)
1464
+ ```
1465
+ ```
1466
+ GET https://service.example.com/restaurants/1
1467
+ {... reviews: { href: 'https://service.example.com/restaurants/1/reviews' }}
1468
+ ```
1469
+
1470
+ ```ruby
1471
+ # app/controllers/some_controller.rb
1472
+
1473
+ restaurant.reviews.create(
1474
+ text: 'Simply awesome!'
1475
+ )
1476
+ ```
1477
+ ```
1478
+ POST https://service.example.com/restaurants/1/reviews { body: "{ 'text': 'Simply awesome!' }" }
1479
+ ```
1480
+
1481
+ #### Start building new records
1482
+
1483
+ With `new` or `build` you can start building new records from scratch, which can be persisted with `save`:
1484
+
1485
+ ```ruby
1486
+ # app/controllers/some_controller.rb
1487
+
1488
+ record = Record.new # or Record.build
1489
+ record.name = 'Starbucks'
1490
+ record.save
1491
+ ```
1492
+ ```
1493
+ POST https://service.example.com/records { body: "{ 'name' : 'Starbucks' }" }
1494
+ ```
1495
+
1496
+ #### Change/Update existing records
1497
+
1498
+ ##### save
1499
+
1500
+ `save` persist the whole object in it's current state.
1501
+
1502
+ `save` will return `false` if persisting fails. `save!` instead will raise an exception.
1503
+
1504
+ ```ruby
1505
+ # app/controllers/some_controller.rb
1506
+
1507
+ record = Record.find('1z-5r1fkaj')
1508
+
1509
+ ```
1510
+ ```
1511
+ GET https://service.example.com/records/1z-5r1fkaj
1512
+ { name: 'Starbucks', recommended: null }
1513
+ ```
1514
+
1515
+ ```ruby
1516
+ # app/controllers/some_controller.rb
1517
+
1518
+ record.recommended = true
1519
+ record.save
1520
+
1521
+ ```
1522
+ ```
1523
+ POST https://service.example.com/records/1z-5r1fkaj { body: "{ 'name': 'Starbucks', 'recommended': true }" }
1524
+ ```
1525
+
1526
+ -> See [record validation](#record-validation) for how to handle validation errors when updating records.
1527
+
1528
+ ##### update
1529
+
1530
+ ###### Directly via Record
1531
+
1532
+ ```ruby
1533
+ # app/controllers/some_controller.rb
1534
+
1535
+ Record.update(id: '1z-5r1fkaj', name: 'Steve')
1536
+
1537
+ ```
1538
+ ```
1539
+ GET https://service.example.com/records/1z-5r1fkaj
1540
+ { name: 'Steve' }
1541
+ ```
1542
+
1543
+ ###### per Instance
1544
+
1545
+ `update` persists the whole object after new parameters are applied through arguments.
1546
+
1547
+ `update` will return false if persisting fails. `update!` instead will raise an exception.
1548
+
1549
+ `update` always updates the data of the local object first, before it tries to sync with an endpoint. So even if persisting fails, the local object is updated.
1550
+
1551
+ ```ruby
1552
+ # app/controllers/some_controller.rb
1553
+
1554
+ record = Record.find('1z-5r1fkaj')
1555
+
1556
+ ```
1557
+ ```
1558
+ GET https://service.example.com/records/1z-5r1fkaj
1559
+ { name: 'Starbucks', recommended: null }
1560
+ ```
1561
+
1562
+ ```ruby
1563
+ # app/controllers/some_controller.rb
1564
+
1565
+ record.update(recommended: true)
1566
+
1567
+ ```
1568
+ ```
1569
+ POST https://service.example.com/records/1z-5r1fkaj { body: "{ 'name': 'Starbucks', 'recommended': true }" }
1570
+ ```
1571
+
1572
+ -> See [record validation](#record-validation) for how to handle validation errors when updating records.
1573
+
1574
+ You can use `update` and the end of query-chains:
1575
+
1576
+ ```ruby
1577
+ # app/controllers/some_controller.rb
1578
+
1579
+ record.options(method: :put).update(recommended: true)
1580
+
1581
+ ```
1582
+
1583
+ You can also pass explicit request options to `update`, by passing two explicit hashes:
1584
+
1585
+ ```ruby
1586
+ # app/controllers/some_controller.rb
1587
+
1588
+ record.update({ recommended: true }, { method: 'put' })
1589
+
1590
+ ```
1591
+
1592
+ ##### partial_update
1593
+
1594
+ `partial_update` updates just the provided parameters.
1595
+
1596
+ `partial_update` will return false if persisting fails. `partial_update!` instead will raise an exception.
1597
+
1598
+ `partial_update` always updates the data of the local object first, before it tries to sync with an endpoint. So even if persisting fails, the local object is updated.
1599
+
1600
+ ```ruby
1601
+ # app/controllers/some_controller.rb
1602
+
1603
+ record = Record.find('1z-5r1fkaj')
1604
+
1605
+ ```
1606
+ ```
1607
+ GET https://service.example.com/records/1z-5r1fkaj
1608
+ { name: 'Starbucks', recommended: null }
1609
+ ```
1610
+
1611
+ ```ruby
1612
+ # app/controllers/some_controller.rb
1613
+
1614
+ record.partial_update(recommended: true)
1615
+
1616
+ ```
1617
+ ```
1618
+ POST https://service.example.com/records/1z-5r1fkaj { body: "{ 'recommended': true }" }
1619
+ ```
1620
+
1621
+ -> See [record validation](#record-validation) for how to handle validation errors when updating records.
1622
+
1623
+ You can use `partial_update` at the end of query-chains:
1624
+
1625
+ ```ruby
1626
+ # app/controllers/some_controller.rb
1627
+
1628
+ record.options(method: :put).partial_update(recommended: true)
1629
+
1630
+ ```
1631
+
1632
+ You can also pass explicit request options to `partial_update`, by passing two explicit hashes:
1633
+
1634
+ ```ruby
1635
+ # app/controllers/some_controller.rb
1636
+
1637
+ record.partial_update({ recommended: true }, { method: 'put' })
1638
+
1639
+ ```
1640
+
1641
+ #### Endpoint url parameter injection during record creation/change
1642
+
1643
+ DHS injects parameters provided to `create`, `update`, `partial_update`, `save` etc. into an endpoint's URL when matching:
1644
+
1645
+ ```ruby
1646
+ # app/models/feedback.rb
1647
+
1648
+ class Feedback << DHS::Record
1649
+ endpoint '{+service}/records/{record_id}/feedbacks'
1650
+ end
1651
+ ```
1652
+
1653
+ ```ruby
1654
+ # app/controllers/some_controller.rb
1655
+
1656
+ Feedback.create(record_id: 51232, text: 'Great Restaurant!')
1657
+ ```
1658
+ ```
1659
+ POST https://service.example.com/records/51232/feedbacks { body: "{ 'text' : 'Great Restaurant!' }" }
1660
+ ```
1661
+
1662
+ #### Record validation
1663
+
1664
+ In order to validate records before persisting them, you can use the `valid?` (`validate` alias) method.
1665
+
1666
+ It's **not recommended** to validate records anywhere, including application side validation via `ActiveModel::Validations`, except, if you validate them via the same endpoint/service, that also creates them.
1667
+
1668
+ The specific endpoint has to support validations without persistence. An endpoint has to be enabled (opt-in) in your record configurations:
1669
+
1670
+ ```ruby
1671
+ # app/models/user.rb
1672
+
1673
+ class User < DHS::Record
1674
+
1675
+ endpoint '{+service}/users', validates: { params: { persist: false } }
1676
+
1677
+ end
1678
+ ```
1679
+
1680
+ ```ruby
1681
+ # app/controllers/some_controller.rb
1682
+
1683
+ user = User.build(email: 'i\'m not an email address')
1684
+
1685
+ unless user.valid?
1686
+ @errors = user.errors
1687
+ render 'new' and return
1688
+ end
1689
+ ```
1690
+ ```
1691
+ POST https://service.example.com/users?persist=false { body: '{ "email" : "i'm not an email address"}' }
1692
+ {
1693
+ "field_errors": [{
1694
+ "path": ["email"],
1695
+ "code": "WRONG_FORMAT",
1696
+ "message": "The property value's format is incorrect."
1697
+ }],
1698
+ "message": "Email must have the correct format."
1699
+ }
1700
+ ```
1701
+
1702
+ The functionalities of `DHS::Errors` pretty much follow those of `ActiveModel::Validation`:
1703
+
1704
+ ```ruby
1705
+ # app/views/some_view.haml
1706
+
1707
+ @errors.any? # true
1708
+ @errors.include?(:email) # true
1709
+ @errors[:email] # ['WRONG_FORMAT']
1710
+ @errors.messages # {:email=>["Translated error message that this value has the wrong format"]}
1711
+ @errors.codes # {:email=>["WRONG_FORMAT"]}
1712
+ @errors.message # Email must have the correct format."
1713
+ ```
1714
+
1715
+ ##### Configure record validations
1716
+
1717
+ The parameters passed to the `validates` endpoint option are used to perform record validations:
1718
+
1719
+ ```ruby
1720
+ # app/models/user.rb
1721
+
1722
+ class User < DHS::Record
1723
+
1724
+ endpoint '{+service}/users', validates: { params: { persist: false } } # will add ?persist=false to the request
1725
+ endpoint '{+service}/users', validates: { params: { publish: false } } # will add ?publish=false to the request
1726
+ endpoint '{+service}/users', validates: { params: { validates: true } } # will add ?validates=true to the request
1727
+ endpoint '{+service}/users', validates: { path: 'validate' } # will perform a validation via ...users/validate
1728
+
1729
+ end
1730
+ ```
1731
+
1732
+ ##### HTTP Status Codes for validation errors
1733
+
1734
+ The HTTP status code received from the endpoint when performing validations on a record, is available through the errors object:
1735
+
1736
+ ```ruby
1737
+ # app/controllers/some_controller.rb
1738
+
1739
+ record.save
1740
+ record.errors.status_code # 400
1741
+ ```
1742
+
1743
+ ##### Reset validation errors
1744
+
1745
+ Clear the error messages like:
1746
+
1747
+ ```ruby
1748
+ # app/controllers/some_controller.rb
1749
+
1750
+ record.errors.clear
1751
+ ```
1752
+
1753
+ ##### Add validation errors
1754
+
1755
+ In case you want to add application side validation errors, even though it's not recommended, do it as following:
1756
+
1757
+ ```ruby
1758
+ user.errors.add(:name, 'WRONG_FORMAT')
1759
+ ```
1760
+
1761
+ ##### Validation errors for nested data
1762
+
1763
+ If you work with complex data structures, you sometimes need to have validation errors delegated/scoped to nested data.
1764
+
1765
+ This features makes `DHS::Record`s compatible with how Rails or Simpleform renders/builds forms and especially error messages:
1766
+
1767
+ ```ruby
1768
+ # app/controllers/some_controller.rb
1769
+
1770
+ unless @customer.save
1771
+ @errors = @customer.errors
1772
+ end
1773
+ ```
1774
+ ```
1775
+ POST https://service.example.com/customers { body: "{ 'address' : { 'street': 'invalid', housenumber: '' } }" }
1776
+ {
1777
+ "field_errors": [{
1778
+ "path": ["address", "street"],
1779
+ "code": "REQUIRED_PROPERTY_VALUE_INCORRECT",
1780
+ "message": "The property value is incorrect."
1781
+ },{
1782
+ "path": ["address", "housenumber"],
1783
+ "code": "REQUIRED_PROPERTY_VALUE",
1784
+ "message": "The property value is required."
1785
+ }],
1786
+ "message": "Some data is invalid."
1787
+ }
1788
+ ```
1789
+
1790
+ ```ruby
1791
+ # app/views/some_view.haml
1792
+
1793
+ = form_for @customer, as: :customer do |customer_form|
1794
+
1795
+ = fields_for 'customer[:address]', @customer.address, do |address_form|
1796
+
1797
+ = fields_for 'customer[:address][:street]', @customer.address.street, do |street_form|
1798
+
1799
+ = street_form.input :name
1800
+ = street_form.input :house_number
1801
+ ```
1802
+
1803
+ This would render nested forms and would also render nested form errors for nested data structures.
1804
+
1805
+ You can also access those nested errors like:
1806
+
1807
+ ```ruby
1808
+ @customer.address.errors
1809
+ @customer.address.street.errors
1810
+ ```
1811
+
1812
+ ##### Translation of validation errors
1813
+
1814
+ If a translation exists for one of the following translation keys, DHS will provide a translated error (also in the following order) rather than the plain error message/code, when building forms or accessing `@errors.messages`:
1815
+
1816
+ ```ruby
1817
+ dhs.errors.records.<record_name>.attributes.<attribute_name>.<error_code>
1818
+ e.g. dhs.errors.records.customer.attributes.name.unsupported_property_value
1819
+
1820
+ dhs.errors.records.<record_name>.<error_code>
1821
+ e.g. dhs.errors.records.customer.unsupported_property_value
1822
+
1823
+ dhs.errors.messages.<error_code>
1824
+ e.g. dhs.errors.messages.unsupported_property_value
1825
+
1826
+ dhs.errors.attributes.<attribute_name>.<error_code>
1827
+ e.g. dhs.errors.attributes.name.unsupported_property_value
1828
+
1829
+ dhs.errors.fallback_message
1830
+
1831
+ dhs.errors.records.<record_name>.attributes.<collection>.<attribute_name>.<error_code>
1832
+ e.g. dhs.errors.records.appointment_proposal.attributes.appointments.date_time.date_property_not_in_future
1833
+ ```
1834
+
1835
+ ##### Validation error types: errors vs. warnings
1836
+
1837
+ ###### Persistance failed: errors
1838
+
1839
+ If an endpoint returns errors in the response body, that is enough to interpret it as: persistance failed.
1840
+ The response status code in this scenario is neglected.
1841
+
1842
+ ###### Persistance succeeded: warnings
1843
+
1844
+ In some cases, you need non blocking meta information about potential problems with the created record, so called warnings.
1845
+
1846
+ If the API endpoint implements warnings, returned when validating, they are provided just as `errors` (same interface and methods) through the `warnings` attribute:
1847
+
1848
+ ```ruby
1849
+ # app/controllres/some_controller.rb
1850
+
1851
+ @presence = Presence.options(params: { synchronize: false }).create(
1852
+ place: { href: 'http://storage/places/1' }
1853
+ )
1854
+ ```
1855
+ ```
1856
+ POST https://service.example.com/presences { body: '{ "place": { "href": "http://storage/places/1" } }' }
1857
+ {
1858
+ field_warnings: [{
1859
+ code: 'WILL_BE_RESIZED',
1860
+ path: ['place', 'photos', 0],
1861
+ message: 'This photo is too small and will be resized.'
1862
+ }
1863
+ }
1864
+ ```
1865
+
1866
+ ```ruby
1867
+
1868
+ presence.warnings.any? # true
1869
+ presence.place.photos[0].warnings.messages.first # 'This photo is too small and will be resized.'
1870
+
1871
+ ```
1872
+
1873
+ ##### Using `ActiveModel::Validations` none the less
1874
+
1875
+ If you are using `ActiveModel::Validations`, even though it's not recommended, and you add errors to the DHS::Record instance, then those errors will be overwritten by the errors from `ActiveModel::Validations` when using `save` or `valid?`.
1876
+
1877
+ So in essence, mixing `ActiveModel::Validations` and DHS built-in validations (via endpoints), is not compatible, yet.
1878
+
1879
+ [Open issue](https://github.com/DePayFi/dhs/issues/159)
1880
+
1881
+ #### Use form_helper to create and update records
1882
+
1883
+ Rails `form_for` view-helper can be used in combination with instances of `DHS::Record`s to autogenerate forms:
1884
+
1885
+ ```ruby
1886
+ <%= form_for(@instance, url: '/create') do |f| %>
1887
+ <%= f.text_field :name %>
1888
+ <%= f.text_area :text %>
1889
+ <%= f.submit "Create" %>
1890
+ <% end %>
1891
+ ```
1892
+
1893
+ ### Destroy records
1894
+
1895
+ `destroy` deletes a record.
1896
+
1897
+ ```ruby
1898
+ # app/controllers/some_controller.rb
1899
+
1900
+ record = Record.find('1z-5r1fkaj')
1901
+ ```
1902
+ ```
1903
+ GET https://service.example.com/records/1z-5r1fkaj
1904
+ ```
1905
+
1906
+ ```ruby
1907
+ # app/controllers/some_controller.rb
1908
+
1909
+ record.destroy
1910
+ ```
1911
+ ```
1912
+ DELETE https://service.example.com/records/1z-5r1fkaj
1913
+ ```
1914
+
1915
+ You can also destroy records directly without fetching them first:
1916
+
1917
+ ```ruby
1918
+ # app/controllers/some_controller.rb
1919
+
1920
+ destroyed_record = Record.destroy('1z-5r1fkaj')
1921
+ ```
1922
+ ```
1923
+ DELETE https://service.example.com/records/1z-5r1fkaj
1924
+ ```
1925
+
1926
+ or with parameters:
1927
+
1928
+ ```ruby
1929
+ # app/controllers/some_controller.rb
1930
+
1931
+ destroyed_records = Record.destroy(name: 'Steve')
1932
+ ```
1933
+ ```
1934
+ DELETE https://service.example.com/records?name='Steve'
1935
+ ```
1936
+
1937
+ ### Record getters and setters
1938
+
1939
+ Sometimes it is neccessary to implement custom getters and setters and convert data to a processable (endpoint) format behind the scenes.
1940
+
1941
+ #### Record setters
1942
+
1943
+ You can define setter methods in `DHS::Record`s that will be used by initializers (`new`) and setter methods, that convert data provided, before storing it in the record and persisting it with a remote endpoint:
1944
+
1945
+ ```ruby
1946
+ # app/models/user.rb
1947
+
1948
+ class Feedback < DHS::Record
1949
+
1950
+ def ratings=(values)
1951
+ super(
1952
+ values.map { |k, v| { name: k, value: v } }
1953
+ )
1954
+ end
1955
+ end
1956
+ ```
1957
+
1958
+ ```ruby
1959
+ # app/controllers/some_controller.rb
1960
+
1961
+ record = Record.new(ratings: { quality: 3 })
1962
+ record.ratings # [{ :name=>:quality, :value=>3 }]
1963
+ ```
1964
+
1965
+ Setting attributes with other names:
1966
+
1967
+ ```ruby
1968
+ # app/models/booking.rb
1969
+
1970
+ class Booking < DHS::Record
1971
+
1972
+ def appointments_attributes=(values)
1973
+ self.appointments = values.map { |appointment| appointment[:id] }
1974
+ end
1975
+ end
1976
+ ```
1977
+
1978
+ or
1979
+
1980
+ ```ruby
1981
+ # app/models/booking.rb
1982
+
1983
+ class Booking < DHS::Record
1984
+
1985
+ def appointments_attributes=(values)
1986
+ self[:appointments] = values.map { |appointment| appointment[:id] }
1987
+ end
1988
+ end
1989
+ ```
1990
+
1991
+ ```ruby
1992
+ # app/controllers/some_controller.rb
1993
+
1994
+ booking.update(params)
1995
+ ```
1996
+
1997
+ #### Record getters
1998
+
1999
+ If you implement accompanying getter methods, the whole data conversion would be internal only:
2000
+
2001
+ ```ruby
2002
+ # app/models/user.rb
2003
+
2004
+ class Feedback < DHS::Record
2005
+
2006
+ def ratings=(values)
2007
+ super(
2008
+ values.map { |k, v| { name: k, value: v } }
2009
+ )
2010
+ end
2011
+
2012
+ def ratings
2013
+ super.map { |r| [r[:name], r[:value]] }]
2014
+ end
2015
+ end
2016
+ ```
2017
+
2018
+ ```ruby
2019
+ # app/controllers/some_controller.rb
2020
+
2021
+ record = Record.new(ratings: { quality: 3 })
2022
+ record.ratings # {:quality=>3}
2023
+ ```
2024
+
2025
+ ### Include linked resources (hyperlinks and hypermedia)
2026
+
2027
+ In a service-oriented architecture using [hyperlinks](https://en.wikipedia.org/wiki/Hyperlink)/[hypermedia](https://en.wikipedia.org/wiki/Hypermedia), records/resources can contain hyperlinks to other records/resources.
2028
+
2029
+ When fetching records with DHS, you can specify in advance all the linked resources that you want to include in the results.
2030
+
2031
+ With `includes` DHS ensures that all matching and explicitly linked resources are loaded and merged (even if the linked resources are paginated).
2032
+
2033
+ 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.
2034
+
2035
+ #### Generate links from parameters
2036
+
2037
+ Sometimes you need to generate full hrefs/urls for records but you just have parameters that describe that record, like the ID.
2038
+
2039
+ For those usecases you can use `href_for(params)`:
2040
+
2041
+ ```ruby
2042
+ # app/controllers/some_controller.rb
2043
+
2044
+ Presence.create(place: { href: Place.href_for(123) })
2045
+ ```
2046
+ ```
2047
+ POST '/presences' { place: { href: "http://datastore/places/123" } }
2048
+ ```
2049
+
2050
+ #### Ensure the whole linked collection is included with includes
2051
+
2052
+ 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`.
2053
+
2054
+ DHS will ensure that all linked resources are around by loading all pages (parallelized/performance optimized).
2055
+
2056
+ ```ruby
2057
+ # app/controllers/some_controller.rb
2058
+
2059
+ customer = Customer.includes(contracts: :products).find(1)
2060
+ ```
2061
+ ```
2062
+ > GET https://service.example.com/customers/1
2063
+ < {... contracts: { href: 'https://service.example.com/customers/1/contracts' } }
2064
+ > GET https://service.example.com/customers/1/contracts?limit=100
2065
+ < {... items: [...], limit: 10, offset: 0, total: 32 }
2066
+ In parallel:
2067
+ > GET https://service.example.com/customers/1/contracts?limit=10&offset=10
2068
+ < {... products: [{ href: 'https://service.example.com/product/LBC' }] }
2069
+ > GET https://service.example.com/customers/1/contracts?limit=10&offset=20
2070
+ < {... products: [{ href: 'https://service.example.com/product/LBB' }] }
2071
+ In parallel:
2072
+ > GET https://service.example.com/product/LBC
2073
+ < {... name: 'Local Business Card' }
2074
+ > GET https://service.example.com/product/LBB
2075
+ < {... name: 'Local Business Basic' }
2076
+ ```
2077
+
2078
+ ```ruby
2079
+ # app/controllers/some_controller.rb
2080
+
2081
+ customer.contracts.length # 32
2082
+ customer.contracts.first.products.first.name # Local Business Card
2083
+
2084
+ ```
2085
+
2086
+ #### Include only the first linked page of a linked collection: includes_first_page
2087
+
2088
+ `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.**
2089
+
2090
+ ```ruby
2091
+ # app/controllers/some_controller.rb
2092
+
2093
+ customer = Customer.includes_first_page(contracts: :products).find(1)
2094
+ ```
2095
+ ```
2096
+ > GET https://service.example.com/customers/1
2097
+ < {... contracts: { href: 'https://service.example.com/customers/1/contracts' } }
2098
+ > GET https://service.example.com/customers/1/contracts?limit=100
2099
+ < {... items: [...], limit: 10, offset: 0, total: 32 }
2100
+ In parallel:
2101
+ > GET https://service.example.com/product/LBC
2102
+ < {... name: 'Local Business Card' }
2103
+ > GET https://service.example.com/product/LBB
2104
+ < {... name: 'Local Business Basic' }
2105
+ ```
2106
+
2107
+ ```ruby
2108
+ # app/controllers/some_controller.rb
2109
+
2110
+ customer.contracts.length # 10
2111
+ customer.contracts.first.products.first.name # Local Business Card
2112
+
2113
+ ```
2114
+
2115
+ #### Include various levels of linked data
2116
+
2117
+ The method syntax of `includes` allows you include hyperlinks stored in deep nested data strutures:
2118
+
2119
+ Some examples:
2120
+
2121
+ ```ruby
2122
+ Record.includes(:depay_account, :entry)
2123
+ # Includes depay_account -> entry
2124
+ # { depay_account: { href: '...', entry: { href: '...' } } }
2125
+
2126
+ Record.includes([:depay_account, :entry])
2127
+ # Includes depay_account and entry
2128
+ # { depay_account: { href: '...' }, entry: { href: '...' } }
2129
+
2130
+ Record.includes(campaign: [:entry, :user])
2131
+ # Includes campaign and entry and user from campaign
2132
+ # { campaign: { href: '...' , entry: { href: '...' }, user: { href: '...' } } }
2133
+ ```
2134
+
2135
+ #### Identify and cast known records when including records
2136
+
2137
+ 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.
2138
+
2139
+ That also means that options for endpoints of linked resources are applied when requesting those in addition.
2140
+
2141
+ This applies for example a records endpoint configuration even though it's fetched/included through another record:
2142
+
2143
+ ```ruby
2144
+ # app/models/favorite.rb
2145
+
2146
+ class Favorite < DHS::Record
2147
+
2148
+ endpoint '{+service}/users/{user_id}/favorites', auth: { basic: { username: 'steve', password: 'can' } }
2149
+ endpoint '{+service}/users/{user_id}/favorites/:id', auth: { basic: { username: 'steve', password: 'can' } }
2150
+
2151
+ end
2152
+ ```
2153
+
2154
+ ```ruby
2155
+ # app/models/place.rb
2156
+
2157
+ class Place < DHS::Record
2158
+
2159
+ endpoint '{+service}/v2/places', auth: { basic: { username: 'steve', password: 'can' } }
2160
+ endpoint '{+service}/v2/places/{id}', auth: { basic: { username: 'steve', password: 'can' } }
2161
+
2162
+ end
2163
+ ```
2164
+
2165
+ ```ruby
2166
+ # app/controllers/some_controller.rb
2167
+
2168
+ Favorite.includes(:place).where(user_id: current_user.id)
2169
+
2170
+ ```
2171
+ ```
2172
+ > GET https://service.example.com/users/123/favorites { headers: { 'Authentication': 'Basic c3RldmU6Y2Fu' } }
2173
+ < {... items: [... { place: { href: 'https://service.example.com/place/456' } } ] }
2174
+ In parallel:
2175
+ > GET https://service.example.com/place/456 { headers: { 'Authentication': 'Basic c3RldmU6Y2Fu' } }
2176
+ > GET https://service.example.com/place/789 { headers: { 'Authentication': 'Basic c3RldmU6Y2Fu' } }
2177
+ > GET https://service.example.com/place/1112 { headers: { 'Authentication': 'Basic c3RldmU6Y2Fu' } }
2178
+ > GET https://service.example.com/place/5423 { headers: { 'Authentication': 'Basic c3RldmU6Y2Fu' } }
2179
+ ```
2180
+
2181
+ #### Apply options for requests performed to fetch included records
2182
+
2183
+ Use `references` to apply request options to requests performed to fetch included records:
2184
+
2185
+ ```ruby
2186
+ # app/controllers/some_controller.rb
2187
+
2188
+ Favorite.includes(:place).references(place: { auth: { bearer: '123' }}).where(user_id: 1)
2189
+ ```
2190
+ ```
2191
+ GET https://service.example.com/users/1/favorites
2192
+ {... items: [... { place: { href: 'https://service.example.com/places/2' } }] }
2193
+ In parallel:
2194
+ GET https://service.example.com/places/2 { headers: { 'Authentication': 'Bearer 123' } }
2195
+ GET https://service.example.com/places/3 { headers: { 'Authentication': 'Bearer 123' } }
2196
+ GET https://service.example.com/places/4 { headers: { 'Authentication': 'Bearer 123' } }
2197
+ ```
2198
+
2199
+ Here is another example, if you want to ignore errors, that occure while you fetch included resources:
2200
+
2201
+ ```ruby
2202
+ # app/controllers/some_controller.rb
2203
+
2204
+ feedback = Feedback
2205
+ .includes(campaign: :entry)
2206
+ .references(campaign: { ignore: DHC::NotFound })
2207
+ .find(12345)
2208
+ ```
2209
+
2210
+ #### compact: Remove included resources that didn't return any records
2211
+
2212
+ In case you include nested data and ignored errors while including, it can happen that you get back a collection that contains data based on response errors:
2213
+
2214
+ ```ruby
2215
+ # app/controllers/some_controller.rb
2216
+
2217
+ user = User
2218
+ .includes(:places)
2219
+ .references(places: { ignore: DHC::NotFound })
2220
+ .find(123)
2221
+ ```
2222
+
2223
+ ```
2224
+ GET http://service/users/123
2225
+ { "places": { "href": "http://service/users/123/places" } }
2226
+
2227
+ GET http://service/users/123/places
2228
+ { "items": [
2229
+ { "href": "http://service/places/1" },
2230
+ { "href": "http://service/places/2" }
2231
+ ] }
2232
+
2233
+ GET http://service/places/1
2234
+ 200 { "name": "Casa Ferlin" }
2235
+
2236
+ GET http://service/places/2
2237
+ 404 { "status": 404, "error": "not found" }
2238
+ ```
2239
+
2240
+ ```ruby
2241
+ user.places[1] # { "status": 404, "error": "not found" }
2242
+ ```
2243
+
2244
+ In order to exclude items from a collection which where not based on successful responses, use `.compact` or `.compact!`:
2245
+
2246
+ ```ruby
2247
+ # app/controllers/some_controller.rb
2248
+
2249
+ user = User
2250
+ .includes(:places)
2251
+ .references(places: { ignore: DHC::NotFound })
2252
+ .find(123)
2253
+ places = user.places.compact
2254
+
2255
+ places # { "items": [ { "href": "http://service/places/1", "name": "Casa Ferlin" } ] }
2256
+ ```
2257
+
2258
+ ### Record batch processing
2259
+
2260
+ **Be careful using methods for batch processing. They could result in a lot of HTTP requests!**
2261
+
2262
+ #### all
2263
+
2264
+ `all` fetches all records from the service by doing multiple requests, best-effort parallelization, and resolving endpoint pagination if necessary:
2265
+
2266
+ ```ruby
2267
+ records = Record.all
2268
+ ```
2269
+ ```
2270
+ > GET https://service.example.com/records?limit=100
2271
+ < {...
2272
+ items: [...]
2273
+ total: 900,
2274
+ limit: 100,
2275
+ offset: 0
2276
+ }
2277
+ In parallel:
2278
+ > GET https://service.example.com/records?limit=100&offset=100
2279
+ > GET https://service.example.com/records?limit=100&offset=200
2280
+ > GET https://service.example.com/records?limit=100&offset=300
2281
+ > GET https://service.example.com/records?limit=100&offset=400
2282
+ > GET https://service.example.com/records?limit=100&offset=500
2283
+ > GET https://service.example.com/records?limit=100&offset=600
2284
+ > GET https://service.example.com/records?limit=100&offset=700
2285
+ > GET https://service.example.com/records?limit=100&offset=800
2286
+ ```
2287
+
2288
+ `all` is chainable and has the same interface like `where`:
2289
+
2290
+ ```ruby
2291
+ Record.where(color: 'blue').all
2292
+ Record.all.where(color: 'blue')
2293
+ Record.all(color: 'blue')
2294
+ ```
2295
+
2296
+ All three are doing the same thing: fetching all records with the color 'blue' from the endpoint while resolving pagingation if endpoint is paginated.
2297
+
2298
+ ##### Using all, when endpoint does not implement response pagination meta data
2299
+
2300
+ In case an API does not provide pagination information in the repsponse data (limit, offset and total), DHS keeps on loading pages when requesting `all` until the first empty page responds.
2301
+
2302
+ #### find_each
2303
+
2304
+ `find_each` is a more fine grained way to process single records that are fetched in batches.
2305
+
2306
+ ```ruby
2307
+ Record.find_each(start: 50, batch_size: 20, params: { has_reviews: true }, headers: { 'Authorization': 'Bearer 123' }) do |record|
2308
+ # Iterates over each record. Starts with record no. 50 and fetches 20 records each batch.
2309
+ record
2310
+ break if record.some_attribute == some_value
2311
+ end
2312
+ ```
2313
+
2314
+ #### find_in_batches
2315
+
2316
+ `find_in_batches` is used by `find_each` and processes batches.
2317
+
2318
+ ```ruby
2319
+ Record.find_in_batches(start: 50, batch_size: 20, params: { has_reviews: true }, headers: { 'Authorization': 'Bearer 123' }) do |records|
2320
+ # Iterates over multiple records (batch size is 20). Starts with record no. 50 and fetches 20 records each batch.
2321
+ records
2322
+ break if records.first.name == some_value
2323
+ end
2324
+ ```
2325
+
2326
+ ### Convert/Cast specific record types: becomes
2327
+
2328
+ Based on [ActiveRecord's implementation](https://api.rubyonrails.org/classes/ActiveRecord/Persistence.html#method-i-becomes), DHS implements `becomes`, too.
2329
+
2330
+ It's a way to convert records of a certain type A to another certain type B.
2331
+
2332
+ _NOTE: RPC-style actions, that are discouraged in REST anyway, are utilizable with this functionality, too. See the following example:_
2333
+
2334
+ ```ruby
2335
+ # app/models/location.rb
2336
+
2337
+ class Location < DHS::Record
2338
+ endpoint '{+service}/locations'
2339
+ endpoint '{+service}/locations/{id}'
2340
+ end
2341
+ ```
2342
+
2343
+ ```ruby
2344
+ # app/models/synchronization.rb
2345
+
2346
+ class Synchronization < DHS::Record
2347
+ endpoint '{+service}/locations/{id}/sync'
2348
+ end
2349
+ ```
2350
+
2351
+ ```ruby
2352
+ # app/controllers/some_controller.rb
2353
+
2354
+ location = Location.find(1)
2355
+ ```
2356
+ ```
2357
+ GET https://service.example.com/location/1
2358
+ ```
2359
+
2360
+ ```ruby
2361
+ # app/controllers/some_controller.rb
2362
+
2363
+ synchronization = location.becomes(Synchronization)
2364
+ synchronization.save!
2365
+ ```
2366
+ ```
2367
+ POST https://service.example.com/location/1/sync { body: '{ ... }' }
2368
+ ```
2369
+
2370
+ ### Assign attributes
2371
+
2372
+ Allows you to set the attributes by passing in a hash of attributes.
2373
+
2374
+ ```ruby
2375
+ entry = LocalEntry.new
2376
+ entry.assign_attributes(company_name: 'DePay')
2377
+ entry.company_name # => 'DePay'
2378
+ ```
2379
+
2380
+ ## Request Cycle Cache
2381
+
2382
+ By default, DHS does not perform the same http request multiple times during one request/response cycle.
2383
+
2384
+ ```ruby
2385
+ # app/models/user.rb
2386
+
2387
+ class User < DHS::Record
2388
+ endpoint '{+service}/users/{id}'
2389
+ end
2390
+ ```
2391
+
2392
+ ```ruby
2393
+ # app/models/location.rb
2394
+
2395
+ class Location < DHS::Record
2396
+ endpoint '{+service}/locations/{id}'
2397
+ end
2398
+ ```
2399
+
2400
+ ```ruby
2401
+ # app/controllers/some_controller.rb
2402
+
2403
+ def index
2404
+ @user = User.find(1)
2405
+ @locations = Location.includes(:owner).find(2)
2406
+ end
2407
+ ```
2408
+ ```
2409
+ GET https://service.example.com/users/1
2410
+ GET https://service.example.com/location/2
2411
+ {... owner: { href: 'https://service.example.com/users/1' } }
2412
+ From cache:
2413
+ GET https://service.example.com/users/1
2414
+ ```
2415
+
2416
+ It uses the [DHC Caching Interceptor](https://github.com/DePayFi/dhc#caching-interceptor) as caching mechanism base and sets a unique request id for every request cycle with Railties to ensure data is just cached within one request cycle and not shared with other requests.
2417
+
2418
+ Only GET requests are considered for caching by using DHC Caching Interceptor's `cache_methods` option internally and considers request headers when caching requests, so requests with different headers are not served from cache.
2419
+
2420
+ The DHS Request Cycle Cache is opt-out, so it's enabled by default and will require you to enable the [DHC Caching Interceptor](https://github.com/DePayFi/dhc#caching-interceptor) in your project.
2421
+
2422
+ ### Change store for DHS' request cycle cache
2423
+
2424
+ By default the DHS Request Cycle Cache will use `ActiveSupport::Cache::MemoryStore` as its cache store. Feel free to configure a cache that is better suited for your needs by:
2425
+
2426
+ ```ruby
2427
+ # config/initializers/dhs.rb
2428
+
2429
+ DHS.configure do |config|
2430
+ config.request_cycle_cache = ActiveSupport::Cache::MemoryStore.new
2431
+ end
2432
+ ```
2433
+
2434
+ ### Disable request cycle cache
2435
+
2436
+ If you want to disable the DHS Request Cycle Cache, simply disable it within configuration:
2437
+
2438
+ ```ruby
2439
+ # config/initializers/dhs.rb
2440
+
2441
+ DHS.configure do |config|
2442
+ config.request_cycle_cache_enabled = false
2443
+ end
2444
+ ```
2445
+
2446
+ ## Automatic Authentication (OAuth)
2447
+
2448
+ DHS provides a way to have records automatically fetch and use OAuth authentication when performing requests within Rails.
2449
+
2450
+ In order to enable automatic oauth authentication, perform the following steps:
2451
+
2452
+ 1. Make sure DHS is configured to perform `auto_oauth`. Provide a block that, when executed in the controller context, returns a valid access_token/bearer_token.
2453
+ ```ruby
2454
+ # config/initializers/dhs.rb
2455
+
2456
+ DHS.configure do |config|
2457
+ config.auto_oauth = -> { access_token }
2458
+ end
2459
+ ```
2460
+
2461
+ 2. Opt-in records requiring oauth authentication:
2462
+
2463
+ ```ruby
2464
+ # app/models/record.rb
2465
+
2466
+ class Record < DHS::Record
2467
+ oauth
2468
+ # ...
2469
+ end
2470
+ ```
2471
+
2472
+ 3. Include the `DHS::OAuth` context into your application controller:
2473
+
2474
+ ```ruby
2475
+ # app/controllers/application_controller.rb
2476
+
2477
+ class ApplicationController < ActionController::Base
2478
+ include DHS::OAuth
2479
+
2480
+ # ...
2481
+ end
2482
+ ```
2483
+
2484
+ 4. Make sure you have the `DHC::Auth` interceptor enabled:
2485
+
2486
+ ```ruby
2487
+ # config/initializers/dhc.rb
2488
+
2489
+ DHC.configure do |config|
2490
+ config.interceptors = [DHC::Auth]
2491
+ end
2492
+ ```
2493
+
2494
+ Now you can perform requests based on the record that will be auto authenticated from now on:
2495
+
2496
+ ```ruby
2497
+ # app/controllers/some_controller.rb
2498
+
2499
+ Record.find(1)
2500
+ ```
2501
+ ```
2502
+ https://records/1
2503
+ Authentication: 'Bearer token-12345'
2504
+ ```
2505
+
2506
+ ### Configure multiple auth providers (even per endpoint)
2507
+
2508
+ In case you need to configure multiple auth provider access_tokens within your application,
2509
+ make sure you provide a proc returning a hash when configuring `auto_oauth`,
2510
+ naming every single provider and the responsive method to retrieve the access_tokens in the controller context:
2511
+
2512
+ ```ruby
2513
+ # config/initializers/dhs.rb
2514
+ DHS.configure do |config|
2515
+ config.auto_oauth = proc do
2516
+ {
2517
+ provider1: access_token_provider_1,
2518
+ provider2: access_token_provider_2
2519
+ }
2520
+ end
2521
+ end
2522
+ ```
2523
+
2524
+ Then make sure you either define which provider to use on a record level:
2525
+
2526
+ ```ruby
2527
+ # model/record.rb
2528
+ class Record < DHS::Record
2529
+ oauth(:provider1)
2530
+ #...
2531
+ end
2532
+ ```
2533
+
2534
+ or on an endpoint level:
2535
+
2536
+ ```ruby
2537
+ # model/record.rb
2538
+ class Record < DHS::Record
2539
+ endpoint 'https://service/records', oauth: :provider1
2540
+ #...
2541
+ end
2542
+ ```
2543
+
2544
+ ### Configure providers
2545
+
2546
+ If you're using DHS service providers, you can also configure auto auth on a provider level:
2547
+
2548
+ ```ruby
2549
+ # app/models/providers/depay.rb
2550
+ module Providers
2551
+ class Depay < DHS::Record
2552
+
2553
+ provider(
2554
+ oauth: true
2555
+ )
2556
+ end
2557
+ end
2558
+ ```
2559
+
2560
+ or with multiple auth providers:
2561
+
2562
+ ```ruby
2563
+ # app/models/providers/depay.rb
2564
+ module Providers
2565
+ class Depay < DHS::Record
2566
+
2567
+ provider(
2568
+ oauth: :provider_1
2569
+ )
2570
+ end
2571
+ end
2572
+ ```
2573
+
2574
+ ## Option Blocks
2575
+
2576
+ In order to apply options to all requests performed in a give block, DHS provides option blocks.
2577
+
2578
+ ```ruby
2579
+ # app/controllers/records_controller.rb
2580
+
2581
+ DHS.options(headers: { 'Tracking-Id' => 123 }) do
2582
+ Record.find(1)
2583
+ end
2584
+
2585
+ Record.find(2)
2586
+ ```
2587
+ ```
2588
+ GET https://records/1 { headers: { 'Tracking-Id' => '123' } }
2589
+ GET https://records/2 { headers: { } }
2590
+ ```
2591
+
2592
+ ## Request tracing
2593
+
2594
+ DHS supports tracing the source (in your application code) of http requests being made with methods like `find find_by find_by! first first! last last!`.
2595
+
2596
+ Following links, and using `includes` are not traced (just yet).
2597
+
2598
+ In order to enable tracing you need to enable it via DHS configuration:
2599
+
2600
+ ```ruby
2601
+ # config/initializers/dhs.rb
2602
+
2603
+ DHS.configure do |config|
2604
+ config.trace = Rails.env.development? || Rails.logger.level == 0 # debug
2605
+ end
2606
+ ```
2607
+
2608
+ ```ruby
2609
+ # app/controllers/application_controller.rb
2610
+
2611
+ code = Code.find(code: params[:code])
2612
+ ```
2613
+ ```
2614
+ Called from onboarding/app/controllers/concerns/access_code_concern.rb:11:in `access_code'
2615
+ ```
2616
+
2617
+ However, following links and includes won't get traced (just yet):
2618
+
2619
+ ```ruby
2620
+ # app/controllers/application_controller.rb
2621
+
2622
+ code = Code.includes(:places).find(123)
2623
+ ```
2624
+
2625
+ ```
2626
+ # Nothing is traced
2627
+ {
2628
+ places: [...]
2629
+ }
2630
+ ```
2631
+
2632
+ ```ruby
2633
+ code.places
2634
+ ```
2635
+ ```
2636
+ {
2637
+ token: "XYZABCDEF",
2638
+ places:
2639
+ [
2640
+ { href: "http://storage-stg.preprod-depay.fi/v2/places/egZelgYhdlg" }
2641
+ ]
2642
+ }
2643
+ ```
2644
+
2645
+ ## Extended Rollbar Logging
2646
+
2647
+ In order to log all requests/responses prior to an exception reported by Rollbar in addition to the exception itself, use the `DHS::ExtendedRollbar` interceptor in combination with the rollbar processor/handler:
2648
+
2649
+ ```ruby
2650
+ # config/initializers/dhc.rb
2651
+
2652
+ DHC.configure do |config|
2653
+ config.interceptors = [DHS::ExtendedRollbar]
2654
+ end
2655
+ ```
2656
+
2657
+ ```ruby
2658
+ # config/initializers/rollbar.rb
2659
+
2660
+ Rollbar.configure do |config|
2661
+ config.before_process << DHS::Interceptors::ExtendedRollbar::Handler.init
2662
+ end
2663
+ ```
2664
+
2665
+ ## Testing with DHS
2666
+
2667
+ **Best practice in regards of testing applications using DHS, is to let DHS fetch your records, actually perform HTTP requests and [WebMock](https://github.com/bblimke/webmock) to stub/mock those http requests/responses.**
2668
+
2669
+ This follows the [Black Box Testing](https://en.wikipedia.org/wiki/Black-box_testing) approach and prevents you from creating constraints to DHS' internal structures and mechanisms, which will break as soon as we change internals.
2670
+
2671
+ ```ruby
2672
+ # specs/*/some_spec.rb
2673
+
2674
+ let(:contracts) do
2675
+ [
2676
+ {number: '1'},
2677
+ {number: '2'},
2678
+ {number: '3'}
2679
+ ]
2680
+ end
2681
+
2682
+ before do
2683
+ stub_request(:get, "https://service.example.com/contracts")
2684
+ .to_return(
2685
+ body: {
2686
+ items: contracts,
2687
+ limit: 10,
2688
+ total: contracts.length,
2689
+ offset: 0
2690
+ }.to_json
2691
+ )
2692
+ end
2693
+
2694
+ it 'displays contracts' do
2695
+ visit 'contracts'
2696
+ contracts.each do |contract|
2697
+ expect(page).to have_content(contract[:number])
2698
+ end
2699
+ end
2700
+ ```
2701
+
2702
+ ### Test helper
2703
+
2704
+ In order to load DHS test helpers into your tests, add the following to your spec helper:
2705
+
2706
+ ```ruby
2707
+ # spec/spec_helper.rb
2708
+
2709
+ require 'dhs/rspec'
2710
+ ```
2711
+
2712
+ This e.g. will prevent running into caching issues during your tests, when (request cycle cache)[#request-cycle-cache] is enabled.
2713
+ It will initialize a MemoryStore cache for DHC::Caching interceptor and resets the cache before every test.
2714
+
2715
+ #### Stub
2716
+
2717
+ DHS offers stub helpers that simplify stubbing https request to your apis through your defined Records.
2718
+
2719
+ ##### stub_all
2720
+
2721
+ `Record.stub_all(url, items, additional_options)`
2722
+
2723
+ ```ruby
2724
+ # your_spec.rb
2725
+
2726
+ before do
2727
+ class Record < DHS::Record
2728
+ endpoint 'https://records'
2729
+ end
2730
+
2731
+ Record.stub_all(
2732
+ 'https://records',
2733
+ 200.times.map{ |index| { name: "Item #{index}" } },
2734
+ headers: {
2735
+ 'Authorization' => 'Bearer 123'
2736
+ }
2737
+ )
2738
+ end
2739
+ ```
2740
+ ```
2741
+ GET https://records?limit=100
2742
+ GET https://records?limit=100&offset=100
2743
+ ```
2744
+
2745
+ DHS also uses Record configuration when stubbing all.
2746
+ ```ruby
2747
+ # your_spec.rb
2748
+
2749
+ before do
2750
+ class Record < DHS::Record
2751
+ configuration limit_key: :per_page, pagination_strategy: :page, pagination_key: :page
2752
+
2753
+ endpoint 'https://records'
2754
+ end
2755
+
2756
+ Record.stub_all(
2757
+ 'https://records',
2758
+ 200.times.map{ |index| { name: "Item #{index}" } }
2759
+ )
2760
+ end
2761
+ ```
2762
+ ```
2763
+ GET https://records?per_page=100
2764
+ GET https://records?per_page=100&page=2
2765
+ ```
2766
+
2767
+ ### Test query chains
2768
+
2769
+ #### By explicitly resolving the chain: fetch
2770
+
2771
+ Use `fetch` in tests to resolve chains in place and expect WebMock stubs to be requested.
2772
+
2773
+ ```ruby
2774
+ # specs/*/some_spec.rb
2775
+
2776
+ records = Record.where(color: 'blue').where(available: true).where(color: 'red')
2777
+
2778
+ expect(
2779
+ records.fetch
2780
+ ).to have_requested(:get, %r{records/})
2781
+ .with(query: hash_including(color: 'blue', available: true))
2782
+ ```
2783
+
2784
+ #### Without resolving the chain: where_values_hash
2785
+
2786
+ As `where` chains are not resolving to HTTP-requests when no data is accessed, you can use `where_values_hash` to access the values that would be used to resolve the chain, and test those:
2787
+
2788
+ ```ruby
2789
+ # specs/*/some_spec.rb
2790
+
2791
+ records = Record.where(color: 'blue').where(available: true).where(color: 'red')
2792
+
2793
+ expect(
2794
+ records.where_values_hash
2795
+ ).to eq {color: 'red', available: true}
2796
+ ```
2797
+
2798
+ ## Extended developer documentation
2799
+
2800
+ ### Accessing data in DHS
2801
+
2802
+ ![diagram](docs/accessing-data-in-dhs.png)
2803
+
2804
+ ## License
2805
+
2806
+ [GNU General Public License Version 3.](https://www.gnu.org/licenses/gpl-3.0.en.html)
2807
+