diff --git a/README.md b/README.md index 7d8bf4d..68e115d 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ class ResourceInterface # @return [String] def jsonapi_id; end - # Returns a hash containing, for each included relationship, an array of the + # Returns a hash containing, for each included relationship, an array of the # resources to be included from that one. # @param included_relationships [Array] The keys of the relationships # to be included. @@ -57,8 +57,8 @@ class ResourceInterface # Returns a JSON API-compliant representation of the resource as a hash. # @param options [Hash] - # @option fields [Array, Nil] The requested fields, or nil. - # @option include [Array] The requested relationships to + # @option fields [Set, Nil] The requested fields, or nil. + # @option include [Set] The requested relationships to # include (defaults to []). # @return [Hash] def as_jsonapi(options = {}); end @@ -100,6 +100,32 @@ returns a JSON API-compliant representation of the error. This returns a JSON API compliant hash representing the described document. +### Caching + +The generated JSON fragments can be cached in any cache implementation +supporting the `fetch_multi` method. + +When using caching, the serializable resources must implement an +additional `jsonapi_cache_key` method: +```ruby + # Returns a cache key for the resource, parametered by the `include` and + # `fields` options. + # @param options [Hash] + # @option fields [Set, Nil] The requested fields, or nil. + # @option include [Set] The requested relationships to + # include (defaults to []). + # @return [String] + def jsonapi_cache_key(options = {}); end +``` + +The cache instance must be passed to the renderer as follows: +```ruby +JSONAPI.render(data: resources, + include: include_string, + fields: fields_hash, + cache: cache_instance) +``` + ## License jsonapi-renderer is released under the [MIT License](http://www.opensource.org/licenses/MIT). diff --git a/lib/jsonapi/renderer/cached_resources_processor.rb b/lib/jsonapi/renderer/cached_resources_processor.rb new file mode 100644 index 0000000..080ee99 --- /dev/null +++ b/lib/jsonapi/renderer/cached_resources_processor.rb @@ -0,0 +1,42 @@ +require 'jsonapi/renderer/resources_processor' + +module JSONAPI + class Renderer + # @private + class CachedResourcesProcessor < ResourcesProcessor + class JSONString < String + def to_json(*) + self + end + end + + def initialize(cache) + @cache = cache + end + + def process_resources + [@primary, @included].each do |resources| + cache_hash = cache_key_map(resources) + processed_resources = @cache.fetch_multi(cache_hash.keys) do |key| + res, include, fields = cache_hash[key] + json = res.as_jsonapi(include: include, fields: fields).to_json + + JSONString.new(json) + end + + resources.replace(processed_resources.values) + end + end + + def cache_key_map(resources) + resources.each_with_object({}) do |res, h| + ri = [res.jsonapi_type, res.jsonapi_id] + include_dir = @include_rels[ri] + fields = @fields[ri.first.to_sym] + h[res.jsonapi_cache_key(include: include_dir, fields: fields)] = + [res, include_dir, fields] + end + end + end + end +end diff --git a/lib/jsonapi/renderer/document.rb b/lib/jsonapi/renderer/document.rb index f94d658..aaea715 100644 --- a/lib/jsonapi/renderer/document.rb +++ b/lib/jsonapi/renderer/document.rb @@ -1,5 +1,6 @@ require 'jsonapi/include_directive' -require 'jsonapi/renderer/resources_processor' +require 'jsonapi/renderer/simple_resources_processor' +require 'jsonapi/renderer/cached_resources_processor' module JSONAPI class Renderer @@ -12,6 +13,7 @@ def initialize(params = {}) @fields = _symbolize_fields(params[:fields] || {}) @jsonapi = params[:jsonapi] @include = JSONAPI::IncludeDirective.new(params[:include] || {}) + @cache = params[:cache] end def to_hash @@ -36,13 +38,21 @@ def document_hash def data_hash primary, included = - ResourcesProcessor.new(Array(@data), @include, @fields).process + resources_processor.process(Array(@data), @include, @fields) {}.tap do |hash| hash[:data] = @data.respond_to?(:to_ary) ? primary : primary[0] hash[:included] = included if included.any? end end + def resources_processor + if @cache + CachedResourcesProcessor.new(@cache) + else + SimpleResourcesProcessor.new + end + end + def errors_hash {}.tap do |hash| hash[:errors] = @errors.flat_map(&:as_jsonapi) diff --git a/lib/jsonapi/renderer/resources_processor.rb b/lib/jsonapi/renderer/resources_processor.rb index 9e6be30..0217c49 100644 --- a/lib/jsonapi/renderer/resources_processor.rb +++ b/lib/jsonapi/renderer/resources_processor.rb @@ -2,14 +2,13 @@ module JSONAPI class Renderer + # @api private class ResourcesProcessor - def initialize(resources, include, fields) + def process(resources, include, fields) @resources = resources @include = include @fields = fields - end - def process traverse_resources process_resources @@ -73,14 +72,7 @@ def enqueue_resource(res, prefix, include_dir) end def process_resources - [@primary, @included].each do |resources| - resources.map! do |res| - ri = [res.jsonapi_type, res.jsonapi_id] - include_dir = @include_rels[ri] - fields = @fields[res.jsonapi_type.to_sym] - res.as_jsonapi(include: include_dir, fields: fields) - end - end + raise 'Not implemented' end end end diff --git a/lib/jsonapi/renderer/simple_resources_processor.rb b/lib/jsonapi/renderer/simple_resources_processor.rb new file mode 100644 index 0000000..cd2bc0c --- /dev/null +++ b/lib/jsonapi/renderer/simple_resources_processor.rb @@ -0,0 +1,19 @@ +require 'jsonapi/renderer/resources_processor' + +module JSONAPI + class Renderer + # @api private + class SimpleResourcesProcessor < ResourcesProcessor + def process_resources + [@primary, @included].each do |resources| + resources.map! do |res| + ri = [res.jsonapi_type, res.jsonapi_id] + include_dir = @include_rels[ri] + fields = @fields[res.jsonapi_type.to_sym] + res.as_jsonapi(include: include_dir, fields: fields) + end + end + end + end + end +end diff --git a/spec/caching_spec.rb b/spec/caching_spec.rb new file mode 100644 index 0000000..f153ba7 --- /dev/null +++ b/spec/caching_spec.rb @@ -0,0 +1,101 @@ +require 'spec_helper' + +class Cache + def initialize + @cache = {} + end + + def fetch_multi(keys) + keys.each_with_object({}) do |k, h| + @cache[k] = yield(k) unless @cache.key?(k) + h[k] = @cache[k] + end + end +end + +describe JSONAPI::Renderer, '#render' do + before(:all) do + @users = [ + UserResource.new(1, 'User 1', '123 Example st.', []), + UserResource.new(2, 'User 2', '234 Example st.', []), + UserResource.new(3, 'User 3', '345 Example st.', []), + UserResource.new(4, 'User 4', '456 Example st.', []) + ] + @posts = [ + PostResource.new(1, 'Post 1', 'yesterday', @users[1]), + PostResource.new(2, 'Post 2', 'today', @users[0]), + PostResource.new(3, 'Post 3', 'tomorrow', @users[1]) + ] + @users[0].posts = [@posts[1]] + @users[1].posts = [@posts[0], @posts[2]] + end + + it 'renders included relationships' do + cache = Cache.new + # Warm up the cache. + subject.render(data: @users[0], + include: 'posts', + cache: cache) + # Actual call on warm cache. + actual = subject.render(data: @users[0], + include: 'posts', + cache: cache) + expected = { + data: { + type: 'users', + id: '1', + attributes: { + name: 'User 1', + address: '123 Example st.' + }, + relationships: { + posts: { + data: [{ type: 'posts', id: '2' }], + links: { + self: 'http://api.example.com/users/1/relationships/posts', + related: { + href: 'http://api.example.com/users/1/posts', + meta: { + do_not_use: true + } + } + }, + meta: { + deleted_posts: 5 + } + } + }, + links: { + self: 'http://api.example.com/users/1' + }, + meta: { + user_meta: 'is_meta' + } + }, + included: [ + { + type: 'posts', + id: '2', + attributes: { + title: 'Post 2', + date: 'today' + }, + relationships: { + author: { + links: { + self: 'http://api.example.com/posts/2/relationships/author', + related: 'http://api.example.com/posts/2/author' + }, + meta: { + author_active: true + } + } + } + } + ] + } + + expect(JSON.parse(actual.to_json)).to eq(JSON.parse(expected.to_json)) + expect(actual[:data]).to be_a(JSONAPI::Renderer::CachedResourcesProcessor::JSONString) + end +end diff --git a/spec/renderer_spec.rb b/spec/renderer_spec.rb index 2eae4f3..0edc8ae 100644 --- a/spec/renderer_spec.rb +++ b/spec/renderer_spec.rb @@ -1,124 +1,5 @@ require 'spec_helper' -class UserResource - attr_accessor :id, :name, :address, :posts - - def initialize(id, name, address, posts) - @id = id - @name = name - @address = address - @posts = posts - end - - def jsonapi_type - 'users' - end - - def jsonapi_id - @id.to_s - end - - def jsonapi_related(included) - if included.include?(:posts) - { posts: @posts.map { |p| p } } - else - {} - end - end - - def as_jsonapi(options = {}) - fields = options[:fields] || [:name, :address, :posts] - included = options[:include] || [] - - hash = { id: jsonapi_id, type: jsonapi_type } - hash[:attributes] = { name: @name, address: @address } - .select { |k, _| fields.include?(k) } - if fields.include?(:posts) - hash[:relationships] = { posts: {} } - hash[:relationships][:posts] = { - links: { - self: "http://api.example.com/users/#{@id}/relationships/posts", - related: { - href: "http://api.example.com/users/#{@id}/posts", - meta: { - do_not_use: true - } - } - }, - meta: { - deleted_posts: 5 - } - } - if included.include?(:posts) - hash[:relationships][:posts][:data] = @posts.map do |p| - { type: 'posts', id: p.id.to_s } - end - end - end - - hash[:links] = { - self: "http://api.example.com/users/#{@id}" - } - hash[:meta] = { user_meta: 'is_meta' } - - hash - end -end - -class PostResource - attr_accessor :id, :title, :date, :author - - def initialize(id, title, date, author) - @id = id - @title = title - @date = date - @author = author - end - - def jsonapi_type - 'posts' - end - - def jsonapi_id - @id.to_s - end - - def jsonapi_related(included) - included.include?(:author) ? { author: [@author] } : {} - end - - def as_jsonapi(options = {}) - fields = options[:fields] || [:title, :date, :author] - included = options[:include] || [] - hash = { id: jsonapi_id, type: jsonapi_type } - - hash[:attributes] = { title: @title, date: @date } - .select { |k, _| fields.include?(k) } - if fields.include?(:author) - hash[:relationships] = { author: {} } - hash[:relationships][:author] = { - links: { - self: "http://api.example.com/posts/#{@id}/relationships/author", - related: "http://api.example.com/posts/#{@id}/author" - }, - meta: { - author_active: true - } - } - if included.include?(:author) - hash[:relationships][:author][:data] = - if @author.nil? - nil - else - { type: 'users', id: @author.id.to_s } - end - end - end - - hash - end -end - describe JSONAPI::Renderer, '#render' do before(:all) do @users = [ diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 2a5ea8b..547994d 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -4,3 +4,130 @@ end require 'jsonapi/renderer' + +class UserResource + attr_accessor :id, :name, :address, :posts + + def initialize(id, name, address, posts) + @id = id + @name = name + @address = address + @posts = posts + end + + def jsonapi_type + 'users' + end + + def jsonapi_id + @id.to_s + end + + def jsonapi_related(included) + if included.include?(:posts) + { posts: @posts.map { |p| p } } + else + {} + end + end + + def jsonapi_cache_key(options = {}) + "#{jsonapi_type} - #{jsonapi_id} - #{options[:include].to_a.sort} - #{(options[:fields] || Set.new).to_a.sort}" + end + + def as_jsonapi(options = {}) + fields = options[:fields] || [:name, :address, :posts] + included = options[:include] || [] + + hash = { id: jsonapi_id, type: jsonapi_type } + hash[:attributes] = { name: @name, address: @address } + .select { |k, _| fields.include?(k) } + if fields.include?(:posts) + hash[:relationships] = { posts: {} } + hash[:relationships][:posts] = { + links: { + self: "http://api.example.com/users/#{@id}/relationships/posts", + related: { + href: "http://api.example.com/users/#{@id}/posts", + meta: { + do_not_use: true + } + } + }, + meta: { + deleted_posts: 5 + } + } + if included.include?(:posts) + hash[:relationships][:posts][:data] = @posts.map do |p| + { type: 'posts', id: p.id.to_s } + end + end + end + + hash[:links] = { + self: "http://api.example.com/users/#{@id}" + } + hash[:meta] = { user_meta: 'is_meta' } + + hash + end +end + +class PostResource + attr_accessor :id, :title, :date, :author + + def initialize(id, title, date, author) + @id = id + @title = title + @date = date + @author = author + end + + def jsonapi_type + 'posts' + end + + def jsonapi_id + @id.to_s + end + + def jsonapi_related(included) + included.include?(:author) ? { author: [@author] } : {} + end + + def jsonapi_cache_key(options = {}) + "#{jsonapi_type} - #{jsonapi_id} - #{options[:include].to_a.sort} - #{(options[:fields] || Set.new).to_a.sort}" + end + + def as_jsonapi(options = {}) + fields = options[:fields] || [:title, :date, :author] + included = options[:include] || [] + hash = { id: jsonapi_id, type: jsonapi_type } + + hash[:attributes] = { title: @title, date: @date } + .select { |k, _| fields.include?(k) } + if fields.include?(:author) + hash[:relationships] = { author: {} } + hash[:relationships][:author] = { + links: { + self: "http://api.example.com/posts/#{@id}/relationships/author", + related: "http://api.example.com/posts/#{@id}/author" + }, + meta: { + author_active: true + } + } + if included.include?(:author) + hash[:relationships][:author][:data] = + if @author.nil? + nil + else + { type: 'users', id: @author.id.to_s } + end + end + end + + hash + end +end