An easy to use HAL API client for elixir.
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{...}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"}}]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 responseiex>ExHal.put(doc,"self",name_change){:ok,%ExHal.Document{...},%ExHal.ResponseHeader{...}}# make a request that just returns a response without a bodyiex>{: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"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,fnfollow_results ->
...> casefollow_resultsdo...>{: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"]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!
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.
defmodulePersonTranscoderdouseExHal.Transcoderdefproperty"name"defproperty"mailingAddress",param: :addressdeflink"app:department",param: :department_urldeflink"app:manager",param: :manager_id,value_converter: PersonUrlConverterdeflinks"tags"endPersonUrlConverter is a module that has adopted the ExHal.ValueConverter behavior.
defmodulePersonUrlConverterdo@behaviourExHal.ValueConverterdeffrom_hal(person_url)doto_string(person_url)|>String.split("/")|>List.lastenddefto_hal(person_id)do"http://example.com/people/#{person_id}"endendWe 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.
ExHal.Transcoder also supports modifying objects with JSON patch.
defmodulePetTranscoderdouseExHal.Transcoderdefproperty"name"defproperty"animalType",param: species,protected: truedeflink"favoriteToy",param: :favorite_toydeflinks"friends"endCreate 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.
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)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
PUTandPOSTforms - only JSON content types
- only native JSON field types
Several assertion and helper functions are available to support testing. These functions accept a ExHal.Document or a string.
iex>importExUnit.Assertionsniliex>importExHal.Assertionsniliex>assert_property~s({"name": "foo"}),"name"trueiex>assert_property~s({"name": "foo"}),"address"**(ExUnit.AssertionError) address is absent
iex>assert_property~s({"name": "foo"}),"name",eq"foo"trueiex>assert_property~s({"name": "foo"}),"name",matches~r/fo/trueiex>assert_property~s({"name": "foo"}),"name",eq"bar"**(ExUnit.AssertionError) expected property `name` toeq("bar")iex>assert_link_target~s({"_links": { "profile": {"href": "http://example.com" }}}),...>"profile"trueiex>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"trueiex>assert_link_target~s({"_links": { "profile": {"href": "http://example.com" }}}),...>"profile",matches~r/example.com/trueiex>assert_link_target~s({"_links": { "profile": {"href": "http://example.com" }}}),...>"profile",eq"http://bad.com"**(ExUnit.AssertionError)expected(atleastone) `item` linktoeq("http://bad.com")butfoundonlyhttp://example.comiex>assertcollection("{}")|>Enum.empty?trueiex>assert1==collection("{}")|>Enum.count**(ExUnit.AssertionError) Assertion with ==failedAdd the following to your project :deps list:
{:exhal,"~> 7.1"}- Upgrade elixir to ~> 1.6
- Upgrade httpoison to ~> 1.0
- Upgrade odgn_json_pointer to ~> 2.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.