From a95e19a00654b8e2b6c7283ef64aca143b06c8de Mon Sep 17 00:00:00 2001 From: "Daniel (dB.) Doubrovkine" Date: Mon, 26 Oct 2026 09:20:39 -0400 Subject: [PATCH] Document JSON API included resources with request coverage Show declared relationships and include options with the JSON API adapter, distinguish resource linkage from included attributes, and explain eager loading versus serialization. Verify explicit inclusion, shared-resource deduplication, and route-level configuration with actual requests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 1 + README.md | 45 +++++++++++ .../render_spec.rb | 75 +++++++++++++++++++ 3 files changed, 121 insertions(+) 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