lutaml-hal 0.1.7 → 0.1.9
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 +62 -1
- data/lib/lutaml/hal/model_register.rb +176 -64
- 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
@@ -11,6 +11,9 @@ module Lutaml
|
|
11
11
|
# will be used to resolve unless overriden in resource#realize()
|
12
12
|
attr_accessor Hal::REGISTER_ID_ATTR_NAME.to_sym
|
13
13
|
|
14
|
+
# Store reference to parent resource for automatic embedded content detection
|
15
|
+
attr_accessor :parent_resource
|
16
|
+
|
14
17
|
attribute :href, :string
|
15
18
|
attribute :title, :string
|
16
19
|
attribute :name, :string
|
@@ -24,7 +27,15 @@ module Lutaml
|
|
24
27
|
# This method will use the global register according to the source of the Link object.
|
25
28
|
# If the Link does not have a register, a register needs to be provided explicitly
|
26
29
|
# via the `register:` parameter.
|
27
|
-
def realize(register: nil)
|
30
|
+
def realize(register: nil, parent_resource: nil)
|
31
|
+
# Use provided parent_resource or fall back to stored parent_resource
|
32
|
+
effective_parent = parent_resource || @parent_resource
|
33
|
+
|
34
|
+
# First check if embedded content is available
|
35
|
+
if effective_parent && (embedded_content = check_embedded_content(effective_parent, register))
|
36
|
+
return embedded_content
|
37
|
+
end
|
38
|
+
|
28
39
|
register = find_register(register)
|
29
40
|
raise "No register provided for link resolution (class: #{self.class}, href: #{href})" if register.nil?
|
30
41
|
|
@@ -34,6 +45,56 @@ module Lutaml
|
|
34
45
|
|
35
46
|
private
|
36
47
|
|
48
|
+
def check_embedded_content(parent_resource, register = nil)
|
49
|
+
return nil unless parent_resource.respond_to?(:embedded_data)
|
50
|
+
|
51
|
+
# Try to find embedded content that matches this link
|
52
|
+
# Look for embedded content by matching the link's key or href pattern
|
53
|
+
embedded_data = parent_resource.embedded_data
|
54
|
+
return nil unless embedded_data
|
55
|
+
|
56
|
+
# Try to find matching embedded content
|
57
|
+
# This is a simplified approach - in practice, you might need more sophisticated matching
|
58
|
+
embedded_data.each_value do |content|
|
59
|
+
# If content is an array, check if any item matches this link
|
60
|
+
if content.is_a?(Array)
|
61
|
+
matching_item = content.find { |item| matches_embedded_item?(item) }
|
62
|
+
return create_embedded_resource(matching_item, parent_resource, register) if matching_item
|
63
|
+
elsif content.is_a?(Hash) && matches_embedded_item?(content)
|
64
|
+
return create_embedded_resource(content, parent_resource, register)
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
nil
|
69
|
+
end
|
70
|
+
|
71
|
+
def matches_embedded_item?(item)
|
72
|
+
return false unless item.is_a?(Hash)
|
73
|
+
|
74
|
+
# Check if the embedded item's self link matches this link's href
|
75
|
+
item_self_link = item.dig('_links', 'self', 'href')
|
76
|
+
return true if item_self_link == href
|
77
|
+
|
78
|
+
# Additional matching logic could be added here
|
79
|
+
false
|
80
|
+
end
|
81
|
+
|
82
|
+
def create_embedded_resource(embedded_item, _parent_resource, register = nil)
|
83
|
+
# Get the register to determine the appropriate model class
|
84
|
+
register = find_register(register)
|
85
|
+
return nil unless register
|
86
|
+
|
87
|
+
# Try to find the model class for this href
|
88
|
+
href_path = href.sub(register.client.api_url, '') if register.client
|
89
|
+
model_class = register.send(:find_matching_model_class, href_path)
|
90
|
+
return nil unless model_class
|
91
|
+
|
92
|
+
# Create the resource from embedded data
|
93
|
+
resource = model_class.from_embedded(embedded_item, instance_variable_get("@#{Hal::REGISTER_ID_ATTR_NAME}"))
|
94
|
+
register.send(:mark_model_links_with_register, resource)
|
95
|
+
resource
|
96
|
+
end
|
97
|
+
|
37
98
|
def find_register(explicit_register)
|
38
99
|
return explicit_register if explicit_register
|
39
100
|
|