diff --git a/README.md b/README.md index a6710552..9500c2f6 100644 --- a/README.md +++ b/README.md @@ -196,6 +196,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 @@ -217,6 +234,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 diff --git a/lib/json_api_client.rb b/lib/json_api_client.rb index 01f18f37..2f84ba9a 100644 --- a/lib/json_api_client.rb +++ b/lib/json_api_client.rb @@ -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/connection.rb b/lib/json_api_client/connection.rb index 87839bff..2f207ec4 100644 --- a/lib/json_api_client/connection.rb +++ b/lib/json_api_client/connection.rb @@ -28,8 +28,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/query/builder.rb b/lib/json_api_client/query/builder.rb index e21e2bf8..247b37da 100644 --- a/lib/json_api_client/query/builder.rb +++ b/lib/json_api_client/query/builder.rb @@ -159,22 +159,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..56ab54c6 100644 --- a/lib/json_api_client/query/requestor.rb +++ b/lib/json_api_client/query/requestor.rb @@ -11,36 +11,39 @@ def initialize(klass) # expects a record def create(record) request(:post, klass.path(record.attributes), { - data: record.as_json_api + 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 + 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.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 +59,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 25c31775..5229caad 100644 --- a/lib/json_api_client/resource.rb +++ b/lib/json_api_client/resource.rb @@ -15,7 +15,8 @@ class Resource attr_accessor :last_result_set, :links, - :relationships + :relationships, + :request_params class_attribute :site, :primary_key, :parser, @@ -31,6 +32,8 @@ class Resource :associations, :json_key_format, :route_format, + :request_params_class, + :keep_request_params, instance_accessor: false self.primary_key = :id self.parser = Parsers::Parser @@ -43,6 +46,8 @@ class Resource 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 #:underscored_key, :camelized_key, :dasherized_key, or custom self.json_key_format = :underscored_key @@ -318,6 +323,7 @@ def initialize(params = {}) 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 @@ -416,12 +422,14 @@ 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 end true end @@ -445,6 +453,31 @@ def inspect "#<#{self.class.name}:@attributes=#{attributes.inspect}>" end + def request_includes(*includes) + self.request_params.add_includes(includes) + self + end + + def reset_request_includes! + self.request_params.reset_includes! + self + end + + 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 + + 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 + protected def method_missing(method, *args) diff --git a/lib/json_api_client/utils.rb b/lib/json_api_client/utils.rb index 7c36ab43..73fd2dd8 100644 --- a/lib/json_api_client/utils.rb +++ b/lib/json_api_client/utils.rb @@ -24,5 +24,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/test/unit/creation_test.rb b/test/unit/creation_test.rb index 6d0b012a..455dea9e 100644 --- a/test/unit/creation_test.rb +++ b/test/unit/creation_test.rb @@ -74,6 +74,68 @@ def test_can_create_with_new_record_and_save 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 article = Article.new({ title: "Rails is Omakase" @@ -132,7 +194,7 @@ def test_can_create_with_new_record_with_relationships_and_save 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", diff --git a/test/unit/updating_test.rb b/test/unit/updating_test.rb index c80e5154..02fe453a 100644 --- a/test/unit/updating_test.rb +++ b/test/unit/updating_test.rb @@ -661,5 +661,208 @@ def test_callbacks_on_update assert_equal 100, callback_test.bar end + def test_can_update_with_includes_and_fields + 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_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 end