New Upstream Snapshot - ruby-jsonapi-renderer

Ready changes

Summary

Merged new upstream version: 0.2.2+git20190915.1.92e2d1f (was: 0.1.3).

Resulting package

Built on 2022-12-20T08:36 (took 6m23s)

The resulting binary packages can be installed (if you have the apt repository enabled) by running one of:

apt install -t fresh-snapshots ruby-jsonapi-renderer

Lintian Result

Diff

diff --git a/.travis.yml b/.travis.yml
new file mode 100644
index 0000000..2e7d8de
--- /dev/null
+++ b/.travis.yml
@@ -0,0 +1,28 @@
+language: ruby
+sudo: false
+before_install:
+  - bundle update
+env:
+  global:
+    - CC_TEST_REPORTER_ID=63659c56322a7a1262f6375083f44c8789ee405a6bcf9027189d67c90d08887c
+    - GIT_COMMITTED_AT=$(if [ "$TRAVIS_PULL_REQUEST" == "false" ]; then git log -1 --pretty=format:%ct; else git log -1 --skip 1 --pretty=format:%ct; fi)
+rvm:
+  - 2.1
+  - 2.2.2
+  - 2.3.3
+  - ruby-head
+matrix:
+  allow_failures:
+    - rvm: ruby-head
+before_script:
+  - curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter
+  - chmod +x ./cc-test-reporter
+  - ./cc-test-reporter before-build
+after_script:
+  # Preferably you will run test-reporter on branch update events. But
+  # if you setup travis to build PR updates only, you don't need to run
+  # the line below
+  - if [ "$TRAVIS_PULL_REQUEST" == "false" ]; then ./cc-test-reporter after-build --exit-code $TRAVIS_TEST_RESULT; fi
+  # In the case where travis is setup to build PR updates only,
+  # uncomment the line below
+  # - ./cc-test-reporter after-build --exit-code $TRAVIS_TEST_RESULT
diff --git a/Gemfile b/Gemfile
new file mode 100644
index 0000000..fa75df1
--- /dev/null
+++ b/Gemfile
@@ -0,0 +1,3 @@
+source 'https://rubygems.org'
+
+gemspec
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..0c6e615
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2016 Lucas Hosseini
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index 7d8bf4d..2b6d443 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<Symbol>] 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<Symbol>, Nil] The requested fields, or nil.
-  #   @option include [Array<Symbol>] The requested relationships to 
+  #   @option fields [Set<Symbol>, Nil] The requested fields, or nil.
+  #   @option include [Set<Symbol>] The requested relationships to
   #     include (defaults to []).
   # @return [Hash]
   def as_jsonapi(options = {}); end
@@ -87,6 +87,18 @@ JSONAPI.render(data: resources,
 
 This returns a JSON API compliant hash representing the described document.
 
+#### Rendering a relationship
+```ruby
+JSONAPI.render(data: resource,
+               relationship: :posts,
+               include: include_string,
+               fields: fields_hash,
+               meta: meta_hash,
+               links: links_hash)
+```
+
+This returns a JSON API compliant hash representing the described document.
+
 ### Rendering errors
 
 ```ruby
@@ -100,6 +112,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, parameterized by the `include` and
+  #   `fields` options.
+  # @param options [Hash]
+  #   @option fields [Set<Symbol>, Nil] The requested fields, or nil.
+  #   @option include [Set<Symbol>] 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/Rakefile b/Rakefile
new file mode 100644
index 0000000..7a4e66c
--- /dev/null
+++ b/Rakefile
@@ -0,0 +1,9 @@
+require 'bundler/gem_tasks'
+require 'rspec/core/rake_task'
+
+RSpec::Core::RakeTask.new(:spec) do |t|
+  t.pattern = Dir.glob('spec/**/*_spec.rb')
+end
+
+task default: :test
+task test: :spec
diff --git a/VERSION b/VERSION
new file mode 100644
index 0000000..ee1372d
--- /dev/null
+++ b/VERSION
@@ -0,0 +1 @@
+0.2.2
diff --git a/debian/changelog b/debian/changelog
index 9005761..f3306bc 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,4 +1,4 @@
-ruby-jsonapi-renderer (0.1.3-2) UNRELEASED; urgency=medium
+ruby-jsonapi-renderer (0.2.2+git20190915.1.92e2d1f-1) UNRELEASED; urgency=medium
 
   [ Utkarsh Gupta ]
   * Add salsa-ci.yml
@@ -17,8 +17,9 @@ ruby-jsonapi-renderer (0.1.3-2) UNRELEASED; urgency=medium
     + ruby-jsonapi-renderer: Add :all qualifier for ruby dependency.
   * Update watch file format version to 4.
   * Bump debhelper from old 12 to 13.
+  * New upstream snapshot.
 
- -- Utkarsh Gupta <guptautkarsh2102@gmail.com>  Tue, 13 Aug 2019 05:50:36 +0530
+ -- Utkarsh Gupta <guptautkarsh2102@gmail.com>  Tue, 20 Dec 2022 08:32:50 -0000
 
 ruby-jsonapi-renderer (0.1.3-1) unstable; urgency=medium
 
diff --git a/jsonapi-renderer.gemspec b/jsonapi-renderer.gemspec
index fa1c608..d357345 100644
--- a/jsonapi-renderer.gemspec
+++ b/jsonapi-renderer.gemspec
@@ -1,40 +1,19 @@
-#########################################################
-# This file has been automatically generated by gem2tgz #
-#########################################################
-# -*- encoding: utf-8 -*-
-# stub: jsonapi-renderer 0.1.3 ruby lib
+version = File.read(File.expand_path('../VERSION', __FILE__)).strip
 
-Gem::Specification.new do |s|
-  s.name = "jsonapi-renderer".freeze
-  s.version = "0.1.3"
+Gem::Specification.new do |spec|
+  spec.name          = 'jsonapi-renderer'
+  spec.version       = version
+  spec.author        = 'Lucas Hosseini'
+  spec.email         = 'lucas.hosseini@gmail.com'
+  spec.summary       = 'Render JSONAPI documents.'
+  spec.description   = 'Efficiently render JSON API documents.'
+  spec.homepage      = 'https://github.com/jsonapi-rb/jsonapi-renderer'
+  spec.license       = 'MIT'
 
-  s.required_rubygems_version = Gem::Requirement.new(">= 0".freeze) if s.respond_to? :required_rubygems_version=
-  s.require_paths = ["lib".freeze]
-  s.authors = ["Lucas Hosseini".freeze]
-  s.date = "2017-07-12"
-  s.description = "Efficiently render JSON API documents.".freeze
-  s.email = "lucas.hosseini@gmail.com".freeze
-  s.files = ["README.md".freeze, "lib/jsonapi/include_directive.rb".freeze, "lib/jsonapi/include_directive/parser.rb".freeze, "lib/jsonapi/renderer.rb".freeze, "lib/jsonapi/renderer/document.rb".freeze, "lib/jsonapi/renderer/resources_processor.rb".freeze]
-  s.homepage = "https://github.com/jsonapi-rb/jsonapi-renderer".freeze
-  s.licenses = ["MIT".freeze]
-  s.rubygems_version = "2.5.2".freeze
-  s.summary = "Render JSONAPI documents.".freeze
+  spec.files         = Dir['README.md', 'lib/**/*']
+  spec.require_path  = 'lib'
 
-  if s.respond_to? :specification_version then
-    s.specification_version = 4
-
-    if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then
-      s.add_development_dependency(%q<codecov>.freeze, ["~> 0.1"])
-      s.add_development_dependency(%q<rake>.freeze, ["~> 11.3"])
-      s.add_development_dependency(%q<rspec>.freeze, ["~> 3.5"])
-    else
-      s.add_dependency(%q<codecov>.freeze, ["~> 0.1"])
-      s.add_dependency(%q<rake>.freeze, ["~> 11.3"])
-      s.add_dependency(%q<rspec>.freeze, ["~> 3.5"])
-    end
-  else
-    s.add_dependency(%q<codecov>.freeze, ["~> 0.1"])
-    s.add_dependency(%q<rake>.freeze, ["~> 11.3"])
-    s.add_dependency(%q<rspec>.freeze, ["~> 3.5"])
-  end
+  spec.add_development_dependency 'rake', '~> 11.3'
+  spec.add_development_dependency 'rspec', '~> 3.5'
+  spec.add_development_dependency 'simplecov'
 end
diff --git a/lib/jsonapi/include_directive.rb b/lib/jsonapi/include_directive.rb
index 32ec4f7..142633d 100644
--- a/lib/jsonapi/include_directive.rb
+++ b/lib/jsonapi/include_directive.rb
@@ -16,6 +16,8 @@ module JSONAPI
     def initialize(include_args, options = {})
       include_hash = Parser.parse_include_args(include_args)
       @hash = include_hash.each_with_object({}) do |(key, value), hash|
+        raise InvalidKey, key unless valid?(key)
+
         hash[key] = self.class.new(value, options)
       end
       @options = options
@@ -68,5 +70,18 @@ module JSONAPI
 
       string_array.join(',')
     end
+
+    class InvalidKey < StandardError; end
+
+    private
+
+    def valid?(key)
+      key.match(valid_json_key_name_regex)
+    end
+
+    def valid_json_key_name_regex
+      # https://jsonapi.org/format/#document-member-names
+      /^(?![\s\-_])[\u0080-\u10FFA-Za-z0-9* _-]+(?<![\s\-_])$/
+    end
   end
 end
diff --git a/lib/jsonapi/renderer/cached_resources_processor.rb b/lib/jsonapi/renderer/cached_resources_processor.rb
new file mode 100644
index 0000000..3f77228
--- /dev/null
+++ b/lib/jsonapi/renderer/cached_resources_processor.rb
@@ -0,0 +1,44 @@
+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_rels = @include_rels[ri]
+          # Sort for cache key consistency
+          include_dir = include_rels.keys.sort unless include_rels.nil?
+          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 6b9d4ad..21a5ae7 100644
--- a/lib/jsonapi/renderer/document.rb
+++ b/lib/jsonapi/renderer/document.rb
@@ -1,17 +1,21 @@
 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
+    # @private
     class Document
       def initialize(params = {})
         @data    = params.fetch(:data,    :no_data)
         @errors  = params.fetch(:errors,  [])
         @meta    = params[:meta]
         @links   = params[:links] || {}
-        @fields  = _symbolize_fields(params[:fields] || {})
+        @fields  = _canonize_fields(params[:fields] || {})
         @jsonapi = params[:jsonapi]
         @include = JSONAPI::IncludeDirective.new(params[:include] || {})
+        @relationship = params[:relationship]
+        @cache = params[:cache]
       end
 
       def to_hash
@@ -21,37 +25,75 @@ module JSONAPI
 
       private
 
+      # rubocop:disable Metrics/PerceivedComplexity, Metrics/MethodLength
+      # rubocop:disable Metrics/CyclomaticComplexity
       def document_hash
         {}.tap do |hash|
-          if @data != :no_data
+          if @relationship
+            hash.merge!(relationship_hash)
+          elsif @data != :no_data
             hash.merge!(data_hash)
           elsif @errors.any?
             hash.merge!(errors_hash)
           end
-          hash[:links]   = @links    if @links.any?
-          hash[:meta]    = @meta     unless @meta.nil?
-          hash[:jsonapi] = @jsonapi  unless @jsonapi.nil?
+          hash[:links]   = @links   if @links.any?
+          hash[:meta]    = @meta    unless @meta.nil?
+          hash[:jsonapi] = @jsonapi unless @jsonapi.nil?
         end
       end
+      # rubocop:enable Metrics/PerceivedComplexity, Metrics/MethodLength
+      # rubocop:enable Metrics/CyclomaticComplexity
 
       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
 
+      # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
+      def relationship_hash
+        rel_name = @relationship.to_sym
+        data = @data.jsonapi_related([rel_name])[rel_name]
+        included =
+          if @include.key?(rel_name)
+            resources_processor.process(data, @include[rel_name], @fields)
+                               .flatten!
+          else
+            []
+          end
+
+        res = @data.as_jsonapi(fields: [rel_name], include: [rel_name])
+        rel = res[:relationships][rel_name]
+        @links = rel[:links].merge!(@links)
+        @meta ||= rel[:meta]
+
+        {}.tap do |hash|
+          hash[:data]     = rel[:data]
+          hash[:included] = included if included.any?
+        end
+      end
+      # rubocop:enable Metrics/MethodLength, Metrics/AbcSize
+
       def errors_hash
         {}.tap do |hash|
-          hash[:errors] = @errors.map(&:as_jsonapi)
+          hash[:errors] = @errors.flat_map(&:as_jsonapi)
+        end
+      end
+
+      def resources_processor
+        if @cache
+          CachedResourcesProcessor.new(@cache)
+        else
+          SimpleResourcesProcessor.new
         end
       end
 
-      def _symbolize_fields(fields)
+      def _canonize_fields(fields)
         fields.each_with_object({}) do |(k, v), h|
-          h[k.to_sym] = v.map(&:to_sym)
+          h[k.to_sym] = v.map(&:to_sym).sort!
         end
       end
     end
diff --git a/lib/jsonapi/renderer/resources_processor.rb b/lib/jsonapi/renderer/resources_processor.rb
index 9e6be30..dfd5264 100644
--- a/lib/jsonapi/renderer/resources_processor.rb
+++ b/lib/jsonapi/renderer/resources_processor.rb
@@ -1,15 +1,12 @@
-require 'set'
-
 module JSONAPI
   class Renderer
+    # @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
 
@@ -19,8 +16,9 @@ module JSONAPI
       private
 
       def traverse_resources
-        @traversed    = Set.new # [type, id, prefix]
-        @include_rels = {} # [type, id => Set]
+        # Use hash instead of set for better performances
+        @traversed    = {} # Hash[type, id, prefix] => true
+        @include_rels = {} # Hash[type, id] => Hash[include_key => true]
         @queue        = []
         @primary      = []
         @included     = []
@@ -31,7 +29,7 @@ module JSONAPI
 
       def initialize_queue
         @resources.each do |res|
-          @traversed.add([res.jsonapi_type, res.jsonapi_id, ''])
+          @traversed[[res.jsonapi_type, res.jsonapi_id, '']] = true
           traverse_resource(res, @include.keys, true)
           enqueue_related_resources(res, '', @include)
         end
@@ -47,40 +45,37 @@ module JSONAPI
 
       def traverse_resource(res, include_keys, primary)
         ri = [res.jsonapi_type, res.jsonapi_id]
+        keys_hash = {}
+        include_keys.each { |k| keys_hash[k] = true }
+
         if @include_rels.include?(ri)
-          @include_rels[ri].merge(include_keys)
+          @include_rels[ri].merge!(keys_hash)
         else
-          @include_rels[ri] = Set.new(include_keys)
+          @include_rels[ri] = keys_hash
           (primary ? @primary : @included) << res
         end
       end
 
       def enqueue_related_resources(res, prefix, include_dir)
         res.jsonapi_related(include_dir.keys).each do |key, data|
+          child_prefix = "#{prefix}.#{key}".freeze
           data.each do |child_res|
             next if child_res.nil?
-            child_prefix = "#{prefix}.#{key}"
             enqueue_resource(child_res, child_prefix, include_dir[key])
           end
         end
       end
 
       def enqueue_resource(res, prefix, include_dir)
-        return unless @traversed.add?([res.jsonapi_type,
-                                       res.jsonapi_id,
-                                       prefix])
+        key = [res.jsonapi_type, res.jsonapi_id, prefix]
+        return if @traversed[key]
+
+        @traversed[key] = true
         @queue << [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..1249e6d
--- /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].keys
+            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..35c605c
--- /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/include_directive/parser_spec.rb b/spec/include_directive/parser_spec.rb
new file mode 100644
index 0000000..6179da0
--- /dev/null
+++ b/spec/include_directive/parser_spec.rb
@@ -0,0 +1,82 @@
+require 'spec_helper'
+
+require 'jsonapi/include_directive'
+
+describe JSONAPI::IncludeDirective::Parser, '.parse_include_args' do
+  it 'handles arrays of symbols and hashes' do
+    args = [:friends,
+            comments: [:author],
+            posts: [:author,
+                    comments: [:author]]]
+    hash = JSONAPI::IncludeDirective::Parser.parse_include_args(args)
+    expected = {
+      friends: {},
+      comments: { author: {} },
+      posts: { author: {}, comments: { author: {} } }
+    }
+
+    expect(hash).to eq expected
+  end
+
+  it 'handles strings' do
+    str = 'friends,comments.author,posts.author,posts.comments.author'
+    hash = JSONAPI::IncludeDirective::Parser.parse_include_args(str)
+    expected = {
+      friends: {},
+      comments: { author: {} },
+      posts: { author: {}, comments: { author: {} } }
+    }
+
+    expect(hash).to eq expected
+  end
+
+  it 'treats spaces as part of the resource name' do
+    str = 'friends, comments.author , posts.author,posts. comments.author'
+    hash = JSONAPI::IncludeDirective::Parser.parse_include_args(str)
+    expected = {
+      friends: {},
+      :' comments' => { :'author ' => {} },
+      :' posts' => { author: {} },
+      :'posts' => { :' comments' => { author: {} } }
+    }
+
+    expect(hash).to eq expected
+  end
+
+  it 'handles common prefixes in strings' do
+    args = ['friends', 'comments.author', 'posts.author',
+            'posts.comments.author']
+    hash = JSONAPI::IncludeDirective::Parser.parse_include_args(args)
+    expected = {
+      friends: {},
+      comments: { author: {} },
+      posts: { author: {}, comments: { author: {} } }
+    }
+
+    expect(hash).to eq expected
+  end
+
+  it 'handles an empty string' do
+    args = ''
+    hash = JSONAPI::IncludeDirective::Parser.parse_include_args(args)
+    expected = {}
+
+    expect(hash).to eq expected
+  end
+
+  it 'handles an empty array' do
+    args = []
+    hash = JSONAPI::IncludeDirective::Parser.parse_include_args(args)
+    expected = {}
+
+    expect(hash).to eq expected
+  end
+
+  it 'handles invalid input' do
+    args = Object.new
+    hash = JSONAPI::IncludeDirective::Parser.parse_include_args(args)
+    expected = {}
+
+    expect(hash).to eq expected
+  end
+end
diff --git a/spec/include_directive_spec.rb b/spec/include_directive_spec.rb
new file mode 100644
index 0000000..3223d1b
--- /dev/null
+++ b/spec/include_directive_spec.rb
@@ -0,0 +1,94 @@
+require 'spec_helper'
+
+require 'jsonapi/include_directive'
+
+describe JSONAPI::IncludeDirective, '.initialize' do
+  context 'raises InvalidKey when' do
+    (
+      ["\u002B", "\u002C", "\u002E", "\u005B", "\u005D", "\u002A", "\u002F",
+       "\u0040", "\u005C", "\u005E", "\u0060"] + ("\u0021".."\u0029").to_a \
+                                               + ("\u003A".."\u003F").to_a \
+                                               + ("\u007B".."\u007F").to_a \
+                                               + ("\u0000".."\u001F").to_a \
+                                               - ['*', '.', ',']
+    ).each do |invalid_character|
+      it "invalid character provided: '#{invalid_character}'" do
+        expect { JSONAPI::IncludeDirective.new(invalid_character) }
+          .to raise_error(JSONAPI::IncludeDirective::InvalidKey)
+      end
+    end
+
+    [' ', '_', '-'].each do |char|
+      it "starts with following character: '#{char}'" do
+        expect { JSONAPI::IncludeDirective.new("#{char}_with_valid") }
+          .to raise_error(JSONAPI::IncludeDirective::InvalidKey, "#{char}_with_valid")
+      end
+
+      it "ends with following character: '#{char}'" do
+        expect { JSONAPI::IncludeDirective.new("valid_with_#{char}") }
+          .to raise_error(JSONAPI::IncludeDirective::InvalidKey, "valid_with_#{char}")
+      end
+    end
+  end
+
+  context 'does not raise InvalidKey' do
+    ["\u0080", "B", "t", "5", "\u0100", "\u10FFFAA"].each do |char|
+      it "when provided character '#{char}'" do
+        expect(JSONAPI::IncludeDirective.new(char).key?(char)).to be true
+      end
+    end
+
+    [' ', '_', '-'].each do |char|
+      it "when '#{char}' is not at beginning or end" do
+        key = "valid#{char}valid"
+        expect(JSONAPI::IncludeDirective.new(key).key?(key)).to be true
+      end
+    end
+  end
+end
+
+describe JSONAPI::IncludeDirective, '.key?' do
+  it 'handles existing keys' do
+    str = 'posts.comments'
+    include_directive = JSONAPI::IncludeDirective.new(str)
+
+    expect(include_directive.key?(:posts)).to be_truthy
+  end
+
+  it 'handles absent keys' do
+    str = 'posts.comments'
+    include_directive = JSONAPI::IncludeDirective.new(str)
+
+    expect(include_directive.key?(:author)).to be_falsy
+  end
+
+  it 'handles wildcards' do
+    str = 'posts.*'
+    include_directive = JSONAPI::IncludeDirective.new(
+      str, allow_wildcard: true)
+
+    expect(include_directive[:posts].key?(:author)).to be_truthy
+    expect(include_directive[:posts][:author].key?(:comments)).to be_falsy
+  end
+
+  it 'handles wildcards' do
+    str = 'posts.**'
+    include_directive = JSONAPI::IncludeDirective.new(
+      str, allow_wildcard: true)
+
+    expect(include_directive[:posts].key?(:author)).to be_truthy
+    expect(include_directive[:posts][:author].key?(:comments)).to be_truthy
+  end
+end
+
+describe JSONAPI::IncludeDirective, '.to_string' do
+  it 'works' do
+    str = 'friends,comments.author,posts.author,posts.comments.author'
+    include_directive = JSONAPI::IncludeDirective.new(str)
+    expected = include_directive.to_hash
+    actual = JSONAPI::IncludeDirective.new(include_directive.to_string)
+                                        .to_hash
+
+    expect(actual).to eq expected
+  end
+end
diff --git a/spec/renderer_spec.rb b/spec/renderer_spec.rb
new file mode 100644
index 0000000..71cf473
--- /dev/null
+++ b/spec/renderer_spec.rb
@@ -0,0 +1,439 @@
+require 'spec_helper'
+
+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 nil' do
+    actual = subject.render(data: nil)
+    expected = {
+      data: nil
+    }
+
+    expect(actual).to eq(expected)
+  end
+
+  it 'renders an empty array' do
+    actual = subject.render(data: [])
+    expected = {
+      data: []
+    }
+
+    expect(actual).to eq(expected)
+  end
+
+  it 'renders a single resource' do
+    actual = subject.render(data: @users[0])
+    expected = {
+      data: {
+        type: 'users',
+        id: '1',
+        attributes: {
+          name: 'User 1',
+          address: '123 Example st.'
+        },
+        relationships: {
+          posts: {
+            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'
+        }
+      }
+    }
+
+    expect(actual).to eq(expected)
+  end
+
+  it 'renders a collection of resources' do
+    actual = subject.render(data: [@users[0],
+                                   @users[1]])
+    expected = {
+      data: [
+        {
+          type: 'users',
+          id: '1',
+          attributes: {
+            name: 'User 1',
+            address: '123 Example st.'
+          },
+          relationships: {
+            posts: {
+              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'
+          }
+        },
+        {
+          type: 'users',
+          id: '2',
+          attributes: {
+            name: 'User 2',
+            address: '234 Example st.'
+          },
+          relationships: {
+            posts: {
+              links: {
+                self: 'http://api.example.com/users/2/relationships/posts',
+                related: {
+                  href: 'http://api.example.com/users/2/posts',
+                  meta: {
+                    do_not_use: true
+                  }
+                }
+              },
+              meta: {
+                deleted_posts: 5
+              }
+            }
+          },
+          links: {
+            self: 'http://api.example.com/users/2'
+          },
+          meta: {
+            user_meta: 'is_meta'
+          }
+        }
+      ]
+    }
+
+    expect(actual).to eq(expected)
+  end
+
+  it 'renders included relationships' do
+    actual = subject.render(data: @users[0],
+                            include: 'posts')
+    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(actual).to eq(expected)
+  end
+
+  it 'filters out fields' do
+    actual = subject.render(data: @users[0],
+                            fields: { users: [:name] })
+    expected = {
+      data: {
+        type: 'users',
+        id: '1',
+        attributes: {
+          name: 'User 1'
+        },
+        links: {
+          self: 'http://api.example.com/users/1'
+        },
+        meta: {
+          user_meta: 'is_meta'
+        }
+      }
+    }
+
+    expect(actual).to eq(expected)
+  end
+
+  context 'when fields option is nil' do
+    it 'does not filter out fields' do
+      actual = subject.render(data: @users[0], fields: nil)
+
+      expected = {
+        data: {
+          type: 'users',
+          id: '1',
+          attributes: {
+            name: 'User 1',
+            address: '123 Example st.'
+          },
+          relationships: {
+            posts: {
+              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'
+          }
+        }
+      }
+
+      expect(actual).to eq(expected)
+    end
+  end
+
+  it 'renders a toplevel meta' do
+    actual = subject.render(data: nil,
+                            meta: { this: 'is_meta' })
+    expected = {
+      data: nil,
+      meta: { this: 'is_meta' }
+    }
+
+    expect(actual).to eq(expected)
+  end
+
+  it 'renders toplevel links' do
+    actual = subject.render(data: nil,
+                            links: { self: 'http://api.example.com/users' })
+    expected = {
+      data: nil,
+      links: { self: 'http://api.example.com/users' }
+    }
+
+    expect(actual).to eq(expected)
+  end
+
+  it 'renders a toplevel jsonapi object' do
+    actual = subject.render(data: nil,
+                            jsonapi: {
+                              version: '1.0',
+                              meta: 'For real'
+                            })
+    expected = {
+      data: nil,
+      jsonapi: {
+        version: '1.0',
+        meta: 'For real'
+      }
+    }
+
+    expect(actual).to eq(expected)
+  end
+
+  it 'renders an empty hash if neither errors nor data provided' do
+    actual = subject.render({})
+    expected = {}
+
+    expect(actual).to eq(expected)
+  end
+
+  class ErrorResource
+    def initialize(id, title)
+      @id = id
+      @title = title
+    end
+
+    def as_jsonapi
+      { id: @id, title: @title }
+    end
+  end
+
+  it 'renders errors' do
+    errors = [ErrorResource.new('1', 'Not working'),
+              ErrorResource.new('2', 'Works poorly')]
+    actual = subject.render(errors: errors)
+    expected = {
+      errors: [{ id: '1', title: 'Not working' },
+               { id: '2', title: 'Works poorly' }]
+    }
+
+    expect(actual).to eq(expected)
+  end
+
+  context 'when rendering a relationship' do
+    it 'renders the linkage data only' do
+      actual = subject.render(data: @users[0], relationship: :posts)
+      expected = {
+        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
+        }
+      }
+
+      expect(actual).to eq(expected)
+    end
+
+    it 'renders supports include parameter' do
+      actual = subject.render(data: @users[0], relationship: :posts,
+                              include: 'posts.author')
+      actual_included = actual.delete(:included)
+
+      expected = {
+        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
+        }
+      }
+      expected_included = [
+        {
+          type: 'users',
+          id: '1',
+          attributes: {
+            name: 'User 1',
+            address: '123 Example st.'
+          },
+          relationships: {
+            posts: {
+              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'
+          }
+        },
+        {
+          type: 'posts',
+          id: '2',
+          attributes: {
+            title: 'Post 2',
+            date: 'today'
+          },
+          relationships: {
+            author: {
+              data: { type: 'users', id: '1' },
+              links: {
+                self: 'http://api.example.com/posts/2/relationships/author',
+                related: 'http://api.example.com/posts/2/author'
+              },
+              meta: {
+                author_active: true
+              }
+            }
+          }
+        }
+      ]
+
+      expect(actual).to eq(expected)
+      expect(actual_included).to match_array(expected_included)
+    end
+  end
+end
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
new file mode 100644
index 0000000..547994d
--- /dev/null
+++ b/spec/spec_helper.rb
@@ -0,0 +1,133 @@
+require 'simplecov'
+SimpleCov.start do
+  add_filter '/spec/'
+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

Debdiff

[The following lists of changes regard files as different if they have different names, permissions or owners.]

Files in second set of .debs but not in first

-rw-r--r--  root/root   /usr/lib/ruby/vendor_ruby/jsonapi/renderer/cached_resources_processor.rb
-rw-r--r--  root/root   /usr/lib/ruby/vendor_ruby/jsonapi/renderer/simple_resources_processor.rb
-rw-r--r--  root/root   /usr/share/doc/ruby-jsonapi-renderer/README.md.gz
-rw-r--r--  root/root   /usr/share/rubygems-integration/all/specifications/jsonapi-renderer-0.2.2.gemspec

Files in first set of .debs but not in second

-rw-r--r--  root/root   /usr/share/doc/ruby-jsonapi-renderer/README.md
-rw-r--r--  root/root   /usr/share/rubygems-integration/all/specifications/jsonapi-renderer-0.1.3.gemspec

Control files: lines which differ (wdiff format)

  • Ruby-Versions: all

More details

Full run details