/exhal

Use HAL APIs with ease

Primary LanguageElixirMIT LicenseMIT

ExHal

Build Status Hex.pm

An easy to use HAL API client for elixir.

Usage

Consider a resource http://example.com/hal whose HAL representation looks like

{ "name": "Hello!",
  "_links": {
     "self"   : { "href": "http://example.com" },
      "profile": [{ "href": "http://example.com/special" },
                  { "href": "http://example.com/normal" }]
  }
}
iex> {:ok, doc, response_header} = ExHal.client
...> |> ExHal.Client.add_headers("User-Agent": "MyClient/1.0")
...> |> ExHal.Client.get("http://example.com/hal")
%ExHal.Document{...}

Navigation

Now we have an entry point to the API. From there we can follow links to navigate around.

iex> ExHal.follow_link(doc, "profile")
{:ok, %ExHal.Document{...}, %ExHal.ResponseHeader{...}}

iex> ExHal.follow_link(doc, "self")
{:ok, %ExHal.Document{...}, %ExHal.ResponseHeader{...}}

iex> ExHal.follow_links(doc, "profile")
[{:ok, %ExHal.Document{...}, %ExHal.ResponseHeader{...}}, {:ok, %ExHal.Document{...}, %ExHal.ResponseHeader{...}}]

We can specify headers for each request in addition to the headers specified in the client.

iex> ExHal.follow_links(doc, "profile",
                        headers: ["Accept": "application/vnd.custom.json+type"])
[{:ok, %ExHal.Document{...}, %ExHal.ResponseHeader{...}}, {:ok, %ExHal.Document{...}, %ExHal.ResponseHeader{...}}]

If we try to follow a non-existent or compound link with ExHal.follow_link it will return an error tuple.

iex> ExHal.follow_link(doc, "nonexistent")
{:error, %ExHal.Error{reason: "no such link"}}

iex> ExHal.follow_link(doc, "profile", strict: true)
{:error, %ExHal.Error{reason: "multiple choices"}}

If we try to follow a non-existent with ExHal.follow_links it will return a list of error tuples.

iex> ExHal.follow_links(doc, "nonexistent")
[{:error, %ExHal.Error{reason: "no such link"}}]

Actions

If you want to take an action (ie, make a PUT, POST, etc) request you can do that too.

name_change = """
  { "name": "Bye!",
    "_links": {
       "self"   : { "href": "http://example.com" },
       "profile": [{ "href": "http://example.com/special" },
       { "href": "http://example.com/normal" }]
    }
  }
  """

# make a request that returns a HAL response
iex> ExHal.put(doc, "self", name_change)
{:ok, %ExHal.Document{...}, %ExHal.ResponseHeader{...}}

# make a request that just returns a response without a body
iex> {:ok, resp} = ExHal.post(doc, "add-child", "{\"name\": \"child\"}")
{:ok, %ExHal.NonHalResponse{status_code: 201, headers: [{"Location", "http://example.com/child"}, ...], body: ""}}
iex> ExHal.url(resp)
"http://example.com/child"

Collections

Consider a resource http://example.com/hal-collection whose HAL representation looks like

{ "_links": {
     "self"   : { "href": "http://example.com/hal-collection" },
      "item": [{ "href": "http://example.com/beginning" },
               { "href": "http://example.com/middle" }],
      "next": { "href": "http://example.com/hal-collection?p=2" }
  }
}

and a resource http://example.com/hal-collection?p=2 whose HAL representation looks like

{ "_links": {
     "self"   : { "href": "http://example.com/hal-collection?p=2" },
      "item": [{ "href": "http://example.com/end" }]
  }
}

If we get the first HAL collection resource and turn it into a stream we can use all our favorite Stream functions on it.

iex> collection = ExHal.client
...> |> ExHal.Client.add_headers("User-Agent": "MyClient/1.0")
...> |> ExHal.Client.get("http://example.com/hal-collection")
...> |> ExHal.to_stream
#Function<11.52512309/2 in Stream.resource/3>

iex> Stream.map(collection, fn follow_results ->
...>   case follow_results do
...>     {:ok, a_doc, %ResponseHeader{}} -> ExHal.url(a_doc)
...>     {:ok, a_doc} -> ExHal.url(a_doc)
...>     {:error, _}  -> :error
...>   end
...> end )
["http://example.com/beginning", "http://example.com/middle", "http://example.com/end"]

Serialization

Collections and Document can render themselves to a json-like structure that can then be serialized using your favorite json encoder (e.g. Poison):

ExHal.Collection.render!

or

ExHal.Document.render!

Transcoding

ExHal also supports interpreting HAL documents. The following is a HAL document interpreter

Given a document like

{
  "name": "Jane Doe",
  "mailingAddress": "123 Main St",
  "_links": {
    "app:department": { "href": "http://example.com/dept/42" },
    "app:manager":    { "href": "http://example.com/people/84" },
    "tag": [
      {"href": "foo:1"},
      {"href": "http://2"},
      {"href": "urn:1"}
    ]
  }
}

We can define an transcoder for it.

defmodule PersonTranscoder do
  use ExHal.Transcoder

  defproperty "name"
  defproperty "mailingAddress", param: :address
  deflink     "app:department", param: :department_url
  deflink     "app:manager",    param: :manager_id, value_converter: PersonUrlConverter
  deflinks    "tags"
end

PersonUrlConverter is a module that has adopted the ExHal.ValueConverter behavior.

defmodule PersonUrlConverter do
  @behaviour ExHal.ValueConverter

  def from_hal(person_url) do
    to_string(person_url)
    |> String.split("/")
    |> List.last
  end

  def to_hal(person_id) do
    "http://example.com/people/#{person_id}"
  end
end

We can use this transcoder to to extract the pertinent parts of the document into a map.

iex> PersonTranscoder.decode!(doc)
%{name: "Jane Doe",
  address: "123 Main St",
  department_url: "http://example.com/dept/42",
  manager_id: 84}
iex> PersonTranscoder.encode!(%{name: "Jane Doe",
  address: "123 Main St",
  department_url: "http://example.com/dept/42",
  manager_id: 84})
~s(
{
  "name": "Jane Doe",
  "mailingAddress": "123 Main St",
  "_links": {
    "app:department": { "href": "http://example.com/dept/42" },
    "app:manager":    { "href": "http://example.com/people/84" }
   }
} )

This can be used to, for example, build Ecto changesets via a changeset/2 functions and to render HAL responses to HTTP requests.

Patching

ExHal.Transcoder also supports modifying objects with JSON patch.

defmodule PetTranscoder do
  use ExHal.Transcoder

  defproperty "name"
  defproperty "animalType",   param: species, protected: true
  deflink     "favoriteToy",  param: :favorite_toy
  deflinks    "friends"
end

Create an object from the transcoder, then modify it

iex> spot = PetTranscoder.decode!(doc)
%{name: "Spot",
  species: "dog",
  favorite_toy: "http://a.co/56guxwO"
  friends: ["https://petbook.com/u/fifi", "https://petbook.com/u/fido"]}

iex> json_patches = [
  %{"op" => "replace", "path" => "/name",       "value" => "Bowser"},
  %{"op" => "replace", "path" => "/animalType", "value" => "dragon"},
  %{"op" => "replace", "path" => "/_links/favoriteToy", "value" => %{"href" => "http://a.co/9cs2VQd"}},
  %{"op" => "add",     "path" => "/_links/friends/-",   "value" => %{"href" => "https://doggo.biz/12345"}}]
...

iex> spot |> PetTranscoder.patch!(json_patches)
%{name: "Bowser",
  species: "dog",
  favorite_toy: "http://a.co/9cs2VQd",
  friends: ["https://petbook.com/u/fifi", "https://petbook.com/u/fido", "https://doggo.biz/12345"]}

"replace" operations are supported for properties and links. "add" (append) is supported for link collections (deflinks) with /propertyName/- path syntax. Any properties or links marked with protected: true cannot be changed via patch!. Patch operations against properties or links not defined in the transcoder are ignored.

Composing

Transcoders are also chainable. For example, given a ManagerTranscoder the following would produce a Map that includes all person params and all the manager params: PersonTranscoder.decode!(doc) |> ManagerTranscoder.decode!(doc). Similarly PersonTranscoder.encode!(model) |> ManagerTranscoder.encode!(module) would produce an ExHal.Document that has all the properties and links defined in those transcoders.

Similarly, patch! operations can be chained:

iex> employee = PersonTranscoder.decode!(doc) |> ManagerTranscoder.decode!(doc)
...
iex> json_patches = [%{"op" => "replace", "path" => "/mailingAddress", "value" => "..."}, ...]
...
iex> employee |> PersonTranscoder.patch!(json_patches) |> ManagerTranscoder.patch!(json_patches)

Forms

ExHal supports Forms (Dwolla style)* to allow further decoupling of server and client implementations.

Given a document

{ "_links": {
    "item": [],
    "self": { "href": "http://example.com/comments" }
  },
  "_forms": {
    "create-form": {
      "_links": { "target": { "href": "http://example.com/comments" } },
      "method": "POST",
      "contentType": "application/hal+json",
      "fields": [
        { "name": "comment",
          "path": "/comment",
          "type": "string",
          "displayText": "Comment",
          "validations": {
            "required": true
          }
        }
      ]
    }
  }
}

Creating a new item in the collection is simple:

iex> ExHal.get_form(doc, "create-form")
...> |> ExHal.Form.set_field("comment", "Very good!")
...> |> ExHal.Form.submit(ExHal.client)
{:ok, %ExHal.Document{...}, %ExHal.ResponseHeader{...}}

The list of fields is also available. This makes it possible to dynamically build user interfaces.

iex> ExHal.get_form(doc, "create-form")
...> |> ExHal.Form.get_fields()
...> |> Enum.map(fn (field) -> {field.display_name, field.type} end)
[{"Comment", :string}]

* At this time there are major limitations to the forms support. Namely

  • only PUT and POST forms
  • only JSON content types
  • only native JSON field types

Assertions about HAL documents

Several assertion and helper functions are available to support testing. These functions accept a ExHal.Document or a string.

iex> import ExUnit.Assertions
nil
iex> import ExHal.Assertions
nil
iex> assert_property ~s({"name": "foo"}), "name"
true
iex> assert_property ~s({"name": "foo"}), "address"
** (ExUnit.AssertionError) address is absent
iex> assert_property ~s({"name": "foo"}), "name", eq "foo"
true
iex> assert_property ~s({"name": "foo"}), "name", matches ~r/fo/
true
iex> assert_property ~s({"name": "foo"}), "name", eq "bar"
** (ExUnit.AssertionError) expected property `name` to eq("bar")
iex> assert_link_target ~s({"_links": { "profile": {"href": "http://example.com" }}}),
...>   "profile"
true
iex> assert_link_target ~s({"_links": { "profile": {"href": "http://example.com" }}}),
...>   "item"
** (ExUnit.AssertionError) link `item` is absent
iex> assert_link_target ~s({"_links": { "profile": {"href": "http://example.com" }}}),
...>   "profile", eq "http://example.com"
true
iex> assert_link_target ~s({"_links": { "profile": {"href": "http://example.com" }}}),
...>   "profile", matches ~r/example.com/
true
iex> assert_link_target ~s({"_links": { "profile": {"href": "http://example.com" }}}),
...>   "profile", eq "http://bad.com"
** (ExUnit.AssertionError) expected (at least one) `item` link to eq("http://bad.com") but found only http://example.com
iex> assert collection("{}") |> Enum.empty?
true
iex> assert 1 == collection("{}") |> Enum.count
** (ExUnit.AssertionError) Assertion with == failed

Installation

Add the following to your project :deps list:

{:exhal, "~> 7.1"}

Upgrading

from 7.x to 8.x

  • Upgrade elixir to ~> 1.6
  • Upgrade httpoison to ~> 1.0
  • Upgrade odgn_json_pointer to ~> 2.0

From 6.0 to 7.0

  • All HTTP requesting functions return a three-tuple in 7.0 (rather the two-tuple in versions before that). Code that invokes those will need to handle the change in cardinatlity.