The lutaml-hal
gem provides a framework for interacting with HAL-compliant
APIs using the power of LutaML Models.
Hypertext Application Language (HAL) (HAL Internet-Draft) is a simple format for representing resources and their relationships in a hypermedia-driven API.
It allows clients to navigate and interact with resources using links, making it easier to build flexible and extensible applications.
This library provides a set of classes and methods for modeling HAL resources, links, and collections, as well as a client for making HTTP requests to HAL APIs.
-
Classes for modeling HAL resources and links
-
A client for making HTTP requests to HAL APIs
-
Tools for pagination and resource resolution
-
Integration with the
lutaml-model
serialization framework -
Error handling and response validation for API interactions
Add this line to your application’s Gemfile:
gem 'lutaml-hal'
And then execute:
$ bundle install
Or install it yourself as:
$ gem install lutaml-hal
The classes in this library are organized into the following modules:
Lutaml::Hal::Client
-
A client for making HTTP requests to HAL APIs. It includes methods for setting the API endpoint, making GET requests, and handling responses.
NoteOnly GET requests are supported at the moment. Lutaml::Hal::ModelRegister
-
A registry for managing HAL resource models and their endpoints. It allows you to register models, define their relationships, and fetch resources from the API.
Lutaml::Hal::GlobalRegister
-
A global registry (Singleton) for managing ModelRegisters and facilitating model resolution across different resources. Its usage is optional.
Lutaml::Hal::Resource
-
A base class for defining HAL resource models. It includes methods for defining attributes, links, and key-value mappings for resources.
Lutaml::Hal::Link
-
A class for defining HAL links. It includes methods for specifying the relationship between resources and their links, as well as methods for resolving links to their target resources.
Lutaml::Hal::Page
-
A class for handling pagination in HAL APIs. It includes methods for defining pagination attributes, such as
page
,pages
,limit
, andtotal
, as well as methods for accessing linked resources within a page.
In order to interact with a HAL API using lutaml-hal
, there are two
stages of usage: data definition and runtime.
At the data definition phase:
-
Define the API endpoint using the
Client
class. -
Create a
ModelRegister
to manage the resource models and their respective endpoints. -
(optional) Create a
GlobalRegister
to manage one or moreModelRegister
instances. It is necessary for automatic Link resolution. -
Define the resource models using the
Resource
class. -
Register the models with the
ModelRegister
and define their relationships using theadd_endpoint
method.
Once data definition is present, the following operations can be performed at runtime:
-
Fetch resources from the API using the
ModelRegister
andLink#realize
methods.-
Once the resources are fetched, you can access their attributes and links and navigate through the resource graph.
-
-
Pagination, such as on "index" type pages, can be handled by subclassing the
Page
class.NoteThe Page
class itself is also implemented as aResource
, so you can use the same methods to access the page’s attributes and links.
HAL resources need to be defined as models to allow data access and serialization.
The following steps are required:
-
Define HAL resource models.
-
Define the base API URL using the
Client
class. -
Create a
ModelRegister
to manage the resource models. -
Define the resource models' respective endpoints on the base API URL.
The ModelRegister
class is used to manage the resource models and their
respective endpoints on the base API URL.
It relies on the Client
class to perform HTTP requests to the API. The base
API URL is defined at the Client
object.
Note
|
The base API URL is used for all requests made by the Client class,
including the requests made by the ModelRegister class.
|
require 'lutaml-hal'
# Create a new client with API endpoint
client = Lutaml::Hal::Client.new(api_url: 'https://api.example.com')
register = Lutaml::Hal::ModelRegister.new(name: :my_model_register, client: client)
# Or set client later, `register.client = client`
The name:
parameter is used to identify the ModelRegister
instance.
The GlobalRegister
class is a singleton that manages one or more
ModelRegister
instances.
It is optional, but is required for automatic realization of models from Link objects. See Fetching a resource via link realization for more details.
require 'lutaml-hal'
# Create a new client with API endpoint
client = Lutaml::Hal::Client.new(api_url: 'https://api.example.com')
register = Lutaml::Hal::ModelRegister.new(name: :my_model_register, client: client)
# Register the ModelRegister with the global register
global_register = Lutaml::Hal::GlobalRegister.instance.register(:my_model_register, register)
# Obtain the global register
global_register.get(:my_model_register)
# Delete a register mapping
global_register.delete(:my_model_register)
A HAL resource is defined by creating a subclass of the Resource
class and
defining its attributes, links, and key-value mappings.
The Resource
class is the base class for defining HAL resource models.
It inherits from Lutaml::Model::Serializable
, which provides data
modelling and serialization capabilities.
The declaration of attributes, links, and key-value mappings for a HAL resource
is performed using the attribute
, hal_link
, and key_value
methods.
There are 3 levels of data modeling in a HAL resource, all of which are necessary for the full usage of a HAL resource:
-
Resource attributes
-
Serialization mappings
-
HAL Links
module MyApi
class Product < Lutaml::Hal::Resource
attribute :id, :string
attribute :name, :string
attribute :price, :float
hal_link :self, key: 'self', realize_class: 'Product'
hal_link :category, key: 'category', realize_class: 'Category'
key_value do
map 'id', to: :id
map 'name', to: :name
map 'price', to: :price
end
end
end
A resource attribute is a direct property of the HAL resource.
These attributes typically hold values of simple data types, and are directly serialized into JSON.
These attributes are declared using the attribute
method from lutaml-model
.
A HAL resource of class Product
can have attributes id
, name
, and price
.
Please refer to syntax as described in the
lutaml-model
documentation.
module MyApi
class Product < Lutaml::Hal::Resource
attribute :id, :string
attribute :name, :string
attribute :price, :float
# ...
end
end
A serialization mapping defines rules to serialize a HAL resource to and from a serialization format. In HAL, the serialization format is JSON, but other formats can also be supported.
The mapping between the HAL model attributes and their corresponding JSON
serialization is performed using the key_value do
or json do
blocks from
lutaml-model
. The mapping of the contents of _links
is automatically
performed using hal_link
.
A HAL resource of class Product
with attributes id
, name
, and price
will
need to declare a key_value
block to map the attributes to their corresponding
JSON keys, namely, "id"
, "name"
, and "price"
.
Please refer to syntax as described in the
lutaml-model
documentation.
module MyApi
class Product < Lutaml::Hal::Resource
attribute :id, :string
attribute :name, :string
attribute :price, :float
key_value do
map 'id', to: :id
map 'name', to: :name
map 'price', to: :price
end
end
end
A HAL resource has links to other resources, typically serialized in
the _links
section of the JSON response.
A HAL resource of class Product
can have links self
(which is a
self-referential identifier link) and category
.
HAL links need to be defined in the resource model to allow the resolution of the links to their target resources.
These links are declared using the hal_link
method provided by lutaml-hal
.
Syntax:
hal_link :link_name,
key: 'link_key',
realize_class: 'TargetResourceClass',
link_class: 'LinkClass',
link_set_class: 'LinkSetClass'
:link_name
-
The name of the link, which will be used to access the link in the resource object.
key: 'link_key'
-
The key of the link in the JSON response. This is the name of the link as it appears in the
_links
section of the HAL resource. realize_class: 'TargetResourceClass'
-
The class of the target resource that the link points to. This is used to resolve the link to the associated resource.
link_class: 'LinkClass'
-
(optional) The class of the link that defines specific behavior or attributes for the link object itself. This is dynamically created and is inherited from
Lutaml::Hal::Link
if not provided. link_set_class: 'LinkSetClass'
-
(optional) The class of the link set object that contains the links. This is dynamically created and is inherited from
Lutaml::Model::Serializable
if not provided.
The _links
section is modeled as a dynamically created link set class, named
after the resource’s class name (with an appended LinkSet
string), which in turn
contains the defined links to other resources. The link set class is inherited
from Lutaml::Model::Serializable
.
A HAL resource of class Product
may have a link set of class ProductLinkSet
which contains the self
and category
links as its attributes.
Each link object of the link set is provided as a Link
object that is
dynamically created for the type of resolved resource. The name of the link
class is the same as the resource class name with an appended Link
string.
This Link class is inherited from Lutaml::Hal::Link
.
A HAL resource of class Product
with a link set that contains the self
(points to a Product
) and category
(points to a Category
) links will
have:
-
a link set of class
ProductLinks
which contains:-
a
self
attribute that is an instance ofProductLink
-
a
category
attribute that is an instance ofCategoryLink
-
For an instance of Product
:
module MyApi
class Product < Lutaml::Hal::Resource
attribute :id, :string
attribute :name, :string
attribute :price, :float
hal_link :self, key: 'self', realize_class: 'Product'
hal_link :category, key: 'category', realize_class: 'Category'
key_value do
map 'id', to: :id
map 'name', to: :name
map 'price', to: :price
end
end
end
The library will provide:
-
the link set (serialized in HAL as JSON
_links
) in the classProductLinks
. -
the link set contains the
self
and thecategory
links of classLutaml::Hal::Link
.
As a result:
-
calling
product.links.self
will return an instance ofProductLink
. -
calling
product.links.self.realize(register)
will dynamically fetch and return an instance ofProduct
.
When a custom link set class (via link_set_class:
) is provided, links are no
longer automatically added to the link set via hal_link
. Please ensure that
all links are defined as model attributes
and their key_value
mappings
provided.
This is useful for the scenario where the link set needs to be customized to provide additional attributes or behavior.
A LinkSetClass for a resource must implement the following interface:
module MyApi
# This represents the link set of a Resource
class ResourceLinkSet < Lutaml::Model::Serializable
attribute :attribute_name_1, :link_class_1, collection: {true|false}
attribute :attribute_name_2, :link_class_2, collection: {true|false}
# ...
key_value do
map 'link_key_1', to: :attribute_name_1
map 'link_key_2', to: :attribute_name_2
# ...
end
end
# This represents the basic setup of a Resource with a custom LinkSet class
class Resource < Lutaml::Hal::Resource
attribute :links, ResourceLinkSet
# Define resource attributes
key_value do
# This is the mapping of the `_links` key to the attribute `links`.
map '_links', to: :links
# Mappings for resource attributes need to be explicitly provided
end
end
end
Alternatively, it is possible to re-open the dynamically created link set class and add additional attributes to it.
module MyApi
class Product < Lutaml::Hal::Resource
attribute :id, :string
end
# The class `MyApi::ProductLinkSet` is created automatically by the library.
# Re-open the default link set class and add additional attributes
class ProductLinkSet < Lutaml::Hal::LinkSet
# Add additional attributes to the link set
attribute :custom_link_set_attribute, Something, collection: false
key_value do
map 'my_custom_link', to: :custom_link_set_attribute
end
end
end
When a custom link class (via link_class:
) is provided, the custom link class
is automatically added into the link set.
This makes it possible to:
-
supplement the link with additional attributes, or
-
override the
realize(register)
method to provide custom behavior for the link.
A Link class pointing to a resource must implement the following interface:
module MyApi
# This represents a link set pointing to a Resource
class TargetResourceLink < Lutaml::Model::Serializable
# This is the link class for the resource class Resource
# 'default:' needs to be set to the name of the target resource class
attribute :type, :string, default: 'Resource'
# No specification of key_value block needed since attribute presence
# provides a default mapping.
end
end
Alternatively, it is possible to re-open the dynamically created link class and add additional attributes to it.
module MyApi
class Product < Lutaml::Hal::Resource
attribute :id, :string
hal_link :category, key: 'category', realize_class: 'Category'
end
# The class `MyApi::CategoryLink` is created automatically by the library.
# Re-open the default link class and add additional attributes
class CategoryLink < Lutaml::Hal::Link
# Add additional attributes to the link
attribute :language_code, :string, collection: false
key_value do
map 'language_code', to: :language_code
end
end
end
The ModelRegister
allows you to register resource models and their endpoints.
You can define endpoints for collections (index) and individual resources
(resource) using the add_endpoint
method.
The add_endpoint
method takes the following parameters:
id
-
A unique identifier for the endpoint.
type
-
The type of endpoint, which can be
index
orresource
. url
-
The URL of the endpoint, which can include path parameters.
model
-
The class of the resource that will be fetched from the API. The class must inherit from
Lutaml::Hal::Resource
.
In the url
, you can use interpolation parameters, which will be replaced with
the actual values when fetching the resource. The interpolation parameters are
defined in the url
string using curly braces {}
.
The add_endpoint
method will automatically handle the URL resolution and fetch
the resource from the API.
When the ModelRegister
fetches a resource using the realize
method, it will
match the resource URL against registered paths in order to find the
appropriate model class to use for deserialization and resolution.
Syntax:
register.add_endpoint( (1)
id: :model_index, (2)
type: :index, (3)
url: '/url_supporting_interpolation/{param}', (4)
model: ModelClass (5)
)
-
The
add_endpoint
method is used to register an endpoint for a model. -
The
id
is a unique identifier for the endpoint, which is required to fetch the resource later. -
The
type
specifies the type of endpoint, which can beindex
orresource
. Theindex
type is used for collections, while theresource
type is used for individual resources. -
The
url
is the URL of the endpoint, which can include path parameters. The URL can also include interpolation parameters, which will be replaced with the actual values when fetching the resource. -
The
model
is the class of the resource that will be fetched from the API. The class must inherit fromLutaml::Hal::Resource
.
register.add_endpoint(
id: :product_index,
type: :index,
url: '/products',
model: Product
)
register.add_endpoint(
id: :product_resource,
type: :resource,
url: '/products/{id}',
model: Product
)
HAL index APIs often support pagination, which allows clients to retrieve a limited number of resources at a time.
The Lutaml::Hal::Page
class is used to handle pagination in HAL APIs. It is a
subclass of Resource
, and provides additional attributes and methods for
handling pagination information
The Page
class by default supports the following attributes:
page
-
The current page number.
pages
-
The total number of pages.
limit
-
The number of resources per page.
total
-
The total number of resources.
The way to use the Page
class is through inheritance from it, where the
class will automatically create the necessary links for typical page objects.
The typical links of a page object are:
self
-
A link to the current page.
prev
-
A link to the previous page.
next
-
A link to the next page.
first
-
A link to the first page.
last
-
A link to the last page.
The "realize class" of these links are the same as the inherited page object, ensuring consistency in the pagination model.
Syntax:
class ProductIndex < Lutaml::Hal::Page
# No attributes necessary
end
register.add_endpoint(
id: :product_index,
type: :index,
url: '/products',
model: ProductIndex
)
page_1 = register.fetch(:product_index) # Updated to use the correct endpoint id
page_2_link = page_1.links.next
# => <#ProductIndexLink href: "/products/2", title: "Next Page">
Where,
ProductIndex
-
The class of the page that will be fetched from the API. The class must inherit from
Lutaml::Hal::Page
. register
-
The instance of
ModelRegister
. id
-
The ID of the pagination endpoint to be registered in the
ModelRegister
. url
-
The URL of the pagination endpoint.
model
-
The class of the page that will be fetched from the API.
Note
|
The lutaml-hal library currently only supports synchronous data fetching.
Asynchronous data fetching will be supported in the future.
|
Note
|
The lutaml-hal library currently only supports data fetching requests
(GET) today. Additional features may be provided in the future.
|
Once the data definition is complete, you can use the ModelRegister
to
fetch and interact with resources from the API.
The ModelRegister
allows you to fetch resources from the API using the fetch
method.
Note
|
The endpoint of the resource must be already defined through the
add_endpoint method.
|
The fetch
method will automatically handle the URL resolution and fetch the
resource from the API.
Syntax:
register.fetch(:resource_endpoint_id, {parameters})
Where,
resource_endpoint_id
-
The ID of the endpoint registered in the
ModelRegister
. parameters
-
A hash of parameters to be passed to the API. The parameters are used to replace the interpolation parameters in the URL.
register
-
The instance of
ModelRegister
.
product_1 = register.fetch(:product_resource, id: 1)
# => client.get('/products/1')
# => {
# "id": 1,
# "name": "Product 1",
# "price": 10.0,
# "_links": {
# "self": { "href": "/products/1" },
# "category": { "href": "/categories/1", "title": "Category 1" },
# "related": [
# { "href": "/products/3", "title": "Product 3" },
# { "href": "/products/5", "title": "Product 5" }
# ]
# }
# }
product_1
# => #<Product id: 1, name: "Product 1", price: 10.0, links:
# #<ProductLinks self: <ProductLink href: "/products/1">,
# category: <ProductLink href: "/categories/1", title: "Category 1">,
# related: [
# <ProductLink href: "/products/3", title: "Product 3">,
# <ProductLink href: "/products/5", title: "Product 5">
# ]}>
In HAL, collections are provided via the _links
or the _embedded
sections of
the response.
Note
|
The _embedded section is not yet supported by the Lutaml::Hal library.
|
The ModelRegister
allows you to define endpoints for collections and fetch
them using the fetch
method.
The fetch
method will automatically handle the URL resolution and fetch the
resource index from the API.
Syntax:
register.fetch(:index_endpoint_id)
Where,
index_endpoint_id
-
The ID of the endpoint registered in the
ModelRegister
. register
-
The instance of
ModelRegister
.
product_index = register.fetch(:product_index)
# => client.get('/products')
# => {
# "page": 1,
# "pages": 10,
# "limit": 10,
# "total": 45,
# "_links": {
# "self": { "href": "/products/1" },
# "next": { "href": "/products/2" },
# "last": { "href": "/products/5" },
# "products": [
# { "href": "/products/1", "title": "Product 1" },
# { "href": "/products/2", "title": "Product 2" }
# ]
# }
product_index
# => #<ProductPage page: 1, pages: 10, limit: 10, total: 45,
# links: #<ProductLinks self: <ProductLink href: "/products/1">,
# next: <ProductLink href: "/products/2">,
# last: <ProductLink href: "/products/5">,
# products: <ProductLinks
# <ProductLink href: "/products/1", title: "Product 1">,
# <ProductLink href: "/products/2", title: "Product 2">
# ]>>
Given a resource index that contains links to resources, the individual resource
links can be "realized" as actual model instances through the
Link#realize(register:)
method which dynamically retrieves the resource.
Given a Link
object, the realize
method fetches the resource from the API
using the provided register
.
There are two ways a resource gets realized from a Link
object:
-
If a
Lutaml::Hal::GlobalRegister
is used, and theLink
object originated from a fetch using aModelRegister
then therealize
method has sufficient information to automatically fetch the resource from the API using the sameregister
.NoteThis relies on the Hal::REGISTER_ID_ATTR_NAME
attribute to be set in theModelRegister
class. This attribute is used to identify the resource endpoint ID in the URL. -
If a
GlobalRegister
is not used, even if the Link object originated from a fetch using aModelRegister
, therealize
method does not have sufficient information to fetch the resource from the API using the sameregister
. In this case an explicitregister
must be provided to therealize(register: …)
method.
Syntax for standalone usage:
Lutaml::Model::Link.new(
href: 'resource_endpoint_href',
# ... other attributes
).realize(register)
Where,
resource_endpoint_href
-
The href of the resource endpoint. This is the URL of the resource as it appears in the
_links
section of the HAL resource. register
-
The instance of
ModelRegister
.
The realize
method will automatically handle the URL resolution and fetch
the resource from the API, and return an instance of the resource class
defined in the ModelRegister
(through the endpoint definition of realize_class
).
Note
|
It is possible to use the realize method on a link object using another
ModelRegister instance. This is useful when you want to resolve a link
using a different API endpoint or a different set of resource models.
|
Syntax when using a GlobalRegister
:
resource_index = model_register.fetch(:resource_index)
resource_index.links.products.first.realize
# => client.get('/resources/1')
# Without a GlobalRegister
product_2 = product_index.links.products.last.realize(register)
# With a GlobalRegister
product_2 = product_index.links.products.last.realize
# => client.get('/products/2')
# => {
# "id": 2,
# "name": "Product 2",
# "price": 20.0,
# "_links": {
# "self": { "href": "/products/2" },
# "category": { "href": "/categories/2", "title": "Category 2" },
# "related": [
# { "href": "/products/4", "title": "Product 4" },
# { "href": "/products/6", "title": "Product 6" }
# ]
# }
# }
product_2
# => #<Product id: 2, name: "Product 2", price: 20.0, links:
# #<ProductLinks self: <ProductLink href: "/products/2">,
# category: <ProductLink href: "/categories/2", title: "Category 2">,
# related: [
# <ProductLink href: "/products/4", title: "Product 4">,
# <ProductLink href: "/products/6", title: "Product 6">
# ]}>
# Without a GlobalRegister
product_2_related_1 = product_2.links.related.first.realize(register)
# With a GlobalRegister
product_2_related_1 = product_2.links.related.first.realize
The Lutaml::Hal::Page
class is used to handle pagination in HAL APIs.
As described in Defining HAL page models, subclassing the Page
class
provides pagination capabilities, including the management of links to navigate
through pages of resources.
Declaration:
class ResourceIndex < Lutaml::Hal::Page
# No attribute definition necessary
end
register.add_endpoint(
id: :resource_index,
type: :index,
url: '/resources',
model: ResourceIndex
)
Usage:
page_1 = register.fetch(:resource_index)
# => client.get('/resources')
# => {
# "page": 1,
# "pages": 10,
# "limit": 10,
# "total": 100,
# "_links": {
# "self": {
# "href": "https://api.example.com/resources?page=1&items=10"
# },
# "first": {
# "href": "https://api.example.com/resources?page=1&items=10"
# },
# "last": {
# "href": "https://api.example.com/resources?page=10&items=10"
# },
# "next": {
# "href": "https://api.example.com/resources?page=2&items=10"
# }
# }
# }
page_1
# => #<ResourceIndex page: 1, pages: 10, limit: 10, total: 100,
# links: #<ResourceIndexLinks
# self: #<ResourceIndexLink href: "/resources?page=1&items=10">,
# next: #<ResourceIndexLink href: "/resources?page=2&items=10">,
# last: #<ResourceIndexLink href: "/resources?page=10&items=10">>>
# Without a GlobalRegister
page_2 = page.links.next.realize(register)
# With a GlobalRegister
page_2 = page.links.next.realize
# => client.get('/resources?page=2&items=10')
# => #<ResourceIndex page: 2, pages: 10, limit: 10, total: 100,
# links: #<ResourceIndexLinks
# self: #<ResourceIndexLink href: "/resources?page=2&items=10">,
# prev: #<ResourceIndexLink href: "/resources?page=1&items=10">,
# next: #<ResourceIndexLink href: "/resources?page=3&items=10">,
# first: #<ResourceIndexLink href: "/resources?page=1&items=10">,
# last: #<ResourceIndexLink href: "/resources?page=10&items=10">>>,
# prev: #<ResourceIndexLink href: "/resources?page=1&items=10">>>
This project is licensed under the BSD 2-clause License. See the LICENSE.md file for details.
Copyright Ribose.