diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cd19e2..d0f9bc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ * Your contribution here. +* [#108](https://github.com/ruby-grape/grape-active_model_serializers/pull/108): Document JSON:API included resources and verify relationship linkage, explicit inclusion, and collection deduplication through API requests - [@dblock](https://github.com/dblock). * [#110](https://github.com/ruby-grape/grape-active_model_serializers/pull/110): Add SimpleCov LCOV reports, merged stable-matrix Coveralls reporting, and a coverage badge; remove the obsolete Code Climate badge and refresh gem/CI badge links - [@dblock](https://github.com/dblock). * [#109](https://github.com/ruby-grape/grape-active_model_serializers/pull/109): Document the optional third-party Swagger model parser, response model configuration, and schema limitations - [@dblock](https://github.com/dblock). * [#106](https://github.com/ruby-grape/grape-active_model_serializers/pull/106): Update default serializer options documentation to use AMS 0.10 field filtering and cover request-level defaults and overrides - [@dblock](https://github.com/dblock). diff --git a/README.md b/README.md index 2f30de4..a8ec25d 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ - [API Versioning](#api-versioning) - [Manually specifying serializer / adapter options](#manually-specifying-serializer--adapter-options) - [Error Serialization](#error-serialization) + - [JSON:API Included Resources](#jsonapi-included-resources) - [Custom Metadata](#custom-metadata) - [Default Serializer Options](#default-serializer-options) - [Current User](#current-user) @@ -257,6 +258,50 @@ end With the `:json_api` adapter, validation failures return HTTP 422 with a [JSON:API errors array](https://jsonapi.org/format/#error-objects). The response shape follows the configured ActiveModelSerializers adapter, not the Grape format name: the example still uses `format :json`. Setting the global adapter also affects successful responses. +### JSON:API Included Resources + +A resource identifier under `relationships` is linkage, not the related resource's attributes. To include those attributes in a top-level `included` array, declare the relationship in the serializer and pass an AMS `include` option with the `:json_api` adapter: + +```ruby +class AuthorSerializer < ActiveModel::Serializer + attributes :id, :name +end + +class ArticleSerializer < ActiveModel::Serializer + attributes :id, :title + belongs_to :author, serializer: AuthorSerializer +end + +get '/articles/:id' do + article = Article.find(params[:id]) + render article, adapter: :json_api, include: 'author' +end +``` + +With the Grape AMS formatter configured and `format :json`, an article with an author produces a compound document such as: + +```json +{ + "data": { + "id": "1", + "type": "articles", + "attributes": { "title": "Hello" }, + "relationships": { + "author": { "data": { "id": "2", "type": "authors" } } + } + }, + "included": [ + { "id": "2", "type": "authors", "attributes": { "name": "Jane" } } + ] +} +``` + +Related records need identifiers and serializers defining the attributes to expose. Without `include: 'author'`, the JSON:API adapter can emit relationship linkage without adding the author's full resource to `included`. + +You can also specify `adapter: :json_api, include: 'author'` as route options, or return them from `default_serializer_options`. Nested relationships use paths such as `include: 'comments.author'`; each relationship must be declared in its serializer. + +ActiveRecord's `includes(:author)` preloads database associations but does not select AMS included resources. Likewise, an `?include=author` query parameter is not automatically forwarded to AMS; validate allowed relationships and explicitly pass the selected include option. See the [AMS include documentation](https://github.com/rails-api/active_model_serializers/blob/0-10-stable/docs/general/adapters.md#include-option). + ### Custom Metadata ```ruby diff --git a/spec/features/grape-active_model_serializers/render_spec.rb b/spec/features/grape-active_model_serializers/render_spec.rb index c2293c8..6eb6f87 100644 --- a/spec/features/grape-active_model_serializers/render_spec.rb +++ b/spec/features/grape-active_model_serializers/render_spec.rb @@ -115,3 +115,78 @@ def get_resource_with(meta) end end end + +describe 'JSON:API included resources' do + let(:app) { Class.new(Grape::API) } + let(:render_options) { { adapter: :json_api } } + let(:route_options) { {} } + let(:collection) { false } + let(:document) { JSON.parse(last_response.body) } + let(:author_resource) { + { 'id' => '2', 'type' => 'authors', 'attributes' => { 'name' => 'Jane' } } + } + + before do + stub_const('JsonApiAuthor', Class.new(ActiveModelSerializers::Model) do + attributes :id, :name + end) + stub_const('JsonApiArticle', Class.new(ActiveModelSerializers::Model) do + attributes :id, :title, :author + end) + stub_const('JsonApiAuthorSerializer', Class.new(ActiveModel::Serializer) do + type 'authors' + attributes :id, :name + end) + stub_const('JsonApiArticleSerializer', Class.new(ActiveModel::Serializer) do + type 'articles' + attributes :id, :title + belongs_to :author, serializer: JsonApiAuthorSerializer + end) + + ActiveModelSerializers.config.adapter = :json + app.format :json + app.formatter :json, Grape::Formatter::ActiveModelSerializers + author = JsonApiAuthor.new(id: 2, name: 'Jane') + article = JsonApiArticle.new(id: 1, title: 'Hello', author: author) + articles = [article, JsonApiArticle.new(id: 3, title: 'More', author: author)] + resource = collection ? articles : article + options = render_options + app.get('/articles', **route_options) { render resource, options } + + get '/articles' + end + + it 'emits relationship linkage without implicitly including the resource' do + expect(document.fetch('data').fetch('relationships')).to eq( + 'author' => { 'data' => { 'id' => '2', 'type' => 'authors' } } + ) + expect(document).not_to have_key('included') + end + + context 'render inclusion' do + let(:render_options) { super().merge(include: 'author') } + + it 'includes the related resource and its attributes' do + expect(document.fetch('included')).to eq([author_resource]) + end + + context 'a collection' do + let(:collection) { true } + + it 'includes a shared related resource only once' do + ids = document.fetch('data').map { |article| article.fetch('id') } + expect(ids).to eq(%w[1 3]) + expect(document.fetch('included')).to eq([author_resource]) + end + end + end + + context 'route options' do + let(:route_options) { { adapter: :json_api, include: 'author' } } + let(:render_options) { {} } + + it 'selects the adapter and included resource without render overrides' do + expect(document.fetch('included')).to eq([author_resource]) + end + end +end