diff --git a/.gitignore b/.gitignore index 7374a56d..6efdc659 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,10 @@ Gemfile.lock *.gemfile.lock /coverage + +# IntellJ/RubyMine +*.iml +.idea/ + +# asdf config +.tool-versions diff --git a/.travis.yml b/.travis.yml index daa06977..41c52274 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,6 +3,9 @@ rvm: - 2.2.6 - 2.3.3 - 2.4.1 + - 2.5.1 + - 2.6.0 + - 2.7.1 env: global: - CODECLIMATE_REPO_TOKEN=396d4263adb6febf1e6e9b0c0e176fbde35e1a116a3c1ecf8dd4f9384e41979b @@ -12,9 +15,15 @@ gemfile: - gemfiles/4.1.gemfile - gemfiles/4.2.gemfile - gemfiles/5.0.gemfile + - gemfiles/5.2.3.gemfile + - gemfiles/6.0.gemfile matrix: fast_finish: true exclude: + - rvm: 2.2.6 + gemfile: gemfiles/6.0.gemfile + - rvm: 2.3.3 + gemfile: gemfiles/6.0.gemfile - rvm: 2.4.1 gemfile: gemfiles/3.2.gemfile - rvm: 2.4.1 @@ -23,7 +32,31 @@ matrix: gemfile: gemfiles/4.1.gemfile - rvm: 2.4.1 gemfile: gemfiles/4.2.gemfile + - rvm: 2.4.1 + gemfile: gemfiles/6.0.gemfile + - rvm: 2.5.1 + gemfile: gemfiles/3.2.gemfile + - rvm: 2.5.1 + gemfile: gemfiles/4.0.gemfile + - rvm: 2.5.1 + gemfile: gemfiles/4.1.gemfile + - rvm: 2.5.1 + gemfile: gemfiles/4.2.gemfile + - rvm: 2.6.0 + gemfile: gemfiles/4.0.gemfile + - rvm: 2.6.0 + gemfile: gemfiles/4.1.gemfile + - rvm: 2.6.0 + gemfile: gemfiles/4.2.gemfile + - rvm: 2.7.1 + gemfile: gemfiles/3.2.gemfile + - rvm: 2.7.1 + gemfile: gemfiles/4.0.gemfile + - rvm: 2.7.1 + gemfile: gemfiles/4.1.gemfile + - rvm: 2.7.1 + gemfile: gemfiles/4.2.gemfile # We need to install latest version of bundler, because one in travis # image is too old to recognize platform => :mri_22 in Gemfile. before_install: - - gem install bundler + - gem install bundler || gem install bundler -v 1.17.3 diff --git a/CHANGELOG.md b/CHANGELOG.md index 00fc716c..938d58cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,31 @@ # Changelog -## Unreleased +Using a new major version can cause problems when the upstream moves to their v2.0 + +A better way to handle this is: + + - to not fork in the first place, but rather: + - create PRs to contribute to the upstream repository + - put modifications into an initializer until the upstream incorporates your PR + - if a forked repositiory is already in place: + - keep the master of the fork always in-sync with the upstream master + - keep any custom modifications on a private branch, e.g. chime, and use that branch in all your projects + +# Chime ChangeLog: + +DO NOT ADD ANY OTHER FUNCTIONALITY HERE! +CREATE UPSTREAM PRs IF YOU WANT TO CONTRIBUTE + +THE PLAN IS TO GET RID OF OUR FORK ALTOGETHER + +## v2.20.0 +- merge upstream/master 1.20.0 into our fork + +## v2.19.0 +- merge upstream/master 1.19.0 into our fork + +## v2.18.0 +- merge upstream/master 1.18.0 into our fork ## v2.0.1 - relax bigdecimal version constraints @@ -8,6 +33,139 @@ ## v2.0.0 - Adding `bigdecimal ~> 2.0.0` as a gem dependency and updating BigDecimal constructors +------- + +# Upstream ChangeLog: + +## 1.21.0 +- [#395](https://github.com/JsonApiClient/json_api_client/pull/395) - relaxing faraday dependency to anything less than 2.0 + +## 1.20.0 +- [#389](https://github.com/JsonApiClient/json_api_client/pull/389) - adding `create!`, `update_attributes!`, `update!` methods + +## 1.19.0 +- [#382](https://github.com/JsonApiClient/json_api_client/pull/382) - Add extra error classes to hande server errors. + +- [#386](https://github.com/JsonApiClient/json_api_client/pull/386) - use HashWithIndifferentAccess + +## 1.18.0 +- [#372](https://github.com/JsonApiClient/json_api_client/pull/372) - Fix handling of dashed-types associations correctly + +## 1.17.1 +- [#370](https://github.com/JsonApiClient/json_api_client/pull/370) - bigdecimal 2 support + +## 1.17.0 + +- [#364](https://github.com/JsonApiClient/json_api_client/pull/364) - Relax faraday and faraday middleware versions + +## 1.16.1 + +- [#361](https://github.com/JsonApiClient/json_api_client/pull/361) - Call super from inherited method so that it will execute parent classes' implementations + +## 1.16.0 + +- [#359](https://github.com/JsonApiClient/json_api_client/pull/359) - Support gzip content encoding + +## 1.15.0 + +- [#346](https://github.com/JsonApiClient/json_api_client/pull/346) - add the option to have immutable resources +- [#357](https://github.com/JsonApiClient/json_api_client/pull/357) - fix missing constant formatter + +## 1.14.1 + +- [#353](https://github.com/JsonApiClient/json_api_client/pull/353) - fix to support deserializing resources with relationships without those related resources being included in the response (issue [#352](https://github.com/JsonApiClient/json_api_client/issues/352)). + +## 1.14.0 + +- [#338](https://github.com/JsonApiClient/json_api_client/pull/338) - implement hash and eql? for builder class +- [#351](https://github.com/JsonApiClient/json_api_client/pull/351) - Remove rudimental `last_result_set` relationship from serializer + +## 1.13.0 + +- [#348](https://github.com/JsonApiClient/json_api_client/pull/348) - add NestedParamPaginator to address inconsistency in handling of pagination query string params (issue [#347](https://github.com/JsonApiClient/json_api_client/issues/347)). + +## 1.12.2 + +- [#350](https://github.com/JsonApiClient/json_api_client/pull/350) - fix resource including with blank `relationships` response data + +## 1.12.1 + +- [#349](https://github.com/JsonApiClient/json_api_client/pull/349) - fix resource including for STI objects + +## 1.12.0 + +- [#345](https://github.com/JsonApiClient/json_api_client/pull/345) - track the real HTTP reason of ApiErrors + +## 1.11.0 + +- [#344](https://github.com/JsonApiClient/json_api_client/pull/344) - introduce safe singular resource fetching with `raise_on_blank_find_param` resource setting + +## 1.10.0 + +- [#335](https://github.com/JsonApiClient/json_api_client/pull/335) - access to assigned relationship + +## 1.9.0 + +- [#328](https://github.com/JsonApiClient/json_api_client/pull/328) - allow custom type for models + +- [#326](https://github.com/JsonApiClient/json_api_client/pull/326) - correct changes after initialize resource + * remove type from changes on initialize + * ensure that query builder doesn't propagate query values to resource attributes via #build method + +- [#324](https://github.com/JsonApiClient/json_api_client/pull/324) - add possibility to override status handling + * add status_handlers to JsonApiClient::Resource.connection_options + +- [#330](https://github.com/JsonApiClient/json_api_client/pull/330) - deletion use overridden primary key + +## 1.8.0 + +- [#316](https://github.com/JsonApiClient/json_api_client/pull/316) - Allow custom error messages + +- [#305](https://github.com/JsonApiClient/json_api_client/pull/305) - optional search relationship data in result set + +## 1.7.0 + +- [#320](https://github.com/JsonApiClient/json_api_client/pull/320) - fix passing relationships on create + * fix relationships passing to `new` and `create` methods + * fix false positive tests on create + * refactor tests on create/update to prevent false same positive tests in future + +- [#315](https://github.com/JsonApiClient/json_api_client/pull/315) - add shallow_path feature to belongs_to + * add `shallow_path` options to belongs_to to use model w/ and w/o nesting in parent resource + +## v1.6.4 +- [#314](https://github.com/JsonApiClient/json_api_client/pull/314) - Mimic ActiveRecord behavior when destroying a resource: + * Add `destroyed?` method + * Do not clear resource attributes + * Return `false` on `persisted?` after being destroyed + * Return `false` on `new_record?` after being destroyed + +## v1.6.3 + +- [#312](https://github.com/JsonApiClient/json_api_client/pull/312) - Don't raise on `422` + +## v1.6.2 + +- [#311](https://github.com/JsonApiClient/json_api_client/pull/311) - Raise JsonApiClient::Errors::ClientError for unhandled 4xx responses + +## v1.6.1 + +- [#297](https://github.com/JsonApiClient/json_api_client/pull/297) - Fix test_helper +- [#298](https://github.com/JsonApiClient/json_api_client/pull/298) - README update: arguments for custom connections run method +- [#306](https://github.com/JsonApiClient/json_api_client/pull/306) - README update: pagination override examples +- [#307](https://github.com/JsonApiClient/json_api_client/pull/307) - Symbolize params keys on model initialize +- [#304](https://github.com/JsonApiClient/json_api_client/pull/304) - Optional add default to changes +- [#300](https://github.com/JsonApiClient/json_api_client/pull/300) - Define methods for properties and associations getter/setter + +## v1.6.0 + +- [#281](https://github.com/JsonApiClient/json_api_client/pull/281) - Optimize dynamic attribute deref +- [#280](https://github.com/JsonApiClient/json_api_client/pull/280) - Fix custom headers inheritance +- [#287](https://github.com/JsonApiClient/json_api_client/pull/287) - Allow pagination params to be `nil` +- [#284](https://github.com/JsonApiClient/json_api_client/pull/284) - Fix filter to not filter out `[]` values +- [#285](https://github.com/JsonApiClient/json_api_client/pull/285) - Add include params for create/update +- [#293](https://github.com/JsonApiClient/json_api_client/pull/293) - Fix side-effects in scopes + ## v1.5.3 - [#266](https://github.com/chingor13/json_api_client/pull/266) - Fix default attributes being overridden diff --git a/Gemfile b/Gemfile index 163acf72..0b5a6e5c 100644 --- a/Gemfile +++ b/Gemfile @@ -11,5 +11,5 @@ gem 'addressable', '~> 2.2' gem "codeclimate-test-reporter", group: :test, require: nil group :development, :test do - gem 'byebug', platforms: [:mri_20, :mri_21, :mri_22] + gem 'byebug', '~> 10.0', platforms: [:mri_20, :mri_21, :mri_22] end diff --git a/README.md b/README.md index c0298652..f2968cec 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,14 @@ - # PLEASE DO NOT ADD NEW FUNCTIONALITY Instead please make PRs against the public repository https://github.com/JsonApiClient/json_api_client This repository is slated to be archived - -# JsonApiClient [![Build Status](https://travis-ci.org/chingor13/json_api_client.png)](https://travis-ci.org/chingor13/json_api_client) [![Code Climate](https://codeclimate.com/github/chingor13/json_api_client.png)](https://codeclimate.com/github/chingor13/json_api_client) [![Code Coverage](https://codeclimate.com/github/chingor13/json_api_client/coverage.png)](https://codeclimate.com/github/chingor13/json_api_client) +# JsonApiClient [![Build Status](https://travis-ci.org/JsonApiClient/json_api_client.png?branch=master)](https://travis-ci.org/JsonApiClient/json_api_client) [![Code Climate](https://codeclimate.com/github/JsonApiClient/json_api_client.png)](https://codeclimate.com/github/JsonApiClient/json_api_client) [![Code Coverage](https://codeclimate.com/github/JsonApiClient/json_api_client/coverage.png)](https://codeclimate.com/github/JsonApiClient/json_api_client) This gem is meant to help you build an API client for interacting with REST APIs as laid out by [http://jsonapi.org](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](https://github.com/chingor13/json_api_client/tree/0.x)* +*Note: master is currently tracking the 1.0.0 specification. If you're looking for the older code, see [0.x branch](https://github.com/JsonApiClient/json_api_client/tree/0.x)* ## Usage @@ -47,14 +45,29 @@ MyApi::Article.where(author_id: 1).all MyApi::Person.where(name: "foo").order(created_at: :desc).includes(:preferences, :cars).all u = MyApi::Person.new(first_name: "bar", last_name: "foo") +u.new_record? +# => true u.save +u.new_record? +# => false + u = MyApi::Person.find(1).first u.update_attributes( a: "b", c: "d" ) +u.persisted? +# => true + +u.destroy + +u.destroyed? +# => true +u.persisted? +# => false + u = MyApi::Person.create( a: "b", c: "d" @@ -150,13 +163,17 @@ articles.links.related 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.** +**Note: Using belongs_to is only necessary for setting a nested path unless you provide `shallow_path: true` option.** ```ruby module MyApi class Account < JsonApiClient::Resource belongs_to :user end + + class Customer < JsonApiClient::Resource + belongs_to :user, shallow_path: true + end end # try to find without the nested parameter @@ -166,6 +183,28 @@ MyApi::Account.find(1) # makes request to /users/2/accounts/1 MyApi::Account.where(user_id: 2).find(1) # => returns ResultSet + +# makes request to /customers/1 +MyApi::Customer.find(1) +# => returns ResultSet + +# makes request to /users/2/customers/1 +MyApi::Customer.where(user_id: 2).find(1) +# => returns ResultSet +``` + +you can also override param name for `belongs_to` association + +```ruby +module MyApi + class Account < JsonApiClient::Resource + belongs_to :user, param: :customer_id + end +end + +# makes request to /users/2/accounts/1 +MyApi::Account.where(customer_id: 2).find(1) +# => returns ResultSet ``` ## Custom Methods @@ -204,6 +243,23 @@ results = Article.includes(:author, :comments => :author).find(1) # should not have to make additional requests to the server authors = results.map(&:author) + +# makes POST request to /articles?include=author,comments.author +article = Article.new(title: 'New one').request_includes(:author, :comments => :author) +article.save + +# makes PATCH request to /articles/1?include=author,comments.author +article = 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 used +Article.keep_request_params = true + +# to clear request_includes use +article.reset_request_includes! ``` ## Sparse Fieldsets @@ -225,6 +281,24 @@ article.created_at # or you can use fieldsets from multiple resources # makes request to /articles?fields[articles]=title,body&fields[comments]=tag article = Article.select("title", "body",{comments: 'tag'}).first + +# makes POST request to /articles?fields[articles]=title,body&fields[comments]=tag +article = 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]=tag +article = 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 used +Article.keep_request_params = true + +# to clear request fields use +article.reset_request_select!(:comments) # to clear for comments +article.reset_request_select! # to clear for all fields ``` ## Sorting @@ -254,6 +328,10 @@ articles = Article.page(2).per(30).to_a # also makes request to /articles?page=2&per_page=30 articles = 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=30 +articles = Article.paginate(page: nil, per_page: 30).to_a ``` *Note: The mapping of pagination parameters is done by the `query_builder` which is [customizable](#custom-paginator).* @@ -368,7 +446,7 @@ class NullConnection def initialize(*args) end - def run(request_method, path, params = {}, headers = {}) + def run(request_method, path, params: nil, headers: {}, body: nil) end def use(*args); end @@ -402,6 +480,52 @@ module MyApi end ``` +##### 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](https://github.com/JsonApiClient/json_api_client/blob/master/lib/json_api_client/middleware/status.rb) +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. +```ruby +class ApiBadRequestHandler + def self.call(_env) + # do not raise exception + end +end + +class CustomUnauthorizedError < StandardError + attr_reader :env + + def initialize(env) + @env = env + super('not authorized') + end +end + +MyApi::Base.connection_options[:status_handlers] = { + 400 => ApiBadRequestHandler, + 401 => ->(env) { raise CustomUnauthorizedError, env } +} + +module MyApi + class User < Base + # will use the customized status_handlers + end +end + +user = 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. @@ -459,16 +583,16 @@ end You can customize how your resources find pagination information from the response. -If the [existing paginator](https://github.com/chingor13/json_api_client/blob/master/lib/json_api_client/paginating/paginator.rb) 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: +If the [existing paginator](https://github.com/JsonApiClient/json_api_client/blob/master/lib/json_api_client/paginating/paginator.rb) 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: ```ruby -JsonApiClient::Paginating::Paginator.page_param = "page[number]" -JsonApiClient::Paginating::Paginator.per_page_param = "page[size]" +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](https://github.com/chingor13/json_api_client/blob/master/lib/json_api_client/paginating/paginator.rb) does not fit your needs, you can create a custom paginator: +If the [existing paginator](https://github.com/JsonApiClient/json_api_client/blob/master/lib/json_api_client/paginating/paginator.rb) does not fit your needs, you can create a custom paginator: ```ruby class MyPaginator @@ -481,6 +605,36 @@ class MyApi::Base < JsonApiClient::Resource end ``` +### NestedParamPaginator + +The default `JsonApiClient::Paginating::Paginator` is not strict about how it handles the param keys ([#347](https://github.com/JsonApiClient/json_api_client/issues/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: + +```ruby +class Order < JsonApiClient::Resource + self.paginator = JsonApiClient::Paginating::NestedParamPaginator +end +``` + +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 + +```ruby +class MyApi::Base < JsonApiClient::Resource + resolve_custom_type 'document--files', 'File' +end + +class MyApi::File < MyApi::Base + def self.resource_name + 'document--files' + end +end +``` ### Type Casting @@ -510,6 +664,37 @@ end ``` +### 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](https://github.com/JsonApiClient/json_api_client/issues/75) + +So, when we searching for a single resource by primary key, we typically write the things like + +```ruby +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: + +```ruby +class Resource < JsonApiClient::Resource + self.raise_on_blank_find_param = true +end +``` + ## Contributing Contributions are welcome! Please fork this repo and send a pull request. Your pull request should have: @@ -523,4 +708,4 @@ required. The commits will be squashed into master once accepted. ## Changelog -See [changelog](https://github.com/chingor13/json_api_client/blob/master/CHANGELOG.md) +See [changelog](https://github.com/JsonApiClient/json_api_client/blob/master/CHANGELOG.md) diff --git a/gemfiles/5.2.3.gemfile b/gemfiles/5.2.3.gemfile new file mode 100644 index 00000000..9a9fda0a --- /dev/null +++ b/gemfiles/5.2.3.gemfile @@ -0,0 +1,15 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "rake" +gem "appraisal" +gem "activesupport", "~> 5.2.3" +gem "addressable", "~> 2.2" +gem "codeclimate-test-reporter", :group => :test, :require => nil + +group :development, :test do + gem "byebug", :platforms => [:mri_20, :mri_21, :mri_22] +end + +gemspec :path => "../" diff --git a/gemfiles/6.0.gemfile b/gemfiles/6.0.gemfile new file mode 100644 index 00000000..e046e114 --- /dev/null +++ b/gemfiles/6.0.gemfile @@ -0,0 +1,15 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "rake" +gem "appraisal" +gem "activesupport", "~> 6.0.0" +gem "addressable", "~> 2.2" +gem "codeclimate-test-reporter", :group => :test, :require => nil + +group :development, :test do + gem "byebug", :platforms => [:mri_25] +end + +gemspec :path => "../" diff --git a/json_api_client.gemspec b/json_api_client.gemspec index ed0b43f6..b596e939 100644 --- a/json_api_client.gemspec +++ b/json_api_client.gemspec @@ -12,14 +12,15 @@ Gem::Specification.new do |s| s.summary = 'Build client libraries compliant with specification defined by jsonapi.org' s.add_dependency "activesupport", '>= 3.2.0' - s.add_dependency "faraday", '~> 0.9' - s.add_dependency "faraday_middleware", '~> 0.9' + s.add_dependency "faraday", '>= 0.15.2', '< 2.0' + s.add_dependency "faraday_middleware", '>= 0.9.0', '< 2.0' s.add_dependency "addressable", '~> 2.2' s.add_dependency "activemodel", '>= 3.2.0' - s.add_dependency "bigdecimal" + s.add_dependency "rack", '>= 0.2' - s.add_development_dependency "webmock" + s.add_development_dependency "webmock", '~> 3.5.1' s.add_development_dependency "mocha" + s.add_development_dependency "pry" s.license = "MIT" diff --git a/lib/json_api_client.rb b/lib/json_api_client.rb index 01f18f37..b4082727 100644 --- a/lib/json_api_client.rb +++ b/lib/json_api_client.rb @@ -2,6 +2,7 @@ require 'faraday_middleware' require 'json' require 'addressable/uri' +require 'json_api_client/formatter' module JsonApiClient autoload :Associations, 'json_api_client/associations' @@ -9,7 +10,6 @@ module JsonApiClient autoload :Connection, 'json_api_client/connection' autoload :Errors, 'json_api_client/errors' autoload :ErrorCollector, 'json_api_client/error_collector' - autoload :Formatter, 'json_api_client/formatter' autoload :Helpers, 'json_api_client/helpers' autoload :Implementation, 'json_api_client/implementation' autoload :IncludedData, 'json_api_client/included_data' @@ -21,6 +21,7 @@ module JsonApiClient autoload :Paginating, 'json_api_client/paginating' autoload :Parsers, 'json_api_client/parsers' autoload :Query, 'json_api_client/query' + autoload :RequestParams, 'json_api_client/request_params' autoload :Resource, 'json_api_client/resource' autoload :ResultSet, 'json_api_client/result_set' autoload :Schema, 'json_api_client/schema' diff --git a/lib/json_api_client/associations/base_association.rb b/lib/json_api_client/associations/base_association.rb index dfc8166e..ca9ffe62 100644 --- a/lib/json_api_client/associations/base_association.rb +++ b/lib/json_api_client/associations/base_association.rb @@ -21,6 +21,13 @@ def data(url) def from_result_set(result_set) result_set.to_a end + + def load_records(data) + data.map do |d| + record_class = Utils.compute_type(klass, klass.key_formatter.unformat(d["type"]).classify) + record_class.load id: d["id"] + end + end end end end diff --git a/lib/json_api_client/associations/belongs_to.rb b/lib/json_api_client/associations/belongs_to.rb index 14f0df68..a18cfa67 100644 --- a/lib/json_api_client/associations/belongs_to.rb +++ b/lib/json_api_client/associations/belongs_to.rb @@ -1,19 +1,19 @@ module JsonApiClient module Associations module BelongsTo - extend ActiveSupport::Concern + class Association < BaseAssociation + include Helpers::URI + + attr_reader :param - module ClassMethods - def belongs_to(attr_name, options = {}) - # self.associations = self.associations + [HasOne::Association.new(attr_name, self, options)] - self.associations += [BelongsTo::Association.new(attr_name, self, options)] + def initialize(attr_name, klass, options = {}) + super + @param = options.fetch(:param, :"#{attr_name}_id").to_sym + @shallow_path = options.fetch(:shallow_path, false) end - end - class Association < BaseAssociation - include Helpers::URI - def param - :"#{attr_name}_id" + def shallow_path? + @shallow_path end def to_prefix_path(formatter) @@ -21,6 +21,7 @@ def to_prefix_path(formatter) end def set_prefix_path(attrs, formatter) + return if shallow_path? && !attrs[param] attrs[param] = encode_part(attrs[param]) if attrs.key?(param) to_prefix_path(formatter) % attrs end diff --git a/lib/json_api_client/associations/has_many.rb b/lib/json_api_client/associations/has_many.rb index b5f52d0d..861ec3b8 100644 --- a/lib/json_api_client/associations/has_many.rb +++ b/lib/json_api_client/associations/has_many.rb @@ -1,14 +1,6 @@ module JsonApiClient module Associations module HasMany - extend ActiveSupport::Concern - - module ClassMethods - def has_many(attr_name, options = {}) - self.associations = self.associations + [HasMany::Association.new(attr_name, self, options)] - end - end - class Association < BaseAssociation end end diff --git a/lib/json_api_client/associations/has_one.rb b/lib/json_api_client/associations/has_one.rb index 20edc533..df514f52 100644 --- a/lib/json_api_client/associations/has_one.rb +++ b/lib/json_api_client/associations/has_one.rb @@ -1,19 +1,16 @@ module JsonApiClient module Associations module HasOne - extend ActiveSupport::Concern - - module ClassMethods - def has_one(attr_name, options = {}) - self.associations += [HasOne::Association.new(attr_name, self, options)] - end - end - class Association < BaseAssociation def from_result_set(result_set) result_set.first end + + def load_records(data) + record_class = Utils.compute_type(klass, klass.key_formatter.unformat(data["type"]).classify) + record_class.load id: data["id"] + end end end end -end \ No newline at end of file +end diff --git a/lib/json_api_client/connection.rb b/lib/json_api_client/connection.rb index 87839bff..d7e9ab33 100644 --- a/lib/json_api_client/connection.rb +++ b/lib/json_api_client/connection.rb @@ -7,11 +7,14 @@ def initialize(options = {}) site = options.fetch(:site) connection_options = options.slice(:proxy, :ssl, :request, :headers, :params) adapter_options = Array(options.fetch(:adapter, Faraday.default_adapter)) + status_middleware_options = {} + status_middleware_options[:custom_handlers] = options[:status_handlers] if options[:status_handlers].present? @faraday = Faraday.new(site, connection_options) do |builder| builder.request :json builder.use Middleware::JsonRequest - builder.use Middleware::Status + builder.use Middleware::Status, status_middleware_options builder.use Middleware::ParseJson + builder.use ::FaradayMiddleware::Gzip builder.adapter(*adapter_options) end yield(self) if block_given? @@ -28,8 +31,10 @@ def delete(middleware) faraday.builder.delete(middleware) end - def run(request_method, path, params = {}, headers = {}) - faraday.send(request_method, path, params, headers) + def run(request_method, path, params: nil, headers: {}, body: nil) + faraday.run_request(request_method, path, body, headers) do |request| + request.params.update(params) if params + end end end diff --git a/lib/json_api_client/errors.rb b/lib/json_api_client/errors.rb index 0a4a524f..cb267e02 100644 --- a/lib/json_api_client/errors.rb +++ b/lib/json_api_client/errors.rb @@ -1,44 +1,96 @@ +require 'rack' + module JsonApiClient module Errors class ApiError < StandardError attr_reader :env - def initialize(env) + + def initialize(env, msg = nil) @env = env + # Try to fetch json_api errors from response + msg = track_json_api_errors(msg) + + super msg + end + + private + + # Try to fetch json_api errors from response + def track_json_api_errors(msg) + return msg unless env.try(:body).kind_of?(Hash) || env.body.key?('errors') + + errors_msg = env.body['errors'].map { |e| e['title'] }.compact.join('; ').presence + return msg unless errors_msg + + msg.nil? ? errors_msg : "#{msg} (#{errors_msg})" + # Just to be sure that it is back compatible + rescue StandardError + msg end end class ClientError < ApiError end + class ResourceImmutableError < StandardError + def initialize(msg = 'Resource immutable') + super msg + end + end + class AccessDenied < ClientError end class NotAuthorized < ClientError end + class NotFound < ClientError + attr_reader :uri + def initialize(uri) + @uri = uri + + msg = "Resource not found: #{uri.to_s}" + super nil, msg + end + end + + class RequestTimeout < ClientError + end + + class Conflict < ClientError + def initialize(env, msg = 'Resource already exists') + super env, msg + end + end + + class TooManyRequests < ClientError + end + class ConnectionError < ApiError end class ServerError < ApiError - def message - "Internal server error" + def initialize(env, msg = nil) + msg ||= begin + status = env.status + message = ::Rack::Utils::HTTP_STATUS_CODES[status] + "#{status} #{message}" + end + + super env, msg end end - class Conflict < ServerError - def message - "Resource already exists" - end + class InternalServerError < ServerError end - class NotFound < ServerError - attr_reader :uri - def initialize(uri) - @uri = uri - end - def message - "Couldn't find resource at: #{uri.to_s}" - end + class BadGateway < ServerError + end + + class ServiceUnavailable < ServerError + end + + class GatewayTimeout < ServerError end class UnexpectedStatus < ServerError @@ -46,9 +98,9 @@ class UnexpectedStatus < ServerError def initialize(code, uri) @code = code @uri = uri - end - def message - "Unexpected response status: #{code} from: #{uri.to_s}" + + msg = "Unexpected response status: #{code} from: #{uri.to_s}" + super nil, msg end end diff --git a/lib/json_api_client/helpers.rb b/lib/json_api_client/helpers.rb index f80794e1..6f6f71ab 100644 --- a/lib/json_api_client/helpers.rb +++ b/lib/json_api_client/helpers.rb @@ -4,5 +4,6 @@ module Helpers autoload :Dirty, 'json_api_client/helpers/dirty' autoload :DynamicAttributes, 'json_api_client/helpers/dynamic_attributes' autoload :URI, 'json_api_client/helpers/uri' + autoload :Associatable, 'json_api_client/helpers/associatable' end end diff --git a/lib/json_api_client/helpers/associatable.rb b/lib/json_api_client/helpers/associatable.rb new file mode 100644 index 00000000..ce60f029 --- /dev/null +++ b/lib/json_api_client/helpers/associatable.rb @@ -0,0 +1,88 @@ +module JsonApiClient + module Helpers + module Associatable + extend ActiveSupport::Concern + + included do + class_attribute :associations, instance_accessor: false + self.associations = [] + attr_accessor :__cached_associations + attr_accessor :__belongs_to_params + end + + module ClassMethods + def _define_association(attr_name, association_klass, options = {}) + attr_name = attr_name.to_sym + association = association_klass.new(attr_name, self, options) + self.associations += [association] + end + + def _define_relationship_methods(attr_name) + attr_name = attr_name.to_sym + + define_method(attr_name) do + _cached_relationship(attr_name) do + relationship_definition = relationship_definition_for(attr_name) + return unless relationship_definition + relationship_data_for(attr_name, relationship_definition) + end + end + + define_method("#{attr_name}=") do |value| + _clear_cached_relationship(attr_name) + relationships.public_send("#{attr_name}=", value) + end + end + + def belongs_to(attr_name, options = {}) + _define_association(attr_name, JsonApiClient::Associations::BelongsTo::Association, options) + + param = associations.last.param + define_method(param) do + _belongs_to_params[param] + end + + define_method(:"#{param}=") do |value| + _belongs_to_params[param] = value + end + end + + def has_many(attr_name, options = {}) + _define_association(attr_name, JsonApiClient::Associations::HasMany::Association, options) + _define_relationship_methods(attr_name) + end + + def has_one(attr_name, options = {}) + _define_association(attr_name, JsonApiClient::Associations::HasOne::Association, options) + _define_relationship_methods(attr_name) + end + end + + def _belongs_to_params + self.__belongs_to_params ||= {} + end + + def _clear_belongs_to_params + self.__belongs_to_params = {} + end + + def _cached_associations + self.__cached_associations ||= {} + end + + def _clear_cached_relationships + self.__cached_associations = {} + end + + def _clear_cached_relationship(attr_name) + _cached_associations.delete(attr_name) + end + + def _cached_relationship(attr_name) + return _cached_associations[attr_name] if _cached_associations.has_key?(attr_name) + _cached_associations[attr_name] = yield + end + + end + end +end diff --git a/lib/json_api_client/helpers/dirty.rb b/lib/json_api_client/helpers/dirty.rb index 09b6512b..0e2636fb 100644 --- a/lib/json_api_client/helpers/dirty.rb +++ b/lib/json_api_client/helpers/dirty.rb @@ -18,6 +18,10 @@ def clear_changes_information @changed_attributes = ActiveSupport::HashWithIndifferentAccess.new end + def forget_change!(attr) + @changed_attributes.delete(attr.to_s) + end + def set_all_attributes_dirty attributes.each do |k, v| set_attribute_was(k, v) @@ -68,4 +72,4 @@ def set_attribute(name, value) end end -end \ No newline at end of file +end diff --git a/lib/json_api_client/helpers/dynamic_attributes.rb b/lib/json_api_client/helpers/dynamic_attributes.rb index ac23ac3e..18816ba1 100644 --- a/lib/json_api_client/helpers/dynamic_attributes.rb +++ b/lib/json_api_client/helpers/dynamic_attributes.rb @@ -39,12 +39,7 @@ def has_attribute?(attr_name) def method_missing(method, *args, &block) if has_attribute?(method) - self.class.class_eval do - define_method(method) do - attributes[method] - end - end - return send(method) + return attributes[method] end normalized_method = safe_key_formatter.unformat(method.to_s) diff --git a/lib/json_api_client/included_data.rb b/lib/json_api_client/included_data.rb index 2ae26591..8cf689c7 100644 --- a/lib/json_api_client/included_data.rb +++ b/lib/json_api_client/included_data.rb @@ -5,16 +5,29 @@ class IncludedData def initialize(result_set, data) record_class = result_set.record_class grouped_data = data.group_by{|datum| datum["type"]} - @data = grouped_data.inject({}) do |h, (type, records)| + grouped_included_set = grouped_data.each_with_object({}) do |(type, records), h| klass = Utils.compute_type(record_class, record_class.key_formatter.unformat(type).singularize.classify) - h[type] = records.map do |datum| - params = klass.parser.parameters_from_resource(datum) - resource = klass.load(params) - resource.last_result_set = result_set - resource - end.index_by(&:id) - h + h[type] = records.map do |record| + params = klass.parser.parameters_from_resource(record) + klass.load(params).tap do |resource| + resource.last_result_set = result_set + end + end + end + + if record_class.search_included_in_result_set + # deep_merge overrides the nested Arrays o_O + # {a: [1,2]}.deep_merge(a: [3,4]) # => {a: [3,4]} + grouped_included_set.merge!(result_set.group_by(&:type)) do |_, resources1, resources2| + resources1 + resources2 + end end + + grouped_included_set.each do |type, resources| + grouped_included_set[type] = resources.index_by(&:id) + end + + @data = grouped_included_set end def data_for(method_name, definition) @@ -23,9 +36,7 @@ def data_for(method_name, definition) if data.is_a?(Array) # has_many link - data.map do |link_def| - record_for(link_def) - end + data.map(&method(:record_for)).compact else # has_one link record_for(data) @@ -40,7 +51,8 @@ def has_link?(name) # should return a resource record of some type for this linked document def record_for(link_def) - data[link_def["type"]][link_def["id"]] + record = data[link_def["type"]] + record[link_def["id"]] if record end end end diff --git a/lib/json_api_client/middleware/status.rb b/lib/json_api_client/middleware/status.rb index 90467378..37f69a70 100644 --- a/lib/json_api_client/middleware/status.rb +++ b/lib/json_api_client/middleware/status.rb @@ -1,6 +1,11 @@ module JsonApiClient module Middleware class Status < Faraday::Middleware + def initialize(app, options) + super(app) + @options = options + end + def call(environment) @app.call(environment).on_complete do |env| handle_status(env[:status], env) @@ -11,13 +16,20 @@ def call(environment) handle_status(code, env) end end - rescue Faraday::ConnectionFailed, Faraday::TimeoutError - raise Errors::ConnectionError, environment + rescue Faraday::ConnectionFailed, Faraday::TimeoutError => e + raise Errors::ConnectionError.new environment, e.to_s end - protected + private + + def custom_handler_for(code) + @options.fetch(:custom_handlers, {})[code] + end def handle_status(code, env) + custom_handler = custom_handler_for(code) + return custom_handler.call(env) if custom_handler.present? + case code when 200..399 when 401 @@ -26,15 +38,25 @@ def handle_status(code, env) raise Errors::AccessDenied, env when 404 raise Errors::NotFound, env[:url] + when 408 + raise Errors::RequestTimeout, env when 409 raise Errors::Conflict, env + when 422 + # Allow to proceed as resource errors will be populated when 429 - raise Errors::ApiError, env + raise Errors::TooManyRequests, env when 400..499 - # some other error - # TODO: raise Errors::ApiError, env - # https://github.com/JsonApiClient/json_api_client/blame/master/lib/json_api_client/middleware/status.rb#L46 - when 500..599 + raise Errors::ClientError, env + when 500 + raise Errors::InternalServerError, env + when 502 + raise Errors::BadGateway, env + when 503 + raise Errors::ServiceUnavailable, env + when 504 + raise Errors::GatewayTimeout, env + when 501..599 raise Errors::ServerError, env else raise Errors::UnexpectedStatus.new(code, env[:url]) diff --git a/lib/json_api_client/paginating.rb b/lib/json_api_client/paginating.rb index 9443fff8..f49563a2 100644 --- a/lib/json_api_client/paginating.rb +++ b/lib/json_api_client/paginating.rb @@ -1,5 +1,6 @@ module JsonApiClient module Paginating autoload :Paginator, 'json_api_client/paginating/paginator' + autoload :NestedParamPaginator, 'json_api_client/paginating/nested_param_paginator' end -end \ No newline at end of file +end diff --git a/lib/json_api_client/paginating/nested_param_paginator.rb b/lib/json_api_client/paginating/nested_param_paginator.rb new file mode 100644 index 00000000..27e5ca4e --- /dev/null +++ b/lib/json_api_client/paginating/nested_param_paginator.rb @@ -0,0 +1,140 @@ +module JsonApiClient + module Paginating + # An alternate, more consistent Paginator that always wraps + # pagination query string params in a top-level wrapper_name, + # e.g. page[offset]=2, page[limit]=10. + class NestedParamPaginator + DEFAULT_WRAPPER_NAME = "page".freeze + DEFAULT_PAGE_PARAM = "page".freeze + DEFAULT_PER_PAGE_PARAM = "per_page".freeze + + # Define class accessors as methods to enforce standard way + # of defining pagination related query string params. + class << self + + def wrapper_name + @_wrapper_name ||= DEFAULT_WRAPPER_NAME + end + + def wrapper_name=(param = DEFAULT_WRAPPER_NAME) + raise ArgumentError, "don't wrap wrapper_name" unless valid_param?(param) + + @_wrapper_name = param.to_s + end + + def page_param + @_page_param ||= DEFAULT_PAGE_PARAM + "#{wrapper_name}[#{@_page_param}]" + end + + def page_param=(param = DEFAULT_PAGE_PARAM) + raise ArgumentError, "don't wrap page_param" unless valid_param?(param) + + @_page_param = param.to_s + end + + def per_page_param + @_per_page_param ||= DEFAULT_PER_PAGE_PARAM + "#{wrapper_name}[#{@_per_page_param}]" + end + + def per_page_param=(param = DEFAULT_PER_PAGE_PARAM) + raise ArgumentError, "don't wrap per_page_param" unless valid_param?(param) + + @_per_page_param = param + end + + private + + def valid_param?(param) + !(param.nil? || param.to_s.include?("[") || param.to_s.include?("]")) + end + + end + + attr_reader :params, :result_set, :links + + def initialize(result_set, data) + @params = params_for_uri(result_set.uri) + @result_set = result_set + @links = data["links"] + end + + def next + result_set.links.fetch_link("next") + end + + def prev + result_set.links.fetch_link("prev") + end + + def first + result_set.links.fetch_link("first") + end + + def last + result_set.links.fetch_link("last") + end + + def total_pages + if links["last"] + uri = result_set.links.link_url_for("last") + last_params = params_for_uri(uri) + last_params.fetch(page_param, &method(:current_page)).to_i + else + current_page + end + end + + # this is an estimate, not necessarily an exact count + def total_entries + per_page * total_pages + end + def total_count; total_entries; end + + def offset + per_page * (current_page - 1) + end + + def per_page + params.fetch(per_page_param) do + result_set.length + end.to_i + end + + def current_page + params.fetch(page_param, 1).to_i + end + + def out_of_bounds? + current_page > total_pages + end + + def previous_page + current_page > 1 ? (current_page - 1) : nil + end + + def next_page + current_page < total_pages ? (current_page + 1) : nil + end + + def page_param + self.class.page_param + end + + def per_page_param + self.class.per_page_param + end + + alias limit_value per_page + + protected + + def params_for_uri(uri) + return {} unless uri + uri = Addressable::URI.parse(uri) + ( uri.query_values || {} ).with_indifferent_access + end + end + end +end diff --git a/lib/json_api_client/paginating/paginator.rb b/lib/json_api_client/paginating/paginator.rb index cc93066a..9a8b1f98 100644 --- a/lib/json_api_client/paginating/paginator.rb +++ b/lib/json_api_client/paginating/paginator.rb @@ -82,7 +82,7 @@ def next_page def params_for_uri(uri) return {} unless uri uri = Addressable::URI.parse(uri) - uri.query_values || {} + ( uri.query_values || {} ).with_indifferent_access end end end diff --git a/lib/json_api_client/query/builder.rb b/lib/json_api_client/query/builder.rb index e21e2bf8..9d60dd27 100644 --- a/lib/json_api_client/query/builder.rb +++ b/lib/json_api_client/query/builder.rb @@ -1,3 +1,5 @@ +require 'active_support/all' + module JsonApiClient module Query class Builder @@ -5,60 +7,55 @@ class Builder attr_reader :klass delegate :key_formatter, to: :klass - def initialize(klass) - @klass = klass - @primary_key = nil - @pagination_params = {} - @path_params = {} - @additional_params = {} - @filters = {} - @includes = [] - @orders = [] - @fields = [] + def initialize(klass, opts = {}) + @klass = klass + @primary_key = opts.fetch( :primary_key, nil ) + @pagination_params = opts.fetch( :pagination_params, {} ) + @path_params = opts.fetch( :path_params, {} ) + @additional_params = opts.fetch( :additional_params, {} ) + @filters = opts.fetch( :filters, {} ) + @includes = opts.fetch( :includes, [] ) + @orders = opts.fetch( :orders, [] ) + @fields = opts.fetch( :fields, [] ) end def where(conditions = {}) # pull out any path params here - @path_params.merge!(conditions.slice(*klass.prefix_params)) - @filters.merge!(conditions.except(*klass.prefix_params)) - self + path_conditions = conditions.slice(*klass.prefix_params) + unpathed_conditions = conditions.except(*klass.prefix_params) + + _new_scope( path_params: path_conditions, filters: unpathed_conditions ) end def order(*args) - @orders += parse_orders(*args) - self + _new_scope( orders: parse_orders(*args) ) end def includes(*tables) - @includes += parse_related_links(*tables) - self + _new_scope( includes: parse_related_links(*tables) ) end def select(*fields) - @fields += parse_fields(*fields) - self + _new_scope( fields: parse_fields(*fields) ) end def paginate(conditions = {}) - scope = self + scope = _new_scope scope = scope.page(conditions[:page]) if conditions[:page] scope = scope.per(conditions[:per_page]) if conditions[:per_page] scope end def page(number) - @pagination_params[ klass.paginator.page_param ] = number - self + _new_scope( pagination_params: { klass.paginator.page_param => number || 1 } ) end def per(size) - @pagination_params[ klass.paginator.per_page_param ] = size - self + _new_scope( pagination_params: { klass.paginator.per_page_param => size } ) end def with_params(more_params) - @additional_params.merge!(more_params) - self + _new_scope( additional_params: more_params ) end def first @@ -69,8 +66,12 @@ def last paginate(page: 1, per_page: 1).pages.last.to_a.last end - def build - klass.new(params) + def build(attrs = {}) + klass.new @path_params.merge(attrs.with_indifferent_access) + end + + def create(attrs = {}) + klass.create @path_params.merge(attrs.with_indifferent_access) end def params @@ -85,27 +86,63 @@ def params end def to_a - @to_a ||= find + @to_a ||= _fetch end alias all to_a def find(args = {}) + if klass.raise_on_blank_find_param && args.blank? + raise Errors::NotFound, 'blank .find param' + end + case args when Hash - where(args) + scope = where(args) else - @primary_key = args + scope = _new_scope( primary_key: args ) end - klass.requestor.get(params) + scope._fetch end def method_missing(method_name, *args, &block) to_a.send(method_name, *args, &block) end + def hash + [ + klass, + params + ].hash + end + + def ==(other) + return false unless other.is_a?(self.class) + + hash == other.hash + end + alias_method :eql?, :== + + protected + + def _fetch + klass.requestor.get(params) + end + private + def _new_scope( opts = {} ) + self.class.new( @klass, + primary_key: opts.fetch( :primary_key, @primary_key ), + pagination_params: @pagination_params.merge( opts.fetch( :pagination_params, {} ) ), + path_params: @path_params.merge( opts.fetch( :path_params, {} ) ), + additional_params: @additional_params.merge( opts.fetch( :additional_params, {} ) ), + filters: @filters.merge( opts.fetch( :filters, {} ) ), + includes: @includes + opts.fetch( :includes, [] ), + orders: @orders + opts.fetch( :orders, [] ), + fields: @fields + opts.fetch( :fields, [] ) ) + end + def path_params @path_params.empty? ? {} : {path: @path_params} end @@ -123,7 +160,13 @@ def primary_key_params end def pagination_params - @pagination_params.empty? ? {} : {page: @pagination_params} + if klass.paginator.ancestors.include?(Paginating::Paginator) + # Original Paginator inconsistently wraps pagination params here. Keeping + # default behavior for now so as not to break backward compatibility. + @pagination_params.empty? ? {} : {page: @pagination_params} + else + @pagination_params + end end def includes_params @@ -159,22 +202,7 @@ def select_params end def parse_related_links(*tables) - tables.map do |table| - case table - when Hash - table.map do |k, v| - parse_related_links(*v).map do |sub| - "#{k}.#{sub}" - end - end - when Array - table.map do |v| - parse_related_links(*v) - end - else - key_formatter.format(table) - end - end.flatten + Utils.parse_includes(klass, *tables) end def parse_orders(*args) diff --git a/lib/json_api_client/query/requestor.rb b/lib/json_api_client/query/requestor.rb index 522561a9..d7f32c93 100644 --- a/lib/json_api_client/query/requestor.rb +++ b/lib/json_api_client/query/requestor.rb @@ -10,37 +10,44 @@ def initialize(klass) # expects a record def create(record) - request(:post, klass.path(record.attributes), { - data: record.as_json_api - }) + request( + :post, + klass.path(record.path_attributes), + body: { data: record.as_json_api }, + params: record.request_params.to_params + ) end def update(record) - request(:patch, resource_path(record.attributes), { - data: record.as_json_api - }) + request( + :patch, + resource_path(record.path_attributes), + body: { data: record.as_json_api }, + params: record.request_params.to_params + ) end def get(params = {}) path = resource_path(params) params.delete(klass.primary_key) - request(:get, path, params) + request(:get, path, params: params) end def destroy(record) - request(:delete, resource_path(record.attributes), {}) + request(:delete, resource_path(record.path_attributes)) end def linked(path) - request(:get, path, {}) + request(:get, path) end def custom(method_name, options, params) path = resource_path(params) params.delete(klass.primary_key) path = File.join(path, method_name.to_s) - - request(options.fetch(:request_method, :get), path, params) + request_method = options.fetch(:request_method, :get).to_sym + query_params, body_params = [:get, :delete].include?(request_method) ? [params, nil] : [nil, params] + request(request_method, path, params: query_params, body: body_params) end protected @@ -56,8 +63,9 @@ def resource_path(parameters) end end - def request(type, path, params) - klass.parser.parse(klass, connection.run(type, path, params, klass.custom_headers)) + def request(type, path, params: nil, body: nil) + response = connection.run(type, path, params: params, body: body, headers: klass.custom_headers) + klass.parser.parse(klass, response) end end diff --git a/lib/json_api_client/request_params.rb b/lib/json_api_client/request_params.rb new file mode 100644 index 00000000..301c7d85 --- /dev/null +++ b/lib/json_api_client/request_params.rb @@ -0,0 +1,57 @@ +module JsonApiClient + class RequestParams + attr_reader :klass, :includes, :fields + + def initialize(klass, includes: [], fields: {}) + @klass = klass + @includes = includes + @fields = fields + end + + def add_includes(includes) + Utils.parse_includes(klass, *includes).each do |name| + name = name.to_sym + self.includes.push(name) unless self.includes.include?(name) + end + end + + def reset_includes! + @includes = [] + end + + def set_fields(type, field_names) + self.fields[type.to_sym] = field_names.map(&:to_sym) + end + + def remove_fields(type) + self.fields.delete(type.to_sym) + end + + def field_types + self.fields.keys + end + + def clear + reset_includes! + @fields = {} + end + + def to_params + return nil if field_types.empty? && includes.empty? + parsed_fields.merge(parsed_includes) + end + + private + + def parsed_includes + return {} if includes.empty? + {include: includes.join(",")} + end + + def parsed_fields + return {} if field_types.empty? + {fields: fields.map { |type, names| [type, names.join(",")] }.to_h} + end + + end +end diff --git a/lib/json_api_client/resource.rb b/lib/json_api_client/resource.rb index f2a8d29c..0665c996 100644 --- a/lib/json_api_client/resource.rb +++ b/lib/json_api_client/resource.rb @@ -12,10 +12,12 @@ class Resource include Helpers::DynamicAttributes include Helpers::Dirty + include Helpers::Associatable attr_accessor :last_result_set, :links, - :relationships + :relationships, + :request_params class_attribute :site, :primary_key, :parser, @@ -28,10 +30,21 @@ class Resource :relationship_linker, :read_only_attributes, :requestor_class, - :associations, :json_key_format, :route_format, + :request_params_class, + :keep_request_params, + :search_included_in_result_set, + :custom_type_to_class, + :raise_on_blank_find_param, instance_accessor: false + class_attribute :add_defaults_to_changes, + instance_writer: false + + class_attribute :_immutable, + instance_writer: false, + default: false + self.primary_key = :id self.parser = Parsers::Parser self.paginator = Paginating::Paginator @@ -42,7 +55,12 @@ class Resource self.relationship_linker = Relationships::Relations self.read_only_attributes = [:id, :type, :links, :meta, :relationships] self.requestor_class = Query::Requestor - self.associations = [] + self.request_params_class = RequestParams + self.keep_request_params = false + self.add_defaults_to_changes = false + self.search_included_in_result_set = false + self.custom_type_to_class = {} + self.raise_on_blank_find_param = false #:underscored_key, :camelized_key, :dasherized_key, or custom self.json_key_format = :underscored_key @@ -50,14 +68,15 @@ class Resource #:underscored_route, :camelized_route, :dasherized_route, or custom self.route_format = :underscored_route - include Associations::BelongsTo - include Associations::HasMany - include Associations::HasOne - class << self extend Forwardable def_delegators :_new_scope, :where, :order, :includes, :select, :all, :paginate, :page, :with_params, :first, :find, :last + def resolve_custom_type(type_name, class_name) + classified_type = key_formatter.unformat(type_name.to_s).singularize.classify + self.custom_type_to_class = custom_type_to_class.merge(classified_type => class_name.to_s) + end + # The table name for this resource. i.e. Article -> articles, Person -> people # # @return [String] The table name for this resource @@ -80,6 +99,19 @@ def type table_name end + # Indicates whether this resource is mutable or immutable; + # by default, all resources are mutable. + # + # @return [Boolean] + def immutable(flag = true) + self._immutable = flag + end + + def inherited(subclass) + subclass._immutable = false + super + end + # Specifies the relative path that should be used for this resource; # by default, this is inferred from the resource class name. # @@ -95,6 +127,7 @@ def load(params) new(params).tap do |resource| resource.mark_as_persisted! resource.clear_changes_information + resource.relationships.clear_changes_information end end @@ -206,6 +239,11 @@ def route_formatter # @option [Symbol] :on One of [:collection or :member] to decide whether it's a collect or member method # @option [Symbol] :request_method The request method (:get, :post, etc) def custom_endpoint(name, options = {}) + if _immutable + request_method = options.fetch(:request_method, :get).to_sym + raise JsonApiClient::Errors::ResourceImmutableError if request_method != :get + end + if :collection == options.delete(:on) collection_endpoint(name, options) else @@ -251,6 +289,12 @@ def member_endpoint(name, options = {}) # @option options [Symbol] :default The default value for the property def property(name, options = {}) schema.add(name, options) + define_method(name) do + attributes[name] + end + define_method("#{name}=") do |value| + set_attribute(name, value) + end end # Declare multiple properties with the same optional options @@ -312,19 +356,21 @@ def _build_connection(rebuild = false) def initialize(params = {}) params = params.with_indifferent_access @persisted = nil - self.links = self.class.linker.new(params.delete("links") || {}) - self.relationships = self.class.relationship_linker.new(self.class, params.delete("relationships") || {}) - self.attributes = self.class.default_attributes.merge(params) + @destroyed = nil + self.links = self.class.linker.new(params.delete(:links) || {}) + self.relationships = self.class.relationship_linker.new(self.class, params.delete(:relationships) || {}) + self.attributes = self.class.default_attributes.merge params.except(*self.class.prefix_params) + self.forget_change!(:type) + self.__belongs_to_params = params.slice(*self.class.prefix_params) - self.class.schema.each_property do |property| - attributes[property.name] = property.default unless attributes.has_key?(property.name) || property.default.nil? - end + setup_default_properties self.class.associations.each do |association| if params.has_key?(association.attr_name.to_s) set_attribute(association.attr_name, params[association.attr_name.to_s]) end end + self.request_params = self.class.request_params_class.new(self.class) end # Set the current attributes and try to save them @@ -362,14 +408,26 @@ def mark_as_persisted! # # @return [Boolean] def persisted? - !!@persisted && has_attribute?(self.class.primary_key) + !!@persisted && !destroyed? && has_attribute?(self.class.primary_key) + end + + # Mark the record as destroyed + def mark_as_destroyed! + @destroyed = true + end + + # Whether or not this record has been destroyed to the database previously + # + # @return [Boolean] + def destroyed? + !!@destroyed end # Returns true if this is a new record (never persisted to the database) # # @return [Boolean] def new_record? - !persisted? + !persisted? && !destroyed? end # When we represent this resource as a relationship, we do so with id & type @@ -384,7 +442,7 @@ def as_relation # # @return [Hash] Representation of this object as JSONAPI object def as_json_api(*) - attributes.slice(:id, :type).tap do |h| + attributes.slice(self.class.primary_key, :type).tap do |h| relationships_for_serialization.tap do |r| h[:relationships] = self.class.key_formatter.format_keys(r) unless r.empty? end @@ -393,11 +451,11 @@ def as_json_api(*) end def as_json(*) - attributes.slice(:id, :type).tap do |h| + attributes.slice(self.class.primary_key, :type).tap do |h| relationships.as_json.tap do |r| h[:relationships] = r unless r.empty? end - h[:attributes] = attributes.except(:id, :type).as_json + h[:attributes] = attributes.except(self.class.primary_key, :type).as_json end end @@ -420,6 +478,7 @@ def valid?(context = nil) # @return [Boolean] Whether or not the save succeeded def save return false unless valid? + raise JsonApiClient::Errors::ResourceImmutableError if _immutable self.last_result_set = if persisted? self.class.requestor.update(self) @@ -432,12 +491,15 @@ def save false else self.errors.clear if self.errors + self.request_params.clear unless self.class.keep_request_params mark_as_persisted! if updated = last_result_set.first self.attributes = updated.attributes self.links.attributes = updated.links.attributes self.relationships.attributes = updated.relationships.attributes clear_changes_information + self.relationships.clear_changes_information + _clear_cached_relationships end true end @@ -447,12 +509,16 @@ def save # # @return [Boolean] Whether or not the destroy succeeded def destroy + raise JsonApiClient::Errors::ResourceImmutableError if _immutable + self.last_result_set = self.class.requestor.destroy(self) if last_result_set.has_errors? fill_errors false else - self.attributes.clear + mark_as_destroyed! + _clear_cached_relationships + _clear_belongs_to_params true end end @@ -461,27 +527,83 @@ def inspect "#<#{self.class.name}:@attributes=#{attributes.inspect}>" end - protected + def request_includes(*includes) + self.request_params.add_includes(includes) + self + end - def method_missing(method, *args) - association = association_for(method) + def reset_request_includes! + self.request_params.reset_includes! + self + end - return super unless association || (relationships && relationships.has_attribute?(method)) + def request_select(*fields) + fields_by_type = fields.extract_options! + fields_by_type[type.to_sym] = fields if fields.any? + fields_by_type.each do |field_type, field_names| + self.request_params.set_fields(field_type, field_names) + end + self + end - return nil unless relationship_definitions = relationships[method] + def reset_request_select!(*resource_types) + resource_types = self.request_params.field_types if resource_types.empty? + resource_types.each { |resource_type| self.request_params.remove_fields(resource_type) } + self + end - # look in included data - if relationship_definitions.key?("data") - return last_result_set.included.data_for(method, relationship_definitions) + def path_attributes + _belongs_to_params.merge attributes.slice( self.class.primary_key ).with_indifferent_access + end + + protected + + def setup_default_properties + self.class.schema.each_property do |property| + unless attributes.has_key?(property.name) || property.default.nil? + attribute_will_change!(property.name) if add_defaults_to_changes + attributes[property.name] = property.default + end end + end + + def relationship_definition_for(name) + relationships[name] if relationships && relationships.has_attribute?(name) + end - if association = association_for(method) - # look for a defined relationship url - if relationship_definitions["links"] && url = relationship_definitions["links"]["related"] - return association.data(url) + def included_data_for(name, relationship_definition) + last_result_set.included.data_for(name, relationship_definition) + end + + def relationship_data_for(name, relationship_definition) + # look in included data + if relationship_definition.key?("data") + if relationships.attribute_changed?(name) + return relation_objects_for(name, relationship_definition) + else + return included_data_for(name, relationship_definition) end end - nil + + return unless links = relationship_definition["links"] + return unless url = links["related"] + + association_for(name).data(url) + end + + def relation_objects_for(name, relationship_definition) + data = relationship_definition["data"] + assoc = association_for(name) + return if data.nil? || assoc.nil? + assoc.load_records(data) + end + + def method_missing(method, *args) + relationship_definition = relationship_definition_for(method) + + return super unless relationship_definition + + relationship_data_for(method, relationship_definition) end def respond_to_missing?(symbol, include_all = false) @@ -510,18 +632,26 @@ def association_for(name) end end + def non_serializing_attributes + self.class.read_only_attributes + end + def attributes_for_serialization - attributes.except(*self.class.read_only_attributes).slice(*changed) + attributes.except(*non_serializing_attributes).slice(*changed) end def relationships_for_serialization relationships.as_json_api end + def error_message_for(error) + error.error_msg + end + def fill_errors last_result_set.errors.each do |error| key = self.class.key_formatter.unformat(error.error_key) - errors.add(key, error.detail) + errors.add(key, error_message_for(error)) end end end diff --git a/lib/json_api_client/schema.rb b/lib/json_api_client/schema.rb index 3f157a00..db1860ed 100644 --- a/lib/json_api_client/schema.rb +++ b/lib/json_api_client/schema.rb @@ -67,11 +67,11 @@ class TypeFactory # end # end # - # JsonApiClient::Schema::Types.register money: MyMoneyCaster + # JsonApiClient::Schema::TypeFactory.register money: MyMoneyCaster # # You can setup several at once: # - # JsonApiClient::Schema::Types.register money: MyMoneyCaster, + # JsonApiClient::Schema::TypeFactory.register money: MyMoneyCaster, # date: MyJsonDateTypeCaster # # diff --git a/lib/json_api_client/utils.rb b/lib/json_api_client/utils.rb index 7c36ab43..e91e566b 100644 --- a/lib/json_api_client/utils.rb +++ b/lib/json_api_client/utils.rb @@ -2,6 +2,7 @@ module JsonApiClient module Utils def self.compute_type(klass, type_name) + return klass.custom_type_to_class.fetch(type_name).constantize if klass.custom_type_to_class.key?(type_name) # If the type is prefixed with a scope operator then we assume that # the type_name is an absolute reference. return type_name.constantize if type_name.match(/^::/) @@ -24,5 +25,24 @@ def self.compute_type(klass, type_name) raise NameError, "uninitialized constant #{candidates.first}" end + def self.parse_includes(klass, *tables) + tables.map do |table| + case table + when Hash + table.map do |k, v| + parse_includes(klass, *v).map do |sub| + "#{k}.#{sub}" + end + end + when Array + table.map do |v| + parse_includes(klass, *v) + end + else + klass.key_formatter.format(table) + end + end.flatten + end + end -end \ No newline at end of file +end diff --git a/lib/json_api_client/version.rb b/lib/json_api_client/version.rb index ba5e9409..0af9cf38 100644 --- a/lib/json_api_client/version.rb +++ b/lib/json_api_client/version.rb @@ -1,3 +1,3 @@ module JsonApiClient - VERSION = "2.1.0" + VERSION = "2.21.0" # upstream version is 1.21.0 end diff --git a/test/test_helper.rb b/test/test_helper.rb index b580ca38..5f7b6446 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -5,6 +5,7 @@ require 'webmock/minitest' require 'mocha/minitest' require 'pp' +require 'pry' # shim for ActiveSupport 4.0.x requiring minitest 4.2 unless defined?(Minitest::Test) @@ -22,6 +23,12 @@ class Article < TestResource has_one :author end +class ArticleNested < TestResource + belongs_to :author, shallow_path: true + has_many :comments + has_one :author +end + class Person < TestResource end @@ -31,10 +38,49 @@ class Comment < TestResource class User < TestResource end +class ApiBadRequestHandler + def self.call(_env) + # do not raise exception + end +end + +class CustomUnauthorizedError < StandardError + attr_reader :env + + def initialize(env) + @env = env + super('not authorized') + end +end + +class UserWithCustomStatusHandler < TestResource + self.connection_options = { + status_handlers: { + 400 => ApiBadRequestHandler, + 401 => ->(env) { raise CustomUnauthorizedError, env } + } + } +end + class UserPreference < TestResource self.primary_key = :user_id end +class DocumentUser < TestResource + resolve_custom_type 'document--files', 'DocumentFile' +end + +class DocumentStore < TestResource + resolve_custom_type 'document--files', 'DocumentFile' + has_many :files, class_name: 'DocumentFile' +end + +class DocumentFile < TestResource + def self.resource_name + 'document--files' + end +end + def with_altered_config(resource_class, changes) # remember and overwrite config old_config_values = {} diff --git a/test/unit/association_test.rb b/test/unit/association_test.rb index f31ece36..2c01b1e2 100644 --- a/test/unit/association_test.rb +++ b/test/unit/association_test.rb @@ -13,6 +13,10 @@ class Specified < TestResource has_many :bars, class_name: "Owner" end +class Shallowed < TestResource + belongs_to :foo, class_name: "Property", shallow_path: true +end + class PrefixedOwner < TestResource has_many :prefixed_properties end @@ -46,12 +50,104 @@ class MultiWordParent < Formatted class MultiWordChild < Formatted belongs_to :multi_word_parent + self.read_only_attributes = read_only_attributes + [:multi_word_parent_id] + + def self.key_formatter + JsonApiClient::DasherizedKeyFormatter + end + + def self.route_formatter + JsonApiClient::UnderscoredKeyFormatter + end +end + +class DashedOwner < Formatted +end + +class DashedProperty < Formatted + has_one :dashed_owner +end + +class DashedRegion < Formatted + has_many :dashed_properties +end + +class Account < TestResource + property :name + property :is_active, default: true + property :balance +end + +class UserAccount < TestResource + self.add_defaults_to_changes = true + property :name + property :is_active, default: true + property :balance +end + +class Employee < TestResource + has_one :chief, klass: 'Employee' end class AssociationTest < MiniTest::Test + def test_default_properties_no_changes + stub_request(:post, 'http://example.com/accounts'). + with(headers: { content_type: 'application/vnd.api+json', accept: 'application/vnd.api+json' }, body: { + data: { + type: 'accounts', + attributes: { + name: 'foo' + } + } + }.to_json) + .to_return(headers: { content_type: 'application/vnd.api+json' }, body: { + data: { + id: '1', + type: 'accounts', + attributes: { + name: 'foo', + is_active: false, + balance: '0.0' + } + } + }.to_json) + record = Account.new(name: 'foo') + assert record.save + assert_equal(false, record.is_active) + assert_equal('0.0', record.balance) + end + + def test_default_properties_changes + stub_request(:post, 'http://example.com/user_accounts'). + with(headers: { content_type: 'application/vnd.api+json', accept: 'application/vnd.api+json' }, body: { + data: { + type: 'user_accounts', + attributes: { + name: 'foo', + is_active: true + } + } + }.to_json) + .to_return(headers: { content_type: 'application/vnd.api+json' }, body: { + data: { + id: '1', + type: 'user_accounts', + attributes: { + name: 'foo', + is_active: true, + balance: '0.0' + } + } + }.to_json) + record = UserAccount.new(name: 'foo') + assert record.save + assert_equal(true, record.is_active) + assert_equal('0.0', record.balance) + end + def test_belongs_to_urls_are_formatted - request = stub_request(:get, "http://example.com/multi-word-parents/1/multi-word-children") + request = stub_request(:get, "http://example.com/multi_word_parents/1/multi_word_children") .to_return(headers: {content_type: "application/vnd.api+json"}, body: { data: [] }.to_json) MultiWordChild.where(multi_word_parent_id: 1).to_a @@ -59,6 +155,34 @@ def test_belongs_to_urls_are_formatted assert_requested(request) end + def test_belongs_to_urls_create_record + stub_request(:post, 'http://example.com/multi_word_parents/1/multi_word_children'). + with(headers: { content_type: 'application/vnd.api+json', accept: 'application/vnd.api+json' }, body: { + data: { + type: 'multi_word_children', + attributes: { + foo: 'bar', + 'multi-word-field': true + } + } + }.to_json) + .to_return(headers: { content_type: 'application/vnd.api+json' }, body: { + data: { + id: '2', + type: 'multi_word_children', + attributes: { + foo: 'bar', + 'multi-word-field': true + } + } + }.to_json) + + record = MultiWordChild.new(multi_word_parent_id: 1, foo: 'bar', multi_word_field: true) + result = record.save + assert result + assert_equal('2', record.id) + end + def test_load_has_one stub_request(:get, "http://example.com/properties/1") .to_return(headers: {content_type: "application/vnd.api+json"}, body: { @@ -143,6 +267,26 @@ def test_has_one_loads_nil assert_nil property.owner, "expected to be able to ask for explicitly declared association even if it's not present" end + def test_load_has_one_with_dasherized_key_type + stub_request(:get, "http://example.com/dashed-owners/1") + .to_return(headers: {content_type: "application/vnd.api+json"}, body: { + data: [ + { + id: 1, + type: 'dashed-owners', + attributes: { + name: "Arjuna" + } + } + ], + }.to_json) + dashed_owner = DashedOwner.find(1).first + dashed_property = DashedProperty.new(dashed_owner: dashed_owner) + + assert_equal(DashedOwner, dashed_property.dashed_owner.class) + assert_equal(1, dashed_property.dashed_owner.id) + end + def test_has_one_fetches_relationship stub_request(:get, "http://example.com/properties/1") .to_return(headers: {content_type: "application/vnd.api+json"}, body: { @@ -441,6 +585,27 @@ def test_load_has_many_single_entry assert_equal("123 Main St.", owner.properties.first.address) end + def test_load_has_many_with_dasherized_key_type + stub_request(:get, "http://example.com/dashed-properties") + .to_return(headers: {content_type: "application/vnd.api+json"}, body: { + data: [ + { + id: 1, + type: 'dashed-properties', + attributes: { + address: "78 Street No. 9, Ludhiana" + } + } + ], + }.to_json) + + dashed_properties = DashedProperty.all + dashed_region = DashedRegion.new(dashed_properties: dashed_properties) + + assert_equal(1, dashed_region.dashed_properties.count) + assert_equal(DashedProperty, dashed_region.dashed_properties[0].class) + end + def test_respect_included_has_many_relationship_empty_data stub_request(:get, "http://example.com/owners/1?include=properties") .to_return(headers: {content_type: "application/vnd.api+json"}, body: { @@ -525,6 +690,14 @@ def test_belongs_to_path assert_equal("foos/%D0%99%D0%A6%D0%A3%D0%9A%D0%95%D0%9D/specifieds", Specified.path({foo_id: 'ЙЦУКЕН'})) end + def test_belongs_to_shallowed_path + assert_equal([:foo_id], Shallowed.prefix_params) + assert_equal "shalloweds", Shallowed.path({}) + assert_equal("foos/%{foo_id}/shalloweds", Shallowed.path) + assert_equal("foos/1/shalloweds", Shallowed.path({foo_id: 1})) + assert_equal("foos/%D0%99%D0%A6%D0%A3%D0%9A%D0%95%D0%9D/shalloweds", Shallowed.path({foo_id: 'ЙЦУКЕН'})) + end + def test_find_belongs_to stub_request(:get, "http://example.com/foos/1/specifieds") .to_return(headers: {content_type: "application/vnd.api+json"}, body: { @@ -537,6 +710,30 @@ def test_find_belongs_to assert_equal(1, specifieds.length) end + def test_find_belongs_to_shallowed + stub_request(:get, "http://example.com/foos/1/shalloweds") + .to_return(headers: {content_type: "application/vnd.api+json"}, body: { + data: [ + { id: 1, type: "shalloweds", attributes: { name: "nested" } } + ] + }.to_json) + + stub_request(:get, "http://example.com/shalloweds") + .to_return(headers: {content_type: "application/vnd.api+json"}, body: { + data: [ + { id: 1, type: "shalloweds", attributes: { name: "global" } } + ] + }.to_json) + + nested_records = Shallowed.where(foo_id: 1).all + assert_equal(1, nested_records.length) + assert_equal("nested", nested_records.first.name) + + global_records = Shallowed.all + assert_equal(1, global_records.length) + assert_equal("global", global_records.first.name) + end + def test_can_handle_creating stub_request(:post, "http://example.com/foos/10/specifieds") .to_return(headers: {content_type: "application/vnd.api+json"}, body: { @@ -552,6 +749,28 @@ def test_can_handle_creating }) end + def test_can_handle_creating_shallowed + stub_request(:post, "http://example.com/foos/10/shalloweds") + .to_return(headers: {content_type: "application/vnd.api+json"}, body: { + data: { id: 12, type: "shalloweds", attributes: { name: "nested" } } + }.to_json) + + stub_request(:post, "http://example.com/shalloweds") + .to_return(headers: {content_type: "application/vnd.api+json"}, body: { + data: { id: 13, type: "shalloweds", attributes: { name: "global" } } + }.to_json) + + Shallowed.create({ + :id => 12, + :foo_id => 10, + :name => "nested" + }) + Shallowed.create({ + :id => 13, + :name => "global" + }) + end + def test_find_belongs_to_params_unchanged stub_request(:get, "http://example.com/foos/1/specifieds") .to_return(headers: { @@ -587,4 +806,268 @@ def test_nested_create Specified.create(foo_id: 1) end + def test_nested_create_from_scope + stub_request(:post, "http://example.com/foos/1/specifieds") + .to_return(headers: { + content_type: "application/vnd.api+json" + }, body: { + data: { + id: 1, + name: "Jeff Ching", + bars: [{id: 1, attributes: {address: "123 Main St."}}] + } + }.to_json) + + Specified.where(foo_id: 1).create + end + + def test_get_with_relationship_for_model_with_custom_type + stub_request(:get, "http://example.com/document_users/1?include=file") + .to_return(headers: {content_type: "application/vnd.api+json"}, body: { + data: [ + { + id: '1', + type: 'document_users', + attributes: { + name: 'John Doe' + }, + relationships: { + file: { + links: { + self: 'http://example.com/document_users/1/relationships/file', + related: 'http://example.com/document_users/1/file' + }, + data: { + id: '2', + type: 'document--files' + } + } + } + } + ], + included: [ + { + id: '2', + type: 'document--files', + attributes: { + url: 'http://example.com/downloads/2.pdf' + } + } + ] + }.to_json) + + user = DocumentUser.includes('file').find(1).first + + assert_equal 'document--files', user.file.type + assert user.file.is_a?(DocumentFile) + end + + def test_get_with_defined_relationship_for_model_with_custom_type + stub_request(:get, "http://example.com/document_stores/1?include=files") + .to_return(headers: {content_type: "application/vnd.api+json"}, body: { + data: [ + { + id: '1', + type: 'document_stores', + attributes: { + name: 'store #1' + }, + relationships: { + files: { + links: { + self: 'http://example.com/document_stores/1/relationships/files', + related: 'http://example.com/document_stores/1/files' + }, + data: [ + { + id: '2', + type: 'document--files' + } + ] + } + } + } + ], + included: [ + { + id: '2', + type: 'document--files', + attributes: { + url: 'http://example.com/downloads/2.pdf' + } + } + ] + }.to_json) + + user = DocumentStore.includes('files').find(1).first + + assert_equal 1, user.files.size + assert_equal 'document--files', user.files.first.type + assert user.files.first.is_a?(DocumentFile) + end + + def test_get_with_type_attribute + stub_request(:get, "http://example.com/document_users/1?include=file") + .to_return(headers: {content_type: "application/vnd.api+json"}, body: { + data: [ + { + id: '1', + type: 'document_users', + attributes: { + name: 'John Doe' + }, + relationships: { + file: { + links: { + self: 'http://example.com/document_users/1/relationships/file', + related: 'http://example.com/document_users/1/file' + }, + data: { + id: '2', + type: 'document--files' + } + } + } + } + ], + included: [ + { + id: '2', + type: 'document--files', + attributes: { + type: 'STIDocumentFile', + url: 'http://example.com/downloads/2.pdf' + } + } + ] + }.to_json) + + user = DocumentUser.includes('file').find(1).first + + assert_equal 'STIDocumentFile', user.file.type + assert user.file.is_a?(DocumentFile) + end + + def test_include_with_blank_relationships + stub_request(:get, "http://example.com/document_users/1?include=file") + .to_return(headers: {content_type: "application/vnd.api+json"}, body: { + data: [ + { + id: '1', + type: 'document_users', + attributes: { + name: 'John Doe' + }, + relationships: { + file: { + } + } + } + ], + }.to_json) + + user = DocumentUser.includes('file').find(1).first + assert_nil user.file + end + + def test_load_include_from_dataset + stub_request(:get, 'http://example.com/employees?include=chief&page[per_page]=2') + .to_return( + headers: { + content_type: 'application/vnd.api+json' + }, body: { + data: [ + { + id: '1', + type: 'employees', + attributes: { + name: 'John Doe' + }, + relationships: { + chief: { + data: {id: '2', type: 'employees'} + } + } + }, + { + id: '2', + attributes: { + name: 'Jane Doe' + }, + relationships: { + chief: { + data: {id: '3', type: 'employees'} + } + } + } + ], + included: [ + { + id: '3', + type: 'employees', + attributes: { + name: 'Richard Reed' + } + } + ] + }.to_json) + Employee.search_included_in_result_set = true + records = Employee.includes(:chief).per(2).to_a + assert_equal(2, records.size) + assert_equal('1', records.first.id) + assert_equal('2', records.second.id) + assert_equal('3', records.second.chief.id) + assert_equal('2', records.first.chief.id) + end + + def test_does_not_load_include_from_dataset + stub_request(:get, 'http://example.com/employees?include=chief&page[per_page]=2') + .to_return( + headers: { + content_type: 'application/vnd.api+json' + }, body: { + data: [ + { + id: '1', + type: 'employees', + attributes: { + name: 'John Doe' + }, + relationships: { + chief: { + data: {id: '2', type: 'employees'} + } + } + }, + { + id: '2', + attributes: { + name: 'Jane Doe' + }, + relationships: { + chief: { + data: {id: '3', type: 'employees'} + } + } + } + ], + included: [ + { + id: '3', + type: 'employees', + attributes: { + name: 'Richard Reed' + } + } + ] + }.to_json) + Employee.search_included_in_result_set = false + records = Employee.includes(:chief).per(2).to_a + assert_equal(2, records.size) + assert_equal('1', records.first.id) + assert_equal('2', records.second.id) + assert_equal('3', records.second.chief.id) + assert_nil(records.first.chief) + end + end diff --git a/test/unit/compound_non_included_document_test.rb b/test/unit/compound_non_included_document_test.rb new file mode 100644 index 00000000..853d52e1 --- /dev/null +++ b/test/unit/compound_non_included_document_test.rb @@ -0,0 +1,63 @@ +require 'test_helper' + +class CompoundNonIncludedDocumentTest < MiniTest::Test + + TEST_DATA = %{ + { + "links": { + "self": "http://example.com/posts", + "next": "http://example.com/posts?page[offset]=2", + "last": "http://example.com/posts?page[offset]=10" + }, + "data": [{ + "type": "posts", + "id": "1", + "attributes": { + "title": "JSON API paints my bikeshed!" + }, + "relationships": { + "author": { + "links": { + "self": "http://example.com/posts/1/relationships/author", + "related": "http://example.com/posts/1/author" + }, + "data": { "type": "people", "id": "9" } + }, + "comments": { + "links": { + "self": "http://example.com/posts/1/relationships/comments", + "related": "http://example.com/posts/1/comments" + }, + "data": [ + { "type": "comments", "id": "5" }, + { "type": "comments", "id": "12" } + ] + } + }, + "links": { + "self": "http://example.com/posts/1" + } + }] + } + } + + def test_can_handle_related_data_without_included + stub_request(:get, "http://example.com/articles") + .to_return(headers: {content_type: "application/vnd.api+json"}, body: TEST_DATA) + + articles = Article.all + + assert articles.is_a?(JsonApiClient::ResultSet) + assert_equal 1, articles.length + + article = articles.first + assert_equal "1", article.id + assert_equal "JSON API paints my bikeshed!", article.title + + # has_one is nil if not included + assert_nil article.author + + # has_many is empty if not included + assert_equal 0, article.comments.size + end +end diff --git a/test/unit/connection_test.rb b/test/unit/connection_test.rb index 2298a61a..7b029b4c 100644 --- a/test/unit/connection_test.rb +++ b/test/unit/connection_test.rb @@ -15,6 +15,9 @@ def self.parse(*args) end end +class RegularResource < TestResource +end + class CustomConnectionResource < TestResource self.connection_class = NullConnection self.parser = NullParser @@ -69,4 +72,40 @@ def test_can_specify_http_proxy assert_equal proxy.uri.to_s, 'http://proxy.example.com' end + def test_gzipping_without_server_support + stub_request(:get, "http://example.com/regular_resources") + .with(headers: {'Accept-Encoding'=>'gzip,deflate'}) + .to_return( + status: 200, + body: {data: [{id: "1", type: "regular_resources", attributes: {foo: "bar"}}]}.to_json, + headers: {content_type: "application/vnd.api+json"} + ) + + resources = RegularResource.all + assert_equal 1, resources.length + resource = resources.first + assert_equal "bar", resource.foo + end + + def test_gzipping_with_server_support + io = StringIO.new + gz = Zlib::GzipWriter.new(io) + gz.write({data: [{id: "1", type: "regular_resources", attributes: {foo: "bar"}}]}.to_json) + gz.close + body = io.string + body.force_encoding('BINARY') if body.respond_to?(:force_encoding) + + stub_request(:get, "http://example.com/regular_resources") + .with(headers: {'Accept-Encoding'=>'gzip,deflate'}) + .to_return( + status: 200, + body: body, + headers: {content_type: "application/vnd.api+json", content_encoding: 'gzip'} + ) + + resources = RegularResource.all + assert_equal 1, resources.length + resource = resources.first + assert_equal "bar", resource.foo + end end diff --git a/test/unit/creation_test.rb b/test/unit/creation_test.rb index 5a78fca7..00593ac4 100644 --- a/test/unit/creation_test.rb +++ b/test/unit/creation_test.rb @@ -18,8 +18,17 @@ def after_create_method class Author < TestResource end - def setup - super + class User < TestResource + has_one :skill_level + + properties :first_name, type: :string + end + + class SkillLevel < TestResource + property :title, type: :string + end + + def stub_simple_creation stub_request(:post, "http://example.com/articles") .with(headers: {content_type: "application/vnd.api+json", accept: "application/vnd.api+json"}, body: { data: { @@ -47,6 +56,7 @@ def setup end def test_can_create_with_class_method + stub_simple_creation article = Article.create({ title: "Rails is Omakase" }) @@ -56,7 +66,25 @@ def test_can_create_with_class_method assert_equal "Rails is Omakase", article.title end + def test_failed_create! + stub_request(:post, "http://example.com/users") + .to_return(headers: {content_type: "application/vnd.api+json"}, body: { + errors: [ + { + status: "400", + title: "Error" + } + ] + }.to_json) + + exception = assert_raises JsonApiClient::Errors::RecordNotSaved do + User.create!(name: 'Hans') + end + assert_equal "Record not saved", exception.message + end + def test_changed_attributes_empty_after_create_with_class_method + stub_simple_creation article = Article.create({ title: "Rails is Omakase" }) @@ -65,16 +93,82 @@ def test_changed_attributes_empty_after_create_with_class_method end def test_can_create_with_new_record_and_save + stub_simple_creation article = Article.new({ title: "Rails is Omakase" }) + assert article.new_record? assert article.save assert article.persisted? + assert_equal(false, article.new_record?) assert_equal "1", article.id end + def test_can_create_with_includes_and_fields + stub_request(:post, "http://example.com/articles") + .with( + headers: { content_type: "application/vnd.api+json", accept: "application/vnd.api+json" }, + query: { include: 'comments,author.comments', fields: { articles: 'title', authors: 'name' } }, + body: { + data: { + type: "articles", + attributes: { + title: "Rails is Omakase" + } + } + }.to_json + ).to_return( + headers: { content_type: "application/vnd.api+json" }, + body: { + data: { + type: "articles", + id: "1", + attributes: { + title: "Rails is Omakase" + }, + relationships: { + comments: { + data: [ + { + id: "2", + type: "comments" + } + ] + }, + author: { + data: nil + } + } + }, + included: [ + { + id: "2", + type: "comments", + attributes: { + body: "it is isn't it ?" + } + } + ] + }.to_json + ) + article = Article.new({ + title: "Rails is Omakase" + }) + article.request_includes(:comments, author: :comments). + request_select(:title, authors: [:name]) + + assert article.save + assert article.persisted? + assert_equal "1", article.id + assert_nil article.author + assert_equal 1, article.comments.size + assert_equal "2", article.comments.first.id + assert_equal "it is isn't it ?", article.comments.first.body + end + def test_can_create_with_links + stub_simple_creation article = Article.new({ title: "Rails is Omakase" }) @@ -129,7 +223,65 @@ def test_can_create_with_new_record_with_relationships_and_save assert article.persisted? assert_equal article.comments.length, 1 assert_equal "1", article.id + end + def test_can_create_with_new_record_with_associated_relationships_and_save + stub_request(:post, "http://example.com/articles") + .with(headers: {content_type: "application/vnd.api+json", accept: "application/vnd.api+json"}, body: { + data: { + type: "articles", + relationships: { + author: { + data: { + id: 1, + type: "authors" + } + } + }, + attributes: { + title: "Rails is Omakase" + } + } + }.to_json) + .to_return(headers: {content_type: "application/vnd.api+json"}, body: { + data: { + type: "articles", + id: "1", + attributes: { + title: "Rails is Omakase" + }, + relationships: { + author: { + data: [ + { + id: "1", + type: "comments" + } + ] + } + } + }, + included: [ + { + id: "1", + type: "authors", + } + ] + }.to_json) + + author_hash = { + author: { + data: { + id: 1, + type: 'authors' + } + } + } + article = Article.new({title: "Rails is Omakase", "relationships" => author_hash}) + + assert article.save + assert article.persisted? + assert_equal "1", article.id end def test_can_create_with_new_record_with_associated_relationships_and_save @@ -191,7 +343,8 @@ def test_can_create_with_new_record_with_associated_relationships_and_save assert_equal "1", article.id end - def test_correct_create_with_nil_attirbute_value + + def test_correct_create_with_nil_attribute_value stub_request(:post, "http://example.com/authors") .with(headers: { content_type: "application/vnd.api+json", @@ -229,6 +382,7 @@ def test_correct_create_with_nil_attirbute_value end def test_changed_attributes_empty_after_create_with_new_record_and_save + stub_simple_creation article = Article.new({title: "Rails is Omakase"}) article.save @@ -268,4 +422,104 @@ def test_callbacks_on_update assert_equal 100, callback_test.bar end + def test_create_with_relationships_in_payload + stub_request(:post, 'http://example.com/articles') + .with(headers: {content_type: 'application/vnd.api+json', accept: 'application/vnd.api+json'}, body: { + data: { + type: 'articles', + relationships: { + comments: { + data: [ + { + type: 'comments', + id: '2' + } + ] + } + }, + attributes: { + title: 'Rails is Omakase' + } + } + }.to_json) + .to_return(headers: {content_type: 'application/vnd.api+json'}, body: { + data: { + type: 'articles', + id: '1', + attributes: { + title: 'Rails is Omakase' + } + } + }.to_json) + + article = Article.new(title: 'Rails is Omakase', relationships: {comments: [Comment.new(id: '2')]}) + + assert article.save + assert article.persisted? + assert_equal "1", article.id + end + + def test_create_with_custom_type + stub_request(:post, 'http://example.com/document--files') + .with(headers: {content_type: 'application/vnd.api+json', accept: 'application/vnd.api+json'}, body: { + data: { + type: 'document--files', + attributes: { + url: 'http://example.com/downloads/1.pdf' + } + } + }.to_json) + .to_return(headers: {content_type: 'application/vnd.api+json'}, body: { + data: { + type: 'document--files', + id: '1', + attributes: { + url: 'http://example.com/downloads/1.pdf' + } + } + }.to_json) + + file = DocumentFile.new(url: 'http://example.com/downloads/1.pdf') + + assert file.save + assert file.persisted? + assert_equal '1', file.id + end + + def test_access_loaded_relationship_instance + stub_request(:get, 'http://example.com/skill_levels/1') + .to_return(headers: {content_type: 'application/vnd.api+json'}, body: { + data: { + type: 'skill_levels', + id: '1', + attributes: { + title: 'newbie' + } + } + }.to_json) + + stub_request(:get, 'http://example.com/skill_levels/2') + .to_return(headers: {content_type: 'application/vnd.api+json'}, body: { + data: { + type: 'skill_levels', + id: '2', + attributes: { + title: 'pro' + } + } + }.to_json) + + skill_level = SkillLevel.find(1).first + user = User.new(first_name: 'Joe', relationships: { skill_level: skill_level }) + + assert_equal ({'data'=>{'type'=>'skill_levels', 'id'=>'1'}}), user.relationships.skill_level + assert_kind_of SkillLevel, user.skill_level + assert_equal '1', user.skill_level.id + # test that object is cached and not recreated each time + assert_equal user.skill_level.object_id, user.skill_level.object_id + + user.skill_level = SkillLevel.find(2).first + assert_equal '2', user.skill_level.id + end + end diff --git a/test/unit/destroying_test.rb b/test/unit/destroying_test.rb index 5b2896aa..bc673a7e 100644 --- a/test/unit/destroying_test.rb +++ b/test/unit/destroying_test.rb @@ -11,14 +11,56 @@ class CallbackTest < TestResource end def test_destroy - stub_request(:delete, "http://example.com/users/6") + stub_request(:get, "http://example.com/users/1") + .to_return(headers: {content_type: "application/vnd.api+json"}, body: { + data: [ + {id: 1, attributes: {name: "Jeff Ching", email_address: "ching.jeff@gmail.com"}} + ] + }.to_json) + + users = User.find(1) + user = users.first + assert(user.persisted?) + assert_equal(false, user.new_record?) + assert_equal(false, user.destroyed?) + + stub_request(:delete, "http://example.com/users/1") .to_return(headers: {content_type: "application/vnd.api+json"}, body: { data: [] }.to_json) - user = User.new(id: 6) assert(user.destroy, "successful deletion should return truish value") assert_equal(false, user.persisted?) + assert_equal(false, user.new_record?) + assert(user.destroyed?) + assert_equal(1, user.id) + end + + def test_destroy_custom_primary_key + stub_request(:get, "http://example.com/user_preferences/105") + .to_return(headers: {content_type: "application/vnd.api+json"}, body: { + data: [ + {attributes: { user_id: 105, name: "Jeff Ching", email_address: "ching.jeff@gmail.com"}} + ] + }.to_json) + + $print_load = true + user_prefrence = UserPreference.find(105).first + assert(user_prefrence.persisted?) + $print_load = false + assert_equal(false, user_prefrence.new_record?) + assert_equal(false, user_prefrence.destroyed?) + + stub_request(:delete, "http://example.com/user_preferences/105") + .to_return(headers: {content_type: "application/vnd.api+json"}, body: { + data: [] + }.to_json) + + assert(user_prefrence.destroy, "successful deletion should return truish value") + assert_equal(false, user_prefrence.persisted?) + assert_equal(false, user_prefrence.new_record?) + assert(user_prefrence.destroyed?) + assert_equal(105, user_prefrence.user_id) end def test_destroy_no_content diff --git a/test/unit/error_collector_test.rb b/test/unit/error_collector_test.rb index 6db9f08d..ae166763 100644 --- a/test/unit/error_collector_test.rb +++ b/test/unit/error_collector_test.rb @@ -215,6 +215,48 @@ def test_can_handle_parameter_error assert_equal "include", error.source_parameter end + describe 'custom error_msg_key' do + class CustomErrorArticle < TestResource + def error_message_for(error) + error.detail + end + end + + def test_can_handle_custom_parameter_error + stub_request(:post, "http://example.com/custom_error_articles") + .with(headers: {content_type: "application/vnd.api+json", accept: "application/vnd.api+json"}, body: { + data: { + type: "custom_error_articles", + attributes: { + title: "Rails is Omakase", + email_address: "bar" + } + } + }.to_json) + .to_return(headers: {content_type: "application/vnd.api+json"}, body: { + errors: [ + { + id: "1234-abcd", + status: "400", + code: "1337", + title: "bar is required", + detail: "bar include is required for creation", + source: { + parameter: "include" + } + } + ] + }.to_json) + + article = CustomErrorArticle.create({ + title: "Rails is Omakase", + email_address: "bar" + }) + + assert_equal ["bar include is required for creation"], article.errors[:base] + end + end + def test_can_handle_explicit_null_error_values stub_request(:post, "http://example.com/articles") .with(headers: {content_type: "application/vnd.api+json", accept: "application/vnd.api+json"}, body: { diff --git a/test/unit/errors_test.rb b/test/unit/errors_test.rb index 3e8f2c32..6d533598 100644 --- a/test/unit/errors_test.rb +++ b/test/unit/errors_test.rb @@ -4,11 +4,13 @@ class ErrorsTest < MiniTest::Test def test_connection_errors stub_request(:get, "http://example.com/users") - .to_raise(Faraday::ConnectionFailed) + .to_raise(Faraday::ConnectionFailed.new("specific message")) - assert_raises JsonApiClient::Errors::ConnectionError do + err = assert_raises JsonApiClient::Errors::ConnectionError do User.all end + + assert_match(/specific message/, err.message) end def test_timeout_errors @@ -20,13 +22,42 @@ def test_timeout_errors end end - def test_500_errors + def test_internal_server_error_with_plain_text_response stub_request(:get, "http://example.com/users") .to_return(headers: {content_type: "text/plain"}, status: 500, body: "something went wrong") - assert_raises JsonApiClient::Errors::ServerError do - User.all - end + exception = assert_raises(JsonApiClient::Errors::InternalServerError) { User.all } + assert_equal '500 Internal Server Error', exception.message + end + + def test_internal_server_error_with_json_api_response + stub_request(:get, "http://example.com/users").to_return( + headers: {content_type: "application/vnd.api+json"}, + status: 500, + body: {errors: [{title: "Some special error"}]}.to_json + ) + + exception = assert_raises(JsonApiClient::Errors::InternalServerError) { User.all } + assert_equal '500 Internal Server Error (Some special error)', exception.message + end + + def test_500_errors_with_plain_text_response + stub_request(:get, "http://example.com/users") + .to_return(headers: {content_type: "text/plain"}, status: 503, body: "service unavailable") + + exception = assert_raises(JsonApiClient::Errors::ServerError) { User.all } + assert_equal '503 Service Unavailable', exception.message + end + + def test_500_errors_with_with_json_api_response + stub_request(:get, "http://example.com/users").to_return( + headers: {content_type: "application/vnd.api+json"}, + status: 503, + body: {errors: [{title: "Timeout error"}]}.to_json + ) + + exception = assert_raises(JsonApiClient::Errors::ServerError) { User.all } + assert_equal '503 Service Unavailable (Timeout error)', exception.message end def test_not_found @@ -65,6 +96,51 @@ def test_not_authorized end end + def test_request_timeout + stub_request(:get, "http://example.com/users") + .to_return(headers: {content_type: "text/plain"}, status: 408, body: "request timeout") + + assert_raises JsonApiClient::Errors::RequestTimeout do + User.all + end + end + + def test_too_many_requests + stub_request(:get, "http://example.com/users") + .to_return(headers: {content_type: "text/plain"}, status: 429, body: "too many requests") + + assert_raises JsonApiClient::Errors::TooManyRequests do + User.all + end + end + + def test_bad_gateway + stub_request(:get, "http://example.com/users") + .to_return(headers: {content_type: "text/plain"}, status: 502, body: "bad gateway") + + assert_raises JsonApiClient::Errors::BadGateway do + User.all + end + end + + def test_service_unavailable + stub_request(:get, "http://example.com/users") + .to_return(headers: {content_type: "text/plain"}, status: 503, body: "service unavailable") + + assert_raises JsonApiClient::Errors::ServiceUnavailable do + User.all + end + end + + def test_gateway_timeout + stub_request(:get, "http://example.com/users") + .to_return(headers: {content_type: "text/plain"}, status: 504, body: "gateway timeout") + + assert_raises JsonApiClient::Errors::GatewayTimeout do + User.all + end + end + def test_errors_are_rescuable_by_default_rescue begin raise JsonApiClient::Errors::ApiError, "Something bad happened" diff --git a/test/unit/finding_test.rb b/test/unit/finding_test.rb index e1e20c97..34675aab 100644 --- a/test/unit/finding_test.rb +++ b/test/unit/finding_test.rb @@ -77,4 +77,26 @@ def test_find_all assert_equal ["2", "3"], articles.map(&:id) end + def test_find_by_id_with_custom_type + stub_request(:get, "http://example.com/document--files/1") + .to_return(headers: {content_type: "application/vnd.api+json"}, body: { + data: { + type: "document--files", + id: "1", + attributes: { + url: 'http://example.com/downloads/1.pdf' + } + } + }.to_json) + + articles = DocumentFile.find(1) + + assert articles.is_a?(JsonApiClient::ResultSet) + assert_equal 1, articles.length + + article = articles.first + assert_equal '1', article.id + assert_equal 'http://example.com/downloads/1.pdf', article.url + end + end diff --git a/test/unit/nested_param_paginator_test.rb b/test/unit/nested_param_paginator_test.rb new file mode 100644 index 00000000..f6e788a5 --- /dev/null +++ b/test/unit/nested_param_paginator_test.rb @@ -0,0 +1,82 @@ +require 'test_helper' + +class NestedParamPaginatorTest < MiniTest::Test + + class Book < JsonApiClient::Resource + self.site = "http://example.com/" + end + + def setup + @nested_param_paginator = JsonApiClient::Paginating::NestedParamPaginator + @default_paginator = JsonApiClient::Paginating::Paginator + Article.paginator = @nested_param_paginator + end + + def teardown + @nested_param_paginator.page_param = @nested_param_paginator::DEFAULT_PAGE_PARAM + @nested_param_paginator.per_page_param = @nested_param_paginator::DEFAULT_PER_PAGE_PARAM + Article.paginator = @default_paginator + end + + def test_default_page_params_wrapped_consistently + assert_equal "page[page]", @nested_param_paginator.page_param + assert_equal "page[per_page]", @nested_param_paginator.per_page_param + end + + def test_custom_page_params_wrapped_consistently + @nested_param_paginator.page_param = "offset" + @nested_param_paginator.per_page_param = "limit" + assert_equal "page[offset]", @nested_param_paginator.page_param + assert_equal "page[limit]", @nested_param_paginator.per_page_param + end + + def test_custom_page_param_does_not_allow_double_wrap + assert_raises ArgumentError do + @nested_param_paginator.page_param = "page[number]" + end + end + + def test_custom_per_page_param_does_not_allow_double_wrap + assert_raises ArgumentError do + @nested_param_paginator.per_page_param = "page[size]" + end + end + + def test_pagination_params_total_calculations + @nested_param_paginator.page_param = "number" + @nested_param_paginator.per_page_param = "size" + stub_request(:get, "http://example.com/articles?page[number]=1&page[size]=2") + .to_return(headers: {content_type: "application/vnd.api+json"}, body: { + data: [{ + type: "articles", + id: "1", + attributes: { + title: "JSON API paints my bikeshed!" + } + }, + { + type: "articles", + id: "2", + attributes: { + title: "json_api_client counts pages correctly" + } + }], + links: { + self: "http://example.com/articles?page[number]=1&page[size]=2", + next: "http://example.com/articles?page[number]=2&page[size]=2", + prev: nil, + first: "http://example.com/articles?page[number]=1&page[size]=2", + last: "http://example.com/articles?page[number]=4&page[size]=2" + } + }.to_json) + + articles = Article.paginate(page: 1, per_page: 2).to_a + assert_equal 1, articles.current_page + assert_equal 4, articles.total_pages + assert_equal 8, articles.total_entries + ensure + @nested_param_paginator.page_param = "page" + @nested_param_paginator.per_page_param = "per_page" + end + +end diff --git a/test/unit/nested_param_paginator_top_level_links_test.rb b/test/unit/nested_param_paginator_top_level_links_test.rb new file mode 100644 index 00000000..faad42e6 --- /dev/null +++ b/test/unit/nested_param_paginator_top_level_links_test.rb @@ -0,0 +1,282 @@ +require 'test_helper' + +# Copied from TopLevelLinksTest and modified to have consistent +# pagination params using the new NestedParamPaginator +class NestedParamPaginatorTopLevelLinksTest < MiniTest::Test + + def setup + @nested_param_paginator = JsonApiClient::Paginating::NestedParamPaginator + @default_paginator = JsonApiClient::Paginating::Paginator + Article.paginator = @nested_param_paginator + end + + def teardown + @nested_param_paginator.page_param = @nested_param_paginator::DEFAULT_PAGE_PARAM + @nested_param_paginator.per_page_param = @nested_param_paginator::DEFAULT_PER_PAGE_PARAM + Article.paginator = @default_paginator + end + + def test_can_parse_global_links + stub_request(:get, "http://example.com/articles/1") + .to_return(headers: {content_type: "application/vnd.api+json"}, body: { + data: { + type: "articles", + id: "1", + attributes: { + title: "JSON API paints my bikeshed!" + } + }, + links: { + self: "http://example.com/articles/1", + related: "http://example.com/articles/1/related" + } + }.to_json) + stub_request(:get, "http://example.com/articles/1/related") + .to_return(headers: {content_type: "application/vnd.api+json"}, body: { + data: { + type: "article-image", + id: "14", + image: "http://foo.com/cat.png" + } + }.to_json) + + articles = Article.find(1) + links = articles.links + assert links + assert links.respond_to?(:related), "ResultSet links should respond to related" + + related = links.related + assert related.is_a?(JsonApiClient::ResultSet), "expected related link to return another ResultSet" + end + + def test_can_parse_pagination_links + stub_request(:get, "http://example.com/articles") + .to_return(headers: {content_type: "application/vnd.api+json"}, body: { + data: [{ + type: "articles", + id: "1", + attributes: { + title: "JSON API paints my bikeshed!" + } + }], + links: { + self: "http://example.com/articles", + next: "http://example.com/articles?page[page]=2", + prev: nil, + first: "http://example.com/articles", + last: "http://example.com/articles?page[page]=6" + } + }.to_json) + stub_request(:get, "http://example.com/articles?page[page]=2") + .to_return(headers: {content_type: "application/vnd.api+json"}, body: { + data: [{ + type: "articles", + id: "2", + attributes: { + title: "This is tha BOMB" + } + }], + links: { + self: "http://example.com/articles?page[page]=2", + next: "http://example.com/articles?page[page]=3", + prev: "http://example.com/articles", + first: "http://example.com/articles", + last: "http://example.com/articles?page[page]=6" + } + }.to_json) + + assert_pagination + end + + def test_can_parse_pagination_links_with_custom_config + @nested_param_paginator.page_param = "number" + + stub_request(:get, "http://example.com/articles") + .to_return(headers: {content_type: "application/vnd.api+json"}, body: { + data: [{ + type: "articles", + id: "1", + attributes: { + title: "JSON API paints my bikeshed!" + } + }], + links: { + self: "http://example.com/articles", + next: "http://example.com/articles?#{{page: {number: 2}}.to_query}", + prev: nil, + first: "http://example.com/articles", + last: "http://example.com/articles?#{{page: {number: 6}}.to_query}" + } + }.to_json) + stub_request(:get, "http://example.com/articles?#{{page: {number: 2}}.to_query}") + .to_return(headers: {content_type: "application/vnd.api+json"}, body: { + data: [{ + type: "articles", + id: "2", + attributes: { + title: "This is tha BOMB" + } + }], + links: { + self: "http://example.com/articles?#{{page: {number: 2}}.to_query}", + next: "http://example.com/articles?#{{page: {number: 3}}.to_query}", + prev: "http://example.com/articles", + first: "http://example.com/articles", + last: "http://example.com/articles?#{{page: {number: 6}}.to_query}" + } + }.to_json) + + assert_pagination + end + + def test_can_parse_pagination_links_when_no_next_page + stub_request(:get, "http://example.com/articles") + .to_return(headers: {content_type: "application/vnd.api+json"}, body: { + data: [{ + type: "articles", + id: "1", + attributes: { + title: "JSON API paints my bikeshed!" + } + }], + links: { + self: "http://example.com/articles", + prev: nil, + first: "http://example.com/articles", + last: "http://example.com/articles?page[page]=1" + } + }.to_json) + + assert_pagination_when_no_next_page + end + + def test_can_parse_complex_pagination_links + stub_request(:get, "http://example.com/articles") + .to_return(headers: {content_type: "application/vnd.api+json"}, body: { + data: [{ + type: "articles", + id: "1", + attributes: { + title: "JSON API paints my bikeshed!" + } + }], + links: { + self: { + href: "http://example.com/articles", + meta: {} + }, + next: { + href: "http://example.com/articles?page[page]=2", + meta: {} + }, + prev: nil, + first: { + href: "http://example.com/articles", + meta: {} + }, + last: { + href: "http://example.com/articles?page[page]=6", + meta: {} + } + } + }.to_json) + stub_request(:get, "http://example.com/articles?page[page]=2") + .to_return(headers: {content_type: "application/vnd.api+json"}, body: { + data: [{ + type: "articles", + id: "2", + attributes: { + title: "This is tha BOMB" + } + }], + links: { + self: { + href: "http://example.com/articles?page[page]=2", + meta: {} + }, + next: { + href: "http://example.com/articles?page[page]=3", + meta: {} + }, + prev: { + href: "http://example.com/articles", + meta: {} + }, + first: { + href: "http://example.com/articles", + meta: {} + }, + last: { + href: "http://example.com/articles?page[page]=6", + meta: {} + } + } + }.to_json) + + assert_pagination + end + + private + + def assert_pagination + articles = Article.all + + # test kaminari pagination params + assert_equal 1, articles.current_page + assert_equal 1, articles.per_page + assert_equal 6, articles.total_pages + assert_equal 0, articles.offset + assert_equal 6, articles.total_entries + assert_equal 1, articles.limit_value + assert_equal 2, articles.next_page + assert_equal 1, articles.per_page + assert_equal false, articles.out_of_bounds? + + # test browsing to next page + pages = articles.pages + assert pages.respond_to?(:next) + assert pages.respond_to?(:prev) + assert pages.respond_to?(:last) + assert pages.respond_to?(:first) + + page2 = articles.pages.next + assert page2.is_a?(JsonApiClient::ResultSet) + assert_equal 1, page2.length + article = page2.first + assert_equal "2", article.id + assert_equal "This is tha BOMB", article.title + + # test browsing to the previous page + page1 = page2.pages.prev + assert page1.is_a?(JsonApiClient::ResultSet) + assert_equal 1, page1.length + article = page1.first + assert_equal "1", article.id + assert_equal "JSON API paints my bikeshed!", article.title + end + + def assert_pagination_when_no_next_page + articles = Article.all + + # test kaminari pagination params + assert_equal 1, articles.current_page + assert_equal 1, articles.per_page + assert_equal 1, articles.total_pages + assert_equal 0, articles.offset + assert_equal 1, articles.total_entries + assert_equal 1, articles.limit_value + assert_nil articles.next_page + assert_equal 1, articles.per_page + assert_equal false, articles.out_of_bounds? + + # test browsing to next page + pages = articles.pages + assert pages.respond_to?(:next) + assert pages.respond_to?(:prev) + assert pages.respond_to?(:last) + assert pages.respond_to?(:first) + + page2 = articles.pages.next + assert page2.nil? + end +end diff --git a/test/unit/query_builder_test.rb b/test/unit/query_builder_test.rb index c37ca9c9..b72d7711 100644 --- a/test/unit/query_builder_test.rb +++ b/test/unit/query_builder_test.rb @@ -38,6 +38,32 @@ def test_can_paginate Article.paginate(page: 3, per_page: 6).to_a end + def test_pagination_default_number + JsonApiClient::Paginating::Paginator.page_param = :number + stub_request(:get, "http://example.com/articles?#{{page: {number: 1}}.to_query}") + .to_return(headers: {content_type: "application/vnd.api+json"}, body: { + data: [{ + type: "articles", + id: "1", + attributes: { + title: "JSON API paints my bikeshed!" + } + }], + links: { + self: "http://example.com/articles?#{{page: {number: 1}}.to_query}", + next: "http://example.com/articles?#{{page: {number: 2}}.to_query}", + prev: nil, + first: "http://example.com/articles?#{{page: {number: 1}}.to_query}", + last: "http://example.com/articles?#{{page: {number: 6}}.to_query}" + } + }.to_json) + + articles = Article.page(nil) + assert_equal 1, articles.current_page + ensure + JsonApiClient::Paginating::Paginator.page_param = :page + end + def test_can_sort_asc stub_request(:get, "http://example.com/articles") .with(query: {sort: "foo"}) @@ -169,4 +195,143 @@ def test_can_select_nested_fields_using_comma_separated_strings Article.select({comments: 'author,text'}, :tags).to_a end + def test_can_specify_array_filter_value + stub_request(:get, "http://example.com/articles?filter%5Bauthor.id%5D%5B0%5D=foo&filter%5Bauthor.id%5D%5B1%5D=bar") + .to_return(headers: {content_type: "application/vnd.api+json"}, body: { + data: [] + }.to_json) + Article.where(:'author.id' => ['foo', 'bar']).to_a + end + + def test_can_specify_empty_array_filter_value + stub_request(:get, "http://example.com/articles?filter%5Bauthor.id%5D%5B0%5D") + .to_return(headers: {content_type: "application/vnd.api+json"}, body: { + data: [] + }.to_json) + Article.where(:'author.id' => []).to_a + end + + def test_can_specify_empty_string_filter_value + stub_request(:get, "http://example.com/articles") + .with(query: {filter: {:'author.id' => ''}}) + .to_return(headers: {content_type: "application/vnd.api+json"}, body: { + data: [] + }.to_json) + Article.where(:'author.id' => '').to_a + end + + def test_scopes_are_nondestructive + first_stub = stub_request(:get, "http://example.com/articles?page[page]=1&page[per_page]=1") + .to_return(headers: {content_type: "application/vnd.api+json"}, body: { data: [] }.to_json) + + all_stub = stub_request(:get, "http://example.com/articles") + .to_return(headers: {content_type: "application/vnd.api+json"}, body: { data: [] }.to_json) + + scope = Article.where() + + scope.first + scope.all + + assert_requested first_stub, times: 1 + assert_requested all_stub, times: 1 + end + + def test_find_with_args + first_stub = stub_request(:get, "http://example.com/articles?filter[author.id]=foo") + .to_return(headers: {content_type: "application/vnd.api+json"}, body: { data: [] }.to_json) + + all_stub = stub_request(:get, "http://example.com/articles") + .to_return(headers: {content_type: "application/vnd.api+json"}, body: { data: [] }.to_json) + + find_stub = stub_request(:get, "http://example.com/articles/6") + .to_return(headers: {content_type: "application/vnd.api+json"}, body: { data: [] }.to_json) + + scope = Article.where() + + scope.find( "author.id" => "foo" ) + scope.find(6) + scope.all + + assert_requested first_stub, times: 1 + assert_requested all_stub, times: 1 + assert_requested find_stub, times: 1 + end + + def test_find_with_blank_args + blank_params = [nil, '', {}] + + with_altered_config(Article, :raise_on_blank_find_param => true) do + blank_params.each do |blank_param| + assert_raises JsonApiClient::Errors::NotFound do + Article.find(blank_param) + end + end + end + + # `.find('')` hits the INDEX URL with trailing slash, the others without one.. + collection_stub = stub_request(:get, %r{\Ahttp://example.com/articles/?\z}) + .to_return(headers: {content_type: "application/vnd.api+json"}, body: { data: [] }.to_json) + + blank_params.each do |blank_param| + Article.find(blank_param) + end + + assert_requested collection_stub, times: 3 + end + + def test_all_on_blank_raising_resource + with_altered_config(Article, :raise_on_blank_find_param => true) do + all_stub = stub_request(:get, "http://example.com/articles") + .to_return(headers: {content_type: "application/vnd.api+json"}, body: { data: [] }.to_json) + + Article.all + + assert_requested all_stub, times: 1 + end + end + + def test_build_does_not_propagate_values + query = Article.where(name: 'John'). + includes(:author). + order(id: :desc). + select(:id, :name). + page(1). + per(20). + with_params(sort: "foo") + + record = query.build + assert_equal [], record.changed + assert_equal [], record.relationships.changed + end + + def test_build_propagate_only_path_params + query = ArticleNested.where(author_id: '123', name: 'John') + record = query.build + + assert_equal [], record.changed + assert_equal(record.__belongs_to_params[:author_id], '123') + assert_equal(record.__belongs_to_params['author_id'], '123') + assert_equal '123', record.author_id + assert_equal [], record.relationships.changed + end + + def test_build_hash_sum + a = ArticleNested.where(author_id: '123', name: 'John') + b = ArticleNested.where(author_id: '123', name: 'John') + c = ArticleNested.where(author_id: '123') + d = Article.where(author_id: '123', name: 'John') + assert(a.hash == b.hash) + assert_equal(false, a.hash == c.hash) + assert_equal(false, a.hash == d.hash) + end + + def test_build_eql + a = ArticleNested.where(author_id: '123', name: 'John') + b = ArticleNested.where(author_id: '123', name: 'John') + c = ArticleNested.where(author_id: '123') + d = Article.where(author_id: '123', name: 'John') + assert(a == b) + assert_equal(false, a == c) + assert_equal(false, a == d) + end end diff --git a/test/unit/resource_test.rb b/test/unit/resource_test.rb index edb4e4ca..088bdff0 100644 --- a/test/unit/resource_test.rb +++ b/test/unit/resource_test.rb @@ -86,8 +86,8 @@ def test_formatted_key_accessors def test_associations_as_params article = Article.new(foo: 'bar', 'author' => {'type' => 'authors', 'id' => 1}) assert_equal(article.foo, 'bar') - assert_equal(article.attributes['author']['type'], 'authors') - assert_equal(article.attributes['author']['id'], 1) + assert_equal({'type' => 'authors', 'id' => 1}, article.relationships.author) + assert article.relationships.attribute_changed?(:author) end def test_default_params_overrideable @@ -95,4 +95,14 @@ def test_default_params_overrideable assert_equal(article.type, 'Story') end + def test_immutable + Article.immutable(true) + article = Article.new(type: 'Story') + assert_raises JsonApiClient::Errors::ResourceImmutableError do + article.save + end + ensure + Article.immutable(false) + end + end diff --git a/test/unit/serializing_test.rb b/test/unit/serializing_test.rb index f2dd2762..e2460809 100644 --- a/test/unit/serializing_test.rb +++ b/test/unit/serializing_test.rb @@ -6,6 +6,10 @@ class LimitedField < TestResource self.read_only_attributes += ['foo'] end + class NestedResource < TestResource + belongs_to :bar + end + class CustomSerializerAttributes < TestResource protected @@ -48,6 +52,29 @@ def test_as_json assert_equal expected, resource.first.as_json end + def test_as_json_involving_last_result_set + expected = { + 'type' => 'articles', + 'id' => '1', + 'attributes' => { + 'title' => 'Rails is Omakase' + } + } + stub_request(:post, "http://example.com/articles") + .to_return(headers: {content_type: "application/vnd.api+json"}, body: { + data: [{ + type: "articles", + id: "1", + attributes: { + title: "Rails is Omakase" + } + }] + }.to_json) + + resource = Article.create + assert_equal expected, resource.as_json + end + def test_as_json_api expected = { 'type' => 'articles', @@ -121,7 +148,6 @@ def test_update_data_only_includes_relationship_data expected = { "type" => "articles", "id" => "1", - "relationships"=>{"author"=>{"data"=>{"type"=>"people", "id"=>"9"}}}, "attributes" => {} } assert_equal expected, article.as_json_api @@ -318,4 +344,19 @@ def test_underscored_relationship_key_serialization assert_equal expected, article.as_json_api['relationships'] end end + + def test_ensure_nested_path_params_not_serialized + resource = NestedResource.new(foo: 'bar', id: 1, bar_id: 99) + + expected = { + 'id' => 1, + 'type' => "nested_resources", + 'attributes' => { + 'foo' => 'bar' + } + } + + assert_equal expected, resource.as_json_api + end + end diff --git a/test/unit/status_test.rb b/test/unit/status_test.rb index 0da47af6..934962d0 100644 --- a/test/unit/status_test.rb +++ b/test/unit/status_test.rb @@ -11,7 +11,7 @@ def test_server_responding_with_status_meta } }.to_json) - assert_raises JsonApiClient::Errors::ServerError do + assert_raises JsonApiClient::Errors::InternalServerError do User.find(1) end end @@ -24,7 +24,7 @@ def test_server_responding_with_http_status status: 500, body: "something irrelevant") - assert_raises JsonApiClient::Errors::ServerError do + assert_raises JsonApiClient::Errors::InternalServerError do User.find(1) end end @@ -56,6 +56,139 @@ def test_server_responding_with_404_status_meta end end + def test_server_responding_with_408_status + stub_request(:get, "http://example.com/users/1") + .to_return(headers: {content_type: "application/vnd.api+json"}, body: { + meta: { + status: 408, + message: "Request timeout" + } + }.to_json) + + assert_raises JsonApiClient::Errors::ClientError do + User.find(1) + end + end + + def test_server_responding_with_409_status + stub_request(:get, "http://example.com/users/1") + .to_return(headers: {content_type: "application/vnd.api+json"}, body: { + meta: { + status: 409, + message: "Conflict" + } + }.to_json) + + assert_raises JsonApiClient::Errors::Conflict do + User.find(1) + end + end + + def test_server_responding_with_429_status + stub_request(:get, "http://example.com/users/1") + .to_return(headers: {content_type: "application/vnd.api+json"}, body: { + meta: { + status: 429, + message: "Too Many Requests" + } + }.to_json) + + assert_raises JsonApiClient::Errors::ApiError do + User.find(1) + end + end + + def test_server_responding_with_400_status + stub_request(:get, "http://example.com/users/1") + .to_return(headers: {content_type: "application/vnd.api+json"}, body: { + meta: { + status: 400, + message: "Bad Request" + } + }.to_json) + + assert_raises JsonApiClient::Errors::ClientError do + User.find(1) + end + end + + def test_server_responding_with_401_status + stub_request(:get, "http://example.com/users/1") + .to_return(headers: {content_type: "application/vnd.api+json"}, body: { + meta: { + status: 401, + message: "Not Authorized" + } + }.to_json) + + assert_raises JsonApiClient::Errors::NotAuthorized do + User.find(1) + end + end + + def test_server_responding_with_400_status_in_meta_with_custom_status_handler + stub_request(:get, "http://example.com/user_with_custom_status_handlers/1") + .to_return(headers: {content_type: "application/vnd.api+json"}, body: { + meta: { + status: 400, + message: "Bad Request" + } + }.to_json) + + UserWithCustomStatusHandler.find(1) + end + + def test_server_responding_with_401_status_in_meta_with_custom_status_handler + stub_request(:get, "http://example.com/user_with_custom_status_handlers/1") + .to_return(headers: {content_type: "application/vnd.api+json"}, body: { + meta: { + status: 401, + message: "Not Authorized" + } + }.to_json) + + assert_raises CustomUnauthorizedError do + UserWithCustomStatusHandler.find(1) + end + end + + def test_server_responding_with_400_status_with_custom_status_handler + stub_request(:post, "http://example.com/user_with_custom_status_handlers") + .with(headers: { content_type: 'application/vnd.api+json', accept: 'application/vnd.api+json' }, body: { + data: { + type: 'user_with_custom_status_handlers', + attributes: { + name: 'foo' + } + } + }.to_json) + .to_return(status: 400, headers: { content_type: "application/vnd.api+json" }, body: { + errors: [ + { + status: '400', + detail: 'Bad Request' + } + ] + }.to_json) + + user = UserWithCustomStatusHandler.create(name: 'foo') + refute user.persisted? + expected_errors = { base: ['Bad Request'] } + assert_equal expected_errors, user.errors.messages + end + + def test_server_responding_with_422_status + stub_request(:get, "http://example.com/users/1") + .to_return(headers: {content_type: "application/vnd.api+json"}, body: { + meta: { + status: 422 + } + }.to_json) + + # We want to test that this response does not raise an error + User.find(1) + end + # https://github.com/JsonApiClient/json_api_client/blame/master/test/unit/status_test.rb#L59 # def test_server_responding_with_408_status # stub_request(:get, "http://example.com/users/1") diff --git a/test/unit/updating_test.rb b/test/unit/updating_test.rb index c80e5154..b747254b 100644 --- a/test/unit/updating_test.rb +++ b/test/unit/updating_test.rb @@ -8,6 +8,9 @@ def relationships_for_serialization end end + class Editor < TestResource + end + class CallbackTest < TestResource include JsonApiClient::Helpers::Callbacks before_update do @@ -20,8 +23,7 @@ def after_save_method end end - def setup - super + def stub_simple_fetch stub_request(:get, "http://example.com/articles/1") .to_return(headers: {content_type: "application/vnd.api+json"}, body: { data: { @@ -34,7 +36,38 @@ def setup }.to_json) end + def test_failed_update! + stub_simple_fetch + articles = Article.find(1) + article = articles.first + + stub_request(:patch, "http://example.com/articles/1") + .with(headers: {content_type: "application/vnd.api+json", accept: "application/vnd.api+json"}, body: { + data: { + id: "1", + type: "articles", + attributes: { + title: "Modified title", + } + } + }.to_json) + .to_return(headers: {content_type: "application/vnd.api+json"}, body: { + errors: [ + { + status: "400", + title: "Error" + } + ] + }.to_json) + + exception = assert_raises JsonApiClient::Errors::RecordNotSaved do + article.update!(title: 'Modified title') + end + assert_equal "Record not saved", exception.message + end + def test_can_update_found_record + stub_simple_fetch articles = Article.find(1) article = articles.first @@ -66,6 +99,7 @@ def test_can_update_found_record end def test_changed_attributes_blank_after_update + stub_simple_fetch articles = Article.find(1) article = articles.first @@ -106,6 +140,7 @@ def test_changed_attributes_blank_after_update end def test_can_update_found_record_in_bulk + stub_simple_fetch articles = Article.find(1) article = articles.first @@ -138,6 +173,7 @@ def test_can_update_found_record_in_bulk end def test_can_update_found_record_in_builk_using_update_method + stub_simple_fetch articles = Article.find(1) article = articles.first @@ -170,6 +206,7 @@ def test_can_update_found_record_in_builk_using_update_method end def test_can_update_single_relationship + stub_simple_fetch articles = Article.find(1) article = articles.first @@ -212,7 +249,52 @@ def test_can_update_single_relationship assert article.save end + def test_can_update_single_relationship_via_setter + stub_simple_fetch + articles = Article.find(1) + article = articles.first + + stub_request(:patch, "http://example.com/articles/1") + .with(headers: {content_type: "application/vnd.api+json", accept: "application/vnd.api+json"}, body: { + data: { + id: "1", + type: "articles", + relationships: { + author: { + data: { + type: "people", + id: "1" + } + } + }, + attributes: {} + } + }.to_json) + .to_return(headers: {status: 200, content_type: "application/vnd.api+json"}, body: { + data: { + type: "articles", + id: "1", + attributes: { + title: "Rails is Omakase" + }, + relationships: { + author: { + links: { + self: "/articles/1/links/author", + related: "/articles/1/author", + }, + data: { type: "people", id: "1" } + } + } + } + }.to_json) + + article.author = Person.new(id: "1") + assert article.save + end + def test_can_update_single_relationship_with_all_attributes_dirty + stub_simple_fetch articles = Article.find(1) article = articles.first @@ -265,6 +347,7 @@ def test_can_update_single_relationship_with_all_attributes_dirty end def test_can_update_has_many_relationships + stub_simple_fetch articles = Article.find(1) article = articles.first @@ -313,7 +396,58 @@ def test_can_update_has_many_relationships assert article.save end + def test_can_update_has_many_relationships_via_setter + stub_simple_fetch + articles = Article.find(1) + article = articles.first + + stub_request(:patch, "http://example.com/articles/1") + .with(headers: {content_type: "application/vnd.api+json", accept: "application/vnd.api+json"}, body: { + data: { + id: "1", + type: "articles", + relationships: { + comments: { + data: [{ + type: "comments", + id: "2" + },{ + type: "comments", + id: "3" + }] + } + }, + attributes: {} + } + }.to_json) + .to_return(headers: {status: 200, content_type: "application/vnd.api+json"}, body: { + data: { + id: "1", + type: "articles", + relationships: { + author: { + links: { + self: "/articles/1/links/author", + related: "/articles/1/author", + }, + data: { type: "people", id: "1" } + } + }, + attributes: { + title: "Rails is Omakase" + } + } + }.to_json) + + article.comments = [ + Comment.new(id: "2"), + Comment.new(id: "3") + ] + assert article.save + end + def test_can_update_has_many_relationships_with_all_attributes_dirty + stub_simple_fetch articles = Article.find(1) article = articles.first @@ -661,5 +795,341 @@ def test_callbacks_on_update assert_equal 100, callback_test.bar end + def test_can_update_with_includes_and_fields + stub_simple_fetch + stub_request(:patch, "http://example.com/articles/1") + .with( + headers: {content_type: "application/vnd.api+json", accept: "application/vnd.api+json"}, + query: {include: 'comments,author.comments', fields: {articles: 'title', authors: 'name'}}, + body: { + data: { + id: "1", + type: "articles", + attributes: { + title: "Modified title" + } + } + }.to_json + ).to_return(headers: {content_type: "application/vnd.api+json"}, body: { + data: { + type: "articles", + id: "1", + attributes: { + title: "Modified title" + }, + relationships: { + comments: { + data: [ + { + id: "2", + type: "comments" + } + ] + }, + author: { + data: nil + } + } + }, + included: [ + { + id: "2", + type: "comments", + attributes: { + body: "it is isn't it ?" + } + } + ] + }.to_json) + stub_request(:patch, "http://example.com/articles/1") + .with( + headers: {content_type: "application/vnd.api+json", accept: "application/vnd.api+json"}, + body: { + data: { + id: "1", + type: "articles", + attributes: { + title: "Modified title 2" + } + } + }.to_json + ).to_return( + headers: {content_type: "application/vnd.api+json"}, + body: { + data: { + type: "articles", + id: "1", + attributes: { + title: "Modified title 2" + } + } + }.to_json + ) + article = Article.find(1).first + article.title = "Modified title" + article.request_includes(:comments, author: :comments). + request_select(articles: [:title], authors: [:name]) + + assert article.save + assert_equal "1", article.id + assert_equal "Modified title", article.title + assert_nil article.author + assert_equal 1, article.comments.size + assert_equal "2", article.comments.first.id + assert_equal "it is isn't it ?", article.comments.first.body + + article.title = "Modified title 2" + assert article.save + assert_equal "Modified title 2", article.title + end + + def test_can_update_with_includes_and_fields_with_keep_request_params + stub_simple_fetch + stub_request(:patch, "http://example.com/articles/1") + .with( + headers: {content_type: "application/vnd.api+json", accept: "application/vnd.api+json"}, + query: {include: 'comments,author.comments', fields: {articles: 'title', authors: 'name'}}, + body: { + data: { + id: "1", + type: "articles", + attributes: { + title: "Modified title" + } + } + }.to_json + ).to_return(headers: {content_type: "application/vnd.api+json"}, body: { + data: { + type: "articles", + id: "1", + attributes: { + title: "Modified title" + }, + relationships: { + comments: { + data: [ + { + id: "2", + type: "comments" + } + ] + }, + author: { + data: nil + } + } + }, + included: [ + { + id: "2", + type: "comments", + attributes: { + body: "it is isn't it ?" + } + } + ] + }.to_json) + stub_request(:patch, "http://example.com/articles/1") + .with( + headers: {content_type: "application/vnd.api+json", accept: "application/vnd.api+json"}, + query: {include: 'comments,author.comments', fields: {articles: 'title', authors: 'name'}}, + body: { + data: { + id: "1", + type: "articles", + attributes: { + title: "Modified title 2" + } + } + }.to_json + ).to_return( + headers: {content_type: "application/vnd.api+json"}, + body: { + data: { + type: "articles", + id: "1", + attributes: { + title: "Modified title 2" + }, + relationships: { + comments: { + data: [ + { + id: "2", + type: "comments" + } + ] + }, + author: { + data: nil + } + } + }, + included: [ + { + id: "2", + type: "comments", + attributes: { + body: "it is isn't it ?" + } + } + ] + }.to_json + ) + Article.keep_request_params = true + article = Article.find(1).first + article.title = "Modified title" + article.request_includes(:comments, author: :comments). + request_select(:title, authors: [:name]) + + assert article.save + assert_equal "1", article.id + assert_equal "Modified title", article.title + assert_nil article.author + assert_equal 1, article.comments.size + assert_equal "2", article.comments.first.id + assert_equal "it is isn't it ?", article.comments.first.body + + article.title = "Modified title 2" + assert article.save + assert_equal "Modified title 2", article.title + assert_nil article.author + assert_equal 1, article.comments.size + assert_equal "2", article.comments.first.id + assert_equal "it is isn't it ?", article.comments.first.body + ensure + Article.keep_request_params = false + end + + def test_fetch_with_relationships_and_update_attribute + stub_request(:get, "http://example.com/authors/1?include=editor") + .to_return(headers: { + content_type: "application/vnd.api+json" + }, body: { + data: { + type: "authors", + id: "1", + attributes: { + name: "John Doe" + }, + relationships: { + editor: { + links: { + self: "/articles/1/links/editor", + related: "/articles/1/editor" + }, + data: {id: "2", type: "editors"} + } + } + } + }.to_json) + + authors = Author.includes(:editor).find(1) + author = authors.first + + stub_request(:patch, "http://example.com/authors/1") + .with(headers: { + content_type: "application/vnd.api+json", + accept: "application/vnd.api+json" + }, body: { + data: { + id: "1", + type: "authors", + attributes: { + name: "Jane Doe" + } + } + }.to_json) + .to_return(headers: { + status: 200, + content_type: "application/vnd.api+json" + }, body: { + data: { + type: "authors", + id: "1", + relationships: { + editor: { + links: { + self: "/articles/1/links/editor", + related: "/articles/1/editor" + } + } + }, + attributes: { + name: "Jane Doe" + } + } + }.to_json) + + author.name = "Jane Doe" + assert author.save + end + + def test_fetch_with_relationships_and_update_relationships + stub_request(:get, "http://example.com/authors/1?include=editor") + .to_return(headers: { + content_type: "application/vnd.api+json" + }, body: { + data: { + type: "authors", + id: "1", + attributes: { + name: "John Doe" + }, + relationships: { + editor: { + links: { + self: "/articles/1/links/editor", + related: "/articles/1/editor" + }, + data: {id: "2", type: "editors"} + } + } + } + }.to_json) + + authors = Author.includes(:editor).find(1) + author = authors.first + + stub_request(:patch, "http://example.com/authors/1") + .with(headers: { + content_type: "application/vnd.api+json", + accept: "application/vnd.api+json" + }, body: { + data: { + id: "1", + type: "authors", + relationships: { + editor: { + data: {type: "editors", id: "3"} + } + }, + attributes: {} + } + }.to_json) + .to_return(headers: { + status: 200, + content_type: "application/vnd.api+json" + }, body: { + data: { + type: "authors", + id: "1", + relationships: { + editor: { + links: { + self: "/articles/1/links/editor", + related: "/articles/1/editor" + } + } + }, + attributes: { + name: "John Doe" + } + } + }.to_json) + + author.relationships.editor = Editor.new(id: '3') + assert author.save + end end