├── .gitignore ├── .travis.yml ├── Gemfile ├── LICENSE ├── README.md ├── Rakefile ├── VERSION ├── jsonapi-renderer.gemspec ├── lib └── jsonapi │ ├── include_directive.rb │ ├── include_directive │ └── parser.rb │ ├── renderer.rb │ └── renderer │ ├── cached_resources_processor.rb │ ├── document.rb │ ├── resources_processor.rb │ └── simple_resources_processor.rb └── spec ├── caching_spec.rb ├── include_directive └── parser_spec.rb ├── include_directive_spec.rb ├── renderer_spec.rb └── spec_helper.rb /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | /.config 4 | /coverage/ 5 | /InstalledFiles 6 | /pkg/ 7 | /spec/reports/ 8 | /spec/examples.txt 9 | /test/tmp/ 10 | /test/version_tmp/ 11 | /tmp/ 12 | 13 | ## Specific to RubyMotion: 14 | .dat* 15 | .repl_history 16 | build/ 17 | 18 | ## Documentation cache and generated files: 19 | /.yardoc/ 20 | /_yardoc/ 21 | /doc/ 22 | /rdoc/ 23 | 24 | ## Environment normalization: 25 | /.bundle/ 26 | /vendor/bundle 27 | /lib/bundler/man/ 28 | 29 | # for a library or gem, you might want to ignore these files since the code is 30 | # intended to run in multiple environments; otherwise, check them in: 31 | Gemfile.lock 32 | .ruby-version 33 | .ruby-gemset 34 | 35 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: 36 | .rvmrc 37 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | sudo: false 3 | before_install: 4 | - bundle update 5 | env: 6 | global: 7 | - CC_TEST_REPORTER_ID=63659c56322a7a1262f6375083f44c8789ee405a6bcf9027189d67c90d08887c 8 | - 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) 9 | rvm: 10 | - 2.1 11 | - 2.2.2 12 | - 2.3.3 13 | - ruby-head 14 | matrix: 15 | allow_failures: 16 | - rvm: ruby-head 17 | before_script: 18 | - curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter 19 | - chmod +x ./cc-test-reporter 20 | - ./cc-test-reporter before-build 21 | after_script: 22 | # Preferably you will run test-reporter on branch update events. But 23 | # if you setup travis to build PR updates only, you don't need to run 24 | # the line below 25 | - if [ "$TRAVIS_PULL_REQUEST" == "false" ]; then ./cc-test-reporter after-build --exit-code $TRAVIS_TEST_RESULT; fi 26 | # In the case where travis is setup to build PR updates only, 27 | # uncomment the line below 28 | # - ./cc-test-reporter after-build --exit-code $TRAVIS_TEST_RESULT 29 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Lucas Hosseini 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # jsonapi-renderer 2 | Ruby gem for rendering [JSON API](http://jsonapi.org) documents. 3 | 4 | ## Status 5 | 6 | [![Gem Version](https://badge.fury.io/rb/jsonapi-renderer.svg)](https://badge.fury.io/rb/jsonapi-renderer) 7 | [![Build Status](https://secure.travis-ci.org/jsonapi-rb/jsonapi-renderer.svg?branch=master)](http://travis-ci.org/jsonapi-rb/renderer?branch=master) 8 | [![codecov](https://codecov.io/gh/jsonapi-rb/jsonapi-renderer/branch/master/graph/badge.svg)](https://codecov.io/gh/jsonapi-rb/renderer) 9 | [![Gitter chat](https://badges.gitter.im/gitterHQ/gitter.png)](https://gitter.im/jsonapi-rb/Lobby) 10 | 11 | ## Resources 12 | 13 | * Chat: [gitter](http://gitter.im/jsonapi-rb) 14 | * Twitter: [@jsonapirb](http://twitter.com/jsonapirb) 15 | * Docs: [jsonapi-rb.org](http://jsonapi-rb.org) 16 | 17 | ## Installation 18 | ```ruby 19 | # In Gemfile 20 | gem 'jsonapi-renderer' 21 | ``` 22 | then 23 | ``` 24 | $ bundle 25 | ``` 26 | or manually via 27 | ``` 28 | $ gem install jsonapi-renderer 29 | ``` 30 | 31 | ## Usage 32 | 33 | First, require the gem: 34 | ```ruby 35 | require 'jsonapi/renderer' 36 | ``` 37 | 38 | ### Rendering resources 39 | 40 | A resource here is any class that implements the following interface: 41 | ```ruby 42 | class ResourceInterface 43 | # Returns the type of the resource. 44 | # @return [String] 45 | def jsonapi_type; end 46 | 47 | # Returns the id of the resource. 48 | # @return [String] 49 | def jsonapi_id; end 50 | 51 | # Returns a hash containing, for each included relationship, an array of the 52 | # resources to be included from that one. 53 | # @param included_relationships [Array] The keys of the relationships 54 | # to be included. 55 | # @return [Hash{Symbol => Array<#ResourceInterface>}] 56 | def jsonapi_related(included_relationships); end 57 | 58 | # Returns a JSON API-compliant representation of the resource as a hash. 59 | # @param options [Hash] 60 | # @option fields [Set, Nil] The requested fields, or nil. 61 | # @option include [Set] The requested relationships to 62 | # include (defaults to []). 63 | # @return [Hash] 64 | def as_jsonapi(options = {}); end 65 | end 66 | ``` 67 | 68 | #### Rendering a single resource 69 | ```ruby 70 | JSONAPI.render(data: resource, 71 | include: include_string, 72 | fields: fields_hash, 73 | meta: meta_hash, 74 | links: links_hash) 75 | ``` 76 | 77 | This returns a JSON API compliant hash representing the described document. 78 | 79 | #### Rendering a collection of resources 80 | ```ruby 81 | JSONAPI.render(data: resources, 82 | include: include_string, 83 | fields: fields_hash, 84 | meta: meta_hash, 85 | links: links_hash) 86 | ``` 87 | 88 | This returns a JSON API compliant hash representing the described document. 89 | 90 | #### Rendering a relationship 91 | ```ruby 92 | JSONAPI.render(data: resource, 93 | relationship: :posts, 94 | include: include_string, 95 | fields: fields_hash, 96 | meta: meta_hash, 97 | links: links_hash) 98 | ``` 99 | 100 | This returns a JSON API compliant hash representing the described document. 101 | 102 | ### Rendering errors 103 | 104 | ```ruby 105 | JSONAPI.render_errors(errors: errors, 106 | meta: meta_hash, 107 | links: links_hash) 108 | ``` 109 | 110 | where `errors` is an array of objects implementing the `as_jsonapi` method, that 111 | returns a JSON API-compliant representation of the error. 112 | 113 | This returns a JSON API compliant hash representing the described document. 114 | 115 | ### Caching 116 | 117 | The generated JSON fragments can be cached in any cache implementation 118 | supporting the `fetch_multi` method. 119 | 120 | When using caching, the serializable resources must implement an 121 | additional `jsonapi_cache_key` method: 122 | ```ruby 123 | # Returns a cache key for the resource, parameterized by the `include` and 124 | # `fields` options. 125 | # @param options [Hash] 126 | # @option fields [Set, Nil] The requested fields, or nil. 127 | # @option include [Set] The requested relationships to 128 | # include (defaults to []). 129 | # @return [String] 130 | def jsonapi_cache_key(options = {}); end 131 | ``` 132 | 133 | The cache instance must be passed to the renderer as follows: 134 | ```ruby 135 | JSONAPI.render(data: resources, 136 | include: include_string, 137 | fields: fields_hash, 138 | cache: cache_instance) 139 | ``` 140 | 141 | ## License 142 | 143 | jsonapi-renderer is released under the [MIT License](http://www.opensource.org/licenses/MIT). 144 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | require 'rspec/core/rake_task' 3 | 4 | RSpec::Core::RakeTask.new(:spec) do |t| 5 | t.pattern = Dir.glob('spec/**/*_spec.rb') 6 | end 7 | 8 | task default: :test 9 | task test: :spec 10 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 0.2.2 2 | -------------------------------------------------------------------------------- /jsonapi-renderer.gemspec: -------------------------------------------------------------------------------- 1 | version = File.read(File.expand_path('../VERSION', __FILE__)).strip 2 | 3 | Gem::Specification.new do |spec| 4 | spec.name = 'jsonapi-renderer' 5 | spec.version = version 6 | spec.author = 'Lucas Hosseini' 7 | spec.email = 'lucas.hosseini@gmail.com' 8 | spec.summary = 'Render JSONAPI documents.' 9 | spec.description = 'Efficiently render JSON API documents.' 10 | spec.homepage = 'https://github.com/jsonapi-rb/jsonapi-renderer' 11 | spec.license = 'MIT' 12 | 13 | spec.files = Dir['README.md', 'lib/**/*'] 14 | spec.require_path = 'lib' 15 | 16 | spec.add_development_dependency 'rake', '~> 11.3' 17 | spec.add_development_dependency 'rspec', '~> 3.5' 18 | spec.add_development_dependency 'simplecov' 19 | end 20 | -------------------------------------------------------------------------------- /lib/jsonapi/include_directive.rb: -------------------------------------------------------------------------------- 1 | require 'jsonapi/include_directive/parser' 2 | 3 | module JSONAPI 4 | # Represent a recursive set of include directives 5 | # (c.f. http://jsonapi.org/format/#fetching-includes) 6 | # 7 | # Addition to the spec: two wildcards, namely '*' and '**'. 8 | # The former stands for any one level of relationship, and the latter stands 9 | # for any number of levels of relationships. 10 | # @example 'posts.*' # => Include related posts, and all the included posts' 11 | # related resources. 12 | # @example 'posts.**' # => Include related posts, and all the included 13 | # posts' related resources, and their related resources, recursively. 14 | class IncludeDirective 15 | # @param include_args (see Parser.parse_include_args) 16 | def initialize(include_args, options = {}) 17 | include_hash = Parser.parse_include_args(include_args) 18 | @hash = include_hash.each_with_object({}) do |(key, value), hash| 19 | raise InvalidKey, key unless valid?(key) 20 | 21 | hash[key] = self.class.new(value, options) 22 | end 23 | @options = options 24 | end 25 | 26 | # @param key [Symbol, String] 27 | def key?(key) 28 | @hash.key?(key.to_sym) || 29 | (@options[:allow_wildcard] && (@hash.key?(:*) || @hash.key?(:**))) 30 | end 31 | 32 | # @return [Array] 33 | def keys 34 | @hash.keys 35 | end 36 | 37 | # @param key [Symbol, String] 38 | # @return [IncludeDirective, nil] 39 | def [](key) 40 | case 41 | when @hash.key?(key.to_sym) 42 | @hash[key.to_sym] 43 | when @options[:allow_wildcard] && @hash.key?(:**) 44 | self.class.new({ :** => {} }, @options) 45 | when @options[:allow_wildcard] && @hash.key?(:*) 46 | @hash[:*] 47 | end 48 | end 49 | 50 | # @return [Hash{Symbol => Hash}] 51 | def to_hash 52 | @hash.each_with_object({}) do |(key, value), hash| 53 | hash[key] = value.to_hash 54 | end 55 | end 56 | 57 | # @return [String] 58 | def to_string 59 | string_array = @hash.map do |(key, value)| 60 | string_value = value.to_string 61 | if string_value == '' 62 | key.to_s 63 | else 64 | string_value 65 | .split(',') 66 | .map { |x| key.to_s + '.' + x } 67 | .join(',') 68 | end 69 | end 70 | 71 | string_array.join(',') 72 | end 73 | 74 | class InvalidKey < StandardError; end 75 | 76 | private 77 | 78 | def valid?(key) 79 | key.match(valid_json_key_name_regex) 80 | end 81 | 82 | def valid_json_key_name_regex 83 | # https://jsonapi.org/format/#document-member-names 84 | /^(?![\s\-_])[\u0080-\u10FFA-Za-z0-9* _-]+(? {} } 13 | when Hash 14 | parse_hash(include_args) 15 | when Array 16 | parse_array(include_args) 17 | when String 18 | parse_string(include_args) 19 | else 20 | {} 21 | end 22 | end 23 | 24 | # @api private 25 | def parse_string(include_string) 26 | include_string.split(',') 27 | .each_with_object({}) do |path, hash| 28 | deep_merge!(hash, parse_path_string(path)) 29 | end 30 | end 31 | 32 | # @api private 33 | def parse_path_string(include_path) 34 | include_path.split('.') 35 | .reverse 36 | .reduce({}) { |a, e| { e.to_sym => a } } 37 | end 38 | 39 | # @api private 40 | def parse_hash(include_hash) 41 | include_hash.each_with_object({}) do |(key, value), hash| 42 | hash[key.to_sym] = parse_include_args(value) 43 | end 44 | end 45 | 46 | # @api private 47 | def parse_array(include_array) 48 | include_array.each_with_object({}) do |x, hash| 49 | deep_merge!(hash, parse_include_args(x)) 50 | end 51 | end 52 | 53 | # @api private 54 | def deep_merge!(src, ext) 55 | ext.each do |k, v| 56 | if src[k].is_a?(Hash) && v.is_a?(Hash) 57 | deep_merge!(src[k], v) 58 | else 59 | src[k] = v 60 | end 61 | end 62 | end 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/jsonapi/renderer.rb: -------------------------------------------------------------------------------- 1 | require 'jsonapi/renderer/document' 2 | 3 | module JSONAPI 4 | class Renderer 5 | # Render a JSON API document. 6 | # 7 | # @param params [Hash] 8 | # @option data [(#jsonapi_id, #jsonapi_type, #jsonapi_related, #as_jsonapi), 9 | # Array<(#jsonapi_id, #jsonapi_type, #jsonapi_related, 10 | # #as_jsonapi)>, 11 | # nil] Primary resource(s) to be rendered. 12 | # @option errors [Array<#jsonapi_id>] Errors to be rendered. 13 | # @option include Relationships to be included. See 14 | # JSONAPI::IncludeDirective. 15 | # @option fields [Hash{Symbol, Array}, Hash{String, Array}] 16 | # List of requested fields for some or all of the resource types. 17 | # @option meta [Hash] Non-standard top-level meta information to be 18 | # included. 19 | # @option links [Hash] Top-level links to be included. 20 | # @option jsonapi_object [Hash] JSON API object. 21 | def render(params) 22 | Document.new(params).to_hash 23 | end 24 | end 25 | 26 | module_function 27 | 28 | # @see JSONAPI::Renderer#render 29 | def render(params) 30 | Renderer.new.render(params) 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/jsonapi/renderer/cached_resources_processor.rb: -------------------------------------------------------------------------------- 1 | require 'jsonapi/renderer/resources_processor' 2 | 3 | module JSONAPI 4 | class Renderer 5 | # @private 6 | class CachedResourcesProcessor < ResourcesProcessor 7 | class JSONString < String 8 | def to_json(*) 9 | self 10 | end 11 | end 12 | 13 | def initialize(cache) 14 | @cache = cache 15 | end 16 | 17 | def process_resources 18 | [@primary, @included].each do |resources| 19 | cache_hash = cache_key_map(resources) 20 | processed_resources = @cache.fetch_multi(*cache_hash.keys) do |key| 21 | res, include, fields = cache_hash[key] 22 | json = res.as_jsonapi(include: include, fields: fields).to_json 23 | 24 | JSONString.new(json) 25 | end 26 | 27 | resources.replace(processed_resources.values) 28 | end 29 | end 30 | 31 | def cache_key_map(resources) 32 | resources.each_with_object({}) do |res, h| 33 | ri = [res.jsonapi_type, res.jsonapi_id] 34 | include_rels = @include_rels[ri] 35 | # Sort for cache key consistency 36 | include_dir = include_rels.keys.sort unless include_rels.nil? 37 | fields = @fields[ri.first.to_sym] 38 | h[res.jsonapi_cache_key(include: include_dir, fields: fields)] = 39 | [res, include_dir, fields] 40 | end 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/jsonapi/renderer/document.rb: -------------------------------------------------------------------------------- 1 | require 'jsonapi/include_directive' 2 | require 'jsonapi/renderer/simple_resources_processor' 3 | require 'jsonapi/renderer/cached_resources_processor' 4 | 5 | module JSONAPI 6 | class Renderer 7 | # @private 8 | class Document 9 | def initialize(params = {}) 10 | @data = params.fetch(:data, :no_data) 11 | @errors = params.fetch(:errors, []) 12 | @meta = params[:meta] 13 | @links = params[:links] || {} 14 | @fields = _canonize_fields(params[:fields] || {}) 15 | @jsonapi = params[:jsonapi] 16 | @include = JSONAPI::IncludeDirective.new(params[:include] || {}) 17 | @relationship = params[:relationship] 18 | @cache = params[:cache] 19 | end 20 | 21 | def to_hash 22 | @hash ||= document_hash 23 | end 24 | alias to_h to_hash 25 | 26 | private 27 | 28 | # rubocop:disable Metrics/PerceivedComplexity, Metrics/MethodLength 29 | # rubocop:disable Metrics/CyclomaticComplexity 30 | def document_hash 31 | {}.tap do |hash| 32 | if @relationship 33 | hash.merge!(relationship_hash) 34 | elsif @data != :no_data 35 | hash.merge!(data_hash) 36 | elsif @errors.any? 37 | hash.merge!(errors_hash) 38 | end 39 | hash[:links] = @links if @links.any? 40 | hash[:meta] = @meta unless @meta.nil? 41 | hash[:jsonapi] = @jsonapi unless @jsonapi.nil? 42 | end 43 | end 44 | # rubocop:enable Metrics/PerceivedComplexity, Metrics/MethodLength 45 | # rubocop:enable Metrics/CyclomaticComplexity 46 | 47 | def data_hash 48 | primary, included = 49 | resources_processor.process(Array(@data), @include, @fields) 50 | {}.tap do |hash| 51 | hash[:data] = @data.respond_to?(:to_ary) ? primary : primary[0] 52 | hash[:included] = included if included.any? 53 | end 54 | end 55 | 56 | # rubocop:disable Metrics/MethodLength, Metrics/AbcSize 57 | def relationship_hash 58 | rel_name = @relationship.to_sym 59 | data = @data.jsonapi_related([rel_name])[rel_name] 60 | included = 61 | if @include.key?(rel_name) 62 | resources_processor.process(data, @include[rel_name], @fields) 63 | .flatten! 64 | else 65 | [] 66 | end 67 | 68 | res = @data.as_jsonapi(fields: [rel_name], include: [rel_name]) 69 | rel = res[:relationships][rel_name] 70 | @links = rel[:links].merge!(@links) 71 | @meta ||= rel[:meta] 72 | 73 | {}.tap do |hash| 74 | hash[:data] = rel[:data] 75 | hash[:included] = included if included.any? 76 | end 77 | end 78 | # rubocop:enable Metrics/MethodLength, Metrics/AbcSize 79 | 80 | def errors_hash 81 | {}.tap do |hash| 82 | hash[:errors] = @errors.flat_map(&:as_jsonapi) 83 | end 84 | end 85 | 86 | def resources_processor 87 | if @cache 88 | CachedResourcesProcessor.new(@cache) 89 | else 90 | SimpleResourcesProcessor.new 91 | end 92 | end 93 | 94 | def _canonize_fields(fields) 95 | fields.each_with_object({}) do |(k, v), h| 96 | h[k.to_sym] = v.map(&:to_sym).sort! 97 | end 98 | end 99 | end 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /lib/jsonapi/renderer/resources_processor.rb: -------------------------------------------------------------------------------- 1 | module JSONAPI 2 | class Renderer 3 | # @private 4 | class ResourcesProcessor 5 | def process(resources, include, fields) 6 | @resources = resources 7 | @include = include 8 | @fields = fields 9 | 10 | traverse_resources 11 | process_resources 12 | 13 | [@primary, @included] 14 | end 15 | 16 | private 17 | 18 | def traverse_resources 19 | # Use hash instead of set for better performances 20 | @traversed = {} # Hash[type, id, prefix] => true 21 | @include_rels = {} # Hash[type, id] => Hash[include_key => true] 22 | @queue = [] 23 | @primary = [] 24 | @included = [] 25 | 26 | initialize_queue 27 | traverse_queue 28 | end 29 | 30 | def initialize_queue 31 | @resources.each do |res| 32 | @traversed[[res.jsonapi_type, res.jsonapi_id, '']] = true 33 | traverse_resource(res, @include.keys, true) 34 | enqueue_related_resources(res, '', @include) 35 | end 36 | end 37 | 38 | def traverse_queue 39 | until @queue.empty? 40 | res, prefix, include_dir = @queue.shift 41 | traverse_resource(res, include_dir.keys, false) 42 | enqueue_related_resources(res, prefix, include_dir) 43 | end 44 | end 45 | 46 | def traverse_resource(res, include_keys, primary) 47 | ri = [res.jsonapi_type, res.jsonapi_id] 48 | keys_hash = {} 49 | include_keys.each { |k| keys_hash[k] = true } 50 | 51 | if @include_rels.include?(ri) 52 | @include_rels[ri].merge!(keys_hash) 53 | else 54 | @include_rels[ri] = keys_hash 55 | (primary ? @primary : @included) << res 56 | end 57 | end 58 | 59 | def enqueue_related_resources(res, prefix, include_dir) 60 | res.jsonapi_related(include_dir.keys).each do |key, data| 61 | child_prefix = "#{prefix}.#{key}".freeze 62 | data.each do |child_res| 63 | next if child_res.nil? 64 | enqueue_resource(child_res, child_prefix, include_dir[key]) 65 | end 66 | end 67 | end 68 | 69 | def enqueue_resource(res, prefix, include_dir) 70 | key = [res.jsonapi_type, res.jsonapi_id, prefix] 71 | return if @traversed[key] 72 | 73 | @traversed[key] = true 74 | @queue << [res, prefix, include_dir] 75 | end 76 | 77 | def process_resources 78 | raise 'Not implemented' 79 | end 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /lib/jsonapi/renderer/simple_resources_processor.rb: -------------------------------------------------------------------------------- 1 | require 'jsonapi/renderer/resources_processor' 2 | 3 | module JSONAPI 4 | class Renderer 5 | # @api private 6 | class SimpleResourcesProcessor < ResourcesProcessor 7 | def process_resources 8 | [@primary, @included].each do |resources| 9 | resources.map! do |res| 10 | ri = [res.jsonapi_type, res.jsonapi_id] 11 | include_dir = @include_rels[ri].keys 12 | fields = @fields[res.jsonapi_type.to_sym] 13 | res.as_jsonapi(include: include_dir, fields: fields) 14 | end 15 | end 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/caching_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | class Cache 4 | def initialize 5 | @cache = {} 6 | end 7 | 8 | def fetch_multi(*keys) 9 | keys.each_with_object({}) do |k, h| 10 | @cache[k] = yield(k) unless @cache.key?(k) 11 | h[k] = @cache[k] 12 | end 13 | end 14 | end 15 | 16 | describe JSONAPI::Renderer, '#render' do 17 | before(:all) do 18 | @users = [ 19 | UserResource.new(1, 'User 1', '123 Example st.', []), 20 | UserResource.new(2, 'User 2', '234 Example st.', []), 21 | UserResource.new(3, 'User 3', '345 Example st.', []), 22 | UserResource.new(4, 'User 4', '456 Example st.', []) 23 | ] 24 | @posts = [ 25 | PostResource.new(1, 'Post 1', 'yesterday', @users[1]), 26 | PostResource.new(2, 'Post 2', 'today', @users[0]), 27 | PostResource.new(3, 'Post 3', 'tomorrow', @users[1]) 28 | ] 29 | @users[0].posts = [@posts[1]] 30 | @users[1].posts = [@posts[0], @posts[2]] 31 | end 32 | 33 | it 'renders included relationships' do 34 | cache = Cache.new 35 | # Warm up the cache. 36 | subject.render(data: @users[0], 37 | include: 'posts', 38 | cache: cache) 39 | # Actual call on warm cache. 40 | actual = subject.render(data: @users[0], 41 | include: 'posts', 42 | cache: cache) 43 | expected = { 44 | data: { 45 | type: 'users', 46 | id: '1', 47 | attributes: { 48 | name: 'User 1', 49 | address: '123 Example st.' 50 | }, 51 | relationships: { 52 | posts: { 53 | data: [{ type: 'posts', id: '2' }], 54 | links: { 55 | self: 'http://api.example.com/users/1/relationships/posts', 56 | related: { 57 | href: 'http://api.example.com/users/1/posts', 58 | meta: { 59 | do_not_use: true 60 | } 61 | } 62 | }, 63 | meta: { 64 | deleted_posts: 5 65 | } 66 | } 67 | }, 68 | links: { 69 | self: 'http://api.example.com/users/1' 70 | }, 71 | meta: { 72 | user_meta: 'is_meta' 73 | } 74 | }, 75 | included: [ 76 | { 77 | type: 'posts', 78 | id: '2', 79 | attributes: { 80 | title: 'Post 2', 81 | date: 'today' 82 | }, 83 | relationships: { 84 | author: { 85 | links: { 86 | self: 'http://api.example.com/posts/2/relationships/author', 87 | related: 'http://api.example.com/posts/2/author' 88 | }, 89 | meta: { 90 | author_active: true 91 | } 92 | } 93 | } 94 | } 95 | ] 96 | } 97 | 98 | expect(JSON.parse(actual.to_json)).to eq(JSON.parse(expected.to_json)) 99 | expect(actual[:data]).to be_a(JSONAPI::Renderer::CachedResourcesProcessor::JSONString) 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /spec/include_directive/parser_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | require 'jsonapi/include_directive' 4 | 5 | describe JSONAPI::IncludeDirective::Parser, '.parse_include_args' do 6 | it 'handles arrays of symbols and hashes' do 7 | args = [:friends, 8 | comments: [:author], 9 | posts: [:author, 10 | comments: [:author]]] 11 | hash = JSONAPI::IncludeDirective::Parser.parse_include_args(args) 12 | expected = { 13 | friends: {}, 14 | comments: { author: {} }, 15 | posts: { author: {}, comments: { author: {} } } 16 | } 17 | 18 | expect(hash).to eq expected 19 | end 20 | 21 | it 'handles strings' do 22 | str = 'friends,comments.author,posts.author,posts.comments.author' 23 | hash = JSONAPI::IncludeDirective::Parser.parse_include_args(str) 24 | expected = { 25 | friends: {}, 26 | comments: { author: {} }, 27 | posts: { author: {}, comments: { author: {} } } 28 | } 29 | 30 | expect(hash).to eq expected 31 | end 32 | 33 | it 'treats spaces as part of the resource name' do 34 | str = 'friends, comments.author , posts.author,posts. comments.author' 35 | hash = JSONAPI::IncludeDirective::Parser.parse_include_args(str) 36 | expected = { 37 | friends: {}, 38 | :' comments' => { :'author ' => {} }, 39 | :' posts' => { author: {} }, 40 | :'posts' => { :' comments' => { author: {} } } 41 | } 42 | 43 | expect(hash).to eq expected 44 | end 45 | 46 | it 'handles common prefixes in strings' do 47 | args = ['friends', 'comments.author', 'posts.author', 48 | 'posts.comments.author'] 49 | hash = JSONAPI::IncludeDirective::Parser.parse_include_args(args) 50 | expected = { 51 | friends: {}, 52 | comments: { author: {} }, 53 | posts: { author: {}, comments: { author: {} } } 54 | } 55 | 56 | expect(hash).to eq expected 57 | end 58 | 59 | it 'handles an empty string' do 60 | args = '' 61 | hash = JSONAPI::IncludeDirective::Parser.parse_include_args(args) 62 | expected = {} 63 | 64 | expect(hash).to eq expected 65 | end 66 | 67 | it 'handles an empty array' do 68 | args = [] 69 | hash = JSONAPI::IncludeDirective::Parser.parse_include_args(args) 70 | expected = {} 71 | 72 | expect(hash).to eq expected 73 | end 74 | 75 | it 'handles invalid input' do 76 | args = Object.new 77 | hash = JSONAPI::IncludeDirective::Parser.parse_include_args(args) 78 | expected = {} 79 | 80 | expect(hash).to eq expected 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /spec/include_directive_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | require 'jsonapi/include_directive' 4 | 5 | describe JSONAPI::IncludeDirective, '.initialize' do 6 | context 'raises InvalidKey when' do 7 | ( 8 | ["\u002B", "\u002C", "\u002E", "\u005B", "\u005D", "\u002A", "\u002F", 9 | "\u0040", "\u005C", "\u005E", "\u0060"] + ("\u0021".."\u0029").to_a \ 10 | + ("\u003A".."\u003F").to_a \ 11 | + ("\u007B".."\u007F").to_a \ 12 | + ("\u0000".."\u001F").to_a \ 13 | - ['*', '.', ','] 14 | ).each do |invalid_character| 15 | it "invalid character provided: '#{invalid_character}'" do 16 | expect { JSONAPI::IncludeDirective.new(invalid_character) } 17 | .to raise_error(JSONAPI::IncludeDirective::InvalidKey) 18 | end 19 | end 20 | 21 | [' ', '_', '-'].each do |char| 22 | it "starts with following character: '#{char}'" do 23 | expect { JSONAPI::IncludeDirective.new("#{char}_with_valid") } 24 | .to raise_error(JSONAPI::IncludeDirective::InvalidKey, "#{char}_with_valid") 25 | end 26 | 27 | it "ends with following character: '#{char}'" do 28 | expect { JSONAPI::IncludeDirective.new("valid_with_#{char}") } 29 | .to raise_error(JSONAPI::IncludeDirective::InvalidKey, "valid_with_#{char}") 30 | end 31 | end 32 | end 33 | 34 | context 'does not raise InvalidKey' do 35 | ["\u0080", "B", "t", "5", "\u0100", "\u10FFFAA"].each do |char| 36 | it "when provided character '#{char}'" do 37 | expect(JSONAPI::IncludeDirective.new(char).key?(char)).to be true 38 | end 39 | end 40 | 41 | [' ', '_', '-'].each do |char| 42 | it "when '#{char}' is not at beginning or end" do 43 | key = "valid#{char}valid" 44 | expect(JSONAPI::IncludeDirective.new(key).key?(key)).to be true 45 | end 46 | end 47 | end 48 | end 49 | 50 | describe JSONAPI::IncludeDirective, '.key?' do 51 | it 'handles existing keys' do 52 | str = 'posts.comments' 53 | include_directive = JSONAPI::IncludeDirective.new(str) 54 | 55 | expect(include_directive.key?(:posts)).to be_truthy 56 | end 57 | 58 | it 'handles absent keys' do 59 | str = 'posts.comments' 60 | include_directive = JSONAPI::IncludeDirective.new(str) 61 | 62 | expect(include_directive.key?(:author)).to be_falsy 63 | end 64 | 65 | it 'handles wildcards' do 66 | str = 'posts.*' 67 | include_directive = JSONAPI::IncludeDirective.new( 68 | str, allow_wildcard: true) 69 | 70 | expect(include_directive[:posts].key?(:author)).to be_truthy 71 | expect(include_directive[:posts][:author].key?(:comments)).to be_falsy 72 | end 73 | 74 | it 'handles wildcards' do 75 | str = 'posts.**' 76 | include_directive = JSONAPI::IncludeDirective.new( 77 | str, allow_wildcard: true) 78 | 79 | expect(include_directive[:posts].key?(:author)).to be_truthy 80 | expect(include_directive[:posts][:author].key?(:comments)).to be_truthy 81 | end 82 | end 83 | 84 | describe JSONAPI::IncludeDirective, '.to_string' do 85 | it 'works' do 86 | str = 'friends,comments.author,posts.author,posts.comments.author' 87 | include_directive = JSONAPI::IncludeDirective.new(str) 88 | expected = include_directive.to_hash 89 | actual = JSONAPI::IncludeDirective.new(include_directive.to_string) 90 | .to_hash 91 | 92 | expect(actual).to eq expected 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /spec/renderer_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe JSONAPI::Renderer, '#render' do 4 | before(:all) do 5 | @users = [ 6 | UserResource.new(1, 'User 1', '123 Example st.', []), 7 | UserResource.new(2, 'User 2', '234 Example st.', []), 8 | UserResource.new(3, 'User 3', '345 Example st.', []), 9 | UserResource.new(4, 'User 4', '456 Example st.', []) 10 | ] 11 | @posts = [ 12 | PostResource.new(1, 'Post 1', 'yesterday', @users[1]), 13 | PostResource.new(2, 'Post 2', 'today', @users[0]), 14 | PostResource.new(3, 'Post 3', 'tomorrow', @users[1]) 15 | ] 16 | @users[0].posts = [@posts[1]] 17 | @users[1].posts = [@posts[0], @posts[2]] 18 | end 19 | 20 | it 'renders nil' do 21 | actual = subject.render(data: nil) 22 | expected = { 23 | data: nil 24 | } 25 | 26 | expect(actual).to eq(expected) 27 | end 28 | 29 | it 'renders an empty array' do 30 | actual = subject.render(data: []) 31 | expected = { 32 | data: [] 33 | } 34 | 35 | expect(actual).to eq(expected) 36 | end 37 | 38 | it 'renders a single resource' do 39 | actual = subject.render(data: @users[0]) 40 | expected = { 41 | data: { 42 | type: 'users', 43 | id: '1', 44 | attributes: { 45 | name: 'User 1', 46 | address: '123 Example st.' 47 | }, 48 | relationships: { 49 | posts: { 50 | links: { 51 | self: 'http://api.example.com/users/1/relationships/posts', 52 | related: { 53 | href: 'http://api.example.com/users/1/posts', 54 | meta: { 55 | do_not_use: true 56 | } 57 | } 58 | }, 59 | meta: { 60 | deleted_posts: 5 61 | } 62 | } 63 | }, 64 | links: { 65 | self: 'http://api.example.com/users/1' 66 | }, 67 | meta: { 68 | user_meta: 'is_meta' 69 | } 70 | } 71 | } 72 | 73 | expect(actual).to eq(expected) 74 | end 75 | 76 | it 'renders a collection of resources' do 77 | actual = subject.render(data: [@users[0], 78 | @users[1]]) 79 | expected = { 80 | data: [ 81 | { 82 | type: 'users', 83 | id: '1', 84 | attributes: { 85 | name: 'User 1', 86 | address: '123 Example st.' 87 | }, 88 | relationships: { 89 | posts: { 90 | links: { 91 | self: 'http://api.example.com/users/1/relationships/posts', 92 | related: { 93 | href: 'http://api.example.com/users/1/posts', 94 | meta: { 95 | do_not_use: true 96 | } 97 | } 98 | }, 99 | meta: { 100 | deleted_posts: 5 101 | } 102 | } 103 | }, 104 | links: { 105 | self: 'http://api.example.com/users/1' 106 | }, 107 | meta: { 108 | user_meta: 'is_meta' 109 | } 110 | }, 111 | { 112 | type: 'users', 113 | id: '2', 114 | attributes: { 115 | name: 'User 2', 116 | address: '234 Example st.' 117 | }, 118 | relationships: { 119 | posts: { 120 | links: { 121 | self: 'http://api.example.com/users/2/relationships/posts', 122 | related: { 123 | href: 'http://api.example.com/users/2/posts', 124 | meta: { 125 | do_not_use: true 126 | } 127 | } 128 | }, 129 | meta: { 130 | deleted_posts: 5 131 | } 132 | } 133 | }, 134 | links: { 135 | self: 'http://api.example.com/users/2' 136 | }, 137 | meta: { 138 | user_meta: 'is_meta' 139 | } 140 | } 141 | ] 142 | } 143 | 144 | expect(actual).to eq(expected) 145 | end 146 | 147 | it 'renders included relationships' do 148 | actual = subject.render(data: @users[0], 149 | include: 'posts') 150 | expected = { 151 | data: { 152 | type: 'users', 153 | id: '1', 154 | attributes: { 155 | name: 'User 1', 156 | address: '123 Example st.' 157 | }, 158 | relationships: { 159 | posts: { 160 | data: [{ type: 'posts', id: '2' }], 161 | links: { 162 | self: 'http://api.example.com/users/1/relationships/posts', 163 | related: { 164 | href: 'http://api.example.com/users/1/posts', 165 | meta: { 166 | do_not_use: true 167 | } 168 | } 169 | }, 170 | meta: { 171 | deleted_posts: 5 172 | } 173 | } 174 | }, 175 | links: { 176 | self: 'http://api.example.com/users/1' 177 | }, 178 | meta: { 179 | user_meta: 'is_meta' 180 | } 181 | }, 182 | included: [ 183 | { 184 | type: 'posts', 185 | id: '2', 186 | attributes: { 187 | title: 'Post 2', 188 | date: 'today' 189 | }, 190 | relationships: { 191 | author: { 192 | links: { 193 | self: 'http://api.example.com/posts/2/relationships/author', 194 | related: 'http://api.example.com/posts/2/author' 195 | }, 196 | meta: { 197 | author_active: true 198 | } 199 | } 200 | } 201 | } 202 | ] 203 | } 204 | 205 | expect(actual).to eq(expected) 206 | end 207 | 208 | it 'filters out fields' do 209 | actual = subject.render(data: @users[0], 210 | fields: { users: [:name] }) 211 | expected = { 212 | data: { 213 | type: 'users', 214 | id: '1', 215 | attributes: { 216 | name: 'User 1' 217 | }, 218 | links: { 219 | self: 'http://api.example.com/users/1' 220 | }, 221 | meta: { 222 | user_meta: 'is_meta' 223 | } 224 | } 225 | } 226 | 227 | expect(actual).to eq(expected) 228 | end 229 | 230 | context 'when fields option is nil' do 231 | it 'does not filter out fields' do 232 | actual = subject.render(data: @users[0], fields: nil) 233 | 234 | expected = { 235 | data: { 236 | type: 'users', 237 | id: '1', 238 | attributes: { 239 | name: 'User 1', 240 | address: '123 Example st.' 241 | }, 242 | relationships: { 243 | posts: { 244 | links: { 245 | self: 'http://api.example.com/users/1/relationships/posts', 246 | related: { 247 | href: 'http://api.example.com/users/1/posts', 248 | meta: { 249 | do_not_use: true 250 | } 251 | } 252 | }, 253 | meta: { 254 | deleted_posts: 5 255 | } 256 | } 257 | }, 258 | links: { 259 | self: 'http://api.example.com/users/1' 260 | }, 261 | meta: { 262 | user_meta: 'is_meta' 263 | } 264 | } 265 | } 266 | 267 | expect(actual).to eq(expected) 268 | end 269 | end 270 | 271 | it 'renders a toplevel meta' do 272 | actual = subject.render(data: nil, 273 | meta: { this: 'is_meta' }) 274 | expected = { 275 | data: nil, 276 | meta: { this: 'is_meta' } 277 | } 278 | 279 | expect(actual).to eq(expected) 280 | end 281 | 282 | it 'renders toplevel links' do 283 | actual = subject.render(data: nil, 284 | links: { self: 'http://api.example.com/users' }) 285 | expected = { 286 | data: nil, 287 | links: { self: 'http://api.example.com/users' } 288 | } 289 | 290 | expect(actual).to eq(expected) 291 | end 292 | 293 | it 'renders a toplevel jsonapi object' do 294 | actual = subject.render(data: nil, 295 | jsonapi: { 296 | version: '1.0', 297 | meta: 'For real' 298 | }) 299 | expected = { 300 | data: nil, 301 | jsonapi: { 302 | version: '1.0', 303 | meta: 'For real' 304 | } 305 | } 306 | 307 | expect(actual).to eq(expected) 308 | end 309 | 310 | it 'renders an empty hash if neither errors nor data provided' do 311 | actual = subject.render({}) 312 | expected = {} 313 | 314 | expect(actual).to eq(expected) 315 | end 316 | 317 | class ErrorResource 318 | def initialize(id, title) 319 | @id = id 320 | @title = title 321 | end 322 | 323 | def as_jsonapi 324 | { id: @id, title: @title } 325 | end 326 | end 327 | 328 | it 'renders errors' do 329 | errors = [ErrorResource.new('1', 'Not working'), 330 | ErrorResource.new('2', 'Works poorly')] 331 | actual = subject.render(errors: errors) 332 | expected = { 333 | errors: [{ id: '1', title: 'Not working' }, 334 | { id: '2', title: 'Works poorly' }] 335 | } 336 | 337 | expect(actual).to eq(expected) 338 | end 339 | 340 | context 'when rendering a relationship' do 341 | it 'renders the linkage data only' do 342 | actual = subject.render(data: @users[0], relationship: :posts) 343 | expected = { 344 | data: [{ type: 'posts', id: '2' }], 345 | links: { 346 | self: 'http://api.example.com/users/1/relationships/posts', 347 | related: { 348 | href: 'http://api.example.com/users/1/posts', 349 | meta: { 350 | do_not_use: true 351 | } 352 | } 353 | }, 354 | meta: { 355 | deleted_posts: 5 356 | } 357 | } 358 | 359 | expect(actual).to eq(expected) 360 | end 361 | 362 | it 'renders supports include parameter' do 363 | actual = subject.render(data: @users[0], relationship: :posts, 364 | include: 'posts.author') 365 | actual_included = actual.delete(:included) 366 | 367 | expected = { 368 | data: [{ type: 'posts', id: '2' }], 369 | links: { 370 | self: 'http://api.example.com/users/1/relationships/posts', 371 | related: { 372 | href: 'http://api.example.com/users/1/posts', 373 | meta: { 374 | do_not_use: true 375 | } 376 | } 377 | }, 378 | meta: { 379 | deleted_posts: 5 380 | } 381 | } 382 | expected_included = [ 383 | { 384 | type: 'users', 385 | id: '1', 386 | attributes: { 387 | name: 'User 1', 388 | address: '123 Example st.' 389 | }, 390 | relationships: { 391 | posts: { 392 | links: { 393 | self: 'http://api.example.com/users/1/relationships/posts', 394 | related: { 395 | href: 'http://api.example.com/users/1/posts', 396 | meta: { 397 | do_not_use: true 398 | } 399 | } 400 | }, 401 | meta: { 402 | deleted_posts: 5 403 | } 404 | } 405 | }, 406 | links: { 407 | self: 'http://api.example.com/users/1' 408 | }, 409 | meta: { 410 | user_meta: 'is_meta' 411 | } 412 | }, 413 | { 414 | type: 'posts', 415 | id: '2', 416 | attributes: { 417 | title: 'Post 2', 418 | date: 'today' 419 | }, 420 | relationships: { 421 | author: { 422 | data: { type: 'users', id: '1' }, 423 | links: { 424 | self: 'http://api.example.com/posts/2/relationships/author', 425 | related: 'http://api.example.com/posts/2/author' 426 | }, 427 | meta: { 428 | author_active: true 429 | } 430 | } 431 | } 432 | } 433 | ] 434 | 435 | expect(actual).to eq(expected) 436 | expect(actual_included).to match_array(expected_included) 437 | end 438 | end 439 | end 440 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'simplecov' 2 | SimpleCov.start do 3 | add_filter '/spec/' 4 | end 5 | 6 | require 'jsonapi/renderer' 7 | 8 | class UserResource 9 | attr_accessor :id, :name, :address, :posts 10 | 11 | def initialize(id, name, address, posts) 12 | @id = id 13 | @name = name 14 | @address = address 15 | @posts = posts 16 | end 17 | 18 | def jsonapi_type 19 | 'users' 20 | end 21 | 22 | def jsonapi_id 23 | @id.to_s 24 | end 25 | 26 | def jsonapi_related(included) 27 | if included.include?(:posts) 28 | { posts: @posts.map { |p| p } } 29 | else 30 | {} 31 | end 32 | end 33 | 34 | def jsonapi_cache_key(options = {}) 35 | "#{jsonapi_type} - #{jsonapi_id} - #{options[:include].to_a.sort} - #{(options[:fields] || Set.new).to_a.sort}" 36 | end 37 | 38 | def as_jsonapi(options = {}) 39 | fields = options[:fields] || [:name, :address, :posts] 40 | included = options[:include] || [] 41 | 42 | hash = { id: jsonapi_id, type: jsonapi_type } 43 | hash[:attributes] = { name: @name, address: @address } 44 | .select { |k, _| fields.include?(k) } 45 | if fields.include?(:posts) 46 | hash[:relationships] = { posts: {} } 47 | hash[:relationships][:posts] = { 48 | links: { 49 | self: "http://api.example.com/users/#{@id}/relationships/posts", 50 | related: { 51 | href: "http://api.example.com/users/#{@id}/posts", 52 | meta: { 53 | do_not_use: true 54 | } 55 | } 56 | }, 57 | meta: { 58 | deleted_posts: 5 59 | } 60 | } 61 | if included.include?(:posts) 62 | hash[:relationships][:posts][:data] = @posts.map do |p| 63 | { type: 'posts', id: p.id.to_s } 64 | end 65 | end 66 | end 67 | 68 | hash[:links] = { 69 | self: "http://api.example.com/users/#{@id}" 70 | } 71 | hash[:meta] = { user_meta: 'is_meta' } 72 | 73 | hash 74 | end 75 | end 76 | 77 | class PostResource 78 | attr_accessor :id, :title, :date, :author 79 | 80 | def initialize(id, title, date, author) 81 | @id = id 82 | @title = title 83 | @date = date 84 | @author = author 85 | end 86 | 87 | def jsonapi_type 88 | 'posts' 89 | end 90 | 91 | def jsonapi_id 92 | @id.to_s 93 | end 94 | 95 | def jsonapi_related(included) 96 | included.include?(:author) ? { author: [@author] } : {} 97 | end 98 | 99 | def jsonapi_cache_key(options = {}) 100 | "#{jsonapi_type} - #{jsonapi_id} - #{options[:include].to_a.sort} - #{(options[:fields] || Set.new).to_a.sort}" 101 | end 102 | 103 | def as_jsonapi(options = {}) 104 | fields = options[:fields] || [:title, :date, :author] 105 | included = options[:include] || [] 106 | hash = { id: jsonapi_id, type: jsonapi_type } 107 | 108 | hash[:attributes] = { title: @title, date: @date } 109 | .select { |k, _| fields.include?(k) } 110 | if fields.include?(:author) 111 | hash[:relationships] = { author: {} } 112 | hash[:relationships][:author] = { 113 | links: { 114 | self: "http://api.example.com/posts/#{@id}/relationships/author", 115 | related: "http://api.example.com/posts/#{@id}/author" 116 | }, 117 | meta: { 118 | author_active: true 119 | } 120 | } 121 | if included.include?(:author) 122 | hash[:relationships][:author][:data] = 123 | if @author.nil? 124 | nil 125 | else 126 | { type: 'users', id: @author.id.to_s } 127 | end 128 | end 129 | end 130 | 131 | hash 132 | end 133 | end 134 | --------------------------------------------------------------------------------