lutaml-hal 0.1.5 → 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: 2d002eb57e4807e4895639a338e4ad48ec6f18f21b238a4316d9f01f125442eb
4
- data.tar.gz: f25f410b3ede494dff1dcef66ca9f4882d8dad3fa1d7bd71432ad2a62d6ab449
3
+ metadata.gz: 037e6d60811bd1a095b05e04c6b8edfe73cce7103332e52f38f25d5f66ee2733
4
+ data.tar.gz: cdbb38106ef138c2a48407f763e046bdd1956749bf5a55f238d563cc1384058b
5
5
  SHA512:
6
- metadata.gz: ef84a386363fa0d7b4337b1150983e4dbe7aa7bdd56829a34c2ad581e7a773a22369254dd9d20985952308afa0d6be778053dd6db3d6957db84676f47d7fee1f
7
- data.tar.gz: fe63bbe1f11cb5924eb8246958ee5c06d1a6a6325392a4e4dfe07e1b03f593c57d549942ddca833715d8ec14d5a39cabdc73804a2b5ae2a5a2a2c99474dd27b3
6
+ metadata.gz: 683dba3496d46acd61610ad606e59a015b3cf4d041fa8e6efd7a405584571dfd1b6338579e36d603a0c5e6609feabdcbbb3ee4257261ae4319b971b335457367
7
+ data.tar.gz: 47f54aa3d93c6dd34baa6994c5e7102e282f2589abbea7eee5aa95164f17739a67d47331dceb82fdbc220feb9e9bc47ff9fc274fb05fdd5a21bf68fc41c729fa
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
@@ -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.
383
+ [source,ruby]
384
+ ----
385
+ hal_link :category, key: 'category', realize_class: 'Category', link_class: 'CategoryLink'
386
+ ----
354
387
 
388
+ **Class references**: Use actual class objects when classes are statically available:
355
389
 
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`.
390
+ [source,ruby]
391
+ ----
392
+ hal_link :category, key: 'category', realize_class: Category, link_class: CategoryLink
393
+ ----
394
+ --
360
395
 
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
- ====
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:
366
404
 
405
+ [source,ruby]
406
+ ----
407
+ hal_link :category, key: 'category', realize_class: 'Category', link_set_class: 'ProductLinkSet'
408
+ ----
367
409
 
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`.
410
+ **Class references**: Use actual class objects when classes are statically available:
372
411
 
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:
378
-
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.
@@ -552,16 +655,28 @@ You can define endpoints for collections (index) and individual resources
552
655
 
553
656
  The `add_endpoint` method takes the following parameters:
554
657
 
658
+
555
659
  `id`:: A unique identifier for the endpoint.
660
+
556
661
  `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
662
 
663
+ `url`:: The URL of the endpoint, which can include path parameters.
664
+ +
561
665
  In the `url`, you can use interpolation parameters, which will be replaced with
562
666
  the actual values when fetching the resource. The interpolation parameters are
563
667
  defined in the `url` string using curly braces `{}`.
564
668
 
669
+ `model`:: The class of the resource that will be fetched from the API.
670
+ The class must inherit from `Lutaml::Hal::Resource`.
671
+
672
+ `query_params`:: (optional) A hash defining query parameters that should be
673
+ appended to the URL when fetching the resource. Supports parameter templates
674
+ using curly braces `{}` for dynamic values.
675
+ +
676
+ This is essential for APIs that require query parameters for pagination,
677
+ filtering, or other functionality where the same base URL needs different query
678
+ parameters to access different resources or views.
679
+
565
680
  The `add_endpoint` method will automatically handle the URL resolution and fetch
566
681
  the resource from the API.
567
682
 
@@ -592,6 +707,35 @@ register.add_endpoint( <1>
592
707
  <5> The `model` is the class of the resource that will be fetched from
593
708
  the API. The class must inherit from `Lutaml::Hal::Resource`.
594
709
 
710
+
711
+ .Example of registering and using query parameters
712
+ [example]
713
+ ====
714
+ [source,ruby]
715
+ ----
716
+ # Register an endpoint that supports pagination via query parameters
717
+ register.add_endpoint(
718
+ id: :product_index,
719
+ type: :index,
720
+ url: '/products',
721
+ model: ProductIndex,
722
+ query_params: {
723
+ 'page' => '{page}',
724
+ 'items' => '{items}'
725
+ }
726
+ )
727
+
728
+ # Fetch the first page with 10 items per page
729
+ page_1 = register.fetch(:product_index, page: 1, items: 10)
730
+ # => client.get('/products?page=1&items=10')
731
+
732
+ # Fetch the second page with 5 items per page
733
+ page_2 = register.fetch(:product_index, page: 2, items: 5)
734
+ # => client.get('/products?page=2&items=5')
735
+ ----
736
+ ====
737
+
738
+
595
739
  .Example of registering the Product class to both index and resource endpoints
596
740
  [example]
597
741
  ====
@@ -614,6 +758,34 @@ register.add_endpoint(
614
758
  ====
615
759
 
616
760
 
761
+ .Example of using query_params for pagination
762
+ [example]
763
+ ====
764
+ [source,ruby]
765
+ ----
766
+ # Register an endpoint that supports pagination via query parameters
767
+ register.add_endpoint(
768
+ id: :product_index_paginated,
769
+ type: :index,
770
+ url: '/products',
771
+ model: ProductIndex,
772
+ query_params: {
773
+ 'page' => '{page}',
774
+ 'items' => '{items}'
775
+ }
776
+ )
777
+
778
+ # Fetch the first page with 10 items per page
779
+ page_1 = register.fetch(:product_index_paginated, page: 1, items: 10)
780
+ # => client.get('/products?page=1&items=10')
781
+
782
+ # Fetch the second page with 5 items per page
783
+ page_2 = register.fetch(:product_index_paginated, page: 2, items: 5)
784
+ # => client.get('/products?page=2&items=5')
785
+ ----
786
+ ====
787
+
788
+
617
789
  [[defining_hal_page_models]]
618
790
  === Defining HAL page models
619
791
 
@@ -675,8 +847,6 @@ must inherit from `Lutaml::Hal::Page`.
675
847
  `model`:: The class of the page that will be fetched from the API.
676
848
 
677
849
 
678
-
679
-
680
850
  == Usage: Runtime
681
851
 
682
852
  === General
@@ -741,7 +911,7 @@ product_1 = register.fetch(:product_resource, id: 1)
741
911
 
742
912
  product_1
743
913
  # => #<Product id: 1, name: "Product 1", price: 10.0, links:
744
- # #<ProductLinks self: <ProductLink href: "/products/1">,
914
+ # #<ProductLinkSet self: <ProductLink href: "/products/1">,
745
915
  # category: <ProductLink href: "/categories/1", title: "Category 1">,
746
916
  # related: [
747
917
  # <ProductLink href: "/products/3", title: "Product 3">,
@@ -750,8 +920,6 @@ product_1
750
920
  ----
751
921
  ====
752
922
 
753
-
754
-
755
923
  === Fetching a resource index
756
924
 
757
925
  In HAL, collections are provided via the `_links` or the `_embedded` sections of
@@ -803,10 +971,10 @@ product_index = register.fetch(:product_index)
803
971
 
804
972
  product_index
805
973
  # => #<ProductPage page: 1, pages: 10, limit: 10, total: 45,
806
- # links: #<ProductLinks self: <ProductLink href: "/products/1">,
974
+ # links: #<ProductLinkSet self: <ProductLink href: "/products/1">,
807
975
  # next: <ProductLink href: "/products/2">,
808
976
  # last: <ProductLink href: "/products/5">,
809
- # products: <ProductLinks
977
+ # products: <ProductLinkSet
810
978
  # <ProductLink href: "/products/1", title: "Product 1">,
811
979
  # <ProductLink href: "/products/2", title: "Product 2">
812
980
  # ]>>
@@ -902,7 +1070,7 @@ product_2 = product_index.links.products.last.realize
902
1070
 
903
1071
  product_2
904
1072
  # => #<Product id: 2, name: "Product 2", price: 20.0, links:
905
- # #<ProductLinks self: <ProductLink href: "/products/2">,
1073
+ # #<ProductLinkSet self: <ProductLink href: "/products/2">,
906
1074
  # category: <ProductLink href: "/categories/2", title: "Category 2">,
907
1075
  # related: [
908
1076
  # <ProductLink href: "/products/4", title: "Product 4">,
@@ -917,15 +1085,89 @@ product_2_related_1 = product_2.links.related.first.realize
917
1085
  ----
918
1086
  ====
919
1087
 
920
-
921
1088
  === Handling HAL pages / pagination
922
1089
 
1090
+ ==== General
1091
+
923
1092
  The `Lutaml::Hal::Page` class is used to handle pagination in HAL APIs.
924
1093
 
925
1094
  As described in <<defining_hal_page_models>>, subclassing the `Page` class
926
1095
  provides pagination capabilities, including the management of links to navigate
927
1096
  through pages of resources.
928
1097
 
1098
+ ==== Pagination navigation methods
1099
+
1100
+ The `Page` class provides several convenience methods for navigating through
1101
+ paginated results:
1102
+
1103
+ `#next_page`:: Returns the next page link if available, `nil` otherwise.
1104
+
1105
+ `#prev_page`:: Returns the previous page link if available, `nil` otherwise.
1106
+
1107
+ `#first_page`:: Returns the first page link if available, `nil` otherwise.
1108
+
1109
+ `#last_page`:: Returns the last page link if available, `nil` otherwise.
1110
+
1111
+ These methods return `Link` objects that can be realized using the `realize` method:
1112
+
1113
+ [source,ruby]
1114
+ ----
1115
+ # Navigate to next page
1116
+ if current_page.next_page
1117
+ next_page = current_page.next_page.realize
1118
+ end
1119
+
1120
+ # Navigate to previous page
1121
+ if current_page.prev_page
1122
+ prev_page = current_page.prev_page.realize
1123
+ end
1124
+
1125
+ # Jump to first or last page
1126
+ first_page = current_page.first_page.realize if current_page.first_page
1127
+ last_page = current_page.last_page.realize if current_page.last_page
1128
+ ----
1129
+
1130
+ ==== Pagination helper methods
1131
+
1132
+ The `Page` class also provides helper methods to check the availability of
1133
+ navigation links:
1134
+
1135
+ `#has_next?`:: Returns `true` if there is a next page available, `false`
1136
+ otherwise.
1137
+
1138
+ `#has_prev?`:: Returns `true` if there is a previous page available, `false`
1139
+ otherwise.
1140
+
1141
+ `#has_first?`:: Returns `true` if there is a first page link available, `false`
1142
+ otherwise.
1143
+
1144
+ `#has_last?`:: Returns `true` if there is a last page link available, `false`
1145
+ otherwise.
1146
+
1147
+ `#total_pages`:: Returns the total number of pages (alias for the `pages`
1148
+ attribute).
1149
+
1150
+
1151
+ ==== Exhaustive pagination
1152
+
1153
+ For scenarios where you need to process all pages of results, you can combine
1154
+ the pagination methods:
1155
+
1156
+ [source,ruby]
1157
+ ----
1158
+ current_page = register.fetch(:resource_index)
1159
+
1160
+ while current_page
1161
+ # Process current page
1162
+ puts "Processing page #{current_page.page} of #{current_page.total_pages}"
1163
+
1164
+ # Move to next page
1165
+ current_page = current_page.next
1166
+ end
1167
+ ----
1168
+
1169
+
1170
+ ==== Usage
929
1171
 
930
1172
  .Usage example of the Page class
931
1173
  [example]
@@ -980,11 +1222,33 @@ page_1
980
1222
  # next: #<ResourceIndexLink href: "/resources?page=2&items=10">,
981
1223
  # last: #<ResourceIndexLink href: "/resources?page=10&items=10">>>
982
1224
 
1225
+ # Check if navigation is available
1226
+ page_1.has_next? # => true
1227
+ page_1.has_prev? # => false
1228
+ page_1.total_pages # => 10
1229
+
1230
+ # Navigate using convenience methods
1231
+ page_2 = page_1.next
1232
+ # => client.get('/resources?page=2&items=10')
1233
+ # => #<ResourceIndex page: 2, pages: 10, limit: 10, total: 100, ...>
1234
+
1235
+ page_2.has_prev? # => true
1236
+ page_2.has_next? # => true
1237
+
1238
+ # Navigate back to first page
1239
+ first_page = page_2.first
1240
+ # => client.get('/resources?page=1&items=10')
1241
+
1242
+ # Jump to last page
1243
+ last_page = page_2.last
1244
+ # => client.get('/resources?page=10&items=10')
1245
+
1246
+ # Alternative: using link realization (original method)
983
1247
  # Without a GlobalRegister
984
- page_2 = page.links.next.realize(register)
1248
+ page_2 = page_1.links.next.realize(register)
985
1249
 
986
1250
  # With a GlobalRegister
987
- page_2 = page.links.next.realize
1251
+ page_2 = page_1.links.next.realize
988
1252
 
989
1253
  # => client.get('/resources?page=2&items=10')
990
1254
  # => #<ResourceIndex page: 2, pages: 10, limit: 10, total: 100,
@@ -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
@@ -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,10 +104,98 @@ 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
+ else
117
+ # Fixed parameter - always include it
118
+ query_params << "#{param_name}=#{param_template}"
119
+ end
120
+ end
121
+
122
+ query_params.any? ? "#{base_url}?#{query_params.join('&')}" : base_url
123
+ end
124
+
106
125
  def find_matching_model_class(href)
107
- @models.values.find do |model_data|
108
- matches_url?(model_data[:url], href)
109
- 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]
138
+ end
139
+
140
+ def matches_url_with_params?(model_data, href)
141
+ pattern = model_data[:url]
142
+ query_params = model_data[:query_params]
143
+
144
+ return false unless pattern && href
145
+
146
+ uri = parse_href_uri(href)
147
+ pattern_path = extract_pattern_path(pattern)
148
+
149
+ path_match_result = path_matches?(pattern_path, uri.path)
150
+ return false unless path_match_result
151
+
152
+ return true unless query_params
153
+
154
+ parsed_query = parse_query_params(uri.query)
155
+ query_params_match?(query_params, parsed_query)
156
+ end
157
+
158
+ def parse_href_uri(href)
159
+ full_href = href.start_with?('http') ? href : "#{client&.api_url}#{href}"
160
+ URI.parse(full_href)
161
+ end
162
+
163
+ def extract_pattern_path(pattern)
164
+ pattern.split('?').first
165
+ end
166
+
167
+ def path_matches?(pattern_path, href_path)
168
+ pattern_match?(pattern_path, href_path)
169
+ end
170
+
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
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
184
+ end
185
+ end
186
+ end
187
+
188
+ def template_param?(param_pattern)
189
+ param_pattern.is_a?(String) && param_pattern.match?(/\{.+\}/)
190
+ end
191
+
192
+ def parse_query_params(query_string)
193
+ return {} unless query_string
194
+
195
+ query_string.split('&').each_with_object({}) do |param, hash|
196
+ key, value = param.split('=', 2)
197
+ hash[key] = value if key
198
+ end
110
199
  end
111
200
 
112
201
  def matches_url?(pattern, href)
@@ -134,7 +223,8 @@ module Lutaml
134
223
 
135
224
  # Convert {param} to wildcards for matching
136
225
  pattern_with_wildcards = pattern.gsub(/\{[^}]+\}/, '*')
137
- # Convert * wildcards to regex pattern
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
138
228
  regex = Regexp.new("^#{pattern_with_wildcards.gsub('*', '[^/]+')}$")
139
229
 
140
230
  Hal.debug_log("pattern_match?: regex: #{regex.inspect}")
@@ -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
@@ -32,6 +35,144 @@ module Lutaml
32
35
  end
33
36
  end
34
37
  end
38
+
39
+ # Returns the next page of results, or nil if on the last page
40
+ #
41
+ # @return [Object, nil] The next page instance or nil
42
+ def next
43
+ return nil unless links.next
44
+
45
+ links.next.realize
46
+ end
47
+
48
+ # Returns the previous page of results, or nil if on the first page
49
+ #
50
+ # @return [Object, nil] The previous page instance or nil
51
+ def prev
52
+ # If the API provides a prev link, use it
53
+ return links.prev.realize if links.prev
54
+
55
+ # If we're on page 1, there's no previous page
56
+ return nil if page <= 1
57
+
58
+ # Construct the previous page URL manually
59
+ prev_page_url = construct_page_url(page - 1)
60
+ return nil unless prev_page_url
61
+
62
+ # Use the HAL register to fetch the previous page
63
+ register_name = instance_variable_get("@#{Lutaml::Hal::REGISTER_ID_ATTR_NAME}")
64
+ return nil unless register_name
65
+
66
+ hal_register = Lutaml::Hal::GlobalRegister.instance.get(register_name)
67
+ return nil unless hal_register
68
+
69
+ hal_register.resolve_and_cast(nil, prev_page_url)
70
+ end
71
+
72
+ # Returns the first page of results
73
+ #
74
+ # @return [Object, nil] The first page instance or nil
75
+ def first
76
+ return nil unless links.first
77
+
78
+ links.first.realize
79
+ end
80
+
81
+ # Returns the last page of results
82
+ #
83
+ # @return [Object, nil] The last page instance or nil
84
+ def last
85
+ return nil unless links.last
86
+
87
+ links.last.realize
88
+ end
89
+
90
+ # Returns the total number of pages
91
+ #
92
+ # @return [Integer] The total number of pages
93
+ def total_pages
94
+ pages
95
+ end
96
+
97
+ # Checks if there is a next page available
98
+ #
99
+ # @return [Boolean] true if next page exists, false otherwise
100
+ def next?
101
+ !links.next.nil?
102
+ end
103
+
104
+ # Checks if there is a previous page available
105
+ #
106
+ # @return [Boolean] true if previous page exists, false otherwise
107
+ def prev?
108
+ !links.prev.nil?
109
+ end
110
+
111
+ # Checks if there is a first page link available
112
+ #
113
+ # @return [Boolean] true if first page link exists, false otherwise
114
+ def first?
115
+ !links.first.nil?
116
+ end
117
+
118
+ # Checks if there is a last page link available
119
+ #
120
+ # @return [Boolean] true if last page link exists, false otherwise
121
+ def last?
122
+ !links.last.nil?
123
+ end
124
+
125
+ # Returns the next page link, or nil if on the last page
126
+ #
127
+ # @return [Object, nil] The next page link or nil
128
+ def next_page
129
+ links.next
130
+ end
131
+
132
+ # Returns the previous page link, or nil if on the first page
133
+ #
134
+ # @return [Object, nil] The previous page link or nil
135
+ def prev_page
136
+ links.prev
137
+ end
138
+
139
+ # Returns the first page link
140
+ #
141
+ # @return [Object, nil] The first page link or nil
142
+ def first_page
143
+ links.first
144
+ end
145
+
146
+ # Returns the last page link
147
+ #
148
+ # @return [Object, nil] The last page link or nil
149
+ def last_page
150
+ links.last
151
+ end
152
+
153
+ private
154
+
155
+ # Constructs a URL for a specific page based on the current page's URL pattern
156
+ #
157
+ # @param target_page [Integer] The page number to construct URL for
158
+ # @return [String, nil] The constructed URL or nil if unable to construct
159
+ def construct_page_url(target_page)
160
+ # Try to get a reference URL from next, first, or last links
161
+ reference_url = links.next&.href || links.first&.href || links.last&.href
162
+ return nil unless reference_url
163
+
164
+ # Parse the reference URL and modify the page parameter
165
+ uri = URI.parse(reference_url)
166
+ query_params = URI.decode_www_form(uri.query || '')
167
+
168
+ # Update the page parameter
169
+ query_params = query_params.reject { |key, _| key == 'page' }
170
+ query_params << ['page', target_page.to_s]
171
+
172
+ # Reconstruct the URL
173
+ uri.query = URI.encode_www_form(query_params)
174
+ uri.to_s
175
+ end
35
176
  end
36
177
  end
37
178
  end
@@ -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.5'
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.5
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-03-13 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: