{"id":17837052,"url":"https://github.com/sammyhenningsson/shaf_client","last_synced_at":"2025-04-02T13:21:36.548Z","repository":{"id":56895230,"uuid":"160938423","full_name":"sammyhenningsson/shaf_client","owner":"sammyhenningsson","description":"A HAL client with some customization for Shaf APIs","archived":false,"fork":false,"pushed_at":"2023-10-30T05:02:44.000Z","size":155,"stargazers_count":1,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"master","last_synced_at":"2025-02-08T04:27:08.301Z","etag":null,"topics":["hal","hypermedia-client","rest","rest-client","shaf"],"latest_commit_sha":null,"homepage":null,"language":"Ruby","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/sammyhenningsson.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null}},"created_at":"2018-12-08T12:44:37.000Z","updated_at":"2023-03-11T23:00:13.000Z","dependencies_parsed_at":"2022-08-21T01:20:46.661Z","dependency_job_id":"c4022673-1c7e-4c1c-994c-58ce26c1728f","html_url":"https://github.com/sammyhenningsson/shaf_client","commit_stats":null,"previous_names":[],"tags_count":0,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/sammyhenningsson%2Fshaf_client","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/sammyhenningsson%2Fshaf_client/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/sammyhenningsson%2Fshaf_client/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/sammyhenningsson%2Fshaf_client/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/sammyhenningsson","download_url":"https://codeload.github.com/sammyhenningsson/shaf_client/tar.gz/refs/heads/master","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":246819803,"owners_count":20839097,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["hal","hypermedia-client","rest","rest-client","shaf"],"created_at":"2024-10-27T20:45:17.034Z","updated_at":"2025-04-02T13:21:36.526Z","avatar_url":"https://github.com/sammyhenningsson.png","language":"Ruby","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Shaf Client\n[![Gem Version](https://badge.fury.io/rb/shaf_client.svg)](https://badge.fury.io/rb/shaf_client)\n[![Build Status](https://travis-ci.org/sammyhenningsson/shaf_client.svg?branch=master)](https://travis-ci.org/sammyhenningsson/shaf_client)  \nShafClient is a hypermedia client using the [HAL](http://stateless.co/hal_specification.html) mediatype. It supports some mediatype profiles and customizations used in APIs built with [Shaf](https://github.com/sammyhenningsson/shaf).\n\n## Installation\n```sh\ngem install shaf_client\n```\nOr put `gem 'shaf_client'` in your Gemfile and run `bundle install`\n\n\n## Usage\nCreate an instance of `ShafClient` with a uri to the API entry point. Then call `get_root` on the returned client to get back a `ShafClient::Resource` and start interacting with the API.\n```ruby\nclient = ShafClient.new('https://my.hal_api.com/')\nroot = client.get_root\n```\n\nInstances of `ShafClient::Resource` respond to the following methods:\n - `#attributes`                            - Returns a hash of all attributes\n - `#links`                                 - Returns a hash of all links\n - `#curies`                                - Returns a hash of all curies\n - `#embedded_resources`                    - Returns a hash of all embedded resources\n - `#attribute(key)`                        - Returns the value for the attribute with the given _key_\n - `#link(rel)`                             - Returns a `ShafClient::Link` for the given _rel_\n - `#curie(rel)`                            - Returns a `ShafClient::Curie` for the given _rel_\n - `#embedded(rel)`                         - Returns a `ShafClient::BaseResource` for the given _rel_\n - `#[](key)`                               - Alias for `attribute(key)`\n - `#actions`                               - Returns a list of all links relations\n - `#to_s`                                  - Returns a `String` representation\n - `#inspect`                               - Returns a detailed `String` representation\n - `#get(rel, **options)`                   - Performs a GET request to the href of the link with rel _rel_\n - `#put(rel, payload: nil, **options)`     - Performs a PUT request to the href of the link with rel _rel_\n - `#post(rel, payload: nil, **options)`    - Performs a POST request to the href of the link with rel _rel_\n - `#delete(rel, payload: nil, **options)`  - Performs a DELETE request to the href of the link with rel _rel_\n - `#patch(rel, payload: nil, **options)`   - Performs a PATCH request to the href of the link with rel _rel_\n - `#get_doc(rel)`                          - Retrieves the documentation for a _rel_ by looking up its curie\n - `#get_hal_form(rel)`                     - Retrieves a form by performing a GET request on the value of _rel_.\n - `#rel?(rel)`                             - Returns true if the resource has a link with rel _rel_\n - `#reload!`                               - Refresh itself by fetching the _self_ link (by-passing cache)\n - `#destroy!`                              - Performs a DELETE request to the href of the link with rel _delete_\n - `#http_status`                           - The response HTTP status returned by the server\n - `#headers`                               - The response HTTP headers returned by the server\n\nThey will also respond to each attribute key that they contain (i.e like `#[](key)` or `#attribute(key)`)  \n\nInstances of `ShafClient` respond to the following methods:\n - `#get_root(**options)`                   - Performs a GET request to the root_uri (first arg to #initialize)\n - `#head(uri, **options)`                  - Performs a HEAD request to the given uri\n - `#get(uri, **options)`                   - Performs a GET request to the given uri\n - `#put(uri, payload, **options)`          - Performs a PUT request to the given uri\n - `#post(uri, payload, **options)`         - Performs a POST request to the given uri\n - `#delete(uri, payload, **options)`       - Performs a DELETE request to the given uri\n - `#patch(uri, payload, **options)`        - Performs a PATCH request to the given uri\n\n## Examples\n```ruby\nrequire 'shaf_client'\nclient = ShafClient.new('http://localhost:3000')\nroot = client.get_root      # Equivalent to client.get('http://localhost:3000')\nroot.headers                # =\u003e {\"content-type\"=\u003e\"application/hal+json\", \"cache-control\"=\u003e\"private, max-age=20\"…\nroot = client.get_root      # Returns same response as above, except this time no network request is performed. A cached                                 # response is returned instead\nroot.actions                # =\u003e [:self, :posts, :comments]\n\nposts = root.get(:posts)\nposts.embedded_resources    # =\u003e {:posts=\u003e[#\u003cShafClient::Resource:0x00005615723cad10 @payload…\nposts.embedded(:posts)      # Returns an array of `ShafClient::Resource` instances\nposts.actions               # =\u003e [:self, :up, :\"doc:create-form\"]\nform = posts.get(\"doc:create-form\") # this assumes that Content-Type contains the profile 'shaf-form'.\n                                    # it's also possible to type: posts.get(\"create-form\") or posts.get(:create_form)\nform.headers                # =\u003e {\"content-type\"=\u003e\"application/hal+json;profile=shaf-form\", \"cache-control\"=\u003e\"private, max-age=3600\", \"etag\"=\u003e\"W/\\\"83ef6d28f4b81f8f9ceae17f5f8a42d6dedfff73\\\"…\"}\nform.class                  # =\u003e ShafClient::ShafForm\nform.values                 # =\u003e {:title=\u003enil, :message=\u003enil}\nform.valid?                 # =\u003e false\nform[:title] = \"hello\"\nform[:message] = \"world\"\ncreated_post = form.submit  # Returns a new `ShafClient::Resource`\n\ncreated_post.attributes     # =\u003e {:title=\u003e\"hello\", :message=\u003e\"world\"}\ncreated_post.title          # =\u003e \"hello\"\ncreated_post.actions        # =\u003e [:\"collection\", :self, :\"edit-form\", :\"doc:delete\"]\nputs created_post.to_s      # =\u003e {\n                            #      \"title\": \"hello\",\n                            #      \"message\": \"world\",\n                            #      \"_links\": {\n                            #        \"collection\": {\n                            #          \"href\": \"http://localhost:3000/posts\",\n                            #          \"title\": \"up\"\n                            #        },\n                            #        \"self\": {\n                            #          \"href\": \"http://localhost:3000/posts/1\"\n                            #        },\n                            #        \"edit-form\": {\n                            #          \"href\": \"http://localhost:3000/posts/1/edit\",\n                            #          \"title\": \"edit\"\n                            #        },\n                            #        \"doc:delete\": {\n                            #          \"href\": \"http://localhost:3000/posts/1\",\n                            #          \"title\": \"delete\"\n                            #        },\n                            #        \"curies\": [\n                            #          {\n                            #            \"name\": \"doc\",\n                            #            \"href\": \"http://localhost:3000/doc/post/rels/{rel}\",\n                            #            \"templated\": true\n                            #          }\n                            #        ]\n                            #      }\n                            #    }\n\ncreated_post.link(:self).href      # =\u003e \"http://localhost:3000/posts/1\"\n\ndelete_doc = created_post.get_doc(\"doc:delete\")\nputs delete_doc.actions     # =\u003e [:self, :up]\nputs delete_doc.attribute(:delete) # =\u003e Link to delete this post.\n                                   #    Method: DELETE\n                                   #    Example:\n                                   #    \n                                   #    curl -H \"Accept: application/hal+json\" \\\n                                   #         -H \"Authorization: abcdef \\\"\n                                   #         -X DELETE \\\n                                   #         /posts/5\n\n\n# Request headers can be given to #get, #put, etc throught the headers keyword argument\nproblem_json = client.get('http://localhost:3000/idonotexist', headers: {'Accept' =\u003e 'application/problem+json'})\nputs problem_json.content_type    # =\u003e application/problem+json\nputs problem_json\n                                  # =\u003e {\n                                  #      \"status\": 404,\n                                  #      \"type\": \"about:blank\",\n                                  #      \"title\": \"Not Found\",\n                                  #      \"detail\": \"Resource \\\"/idonotexist\\\" does not exist\"\n                                  #    }\n\n\n```\n\n## Adding semantic meaning to resources\nNote the form in the example above. `form` is an instance of `ShafClient::ShafForm` (which is a subclass of `ShafClient::Form` which in turn is a subclass of `ShafClient::Resource`).\nForms have a few extra methods that makes it easy to fill in values and submitting them. The reason that we received an instance of `ShafClient::ShafForm` rather than `ShafClient::Resource` is that the server responded with the Content-Type `application/hal+json;profile=shaf-form`. The [shaf-form](https://gist.github.com/sammyhenningsson/39c8aafeaf60192b082762cbf3e08d57) profile describes the semantic meaning of this representation and luckily ShafClient knowns about this profile.  \nAdding support for other profiles is as simple as creating a subclass of `ShafClient::Resource` and call the class method `profile` with the name of your profile. To be correct the profile should actually be a URI and it can be specified as a media type parameter (as above), using a Link header or using a link in the payload. (Note: to be able to parse the link from the payload, a resource class that matches the Content-Type header must be registered.)  \nSo say that you have a server that returns a response with Content-Type: `application/hal+json;profile=\"https://example.com/foobar\"`. Then you could do something like this:\n```ruby\nclass CustomResource \u003c ShafClient::Resource\n  profile 'https://example.com/foobar'\n\n  def attr_string\n    attributes.keys.join('_')\n  end\nend\n\nfoobar = client.get_root.get(:some_rel_returning_foobar)\nfoobar.class            # =\u003e CustomResource\nfoobar.attr_string      # =\u003e \"key1_key2_key3\"\n```\nNote: This only serves the purpose of understanding how this works :)  \nInstances of `ShafClient::Form` respond to the following methods:\n - `#values`                        - Returns a hash of the form inputs\n - `#[](key)`                       - Returns the value of a given input\n - `#[]=(key, value)`               - Sets the value of a given input\n - `#title`                         - Returns the title of the form\n - `#target`                        - Returns the target href (where the form will be submitted to)\n - `#http_method`                   - Returns the HTTP method to be used when submitting the form\n - `#content_type`                  - Returns the content type used when the form is submitted\n - `#submit`                        - Submit the form\n - `#valid?`                        - Returns `true` if client side validations pass. Otherwise `false`\n\n\n\nIf the profile URI is dereferencable and the returned payload is presented as `application/alps+json`, then ShafClient will parse the ALPS profile to understand more about the resource. Each link relation that is described with an `http_method` extension (look [here](https://gist.github.com/sammyhenningsson/2103d839eb79a7baf8854bfb96bda7ae) for more info) will get a method for activating the corresponding link relation. For example if a payload contains a link with rel `publish`, by default ShafClient wont know how to activate that link. Should it use GET, PUT, POST etc? But if we get a profile that happens to resolve to something like this:\n```json\n{\n  \"alps\": {\n    \"version\": \"1.0\",\n    \"descriptor\": [\n      {\n        \"id\": \"publish\",\n        \"type\": \"idempotent\",\n        \"doc\": {\n          \"value\": \"The link relation 'publish' means that the corresponding post resource\\nmay be requested to be published. To activate this link relation, perform\\nan HTTP PUT request to the href of this link relation.\\n\"\n        },\n        \"name\": \"publish\",\n        \"ext\": [\n          {\n            \"id\": \"http_method\",\n            \"href\": \"https://gist.github.com/sammyhenningsson/2103d839eb79a7baf8854bfb96bda7ae\",\n            \"value\": [\n              \"PUT\"\n            ]\n          }\n        ]\n      }\n    ]\n  }\n}\n```\n\nThis would generate a `publish!` method which will use HTTP PUT. Note: the bang version.\n\n## HAL-FORMS\nShafClient also support forms presented using the [HAL-FORMS](https://rwcbook.github.io/hal-forms/) mediatype.\nThe workflow using `HAL-FORMS` differs a bit from `shaf-forms` and requires the client to intentionally request a form. For this, the method `#get_hal_form(rel)` is used.\nThe returned object is an instance of `ShafClient::HalForm` (which is a subclass of `ShafClient::Form`). So submitting the form follows the same flow as shown above. \nThe `rel` given to `#get_hal_form(rel)` may be compacted with a curie. In that case it will be \"expanded\" before the GET request is performed.\n\n## Non HAL responses\nOf course, not all responses will be formatted as HAL. Whenever the response body is empty an instance of `ShafClient::EmptyResource` is returned.\nIf the Content-Type cannot be understood an instance of `ShafClient::UnknownResource` is returned.\nThese two classes also inherit from `ShafClient::Resource` so all the usual methods are still available (though most of them return `nil`, `\"\"`, `{}` or `[]`).\nInstances of `ShafClient::UnknownResource` also has a`#body` method. `#body`, `#http_status` and `#headers` are basically the only usefull methods for those instances.\nProblem Json responses will return instances of `ShafClient::ProblemJson` (see example above).\n\n## Authentication\nShafClient supports basic auth and token based authentication.  \nFor Basic Auth, pass keyword arguments `:user` and `password` when instantiating the client.\n```ruby\nclient = ShafClient.new('https://my.hal_api.com/', user: \"alice\", password: \"ecila\")\n```\nFor Token based authentication, pass keyword argument `:auth_token` when instantiating the client. This will send the token in the `X-Auth-Token` header. To use another header, set it with the keyword argument `:auth_header`.\n```ruby\nclient = ShafClient.new('https://my.hal_api.com/', auth_token: \"Ohd2quet\")\n# or\nclient = ShafClient.new('https://my.hal_api.com/', auth_token: \"Ohd2quet\", auth_header: \"Authorization\")\n```\n\n## Faraday\nShafClient wraps the [faraday](https://github.com/lostisland/faraday) gem. By default it uses the `Net::HTTP` adapter. To use another adapter pass in the corresponding symbol in the `:faraday_adapter` when instantiating the client. (Note: make sure to install and require corresponding dependencies.)\n```ruby\nclient = ShafClient.new('https://my.hal_api.com/', faraday_adapter: :net_http_persistent)\n```\n\n## HTTP cache\nShafClient supports HTTP caching by using the [faraday-http-cache](https://github.com/plataformatec/faraday-http-cache), Faraday middleware.\nThis means that if the server returns responses with caching directives (e.g. `Cache-Control`, `Etag` etc), those responses are properly cached. And no unnecessary request will be made when a valid cache entry exist.\nTo pass down options to faraday-http-cache (e.g a cache store) pass them to ShafClient as options under the `:faraday_http_cache` key.\n```ruby\nstore = ActiveSupport::Cache.lookup_store(:mem_cache_store, ['localhost:11211'])\nclient = ShafClient.new('https://my.hal_api.com/', faraday_http_cache: {store: store})\n```\n\n## Hypertext Cache Pattern\nServers may preload resources (by embedded them) in hope of increasing the api performance. See [Hypertext Cache Pattern](https://tools.ietf.org/html/draft-kelly-json-hal-08#section-8.3) for more info.\nAn application using ShafClient, might have a \"hard coded\" workflow where it always fetches posts and then their authors. E.g.\n```ruby\npost = client.get(some_post_uri)\nauthor = post.get(:author)\n```\nNormally this would result in two requests to the server. However if the API server decides that the client probably also want the corresponding author resource (which normally is just linked to but not embedded), it may embedded the _author_ resource in the _post_ response. In this case ShafClient will only make one request and simply return the embedded _author_ on the second line above.\nThis adds great flexibility for servers to dynamically change the responds to increase performance.  \nHowever this may cause problems if the embedded resource should not be interpreted as application/hal+json (i.e. plain HAL without any extensions).\nShafClient has three settings for how to handle this:\n 1. Ignore embedded resources and refetch them through the link relation.\n 2. Return the embedded resource (as regular HAL resource).\n 3. Perform a HEAD request and return the resource with correct headers.\n\nThis can be configured globally by calling `ShafClient.default_hypertext_cache_strategy=(strategy)`. Where strategy is one of (Note: same order as above):\n 1. `:no_cache`\n 2. `:use_embedded`\n 3. `:fetch_headers`\n\nIt can also be configured per request, by using the `hypertext_cache_strategy` option. E.g.\n```ruby\npost.get(:author, hypertext_cache_strategy: :fetch_headers)\n```\n\n## Example API\nIf you would like to try out ShafClient but don't yet have a HAL API, then an example api, created with [Shaf](https://github.com/sammyhenningsson/shaf), can be found [here](https://shaf-blog-demo.onrender.com/).\n```ruby\nclient = ShafClient.new(\"https://shaf-blog-demo.onrender.com/\")\n…\n```\n\n## Redirects\nShafClient will automatically follow redirects.  \n\n## Contributing\nIf you find a bug or have suggestions for improvements, please create a new issue on Github. Pull request are welcome!\nAs usual: Fork, commit changes to a new branch, open a pull request!  \n\n## License\nThe gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fsammyhenningsson%2Fshaf_client","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fsammyhenningsson%2Fshaf_client","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fsammyhenningsson%2Fshaf_client/lists"}