lutaml-hal 0.1.0
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 +7 -0
- data/Gemfile +12 -0
- data/LICENSE.md +33 -0
- data/README.adoc +244 -0
- data/lib/lutaml/hal/client.rb +119 -0
- data/lib/lutaml/hal/errors.rb +12 -0
- data/lib/lutaml/hal/link.rb +25 -0
- data/lib/lutaml/hal/model_register.rb +114 -0
- data/lib/lutaml/hal/page.rb +24 -0
- data/lib/lutaml/hal/resource.rb +124 -0
- data/lib/lutaml/hal/version.rb +7 -0
- data/lib/lutaml/hal.rb +17 -0
- data/lib/lutaml-hal.rb +3 -0
- metadata +114 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 7ea54d1764e2083f8ba5e232022fa2bae95db1775db65e1be46101c3dc2db3a8
|
4
|
+
data.tar.gz: 3d519340919d3f8d84b5c6a38bc73796fbf662cc40c4a01cc030b3cfad9b00ea
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 31543a34726a0d12557f07aae9d9f70fba8ea936abb0b3f8792d26741ba4f353fcd7b9fd08e2cbff44e91819e18efcb5b67a3c50714752221c58f6a39c7761f1
|
7
|
+
data.tar.gz: 2422ddeeb2903e7adce861fce307594c11f5d147b185121e082ad49fe19156016a940ee9199d6591ab203942c4713e48c0ab3d375bf03d944df5faa914a1b4b1
|
data/Gemfile
ADDED
@@ -0,0 +1,12 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
source 'https://rubygems.org'
|
4
|
+
|
5
|
+
# Specify your gem's dependencies in lutaml-hal.gemspec
|
6
|
+
gemspec
|
7
|
+
|
8
|
+
gem 'rake'
|
9
|
+
gem 'rspec', '~> 3.12'
|
10
|
+
gem 'rubocop'
|
11
|
+
|
12
|
+
gem 'lutaml-model', git: 'https://github.com/lutaml/lutaml-model.git'
|
data/LICENSE.md
ADDED
@@ -0,0 +1,33 @@
|
|
1
|
+
Licenses & Copyright
|
2
|
+
====================
|
3
|
+
|
4
|
+
This license file adheres to the formatting guidelines of
|
5
|
+
[readable-licenses](https://github.com/nevir/readable-licenses).
|
6
|
+
|
7
|
+
|
8
|
+
Ribose's BSD 2-Clause License
|
9
|
+
-----------------------------
|
10
|
+
|
11
|
+
Copyright (c) 2024, [Ribose Inc](https://www.ribose.com).
|
12
|
+
All rights reserved.
|
13
|
+
|
14
|
+
Redistribution and use in source and binary forms, with or without modification,
|
15
|
+
are permitted provided that the following conditions are met:
|
16
|
+
|
17
|
+
1. Redistributions of source code must retain the above copyright notice,
|
18
|
+
this list of conditions and the following disclaimer.
|
19
|
+
|
20
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
21
|
+
this list of conditions and the following disclaimer in the documentation
|
22
|
+
and/or other materials provided with the distribution.
|
23
|
+
|
24
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
25
|
+
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
26
|
+
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
27
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE
|
28
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
29
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
30
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
31
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
32
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
|
33
|
+
THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
data/README.adoc
ADDED
@@ -0,0 +1,244 @@
|
|
1
|
+
= LutaML Model for HAL
|
2
|
+
|
3
|
+
https://github.com/lutaml/lutaml-hal[image:https://img.shields.io/github/stars/lutaml/lutaml-hal.svg?style=social[GitHub Stars]]
|
4
|
+
https://github.com/lutaml/lutaml-hal[image:https://img.shields.io/github/forks/lutaml/lutaml-hal.svg?style=social[GitHub Forks]]
|
5
|
+
image:https://img.shields.io/github/license/lutaml/lutaml-hal.svg[License]
|
6
|
+
image:https://img.shields.io/github/actions/workflow/status/lutaml/lutaml-hal/test.yml?branch=main[Build Status]
|
7
|
+
image:https://img.shields.io/gem/v/lutaml-hal.svg[RubyGems Version]
|
8
|
+
|
9
|
+
|
10
|
+
== Purpose
|
11
|
+
|
12
|
+
The `lutaml-hal` gem provides a framework for interacting with HAL-compliant
|
13
|
+
APIs using the power of LutaML Models.
|
14
|
+
|
15
|
+
Hypertext Application Language (HAL)
|
16
|
+
(https://www.ietf.org/archive/id/draft-kelly-json-hal-11.html[HAL Internet-Draft])
|
17
|
+
is a simple format for representing
|
18
|
+
resources and their relationships in a hypermedia-driven API.
|
19
|
+
|
20
|
+
It allows clients to navigate and interact with resources using links, making it
|
21
|
+
easier to build flexible and extensible applications.
|
22
|
+
|
23
|
+
This library provides a set of classes and methods for modeling HAL resources,
|
24
|
+
links, and collections, as well as a client for making HTTP requests to HAL
|
25
|
+
APIs.
|
26
|
+
|
27
|
+
== Features
|
28
|
+
|
29
|
+
* Classes for modeling HAL resources and links
|
30
|
+
* A client for making HTTP requests to HAL APIs
|
31
|
+
* Tools for pagination and resource resolution
|
32
|
+
* Integration with the `lutaml-model` serialization framework
|
33
|
+
* Error handling and response validation for API interactions
|
34
|
+
|
35
|
+
|
36
|
+
== Installation
|
37
|
+
|
38
|
+
Add this line to your application's Gemfile:
|
39
|
+
|
40
|
+
[source,ruby]
|
41
|
+
----
|
42
|
+
gem 'lutaml-hal'
|
43
|
+
----
|
44
|
+
|
45
|
+
And then execute:
|
46
|
+
|
47
|
+
[source,sh]
|
48
|
+
----
|
49
|
+
$ bundle install
|
50
|
+
----
|
51
|
+
|
52
|
+
Or install it yourself as:
|
53
|
+
|
54
|
+
[source,sh]
|
55
|
+
----
|
56
|
+
$ gem install lutaml-hal
|
57
|
+
----
|
58
|
+
|
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
|
+
|
89
|
+
== Usage
|
90
|
+
|
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
|
109
|
+
|
110
|
+
[source,ruby]
|
111
|
+
----
|
112
|
+
require 'lutaml-hal'
|
113
|
+
|
114
|
+
# Create a new client with API endpoint
|
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')
|
134
|
+
|
135
|
+
# => {
|
136
|
+
# "page": 1,
|
137
|
+
# "pages": 10,
|
138
|
+
# "limit": 10,
|
139
|
+
# "total": 45,
|
140
|
+
# "_links": {
|
141
|
+
# "self": { "href": "/products/1" },
|
142
|
+
# "next": { "href": "/products/2" },
|
143
|
+
# "last": { "href": "/products/5" },
|
144
|
+
# "products": [
|
145
|
+
# { "id": 1, "name": "Product 1", "price": 10.0 },
|
146
|
+
# { "id": 2, "name": "Product 2", "price": 15.0 }
|
147
|
+
# ]
|
148
|
+
# }
|
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
|
+
# ]}>
|
175
|
+
----
|
176
|
+
|
177
|
+
=== Defining resource models
|
178
|
+
|
179
|
+
[source,ruby]
|
180
|
+
----
|
181
|
+
module MyApi
|
182
|
+
class Product < Lutaml::Hal::Resource
|
183
|
+
attribute :id, :string
|
184
|
+
attribute :name, :string
|
185
|
+
attribute :price, :float
|
186
|
+
|
187
|
+
hal_link :self, key: 'self', realize_class: 'Product'
|
188
|
+
hal_link :category, key: 'category', realize_class: 'Category'
|
189
|
+
|
190
|
+
key_value do
|
191
|
+
map 'id', to: :id
|
192
|
+
map 'name', to: :name
|
193
|
+
map 'price', to: :price
|
194
|
+
end
|
195
|
+
end
|
196
|
+
|
197
|
+
# Register the model with the registry
|
198
|
+
Lutaml::Hal::ModelRegister.register(Product, '/products/{id}')
|
199
|
+
end
|
200
|
+
----
|
201
|
+
|
202
|
+
=== Registering endpoints
|
203
|
+
|
204
|
+
=== Fetching Resources
|
205
|
+
|
206
|
+
[source,ruby]
|
207
|
+
----
|
208
|
+
# Assume that the client is already created and registered at
|
209
|
+
# the ModelRegister
|
210
|
+
# Get a resource
|
211
|
+
product = client.get('products/123')
|
212
|
+
product_resource = MyApi::Product.from_json(product.to_json)
|
213
|
+
|
214
|
+
# Follow a link
|
215
|
+
category = product_resource.category.realize(register)
|
216
|
+
----
|
217
|
+
|
218
|
+
=== Working with Collections
|
219
|
+
|
220
|
+
[source,ruby]
|
221
|
+
----
|
222
|
+
class ProductPage < Lutaml::Hal::Page
|
223
|
+
# Define the relationship between page and items
|
224
|
+
end
|
225
|
+
|
226
|
+
response = client.get('/products')
|
227
|
+
products = ProductPage.from_json(response.to_json)
|
228
|
+
|
229
|
+
# Access pagination info
|
230
|
+
puts "Page #{products.page} of #{products.pages}, total: #{products.total}"
|
231
|
+
|
232
|
+
# Access linked items
|
233
|
+
products.links.products.each do |product|
|
234
|
+
puts "#{product.name}: $#{product.price}"
|
235
|
+
end
|
236
|
+
----
|
237
|
+
|
238
|
+
|
239
|
+
== License and Copyright
|
240
|
+
|
241
|
+
This project is licensed under the BSD 2-clause License.
|
242
|
+
See the link:LICENSE.md[] file for details.
|
243
|
+
|
244
|
+
Copyright Ribose.
|
@@ -0,0 +1,119 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'faraday'
|
4
|
+
require 'faraday/follow_redirects'
|
5
|
+
require 'json'
|
6
|
+
require 'rainbow'
|
7
|
+
require_relative 'errors'
|
8
|
+
|
9
|
+
module Lutaml
|
10
|
+
module Hal
|
11
|
+
# HAL Client for making HTTP requests to HAL APIs
|
12
|
+
class Client
|
13
|
+
attr_reader :last_response, :api_url, :connection
|
14
|
+
|
15
|
+
def initialize(options = {})
|
16
|
+
@api_url = options[:api_url] || raise(ArgumentError, 'api_url is required')
|
17
|
+
@connection = options[:connection] || create_connection
|
18
|
+
@params_default = options[:params_default] || {}
|
19
|
+
@debug = options[:debug] || !ENV['DEBUG_API'].nil?
|
20
|
+
@cache = options[:cache] || {}
|
21
|
+
@cache_enabled = options[:cache_enabled] || false
|
22
|
+
end
|
23
|
+
|
24
|
+
# Get a resource by its full URL
|
25
|
+
def get_by_url(url, params = {})
|
26
|
+
# Strip API endpoint if it's included
|
27
|
+
path = url.sub(%r{^#{@api_url}/}, '')
|
28
|
+
get(path, params)
|
29
|
+
end
|
30
|
+
|
31
|
+
# Make a GET request to the API
|
32
|
+
def get(url, params = {})
|
33
|
+
cache_key = "#{url}:#{params.to_json}"
|
34
|
+
|
35
|
+
return @cache[cache_key] if @cache_enabled && @cache.key?(cache_key)
|
36
|
+
|
37
|
+
@last_response = @connection.get(url, params)
|
38
|
+
|
39
|
+
response = handle_response(@last_response, url)
|
40
|
+
|
41
|
+
@cache[cache_key] = response if @cache_enabled
|
42
|
+
response
|
43
|
+
rescue Faraday::ConnectionFailed => e
|
44
|
+
raise ConnectionError, "Connection failed: #{e.message}"
|
45
|
+
rescue Faraday::TimeoutError => e
|
46
|
+
raise TimeoutError, "Request timed out: #{e.message}"
|
47
|
+
rescue Faraday::ParsingError => e
|
48
|
+
raise ParsingError, "Response parsing error: #{e.message}"
|
49
|
+
rescue Faraday::Adapter::Test::Stubs::NotFound => e
|
50
|
+
raise LinkResolutionError, "Resource not found: #{e.message}"
|
51
|
+
end
|
52
|
+
|
53
|
+
private
|
54
|
+
|
55
|
+
def create_connection
|
56
|
+
Faraday.new(url: @api_url) do |conn|
|
57
|
+
conn.use Faraday::FollowRedirects::Middleware
|
58
|
+
conn.request :json
|
59
|
+
conn.response :json, content_type: /\bjson$/
|
60
|
+
conn.adapter Faraday.default_adapter
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
def handle_response(response, url)
|
65
|
+
debug_log(response, url) if @debug
|
66
|
+
|
67
|
+
case response.status
|
68
|
+
when 200..299
|
69
|
+
response.body
|
70
|
+
when 400
|
71
|
+
raise BadRequestError, response_message(response)
|
72
|
+
when 401
|
73
|
+
raise UnauthorizedError, response_message(response)
|
74
|
+
when 404
|
75
|
+
raise NotFoundError, response_message(response)
|
76
|
+
when 500..599
|
77
|
+
raise ServerError, response_message(response)
|
78
|
+
else
|
79
|
+
raise Error, response_message(response)
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
def debug_log(response, url)
|
84
|
+
if defined?(Rainbow)
|
85
|
+
puts Rainbow("\n===== DEBUG: HAL API REQUEST =====").blue
|
86
|
+
else
|
87
|
+
puts "\n===== DEBUG: HAL API REQUEST ====="
|
88
|
+
end
|
89
|
+
|
90
|
+
puts "URL: #{url}"
|
91
|
+
puts "Status: #{response.status}"
|
92
|
+
|
93
|
+
# Format headers as JSON
|
94
|
+
puts "\nHeaders:"
|
95
|
+
headers_hash = response.headers.to_h
|
96
|
+
puts JSON.pretty_generate(headers_hash)
|
97
|
+
|
98
|
+
puts "\nResponse body:"
|
99
|
+
if response.body.is_a?(Hash) || response.body.is_a?(Array)
|
100
|
+
puts JSON.pretty_generate(response.body)
|
101
|
+
else
|
102
|
+
puts response.body.inspect
|
103
|
+
end
|
104
|
+
|
105
|
+
if defined?(Rainbow)
|
106
|
+
puts Rainbow("===== END DEBUG OUTPUT =====\n").blue
|
107
|
+
else
|
108
|
+
puts "===== END DEBUG OUTPUT =====\n"
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
def response_message(response)
|
113
|
+
message = "Status: #{response.status}"
|
114
|
+
message += ", Error: #{response.body['error']}" if response.body.is_a?(Hash) && response.body['error']
|
115
|
+
message
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Lutaml
|
4
|
+
module Hal
|
5
|
+
class Error < StandardError; end
|
6
|
+
class NotFoundError < Error; end
|
7
|
+
class UnauthorizedError < Error; end
|
8
|
+
class BadRequestError < Error; end
|
9
|
+
class ServerError < Error; end
|
10
|
+
class LinkResolutionError < Error; end
|
11
|
+
end
|
12
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'lutaml/model'
|
4
|
+
require_relative 'model_register'
|
5
|
+
|
6
|
+
module Lutaml
|
7
|
+
module Hal
|
8
|
+
# HAL Link representation with realization capability
|
9
|
+
class Link < Lutaml::Model::Serializable
|
10
|
+
attribute :href, :string
|
11
|
+
attribute :title, :string
|
12
|
+
attribute :name, :string
|
13
|
+
attribute :templated, :boolean
|
14
|
+
attribute :type, :string
|
15
|
+
attribute :deprecation, :string
|
16
|
+
attribute :profile, :string
|
17
|
+
attribute :lang, :string
|
18
|
+
|
19
|
+
# Fetch the actual resource this link points to
|
20
|
+
def realize(register)
|
21
|
+
register.resolve_and_cast(href)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,114 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'errors'
|
4
|
+
|
5
|
+
module Lutaml
|
6
|
+
module Hal
|
7
|
+
# Register to map URL patterns to model classes
|
8
|
+
class ModelRegister
|
9
|
+
attr_accessor :models, :client
|
10
|
+
|
11
|
+
def initialize(client: nil)
|
12
|
+
# If `client` is not set, it can be set later
|
13
|
+
@client = client
|
14
|
+
@models = {}
|
15
|
+
end
|
16
|
+
|
17
|
+
# Register a model with its base URL pattern
|
18
|
+
def add_endpoint(id:, type:, url:, model:)
|
19
|
+
@models ||= {}
|
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
|
+
}
|
32
|
+
end
|
33
|
+
|
34
|
+
# 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
|
+
|
45
|
+
def resolve_and_cast(href)
|
46
|
+
raise 'Client not configured' unless client
|
47
|
+
|
48
|
+
debug_log("href #{href}")
|
49
|
+
response = client.get_by_url(href)
|
50
|
+
|
51
|
+
# TODO: Merge more content into the resource
|
52
|
+
response_with_link_details = response.to_h.merge({ 'href' => href })
|
53
|
+
|
54
|
+
href_path = href.sub(client.api_url, '')
|
55
|
+
model_class = find_matching_model_class(href_path)
|
56
|
+
raise LinkResolutionError, "Unregistered URL pattern: #{href}" unless model_class
|
57
|
+
|
58
|
+
debug_log("model_class #{model_class}")
|
59
|
+
debug_log("response: #{response.inspect}")
|
60
|
+
debug_log("amended: #{response_with_link_details}")
|
61
|
+
|
62
|
+
model_class.from_json(response_with_link_details.to_json)
|
63
|
+
end
|
64
|
+
|
65
|
+
private
|
66
|
+
|
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
|
+
|
73
|
+
def find_matching_model_class(href)
|
74
|
+
@models.values.find do |model_data|
|
75
|
+
matches_url?(model_data[:url], href)
|
76
|
+
end&.[](:model)
|
77
|
+
end
|
78
|
+
|
79
|
+
def matches_url?(pattern, href)
|
80
|
+
return false unless pattern && href
|
81
|
+
|
82
|
+
if href.start_with?('/') && client&.api_url
|
83
|
+
# Try both with and without the API endpoint prefix
|
84
|
+
path_pattern = extract_path(pattern)
|
85
|
+
return pattern_match?(path_pattern, href) ||
|
86
|
+
pattern_match?(pattern, "#{client.api_url}#{href}")
|
87
|
+
end
|
88
|
+
|
89
|
+
pattern_match?(pattern, href)
|
90
|
+
end
|
91
|
+
|
92
|
+
def extract_path(pattern)
|
93
|
+
return pattern unless client&.api_url && pattern.start_with?(client.api_url)
|
94
|
+
|
95
|
+
pattern.sub(client.api_url, '')
|
96
|
+
end
|
97
|
+
|
98
|
+
# Match URL pattern (supports * wildcards and {param} templates)
|
99
|
+
def pattern_match?(pattern, url)
|
100
|
+
return false unless pattern && url
|
101
|
+
|
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('*', '[^/]+')}$")
|
106
|
+
regex.match?(url)
|
107
|
+
end
|
108
|
+
|
109
|
+
def debug_log(message)
|
110
|
+
puts "DEBUG: #{message}" if ENV['DEBUG_API']
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'resource'
|
4
|
+
|
5
|
+
module Lutaml
|
6
|
+
module Hal
|
7
|
+
# Models the pagination of a collection of resources
|
8
|
+
# This class is used to represent the pagination information
|
9
|
+
# for a collection of resources in the HAL format.
|
10
|
+
class Page < Resource
|
11
|
+
attribute :page, :integer
|
12
|
+
attribute :limit, :integer
|
13
|
+
attribute :pages, :integer
|
14
|
+
attribute :total, :integer
|
15
|
+
|
16
|
+
key_value do
|
17
|
+
map 'page', to: :page
|
18
|
+
map 'limit', to: :limit
|
19
|
+
map 'pages', to: :pages
|
20
|
+
map 'total', to: :total
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,124 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'lutaml/model'
|
4
|
+
require_relative 'link'
|
5
|
+
|
6
|
+
module Lutaml
|
7
|
+
module Hal
|
8
|
+
# Resource class for all HAL resources
|
9
|
+
class Resource < Lutaml::Model::Serializable
|
10
|
+
class << self
|
11
|
+
attr_accessor :link_definitions
|
12
|
+
|
13
|
+
# Callback for when a subclass is created
|
14
|
+
def inherited(subclass)
|
15
|
+
super
|
16
|
+
subclass.class_eval do
|
17
|
+
create_links_class
|
18
|
+
init_links_definition
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
# The developer defines a link to another resource
|
23
|
+
# The "key" is the name of the attribute in the JSON
|
24
|
+
# The "realize_class" is the class to be realized
|
25
|
+
# The "collection" is a boolean indicating if the link
|
26
|
+
# is a collection of resources or a single resource
|
27
|
+
# The "type" is the type of the link (default is :link, can be :resource)
|
28
|
+
def hal_link(attr_key, key:, realize_class:, collection: false, type: :link)
|
29
|
+
# Use the provided "key" as the attribute name
|
30
|
+
attribute_name = attr_key.to_sym
|
31
|
+
|
32
|
+
# Create a dynamic Link subclass name based on "realize_class", the
|
33
|
+
# class to realize for a Link object
|
34
|
+
link_klass = create_link_class(realize_class)
|
35
|
+
links_klass = get_links_class
|
36
|
+
links_klass.class_eval do
|
37
|
+
# Declare the corresponding lutaml-model attribute
|
38
|
+
attribute attribute_name, link_klass, collection: collection
|
39
|
+
|
40
|
+
# Define the mapping for the attribute
|
41
|
+
key_value do
|
42
|
+
map attr_key, to: attribute_name
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
# Create a new link definition for future reference
|
47
|
+
link_def = {
|
48
|
+
attribute_name: attribute_name,
|
49
|
+
key: attr_key,
|
50
|
+
klass: link_klass,
|
51
|
+
collection: collection,
|
52
|
+
type: type
|
53
|
+
}
|
54
|
+
|
55
|
+
@link_definitions ||= {}
|
56
|
+
@link_definitions[key] = link_def
|
57
|
+
end
|
58
|
+
|
59
|
+
# This method obtains the Links class that holds the Link classes
|
60
|
+
def get_links_class
|
61
|
+
parent_klass_name = name.split('::')[0..-2].join('::')
|
62
|
+
child_klass_name = "#{name.split('::').last}Links"
|
63
|
+
klass_name = "#{parent_klass_name}::#{child_klass_name}"
|
64
|
+
|
65
|
+
raise unless Object.const_defined?(klass_name)
|
66
|
+
|
67
|
+
Object.const_get(klass_name)
|
68
|
+
end
|
69
|
+
|
70
|
+
private
|
71
|
+
|
72
|
+
# The "links" class holds the `_links` object which contains
|
73
|
+
# the resource-linked Link classes
|
74
|
+
def create_links_class
|
75
|
+
parent_klass_name = name.split('::')[0..-2].join('::')
|
76
|
+
child_klass_name = "#{name.split('::').last}Links"
|
77
|
+
klass_name = "#{parent_klass_name}::#{child_klass_name}"
|
78
|
+
|
79
|
+
# Check if the Links class is already defined, return if so
|
80
|
+
return Object.const_get(klass_name) if Object.const_defined?(klass_name)
|
81
|
+
|
82
|
+
# Define the links class dynamically as a normal Lutaml::Model class
|
83
|
+
# since it is not a Resource
|
84
|
+
klass = Class.new(Lutaml::Model::Serializable)
|
85
|
+
Object.const_get(parent_klass_name).tap do |parent_klass|
|
86
|
+
parent_klass.const_set(child_klass_name, klass)
|
87
|
+
end
|
88
|
+
|
89
|
+
# Define the Links class with mapping inside the current class
|
90
|
+
class_eval do
|
91
|
+
attribute :links, klass
|
92
|
+
key_value do
|
93
|
+
map '_links', to: :links
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
def init_links_definition
|
99
|
+
@link_definitions = {}
|
100
|
+
end
|
101
|
+
|
102
|
+
# This is a Link class that helps us realize the targeted class
|
103
|
+
def create_link_class(realize_class_name)
|
104
|
+
parent_klass_name = name.split('::')[0..-2].join('::')
|
105
|
+
child_klass_name = "#{name.split('::').last}Link"
|
106
|
+
klass_name = "#{parent_klass_name}::#{child_klass_name}"
|
107
|
+
|
108
|
+
return Object.const_get(klass_name) if Object.const_defined?(klass_name)
|
109
|
+
|
110
|
+
# Define the link class dynamically
|
111
|
+
klass = Class.new(Link) do
|
112
|
+
# Define the link class with the specified key and class
|
113
|
+
attribute :type, :string, default: realize_class_name
|
114
|
+
end
|
115
|
+
Object.const_get(parent_klass_name).tap do |parent_klass|
|
116
|
+
parent_klass.const_set(child_klass_name, klass)
|
117
|
+
end
|
118
|
+
|
119
|
+
klass
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
data/lib/lutaml/hal.rb
ADDED
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'lutaml/model'
|
4
|
+
|
5
|
+
module Lutaml
|
6
|
+
# HAL implementation for Lutaml
|
7
|
+
module Hal
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
require_relative 'hal/version'
|
12
|
+
require_relative 'hal/errors'
|
13
|
+
require_relative 'hal/link'
|
14
|
+
require_relative 'hal/resource'
|
15
|
+
require_relative 'hal/page'
|
16
|
+
require_relative 'hal/model_register'
|
17
|
+
require_relative 'hal/client'
|
data/lib/lutaml-hal.rb
ADDED
metadata
ADDED
@@ -0,0 +1,114 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: lutaml-hal
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Ribose Inc.
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2025-03-10 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: faraday
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '2.0'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '2.0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: faraday-follow_redirects
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '0.3'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '0.3'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: lutaml-model
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - ">="
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0'
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - ">="
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: rainbow
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - "~>"
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '3.0'
|
62
|
+
type: :runtime
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - "~>"
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '3.0'
|
69
|
+
description: Hypertext Application Language (HAL) implementation for Lutaml model
|
70
|
+
email:
|
71
|
+
- open.source@ribose.com
|
72
|
+
executables: []
|
73
|
+
extensions: []
|
74
|
+
extra_rdoc_files: []
|
75
|
+
files:
|
76
|
+
- Gemfile
|
77
|
+
- LICENSE.md
|
78
|
+
- README.adoc
|
79
|
+
- lib/lutaml-hal.rb
|
80
|
+
- lib/lutaml/hal.rb
|
81
|
+
- lib/lutaml/hal/client.rb
|
82
|
+
- lib/lutaml/hal/errors.rb
|
83
|
+
- lib/lutaml/hal/link.rb
|
84
|
+
- lib/lutaml/hal/model_register.rb
|
85
|
+
- lib/lutaml/hal/page.rb
|
86
|
+
- lib/lutaml/hal/resource.rb
|
87
|
+
- lib/lutaml/hal/version.rb
|
88
|
+
homepage: https://github.com/lutaml/lutaml-hal
|
89
|
+
licenses:
|
90
|
+
- MIT
|
91
|
+
metadata:
|
92
|
+
homepage_uri: https://github.com/lutaml/lutaml-hal
|
93
|
+
source_code_uri: https://github.com/lutaml/lutaml-hal
|
94
|
+
changelog_uri: https://github.com/lutaml/lutaml-hal/blob/master/CHANGELOG.md
|
95
|
+
post_install_message:
|
96
|
+
rdoc_options: []
|
97
|
+
require_paths:
|
98
|
+
- lib
|
99
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
100
|
+
requirements:
|
101
|
+
- - ">="
|
102
|
+
- !ruby/object:Gem::Version
|
103
|
+
version: 2.6.0
|
104
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
105
|
+
requirements:
|
106
|
+
- - ">="
|
107
|
+
- !ruby/object:Gem::Version
|
108
|
+
version: '0'
|
109
|
+
requirements: []
|
110
|
+
rubygems_version: 3.5.22
|
111
|
+
signing_key:
|
112
|
+
specification_version: 4
|
113
|
+
summary: HAL implementation for LutaML
|
114
|
+
test_files: []
|