Skip to content

Commit 2009ffb

Browse files
committed
feat: implement new register.fetch interface
1 parent 0ffc09e commit 2009ffb

File tree

6 files changed

+213
-82
lines changed

6 files changed

+213
-82
lines changed

.rubocop_todo.yml

+12-5
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
11
# This configuration was generated by
22
# `rubocop --auto-gen-config`
3-
# on 2025-03-10 09:19:41 UTC using RuboCop version 1.73.2.
3+
# on 2025-03-10 11:29:40 UTC using RuboCop version 1.73.2.
44
# The point is for the user to remove these configuration records
55
# one by one as the offenses are removed from the code base.
66
# Note that changes in the inspected code, or installation of new
77
# versions of RuboCop, may require this file to be generated again.
88

9-
# Offense count: 2
9+
# Offense count: 1
10+
# This cop supports safe autocorrection (--autocorrect).
11+
# Configuration parameters: AutoCorrect.
12+
Lint/UselessAssignment:
13+
Exclude:
14+
- 'spec/lutaml/hal/page_spec.rb'
15+
16+
# Offense count: 3
1017
# Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes.
1118
Metrics/AbcSize:
1219
Max: 28
@@ -15,9 +22,9 @@ Metrics/AbcSize:
1522
# Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns.
1623
# AllowedMethods: refine
1724
Metrics/BlockLength:
18-
Max: 174
25+
Max: 177
1926

20-
# Offense count: 6
27+
# Offense count: 8
2128
# Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns.
2229
Metrics/MethodLength:
2330
Max: 21
@@ -43,7 +50,7 @@ Naming/HeredocDelimiterNaming:
4350
Exclude:
4451
- 'spec/lutaml/hal/integration_spec.rb'
4552

46-
# Offense count: 9
53+
# Offense count: 6
4754
# Configuration parameters: EnforcedStyle, CheckMethodNames, CheckSymbols, AllowedIdentifiers, AllowedPatterns.
4855
# SupportedStyles: snake_case, normalcase, non_integer
4956
# AllowedIdentifiers: capture3, iso8601, rfc1123_date, rfc822, rfc2822, rfc3339, x86_64

README.adoc

+96-6
Original file line numberDiff line numberDiff line change
@@ -56,20 +56,82 @@ Or install it yourself as:
5656
$ gem install lutaml-hal
5757
----
5858

59+
== Structure
60+
61+
The classes in this library are organized into the following modules:
62+
63+
`Lutaml::Hal::Client`::
64+
A client for making HTTP requests to HAL APIs. It includes methods for setting
65+
the API endpoint, making GET requests, and handling responses.
66+
+
67+
NOTE: Only GET requests are supported at the moment.
68+
69+
`Lutaml::Hal::ModelRegister`::
70+
A registry for managing HAL resource models and their endpoints. It allows you
71+
to register models, define their relationships, and fetch resources from the
72+
API.
73+
74+
`Lutaml::Hal::Resource`::
75+
A base class for defining HAL resource models. It includes methods for
76+
defining attributes, links, and key-value mappings for resources.
77+
78+
`Lutaml::Hal::Link`::
79+
A class for defining HAL links. It includes methods for specifying the
80+
relationship between resources and their links, as well as methods for
81+
resolving links to their target resources.
82+
83+
`Lutaml::Hal::Page`::
84+
A class for handling pagination in HAL APIs. It includes methods for
85+
defining pagination attributes, such as `page`, `pages`, `limit`, and
86+
`total`, as well as methods for accessing linked resources within a page.
87+
88+
5989
== Usage
6090

61-
=== Creating a Client
91+
=== General
92+
93+
In order to interact with a HAL API, the following steps are required:
94+
95+
. Create a `Client` that points to the API endpoint.
96+
. Create a `ModelRegister` to manage the resource models and their
97+
respective endpoints.
98+
. Define the resource models using the `Resource` class.
99+
. Register the models with the `ModelRegister`.
100+
. Fetch resources from the API using the `ModelRegister`.
101+
.. Once the resources are fetched, you can access their attributes and links
102+
and navigate through the resource graph.
103+
. Pagination, such as on "index" type pages, can be handled by subclassing the `Page` class.
104+
The `Page` class itself is also implemented as a `Resource`, so you can
105+
use the same methods to access the page's attributes and links.
106+
107+
108+
=== Creating a HAL model register
62109

63110
[source,ruby]
64111
----
65112
require 'lutaml-hal'
66113
67114
# Create a new client with API endpoint
68-
client = Lutaml::Hal::Client.new(api_endpoint: 'https://api.example.com')
69-
register = Lutaml::Hal::ModelRegister.new
70-
register.client = client
115+
client = Lutaml::Hal::Client.new(api_url: 'https://api.example.com')
116+
register = Lutaml::Hal::ModelRegister.new(client: client)
117+
# Or set client later, `register.client = client`
118+
119+
register.add_endpoint(
120+
id: :product_index,
121+
type: :index,
122+
url: '/products',
123+
model: Product
124+
)
125+
register.add_endpoint(
126+
id: :product_resource,
127+
type: :resource,
128+
url: '/products/{id}',
129+
model: Product
130+
)
131+
132+
register.fetch(:product_index)
133+
# => client.get('/products')
71134
72-
client.get('/products')
73135
# => {
74136
# "page": 1,
75137
# "pages": 10,
@@ -84,9 +146,35 @@ client.get('/products')
84146
# { "id": 2, "name": "Product 2", "price": 15.0 }
85147
# ]
86148
# }
149+
150+
product_1 = register.fetch(:product_resource, id: 1)
151+
# => client.get('/products/1')
152+
153+
# => {
154+
# "id": 1,
155+
# "name": "Product 1",
156+
# "price": 10.0,
157+
# "_links": {
158+
# "self": { "href": "/products/1" },
159+
# "category": { "href": "/categories/1", "title": "Category 1" },
160+
# "related": [
161+
# { "href": "/products/3", "title": "Product 3" },
162+
# { "href": "/products/5", "title": "Product 5" }
163+
# ]
164+
# }
165+
# }
166+
167+
product_1
168+
# => #<Product id: 1, name: "Product 1", price: 10.0, links:
169+
# #<ProductLinks self: <ProductLink href: "/products/1">,
170+
# category: <ProductLink href: "/categories/1", title: "Category 1">,
171+
# related: [
172+
# <ProductLink href: "/products/3", title: "Product 3">,
173+
# <ProductLink href: "/products/5", title: "Product 5">
174+
# ]}>
87175
----
88176

89-
=== Defining Resources
177+
=== Defining resource models
90178

91179
[source,ruby]
92180
----
@@ -111,6 +199,8 @@ module MyApi
111199
end
112200
----
113201

202+
=== Registering endpoints
203+
114204
=== Fetching Resources
115205

116206
[source,ruby]

lib/lutaml/hal/client.rb

+4-4
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@ module Lutaml
1010
module Hal
1111
# HAL Client for making HTTP requests to HAL APIs
1212
class Client
13-
attr_reader :last_response, :api_endpoint, :connection
13+
attr_reader :last_response, :api_url, :connection
1414

1515
def initialize(options = {})
16-
@api_endpoint = options[:api_endpoint] || raise(ArgumentError, 'api_endpoint is required')
16+
@api_url = options[:api_url] || raise(ArgumentError, 'api_url is required')
1717
@connection = options[:connection] || create_connection
1818
@params_default = options[:params_default] || {}
1919
@debug = options[:debug] || !ENV['DEBUG_API'].nil?
@@ -24,7 +24,7 @@ def initialize(options = {})
2424
# Get a resource by its full URL
2525
def get_by_url(url, params = {})
2626
# Strip API endpoint if it's included
27-
path = url.sub(%r{^#{@api_endpoint}/}, '')
27+
path = url.sub(%r{^#{@api_url}/}, '')
2828
get(path, params)
2929
end
3030

@@ -53,7 +53,7 @@ def get(url, params = {})
5353
private
5454

5555
def create_connection
56-
Faraday.new(url: @api_endpoint) do |conn|
56+
Faraday.new(url: @api_url) do |conn|
5757
conn.use Faraday::FollowRedirects::Middleware
5858
conn.request :json
5959
conn.response :json, content_type: /\bjson$/

lib/lutaml/hal/model_register.rb

+51-13
Original file line numberDiff line numberDiff line change
@@ -8,21 +8,51 @@ module Hal
88
class ModelRegister
99
attr_accessor :models, :client
1010

11+
def initialize(client: nil)
12+
# If `client` is not set, it can be set later
13+
@client = client
14+
@models = {}
15+
end
16+
1117
# Register a model with its base URL pattern
12-
def register(model_class, url_pattern)
18+
def add_endpoint(id:, type:, url:, model:)
1319
@models ||= {}
14-
@models[url_pattern] = model_class
20+
21+
raise "Model with ID #{id} already registered" if @models[id]
22+
if @models.values.any? { |m| m[:url] == url && m[:type] == type }
23+
raise "Duplicate URL pattern #{url} for type #{type}"
24+
end
25+
26+
@models[id] = {
27+
id: id,
28+
type: type,
29+
url: url,
30+
model: model
31+
}
1532
end
1633

1734
# Resolve and cast data to the appropriate model based on URL
35+
def fetch(endpoint_id, **params)
36+
endpoint = @models[endpoint_id] || raise("Unknown endpoint: #{endpoint_id}")
37+
raise 'Client not configured' unless client
38+
39+
url = interpolate_url(endpoint[:url], params)
40+
response = client.get(url)
41+
42+
endpoint[:model].from_json(response.to_json)
43+
end
44+
1845
def resolve_and_cast(href)
1946
raise 'Client not configured' unless client
2047

2148
debug_log("href #{href}")
2249
response = client.get_by_url(href)
50+
51+
# TODO: Merge more content into the resource
2352
response_with_link_details = response.to_h.merge({ 'href' => href })
2453

25-
model_class = find_matching_model_class(href)
54+
href_path = href.sub(client.api_url, '')
55+
model_class = find_matching_model_class(href_path)
2656
raise LinkResolutionError, "Unregistered URL pattern: #{href}" unless model_class
2757

2858
debug_log("model_class #{model_class}")
@@ -34,37 +64,45 @@ def resolve_and_cast(href)
3464

3565
private
3666

67+
def interpolate_url(url_template, params)
68+
params.reduce(url_template) do |url, (key, value)|
69+
url.gsub("{#{key}}", value.to_s)
70+
end
71+
end
72+
3773
def find_matching_model_class(href)
38-
@models.find do |pattern, _|
39-
debug_log("pattern #{pattern}")
40-
matches_url?(pattern, href)
41-
end&.last
74+
@models.values.find do |model_data|
75+
matches_url?(model_data[:url], href)
76+
end&.[](:model)
4277
end
4378

4479
def matches_url?(pattern, href)
4580
return false unless pattern && href
4681

47-
if href.start_with?('/') && client&.api_endpoint
82+
if href.start_with?('/') && client&.api_url
4883
# Try both with and without the API endpoint prefix
4984
path_pattern = extract_path(pattern)
5085
return pattern_match?(path_pattern, href) ||
51-
pattern_match?(pattern, "#{client.api_endpoint}#{href}")
86+
pattern_match?(pattern, "#{client.api_url}#{href}")
5287
end
5388

5489
pattern_match?(pattern, href)
5590
end
5691

5792
def extract_path(pattern)
58-
return pattern unless client&.api_endpoint && pattern.start_with?(client.api_endpoint)
93+
return pattern unless client&.api_url && pattern.start_with?(client.api_url)
5994

60-
pattern.sub(client.api_endpoint, '')
95+
pattern.sub(client.api_url, '')
6196
end
6297

63-
# Match URL pattern (supports * wildcards)
98+
# Match URL pattern (supports * wildcards and {param} templates)
6499
def pattern_match?(pattern, url)
65100
return false unless pattern && url
66101

67-
regex = Regexp.new("^#{pattern.gsub('*', '.*')}$")
102+
# Convert {param} to wildcards for matching
103+
pattern_with_wildcards = pattern.gsub(/\{[^}]+\}/, '*')
104+
# Convert * wildcards to regex pattern
105+
regex = Regexp.new("^#{pattern_with_wildcards.gsub('*', '[^/]+')}$")
68106
regex.match?(url)
69107
end
70108

0 commit comments

Comments
 (0)