├── .github └── workflows │ ├── ci.yml │ ├── ci_jruby.yml │ ├── ci_legacy.yml │ ├── ci_truffleruby.yml │ └── tests.yml ├── .gitignore ├── .yardopts ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Gemfile ├── ISSUE_TEMPLATE.md ├── LICENSE ├── README.markdown ├── Rakefile ├── lib └── roar │ └── json │ ├── json_api.rb │ └── json_api │ ├── declarative.rb │ ├── defaults.rb │ ├── document.rb │ ├── for_collection.rb │ ├── member_name.rb │ ├── meta.rb │ ├── options.rb │ ├── resource_collection.rb │ ├── single_resource.rb │ └── version.rb ├── roar-jsonapi.gemspec └── test ├── jsonapi ├── collection_render_test.rb ├── fieldsets_options_test.rb ├── fieldsets_test.rb ├── member_name_test.rb ├── post_test.rb ├── relationship_custom_naming_test.rb ├── render_test.rb ├── representer.rb └── resource_linkage_test.rb └── test_helper.rb /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | ## This file is managed by Terraform. 2 | ## Do not modify this file directly, as it may be overwritten. 3 | ## Please open an issue instead. 4 | name: CI 5 | on: [push, pull_request] 6 | jobs: 7 | test: 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | ruby: [2.7, '3.0', '3.1', '3.2'] 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: ruby/setup-ruby@v1 16 | with: 17 | ruby-version: ${{ matrix.ruby }} 18 | bundler-cache: true 19 | - run: bundle exec rake 20 | -------------------------------------------------------------------------------- /.github/workflows/ci_jruby.yml: -------------------------------------------------------------------------------- 1 | ## This file is managed by Terraform. 2 | ## Do not modify this file directly, as it may be overwritten. 3 | ## Please open an issue instead. 4 | name: CI JRuby 5 | on: [push, pull_request] 6 | jobs: 7 | test: 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | ruby: [jruby, jruby-head] 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: ruby/setup-ruby@v1 16 | with: 17 | ruby-version: ${{ matrix.ruby }} 18 | bundler-cache: true 19 | - run: bundle exec rake 20 | -------------------------------------------------------------------------------- /.github/workflows/ci_legacy.yml: -------------------------------------------------------------------------------- 1 | ## This file is managed by Terraform. 2 | ## Do not modify this file directly, as it may be overwritten. 3 | ## Please open an issue instead. 4 | name: CI with EOL ruby versions 5 | on: [push, pull_request] 6 | jobs: 7 | test: 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | ruby: [2.5, 2.6] 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: ruby/setup-ruby@v1 16 | with: 17 | ruby-version: ${{ matrix.ruby }} 18 | bundler-cache: true 19 | - run: bundle exec rake 20 | -------------------------------------------------------------------------------- /.github/workflows/ci_truffleruby.yml: -------------------------------------------------------------------------------- 1 | ## This file is managed by Terraform. 2 | ## Do not modify this file directly, as it may be overwritten. 3 | ## Please open an issue instead. 4 | name: CI TruffleRuby 5 | on: [push, pull_request] 6 | jobs: 7 | test: 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | ruby: [truffleruby, truffleruby-head] 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: ruby/setup-ruby@v1 16 | with: 17 | ruby-version: ${{ matrix.ruby }} 18 | bundler-cache: true 19 | - run: bundle exec rake 20 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | on: 2 | pull_request: 3 | push: 4 | branches: 5 | - master 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | continue-on-error: ${{ matrix.experimental }} 11 | strategy: 12 | matrix: 13 | ruby: 14 | - 1.9 15 | - 2.1 16 | - 2.2 17 | - 2.3 18 | - 2.4 19 | - 2.5 20 | - 2.6 21 | - 2.7 22 | - 3.0 23 | - 3.1 24 | experimental: 25 | - false 26 | include: 27 | - ruby: jruby-head 28 | experimental: true 29 | - ruby: ruby-head 30 | experimental: true 31 | 32 | steps: 33 | - uses: actions/checkout@v3 34 | - uses: ruby/setup-ruby@v1 35 | with: 36 | ruby-version: ${{ matrix.ruby }} 37 | bundler-cache: true 38 | - run: bundle exec rake 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | pkg/* 2 | *.gem 3 | .bundle 4 | Gemfile*.lock 5 | .idea 6 | -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --markup markdown 2 | --no-private 3 | - 4 | README.markdown 5 | LICENSE 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 0.0.4 2 | * add as option to has_one and has_many declarations to allow custom names (@gerardo-navarro) 3 | * add included option to relationships to stop including data in compound document (@franworley) 4 | * fixes `extend:` and `decorates:` options to behave identically (@myabc/@KonstantinKo) 5 | * include null attributes in resource documents by default (@franworley) 6 | * Ensure meta: option only renders at top-level (@myabc) 7 | 8 | # 0.0.3 9 | * Make `Document` module part of public API. This allows other libraries to hook 10 | ing into the parsing/rendering of all JSON API documents, whether their data 11 | contains a single Resource Object or a collection of Resource Objects. (@myabc) 12 | 13 | # 0.0.2 14 | 15 | * Require Representable 3.0.3, which replaces `Uber::Option` with the new [`Declarative::Option`](https://github.com/apotonick/declarative-option). (@myabc) 16 | 17 | # 0.0.1 18 | 19 | Initial release as a standalone gem. 20 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## How to contribute to Roar 2 | 3 | #### **Did you find a bug?** 4 | 5 | * **Ensure the bug was not already reported** by searching on GitHub under [Issues](https://github.com/trailblazer/roar-jsonapi/issues). 6 | 7 | * If you're unable to find an open issue addressing the problem, [open a new one](https://github.com/trailblazer/roar-jsonapi/issues/new). Be sure to follow the issue template. 8 | 9 | #### **Did you write a patch that fixes a bug?** 10 | 11 | * Open a new GitHub pull request with the patch. 12 | 13 | * Ensure the PR description clearly describes the problem and solution. Include the relevant issue number if applicable. 14 | 15 | * All code in pull requests is assumed to be MIT licensed. Do not submit a pull request if that isn't the case. 16 | 17 | #### **Do you intend to add a new feature or change an existing one?** 18 | 19 | * Suggest your change in the [Trailblazer Gitter Room](https://gitter.im/trailblazer/chat) and start writing code. 20 | 21 | * Do not open an issue on GitHub until you have collected positive feedback about the change. GitHub issues are primarily intended for bug reports and fixes. 22 | 23 | #### **Do you have questions using Roar?** 24 | 25 | * Ask any questions about how to use Roar in the [Trailblazer Gitter Room](https://gitter.im/trailblazer/chat). Github issues are restricted to bug reports and fixes. 26 | 27 | * GitHub Issues should not be used as a help forum and any such issues will be closed. 28 | 29 | #### **Do you want to contribute to the Roar documentation?** 30 | 31 | * Roar documentation is provided via the [Trailblazer site](http://trailblazer.to/gems/roar/) and not the repository readme. Please add your contributions to the [Trailblazer site repository](https://github.com/trailblazer/trailblazer.github.io) 32 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | git_source(:github) do |repo_name| 3 | "https://github.com/#{repo_name}.git" 4 | end 5 | 6 | gemspec 7 | 8 | case ENV["GEMS_SOURCE"] 9 | when "local" 10 | gem "roar", path: "../roar" 11 | when "github" 12 | gem 'roar', github: 'trailblazer/roar' 13 | end 14 | 15 | gem 'minitest-line' 16 | gem 'minitest-reporters', '<= 1.3.0' # Note 1.3.1 is broken see https://github.com/kern/minitest-reporters/issues/267 17 | gem 'pry' 18 | 19 | gem 'json_spec', require: false 20 | -------------------------------------------------------------------------------- /ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Note: If you have a question about Roar, would like help using 2 | Roar, want to request a feature, or do anything else other than 3 | submit a bug report, please use the Trailblazer gitter channel. 4 | 5 | ### Complete Description of Issue 6 | 7 | 8 | ### Steps to reproduce 9 | 10 | 11 | ### Expected behavior 12 | Tell us what should happen 13 | 14 | ### Actual behavior 15 | Tell us what happens instead 16 | 17 | ### System configuration 18 | **Roar version**: 19 | 20 | ### Full Backtrace of Exception (if any) 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011 - 2017 Nick Sutterer and the roar contributors 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.markdown: -------------------------------------------------------------------------------- 1 | # Roar JSON API 2 | 3 | _Resource-Oriented Architectures in Ruby._ 4 | 5 | [![Gitter Chat](https://badges.gitter.im/trailblazer/chat.svg)](https://gitter.im/trailblazer/chat) 6 | [![TRB Newsletter](https://img.shields.io/badge/TRB-newsletter-lightgrey.svg)](http://trailblazer.to/newsletter/) 7 | ![.github/workflows/tests.yml](https://github.com/trailblazer/roar-jsonapi/workflows/.github/workflows/tests.yml/badge.svg) 8 | [![Gem Version](https://badge.fury.io/rb/roar-jsonapi.svg)](http://badge.fury.io/rb/roar-jsonapi) 9 | 10 | Roar JSON API provides support for [JSON API](http://jsonapi.org/), a specification for building APIs in JSON. It can render _and_ parse singular and collection documents. 11 | 12 | ### Resource 13 | 14 | A minimal representation of a Resource can be defined as follows: 15 | 16 | ```ruby 17 | require 'roar/json/json_api' 18 | 19 | class SongsRepresenter < Roar::Decorator 20 | include Roar::JSON::JSONAPI.resource :songs 21 | 22 | attributes do 23 | property :title 24 | end 25 | end 26 | ``` 27 | 28 | Properties (or attributes) of the represented model are defined within an 29 | `attributes` block. 30 | 31 | An `id` property will automatically defined when using Roar JSON API. 32 | 33 | ### Relationships 34 | 35 | To define relationships, use `::has_one` or `::has_many` with either an inline 36 | or a standalone representer (specified with the `extend:` or `decorates:` option). 37 | 38 | ```ruby 39 | class SongsRepresenter < Roar::Decorator 40 | include Roar::JSON::JSONAPI.resource :songs 41 | 42 | has_one :album do 43 | property :title 44 | end 45 | 46 | has_many :musicians, extend: MusicianRepresenter 47 | end 48 | ``` 49 | 50 | ### Meta information 51 | 52 | Meta information can be included into rendered singular and collection documents in two ways. 53 | 54 | You can define meta information on your collection object and then let Roar compile it. 55 | 56 | ```ruby 57 | class SongsRepresenter < Roar::Decorator 58 | include Roar::JSON::JSONAPI.resource :songs 59 | 60 | meta toplevel: true do 61 | property :page 62 | property :total 63 | end 64 | end 65 | ``` 66 | 67 | Your collection object must expose the respective methods. 68 | 69 | ```ruby 70 | collection.page #=> 1 71 | collection.total #=> 12 72 | ``` 73 | 74 | This will render the `{"meta": {"page": 1, "total": 12}}` hash into the JSON API document. 75 | 76 | Alternatively, you can provide meta information as a hash when rendering. Any values also defined on your object will be overriden. 77 | 78 | ```ruby 79 | collection.to_json(meta: {page: params["page"], total: collection.size}) 80 | ``` 81 | 82 | Both methods work for singular documents too. 83 | 84 | ```ruby 85 | class SongsRepresenter < Roar::Decorator 86 | include Roar::JSON::JSONAPI.resource :songs 87 | 88 | meta do 89 | property :label 90 | property :format 91 | end 92 | end 93 | ``` 94 | 95 | ```ruby 96 | song.to_json(meta: { label: 'EMI' }) 97 | ``` 98 | 99 | If you need more functionality (and parsing), please let us know. 100 | 101 | ### Usage 102 | 103 | As JSON API per definition can represent singular models and collections you have two entry points. 104 | 105 | ```ruby 106 | SongsRepresenter.prepare(Song.find(1)).to_json 107 | SongsRepresenter.prepare(Song.new).from_json("..") 108 | ``` 109 | 110 | Singular models can use the representer module directly. 111 | 112 | ```ruby 113 | SongsRepresenter.for_collection.prepare([Song.find(1), Song.find(2)]).to_json 114 | SongsRepresenter.for_collection.prepare([Song.new, Song.new]).from_json("..") 115 | ``` 116 | 117 | 118 | Parsing currently works great with singular documents - for collections, we are still working out how to encode the application semantics. Feel free to help. 119 | 120 | ## Support 121 | 122 | Questions? Need help? Free 1st Level Support on irc.freenode.org#roar ! 123 | We also have a [mailing list](https://groups.google.com/forum/?fromgroups#!forum/roar-talk), yiha! 124 | 125 | ## License 126 | 127 | Roar is released under the [MIT License](http://www.opensource.org/licenses/MIT). 128 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler' 2 | Bundler::GemHelper.install_tasks 3 | 4 | require 'rake/testtask' 5 | 6 | task default: [:test] 7 | 8 | Rake::TestTask.new(:test) do |test| 9 | test.libs << 'test' 10 | test.test_files = FileList['test/**/*_test.rb'] 11 | test.verbose = true 12 | end 13 | -------------------------------------------------------------------------------- /lib/roar/json/json_api.rb: -------------------------------------------------------------------------------- 1 | require 'roar/json' 2 | require 'roar/decorator' 3 | require 'set' 4 | 5 | require 'roar/json/json_api/member_name' 6 | 7 | require 'roar/json/json_api/defaults' 8 | require 'roar/json/json_api/meta' 9 | require 'roar/json/json_api/declarative' 10 | require 'roar/json/json_api/options' 11 | require 'roar/json/json_api/document' 12 | 13 | require 'roar/json/json_api/single_resource' 14 | require 'roar/json/json_api/resource_collection' 15 | require 'roar/json/json_api/for_collection' 16 | 17 | module Roar 18 | module JSON 19 | module JSONAPI 20 | # Include to define a JSON API Resource and make API methods available to 21 | # your `Roar::Decorator`. 22 | # 23 | # @api public 24 | class Resource < Module 25 | # @param [Symbol, String] type type name of this resource. 26 | # @option options [Symbol] :id_key custom ID key for this resource. 27 | def initialize(type, options = {}) 28 | @type = type 29 | @id_key = options.fetch(:id_key, :id) 30 | end 31 | 32 | private 33 | 34 | # Hook called when module is included 35 | # 36 | # @param [Class,Module] base 37 | # the module or class including JSONAPI 38 | # 39 | # @return [undefined] 40 | # 41 | # @api private 42 | # @see http://www.ruby-doc.org/core/Module.html#method-i-included 43 | def included(base) 44 | base.send(:include, JSONAPI::Mixin) 45 | base.type(@type) 46 | base.property(@id_key, as: :id, render_nil: false, render_filter: ->(input, _opts) { 47 | input.to_s 48 | }) 49 | end 50 | end 51 | 52 | # Include to define a JSON API Resource and make API methods available to 53 | # your `Roar::Decorator`. 54 | # 55 | # @example Basic Usage 56 | # class SongsRepresenter < Roar::Decorator 57 | # include Roar::JSON::JSONAPI.resource :songs 58 | # end 59 | # 60 | # @example Custom ID key 61 | # class SongsRepresenter < Roar::Decorator 62 | # include Roar::JSON::JSONAPI.resource :songs, id_key: :song_id 63 | # end 64 | # 65 | # @param (see Resource.initialize) 66 | # @option options (see Resource.initialize) 67 | # 68 | # @see Mixin 69 | # @api public 70 | def self.resource(type, options = {}) 71 | Resource.new(type, options) 72 | end 73 | 74 | # Include to make API methods available to your `Roar::Decorator`. 75 | # 76 | # Unlike {Resource}, you must define a `type` (by calling 77 | # {Declarative#type}) and `id` property separately. 78 | # 79 | # @example Basic Usage 80 | # class SongsRepresenter < Roar::Decorator 81 | # include Roar::JSON::JSONAPI::Mixin 82 | # 83 | # type :songs 84 | # property :id 85 | # end 86 | # 87 | # @see Resource 88 | # @api semi-public 89 | module Mixin 90 | # Hook called when module is included 91 | # 92 | # @param [Class,Module] base 93 | # the module or class including JSONAPI 94 | # 95 | # @return [undefined] 96 | # 97 | # @api private 98 | # @see http://www.ruby-doc.org/core/Module.html#method-i-included 99 | def self.included(base) 100 | base.class_eval do 101 | feature Roar::JSON 102 | feature Roar::Hypermedia 103 | feature JSONAPI::Defaults, JSONAPI::Meta 104 | extend JSONAPI::Declarative 105 | extend JSONAPI::ForCollection 106 | include JSONAPI::Document 107 | include JSONAPI::SingleResource 108 | self.representation_wrap = :data 109 | 110 | nested :relationships do 111 | end 112 | 113 | nested :included do 114 | def to_hash(*) 115 | super.flat_map { |_, resource| resource } 116 | end 117 | end 118 | end 119 | end 120 | end 121 | 122 | # @api private 123 | module Renderer 124 | class Links 125 | def call(res, _options) 126 | tuples = (res.delete('links') || []).collect { |link| 127 | [JSONAPI::MemberName.(link['rel']), link['href']] 128 | } 129 | 130 | ::Hash[tuples] # NOTE: change to tuples.to_h when dropping < 2.1. 131 | end 132 | end 133 | end 134 | 135 | # @api private 136 | module Fragment 137 | Included = ->(included, options) do 138 | return unless included && included.any? 139 | return if options[:included] == false 140 | 141 | type_and_id_seen = Set.new 142 | 143 | included = included.select { |object| 144 | type_and_id_seen.add? [object['type'], object['id']] 145 | } 146 | 147 | included 148 | end 149 | end 150 | end 151 | 152 | # @api private 153 | module HashUtils 154 | def store_if_any(hash, key, value) 155 | hash[key] = value if value && value.any? 156 | end 157 | module_function :store_if_any 158 | end 159 | end 160 | end 161 | -------------------------------------------------------------------------------- /lib/roar/json/json_api/declarative.rb: -------------------------------------------------------------------------------- 1 | module Roar 2 | module JSON 3 | module JSONAPI 4 | # Declarative API for JSON API Representers. 5 | # 6 | # @since 0.1.0 7 | module Declarative 8 | # Defjne a type for this resource. 9 | # 10 | # @example 11 | # type :articles 12 | # 13 | # @param [Symbol, String] name type name of this resource 14 | # @return [String] type name of this resource 15 | # 16 | # @see http://jsonapi.org/format/#document-resource-object-identification 17 | # @api public 18 | def type(name = nil) 19 | return @type unless name # original name. 20 | 21 | heritage.record(:type, name) 22 | @type = name.to_s 23 | end 24 | 25 | # Define attributes for this resource. 26 | # 27 | # @example 28 | # attributes do 29 | # property :name 30 | # end 31 | # 32 | # @param [#call] block 33 | # 34 | # @see http://jsonapi.org/format/#document-resource-object-attributes 35 | # @api public 36 | def attributes(&block) 37 | nested(:attributes, inherit: true, &block) 38 | end 39 | 40 | # Define a link. 41 | # 42 | # @example Link for a resource 43 | # link(:self) { "http://authors/#{represented.id}" } 44 | # @example Top-level link 45 | # link(:self, toplevel: true) { "http://authors/#{represented.id}" } 46 | # @example Link with options 47 | # link(:self) do |opts| 48 | # "http://articles?locale=#{opts[:user_options][:locale]}" 49 | # end 50 | # 51 | # representer.to_json(user_options: { locale: 'de' }) 52 | # 53 | # @param [Symbol, String] name name of the link. 54 | # @option options [Boolean] :toplevel place link at top-level of document. 55 | # 56 | # @yieldparam opts [Hash] Options passed to render method 57 | # 58 | # @see Roar::Hypermedia::ClassMethods#link 59 | # @see http://jsonapi.org/format/#document-links 60 | # @api public 61 | def link(name, options = {}, &block) 62 | return super(name, &block) unless options[:toplevel] 63 | for_collection.link(name, &block) 64 | end 65 | 66 | # Define meta information. 67 | # 68 | # @example Meta information for a resource 69 | # meta do 70 | # collection :reviewers 71 | # end 72 | # @example Top-level meta information 73 | # meta toplevel: true do 74 | # property :copyright 75 | # end 76 | # 77 | # @param (see Meta::ClassMethods#meta) 78 | # @option options [Boolean] :toplevel place meta information at top-level of document. 79 | # 80 | # @see Meta::ClassMethods#meta 81 | # @see http://jsonapi.org/format/#document-meta 82 | # @api public 83 | def meta(options = {}, &block) 84 | return super(&block) unless options[:toplevel] 85 | for_collection.meta(&block) 86 | end 87 | 88 | # Define links and meta information for a given relationship. 89 | # 90 | # @example 91 | # has_one :author, extend: AuthorDecorator do 92 | # relationship do 93 | # link(:self) { "/articles/#{represented.id}/relationships/author" } 94 | # link(:related) { "/articles/#{represented.id}/author" } 95 | # end 96 | # end 97 | # 98 | # @param [#call] block 99 | # 100 | # @api public 101 | def relationship(&block) 102 | return (@relationship ||= -> {}) unless block 103 | 104 | heritage.record(:relationship, &block) 105 | @relationship = block 106 | end 107 | 108 | # Define a to-one relationship for this resource. 109 | # 110 | # @param [String] name name of the relationship 111 | # @option options [Class,Module,Proc] :extend Representer to use for parsing or rendering 112 | # @option options [Proc] :prepare Decorate the represented object 113 | # @option options [Class,Proc] :class Class to instantiate when parsing nested fragment 114 | # @option options [Proc] :instance Instantiate object directly when parsing nested fragment 115 | # @option options [TrueClass, FalseClass] :included (default: true) whether to include relation data in included object of compound document 116 | # @param options [String] :as custom name for relationship e.g. camel_case 117 | # @param [#call] block Stuff 118 | # 119 | # @see http://trailblazer.to/gems/representable/3.0/function-api.html#options 120 | # @api public 121 | def has_one(name, options = {}, &block) 122 | has_relationship(name, options.merge(collection: false), &block) 123 | end 124 | 125 | # Define a to-many relationship for this resource. 126 | # 127 | # @param (see #has_one) 128 | # @option options (see #has_one) 129 | # 130 | # @api public 131 | def has_many(name, options = {}, &block) 132 | has_relationship(name, options.merge(collection: true), &block) 133 | end 134 | 135 | private 136 | 137 | def has_relationship(name, options = {}, &block) 138 | resource_decorator = options.delete(:decorator) || 139 | options.delete(:extend) || 140 | Class.new(Roar::Decorator).tap { |decorator| 141 | decorator.send(:include, JSONAPI::Resource.new( 142 | name, 143 | id_key: options.fetch(:id_key, :id) 144 | )) 145 | } 146 | resource_decorator.instance_exec(&block) if block 147 | 148 | resource_identifier_representer = Class.new(resource_decorator) 149 | resource_identifier_representer.class_eval do 150 | def to_hash(_options = {}) 151 | super(fields: { self.class.type.to_sym => [] }, include: [], wrap: false) 152 | end 153 | end 154 | 155 | add_included = options[:included].nil? ? true : options[:included] 156 | if add_included 157 | nested(:included, inherit: true) do 158 | property(name, collection: options[:collection], 159 | decorator: resource_decorator, 160 | render_nil: false, 161 | wrap: false) 162 | end 163 | end 164 | 165 | nested(:relationships, inherit: true) do 166 | nested(:"#{name}_relationship", as: options[:as] || MemberName.(name)) do 167 | property name, options.merge(as: :data, 168 | getter: ->(opts) { 169 | object = opts[:binding].send(:exec_context, opts) 170 | value = object.public_send(opts[:binding].getter) 171 | # do not blow up on nil collections 172 | if options[:collection] && value.nil? 173 | [] 174 | else 175 | value 176 | end 177 | }, 178 | render_nil: true, 179 | render_empty: true, 180 | decorator: resource_identifier_representer, 181 | wrap: false) 182 | 183 | instance_exec(&resource_identifier_representer.relationship) 184 | 185 | # rubocop:disable Lint/NestedMethodDefinition 186 | def to_hash(*) 187 | hash = super 188 | links = Renderer::Links.new.(hash, {}) 189 | meta = render_meta({}) 190 | 191 | HashUtils.store_if_any(hash, 'links', links) 192 | HashUtils.store_if_any(hash, 'meta', meta) 193 | 194 | hash 195 | end 196 | # rubocop:enable Lint/NestedMethodDefinition 197 | end 198 | end 199 | end 200 | end 201 | end 202 | end 203 | end 204 | -------------------------------------------------------------------------------- /lib/roar/json/json_api/defaults.rb: -------------------------------------------------------------------------------- 1 | module Roar 2 | module JSON 3 | module JSONAPI 4 | # Defines defaults for JSON API Representers. 5 | # 6 | # @api public 7 | module Defaults 8 | # Hook called when module is included 9 | # 10 | # @param [Class,Module] base 11 | # the module or class including Defaults 12 | # 13 | # @return [undefined] 14 | # 15 | # @api private 16 | # @see http://www.ruby-doc.org/core/Module.html#method-i-included 17 | def self.included(base) 18 | base.defaults do |name, _| 19 | { as: JSONAPI::MemberName.(name), render_nil: true } 20 | end 21 | end 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/roar/json/json_api/document.rb: -------------------------------------------------------------------------------- 1 | module Roar 2 | module JSON 3 | module JSONAPI 4 | # Instance method API for JSON API Documents. 5 | # 6 | module Document 7 | # Render the document as JSON 8 | # 9 | # @example Simple rendering 10 | # representer.to_json 11 | # 12 | # @example Rendering with compound documents and sparse fieldsets 13 | # uri = Addressable::URI.parse('/articles/1?include=author,comments.author') 14 | # query = Rack::Utils.parse_nested_query(uri.query) 15 | # # => {"include"=>"author", "fields"=>{"articles"=>"title,body", "people"=>"name"}} 16 | # 17 | # representer.to_json( 18 | # include: query['include'], 19 | # fields: query['fields'] 20 | # ) 21 | # 22 | # @option options (see #to_hash) 23 | # 24 | # @return [String] JSON String 25 | # 26 | # @see http://jsonapi.org/format/#fetching-includes 27 | # @see http://jsonapi.org/format/#fetching-sparse-fieldsets 28 | # @api public 29 | def to_json(options = {}) 30 | super 31 | end 32 | 33 | # Render the document as a Ruby Hash 34 | # 35 | # @option options [Array<#to_s>,#to_s,false] include 36 | # compound documents to include, specified as a list of relationship 37 | # paths (Array or comma-separated String) or `false`, if no compound 38 | # documents are to be included. 39 | # 40 | # N.B. this syntax and behaviour for this option *is signficantly 41 | # different* to that of the `include` option implemented in other, 42 | # non-JSON API Representers. 43 | # @option options [Hash{Symbol=>[Array]}] fields 44 | # fields to returned on a per-type basis. 45 | # @option options [Hash{#to_s}=>Object] meta 46 | # extra toplevel meta information to be rendered in the document. 47 | # @option options [Hash{Symbol=>Symbol}] user_options 48 | # additional arbitary options to be passed to the Representer. 49 | # 50 | # @return [Hash{String=>Object}] 51 | # 52 | # @api public 53 | def to_hash(options = {}) 54 | super 55 | end 56 | end 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/roar/json/json_api/for_collection.rb: -------------------------------------------------------------------------------- 1 | module Roar 2 | module JSON 3 | module JSONAPI 4 | # @api private 5 | module ForCollection 6 | def collection_representer!(_options) 7 | singular = self # e.g. Song::Representer 8 | 9 | nested_builder.(_base: default_nested_class, _features: [Roar::JSON, Roar::Hypermedia, JSONAPI::Defaults, JSONAPI::Meta], _block: proc do 10 | collection :to_a, as: :data, decorator: singular, wrap: false 11 | 12 | include Document 13 | include ResourceCollection 14 | end) 15 | end 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/roar/json/json_api/member_name.rb: -------------------------------------------------------------------------------- 1 | # encoding=utf-8 2 | 3 | module Roar 4 | module JSON 5 | module JSONAPI 6 | # Member Name formatting according to the JSON API specification. 7 | # 8 | # @see http://jsonapi.org/format/#document-member-names 9 | # @since 0.1.0 10 | class MemberName 11 | # @api private 12 | LENIENT_FILTER_REGEXP = /([^[:alnum:][-_ ]]+)/ 13 | # @api private 14 | STRICT_FILTER_REGEXP = /([^[0-9a-z][-_]]+)/ 15 | 16 | # @see #call 17 | def self.call(name, options = {}) 18 | new.(name, options) 19 | end 20 | 21 | # Format a member name 22 | # 23 | # @param [String, Symbol] name 24 | # member name. 25 | # @option options [Boolean] :strict 26 | # whether strict mode is enabled. 27 | # 28 | # Strict mode applies additional JSON Specification *RECOMMENDATIONS*, 29 | # permitting only non-reserved, URL safe characters specified in RFC 3986. 30 | # The member name will be lower-cased and underscores will be 31 | # transformed to hyphens. 32 | # 33 | # Non-strict mode permits: 34 | # * non-ASCII alphanumeric Unicode characters. 35 | # * spaces, underscores and hyphens, except as the first or last character. 36 | # 37 | # @return [String] formatted member name. 38 | # 39 | # @api public 40 | def call(name, options = {}) 41 | name = name.to_s 42 | strict = options.fetch(:strict, true) 43 | name = if strict 44 | name = name.downcase 45 | name.gsub(STRICT_FILTER_REGEXP, ''.freeze) 46 | else 47 | name.gsub(LENIENT_FILTER_REGEXP, ''.freeze) 48 | end 49 | name = name.gsub(/\A([-_ ])/, '') 50 | name = name.gsub(/([-_ ])\z/, '') 51 | name = name.tr('_', '-') if strict 52 | name 53 | end 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/roar/json/json_api/meta.rb: -------------------------------------------------------------------------------- 1 | module Roar 2 | module JSON 3 | module JSONAPI 4 | # Meta information API for JSON API Representers. 5 | # 6 | # @api public 7 | module Meta 8 | # Hook called when module is included 9 | # 10 | # @param [Class,Module] base 11 | # the module or class including JSONAPI 12 | # 13 | # @return [undefined] 14 | # 15 | # @api private 16 | # @see http://www.ruby-doc.org/core/Module.html#method-i-included 17 | def self.included(base) 18 | base.extend ClassMethods 19 | end 20 | 21 | # Class level interface 22 | module ClassMethods 23 | # Define meta information. 24 | # 25 | # @example 26 | # meta do 27 | # property :copyright 28 | # collection :reviewers 29 | # end 30 | # 31 | # @param [#call] block 32 | # 33 | # @see http://jsonapi.org/format/#document-meta 34 | # @api public 35 | def meta(&block) 36 | representable_attrs[:meta_representer] ||= nested_builder.( 37 | _base: default_nested_class, 38 | _features: [Roar::JSON, JSONAPI::Defaults], 39 | _block: block 40 | ) 41 | representable_attrs[:meta_representer].instance_exec(&block) 42 | end 43 | end 44 | 45 | private 46 | 47 | def render_meta(options) 48 | representer = representable_attrs[:meta_representer] 49 | meta = representer ? representer.new(represented).to_hash : {} 50 | meta.merge!(options[:meta]) if options[:meta] 51 | meta 52 | end 53 | end 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/roar/json/json_api/options.rb: -------------------------------------------------------------------------------- 1 | module Roar 2 | module JSON 3 | module JSONAPI 4 | # @api private 5 | module Options 6 | # Transforms `field:` and `include:`` options to their internal 7 | # equivalents. 8 | # 9 | # @see SingleResource#to_hash 10 | class Include 11 | DEFAULT_INTERNAL_INCLUDES = [:attributes, :relationships].freeze 12 | 13 | def self.call(options, mappings) 14 | new.(options, mappings) 15 | end 16 | 17 | def call(options, mappings) 18 | include, fields = *options.values_at(:include, :fields) 19 | return options if options[:_json_api_parsed] || !(include || fields) 20 | 21 | internal_options = {} 22 | rewrite_include_option!(internal_options, include, 23 | mappings.fetch(:id, {})) 24 | rewrite_fields!(internal_options, fields, 25 | mappings.fetch(:relationships, {})) 26 | 27 | options.reject { |key, _| [:include, :fields].include?(key) } 28 | .merge(internal_options) 29 | end 30 | 31 | private 32 | 33 | def default_includes_for(name, id_mappings) 34 | [id_mappings.fetch(name.to_s, :id).to_sym] + DEFAULT_INTERNAL_INCLUDES 35 | end 36 | 37 | def rewrite_include_option!(options, include, id_mappings) 38 | include_paths = parse_include_option(include) 39 | default_includes = default_includes_for('_self', id_mappings) 40 | options[:include] = default_includes + [:included] 41 | options[:included] = { include: include_paths.map(&:first) - [:_self] } 42 | include_paths.each do |include_path| 43 | includes = default_includes_for(include_path.first, id_mappings) 44 | options[:included].merge!( 45 | explode_include_path(*include_path, includes) 46 | ) 47 | end 48 | options 49 | end 50 | 51 | def rewrite_fields!(options, fields, rel_mappings) 52 | (fields || {}).each do |type, raw_value| 53 | fields_value = parse_fields_value(raw_value) 54 | relationship_name = (rel_mappings.key(type.to_s) || type).to_sym 55 | if relationship_name == :_self 56 | options[:attributes] = { include: fields_value } 57 | options[:relationships] = { include: fields_value } 58 | else 59 | options[:included][relationship_name] ||= {} 60 | options[:included][relationship_name].merge!( 61 | attributes: { include: fields_value }, 62 | relationships: { include: fields_value }, 63 | _json_api_parsed: true # flag to halt recursive parsing 64 | ) 65 | end 66 | end 67 | end 68 | 69 | def parse_include_option(include_value) 70 | Array(include_value).flat_map { |i| i.to_s.split(',') }.map { |path| 71 | path.split('.').map(&:to_sym) 72 | } 73 | end 74 | 75 | def parse_fields_value(fields_value) 76 | Array(fields_value).flat_map { |v| v.to_s.split(',') }.map(&:to_sym) 77 | end 78 | 79 | def explode_include_path(*include_path, default_includes) 80 | head, *tail = *include_path 81 | hash = {} 82 | result = hash[head] ||= { 83 | include: default_includes.dup, _json_api_parsed: true 84 | } 85 | 86 | tail.each do |key| 87 | break unless result[:included].nil? 88 | 89 | result[:include] << :included 90 | result[:included] ||= {} 91 | 92 | result = result[:included][key] ||= { 93 | include: default_includes.dup, _json_api_parsed: true 94 | } 95 | end 96 | 97 | hash 98 | end 99 | end 100 | end 101 | end 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /lib/roar/json/json_api/resource_collection.rb: -------------------------------------------------------------------------------- 1 | module Roar 2 | module JSON 3 | module JSONAPI 4 | # Instance method API for JSON API Documents representing an array of Resources 5 | # 6 | # @api private 7 | module ResourceCollection 8 | # @see Document#to_hash 9 | def to_hash(options = {}) 10 | single_options = options.reject { |key, _| [:meta, :user_options].include?(key) } 11 | document = super(to_a: single_options, user_options: options[:user_options]) # [{data: {..}, data: {..}}] 12 | 13 | links = Renderer::Links.new.(document, options) 14 | meta = render_meta(options) 15 | included = [] 16 | document['data'].each do |single| 17 | included += single.delete('included') || [] 18 | end 19 | 20 | HashUtils.store_if_any(document, 'included', 21 | Fragment::Included.(included, options)) 22 | HashUtils.store_if_any(document, 'links', links) 23 | HashUtils.store_if_any(document, 'meta', meta) 24 | 25 | document 26 | end 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/roar/json/json_api/single_resource.rb: -------------------------------------------------------------------------------- 1 | module Roar 2 | module JSON 3 | module JSONAPI 4 | # Instance method API for JSON API Documents representing a single Resource 5 | # 6 | # @api private 7 | module SingleResource 8 | # @see Document#to_hash 9 | def to_hash(options = {}) 10 | document = super(Options::Include.(options, mappings)) 11 | unwrapped = options[:wrap] == false 12 | resource = unwrapped ? document : document['data'] 13 | resource['type'] = JSONAPI::MemberName.(self.class.type) 14 | 15 | links = Renderer::Links.new.(resource, options) 16 | meta = render_meta(options) 17 | 18 | resource.reject! do |_, v| v && v.empty? end 19 | 20 | unless unwrapped 21 | included = resource.delete('included') 22 | 23 | HashUtils.store_if_any(document, 'included', 24 | Fragment::Included.(included, options)) 25 | end 26 | 27 | HashUtils.store_if_any(resource, 'links', links) 28 | HashUtils.store_if_any(document, 'meta', meta) 29 | 30 | document 31 | end 32 | 33 | private 34 | 35 | def mappings 36 | @mappings ||= begin 37 | mappings = {} 38 | mappings[:id] = find_id_mappings 39 | mappings[:relationships] = find_relationship_mappings 40 | mappings[:relationships]['_self'] = self.class.type 41 | mappings 42 | end 43 | end 44 | 45 | def find_id_mapping(klass) 46 | self_id = klass.definitions.detect { |definition| 47 | definition[:as] && definition[:as].(:value) == 'id' 48 | }.name 49 | end 50 | 51 | def find_id_mappings 52 | included_definitions = self.class.definitions['included'].representer_module.definitions 53 | id_mappings = included_definitions.each_with_object({}) do |definition, hash| 54 | hash[definition.name] = find_id_mapping(definition[:decorator]) 55 | end 56 | id_mappings['_self'] = find_id_mapping(self.class) 57 | id_mappings 58 | end 59 | 60 | def find_relationship_mappings 61 | included_definitions = self.class.definitions['included'].representer_module.definitions 62 | included_definitions.each_with_object({}) do |definition, hash| 63 | hash[definition.name] = definition.representer_module.type 64 | end 65 | end 66 | end 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/roar/json/json_api/version.rb: -------------------------------------------------------------------------------- 1 | module Roar 2 | module JSON 3 | module JSONAPI 4 | VERSION = '0.0.3' 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /roar-jsonapi.gemspec: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.push File.expand_path('../lib', __FILE__) 2 | require 'roar/json/json_api/version' 3 | 4 | Gem::Specification.new do |s| 5 | s.name = 'roar-jsonapi' 6 | s.version = Roar::JSON::JSONAPI::VERSION 7 | s.platform = Gem::Platform::RUBY 8 | s.authors = ['Nick Sutterer', 'Alex Coles'] 9 | s.email = ['apotonick@gmail.com', 'alex@alexbcoles.com'] 10 | s.homepage = 'http://trailblazer.to/gems/roar/jsonapi.html' 11 | s.summary = 'Parse and render JSON API documents using representers.' 12 | s.description = 'Object-oriented representers help you define nested JSON API documents which can then be rendered and parsed using one and the same concept.' 13 | s.license = 'MIT' 14 | 15 | s.files = `git ls-files`.split("\n") 16 | s.test_files = `git ls-files -- {test}/*`.split("\n") 17 | s.executables = `git ls-files -- bin/*`.split("\n").map { |f| File.basename(f) } 18 | s.require_paths = ['lib'] 19 | 20 | s.required_ruby_version = '>= 1.9.3' 21 | 22 | s.add_runtime_dependency 'roar', '~> 1.1' 23 | 24 | s.add_runtime_dependency 'representable', '~> 3.0', '>= 3.0.3' 25 | 26 | s.add_development_dependency 'rake', '>= 0.10.1' 27 | s.add_development_dependency 'minitest', '>= 5.10' 28 | s.add_development_dependency 'multi_json' 29 | end 30 | -------------------------------------------------------------------------------- /test/jsonapi/collection_render_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | require 'roar/json/json_api' 3 | require 'json' 4 | 5 | class JsonapiCollectionRenderTest < MiniTest::Spec 6 | let(:article) { Article.new(1, 'Health walk', Author.new(2, 'someone@author.com'), Author.new('editor:1'), [Comment.new('comment:1', 'Ice and Snow'), Comment.new('comment:2', 'Red Stripe Skank')], [Author.new('contributor:1'), Author.new('contributor:2')]) } 7 | let(:article2) { Article.new(2, 'Virgin Ska', Author.new('author:1'), nil, [Comment.new('comment:3', 'Cool song!')], [Author.new('contributor:1'), Author.new('contributor:2')]) } 8 | let(:article3) { Article.new(3, 'Gramo echo', Author.new('author:1'), nil, [Comment.new('comment:4', 'Skalar')], [Author.new('contributor:1'), Author.new('contributor:2')]) } 9 | let(:decorator) { ArticleDecorator.for_collection.new([article, article2, article3]) } 10 | 11 | it 'renders full document' do 12 | json = decorator.to_json 13 | json.must_equal_json(%({ 14 | "data": [{ 15 | "type": "articles", 16 | "id": "1", 17 | "attributes": { 18 | "title": "Health walk" 19 | }, 20 | "relationships": { 21 | "author": { 22 | "data": { 23 | "type": "authors", 24 | "id": "2" 25 | }, 26 | "links": { 27 | "self": "/articles/1/relationships/author", 28 | "related": "/articles/1/author" 29 | } 30 | }, 31 | "editor": { 32 | "data": { 33 | "type": "editors", 34 | "id": "editor:1" 35 | }, 36 | "meta": { 37 | "peer-reviewed": false 38 | } 39 | }, 40 | "comments": { 41 | "data": [{ 42 | "type": "comments", 43 | "id": "comment:1" 44 | }, { 45 | "type": "comments", 46 | "id": "comment:2" 47 | }], 48 | "links": { 49 | "self": "/articles/1/relationships/comments", 50 | "related": "/articles/1/comments" 51 | }, 52 | "meta": { 53 | "comment-count": 6 54 | } 55 | }, 56 | "contributors": { 57 | "data": [{ 58 | "id": "contributor:1", 59 | "type": "authors" 60 | }, { 61 | "id": "contributor:2", 62 | "type": "authors" 63 | }], 64 | "links": { 65 | "self": "/articles/1/relationships/contributors", 66 | "related": "/articles/1/contributors" 67 | } 68 | } 69 | }, 70 | "links": { 71 | "self": "http://Article/1" 72 | }, 73 | "meta": { 74 | "reviewers": ["Christian Bernstein"], 75 | "reviewer-initials": "C.B." 76 | } 77 | }, { 78 | "type": "articles", 79 | "id": "2", 80 | "attributes": { 81 | "title": "Virgin Ska" 82 | }, 83 | "relationships": { 84 | "author": { 85 | "data": { 86 | "type": "authors", 87 | "id": "author:1" 88 | }, 89 | "links": { 90 | "self": "/articles/2/relationships/author", 91 | "related": "/articles/2/author" 92 | } 93 | }, 94 | "editor": { 95 | "data": null, 96 | "meta": { 97 | "peer-reviewed": false 98 | } 99 | }, 100 | "comments": { 101 | "data": [{ 102 | "type": "comments", 103 | "id": "comment:3" 104 | }], 105 | "links": { 106 | "self": "/articles/2/relationships/comments", 107 | "related": "/articles/2/comments" 108 | }, 109 | "meta": { 110 | "comment-count": 6 111 | } 112 | }, 113 | "contributors": { 114 | "data": [{ 115 | "id": "contributor:1", 116 | "type": "authors" 117 | }, { 118 | "id": "contributor:2", 119 | "type": "authors" 120 | }], 121 | "links": { 122 | "self": "/articles/2/relationships/contributors", 123 | "related": "/articles/2/contributors" 124 | } 125 | } 126 | }, 127 | "links": { 128 | "self": "http://Article/2" 129 | }, 130 | "meta": { 131 | "reviewers": ["Christian Bernstein"], 132 | "reviewer-initials": "C.B." 133 | } 134 | }, { 135 | "type": "articles", 136 | "id": "3", 137 | "attributes": { 138 | "title": "Gramo echo" 139 | }, 140 | "relationships": { 141 | "author": { 142 | "data": { 143 | "type": "authors", 144 | "id": "author:1" 145 | }, 146 | "links": { 147 | "self": "/articles/3/relationships/author", 148 | "related": "/articles/3/author" 149 | } 150 | }, 151 | "editor": { 152 | "data": null, 153 | "meta": { 154 | "peer-reviewed": false 155 | } 156 | }, 157 | "comments": { 158 | "data": [{ 159 | "type": "comments", 160 | "id": "comment:4" 161 | }], 162 | "links": { 163 | "self": "/articles/3/relationships/comments", 164 | "related": "/articles/3/comments" 165 | }, 166 | "meta": { 167 | "comment-count": 6 168 | } 169 | }, 170 | "contributors": { 171 | "data": [{ 172 | "id": "contributor:1", 173 | "type": "authors" 174 | }, { 175 | "id": "contributor:2", 176 | "type": "authors" 177 | }], 178 | "links": { 179 | "self": "/articles/3/relationships/contributors", 180 | "related": "/articles/3/contributors" 181 | } 182 | } 183 | }, 184 | "links": { 185 | "self": "http://Article/3" 186 | }, 187 | "meta": { 188 | "reviewers": ["Christian Bernstein"], 189 | "reviewer-initials": "C.B." 190 | } 191 | }], 192 | "links": { 193 | "self": "//articles" 194 | }, 195 | "meta": { 196 | "count": 3 197 | }, 198 | "included": [{ 199 | "type": "authors", 200 | "attributes": { 201 | "email": "someone@author.com" 202 | }, 203 | "id": "2", 204 | "links": { 205 | "self": "http://authors/2" 206 | } 207 | }, { 208 | "attributes": { 209 | "email": null 210 | }, 211 | "type": "editors", 212 | "id": "editor:1" 213 | }, { 214 | "type": "comments", 215 | "id": "comment:1", 216 | "attributes": { 217 | "body": "Ice and Snow" 218 | }, 219 | "links": { 220 | "self": "http://comments/comment:1" 221 | } 222 | }, { 223 | "type": "comments", 224 | "id": "comment:2", 225 | "attributes": { 226 | "body": "Red Stripe Skank" 227 | }, 228 | "links": { 229 | "self": "http://comments/comment:2" 230 | } 231 | }, { 232 | "attributes": { 233 | "email": null 234 | }, 235 | "type": "authors", 236 | "id": "author:1", 237 | "links": { 238 | "self": "http://authors/author:1" 239 | } 240 | }, { 241 | "type": "comments", 242 | "id": "comment:3", 243 | "attributes": { 244 | "body": "Cool song!" 245 | }, 246 | "links": { 247 | "self": "http://comments/comment:3" 248 | } 249 | }, { 250 | "type": "comments", 251 | "id": "comment:4", 252 | "attributes": { 253 | "body": "Skalar" 254 | }, 255 | "links": { 256 | "self": "http://comments/comment:4" 257 | } 258 | }] 259 | })) 260 | end 261 | 262 | it 'included: false suppresses compound docs' do 263 | json = decorator.to_json(included: false) 264 | json.must_equal_json(%({ 265 | "data": [{ 266 | "type": "articles", 267 | "id": "1", 268 | "attributes": { 269 | "title": "Health walk" 270 | }, 271 | "relationships": { 272 | "author": { 273 | "data": { 274 | "type": "authors", 275 | "id": "2" 276 | }, 277 | "links": { 278 | "self": "/articles/1/relationships/author", 279 | "related": "/articles/1/author" 280 | } 281 | }, 282 | "editor": { 283 | "data": { 284 | "type": "editors", 285 | "id": "editor:1" 286 | }, 287 | "meta": { 288 | "peer-reviewed": false 289 | } 290 | }, 291 | "comments": { 292 | "data": [{ 293 | "type": "comments", 294 | "id": "comment:1" 295 | }, { 296 | "type": "comments", 297 | "id": "comment:2" 298 | }], 299 | "links": { 300 | "self": "/articles/1/relationships/comments", 301 | "related": "/articles/1/comments" 302 | }, 303 | "meta": { 304 | "comment-count": 6 305 | } 306 | }, 307 | "contributors": { 308 | "data": [{ 309 | "id": "contributor:1", 310 | "type": "authors" 311 | }, { 312 | "id": "contributor:2", 313 | "type": "authors" 314 | }], 315 | "links": { 316 | "self": "/articles/1/relationships/contributors", 317 | "related": "/articles/1/contributors" 318 | } 319 | } 320 | }, 321 | "links": { 322 | "self": "http://Article/1" 323 | }, 324 | "meta": { 325 | "reviewers": ["Christian Bernstein"], 326 | "reviewer-initials": "C.B." 327 | } 328 | }, { 329 | "type": "articles", 330 | "id": "2", 331 | "attributes": { 332 | "title": "Virgin Ska" 333 | }, 334 | "relationships": { 335 | "author": { 336 | "data": { 337 | "type": "authors", 338 | "id": "author:1" 339 | }, 340 | "links": { 341 | "self": "/articles/2/relationships/author", 342 | "related": "/articles/2/author" 343 | } 344 | }, 345 | "editor": { 346 | "data": null, 347 | "meta": { 348 | "peer-reviewed": false 349 | } 350 | }, 351 | "comments": { 352 | "data": [{ 353 | "type": "comments", 354 | "id": "comment:3" 355 | }], 356 | "links": { 357 | "self": "/articles/2/relationships/comments", 358 | "related": "/articles/2/comments" 359 | }, 360 | "meta": { 361 | "comment-count": 6 362 | } 363 | }, 364 | "contributors": { 365 | "data": [{ 366 | "id": "contributor:1", 367 | "type": "authors" 368 | }, { 369 | "id": "contributor:2", 370 | "type": "authors" 371 | }], 372 | "links": { 373 | "self": "/articles/2/relationships/contributors", 374 | "related": "/articles/2/contributors" 375 | } 376 | } 377 | }, 378 | "links": { 379 | "self": "http://Article/2" 380 | }, 381 | "meta": { 382 | "reviewers": ["Christian Bernstein"], 383 | "reviewer-initials": "C.B." 384 | } 385 | }, { 386 | "type": "articles", 387 | "id": "3", 388 | "attributes": { 389 | "title": "Gramo echo" 390 | }, 391 | "relationships": { 392 | "author": { 393 | "data": { 394 | "type": "authors", 395 | "id": "author:1" 396 | }, 397 | "links": { 398 | "self": "/articles/3/relationships/author", 399 | "related": "/articles/3/author" 400 | } 401 | }, 402 | "editor": { 403 | "data": null, 404 | "meta": { 405 | "peer-reviewed": false 406 | } 407 | }, 408 | "comments": { 409 | "data": [{ 410 | "type": "comments", 411 | "id": "comment:4" 412 | }], 413 | "links": { 414 | "self": "/articles/3/relationships/comments", 415 | "related": "/articles/3/comments" 416 | }, 417 | "meta": { 418 | "comment-count": 6 419 | } 420 | }, 421 | "contributors": { 422 | "data": [{ 423 | "id": "contributor:1", 424 | "type": "authors" 425 | }, { 426 | "id": "contributor:2", 427 | "type": "authors" 428 | }], 429 | "links": { 430 | "self": "/articles/3/relationships/contributors", 431 | "related": "/articles/3/contributors" 432 | } 433 | } 434 | }, 435 | "links": { 436 | "self": "http://Article/3" 437 | }, 438 | "meta": { 439 | "reviewers": ["Christian Bernstein"], 440 | "reviewer-initials": "C.B." 441 | } 442 | }], 443 | "links": { 444 | "self": "//articles" 445 | }, 446 | "meta": { 447 | "count": 3 448 | } 449 | })) 450 | end 451 | 452 | it 'passes :user_options to toplevel links when rendering' do 453 | hash = decorator.to_hash(user_options: { page: 2, per_page: 10 }) 454 | hash['links'].must_equal('self' => '//articles?page=2&per_page=10') 455 | end 456 | 457 | it 'renders extra toplevel meta information if meta option supplied' do 458 | hash = decorator.to_hash(meta: { page: 2, total: 9 }) 459 | hash['meta'].must_equal('count' => 3, page: 2, total: 9) 460 | end 461 | 462 | it 'does not render extra meta information on resource objects' do 463 | hash = decorator.to_hash(meta: { page: 2, total: 9 }) 464 | refute hash['data'].first['meta'].key?(:page) 465 | refute hash['data'].first['meta'].key?(:total) 466 | end 467 | 468 | it 'does not render extra toplevel meta information if meta option is empty' do 469 | hash = decorator.to_hash(meta: {}) 470 | hash['meta'][:page].must_be_nil 471 | hash['meta'][:total].must_be_nil 472 | end 473 | 474 | describe 'Fetching Resources (empty collection)' do 475 | let(:document) { 476 | %({ 477 | "data": [], 478 | "links": { 479 | "self": "//articles" 480 | }, 481 | "meta": { 482 | "count": 0 483 | } 484 | }) 485 | } 486 | 487 | let(:articles) { [] } 488 | subject { ArticleDecorator.for_collection.new(articles).to_json } 489 | 490 | it { subject.must_equal_json document } 491 | end 492 | end 493 | -------------------------------------------------------------------------------- /test/jsonapi/fieldsets_options_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | require 'roar/json/json_api' 3 | 4 | class FieldsetsOptionsTest < Minitest::Spec 5 | Include = Roar::JSON::JSONAPI::Options::Include 6 | 7 | describe 'with non-empty :include and non-empty :fields option' do 8 | it 'rewrites :include and parses :fields' do 9 | [{ include: [:articles], 10 | fields: { articles: [:title, :body], people: [] } }, 11 | { include: ['articles'], 12 | fields: { articles: ['title,body'], people: [] } }, 13 | { include: 'articles', 14 | fields: { 'articles' => 'title,body', 'people' => '' } }].each do |options| 15 | options = Include.(options, relationships: { 'articles' => 'articles' }) 16 | options.must_equal(include: [:id, :attributes, :relationships, :included], 17 | included: { 18 | include: [:articles], 19 | articles: { 20 | include: [:id, :attributes, :relationships], 21 | attributes: { include: [:title, :body] }, 22 | relationships: { include: [:title, :body] }, 23 | _json_api_parsed: true 24 | }, 25 | people: { 26 | attributes: { include: [] }, 27 | relationships: { include: [] }, 28 | _json_api_parsed: true 29 | } 30 | }) 31 | end 32 | end 33 | end 34 | 35 | describe 'with empty :include option' do 36 | it 'rewrites :include' do 37 | [{ include: [] }, 38 | { include: [''] }, 39 | { include: '' }].each do |options| 40 | options = Include.(options, {}) 41 | options.must_equal(include: [:id, :attributes, :relationships, :included], 42 | included: { include: [] }) 43 | end 44 | end 45 | end 46 | 47 | describe 'with empty :include option and non-empty :fields option' do 48 | it 'parses :fields (_self), but does not include other resources' do 49 | [{ include: [], fields: { articles: [:title, :body], people: [] } }, 50 | { include: [''], fields: { articles: ['title,body'], people: [] } }, 51 | { include: '', fields: { 'articles' => 'title,body', 'people' => '' } }].each do |options| 52 | options = Include.(options, relationships: { '_self' => 'articles' }) 53 | options.must_equal(include: [:id, :attributes, :relationships, :included], 54 | included: { 55 | include: [], 56 | people: { 57 | attributes: { include: [] }, 58 | relationships: { include: [] }, 59 | _json_api_parsed: true 60 | } 61 | }, 62 | attributes: { include: [:title, :body] }, 63 | relationships: { include: [:title, :body] }) 64 | end 65 | end 66 | 67 | it 'parses :fields, but does not include other resources' do 68 | [{ include: [], fields: { articles: [:title, :body], people: [:email] } }, 69 | { include: [''], fields: { articles: ['title,body'], people: ['email'] } }, 70 | { include: '', fields: { 'articles' => 'title,body', 'people' => 'email' } }].each do |options| 71 | options = Include.(options, relationships: { 'author' => 'people', 'articles' => 'articles' }) 72 | options.must_equal(include: [:id, :attributes, :relationships, :included], 73 | included: { 74 | include: [], 75 | articles: { 76 | attributes: { include: [:title, :body] }, 77 | relationships: { include: [:title, :body] }, 78 | _json_api_parsed: true 79 | }, 80 | author: { 81 | attributes: { include: [:email] }, 82 | relationships: { include: [:email] }, 83 | _json_api_parsed: true 84 | } 85 | }) 86 | end 87 | end 88 | end 89 | 90 | describe 'with non-empty :include option' do 91 | it 'rewrites :include given a relationship name' do 92 | [{ include: [:comments] }, 93 | { include: ['comments'] }, 94 | { include: 'comments' }].each do |options| 95 | options = Include.(options, {}) 96 | options.must_equal(include: [:id, :attributes, :relationships, :included], 97 | included: { 98 | include: [:comments], 99 | comments: { 100 | include: [:id, :attributes, :relationships], 101 | _json_api_parsed: true 102 | } 103 | }) 104 | end 105 | end 106 | 107 | it 'rewrites :include given a dot-separated path of relationship names' do 108 | [{ include: [:"comments.author.employer"] }, 109 | { include: ['comments.author.employer'] }, 110 | { include: 'comments.author.employer' }].each do |options| 111 | options = Include.(options, {}) 112 | options.must_equal(include: [:id, :attributes, :relationships, :included], 113 | included: { 114 | include: [:comments], 115 | comments: { 116 | include: [:id, :attributes, :relationships, :included], 117 | included: { 118 | author: { 119 | include: [:id, :attributes, :relationships, :included], 120 | included: { 121 | employer: { 122 | include: [:id, :attributes, :relationships], 123 | _json_api_parsed: true 124 | } 125 | }, 126 | _json_api_parsed: true 127 | } 128 | }, 129 | _json_api_parsed: true 130 | } 131 | }) 132 | end 133 | end 134 | 135 | it 'does not rewrite :include if _json_api_parsed: true' do 136 | options = Include.({ include: [:id, :attributes], 137 | _json_api_parsed: true }, {}) 138 | options.must_equal(include: [:id, :attributes], 139 | _json_api_parsed: true) 140 | end 141 | end 142 | 143 | describe 'with falsey :include options' do 144 | it 'does not rewrite include: false' do 145 | options = Include.({ include: false }, {}) 146 | options.must_equal(include: false) 147 | end 148 | 149 | it 'does not rewrite include: nil' do 150 | options = Include.({ include: nil }, {}) 151 | options.must_equal(include: nil) 152 | end 153 | end 154 | 155 | describe 'with falsey :fields options' do 156 | it 'does not parse fields: nil' do 157 | options = Include.({ fields: nil }, {}) 158 | options.must_equal(fields: nil) 159 | end 160 | end 161 | end 162 | -------------------------------------------------------------------------------- /test/jsonapi/fieldsets_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | require 'roar/json/json_api' 3 | require 'json' 4 | 5 | class JSONAPIFieldsetsTest < Minitest::Spec 6 | Article = Struct.new(:id, :title, :summary, :comments, :author, :slug) 7 | Comment = Struct.new(:id, :body, :good, :comment_author) 8 | Author = Struct.new(:id, :name, :email) 9 | 10 | describe 'Single Resource Object With Options' do 11 | class DocumentSingleResourceObjectDecorator < Roar::Decorator 12 | include Roar::JSON::JSONAPI.resource :articles 13 | 14 | attributes do 15 | property :title 16 | property :summary 17 | end 18 | 19 | has_many :comments do 20 | attributes do 21 | property :body 22 | property :good 23 | end 24 | 25 | has_one :comment_author, class: Comment do 26 | type :authors 27 | 28 | attributes do 29 | property :name 30 | property :email 31 | end 32 | end 33 | end 34 | 35 | has_one :author do 36 | type :authors 37 | 38 | attributes do 39 | property :name 40 | property :email 41 | end 42 | end 43 | end 44 | 45 | let(:comments) { 46 | [ 47 | Comment.new('c:1', 'Cool!', true, 48 | Author.new('a:2', 'Tim', 'troll@trollblazer.io')), 49 | Comment.new('c:2', 'Nah', false) 50 | ] 51 | } 52 | 53 | let(:article) { 54 | Article.new(1, 'My Article', 'An interesting read.', comments, 55 | Author.new('a:1', 'Celso', 'celsito@trb.to')) 56 | } 57 | 58 | it 'includes scalars' do 59 | DocumentSingleResourceObjectDecorator.new(article) 60 | .to_json( 61 | fields: { articles: 'title' } 62 | ) 63 | .must_equal_json(%( 64 | { 65 | "data": { 66 | "id": "1", 67 | "attributes": { 68 | "title": "My Article" 69 | }, 70 | "type": "articles" 71 | } 72 | } 73 | )) 74 | end 75 | 76 | it 'includes compound objects' do 77 | DocumentSingleResourceObjectDecorator.new(article) 78 | .to_json( 79 | fields: { articles: 'title' }, 80 | include: :comments 81 | ) 82 | .must_equal_json(%( 83 | { 84 | "data": { 85 | "id": "1", 86 | "attributes": { 87 | "title": "My Article" 88 | }, 89 | "type": "articles" 90 | }, 91 | "included": [ 92 | { 93 | "type": "comments", 94 | "id": "c:1", 95 | "attributes": { 96 | "body": "Cool!", 97 | "good": true 98 | }, 99 | "relationships": { 100 | "comment-author": { 101 | "data": { 102 | "type": "authors", 103 | "id": "a:2" 104 | } 105 | } 106 | } 107 | }, 108 | { 109 | "type": "comments", 110 | "id": "c:2", 111 | "attributes": { 112 | "body": "Nah", 113 | "good": false 114 | }, 115 | "relationships": { 116 | "comment-author": { 117 | "data": null 118 | } 119 | } 120 | } 121 | ] 122 | } 123 | )) 124 | end 125 | 126 | it 'includes nested compound objects' do 127 | DocumentSingleResourceObjectDecorator.new(article) 128 | .to_json( 129 | fields: { articles: 'title' }, 130 | include: 'comments.author' 131 | ) 132 | .must_equal_json(%( 133 | { 134 | "data": { 135 | "id": "1", 136 | "attributes": { 137 | "title": "My Article" 138 | }, 139 | "type": "articles" 140 | }, 141 | "included": [ 142 | { 143 | "type": "comments", 144 | "id": "c:1", 145 | "attributes": { 146 | "body": "Cool!", 147 | "good": true 148 | }, 149 | "relationships": { 150 | "comment-author": { 151 | "data": { 152 | "type": "authors", 153 | "id": "a:2" 154 | } 155 | } 156 | }, 157 | "included": [ 158 | { 159 | "type": "authors", 160 | "id": "a:2", 161 | "attributes": { 162 | "email": "troll@trollblazer.io", 163 | "name": "Tim" 164 | } 165 | } 166 | ] 167 | }, 168 | { 169 | "type": "comments", 170 | "id": "c:2", 171 | "attributes": { 172 | "body": "Nah", 173 | "good": false 174 | }, 175 | "relationships": { 176 | "comment-author": { 177 | "data": null 178 | } 179 | } 180 | } 181 | ] 182 | } 183 | )) 184 | end 185 | 186 | it 'includes other compound objects' do 187 | DocumentSingleResourceObjectDecorator.new(article) 188 | .to_json( 189 | fields: { articles: 'title' }, 190 | include: :author 191 | ) 192 | .must_equal_json(%( 193 | { 194 | "data": { 195 | "id": "1", 196 | "attributes": { 197 | "title": "My Article" 198 | }, 199 | "type": "articles" 200 | }, 201 | "included": [ 202 | { 203 | "type": "authors", 204 | "id": "a:1", 205 | "attributes": { 206 | "email": "celsito@trb.to", 207 | "name": "Celso" 208 | } 209 | } 210 | ] 211 | } 212 | )) 213 | end 214 | 215 | describe 'collection' do 216 | it 'supports :includes' do 217 | DocumentSingleResourceObjectDecorator.for_collection.new([article]) 218 | .to_hash( 219 | fields: { articles: 'title' }, 220 | include: :author 221 | ) 222 | .must_equal Hash[{ 223 | 'data' => [ 224 | { 'type' => 'articles', 225 | 'id' => '1', 226 | 'attributes' => { 'title'=>'My Article' } } 227 | ], 228 | 'included' => 229 | [{ 'type' => 'authors', 'id' => 'a:1', 'attributes' => { 'name' => 'Celso', 'email' => 'celsito@trb.to' } }] 230 | }] 231 | end 232 | 233 | # include: ROAR API 234 | it 'blaaaaaaa' do 235 | DocumentSingleResourceObjectDecorator.for_collection.new([article]) 236 | .to_hash( 237 | fields: { articles: 'title', authors: [:email] }, 238 | include: :author 239 | ) 240 | .must_equal Hash[{ 241 | 'data' => [ 242 | { 'type' => 'articles', 243 | 'id' => '1', 244 | 'attributes' => { 'title'=>'My Article' } } 245 | ], 246 | 'included' => 247 | [{ 'type' => 'authors', 'id' => 'a:1', 'attributes' => { 'email'=>'celsito@trb.to' } }] 248 | }] 249 | end 250 | end 251 | end 252 | 253 | describe 'Collection Resources With Options' do 254 | class CollectionResourceObjectDecorator < Roar::Decorator 255 | include Roar::JSON::JSONAPI.resource :articles 256 | 257 | attributes do 258 | property :title 259 | property :summary 260 | end 261 | end 262 | 263 | let(:document) { 264 | %({ 265 | "data": [ 266 | { 267 | "id": "1", 268 | "attributes": { 269 | "title": "My Article" 270 | }, 271 | "type": "articles" 272 | }, 273 | { 274 | "id": "2", 275 | "attributes": { 276 | "title": "My Other Article" 277 | }, 278 | "type": "articles" 279 | } 280 | ] 281 | }) 282 | } 283 | 284 | it do 285 | CollectionResourceObjectDecorator.for_collection.new([ 286 | Article.new(1, 'My Article', 'An interesting read.'), 287 | Article.new(2, 'My Other Article', 'An interesting read.') 288 | ]).to_json( 289 | fields: { articles: :title } 290 | ).must_equal_json document 291 | end 292 | end 293 | 294 | describe 'Document with given id_key and nested resources with default id_key' do 295 | class DocumentResourceWithDifferentIdAtRoot < Roar::Decorator 296 | include Roar::JSON::JSONAPI.resource :articles, id_key: :article_id 297 | 298 | attributes do 299 | property :title 300 | property :summary 301 | end 302 | 303 | has_many(:comments) do 304 | attributes do 305 | property :body 306 | property :good 307 | end 308 | end 309 | end 310 | 311 | let(:comments) { 312 | [ 313 | Comment.new('c:1', 'Cool!', true, 314 | Author.new('a:2', 'Tim', 'troll@trollblazer.io')), 315 | Comment.new('c:2', 'Nah', false) 316 | ] 317 | } 318 | 319 | let(:article) { 320 | klass = Struct.new(:article_id, :title, :summary, :comments, :author) 321 | klass.new(1, 'My Article', 'An interesting read.', comments, 322 | Author.new('a:1', 'Celso', 'celsito@trb.to')) 323 | } 324 | 325 | let(:document) { 326 | %({ 327 | "data": { 328 | "id": "1", 329 | "attributes": { 330 | "summary": "An interesting read.", 331 | "title": "My Article" 332 | }, 333 | "type": "articles", 334 | "relationships": { 335 | "comments": { 336 | "data": [ 337 | { 338 | "id": "c:1", 339 | "type": "comments" 340 | }, 341 | { 342 | "id": "c:2", 343 | "type": "comments" 344 | } 345 | ] 346 | } 347 | } 348 | }, 349 | "included": [ 350 | { 351 | "type": "comments", 352 | "id": "c:1", 353 | "attributes": { 354 | "body": "Cool!", 355 | "good": true 356 | } 357 | }, 358 | { 359 | "type": "comments", 360 | "id": "c:2", 361 | "attributes": { 362 | "body": "Nah", 363 | "good": false 364 | } 365 | } 366 | ] 367 | }) 368 | } 369 | 370 | it do 371 | DocumentResourceWithDifferentIdAtRoot.new(article).to_json(include: 'comments') 372 | .must_equal_json document 373 | end 374 | end 375 | 376 | describe 'Document with default id_key and nested resources with given id_key' do 377 | class CommentDecorator < Roar::Decorator 378 | include Roar::JSON::JSONAPI.resource :comments, id_key: :comment_id 379 | 380 | attributes do 381 | property :body 382 | property :good 383 | end 384 | end 385 | 386 | class DocumentResourceWithDifferentIdAtRelation < Roar::Decorator 387 | include Roar::JSON::JSONAPI.resource :articles 388 | 389 | attributes do 390 | property :title 391 | property :summary 392 | end 393 | 394 | has_many :comments, decorator: CommentDecorator 395 | end 396 | 397 | let(:comments) { 398 | klass = Struct.new(:comment_id, :body, :good, :comment_author) 399 | [ 400 | klass.new('c:1', 'Cool!', true, 401 | Author.new('a:2', 'Tim', 'troll@trollblazer.io')), 402 | klass.new('c:2', 'Nah', false) 403 | ] 404 | } 405 | 406 | let(:article) { 407 | Article.new(1, 'My Article', 'An interesting read.', comments, 408 | Author.new('a:1', 'Celso', 'celsito@trb.to')) 409 | } 410 | 411 | let(:document) { 412 | %({ 413 | "data": { 414 | "id": "1", 415 | "attributes": { 416 | "summary": "An interesting read.", 417 | "title": "My Article" 418 | }, 419 | "type": "articles", 420 | "relationships": { 421 | "comments": { 422 | "data": [ 423 | { 424 | "id": "c:1", 425 | "type": "comments" 426 | }, 427 | { 428 | "id": "c:2", 429 | "type": "comments" 430 | } 431 | ] 432 | } 433 | } 434 | }, 435 | "included": [ 436 | { 437 | "type": "comments", 438 | "id": "c:1", 439 | "attributes": { 440 | "body": "Cool!", 441 | "good": true 442 | } 443 | }, 444 | { 445 | "type": "comments", 446 | "id": "c:2", 447 | "attributes": { 448 | "body": "Nah", 449 | "good": false 450 | } 451 | } 452 | ] 453 | }) 454 | } 455 | 456 | it do 457 | DocumentResourceWithDifferentIdAtRelation.new(article).to_json(include: 'comments') 458 | .must_equal_json document 459 | end 460 | end 461 | 462 | describe 'Document with given id_key and nested resources with given id_key' do 463 | class CommentDecoratorWithId < Roar::Decorator 464 | include Roar::JSON::JSONAPI.resource :comments, id_key: :comment_id 465 | 466 | attributes do 467 | property :body 468 | property :good 469 | end 470 | end 471 | 472 | class DocumentAndRelationWithDifferentId < Roar::Decorator 473 | include Roar::JSON::JSONAPI.resource :articles, id_key: :slug 474 | 475 | attributes do 476 | property :title 477 | property :summary 478 | end 479 | 480 | has_many :comments, decorator: CommentDecoratorWithId 481 | end 482 | 483 | let(:comments) { 484 | klass = Struct.new(:comment_id, :body, :good, :comment_author) 485 | [ 486 | klass.new('c:1', 'Cool!', true, 487 | Author.new('a:2', 'Tim', 'troll@trollblazer.io')), 488 | klass.new('c:2', 'Nah', false) 489 | ] 490 | } 491 | 492 | let(:article) { 493 | Article.new(1, 'My Article', 'An interesting read.', comments, 494 | Author.new('a:1', 'Celso', 'celsito@trb.to'), 'my-article') 495 | } 496 | 497 | let(:document) { 498 | %({ 499 | "data": { 500 | "id": "my-article", 501 | "attributes": { 502 | "summary": "An interesting read.", 503 | "title": "My Article" 504 | }, 505 | "type": "articles", 506 | "relationships": { 507 | "comments": { 508 | "data": [ 509 | { 510 | "id": "c:1", 511 | "type": "comments" 512 | }, 513 | { 514 | "id": "c:2", 515 | "type": "comments" 516 | } 517 | ] 518 | } 519 | } 520 | }, 521 | "included": [ 522 | { 523 | "type": "comments", 524 | "id": "c:1", 525 | "attributes": { 526 | "body": "Cool!", 527 | "good": true 528 | } 529 | }, 530 | { 531 | "type": "comments", 532 | "id": "c:2", 533 | "attributes": { 534 | "body": "Nah", 535 | "good": false 536 | } 537 | } 538 | ] 539 | }) 540 | } 541 | 542 | it do 543 | DocumentAndRelationWithDifferentId.new(article).to_json(include: 'comments') 544 | .must_equal_json document 545 | end 546 | end 547 | end 548 | -------------------------------------------------------------------------------- /test/jsonapi/member_name_test.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | require 'test_helper' 4 | require 'roar/json/json_api' 5 | require 'json' 6 | require 'jsonapi/representer' 7 | 8 | class MemberNameTest < MiniTest::Spec 9 | MemberName = Roar::JSON::JSONAPI::MemberName 10 | 11 | # http://jsonapi.org/format/#document-member-names-reserved-characters 12 | UNICODE_RESERVED_CHARACTERS = [ 13 | "\u002B", 14 | "\u002C", 15 | "\u002E", 16 | "\u005B", 17 | "\u005D", 18 | "\u0021", 19 | "\u0022", 20 | "\u0023", 21 | "\u0024", 22 | "\u0025", 23 | "\u0026", 24 | "\u0027", 25 | "\u0028", 26 | "\u0029", 27 | "\u002A", 28 | "\u002F", 29 | "\u003A", 30 | "\u003B", 31 | "\u003C", 32 | "\u003D", 33 | "\u003E", 34 | "\u003F", 35 | "\u0040", 36 | "\u005C", 37 | "\u005E", 38 | "\u0060", 39 | "\u007B", 40 | "\u007C", 41 | "\u007D", 42 | "\u007E" 43 | ].freeze 44 | 45 | describe 'strict (default)' do 46 | it 'permits alphanumeric ASCII characters, hyphens' do 47 | MemberName.('99 Luftballons').must_equal '99luftballons' 48 | MemberName.('Artist').must_equal 'artist' 49 | MemberName.('Актер').must_equal '' 50 | MemberName.('おまかせ').must_equal '' 51 | MemberName.('auf-der-bühne').must_equal 'auf-der-bhne' 52 | MemberName.('nouvelle_interprétation').must_equal 'nouvelle-interprtation' 53 | end 54 | 55 | it 'does not permit any reserved characters' do 56 | MemberName.(UNICODE_RESERVED_CHARACTERS.join).must_equal '' 57 | end 58 | 59 | it 'hyphenates underscored words' do 60 | MemberName.('playtime_report').must_equal 'playtime-report' 61 | end 62 | end 63 | 64 | describe 'non-strict' do 65 | it 'permits alphanumeric unicode characters, hyphens, underscores and spaces' do 66 | MemberName.('99 Luftballons', strict: false).must_equal '99 Luftballons' 67 | MemberName.('Artist', strict: false).must_equal 'Artist' 68 | MemberName.('Актер', strict: false).must_equal 'Актер' 69 | MemberName.('おまかせ', strict: false).must_equal 'おまかせ' 70 | MemberName.('auf-der-bühne', strict: false).must_equal 'auf-der-bühne' 71 | MemberName.('nouvelle_interprétation', strict: false).must_equal 'nouvelle_interprétation' 72 | end 73 | 74 | it 'does not permit any reserved characters' do 75 | MemberName.(UNICODE_RESERVED_CHARACTERS.join, strict: false).must_equal '' 76 | end 77 | 78 | it 'does not permit hyphens, underscores or spaces at beginning or end' do 79 | MemberName.(' 99 Luftballons ', strict: false).must_equal '99 Luftballons' 80 | MemberName.('-Artist_', strict: false).must_equal 'Artist' 81 | MemberName.('_Актер', strict: false).must_equal 'Актер' 82 | MemberName.(' おまかせ', strict: false).must_equal 'おまかせ' 83 | MemberName.('-auf-der-bühne', strict: false).must_equal 'auf-der-bühne' 84 | MemberName.('nouvelle_interprétation_', strict: false).must_equal 'nouvelle_interprétation' 85 | end 86 | 87 | it 'preserves underscored words' do 88 | MemberName.('playtime_report', strict: false).must_equal 'playtime_report' 89 | end 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /test/jsonapi/post_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | require 'roar/json/json_api' 3 | require 'json' 4 | 5 | class JsonapiPostTest < MiniTest::Spec 6 | describe 'Parse' do 7 | let(:post_article) { 8 | %({ 9 | "data": { 10 | "type": "articles", 11 | "attributes": { 12 | "title": "Ember Hamster" 13 | }, 14 | "relationships": { 15 | "author": { 16 | "data": { 17 | "type": "people", 18 | "id": "9", 19 | "name": "Celsito" 20 | } 21 | }, 22 | "comments": { 23 | "data": [{ 24 | "type": "comment", 25 | "id": "2" 26 | }, { 27 | "type": "comment", 28 | "id": "3" 29 | }] 30 | } 31 | } 32 | } 33 | }) 34 | } 35 | 36 | subject { ArticleDecorator.new(Article.new(nil, nil, nil, nil, [])).from_json(post_article) } 37 | 38 | it do 39 | subject.title.must_equal 'Ember Hamster' 40 | subject.author.id.must_equal '9' 41 | subject.author.email.must_equal '9@nine.to' 42 | # subject.author.name.must_be_nil 43 | 44 | subject.comments.must_equal [Comment.new('2'), Comment.new('3')] 45 | end 46 | end 47 | 48 | describe 'Parse Simple' do 49 | let(:post_article) { 50 | %({ 51 | "data": { 52 | "type": "articles", 53 | "attributes": { 54 | "title": "Ember Hamster" 55 | } 56 | } 57 | }) 58 | } 59 | 60 | subject { ArticleDecorator.new(Article.new(nil, nil, nil, nil, [])).from_json(post_article) } 61 | 62 | it do 63 | subject.title.must_equal 'Ember Hamster' 64 | end 65 | end 66 | 67 | describe 'Parse Badly Formed Document' do 68 | let(:post_article) { 69 | %({"title":"Ember Hamster"}) 70 | } 71 | 72 | subject { ArticleDecorator.new(Article.new(nil, nil, nil, nil, [])).from_json(post_article) } 73 | 74 | it do 75 | subject.title.must_be_nil 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /test/jsonapi/relationship_custom_naming_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class RelationshipCustomNameTest < MiniTest::Spec 4 | class ChefDecorator < Roar::Decorator 5 | include Roar::JSON::JSONAPI.resource :chefs 6 | 7 | attributes do 8 | property :name 9 | end 10 | end 11 | 12 | class IngredientDecorator < Roar::Decorator 13 | include Roar::JSON::JSONAPI.resource :ingredients 14 | 15 | attributes do 16 | property :name 17 | end 18 | end 19 | 20 | class RecipeDecorator < Roar::Decorator 21 | include Roar::JSON::JSONAPI.resource :recipes 22 | 23 | attributes do 24 | property :name 25 | end 26 | 27 | has_one :best_chef, as: "bestChefEver", extend: ChefDecorator 28 | has_many :best_ingredients, as: "bestIngridients", extend: IngredientDecorator 29 | end 30 | 31 | Recipe = Struct.new(:id, :name, :best_chef, :best_ingredients, :reviews) 32 | Chef = Struct.new(:id, :name) 33 | Ingredient = Struct.new(:id, :name) 34 | 35 | let(:doc) { RecipeDecorator.new(souffle).to_hash } 36 | let(:doc_relationships) { doc['data']['relationships'] } 37 | describe 'non-empty relationships' do 38 | let(:souffle) { 39 | Recipe.new(1, 'Cheese Muffins', 40 | Chef.new(1, 'Jamie Oliver'), 41 | [Ingredient.new(5, 'Eggs'), Ingredient.new(6, 'Emmental')]) 42 | } 43 | 44 | it 'renders a single object for non-empty to-one relationships with custom name' do 45 | doc_relationships['best_chef'].must_be_nil 46 | doc_relationships['bestChefEver'].wont_be_nil 47 | end 48 | 49 | it 'renders an array for non-empty to-many relationships with custom name' do 50 | doc_relationships['best_ingredients'].must_be_nil 51 | doc_relationships['bestIngridients'].wont_be_nil 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /test/jsonapi/render_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | require 'roar/json/json_api' 3 | require 'json' 4 | 5 | class JsonapiRenderTest < MiniTest::Spec 6 | let(:article) { Article.new(1, 'Health walk', Author.new(2), Author.new('editor:1'), [Comment.new('comment:1', 'Ice and Snow'), Comment.new('comment:2', 'Red Stripe Skank')], [Author.new('contributor:1'),Author.new('contributor:2')]) } 7 | let(:decorator) { ArticleDecorator.new(article) } 8 | 9 | it 'renders full document' do 10 | json = decorator.to_json 11 | json.must_equal_json(%({ 12 | "data": { 13 | "id": "1", 14 | "relationships": { 15 | "author": { 16 | "data": { 17 | "id": "2", 18 | "type": "authors" 19 | }, 20 | "links": { 21 | "self": "/articles/1/relationships/author", 22 | "related": "/articles/1/author" 23 | } 24 | }, 25 | "editor": { 26 | "data": { 27 | "id": "editor:1", 28 | "type": "editors" 29 | }, 30 | "meta": { 31 | "peer-reviewed": false 32 | } 33 | }, 34 | "comments": { 35 | "data": [{ 36 | "id": "comment:1", 37 | "type": "comments" 38 | }, { 39 | "id": "comment:2", 40 | "type": "comments" 41 | }], 42 | "links": { 43 | "self": "/articles/1/relationships/comments", 44 | "related": "/articles/1/comments" 45 | }, 46 | "meta": { 47 | "comment-count": 6 48 | } 49 | }, 50 | "contributors": { 51 | "data": [{ 52 | "id": "contributor:1", 53 | "type": "authors" 54 | }, { 55 | "id": "contributor:2", 56 | "type": "authors" 57 | }], 58 | "links": { 59 | "self": "/articles/1/relationships/contributors", 60 | "related": "/articles/1/contributors" 61 | } 62 | } 63 | }, 64 | "attributes": { 65 | "title": "Health walk" 66 | }, 67 | "type": "articles", 68 | "links": { 69 | "self": "http://Article/1" 70 | } 71 | }, 72 | "included": [{ 73 | "id": "2", 74 | "type": "authors", 75 | "attributes": { 76 | "email": null 77 | }, 78 | "links": { 79 | "self": "http://authors/2" 80 | } 81 | }, { 82 | "attributes": { 83 | "email": null 84 | }, 85 | "id": "editor:1", 86 | "type": "editors" 87 | }, { 88 | "id": "comment:1", 89 | "attributes": { 90 | "body": "Ice and Snow" 91 | }, 92 | "type": "comments", 93 | "links": { 94 | "self": "http://comments/comment:1" 95 | } 96 | }, { 97 | "id": "comment:2", 98 | "attributes": { 99 | "body": "Red Stripe Skank" 100 | }, 101 | "type": "comments", 102 | "links": { 103 | "self": "http://comments/comment:2" 104 | } 105 | }], 106 | "meta": { 107 | "reviewers": ["Christian Bernstein"], 108 | "reviewer-initials": "C.B." 109 | } 110 | })) 111 | end 112 | 113 | it 'included: false suppresses compound docs' do 114 | json = decorator.to_json(included: false) 115 | json.must_equal_json(%({ 116 | "data": { 117 | "id": "1", 118 | "relationships": { 119 | "author": { 120 | "data": { 121 | "id": "2", 122 | "type": "authors" 123 | }, 124 | "links": { 125 | "self": "/articles/1/relationships/author", 126 | "related": "/articles/1/author" 127 | } 128 | }, 129 | "editor": { 130 | "data": { 131 | "id": "editor:1", 132 | "type": "editors" 133 | }, 134 | "meta": { 135 | "peer-reviewed": false 136 | } 137 | }, 138 | "comments": { 139 | "data": [{ 140 | "id": "comment:1", 141 | "type": "comments" 142 | }, { 143 | "id": "comment:2", 144 | "type": "comments" 145 | }], 146 | "links": { 147 | "self": "/articles/1/relationships/comments", 148 | "related": "/articles/1/comments" 149 | }, 150 | "meta": { 151 | "comment-count": 6 152 | } 153 | }, 154 | "contributors": { 155 | "data": [{ 156 | "id": "contributor:1", 157 | "type": "authors" 158 | }, { 159 | "id": "contributor:2", 160 | "type": "authors" 161 | }], 162 | "links": { 163 | "self": "/articles/1/relationships/contributors", 164 | "related": "/articles/1/contributors" 165 | } 166 | } 167 | }, 168 | "attributes": { 169 | "title": "Health walk" 170 | }, 171 | "type": "articles", 172 | "links": { 173 | "self": "http://Article/1" 174 | } 175 | }, 176 | "meta": { 177 | "reviewers": ["Christian Bernstein"], 178 | "reviewer-initials": "C.B." 179 | } 180 | })) 181 | end 182 | 183 | it 'renders extra toplevel meta information if meta option supplied' do 184 | hash = decorator.to_hash(meta: { 185 | 'copyright' => 'Nick Sutterer', 'reviewers' => [] 186 | }) 187 | hash['meta']['copyright'].must_equal('Nick Sutterer') 188 | hash['meta']['reviewers'].must_equal([]) 189 | hash['meta']['reviewer-initials'].must_equal('C.B.') 190 | end 191 | 192 | it 'does not render extra toplevel meta information if meta option is empty' do 193 | hash = decorator.to_hash(meta: {}) 194 | hash['meta']['copyright'].must_be_nil 195 | hash['meta']['reviewers'].must_equal(['Christian Bernstein']) 196 | hash['meta']['reviewer-initials'].must_equal('C.B.') 197 | end 198 | 199 | describe 'Single Resource Object with simple attributes' do 200 | class DocumentSingleResourceObjectDecorator < Roar::Decorator 201 | include Roar::JSON::JSONAPI.resource :articles 202 | 203 | attributes do 204 | property :title 205 | end 206 | end 207 | 208 | let(:document) { 209 | %({ 210 | "data": { 211 | "id": "1", 212 | "attributes": { 213 | "title": "My Article" 214 | }, 215 | "type": "articles" 216 | } 217 | }) 218 | } 219 | 220 | let(:collection_document) { 221 | %({ 222 | "data": [ 223 | { 224 | "type": "articles", 225 | "id": "1", 226 | "attributes": { 227 | "title": "My Article" 228 | } 229 | } 230 | ] 231 | }) 232 | } 233 | 234 | it { DocumentSingleResourceObjectDecorator.new(Article.new(1, 'My Article')).to_json.must_equal_json document } 235 | it { DocumentSingleResourceObjectDecorator.for_collection.new([Article.new(1, 'My Article')]).to_json.must_equal_json collection_document } 236 | end 237 | 238 | describe 'Single Resource Object with complex attributes' do 239 | class VisualArtistDecorator < Roar::Decorator 240 | include Roar::JSON::JSONAPI.resource :visual_artists 241 | 242 | attributes do 243 | property :name 244 | collection :known_aliases 245 | property :movement 246 | collection :noteable_works 247 | end 248 | 249 | link(:self) { "http://visual_artists/#{represented.id}" } 250 | link(:wikipedia_page) { "https://en.wikipedia.org/wiki/#{represented.name}" } 251 | end 252 | 253 | Painter = Struct.new(:id, :name, :known_aliases, :movement, :noteable_works) 254 | 255 | let(:document) { 256 | %({ 257 | "data": { 258 | "type": "visual-artists", 259 | "id": "p1", 260 | "attributes": { 261 | "name": "Pablo Picasso", 262 | "known-aliases": [ 263 | "Pablo Ruiz Picasso" 264 | ], 265 | "movement": "Cubism", 266 | "noteable-works": [ 267 | "Kahnweiler", 268 | "Guernica" 269 | ] 270 | }, 271 | "links": { 272 | "self": "http://visual_artists/p1", 273 | "wikipedia-page": "https://en.wikipedia.org/wiki/Pablo Picasso" 274 | } 275 | } 276 | }) 277 | } 278 | 279 | let(:collection_document) { 280 | %({ 281 | "data": [ 282 | { 283 | "type": "visual-artists", 284 | "id": "p1", 285 | "attributes": { 286 | "name": "Pablo Picasso", 287 | "known-aliases": [ 288 | "Pablo Ruiz Picasso" 289 | ], 290 | "movement": "Cubism", 291 | "noteable-works": [ 292 | "Kahnweiler", 293 | "Guernica" 294 | ] 295 | }, 296 | "links": { 297 | "self": "http://visual_artists/p1", 298 | "wikipedia-page": "https://en.wikipedia.org/wiki/Pablo Picasso" 299 | } 300 | } 301 | ] 302 | }) 303 | } 304 | 305 | let(:painter) { 306 | Painter.new('p1', 'Pablo Picasso', ['Pablo Ruiz Picasso'], 'Cubism', 307 | %w(Kahnweiler Guernica)) 308 | } 309 | 310 | it { VisualArtistDecorator.new(painter).to_json.must_equal_json document } 311 | it { VisualArtistDecorator.for_collection.new([painter]).to_json.must_equal_json collection_document } 312 | end 313 | 314 | 315 | describe 'null/ empty attributes render correctly' do 316 | class ArtistDecorator < Roar::Decorator 317 | include Roar::JSON::JSONAPI.resource :artists 318 | 319 | attributes do 320 | property :name 321 | collection :known_aliases 322 | property :movement 323 | collection :noteable_works 324 | property :genre, render_nil: false # tests that we can override default setting 325 | end 326 | 327 | link(:self) { "http://artists/#{represented.id}" } 328 | end 329 | 330 | Painter = Struct.new(:id, :name, :known_aliases, :movement, :noteable_works, :genre) 331 | 332 | let(:document) { 333 | %({ 334 | "data": { 335 | "type": "artists", 336 | "id": "p1", 337 | "attributes": { 338 | "name": null, 339 | "known-aliases": [], 340 | "movement": null, 341 | "noteable-works": [] 342 | }, 343 | "links": { 344 | "self": "http://artists/p1" 345 | } 346 | } 347 | }) 348 | } 349 | 350 | let(:collection_document) { 351 | %({ 352 | "data": [ 353 | { 354 | "type": "artists", 355 | "id": "p1", 356 | "attributes": { 357 | "name": null, 358 | "known-aliases": [], 359 | "movement": null, 360 | "noteable-works": [] 361 | }, 362 | "links": { 363 | "self": "http://artists/p1" 364 | } 365 | } 366 | ] 367 | }) 368 | } 369 | 370 | let(:painter) { 371 | Painter.new('p1', nil, [], nil, [], nil) 372 | } 373 | 374 | it { ArtistDecorator.new(painter).to_json.must_equal_json document } 375 | it { ArtistDecorator.for_collection.new([painter]).to_json.must_equal_json collection_document } 376 | end 377 | end 378 | -------------------------------------------------------------------------------- /test/jsonapi/representer.rb: -------------------------------------------------------------------------------- 1 | Author = Struct.new(:id, :email, :name) do 2 | def self.find_by(options) 3 | AuthorNine if options[:id].to_s == '9' 4 | end 5 | end 6 | AuthorNine = Author.new(9, '9@nine.to') 7 | 8 | Article = Struct.new(:id, :title, :author, :editor, :comments, :contributors) do 9 | def reviewers 10 | ['Christian Bernstein'] 11 | end 12 | end 13 | 14 | Comment = Struct.new(:comment_id, :body) do 15 | def self.find_by(_options) 16 | new 17 | end 18 | end 19 | 20 | class AuthorDecorator < Roar::Decorator 21 | include Roar::JSON::JSONAPI.resource :authors 22 | 23 | attributes do 24 | property :email 25 | end 26 | 27 | link(:self) { "http://authors/#{represented.id}" } 28 | end 29 | 30 | class CommentDecorator < Roar::Decorator 31 | include Roar::JSON::JSONAPI.resource(:comments, id_key: :comment_id) 32 | 33 | attributes do 34 | property :body 35 | end 36 | 37 | link(:self) { "http://comments/#{represented.comment_id}" } 38 | end 39 | 40 | class ArticleDecorator < Roar::Decorator 41 | include Roar::JSON::JSONAPI.resource :articles 42 | 43 | # top-level link. 44 | link :self, toplevel: true do |options| 45 | if options 46 | "//articles?page=#{options[:page]}&per_page=#{options[:per_page]}" 47 | else 48 | '//articles' 49 | end 50 | end 51 | 52 | meta toplevel: true do 53 | property :count 54 | end 55 | 56 | attributes do 57 | property :title 58 | end 59 | 60 | meta do 61 | collection :reviewers 62 | end 63 | 64 | meta do 65 | property :reviewer_initials, getter: ->(_) { 66 | reviewers.map { |reviewer| 67 | reviewer.split.map { |name| "#{name[0]}." }.join 68 | }.join(', ') 69 | } 70 | end 71 | 72 | # resource object links 73 | link(:self) { "http://#{represented.class}/#{represented.id}" } 74 | 75 | # relationships 76 | has_one :author, class: Author, decorator: AuthorDecorator, 77 | populator: ::Representable::FindOrInstantiate do # populator is for parsing, only. 78 | 79 | relationship do 80 | link(:self) { "/articles/#{represented.id}/relationships/author" } 81 | link(:related) { "/articles/#{represented.id}/author" } 82 | end 83 | end 84 | 85 | has_one :editor do 86 | type :editors 87 | 88 | relationship do 89 | meta do 90 | property :peer_reviewed, getter: ->(_) { false } 91 | end 92 | end 93 | 94 | attributes do 95 | property :email 96 | end 97 | # No self link for editors because we want to make sure the :links option does not appear in the hash. 98 | end 99 | 100 | has_many :comments, class: Comment, decorator: CommentDecorator, 101 | populator: ::Representable::FindOrInstantiate do 102 | 103 | relationship do 104 | link(:self) { "/articles/#{represented.id}/relationships/comments" } 105 | link(:related) { "/articles/#{represented.id}/comments" } 106 | 107 | meta do 108 | property :count, as: 'comment-count' 109 | end 110 | end 111 | end 112 | 113 | 114 | # this relationship should be listed in relationships but no data included/sideloaded 115 | has_many :contributors, class: Author, included: false do 116 | type :authors 117 | 118 | relationship do 119 | link(:self) { "/articles/#{represented.id}/relationships/contributors" } 120 | link(:related) { "/articles/#{represented.id}/contributors" } 121 | end 122 | end 123 | end 124 | -------------------------------------------------------------------------------- /test/jsonapi/resource_linkage_test.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | require 'test_helper' 4 | require 'roar/json/json_api' 5 | require 'json' 6 | 7 | class ResourceLinkageTest < MiniTest::Spec 8 | class ChefDecorator < Roar::Decorator 9 | include Roar::JSON::JSONAPI.resource :chefs 10 | 11 | attributes do 12 | property :name 13 | end 14 | end 15 | 16 | class IngredientDecorator < Roar::Decorator 17 | include Roar::JSON::JSONAPI.resource :ingredients 18 | 19 | attributes do 20 | property :name 21 | end 22 | end 23 | 24 | class RecipeDecorator < Roar::Decorator 25 | include Roar::JSON::JSONAPI.resource :recipes 26 | 27 | attributes do 28 | property :name 29 | end 30 | 31 | has_one :chef, extend: ChefDecorator 32 | has_many :ingredients, extend: IngredientDecorator 33 | 34 | has_many :reviews do 35 | type :review 36 | 37 | attributes do 38 | property :text 39 | end 40 | end 41 | end 42 | 43 | Recipe = Struct.new(:id, :name, :chef, :ingredients, :reviews) 44 | Chef = Struct.new(:id, :name) 45 | Ingredient = Struct.new(:id, :name) 46 | 47 | let(:doc) { RecipeDecorator.new(souffle).to_hash } 48 | let(:doc_relationships) { doc['data']['relationships'] } 49 | 50 | describe 'non-empty relationships' do 51 | let(:souffle) { 52 | Recipe.new(1, 'Cheese soufflé', 53 | Chef.new(1, 'Jamie Oliver'), 54 | [Ingredient.new(5, 'Eggs'), Ingredient.new(6, 'Gruyère')]) 55 | } 56 | 57 | it 'renders a single object for non-empty to-one relationships' do 58 | doc_relationships['chef'].must_equal('data'=>{ 'type' => 'chefs', 'id' => '1' }) 59 | end 60 | 61 | it 'renders an array for non-empty to-many relationships' do 62 | doc_relationships['ingredients'].must_equal('data' => [ 63 | { 'type' => 'ingredients', 'id' => '5' }, 64 | { 'type' => 'ingredients', 'id' => '6' } 65 | ]) 66 | end 67 | end 68 | 69 | describe 'empty (nil) relationships' do 70 | let(:souffle) { Recipe.new(1, 'Cheese soufflé', nil, nil) } 71 | 72 | it 'renders null for an empty to-one relationships' do 73 | doc_relationships['chef'].must_equal('data' => nil) 74 | end 75 | 76 | it 'renders an empty array ([]) for empty (nil) to-many relationships' do 77 | doc_relationships['ingredients'].must_equal('data' => []) 78 | end 79 | end 80 | 81 | describe 'empty to-many relationships' do 82 | let(:souffle) { Recipe.new(1, 'Cheese soufflé', nil, []) } 83 | 84 | it 'renders an empty array ([]) for empty to-many relationships' do 85 | doc_relationships['ingredients'].must_equal('data' => []) 86 | end 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require 'minitest/autorun' 2 | require 'minitest/reporters' 3 | Minitest::Reporters.use! Minitest::Reporters::DefaultReporter.new 4 | 5 | require 'roar/representer' 6 | require 'roar/json' 7 | require 'roar/json/json_api' 8 | 9 | require 'representable/debug' 10 | require 'pp' 11 | 12 | require_relative 'jsonapi/representer' 13 | 14 | require 'json_spec/configuration' 15 | require 'json_spec/helpers' 16 | require 'json_spec/exclusion' 17 | 18 | if system('colordiff', __FILE__, __FILE__) 19 | MiniTest::Assertions.diff = 'colordiff -u' 20 | end 21 | 22 | module JsonSpec 23 | extend Configuration 24 | 25 | self.excluded_keys = [] 26 | end 27 | module MiniTest::Assertions 28 | def assert_equal_json(actual, expected) 29 | assert_equal scrub(actual), scrub(expected) 30 | end 31 | 32 | def scrub(json, path = nil) 33 | JsonSpec::Helpers.generate_normalized_json( 34 | JsonSpec::Exclusion.exclude_keys( 35 | JsonSpec::Helpers.parse_json(json, path) 36 | ) 37 | ).chomp + "\n" 38 | end 39 | end 40 | module Minitest::Expectations 41 | infect_an_assertion :assert_equal_json, :must_equal_json 42 | end 43 | --------------------------------------------------------------------------------