lutaml-hal 0.1.6 → 0.1.7

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: 173922c9afbab790441aa0b40fc7a2401c78c72cbcc55d8cf3a7de737e12ea88
4
- data.tar.gz: 8fdde395a4ac1898f2df9a259210127a3955e8d49d6b2cf3b710649daae0d3d7
3
+ metadata.gz: 037e6d60811bd1a095b05e04c6b8edfe73cce7103332e52f38f25d5f66ee2733
4
+ data.tar.gz: cdbb38106ef138c2a48407f763e046bdd1956749bf5a55f238d563cc1384058b
5
5
  SHA512:
6
- metadata.gz: b9bfd6e3a62e7359b4e0ad38ed72b3f300d23640d44f31942a5a178ef0bec5617d1d6202360bc9ec6cee10ff911e62a81752659ed5820c25e731e8304dbd7158
7
- data.tar.gz: 6b256a2a898e2ed123c267ac7ee93780e0aa77b3715832e9f15856c0d9f600b1d05d080d469569060c086d348fc9a294d5d9eff6c6269c838d0ecd9883988eb5
6
+ metadata.gz: 683dba3496d46acd61610ad606e59a015b3cf4d041fa8e6efd7a405584571dfd1b6338579e36d603a0c5e6609feabdcbbb3ee4257261ae4319b971b335457367
7
+ data.tar.gz: 47f54aa3d93c6dd34baa6994c5e7102e282f2589abbea7eee5aa95164f17739a67d47331dceb82fdbc220feb9e9bc47ff9fc274fb05fdd5a21bf68fc41c729fa
data/README.adoc CHANGED
@@ -306,8 +306,6 @@ end
306
306
  ----
307
307
  ====
308
308
 
309
-
310
-
311
309
  ==== HAL Links
312
310
 
313
311
  A HAL resource has links to other resources, typically serialized in
@@ -335,6 +333,8 @@ hal_link :link_name,
335
333
  link_set_class: 'LinkSetClass'
336
334
  ----
337
335
 
336
+ Where,
337
+
338
338
  `:link_name`:: The name of the link, which will be used to access the link in
339
339
  the resource object.
340
340
 
@@ -343,43 +343,77 @@ of the link as it appears in the `_links` section of the HAL resource.
343
343
 
344
344
  `realize_class: 'TargetResourceClass'`:: The class of the target resource that
345
345
  the link points to. This is used to resolve the link to the associated resource.
346
+ +
347
+ The `realize_class` parameter supports two distinct use cases:
348
+ +
349
+ --
350
+ **String reference (recommended)**: Use string class names to delay resolution,
351
+ especially when classes may be dynamically loaded or not available at definition time:
352
+
353
+ [source,ruby]
354
+ ----
355
+ hal_link :category, key: 'category', realize_class: 'Category'
356
+ hal_link :products, key: 'products', realize_class: 'ProductIndex'
357
+ ----
358
+
359
+ **Class reference**: Use actual class objects when classes are statically available
360
+ at definition time or via autoload:
361
+
362
+ [source,ruby]
363
+ ----
364
+ hal_link :category, key: 'category', realize_class: Category
365
+ hal_link :products, key: 'products', realize_class: ProductIndex
366
+ ----
367
+
368
+ The framework's lazy resolution mechanism handles both cases seamlessly,
369
+ automatically resolving string references to actual classes when needed during
370
+ serialization. This ensures consistent type names in HAL output regardless of
371
+ class loading order.
372
+ --
346
373
 
347
374
  `link_class: 'LinkClass'`:: (optional) The class of the link that defines
348
375
  specific behavior or attributes for the link object itself. This is dynamically
349
376
  created and is inherited from `Lutaml::Hal::Link` if not provided.
377
+ +
378
+ Like `realize_class`, this parameter supports both string and class references:
379
+ +
380
+ --
381
+ **String references (Recommended)**: Use string class names for maximum flexibility:
350
382
 
351
- `link_set_class: 'LinkSetClass'`:: (optional) The class of the link set object
352
- that contains the links. This is dynamically created and is inherited from
353
- `Lutaml::Model::Serializable` if not provided.
354
-
383
+ [source,ruby]
384
+ ----
385
+ hal_link :category, key: 'category', realize_class: 'Category', link_class: 'CategoryLink'
386
+ ----
355
387
 
356
- The `_links` section is modeled as a dynamically created link set class, named
357
- after the resource's class name (with an appended `LinkSet` string), which in turn
358
- contains the defined links to other resources. The link set class is inherited
359
- from `Lutaml::Model::Serializable`.
388
+ **Class references**: Use actual class objects when classes are statically available:
360
389
 
361
- [example]
362
- ====
363
- A HAL resource of class `Product` may have a link set of class `ProductLinkSet`
364
- which contains the `self` and `category` links as its attributes.
365
- ====
390
+ [source,ruby]
391
+ ----
392
+ hal_link :category, key: 'category', realize_class: Category, link_class: CategoryLink
393
+ ----
394
+ --
366
395
 
396
+ `link_set_class: 'LinkSetClass'`:: (optional) The class of the link set object
397
+ that contains the links. This is dynamically created and is inherited from
398
+ `Lutaml::Hal::LinkSet` if not provided.
399
+ +
400
+ Like `realize_class`, this parameter supports both string and class references:
401
+ +
402
+ --
403
+ **String references (Recommended)**: Use string class names for maximum flexibility:
367
404
 
368
- Each link object of the link set is provided as a `Link` object that is
369
- dynamically created for the type of resolved resource. The name of the link
370
- class is the same as the resource class name with an appended `Link` string.
371
- This Link class is inherited from `Lutaml::Hal::Link`.
405
+ [source,ruby]
406
+ ----
407
+ hal_link :category, key: 'category', realize_class: 'Category', link_set_class: 'ProductLinkSet'
408
+ ----
372
409
 
373
- [example]
374
- ====
375
- A HAL resource of class `Product` with a link set that contains the `self`
376
- (points to a `Product`) and `category` (points to a `Category`) links will
377
- have:
410
+ **Class references**: Use actual class objects when classes are statically available:
378
411
 
379
- * a link set of class `ProductLinks` which contains:
380
- ** a `self` attribute that is an instance of `ProductLink`
381
- ** a `category` attribute that is an instance of `CategoryLink`
382
- ====
412
+ [source,ruby]
413
+ ----
414
+ hal_link :category, key: 'category', realize_class: Category, link_set_class: ProductLinkSet
415
+ ----
416
+ --
383
417
 
384
418
 
385
419
  .Integrated example of a HAL resource model using auto-generated LinkSet and Link classes
@@ -409,9 +443,11 @@ end
409
443
 
410
444
  The library will provide:
411
445
 
412
- * the link set (serialized in HAL as JSON `_links`) in the class `ProductLinks`.
446
+ * the link set (serialized in HAL as JSON `_links`) in the class
447
+ `ProductLinkSet`.
413
448
 
414
- * the link set contains the `self` and the `category` links of class `Lutaml::Hal::Link`.
449
+ * the link set contains the `self` link (as `ProductLink`) and the `category`
450
+ link (as `CategoryLink`).
415
451
 
416
452
  As a result:
417
453
 
@@ -423,7 +459,76 @@ return an instance of `Product`.
423
459
 
424
460
 
425
461
 
426
- ==== Custom link set class
462
+ ===== Dynamic definition of LinkSet and Link
463
+
464
+ The `_links` section is modeled as a dynamically created link set class, named
465
+ after the resource's class name (with an appended `LinkSet` string), which in turn
466
+ contains the defined links to other resources. The link set class is automatically
467
+ inherited from `Lutaml::Hal::LinkSet`.
468
+
469
+ Each link in the link set is modeled as a dynamically created link class, named
470
+ after the resource's class name (with an appended `Link` string). This link class
471
+ is inherited from `Lutaml::Hal::Link`.
472
+
473
+ [example]
474
+ ====
475
+ A HAL resource of class `Product` may have a link set of class `ProductLinkSet`
476
+ which contains the `self` and `category` links as its attributes.
477
+ ====
478
+
479
+ The framework automatically:
480
+
481
+ * Creates the LinkSet class when the resource class is defined
482
+ * Adds a `links` attribute to the resource class
483
+ * Maps the `_links` JSON key to the `links` attribute
484
+ * Ensures consistent type naming regardless of class loading order
485
+
486
+ Each link object of the link set is provided as a `Link` object that is
487
+ dynamically created for the type of resolved resource. The name of the link
488
+ class is the same as the resource class name with an appended `Link` string.
489
+ This Link class is inherited from `Lutaml::Hal::Link`.
490
+
491
+ [example]
492
+ ====
493
+ A HAL resource of class `Product` with a link set that contains the `self`
494
+ (points to a `Product`) and `category` (points to a `Category`) links will
495
+ have:
496
+
497
+ * a link set of class `ProductLinkSet` which contains:
498
+ ** a `self` attribute that is an instance of `ProductLink`
499
+ ** a `category` attribute that is an instance of `CategoryLink`
500
+ ====
501
+
502
+ ===== Lazy realization class loading and type naming
503
+
504
+ The framework implements lazy type resolution of the `realize_class` argument in
505
+ the `hal_link` command. This allows the instance to be realized on resolution to
506
+ have its class defined after the definition of the `hal_link` command, for
507
+ example, in the case when the class to be realized is loaded later in the
508
+ application lifecycle.
509
+
510
+ Technically, it is possible to have all models (the classes to be realized) to
511
+ be defined before the HAL resource is created to ensure the realization classes
512
+ are resolved. However, there are cases where classes are dynamically generated,
513
+ resolved via registers or other mechanisms that make those classes available
514
+ after the HAL resource is defined.
515
+
516
+ This allows for greater flexibility in defining resource relationships and
517
+ enables the use of dynamic class loading techniques.
518
+
519
+ In addition, the definition of the `realize_class` argument in the `hal_link`
520
+ command becomes useful in the case of polymorphism. The type name is used in
521
+ Lutaml::Model for polymorphism and potentially serialized (if defined through
522
+ Lutaml::Model serializatiion methods, as a Hal::Resource is also a
523
+ Lutaml::Model).
524
+
525
+ NOTE: This framework uses base class names (e.g., `ResourceClass`) instead of
526
+ fully qualified namespaced class names (e.g., `MyModule::ResourceClass`) as the
527
+ `type` attribute, by default.
528
+
529
+
530
+
531
+ ===== Custom link set class
427
532
 
428
533
  When a custom link set class (via `link_set_class:`) is provided, links are no
429
534
  longer automatically added to the link set via `hal_link`. Please ensure that
@@ -489,7 +594,7 @@ module MyApi
489
594
  end
490
595
  ----
491
596
 
492
- ==== Custom link class
597
+ ===== Custom link class
493
598
 
494
599
  When a custom link class (via `link_class:`) is provided, the custom link class
495
600
  is automatically added into the link set.
@@ -541,8 +646,6 @@ module MyApi
541
646
  end
542
647
  ----
543
648
 
544
-
545
-
546
649
  === Registering resource models and endpoints
547
650
 
548
651
  The `ModelRegister` allows you to register resource models and their endpoints.
@@ -744,8 +847,6 @@ must inherit from `Lutaml::Hal::Page`.
744
847
  `model`:: The class of the page that will be fetched from the API.
745
848
 
746
849
 
747
-
748
-
749
850
  == Usage: Runtime
750
851
 
751
852
  === General
@@ -810,7 +911,7 @@ product_1 = register.fetch(:product_resource, id: 1)
810
911
 
811
912
  product_1
812
913
  # => #<Product id: 1, name: "Product 1", price: 10.0, links:
813
- # #<ProductLinks self: <ProductLink href: "/products/1">,
914
+ # #<ProductLinkSet self: <ProductLink href: "/products/1">,
814
915
  # category: <ProductLink href: "/categories/1", title: "Category 1">,
815
916
  # related: [
816
917
  # <ProductLink href: "/products/3", title: "Product 3">,
@@ -819,8 +920,6 @@ product_1
819
920
  ----
820
921
  ====
821
922
 
822
-
823
-
824
923
  === Fetching a resource index
825
924
 
826
925
  In HAL, collections are provided via the `_links` or the `_embedded` sections of
@@ -872,10 +971,10 @@ product_index = register.fetch(:product_index)
872
971
 
873
972
  product_index
874
973
  # => #<ProductPage page: 1, pages: 10, limit: 10, total: 45,
875
- # links: #<ProductLinks self: <ProductLink href: "/products/1">,
974
+ # links: #<ProductLinkSet self: <ProductLink href: "/products/1">,
876
975
  # next: <ProductLink href: "/products/2">,
877
976
  # last: <ProductLink href: "/products/5">,
878
- # products: <ProductLinks
977
+ # products: <ProductLinkSet
879
978
  # <ProductLink href: "/products/1", title: "Product 1">,
880
979
  # <ProductLink href: "/products/2", title: "Product 2">
881
980
  # ]>>
@@ -971,7 +1070,7 @@ product_2 = product_index.links.products.last.realize
971
1070
 
972
1071
  product_2
973
1072
  # => #<Product id: 2, name: "Product 2", price: 20.0, links:
974
- # #<ProductLinks self: <ProductLink href: "/products/2">,
1073
+ # #<ProductLinkSet self: <ProductLink href: "/products/2">,
975
1074
  # category: <ProductLink href: "/categories/2", title: "Category 2">,
976
1075
  # related: [
977
1076
  # <ProductLink href: "/products/4", title: "Product 4">,
@@ -986,7 +1085,6 @@ product_2_related_1 = product_2.links.related.first.realize
986
1085
  ----
987
1086
  ====
988
1087
 
989
-
990
1088
  === Handling HAL pages / pagination
991
1089
 
992
1090
  ==== General
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'link'
4
+ require_relative 'type_resolver'
5
+
6
+ module Lutaml
7
+ module Hal
8
+ # Factory class responsible for creating dynamic Link classes
9
+ class LinkClassFactory
10
+ def self.create_for(resource_class, realize_class_name)
11
+ new(resource_class, realize_class_name).create
12
+ end
13
+
14
+ def initialize(resource_class, realize_class_name)
15
+ @resource_class = resource_class
16
+ @realize_class_name = realize_class_name
17
+ end
18
+
19
+ def create
20
+ return create_anonymous_link_class if anonymous_class?
21
+
22
+ class_names = build_class_names
23
+ return existing_class(class_names[:full_name]) if class_exists?(class_names[:full_name])
24
+
25
+ klass = create_named_link_class(class_names)
26
+ register_constant(klass, class_names)
27
+ klass
28
+ end
29
+
30
+ private
31
+
32
+ def anonymous_class?
33
+ @resource_class.name.nil?
34
+ end
35
+
36
+ def create_anonymous_link_class
37
+ create_link_class_with_type_resolution
38
+ end
39
+
40
+ def create_named_link_class(class_names)
41
+ Hal.debug_log "Creating link class #{class_names[:full_name]} for #{@realize_class_name}"
42
+ create_link_class_with_type_resolution
43
+ end
44
+
45
+ def create_link_class_with_type_resolution
46
+ realize_class_name = @realize_class_name
47
+
48
+ Class.new(Link) do
49
+ include TypeResolver
50
+ setup_type_resolution(realize_class_name)
51
+ end
52
+ end
53
+
54
+ def build_class_names
55
+ parent_namespace = @resource_class.name.split('::')[0..-2].join('::')
56
+ child_class_name = "#{@realize_class_name.split('::').last}Link"
57
+ full_class_name = [parent_namespace, child_class_name].reject(&:empty?).join('::')
58
+
59
+ {
60
+ parent_namespace: parent_namespace,
61
+ child_name: child_class_name,
62
+ full_name: full_class_name
63
+ }
64
+ end
65
+
66
+ def class_exists?(class_name)
67
+ Object.const_defined?(class_name)
68
+ end
69
+
70
+ def existing_class(class_name)
71
+ Object.const_get(class_name)
72
+ end
73
+
74
+ def register_constant(klass, class_names)
75
+ parent_klass = resolve_parent_class(class_names[:parent_namespace])
76
+
77
+ # Avoid registering constants on Object in test scenarios
78
+ # This prevents conflicts with test mocks that expect specific const_set calls
79
+ return if parent_klass == Object && in_test_environment?
80
+
81
+ parent_klass.const_set(class_names[:child_name], klass)
82
+ end
83
+
84
+ def resolve_parent_class(parent_namespace)
85
+ return Object if parent_namespace.empty?
86
+
87
+ begin
88
+ Object.const_get(parent_namespace)
89
+ rescue NameError
90
+ Object
91
+ end
92
+ end
93
+
94
+ def in_test_environment?
95
+ # Check if we're in a test environment by looking for RSpec or test-related constants
96
+ defined?(RSpec) || defined?(Test::Unit) || ENV['RAILS_ENV'] == 'test' || ENV['RACK_ENV'] == 'test'
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'link_set'
4
+
5
+ module Lutaml
6
+ module Hal
7
+ # Factory class responsible for creating dynamic LinkSet classes
8
+ class LinkSetClassFactory
9
+ def self.create_for(resource_class)
10
+ new(resource_class).create
11
+ end
12
+
13
+ def initialize(resource_class)
14
+ @resource_class = resource_class
15
+ end
16
+
17
+ def create
18
+ return create_anonymous_link_set_class if anonymous_class?
19
+
20
+ class_names = build_class_names
21
+ return existing_class(class_names[:full_name]) if class_exists?(class_names[:full_name])
22
+
23
+ klass = create_named_link_set_class(class_names)
24
+ register_constant(klass, class_names)
25
+ setup_resource_mapping(klass)
26
+ klass
27
+ end
28
+
29
+ private
30
+
31
+ def anonymous_class?
32
+ @resource_class.name.nil?
33
+ end
34
+
35
+ def create_anonymous_link_set_class
36
+ klass = Class.new(LinkSet)
37
+ setup_resource_mapping(klass)
38
+ klass
39
+ end
40
+
41
+ def create_named_link_set_class(class_names)
42
+ Hal.debug_log "Creating link set class #{class_names[:full_name]}"
43
+ Class.new(LinkSet)
44
+ end
45
+
46
+ def build_class_names
47
+ parent_namespace = @resource_class.name.split('::')[0..-2].join('::')
48
+ child_class_name = "#{@resource_class.name.split('::').last}LinkSet"
49
+ full_class_name = [parent_namespace, child_class_name].reject(&:empty?).join('::')
50
+
51
+ {
52
+ parent_namespace: parent_namespace,
53
+ child_name: child_class_name,
54
+ full_name: full_class_name
55
+ }
56
+ end
57
+
58
+ def class_exists?(class_name)
59
+ Object.const_defined?(class_name)
60
+ end
61
+
62
+ def existing_class(class_name)
63
+ Object.const_get(class_name)
64
+ end
65
+
66
+ def register_constant(klass, class_names)
67
+ parent_klass = resolve_parent_class(class_names[:parent_namespace])
68
+ parent_klass.const_set(class_names[:child_name], klass)
69
+ end
70
+
71
+ def resolve_parent_class(parent_namespace)
72
+ return Object if parent_namespace.empty?
73
+
74
+ begin
75
+ Object.const_get(parent_namespace)
76
+ rescue NameError
77
+ Object
78
+ end
79
+ end
80
+
81
+ def setup_resource_mapping(link_set_class)
82
+ # Define the LinkSet class with mapping inside the resource class
83
+ @resource_class.class_eval do
84
+ attribute :links, link_set_class
85
+ key_value do
86
+ map '_links', to: :links
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -113,6 +113,9 @@ module Lutaml
113
113
  if param_template.is_a?(String) && param_template.match?(/\{(.+)\}/)
114
114
  param_key = param_template.match(/\{(.+)\}/)[1]
115
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}"
116
119
  end
117
120
  end
118
121
 
@@ -120,9 +123,18 @@ module Lutaml
120
123
  end
121
124
 
122
125
  def find_matching_model_class(href)
123
- @models.values.find do |model_data|
124
- matches_url_with_params?(model_data, href)
125
- end&.[](:model)
126
+ # Find all matching patterns and select the most specific one (longest pattern)
127
+ matching_models = @models.values.select do |model_data|
128
+ matches = matches_url_with_params?(model_data, href)
129
+ matches
130
+ end
131
+
132
+ return nil if matching_models.empty?
133
+
134
+ # Sort by pattern length (descending) to get the most specific match first
135
+ result = matching_models.max_by { |model_data| model_data[:url].length }
136
+
137
+ result[:model]
126
138
  end
127
139
 
128
140
  def matches_url_with_params?(model_data, href)
@@ -134,10 +146,13 @@ module Lutaml
134
146
  uri = parse_href_uri(href)
135
147
  pattern_path = extract_pattern_path(pattern)
136
148
 
137
- return false unless path_matches?(pattern_path, uri.path)
149
+ path_match_result = path_matches?(pattern_path, uri.path)
150
+ return false unless path_match_result
151
+
138
152
  return true unless query_params
139
153
 
140
- query_params_match?(query_params, parse_query_params(uri.query))
154
+ parsed_query = parse_query_params(uri.query)
155
+ query_params_match?(query_params, parsed_query)
141
156
  end
142
157
 
143
158
  def parse_href_uri(href)
@@ -150,20 +165,23 @@ module Lutaml
150
165
  end
151
166
 
152
167
  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
168
+ pattern_match?(pattern_path, href_path)
159
169
  end
160
170
 
161
171
  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
162
174
  expected_params.all? do |param_name, param_pattern|
163
175
  actual_value = actual_params[param_name]
164
- next false unless actual_value
165
176
 
166
- template_param?(param_pattern) || actual_value == param_pattern.to_s
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
184
+ end
167
185
  end
168
186
  end
169
187
 
@@ -205,8 +223,9 @@ module Lutaml
205
223
 
206
224
  # Convert {param} to wildcards for matching
207
225
  pattern_with_wildcards = pattern.gsub(/\{[^}]+\}/, '*')
208
- # Convert * wildcards to regex pattern - use .+ instead of [^/]+ to match query parameters
209
- regex = Regexp.new("^#{pattern_with_wildcards.gsub('*', '.+')}$")
226
+ # Convert * wildcards to regex pattern - use [^/]+ to match path segments, not across slashes
227
+ # This ensures that {param} only matches a single path segment
228
+ regex = Regexp.new("^#{pattern_with_wildcards.gsub('*', '[^/]+')}$")
210
229
 
211
230
  Hal.debug_log("pattern_match?: regex: #{regex.inspect}")
212
231
  Hal.debug_log("pattern_match?: href to match #{url}")
@@ -23,6 +23,9 @@ module Lutaml
23
23
  def self.inherited(subclass)
24
24
  super
25
25
 
26
+ # Skip automatic link creation for anonymous classes (used in tests)
27
+ return unless subclass.name
28
+
26
29
  page_links_symbols = %i[self next prev first last up]
27
30
  subclass_name = subclass.name
28
31
  subclass.class_eval do
@@ -2,6 +2,8 @@
2
2
 
3
3
  require 'lutaml/model'
4
4
  require_relative 'link'
5
+ require_relative 'link_class_factory'
6
+ require_relative 'link_set_class_factory'
5
7
 
6
8
  module Lutaml
7
9
  module Hal
@@ -18,7 +20,6 @@ module Lutaml
18
20
  def inherited(subclass)
19
21
  super
20
22
  subclass.class_eval do
21
- create_link_set_class
22
23
  init_links_definition
23
24
  end
24
25
  end
@@ -36,21 +37,47 @@ module Lutaml
36
37
  link_set_class: nil,
37
38
  collection: false,
38
39
  type: :link)
40
+ # Validate required parameters
41
+ raise ArgumentError, 'realize_class parameter is required' if realize_class.nil?
42
+
39
43
  # Use the provided "key" as the attribute name
40
44
  attribute_name = attr_key.to_sym
41
45
 
42
46
  Hal.debug_log "Defining HAL link for `#{attr_key}` with realize class `#{realize_class}`"
43
47
 
48
+ # Normalize realize_class to a string for consistent handling
49
+ # Support both Class objects (when autoload is available) and strings (for delayed interpretation)
50
+ realize_class_name = case realize_class
51
+ when Class
52
+ realize_class.name.split('::').last # Use simple name from actual class
53
+ when String
54
+ realize_class # Use string as-is for lazy resolution
55
+ else
56
+ raise ArgumentError,
57
+ "realize_class must be a Class or String, got #{realize_class.class}"
58
+ end
59
+
60
+ # Create a dynamic LinkSet class if `link_set_class:` is not provided.
61
+ # This must happen BEFORE creating the Link class to ensure proper order
62
+ link_set_klass = link_set_class || create_link_set_class
63
+
64
+ # Ensure it was actually created
65
+ raise 'Failed to create LinkSet class' if link_set_klass.nil?
66
+
44
67
  # Create a dynamic Link subclass name based on "realize_class", the
45
68
  # class to realize for a Link object, if `link_class:` is not provided.
46
- link_klass = link_class || create_link_class(realize_class)
69
+ link_klass = link_class || create_link_class(realize_class_name)
47
70
 
48
- # Create a dynamic LinkSet class if `link_set_class:` is not provided.
71
+ # Now add the link to the LinkSet class
49
72
  unless link_set_class
50
- link_set_klass = link_set_class || get_link_set_class
51
73
  link_set_klass.class_eval do
52
74
  # Declare the corresponding lutaml-model attribute
53
- attribute attribute_name, link_klass, collection: collection
75
+ # Pass collection parameter correctly to the attribute definition
76
+ if collection
77
+ attribute attribute_name, link_klass, collection: true
78
+ else
79
+ attribute attribute_name, link_klass
80
+ end
54
81
 
55
82
  # Define the mapping for the attribute
56
83
  key_value do
@@ -73,69 +100,26 @@ module Lutaml
73
100
  end
74
101
 
75
102
  # This method obtains the Links class that holds the Link classes
103
+ # Delegates to LinkSetClassFactory for simplified implementation
76
104
  def get_link_set_class
77
- parent_klass_name = name.split('::')[0..-2].join('::')
78
- child_klass_name = "#{name.split('::').last}LinkSet"
79
- klass_name = [parent_klass_name, child_klass_name].join('::')
80
-
81
- raise unless Object.const_defined?(klass_name)
82
-
83
- Object.const_get(klass_name)
105
+ create_link_set_class
84
106
  end
85
107
 
86
- private
87
-
88
108
  # The "links" class holds the `_links` object which contains
89
109
  # the resource-linked Link classes
110
+ # Delegates to LinkSetClassFactory for simplified implementation
90
111
  def create_link_set_class
91
- parent_klass_name = name.split('::')[0..-2].join('::')
92
- child_klass_name = "#{name.split('::').last}LinkSet"
93
- klass_name = [parent_klass_name, child_klass_name].join('::')
94
-
95
- Hal.debug_log "Creating link set class #{klass_name}"
96
-
97
- # Check if the LinkSet class is already defined, return if so
98
- return Object.const_get(klass_name) if Object.const_defined?(klass_name)
99
-
100
- # Define the LinkSet class dynamically as a normal Lutaml::Model class
101
- # since it is not a Resource.
102
- klass = Class.new(Lutaml::Hal::LinkSet)
103
- parent_klass = !parent_klass_name.empty? ? Object.const_get(parent_klass_name) : Object
104
- parent_klass.const_set(child_klass_name, klass)
105
-
106
- # Define the LinkSet class with mapping inside the current class
107
- class_eval do
108
- attribute :links, klass
109
- key_value do
110
- map '_links', to: :links
111
- end
112
- end
112
+ LinkSetClassFactory.create_for(self)
113
113
  end
114
114
 
115
115
  def init_links_definition
116
116
  @link_definitions = {}
117
117
  end
118
118
 
119
- # This is a Link class that helps us realize the targeted class
119
+ # Creates a Link class that helps us realize the targeted class
120
+ # Delegates to LinkClassFactory for simplified implementation
120
121
  def create_link_class(realize_class_name)
121
- parent_klass_name = name.split('::')[0..-2].join('::')
122
- child_klass_name = "#{realize_class_name.split('::').last}Link"
123
- klass_name = [parent_klass_name, child_klass_name].join('::')
124
-
125
- Hal.debug_log "Creating link class #{klass_name} for #{realize_class_name}"
126
-
127
- return Object.const_get(klass_name) if Object.const_defined?(klass_name)
128
-
129
- # Define the link class dynamically
130
- klass = Class.new(Link) do
131
- # Define the link class with the specified key and class
132
- attribute :type, :string, default: realize_class_name
133
- end
134
-
135
- parent_klass = !parent_klass_name.empty? ? Object.const_get(parent_klass_name) : Object
136
- parent_klass.const_set(child_klass_name, klass)
137
-
138
- klass
122
+ LinkClassFactory.create_for(self, realize_class_name)
139
123
  end
140
124
  end
141
125
  end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Hal
5
+ # Module that provides lazy type resolution functionality for dynamically created classes
6
+ # This solves the class loading order problem where HAL type names would be inconsistent
7
+ # depending on file loading order.
8
+ module TypeResolver
9
+ def self.included(base)
10
+ base.extend(ClassMethods)
11
+ end
12
+
13
+ module ClassMethods
14
+ attr_reader :realize_class_name
15
+
16
+ def setup_type_resolution(realize_class_name)
17
+ @realize_class_name = realize_class_name
18
+ @resolved_type_name = nil
19
+ end
20
+
21
+ # Lazy resolution at class level - only happens once per class
22
+ def resolved_type_name
23
+ @resolved_type_name ||= resolve_type_name(@realize_class_name)
24
+ end
25
+
26
+ private
27
+
28
+ def resolve_type_name(class_name_string)
29
+ return class_name_string unless class_name_string.is_a?(String)
30
+
31
+ # Try simple name first (preferred for HAL output)
32
+ begin
33
+ Object.const_get(class_name_string)
34
+ class_name_string
35
+ rescue NameError
36
+ # Try within current module namespace
37
+ begin
38
+ current_module = name.split('::')[0..-2].join('::')
39
+ unless current_module.empty?
40
+ Object.const_get(current_module).const_get(class_name_string)
41
+ return class_name_string
42
+ end
43
+ rescue NameError
44
+ # Continue to fallback
45
+ end
46
+
47
+ # Fallback: return the original string (may be fully qualified)
48
+ class_name_string
49
+ end
50
+ end
51
+ end
52
+
53
+ # Override the type getter to use class-level lazy resolution
54
+ def type
55
+ @type || self.class.resolved_type_name
56
+ end
57
+ end
58
+ end
59
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Hal
5
- VERSION = '0.1.6'
5
+ VERSION = '0.1.7'
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.6
4
+ version: 0.1.7
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-07-03 00:00:00.000000000 Z
11
+ date: 2025-07-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -82,10 +82,13 @@ files:
82
82
  - lib/lutaml/hal/errors.rb
83
83
  - lib/lutaml/hal/global_register.rb
84
84
  - lib/lutaml/hal/link.rb
85
+ - lib/lutaml/hal/link_class_factory.rb
85
86
  - lib/lutaml/hal/link_set.rb
87
+ - lib/lutaml/hal/link_set_class_factory.rb
86
88
  - lib/lutaml/hal/model_register.rb
87
89
  - lib/lutaml/hal/page.rb
88
90
  - lib/lutaml/hal/resource.rb
91
+ - lib/lutaml/hal/type_resolver.rb
89
92
  - lib/lutaml/hal/version.rb
90
93
  homepage: https://github.com/lutaml/lutaml-hal
91
94
  licenses: