lutaml-hal 0.1.4 → 0.1.6

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b1006ba9c57ff945fa51c368b1ca12971d83fc47f668513a962da76811b6cc91
4
- data.tar.gz: 4bc4bc6697181d9512604736ba42a6d4ce16d67d235ffd5ef79b481e09ca8243
3
+ metadata.gz: 173922c9afbab790441aa0b40fc7a2401c78c72cbcc55d8cf3a7de737e12ea88
4
+ data.tar.gz: 8fdde395a4ac1898f2df9a259210127a3955e8d49d6b2cf3b710649daae0d3d7
5
5
  SHA512:
6
- metadata.gz: befafec41b21cd9244ae937bdb5ce5e975a99bd708cd960e68b4db122554b73d73ef506bc82bda0961d196f10785957c10246d14607345d2e8239420571b1a58
7
- data.tar.gz: e719a907ff8e198f797691829b4fab1280e866f8a403ca80945a55f0178d48a0737c274dc12f3052c635d96f53d7ca3663081f12359293638aa7d6b2d45f9845
6
+ metadata.gz: b9bfd6e3a62e7359b4e0ad38ed72b3f300d23640d44f31942a5a178ef0bec5617d1d6202360bc9ec6cee10ff911e62a81752659ed5820c25e731e8304dbd7158
7
+ data.tar.gz: 6b256a2a898e2ed123c267ac7ee93780e0aa77b3715832e9f15856c0d9f600b1d05d080d469569060c086d348fc9a294d5d9eff6c6269c838d0ecd9883988eb5
data/Gemfile CHANGED
@@ -6,7 +6,8 @@ source 'https://rubygems.org'
6
6
  gemspec
7
7
 
8
8
  gem 'rake'
9
- gem 'rspec', '~> 3.12'
9
+ gem 'rspec'
10
10
  gem 'rubocop'
11
-
12
- gem 'lutaml-model', git: 'https://github.com/lutaml/lutaml-model.git'
11
+ gem 'rubocop-performance'
12
+ gem 'rubocop-rake'
13
+ gem 'rubocop-rspec'
data/README.adoc CHANGED
@@ -552,16 +552,28 @@ You can define endpoints for collections (index) and individual resources
552
552
 
553
553
  The `add_endpoint` method takes the following parameters:
554
554
 
555
+
555
556
  `id`:: A unique identifier for the endpoint.
557
+
556
558
  `type`:: The type of endpoint, which can be `index` or `resource`.
557
- `url`:: The URL of the endpoint, which can include path parameters.
558
- `model`:: The class of the resource that will be fetched from the API.
559
- The class must inherit from `Lutaml::Hal::Resource`.
560
559
 
560
+ `url`:: The URL of the endpoint, which can include path parameters.
561
+ +
561
562
  In the `url`, you can use interpolation parameters, which will be replaced with
562
563
  the actual values when fetching the resource. The interpolation parameters are
563
564
  defined in the `url` string using curly braces `{}`.
564
565
 
566
+ `model`:: The class of the resource that will be fetched from the API.
567
+ The class must inherit from `Lutaml::Hal::Resource`.
568
+
569
+ `query_params`:: (optional) A hash defining query parameters that should be
570
+ appended to the URL when fetching the resource. Supports parameter templates
571
+ using curly braces `{}` for dynamic values.
572
+ +
573
+ This is essential for APIs that require query parameters for pagination,
574
+ filtering, or other functionality where the same base URL needs different query
575
+ parameters to access different resources or views.
576
+
565
577
  The `add_endpoint` method will automatically handle the URL resolution and fetch
566
578
  the resource from the API.
567
579
 
@@ -592,6 +604,35 @@ register.add_endpoint( <1>
592
604
  <5> The `model` is the class of the resource that will be fetched from
593
605
  the API. The class must inherit from `Lutaml::Hal::Resource`.
594
606
 
607
+
608
+ .Example of registering and using query parameters
609
+ [example]
610
+ ====
611
+ [source,ruby]
612
+ ----
613
+ # Register an endpoint that supports pagination via query parameters
614
+ register.add_endpoint(
615
+ id: :product_index,
616
+ type: :index,
617
+ url: '/products',
618
+ model: ProductIndex,
619
+ query_params: {
620
+ 'page' => '{page}',
621
+ 'items' => '{items}'
622
+ }
623
+ )
624
+
625
+ # Fetch the first page with 10 items per page
626
+ page_1 = register.fetch(:product_index, page: 1, items: 10)
627
+ # => client.get('/products?page=1&items=10')
628
+
629
+ # Fetch the second page with 5 items per page
630
+ page_2 = register.fetch(:product_index, page: 2, items: 5)
631
+ # => client.get('/products?page=2&items=5')
632
+ ----
633
+ ====
634
+
635
+
595
636
  .Example of registering the Product class to both index and resource endpoints
596
637
  [example]
597
638
  ====
@@ -614,6 +655,34 @@ register.add_endpoint(
614
655
  ====
615
656
 
616
657
 
658
+ .Example of using query_params for pagination
659
+ [example]
660
+ ====
661
+ [source,ruby]
662
+ ----
663
+ # Register an endpoint that supports pagination via query parameters
664
+ register.add_endpoint(
665
+ id: :product_index_paginated,
666
+ type: :index,
667
+ url: '/products',
668
+ model: ProductIndex,
669
+ query_params: {
670
+ 'page' => '{page}',
671
+ 'items' => '{items}'
672
+ }
673
+ )
674
+
675
+ # Fetch the first page with 10 items per page
676
+ page_1 = register.fetch(:product_index_paginated, page: 1, items: 10)
677
+ # => client.get('/products?page=1&items=10')
678
+
679
+ # Fetch the second page with 5 items per page
680
+ page_2 = register.fetch(:product_index_paginated, page: 2, items: 5)
681
+ # => client.get('/products?page=2&items=5')
682
+ ----
683
+ ====
684
+
685
+
617
686
  [[defining_hal_page_models]]
618
687
  === Defining HAL page models
619
688
 
@@ -920,12 +989,87 @@ product_2_related_1 = product_2.links.related.first.realize
920
989
 
921
990
  === Handling HAL pages / pagination
922
991
 
992
+ ==== General
993
+
923
994
  The `Lutaml::Hal::Page` class is used to handle pagination in HAL APIs.
924
995
 
925
996
  As described in <<defining_hal_page_models>>, subclassing the `Page` class
926
997
  provides pagination capabilities, including the management of links to navigate
927
998
  through pages of resources.
928
999
 
1000
+ ==== Pagination navigation methods
1001
+
1002
+ The `Page` class provides several convenience methods for navigating through
1003
+ paginated results:
1004
+
1005
+ `#next_page`:: Returns the next page link if available, `nil` otherwise.
1006
+
1007
+ `#prev_page`:: Returns the previous page link if available, `nil` otherwise.
1008
+
1009
+ `#first_page`:: Returns the first page link if available, `nil` otherwise.
1010
+
1011
+ `#last_page`:: Returns the last page link if available, `nil` otherwise.
1012
+
1013
+ These methods return `Link` objects that can be realized using the `realize` method:
1014
+
1015
+ [source,ruby]
1016
+ ----
1017
+ # Navigate to next page
1018
+ if current_page.next_page
1019
+ next_page = current_page.next_page.realize
1020
+ end
1021
+
1022
+ # Navigate to previous page
1023
+ if current_page.prev_page
1024
+ prev_page = current_page.prev_page.realize
1025
+ end
1026
+
1027
+ # Jump to first or last page
1028
+ first_page = current_page.first_page.realize if current_page.first_page
1029
+ last_page = current_page.last_page.realize if current_page.last_page
1030
+ ----
1031
+
1032
+ ==== Pagination helper methods
1033
+
1034
+ The `Page` class also provides helper methods to check the availability of
1035
+ navigation links:
1036
+
1037
+ `#has_next?`:: Returns `true` if there is a next page available, `false`
1038
+ otherwise.
1039
+
1040
+ `#has_prev?`:: Returns `true` if there is a previous page available, `false`
1041
+ otherwise.
1042
+
1043
+ `#has_first?`:: Returns `true` if there is a first page link available, `false`
1044
+ otherwise.
1045
+
1046
+ `#has_last?`:: Returns `true` if there is a last page link available, `false`
1047
+ otherwise.
1048
+
1049
+ `#total_pages`:: Returns the total number of pages (alias for the `pages`
1050
+ attribute).
1051
+
1052
+
1053
+ ==== Exhaustive pagination
1054
+
1055
+ For scenarios where you need to process all pages of results, you can combine
1056
+ the pagination methods:
1057
+
1058
+ [source,ruby]
1059
+ ----
1060
+ current_page = register.fetch(:resource_index)
1061
+
1062
+ while current_page
1063
+ # Process current page
1064
+ puts "Processing page #{current_page.page} of #{current_page.total_pages}"
1065
+
1066
+ # Move to next page
1067
+ current_page = current_page.next
1068
+ end
1069
+ ----
1070
+
1071
+
1072
+ ==== Usage
929
1073
 
930
1074
  .Usage example of the Page class
931
1075
  [example]
@@ -980,11 +1124,33 @@ page_1
980
1124
  # next: #<ResourceIndexLink href: "/resources?page=2&items=10">,
981
1125
  # last: #<ResourceIndexLink href: "/resources?page=10&items=10">>>
982
1126
 
1127
+ # Check if navigation is available
1128
+ page_1.has_next? # => true
1129
+ page_1.has_prev? # => false
1130
+ page_1.total_pages # => 10
1131
+
1132
+ # Navigate using convenience methods
1133
+ page_2 = page_1.next
1134
+ # => client.get('/resources?page=2&items=10')
1135
+ # => #<ResourceIndex page: 2, pages: 10, limit: 10, total: 100, ...>
1136
+
1137
+ page_2.has_prev? # => true
1138
+ page_2.has_next? # => true
1139
+
1140
+ # Navigate back to first page
1141
+ first_page = page_2.first
1142
+ # => client.get('/resources?page=1&items=10')
1143
+
1144
+ # Jump to last page
1145
+ last_page = page_2.last
1146
+ # => client.get('/resources?page=10&items=10')
1147
+
1148
+ # Alternative: using link realization (original method)
983
1149
  # Without a GlobalRegister
984
- page_2 = page.links.next.realize(register)
1150
+ page_2 = page_1.links.next.realize(register)
985
1151
 
986
1152
  # With a GlobalRegister
987
- page_2 = page.links.next.realize
1153
+ page_2 = page_1.links.next.realize
988
1154
 
989
1155
  # => client.get('/resources?page=2&items=10')
990
1156
  # => #<ResourceIndex page: 2, pages: 10, limit: 10, total: 100,
@@ -16,11 +16,11 @@ module Lutaml
16
16
  end
17
17
 
18
18
  # Register a model with its base URL pattern
19
- def add_endpoint(id:, type:, url:, model:)
19
+ def add_endpoint(id:, type:, url:, model:, query_params: nil)
20
20
  @models ||= {}
21
21
 
22
22
  raise "Model with ID #{id} already registered" if @models[id]
23
- if @models.values.any? { |m| m[:url] == url && m[:type] == type }
23
+ if @models.values.any? { |m| m[:url] == url && m[:type] == type && m[:query_params] == query_params }
24
24
  raise "Duplicate URL pattern #{url} for type #{type}"
25
25
  end
26
26
 
@@ -28,7 +28,8 @@ module Lutaml
28
28
  id: id,
29
29
  type: type,
30
30
  url: url,
31
- model: model
31
+ model: model,
32
+ query_params: query_params
32
33
  }
33
34
  end
34
35
 
@@ -38,7 +39,7 @@ module Lutaml
38
39
  raise 'Client not configured' unless client
39
40
 
40
41
  url = interpolate_url(endpoint[:url], params)
41
- response = client.get(url)
42
+ response = client.get(build_url_with_query_params(url, endpoint[:query_params], params))
42
43
 
43
44
  realized_model = endpoint[:model].from_json(response.to_json)
44
45
 
@@ -103,12 +104,82 @@ module Lutaml
103
104
  end
104
105
  end
105
106
 
107
+ def build_url_with_query_params(base_url, query_params_template, params)
108
+ return base_url unless query_params_template
109
+
110
+ query_params = []
111
+ query_params_template.each do |param_name, param_template|
112
+ # If the template is like {page}, look for the param in the passed params
113
+ if param_template.is_a?(String) && param_template.match?(/\{(.+)\}/)
114
+ param_key = param_template.match(/\{(.+)\}/)[1]
115
+ query_params << "#{param_name}=#{params[param_key.to_sym]}" if params[param_key.to_sym]
116
+ end
117
+ end
118
+
119
+ query_params.any? ? "#{base_url}?#{query_params.join('&')}" : base_url
120
+ end
121
+
106
122
  def find_matching_model_class(href)
107
123
  @models.values.find do |model_data|
108
- matches_url?(model_data[:url], href)
124
+ matches_url_with_params?(model_data, href)
109
125
  end&.[](:model)
110
126
  end
111
127
 
128
+ def matches_url_with_params?(model_data, href)
129
+ pattern = model_data[:url]
130
+ query_params = model_data[:query_params]
131
+
132
+ return false unless pattern && href
133
+
134
+ uri = parse_href_uri(href)
135
+ pattern_path = extract_pattern_path(pattern)
136
+
137
+ return false unless path_matches?(pattern_path, uri.path)
138
+ return true unless query_params
139
+
140
+ query_params_match?(query_params, parse_query_params(uri.query))
141
+ end
142
+
143
+ def parse_href_uri(href)
144
+ full_href = href.start_with?('http') ? href : "#{client&.api_url}#{href}"
145
+ URI.parse(full_href)
146
+ end
147
+
148
+ def extract_pattern_path(pattern)
149
+ pattern.split('?').first
150
+ end
151
+
152
+ def path_matches?(pattern_path, href_path)
153
+ if href_path.start_with?('/') && client&.api_url
154
+ path_pattern = extract_path(pattern_path)
155
+ pattern_match?(path_pattern, href_path) || pattern_match?(pattern_path, href_path)
156
+ else
157
+ pattern_match?(pattern_path, href_path)
158
+ end
159
+ end
160
+
161
+ def query_params_match?(expected_params, actual_params)
162
+ expected_params.all? do |param_name, param_pattern|
163
+ actual_value = actual_params[param_name]
164
+ next false unless actual_value
165
+
166
+ template_param?(param_pattern) || actual_value == param_pattern.to_s
167
+ end
168
+ end
169
+
170
+ def template_param?(param_pattern)
171
+ param_pattern.is_a?(String) && param_pattern.match?(/\{.+\}/)
172
+ end
173
+
174
+ def parse_query_params(query_string)
175
+ return {} unless query_string
176
+
177
+ query_string.split('&').each_with_object({}) do |param, hash|
178
+ key, value = param.split('=', 2)
179
+ hash[key] = value if key
180
+ end
181
+ end
182
+
112
183
  def matches_url?(pattern, href)
113
184
  return false unless pattern && href
114
185
 
@@ -134,8 +205,8 @@ module Lutaml
134
205
 
135
206
  # Convert {param} to wildcards for matching
136
207
  pattern_with_wildcards = pattern.gsub(/\{[^}]+\}/, '*')
137
- # Convert * wildcards to regex pattern
138
- regex = Regexp.new("^#{pattern_with_wildcards.gsub('*', '[^/]+')}$")
208
+ # Convert * wildcards to regex pattern - use .+ instead of [^/]+ to match query parameters
209
+ regex = Regexp.new("^#{pattern_with_wildcards.gsub('*', '.+')}$")
139
210
 
140
211
  Hal.debug_log("pattern_match?: regex: #{regex.inspect}")
141
212
  Hal.debug_log("pattern_match?: href to match #{url}")
@@ -32,6 +32,144 @@ module Lutaml
32
32
  end
33
33
  end
34
34
  end
35
+
36
+ # Returns the next page of results, or nil if on the last page
37
+ #
38
+ # @return [Object, nil] The next page instance or nil
39
+ def next
40
+ return nil unless links.next
41
+
42
+ links.next.realize
43
+ end
44
+
45
+ # Returns the previous page of results, or nil if on the first page
46
+ #
47
+ # @return [Object, nil] The previous page instance or nil
48
+ def prev
49
+ # If the API provides a prev link, use it
50
+ return links.prev.realize if links.prev
51
+
52
+ # If we're on page 1, there's no previous page
53
+ return nil if page <= 1
54
+
55
+ # Construct the previous page URL manually
56
+ prev_page_url = construct_page_url(page - 1)
57
+ return nil unless prev_page_url
58
+
59
+ # Use the HAL register to fetch the previous page
60
+ register_name = instance_variable_get("@#{Lutaml::Hal::REGISTER_ID_ATTR_NAME}")
61
+ return nil unless register_name
62
+
63
+ hal_register = Lutaml::Hal::GlobalRegister.instance.get(register_name)
64
+ return nil unless hal_register
65
+
66
+ hal_register.resolve_and_cast(nil, prev_page_url)
67
+ end
68
+
69
+ # Returns the first page of results
70
+ #
71
+ # @return [Object, nil] The first page instance or nil
72
+ def first
73
+ return nil unless links.first
74
+
75
+ links.first.realize
76
+ end
77
+
78
+ # Returns the last page of results
79
+ #
80
+ # @return [Object, nil] The last page instance or nil
81
+ def last
82
+ return nil unless links.last
83
+
84
+ links.last.realize
85
+ end
86
+
87
+ # Returns the total number of pages
88
+ #
89
+ # @return [Integer] The total number of pages
90
+ def total_pages
91
+ pages
92
+ end
93
+
94
+ # Checks if there is a next page available
95
+ #
96
+ # @return [Boolean] true if next page exists, false otherwise
97
+ def next?
98
+ !links.next.nil?
99
+ end
100
+
101
+ # Checks if there is a previous page available
102
+ #
103
+ # @return [Boolean] true if previous page exists, false otherwise
104
+ def prev?
105
+ !links.prev.nil?
106
+ end
107
+
108
+ # Checks if there is a first page link available
109
+ #
110
+ # @return [Boolean] true if first page link exists, false otherwise
111
+ def first?
112
+ !links.first.nil?
113
+ end
114
+
115
+ # Checks if there is a last page link available
116
+ #
117
+ # @return [Boolean] true if last page link exists, false otherwise
118
+ def last?
119
+ !links.last.nil?
120
+ end
121
+
122
+ # Returns the next page link, or nil if on the last page
123
+ #
124
+ # @return [Object, nil] The next page link or nil
125
+ def next_page
126
+ links.next
127
+ end
128
+
129
+ # Returns the previous page link, or nil if on the first page
130
+ #
131
+ # @return [Object, nil] The previous page link or nil
132
+ def prev_page
133
+ links.prev
134
+ end
135
+
136
+ # Returns the first page link
137
+ #
138
+ # @return [Object, nil] The first page link or nil
139
+ def first_page
140
+ links.first
141
+ end
142
+
143
+ # Returns the last page link
144
+ #
145
+ # @return [Object, nil] The last page link or nil
146
+ def last_page
147
+ links.last
148
+ end
149
+
150
+ private
151
+
152
+ # Constructs a URL for a specific page based on the current page's URL pattern
153
+ #
154
+ # @param target_page [Integer] The page number to construct URL for
155
+ # @return [String, nil] The constructed URL or nil if unable to construct
156
+ def construct_page_url(target_page)
157
+ # Try to get a reference URL from next, first, or last links
158
+ reference_url = links.next&.href || links.first&.href || links.last&.href
159
+ return nil unless reference_url
160
+
161
+ # Parse the reference URL and modify the page parameter
162
+ uri = URI.parse(reference_url)
163
+ query_params = URI.decode_www_form(uri.query || '')
164
+
165
+ # Update the page parameter
166
+ query_params = query_params.reject { |key, _| key == 'page' }
167
+ query_params << ['page', target_page.to_s]
168
+
169
+ # Reconstruct the URL
170
+ uri.query = URI.encode_www_form(query_params)
171
+ uri.to_s
172
+ end
35
173
  end
36
174
  end
37
175
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Hal
5
- VERSION = '0.1.4'
5
+ VERSION = '0.1.6'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lutaml-hal
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.4
4
+ version: 0.1.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-03-12 00:00:00.000000000 Z
11
+ date: 2025-07-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday