lutaml-hal 0.1.7 → 0.1.8

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.
@@ -57,6 +57,30 @@ module Lutaml
57
57
  raise LinkResolutionError, "Resource not found: #{e.message}"
58
58
  end
59
59
 
60
+ # Make a GET request with custom headers
61
+ def get_with_headers(url, headers = {})
62
+ cache_key = "#{url}:#{headers.to_json}"
63
+
64
+ return @cache[cache_key] if @cache_enabled && @cache.key?(cache_key)
65
+
66
+ @last_response = @connection.get(url) do |req|
67
+ headers.each { |key, value| req.headers[key] = value }
68
+ end
69
+
70
+ response = handle_response(@last_response, url)
71
+
72
+ @cache[cache_key] = response if @cache_enabled
73
+ response
74
+ rescue Faraday::ConnectionFailed => e
75
+ raise ConnectionError, "Connection failed: #{e.message}"
76
+ rescue Faraday::TimeoutError => e
77
+ raise TimeoutError, "Request timed out: #{e.message}"
78
+ rescue Faraday::ParsingError => e
79
+ raise ParsingError, "Response parsing error: #{e.message}"
80
+ rescue Faraday::Adapter::Test::Stubs::NotFound => e
81
+ raise LinkResolutionError, "Resource not found: #{e.message}"
82
+ end
83
+
60
84
  private
61
85
 
62
86
  def create_connection
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Hal
5
+ # Configuration object for endpoint registration with EndpointParameter support
6
+ class EndpointConfiguration
7
+ attr_reader :endpoint_path, :parameters
8
+
9
+ def initialize
10
+ @endpoint_path = nil
11
+ @parameters = []
12
+ end
13
+
14
+ # Set the endpoint path
15
+ def path(path_string)
16
+ @endpoint_path = path_string
17
+ end
18
+
19
+ # Add a parameter to this endpoint
20
+ def add_parameter(parameter)
21
+ @parameters << parameter
22
+ end
23
+
24
+ # Add multiple parameters
25
+ def parameters=(param_list)
26
+ @parameters = param_list || []
27
+ end
28
+
29
+ # DSL method to add path parameter
30
+ def path_parameter(name, **options)
31
+ add_parameter(EndpointParameter.path(name, **options))
32
+ end
33
+
34
+ # DSL method to add query parameter
35
+ def query_parameter(name, **options)
36
+ add_parameter(EndpointParameter.query(name, **options))
37
+ end
38
+
39
+ # DSL method to add header parameter
40
+ def header_parameter(name, **options)
41
+ add_parameter(EndpointParameter.header(name, **options))
42
+ end
43
+
44
+ # DSL method to add cookie parameter
45
+ def cookie_parameter(name, **options)
46
+ add_parameter(EndpointParameter.cookie(name, **options))
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Hal
5
+ # OpenAPI-inspired parameter definition for HAL endpoints
6
+ class EndpointParameter
7
+ attr_reader :name, :location, :required, :schema, :description, :example
8
+
9
+ def initialize(name:, in:, required: nil, schema: {}, description: nil, example: nil)
10
+ @name = name.to_s
11
+ @location = validate_location(binding.local_variable_get(:in))
12
+ @required = determine_required(required, @location)
13
+ @schema = Schema.new(schema)
14
+ @description = description
15
+ @example = example
16
+
17
+ validate!
18
+ end
19
+
20
+ # Convenience constructors for different parameter types
21
+ def self.path(name, **options)
22
+ new(name: name, in: :path, **options)
23
+ end
24
+
25
+ def self.query(name, **options)
26
+ new(name: name, in: :query, **options)
27
+ end
28
+
29
+ def self.header(name, **options)
30
+ new(name: name, in: :header, **options)
31
+ end
32
+
33
+ def self.cookie(name, **options)
34
+ new(name: name, in: :cookie, **options)
35
+ end
36
+
37
+ # Parameter type checks
38
+ def path_parameter?
39
+ @location == :path
40
+ end
41
+
42
+ def query_parameter?
43
+ @location == :query
44
+ end
45
+
46
+ def header_parameter?
47
+ @location == :header
48
+ end
49
+
50
+ def cookie_parameter?
51
+ @location == :cookie
52
+ end
53
+
54
+ # Validate a parameter value against this parameter's schema
55
+ def validate_value(value)
56
+ @schema.validate(value)
57
+ end
58
+
59
+ # Get the default value for this parameter
60
+ def default_value
61
+ @schema.default
62
+ end
63
+
64
+ def validate!
65
+ # Path parameters must be required
66
+ raise ArgumentError, 'Path parameters must be required' if path_parameter? && !@required
67
+
68
+ @schema.validate_definition!
69
+ end
70
+
71
+ private
72
+
73
+ def validate_location(location)
74
+ valid_locations = %i[path query header cookie]
75
+ unless valid_locations.include?(location)
76
+ raise ArgumentError, "Invalid parameter location: #{location}. Must be one of: #{valid_locations.join(', ')}"
77
+ end
78
+
79
+ location
80
+ end
81
+
82
+ def determine_required(required, location)
83
+ return true if location == :path # Path parameters are always required
84
+
85
+ required.nil? ? false : required
86
+ end
87
+
88
+ # Schema validation and type checking
89
+ class Schema
90
+ attr_reader :type, :format, :enum, :minimum, :maximum, :min_length, :max_length,
91
+ :pattern, :items, :default, :nullable
92
+
93
+ def initialize(definition = {})
94
+ @type = definition[:type] || :string
95
+ @format = definition[:format]
96
+ @enum = definition[:enum]
97
+ @minimum = definition[:minimum]
98
+ @maximum = definition[:maximum]
99
+ @min_length = definition[:min_length]
100
+ @max_length = definition[:max_length]
101
+ @pattern = definition[:pattern]
102
+ @items = definition[:items]
103
+ @default = definition[:default]
104
+ @nullable = definition[:nullable] || false
105
+ end
106
+
107
+ def validate(value)
108
+ return true if value.nil? && @nullable
109
+ return false if value.nil? && !@nullable
110
+
111
+ case @type
112
+ when :string
113
+ validate_string(value)
114
+ when :integer
115
+ validate_integer(value)
116
+ when :number
117
+ validate_number(value)
118
+ when :boolean
119
+ validate_boolean(value)
120
+ when :array
121
+ validate_array(value)
122
+ else
123
+ true
124
+ end
125
+ end
126
+
127
+ def validate_definition!
128
+ valid_types = %i[string integer number boolean array object]
129
+ raise ArgumentError, "Invalid schema type: #{@type}" unless valid_types.include?(@type)
130
+
131
+ return unless @type == :array && @items.nil?
132
+
133
+ raise ArgumentError, "Array type requires 'items' definition"
134
+ end
135
+
136
+ private
137
+
138
+ def validate_string(value)
139
+ return false unless value.is_a?(String)
140
+ return false if @min_length && value.length < @min_length
141
+ return false if @max_length && value.length > @max_length
142
+ return false if @pattern && !value.match?(@pattern)
143
+ return false if @enum && !@enum.include?(value)
144
+
145
+ true
146
+ end
147
+
148
+ def validate_integer(value)
149
+ int_value = value.is_a?(String) ? value.to_i : value
150
+ return false unless int_value.is_a?(Integer)
151
+ return false if @minimum && int_value < @minimum
152
+ return false if @maximum && int_value > @maximum
153
+ return false if @enum && !@enum.include?(int_value)
154
+
155
+ true
156
+ end
157
+
158
+ def validate_number(value)
159
+ num_value = value.is_a?(String) ? value.to_f : value
160
+ return false unless num_value.is_a?(Numeric)
161
+ return false if @minimum && num_value < @minimum
162
+ return false if @maximum && num_value > @maximum
163
+ return false if @enum && !@enum.include?(num_value)
164
+
165
+ true
166
+ end
167
+
168
+ def validate_boolean(value)
169
+ [true, false, 'true', 'false', '1', '0'].include?(value)
170
+ end
171
+
172
+ def validate_array(value)
173
+ return false unless value.is_a?(Array)
174
+ return false if @min_items && value.length < @min_items
175
+ return false if @max_items && value.length > @max_items
176
+
177
+ # Validate each item if items schema is provided
178
+ if @items
179
+ item_schema = Schema.new(@items)
180
+ return false unless value.all? { |item| item_schema.validate(item) }
181
+ end
182
+
183
+ true
184
+ end
185
+ end
186
+ end
187
+ end
188
+ end
@@ -39,6 +39,10 @@ module Lutaml
39
39
 
40
40
  @model_registers.delete(name)
41
41
  end
42
+
43
+ def unregister(name)
44
+ delete(name)
45
+ end
42
46
  end
43
47
  end
44
48
  end
@@ -24,7 +24,12 @@ module Lutaml
24
24
  # This method will use the global register according to the source of the Link object.
25
25
  # If the Link does not have a register, a register needs to be provided explicitly
26
26
  # via the `register:` parameter.
27
- def realize(register: nil)
27
+ def realize(register: nil, parent_resource: nil)
28
+ # First check if embedded content is available
29
+ if parent_resource && (embedded_content = check_embedded_content(parent_resource, register))
30
+ return embedded_content
31
+ end
32
+
28
33
  register = find_register(register)
29
34
  raise "No register provided for link resolution (class: #{self.class}, href: #{href})" if register.nil?
30
35
 
@@ -34,6 +39,56 @@ module Lutaml
34
39
 
35
40
  private
36
41
 
42
+ def check_embedded_content(parent_resource, register = nil)
43
+ return nil unless parent_resource.respond_to?(:embedded_data)
44
+
45
+ # Try to find embedded content that matches this link
46
+ # Look for embedded content by matching the link's key or href pattern
47
+ embedded_data = parent_resource.embedded_data
48
+ return nil unless embedded_data
49
+
50
+ # Try to find matching embedded content
51
+ # This is a simplified approach - in practice, you might need more sophisticated matching
52
+ embedded_data.each_value do |content|
53
+ # If content is an array, check if any item matches this link
54
+ if content.is_a?(Array)
55
+ matching_item = content.find { |item| matches_embedded_item?(item) }
56
+ return create_embedded_resource(matching_item, parent_resource, register) if matching_item
57
+ elsif content.is_a?(Hash) && matches_embedded_item?(content)
58
+ return create_embedded_resource(content, parent_resource, register)
59
+ end
60
+ end
61
+
62
+ nil
63
+ end
64
+
65
+ def matches_embedded_item?(item)
66
+ return false unless item.is_a?(Hash)
67
+
68
+ # Check if the embedded item's self link matches this link's href
69
+ item_self_link = item.dig('_links', 'self', 'href')
70
+ return true if item_self_link == href
71
+
72
+ # Additional matching logic could be added here
73
+ false
74
+ end
75
+
76
+ def create_embedded_resource(embedded_item, _parent_resource, register = nil)
77
+ # Get the register to determine the appropriate model class
78
+ register = find_register(register)
79
+ return nil unless register
80
+
81
+ # Try to find the model class for this href
82
+ href_path = href.sub(register.client.api_url, '') if register.client
83
+ model_class = register.send(:find_matching_model_class, href_path)
84
+ return nil unless model_class
85
+
86
+ # Create the resource from embedded data
87
+ resource = model_class.from_embedded(embedded_item, instance_variable_get("@#{Hal::REGISTER_ID_ATTR_NAME}"))
88
+ register.send(:mark_model_links_with_register, resource)
89
+ resource
90
+ end
91
+
37
92
  def find_register(explicit_register)
38
93
  return explicit_register if explicit_register
39
94
 
@@ -1,10 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'cgi'
3
4
  require_relative 'errors'
5
+ require_relative 'endpoint_configuration'
4
6
 
5
7
  module Lutaml
6
8
  module Hal
7
- # Register to map URL patterns to model classes
9
+ # Register to map URL patterns to model classes with EndpointParameter support
8
10
  class ModelRegister
9
11
  attr_accessor :models, :client, :register_name
10
12
 
@@ -15,12 +17,22 @@ module Lutaml
15
17
  @models = {}
16
18
  end
17
19
 
18
- # Register a model with its base URL pattern
19
- def add_endpoint(id:, type:, url:, model:, query_params: nil)
20
+ # Register a model with its URL pattern and parameters
21
+ def add_endpoint(id:, type:, url:, model:, parameters: [])
20
22
  @models ||= {}
21
23
 
22
24
  raise "Model with ID #{id} already registered" if @models[id]
23
- if @models.values.any? { |m| m[:url] == url && m[:type] == type && m[:query_params] == query_params }
25
+
26
+ # Validate all parameters
27
+ parameters.each(&:validate!)
28
+
29
+ # Ensure path parameters in URL have corresponding parameter definitions
30
+ validate_path_parameters(url, parameters)
31
+
32
+ # Check for duplicate endpoints
33
+ if @models.values.any? do |m|
34
+ m[:url] == url && m[:type] == type && parameters_match?(m[:parameters], parameters)
35
+ end
24
36
  raise "Duplicate URL pattern #{url} for type #{type}"
25
37
  end
26
38
 
@@ -29,20 +41,52 @@ module Lutaml
29
41
  type: type,
30
42
  url: url,
31
43
  model: model,
32
- query_params: query_params
44
+ parameters: parameters
33
45
  }
34
46
  end
35
47
 
48
+ # Register an endpoint using block configuration syntax
49
+ def register_endpoint(id, model, type: :index)
50
+ config = EndpointConfiguration.new
51
+ yield(config) if block_given?
52
+
53
+ raise ArgumentError, 'Endpoint path must be configured' unless config.endpoint_path
54
+
55
+ add_endpoint(
56
+ id: id,
57
+ type: type,
58
+ url: config.endpoint_path,
59
+ model: model,
60
+ parameters: config.parameters || []
61
+ )
62
+ end
63
+
36
64
  # Resolve and cast data to the appropriate model based on URL
37
65
  def fetch(endpoint_id, **params)
38
66
  endpoint = @models[endpoint_id] || raise("Unknown endpoint: #{endpoint_id}")
39
67
  raise 'Client not configured' unless client
40
68
 
41
- url = interpolate_url(endpoint[:url], params)
42
- response = client.get(build_url_with_query_params(url, endpoint[:query_params], params))
69
+ # Process parameters through EndpointParameter objects
70
+ processed_params = process_parameters(endpoint[:parameters], params)
71
+
72
+ # Build URL with path parameters
73
+ url = build_url_with_path_params(endpoint[:url], processed_params[:path])
74
+
75
+ # Add query parameters
76
+ final_url = build_url_with_query_params(url, processed_params[:query])
77
+
78
+ # Make request with headers
79
+ response = if processed_params[:headers].any?
80
+ client.get_with_headers(final_url, processed_params[:headers])
81
+ else
82
+ client.get(final_url)
83
+ end
43
84
 
44
85
  realized_model = endpoint[:model].from_json(response.to_json)
45
86
 
87
+ # Store embedded data for later resolution
88
+ realized_model.instance_variable_set(:@_embedded, response['_embedded']) if response['_embedded']
89
+
46
90
  mark_model_links_with_register(realized_model)
47
91
  realized_model
48
92
  end
@@ -98,35 +142,116 @@ module Lutaml
98
142
 
99
143
  private
100
144
 
101
- def interpolate_url(url_template, params)
102
- params.reduce(url_template) do |url, (key, value)|
103
- url.gsub("{#{key}}", value.to_s)
145
+ def process_parameters(parameter_definitions, provided_params)
146
+ result = { path: {}, query: {}, headers: {}, cookies: {} }
147
+
148
+ parameter_definitions.each do |param_def|
149
+ param_name = param_def.name.to_sym
150
+ provided_value = provided_params[param_name]
151
+
152
+ # Check required parameters
153
+ if param_def.required && provided_value.nil?
154
+ raise ArgumentError, "Required parameter '#{param_def.name}' is missing"
155
+ end
156
+
157
+ # Use default value if not provided
158
+ value = provided_value || param_def.default_value
159
+
160
+ # Skip if still nil and not required
161
+ next if value.nil?
162
+
163
+ # Validate parameter value
164
+ unless param_def.validate_value(value)
165
+ raise ArgumentError, "Invalid value for parameter '#{param_def.name}': #{value}"
166
+ end
167
+
168
+ # Store in appropriate category
169
+ case param_def.location
170
+ when :path
171
+ result[:path][param_def.name] = value
172
+ when :query
173
+ result[:query][param_def.name] = value
174
+ when :header
175
+ result[:headers][param_def.name] = value
176
+ when :cookie
177
+ result[:cookies][param_def.name] = value
178
+ end
104
179
  end
180
+
181
+ result
105
182
  end
106
183
 
107
- def build_url_with_query_params(base_url, query_params_template, params)
108
- return base_url unless query_params_template
184
+ def validate_path_parameters(url, parameters)
185
+ # Extract path parameter names from URL template
186
+ url_params = url.scan(/\{([^}]+)\}/).flatten
187
+
188
+ # Find path parameters in parameter definitions
189
+ path_params = parameters.select(&:path_parameter?).map(&:name)
190
+
191
+ # Check that all URL parameters have definitions
192
+ missing_params = url_params - path_params
193
+ unless missing_params.empty?
194
+ raise ArgumentError, "URL contains undefined path parameters: #{missing_params.join(', ')}"
195
+ end
196
+
197
+ # Check that all path parameter definitions are used in URL
198
+ unused_params = path_params - url_params
199
+ return if unused_params.empty?
109
200
 
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
- else
117
- # Fixed parameter - always include it
118
- query_params << "#{param_name}=#{param_template}"
201
+ raise ArgumentError, "Path parameters defined but not used in URL: #{unused_params.join(', ')}"
202
+ end
203
+
204
+ def parameters_match?(params1, params2)
205
+ return true if params1.nil? && params2.nil?
206
+ return false if params1.nil? || params2.nil?
207
+ return false if params1.length != params2.length
208
+
209
+ params1.zip(params2).all? do |p1, p2|
210
+ p1.name == p2.name && p1.location == p2.location
211
+ end
212
+ end
213
+
214
+ def build_url_with_path_params(url_template, path_params)
215
+ path_params.reduce(url_template) do |url, (key, value)|
216
+ url.gsub("{#{key}}", value.to_s)
217
+ end
218
+ end
219
+
220
+ def build_url_with_query_params(base_url, query_params, params = nil)
221
+ # Handle both 2-argument and 3-argument calls for backward compatibility
222
+ if params.nil?
223
+ # 2-argument call: query_params is the final query parameters
224
+ final_query_params = query_params
225
+ else
226
+ # 3-argument call: query_params is template, params contains values
227
+ final_query_params = {}
228
+ query_params.each do |key, template_value|
229
+ if template_value.is_a?(String) && template_value.match?(/\{(\w+)\}/)
230
+ param_name = template_value.match(/\{(\w+)\}/)[1].to_sym
231
+ final_query_params[key] = params[param_name] if params[param_name]
232
+ else
233
+ final_query_params[key] = template_value
234
+ end
119
235
  end
120
236
  end
121
237
 
122
- query_params.any? ? "#{base_url}?#{query_params.join('&')}" : base_url
238
+ return base_url if final_query_params.empty?
239
+
240
+ query_string = final_query_params.map { |key, value| "#{key}=#{CGI.escape(value.to_s)}" }.join('&')
241
+ "#{base_url}?#{query_string}"
242
+ end
243
+
244
+ # Interpolate path parameters in a URL template
245
+ def interpolate_url(url_template, params)
246
+ params.reduce(url_template) do |url, (key, value)|
247
+ url.gsub("{#{key}}", value.to_s)
248
+ end
123
249
  end
124
250
 
125
251
  def find_matching_model_class(href)
126
252
  # Find all matching patterns and select the most specific one (longest pattern)
127
253
  matching_models = @models.values.select do |model_data|
128
- matches = matches_url_with_params?(model_data, href)
129
- matches
254
+ matches_url_with_params?(model_data, href)
130
255
  end
131
256
 
132
257
  return nil if matching_models.empty?
@@ -139,7 +264,7 @@ module Lutaml
139
264
 
140
265
  def matches_url_with_params?(model_data, href)
141
266
  pattern = model_data[:url]
142
- query_params = model_data[:query_params]
267
+ parameters = model_data[:parameters]
143
268
 
144
269
  return false unless pattern && href
145
270
 
@@ -149,7 +274,9 @@ module Lutaml
149
274
  path_match_result = path_matches?(pattern_path, uri.path)
150
275
  return false unless path_match_result
151
276
 
152
- return true unless query_params
277
+ # Check query parameters if any are defined
278
+ query_params = parameters.select(&:query_parameter?)
279
+ return true if query_params.empty?
153
280
 
154
281
  parsed_query = parse_query_params(uri.query)
155
282
  query_params_match?(query_params, parsed_query)
@@ -169,24 +296,23 @@ module Lutaml
169
296
  end
170
297
 
171
298
  def query_params_match?(expected_params, actual_params)
172
- # Query parameters should be optional - if they're template parameters (like {page}),
173
- # they don't need to be present in the actual URL
174
- expected_params.all? do |param_name, param_pattern|
175
- actual_value = actual_params[param_name]
176
-
177
- # If it's a template parameter (like {page}), it's optional
178
- if template_param?(param_pattern)
179
- # Template parameters are always considered matching (they're optional)
180
- true
181
- else
182
- # Non-template parameters must match exactly if present
183
- actual_value == param_pattern.to_s
299
+ # Query parameters should be optional unless marked as required
300
+ expected_params.all? do |param_def|
301
+ actual_value = actual_params[param_def.name]
302
+
303
+ # Required parameters must be present
304
+ if param_def.required
305
+ return false if actual_value.nil?
306
+
307
+ return param_def.validate_value(actual_value)
184
308
  end
185
- end
186
- end
187
309
 
188
- def template_param?(param_pattern)
189
- param_pattern.is_a?(String) && param_pattern.match?(/\{.+\}/)
310
+ # Optional parameters are always considered matching if not present
311
+ return true if actual_value.nil?
312
+
313
+ # If present, they must be valid
314
+ param_def.validate_value(actual_value)
315
+ end
190
316
  end
191
317
 
192
318
  def parse_query_params(query_string)
@@ -194,30 +320,11 @@ module Lutaml
194
320
 
195
321
  query_string.split('&').each_with_object({}) do |param, hash|
196
322
  key, value = param.split('=', 2)
197
- hash[key] = value if key
198
- end
199
- end
200
-
201
- def matches_url?(pattern, href)
202
- return false unless pattern && href
203
-
204
- if href.start_with?('/') && client&.api_url
205
- # Try both with and without the API endpoint prefix
206
- path_pattern = extract_path(pattern)
207
- return pattern_match?(path_pattern, href) ||
208
- pattern_match?(pattern, "#{client.api_url}#{href}")
323
+ hash[key] = CGI.unescape(value) if key && value
209
324
  end
210
-
211
- pattern_match?(pattern, href)
212
- end
213
-
214
- def extract_path(pattern)
215
- return pattern unless client&.api_url && pattern.start_with?(client.api_url)
216
-
217
- pattern.sub(client.api_url, '')
218
325
  end
219
326
 
220
- # Match URL pattern (supports * wildcards and {param} templates)
327
+ # Match URL pattern (supports {param} templates)
221
328
  def pattern_match?(pattern, url)
222
329
  return false unless pattern && url
223
330