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.
- checksums.yaml +4 -4
- data/README.adoc +115 -1145
- data/lib/lutaml/hal/client.rb +24 -0
- data/lib/lutaml/hal/endpoint_configuration.rb +50 -0
- data/lib/lutaml/hal/endpoint_parameter.rb +188 -0
- data/lib/lutaml/hal/global_register.rb +4 -0
- data/lib/lutaml/hal/link.rb +56 -1
- data/lib/lutaml/hal/model_register.rb +170 -63
- data/lib/lutaml/hal/resource.rb +22 -0
- data/lib/lutaml/hal/version.rb +1 -1
- data/lib/lutaml/hal.rb +1 -0
- metadata +4 -2
data/lib/lutaml/hal/client.rb
CHANGED
@@ -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
|
data/lib/lutaml/hal/link.rb
CHANGED
@@ -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
|
19
|
-
def add_endpoint(id:, type:, url:, model:,
|
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
|
-
|
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
|
-
|
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
|
-
|
42
|
-
|
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
|
102
|
-
|
103
|
-
|
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
|
108
|
-
|
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
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
173
|
-
|
174
|
-
|
175
|
-
|
176
|
-
|
177
|
-
|
178
|
-
|
179
|
-
|
180
|
-
|
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
|
-
|
189
|
-
|
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
|
327
|
+
# Match URL pattern (supports {param} templates)
|
221
328
|
def pattern_match?(pattern, url)
|
222
329
|
return false unless pattern && url
|
223
330
|
|