Skip to content

Repository files navigation

JsonApiClient Build StatusCode ClimateCode Coverage

This gem is meant to help you build an API client for interacting with REST APIs as laid out by http://jsonapi.org. It attempts to give you a query building framework that is easy to understand (it is similar to ActiveRecord scopes).

Note: master is currently tracking the 1.0.0 specification. If you're looking for the older code, see 0.x branch

Usage

You will want to create your own resource classes that inherit from JsonApiClient::Resource similar to how you would create an ActiveRecord class. You may also want to create your own abstract base class to share common behavior. Additionally, you will probably want to namespace your models. Namespacing your model will not affect the url routing to that resource.

moduleMyApi# this is an "abstract" base class thatclassBase < JsonApiClient::Resource# set the api base url in an abstract base classself.site="http://example.com/"endclassArticle < BaseendclassComment < BaseendclassPerson < Baseendend

By convention, we guess the resource route from the class name. In the above example, Article's path is "http://example.com/articles" and Person's path would be "http://example.com/people".

Some basic example usage:

MyApi::Article.allMyApi::Article.where(author_id: 1).find(2)MyApi::Article.where(author_id: 1).allMyApi::Person.where(name: "foo").order(created_at: :desc).includes(:preferences,:cars).allu=MyApi::Person.new(first_name: "bar",last_name: "foo")u.new_record?# => trueu.saveu.new_record?# => falseu=MyApi::Person.find(1).firstu.update_attributes(a: "b",c: "d")u.persisted?# => trueu.destroyu.destroyed?# => trueu.persisted?# => falseu=MyApi::Person.create(a: "b",c: "d")

All class level finders/creators should return a JsonApiClient::ResultSet which behaves like an Array and contains extra data about the api response.

Handling Validation Errors

See specification

Out of the box, json_api_client handles server side validation only.

User.create(name: "Bob",email_address: "invalid email")# => falseuser=User.new(name: "Bob",email_address: "invalid email")user.save# => false# returns an error collector which is array-likeuser.errors# => ["Email address is invalid"]# get all error titlesuser.errors.full_messages# => ["Email address is invalid"]# get errors for a specific parameteruser.errors[:email_address]# => ["Email address is invalid"]user=User.find(1)user.update_attributes(email_address: "invalid email")# => falseuser.errors# => ["Email address is invalid"]user.email_address# => "invalid email"

For now we are assuming that error sources are all parameters.

If you want to add client side validation, I suggest creating a form model class that uses ActiveModel's validations.

Meta information

See specification

If the response has a top level meta data section, we can access it via the meta accessor on ResultSet.

# Example response:{"meta": {"copyright": "Copyright 2015 Example Corp.","authors": ["Yehuda Katz","Steve Klabnik","Dan Gebhardt"]},"data": {// ...
}}articles=Articles.allarticles.meta.copyright# => "Copyright 2015 Example Corp."articles.meta.authors# => ["Yehuda Katz", "Steve Klabnik", "Dan Gebhardt"]

Top-level Links

See specification

If the resource returns top level links, we can access them via the links accessor on ResultSet.

articles=Articles.find(1)articles.links.related

Nested Resources

You can force nested resource paths for your models by using a belongs_to association.

Note: Using belongs_to is only necessary for setting a nested path unless you provide shallow_path: true option.

moduleMyApiclassAccount < JsonApiClient::Resourcebelongs_to:userendclassCustomer < JsonApiClient::Resourcebelongs_to:user,shallow_path: trueendend# try to find without the nested parameterMyApi::Account.find(1)# => raises ArgumentError# makes request to /users/2/accounts/1MyApi::Account.where(user_id: 2).find(1)# => returns ResultSet# makes request to /customers/1MyApi::Customer.find(1)# => returns ResultSet# makes request to /users/2/customers/1MyApi::Customer.where(user_id: 2).find(1)# => returns ResultSet

you can also override param name for belongs_to association

moduleMyApiclassAccount < JsonApiClient::Resourcebelongs_to:user,param: :customer_idendend# makes request to /users/2/accounts/1MyApi::Account.where(customer_id: 2).find(1)# => returns ResultSet

Custom Methods

You can create custom methods on both collections (class method) and members (instance methods).

moduleMyApiclassUser < JsonApiClient::Resource# GET /users/searchcustom_endpoint:search,on: :collection,request_method: :get# PUT /users/:id/verifycustom_endpoint:verify,on: :member,request_method: :putendend# makes GET request to /users/search?name=JeffMyApi::User.search(name: 'Jeff')# => <ResultSet of MyApi::User instances>user=MyApi::User.find(1)# makes PUT request to /users/1/verify?foo=baruser.verify(foo: 'bar')

Fetching Includes

See specification

If the response returns a compound document, then we should be able to get the related resources.

# makes request to /articles/1?include=author,comments.authorresults=Article.includes(:author,:comments=>:author).find(1)# should not have to make additional requests to the serverauthors=results.map(&:author)# makes POST request to /articles?include=author,comments.authorarticle=Article.new(title: 'New one').request_includes(:author,:comments=>:author)article.save# makes PATCH request to /articles/1?include=author,comments.authorarticle=Article.find(1)article.title='Changed'article.request_includes(:author,:comments=>:author)article.save# request includes will be cleared if response is successful# to avoid this `keep_request_params` class attribute can be usedArticle.keep_request_params=true# to clear request_includes usearticle.reset_request_includes!

Sparse Fieldsets

See specification

# makes request to /articles?fields[articles]=title,bodyarticle=Article.select("title","body").first# should have fetched the requested fieldsarticle.title# => "Rails is Omakase"# should not have returned the created_atarticle.created_at# => raise NoMethodError# or you can use fieldsets from multiple resources# makes request to /articles?fields[articles]=title,body&fields[comments]=tagarticle=Article.select("title","body",{comments: 'tag'}).first# makes POST request to /articles?fields[articles]=title,body&fields[comments]=tagarticle=Article.new(title: 'New one').request_select(:title,:body,comments: 'tag')article.save# makes PATCH request to /articles/1?fields[articles]=title,body&fields[comments]=tagarticle=Article.find(1)article.title='Changed'article.request_select(:title,:body,comments: 'tag')article.save# request fields will be cleared if response is successful# to avoid this `keep_request_params` class attribute can be usedArticle.keep_request_params=true# to clear request fields usearticle.reset_request_select!(:comments)# to clear for commentsarticle.reset_request_select!# to clear for all fields

Sorting

See specification

# makes request to /people?sort=ageyoungest=Person.order(:age).all# also makes request to /people?sort=ageyoungest=Person.order(age: :asc).all# makes request to /people?sort=-ageoldest=Person.order(age: :desc).all

Paginating

See specification

Requesting

# makes request to /articles?page=2&per_page=30articles=Article.page(2).per(30).to_a# also makes request to /articles?page=2&per_page=30articles=Article.paginate(page: 2,per_page: 30).to_a# keep in mind that page number can be nil - in that case default number will be applied# also makes request to /articles?page=1&per_page=30articles=Article.paginate(page: nil,per_page: 30).to_a

Note: The mapping of pagination parameters is done by the query_builder which is customizable.

Browsing

If the response contains additional pagination links, you can also get at those:

articles=Article.paginate(page: 2,per_page: 30).to_aarticles.pages.nextarticles.pages.last

Library compatibility

A JsonApiClient::ResultSet object should be paginatable with both kaminari and will_paginate.

Filtering

See specifiation

# makes request to /people?filter[name]=JeffPerson.where(name: 'Jeff').all

Schema

You can define schema within your client model. You can define basic types and set default values if you wish. If you declare a basic type, we will try to cast any input to be that type.

The added benefit of declaring your schema is that you can access fields before data is set (otherwise, you'll get a NoMethodError).

Note: This is completely optional. This will set default values and handle typecasting.

Example

classUser < JsonApiClient::Resourceproperty:name,type: :stringproperty:is_admin,type: :boolean,default: falseproperty:points_accrued,type: :int,default: 0property:averge_points_per_day,type: :floatend# default valuesu=User.newu.name# => nilu.is_admin# => falseu.points_accrued# => 0# castingu.average_points_per_day="0.3"u.average_points_per_day# => 0.3

Types

The basic types that we allow are:

  • :int or :integer
  • :float
  • :string
  • :time - *Note: Include the time zone in the string if it's different than local time.
  • :boolean - Note: we will cast the string version of "true" and "false" to their respective values

Also, we consider nil to be an acceptable value and will not cast the value.

Note : Do not map the primary key as int.

Customizing

Paths

You can customize this path by changing your resource's table_name:

moduleMyApiclassSomeResource < Basedefself.table_name"foobar"endendend# requests http://example.com/foobarMyApi::SomeResource.all

Custom headers

You can inject custom headers on resource request by wrapping your code into block:

MyApi::SomeResource.with_headers(x_access_token: 'secure_token_here')doMyApi::SomeResource.find(1)end

Connections

You can configure your API client to use a custom connection that implementes the run instance method. It should return data that your parser can handle. The default connection class wraps Faraday and lets you add middleware.

classNullConnectiondefinitialize(*args)enddefrun(request_method,path,params: nil,headers: {},body: nil)enddefuse(*args);endendclassCustomConnectionResource < TestResourceself.connection_class=NullConnectionend

Connection Options

You can configure your connection using Faraday middleware. In general, you'll want to do this in a base model that all your resources inherit from:

MyApi::Base.connectiondo |connection|
# set OAuth2 headersconnection.useFaradayMiddleware::OAuth2,'MYTOKEN'# log responsesconnection.useFaraday::Response::Loggerconnection.useMyCustomMiddlewareendmoduleMyApiclassUser < Base# will use the customized connectionendend
Server errors handling

Non-success API response will cause the specific JsonApiClient::Errors::SomeException raised, depends on responded HTTP status. Please refer to JsonApiClient::Middleware::Status#handle_status method for concrete status-to-exception mapping used out of the box.

JsonApiClient will try determine is failed API response JsonApi-compatible, if so - JsonApi error messages will be parsed from response body, and tracked as a part of particular exception message. In additional, JsonApiClient::Errors::ServerError exception will keep the actual HTTP status and message within its message.

Custom status handler

You can change handling of response status using connection_options. For example you can override 400 status handling. By default it raises JsonApiClient::Errors::ClientError but you can skip exception if you want to process errors from the server. You need to provide a proc which should call throw(:handled) default handler for this status should be skipped.

classApiBadRequestHandlerdefself.call(_env)# do not raise exceptionendendclassCustomUnauthorizedError < StandardErrorattr_reader:envdefinitialize(env)@env=envsuper('not authorized')endendMyApi::Base.connection_options[:status_handlers]={400=>ApiBadRequestHandler,401=>->(env){raiseCustomUnauthorizedError,env}}moduleMyApiclassUser < Base# will use the customized status_handlersendenduser=MyApi::User.create(name: 'foo')# server responds with { errors: [ { detail: 'bad request' } ] }user.errors.messages# { base: ['bad request'] }# on 401 it will raise CustomUnauthorizedError instead of JsonApiClient::Errors::NotAuthorized
Specifying an HTTP Proxy

All resources have a class method connection_options used to pass options to the JsonApiClient::Connection initializer.

MyApi::Base.connection_options[:proxy]='http://proxy.example.com'MyApi::Base.connectiondo |connection|
# ...endmoduleMyApiclassUser < Base# will use the customized connection with proxyendend

Custom Parser

You can configure your API client to use a custom parser that implements the parse class method. It should return a JsonApiClient::ResultSet instance. You can use it by setting the parser attribute on your model:

classMyCustomParserdefself.parse(klass,response)# …# returns some ResultSet objectendendclassMyApi::Base < JsonApiClient::Resourceself.parser=MyCustomParserend

Custom Query Builder

You can customize how the scope builder methods map to request parameters.

classMyQueryBuilderdefinitialize(klass);enddefwhere(conditions={})end# … add order, includes, paginate, page, first, buildendclassMyApi::Base < JsonApiClient::Resourceself.query_builder=MyQueryBuilderend

Custom Paginator

You can customize how your resources find pagination information from the response.

If the existing paginator fits your requirements but you don't use the default page and per_page params for pagination, you can customise the param keys as follows:

JsonApiClient::Paginating::Paginator.page_param="number"JsonApiClient::Paginating::Paginator.per_page_param="size"

Please note that this is a global configuration, so library authors should create a custom paginator that inherits JsonApiClient::Paginating::Paginator and configure the custom paginator to avoid modifying global config.

If the existing paginator does not fit your needs, you can create a custom paginator:

classMyPaginatordefinitialize(result_set,data);end# implement current_page, total_entries, etcendclassMyApi::Base < JsonApiClient::Resourceself.paginator=MyPaginatorend

NestedParamPaginator

The default JsonApiClient::Paginating::Paginator is not strict about how it handles the param keys (#347). There is a second paginator that more rigorously adheres to the JSON:API pagination recommendation style of page[page]=1&page[per_page]=10.

If this second style suits your needs better, it is available as a class override:

classOrder < JsonApiClient::Resourceself.paginator=JsonApiClient::Paginating::NestedParamPaginatorend

You can also extend NestedParamPaginator in your custom paginators or assign the page_param or per_page_param as with the default version above.

Custom type

If your model must be named differently from classified type of resource you can easily customize it. It will work both for defined and not defined relationships

classMyApi::Base < JsonApiClient::Resourceresolve_custom_type'document--files','File'endclassMyApi::File < MyApi::Basedefself.resource_name'document--files'endend

Type Casting

You can define your own types and its casting mechanism for schema.

require'money'classMyMoneyCasterdefself.cast(value,default)beginMoney.new(value,"USD")rescueArgumentErrordefaultendendendJsonApiClient::Schema.registermoney: MyMoneyCaster

and finally

classOrder < JsonApiClient::Resourceproperty:total_amount,type: :moneyend

Safe singular resource fetching

That is a bit curios, but json_api_client returns an array from .find method, always. The history of this fact was discussed here

So, when we searching for a single resource by primary key, we typically write the things like

admin=User.find(id).first

The next thing which we need to notice - json_api_client will just interpolate the incoming .find param to the end of API URL, just like that:

http://somehost/api/v1/users/{id}

What will happen if we pass the blank id (nil or empty string) to the .find method then?.. Yeah, json_api_client will try to call the INDEX API endpoint instead of SHOW one:

http://somehost/api/v1/users/

Lets sum all together - in case if id comes blank (from CGI for instance), we can silently receive the admin variable equal to some existing resource, with all the consequences.

Even worse, admin variable can equal to random resource, depends on ordering applied by INDEX endpoint.

If you prefer to get JsonApiClient::Errors::NotFound raised, please define in your base Resource class:

classResource < JsonApiClient::Resourceself.raise_on_blank_find_param=trueend

Contributing

Contributions are welcome! Please fork this repo and send a pull request. Your pull request should have:

  • a description about what's broken or what the desired functionality is
  • a test illustrating the bug or new feature
  • the code to fix the bug

Ideally, the PR has 2 commits - the first showing the failed test and the second with the fix - although this is not required. The commits will be squashed into master once accepted.

Set up development locally

  • With a decent ruby version:

    bundle install
    appraisal install
  • Run all tests for that ruby version:

    appraisal rake
  • For more info on appraisal:

    appraisal help

Changelog

See changelog

About

Build client libraries compliant with specification defined by jsonapi.org

Topics

Resources

Stars

363 stars

Watchers

14 watching

Forks

Releases

Packages

Used by

Contributors

Languages